From cd50b068f9a866b2b030283bc42d1dd64bce9600 Mon Sep 17 00:00:00 2001 From: jenslar Date: Fri, 17 Mar 2023 14:36:44 +0100 Subject: [PATCH] Initial commit --- Cargo.toml | 17 + LICENSE.txt | 8 + README.md | 16 + src/content_types/data_types.rs | 152 +++++ src/content_types/gps.rs | 256 +++++++++ src/content_types/mod.rs | 25 + src/content_types/sensor/mod.rs | 18 + src/content_types/sensor/orientation.rs | 37 ++ src/content_types/sensor/sensor_data.rs | 114 ++++ src/content_types/sensor/sensor_field.rs | 35 ++ src/content_types/sensor/sensor_quantifier.rs | 37 ++ src/content_types/sensor/sensor_type.rs | 55 ++ src/errors.rs | 144 +++++ src/files.rs | 35 ++ src/geo/geojson.rs | 1 + src/geo/kml.rs | 0 src/geo/mod.rs | 6 + src/gopro/device_id.rs | 39 ++ src/gopro/device_name.rs | 184 ++++++ src/gopro/file.rs | 344 ++++++++++++ src/gopro/meta.rs | 68 +++ src/gopro/mod.rs | 13 + src/gopro/session.rs | 346 ++++++++++++ src/gpmf/fourcc.rs | 481 ++++++++++++++++ src/gpmf/gpmf.rs | 481 ++++++++++++++++ src/gpmf/header.rs | 146 +++++ src/gpmf/mod.rs | 15 + src/gpmf/stream.rs | 525 ++++++++++++++++++ src/gpmf/timestamp.rs | 70 +++ src/gpmf/value.rs | 369 ++++++++++++ src/lib.rs | 35 ++ 31 files changed, 4072 insertions(+) create mode 100644 Cargo.toml create mode 100644 LICENSE.txt create mode 100644 README.md create mode 100644 src/content_types/data_types.rs create mode 100644 src/content_types/gps.rs create mode 100644 src/content_types/mod.rs create mode 100644 src/content_types/sensor/mod.rs create mode 100644 src/content_types/sensor/orientation.rs create mode 100644 src/content_types/sensor/sensor_data.rs create mode 100644 src/content_types/sensor/sensor_field.rs create mode 100644 src/content_types/sensor/sensor_quantifier.rs create mode 100644 src/content_types/sensor/sensor_type.rs create mode 100644 src/errors.rs create mode 100644 src/files.rs create mode 100644 src/geo/geojson.rs create mode 100644 src/geo/kml.rs create mode 100644 src/geo/mod.rs create mode 100644 src/gopro/device_id.rs create mode 100644 src/gopro/device_name.rs create mode 100644 src/gopro/file.rs create mode 100644 src/gopro/meta.rs create mode 100644 src/gopro/mod.rs create mode 100644 src/gopro/session.rs create mode 100644 src/gpmf/fourcc.rs create mode 100644 src/gpmf/gpmf.rs create mode 100644 src/gpmf/header.rs create mode 100644 src/gpmf/mod.rs create mode 100644 src/gpmf/stream.rs create mode 100644 src/gpmf/timestamp.rs create mode 100644 src/gpmf/value.rs create mode 100644 src/lib.rs diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..4fdd024 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "gpmf-rs" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +binread = "2.2" +rayon = "1.5.3" +time = {version = "0.3", features = ["formatting", "parsing"]} +walkdir = "2.3.2" +geojson = {version = "0.24", features = ["geo-types"]} +kml = "0.7.1" +mp4iter = {git = "https://github.com/jenslar/mp4iter.git"} +jpegiter = {git = "https://github.com/jenslar/jpegiter.git"} +blake3 = "1.3.3" diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..de6864f --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,8 @@ +The MIT License (MIT) +Copyright © 2023 Jens Larsson, jens.dev@fastmail.com + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..d159608 --- /dev/null +++ b/README.md @@ -0,0 +1,16 @@ +# gpmf-rs + +Rust crate for parsing GoPro GPMF data, directly from MP4, from "raw" GPMF-files extracted via ffmpeg, or byte slices. + +Example: +```rs +use gpmf_rs::Gpmf; +use std::path::Path; + +fn main() -> std::io::Result<()> { + let path = Path::new("GOPRO_VIDEO.MP4"); + let gpmf = Gpmf::new(&path)?; + println!("{gpmf:#?}"); + Ok(()) +} +``` \ No newline at end of file diff --git a/src/content_types/data_types.rs b/src/content_types/data_types.rs new file mode 100644 index 0000000..55270b4 --- /dev/null +++ b/src/content_types/data_types.rs @@ -0,0 +1,152 @@ +/// Stream type, mostly for internal use. +/// This will have to be updated if new stream types are added or STNM free text descriptions change. +#[derive(Debug, Clone)] +pub enum DataType { + /// Accelerometer + Accelerometer, // Hero 7, 9 + /// Accelerometer (up/down, right/left, forward/back) + AccelerometerUrf, // Hero 5, 6 + AgcAudioLevel, // Hero 9 + AverageLuminance, // Hero 7 + CameraOrientation, // Hero 9 + ExposureTime, // Hero 7, 9 + FaceCoordinates, // Hero 7, 9 + Gps5, // Hero 5, 6, 7, 9, 10, 11 + Gps9, // Hero 11 + GravityVector, // Hero 9 + Gyroscope, // Hero 7, 9 + GyroscopeZxy, // Hero 5, 6 + ImageUniformity, // Hero 7, 9 + ImageOrientation, // Hero 9 + LrvFrameSkip, // Hero 9 + MicrophoneWet, // Hero 9 + MrvFrameSkip, // Hero 9 + PredominantHue, // Hero 7 + SceneClassification, // Hero 7 + SensorGain, // Fusion + SensorIso, // Hero 7, 9 + SensorReadOutTime, // Hero 7 + WhiteBalanceRgbGains, // Hero 7, 9 + WhiteBalanceTemperature, // Hero 7, 9 + WindProcessing, // Hero 9 + Other(String), +} + +impl DataType { + /// Returns stream name (`STNM`) specified in gpmf documentation as a string slice. + pub fn to_str(&self) -> &str { + match self { + // Confirmed for Hero 7, 8, 9, 11 + Self::Accelerometer => "Accelerometer", + // Confirmed for Hero 5, 6 + Self::AccelerometerUrf => "Accelerometer (up/down, right/left, forward/back)", + // Confirmed for Hero 8, 9 (' ,' typo exists in GPMF) + Self::AgcAudioLevel => "AGC audio level[rms_level ,peak_level]", + // Confirmed for Hero 7 + Self::AverageLuminance => "Average luminance", + // Confirmed for Hero 9 + Self::CameraOrientation => "CameraOrientation", + // Confirmed for Hero 7, 9, Fusion + Self::ExposureTime => "Exposure time (shutter speed)", + // Confirmed for Hero 7, 9 + Self::FaceCoordinates => "Face Coordinates and details", + // Confirmed for Hero 5, 6, 7, 9, 10, Fusion + Self::Gps5 => "GPS (Lat., Long., Alt., 2D speed, 3D speed)", + // Confirmed for Hero 11 + Self::Gps9 => "GPS (Lat., Long., Alt., 2D, 3D, days, secs, DOP, fix)", + // Confirmed for Hero 9 + Self::GravityVector => "Gravity Vector", + // Confirmed for Hero 7, 9, 11. + Self::Gyroscope => "Gyroscope", + Self::GyroscopeZxy => "Gyroscope (z,x,y)", + // Confirmed for Hero 7, 9 + Self::ImageUniformity => "Image uniformity", + // Confirmed for Hero 9 + Self::ImageOrientation => "ImageOrientation", + // Confirmed for Hero 9 + Self::LrvFrameSkip => "LRV Frame Skip", + // Confirmed for Hero 9 + Self::MicrophoneWet => "Microphone Wet[mic_wet, all_mics, confidence]", + // Confirmed for Hero 9 + Self::MrvFrameSkip => "MRV Frame Skip", + // Confirmed for Hero 7 + Self::PredominantHue => "Predominant hue[[hue, weight], ...]", + // Confirmed for Hero 7 + Self::SceneClassification => "Scene classification[[CLASSIFIER_FOUR_CC,prob], ...]", + // Confirmed for Fusion + Self::SensorGain => "Sensor gain", + // Confirmed for Hero 7, 9 + Self::SensorIso => "Sensor ISO", + // Confirmed for Hero 7 + Self::SensorReadOutTime => "Sensor read out time", + // Confirmed for Hero 7, 9 + Self::WhiteBalanceRgbGains => "White Balance RGB gains", + // Confirmed for Hero 7, 9 + Self::WhiteBalanceTemperature => "White Balance temperature (Kelvin)", + // Confirmed for Hero 9 + Self::WindProcessing => "Wind Processing[wind_enable, meter_value(0 - 100)]", + Self::Other(s) => s, + } + } + + /// Returns enum corresponding to stream name (`STNM`) specified in gpmf stream. + /// If no results are returned despite the data being present, + /// try using `Self::Other(String)` instead. Gpmf data can only be identified + /// via its stream name free text description (`STNM`), which may differ between devices + /// for the same kind of data. + pub fn from_str(stream_type: &str) -> DataType { + match stream_type { + // Hero 7, 9 | Fusion + "Accelerometer" => Self::Accelerometer, + // Hero 5, 6 + "Accelerometer (up/down, right/left, forward/back)" => Self::AccelerometerUrf, + // Hero 9 (comma spacing is correct) + "AGC audio level[rms_level ,peak_level]" => Self::AgcAudioLevel, + // Hero 7 + "Average luminance" => Self::AverageLuminance, + // Hero 9 + "CameraOrientation" => Self::CameraOrientation, + // Hero 7, 9, Fusion + "Exposure time (shutter speed)" => Self::ExposureTime, + // Hero 7, 9 + "Face Coordinates and details" => Self::FaceCoordinates, + // Hero 7, 9 + "GPS (Lat., Long., Alt., 2D speed, 3D speed)" => Self::Gps5, + "GPS (Lat., Long., Alt., 2D, 3D, days, secs, DOP, fix)" => Self::Gps9, + // Hero 9 + "Gravity Vector" => Self::GravityVector, + // Hero 7, 9 | Fusion + "Gyroscope" => Self::Gyroscope, + // Hero 5, 6 + "Gyroscope (z,x,y)" => Self::GyroscopeZxy, + // Hero 7, 9 + "Image uniformity" => Self::ImageUniformity, + // Hero 9 + "ImageOrientation" => Self::ImageOrientation, + // Hero 9 + "LRV Frame Skip" => Self::LrvFrameSkip, + // Hero 9 + "Microphone Wet[mic_wet, all_mics, confidence]" => Self::MicrophoneWet, + // Hero 9 + "MRV Frame Skip" => Self::MrvFrameSkip, + // Hero 7 + "Predominant hue[[hue, weight], ...]" => Self::PredominantHue, + // Hero 7 + "Scene classification[[CLASSIFIER_FOUR_CC,prob], ...]" => Self::SceneClassification, + // Fusion + "Sensor gain (ISO x100)" => Self::SensorGain, + // Hero 7, 9 + "Sensor ISO" => Self::SensorIso, + // Hero 7 + "Sensor read out time" => Self::SensorReadOutTime, + // Hero 7, 9 + "White Balance RGB gains" => Self::WhiteBalanceRgbGains, + // Hero 7, 9 + "White Balance temperature (Kelvin)" => Self::WhiteBalanceTemperature, + // Hero 9 + "Wind Processing[wind_enable, meter_value(0 - 100)]" => Self::WindProcessing, + // Other + s => Self::Other(s.to_owned()), + } + } +} diff --git a/src/content_types/gps.rs b/src/content_types/gps.rs new file mode 100644 index 0000000..4dd6d35 --- /dev/null +++ b/src/content_types/gps.rs @@ -0,0 +1,256 @@ +use time::PrimitiveDateTime; + +use crate::{ + FourCC, + GpmfError, + Stream, + Timestamp +}; + +use super::primitivedatetime_to_string; + +#[derive(Debug, Default, Clone)] +pub struct Gps(pub Vec); + +impl Gps { + pub fn len(&self) -> usize { + self.0.len() + } + + pub fn iter(&self) -> impl Iterator { + self.0.iter() + } + + pub fn iter_mut(&mut self) -> impl Iterator { + self.0.iter_mut() + } + + pub fn into_iter(self) -> impl Iterator { + self.0.into_iter() + } + + pub fn first(&self) -> Option<&GoProPoint> { + self.0.first() + } + + pub fn last(&self) -> Option<&GoProPoint> { + self.0.last() + } + + /// Returns the start of the GPMF stream as a date time object. + /// If no coordinates were logged `None` is returned. + pub fn t0(&self) -> Option { + let first_point = self.first()?.to_owned(); + + Some( + // subtract timestamp relative to video timeline from datetime + first_point.datetime + - time::Duration::milliseconds(first_point.time?.relative as i64) + ) + } + + /// Returns the start of the GPMF stream as an ISO8601 formatted string. + /// If no coordinates were logged `None` is returned. + pub fn t0_as_string(&self) -> Option { + self.t0() + .and_then(|t| primitivedatetime_to_string(&t).ok()) + } + + pub fn t_last_as_string(&self) -> Option { + self.last() + .and_then(|p| primitivedatetime_to_string(&p.datetime).ok()) + } + + /// Filter points on GPS fix, i.e. the number of satellites + /// the GPS is locked on to. If satellite lock is not acquired, + /// the device will log latest known location with a + /// GPS fix of `0`, meaning both time and location will be + /// wrong. + /// + /// Defaults to 2 if `gps_fix` is `None`. + /// Uses the `GPSF` value. + pub fn filter(&self, gps_fix: Option) -> Self { + // GoPro has four levels: 0, 1, 2, 3 + let threshold = gps_fix.unwrap_or(2); + let filtered = self.0.iter() + .filter(|p| + match p.fix { + Some(f) => f >= threshold, + None => false + }) + .cloned() + .collect::>(); + Self(filtered) + } + + // pub fn filter(&self, start_ms: u64, end_ms: u64) -> Option { + // let mut points: Vec = Vec::new(); + + // for point in points.into_iter() { + // let t = point.time.as_ref()?; + // let start = t.to_relative().num_milliseconds(); + // let end = start + t.to_duration().num_milliseconds(); + + // if start_ms >= start as u64 && end_ms <= end as u64 { + // // points.push(point.to_owned()); + // } + // } + + // Some(Gps(points)) + // } +} + +/// Point derived from GPS data stream. +#[derive(Debug, Clone)] +pub struct GoProPoint { + /// Latitude. + pub latitude: f64, + /// Longitude. + pub longitude: f64, + /// Altitude. + pub altitude: f64, + /// 2D speed. + pub speed2d: f64, + /// 3D speed. + pub speed3d: f64, + // /// Heading 0-360 degrees + // pub heading: f64, + /// Datetime derived from `GPSU` message. + pub datetime: PrimitiveDateTime, + /// GPSF + pub fix: Option, + /// GPSP + pub precision: Option, + /// Timestamp + pub time: Option, +} + +impl std::fmt::Display for GoProPoint { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(f, "\ + latitude: {} + longitude: {} + altitude: {} + speed2d: {} + speed3d: {} + datetime: {:?} + fix: {:?} + precision: {:?} + time: {:?}", + self.latitude, + self.longitude, + self.altitude, + self.speed2d, + self.speed3d, + // self.heading, + self.datetime, + self.fix, + self.precision, + self.time, + ) + } +} + +/// Point derived from GPS STRM with STNM "GPS (Lat., Long., Alt., 2D speed, 3D speed)" +impl GoProPoint { + /// Parse stream of type `STRM` with `STNM` "GPS (Lat., Long., Alt., 2D speed, 3D speed)", + /// containing coordinate cluster into a single `Point` struct. + /// Returns a linear average of values within a single GPS stream, lumped together + /// once/second (only a single timestamp is logged for each cluster). + /// GoPro GPS logs at 10 or 18Hz (depending on model) so on average 10 or 18 points are logged each second. + /// For those who record while moving at very high velocities, a latitude dependent average could + /// be implemented in a future release. + pub fn new(devc_stream: &Stream) -> Option { + // REQUIRED, each Vec, logged coordinates as cluster: [lat, lon, alt, 2d speed, 3d speed] + // On average 18 coordinates per GPS5 message. + let gps5 = devc_stream + .find(&FourCC::GPS5) + .and_then(|s| s.to_vec_f64())?; + + let mut lat_sum: f64 = 0.0; + let mut lon_sum: f64 = 0.0; + let mut alt_sum: f64 = 0.0; + let mut sp2d_sum: f64 = 0.0; + let mut sp3d_sum: f64 = 0.0; + + // let mut gps5_count: usize = 0; + + let len = gps5.len(); + + gps5.iter().for_each(|v| { + // gps5.iter().enumerate().for_each(|(i, v)| { + // gps5_count = i + 1; // should be equal to len of corresponding vec + lat_sum += v[0]; + lon_sum += v[1]; + alt_sum += v[2]; + sp2d_sum += v[3]; + sp3d_sum += v[4]; + }); + + // REQUIRED + let scale = devc_stream + .find(&FourCC::SCAL) + .and_then(|s| s.to_f64())?; + + // all set to 1.0 to avoid div by 0 + let mut lat_scl: f64 = 1.0; + let mut lon_scl: f64 = 1.0; + let mut alt_scl: f64 = 1.0; + let mut sp2d_scl: f64 = 1.0; + let mut sp3d_scl: f64 = 1.0; + + // REQUIRED, 5 single-value BaseTypes, each a scale divisor for the + // corresponding raw value in GPS5. Order is the same as for GPS5: + // the first scale value should be applied to first value in a single GPS5 + // BaseType vec (latitude), the second to the second GPS5 value (longitude) and so on. + scale.iter().enumerate().for_each(|(i, &s)| { + match i { + 0 => lat_scl = s, + 1 => lon_scl = s, + 2 => alt_scl = s, + 3 => sp2d_scl = s, + 4 => sp3d_scl = s, + _ => (), // i > 4 should not exist, check? break? + } + }); + + // OPTIONAL (is it...?), timestamp for coordinate cluster + let gpsu: PrimitiveDateTime = devc_stream + .find(&FourCC::GPSU) + .and_then(|s| s.first_value()) + .and_then(|v| v.into())?; + // or return generic date than error if it's only timestamp that can not be parsed then use: + // .unwrap_or(NaiveDate::from_ymd(2000, 1, 1) + // .and_hms_milli(0, 0, 0, 0)), + + // OPTIONAL, GPS fix + let gpsf: Option = devc_stream + // let gpsf: Option = stream + .find(&FourCC::GPSF) + .and_then(|s| s.first_value()) + .and_then(|v| v.into()); // GPS Fix Hero 7, 9 confirmed + + // OPTIONAL, GPS precision + let gpsp: Option = devc_stream + // let gpsp: Option = stream + .find(&FourCC::GPSP) + .and_then(|s| s.first_value()) + .and_then(|v| v.into()); // GPS Precision Hero 7, 9 confirmed + + Some(Self { + latitude: lat_sum / len as f64 / lat_scl, + longitude: lon_sum / len as f64 / lon_scl, + altitude: alt_sum / len as f64 / alt_scl, + speed2d: sp2d_sum / len as f64 / sp2d_scl, + speed3d: sp3d_sum / len as f64 / sp3d_scl, + datetime: gpsu, + time: devc_stream.time.to_owned(), + fix: gpsf, + precision: gpsp, + }) + } + + pub fn datetime_to_string(&self) -> Result { + primitivedatetime_to_string(&self.datetime) + } +} diff --git a/src/content_types/mod.rs b/src/content_types/mod.rs new file mode 100644 index 0000000..db1edfa --- /dev/null +++ b/src/content_types/mod.rs @@ -0,0 +1,25 @@ +//! Processing and conversion of various kinds of sensor data. +//! Currently only GPS is supported. The rest will be added gradually. + +use time::{PrimitiveDateTime, format_description}; + +use crate::GpmfError; + +pub mod data_types; +pub mod gps; +pub mod sensor; + +pub use data_types::DataType; +// pub use sensor::{Acceleration, Accelerometer}; +// pub use sensor::{Orientation, Rotation, Gyroscope}; +pub use sensor::{SensorData, SensorType}; +pub use gps::{GoProPoint, Gps}; + +/// String representation for datetime objects. +pub(crate) fn primitivedatetime_to_string(datetime: &PrimitiveDateTime) -> Result { + // PrimitiveDateTime::to_string(&self.datetime) // sufficient? + let format = format_description::parse("[year]-[month]-[day]T[hour]:[minute]:[second]") + .map_err(|e| GpmfError::TimeError(e.into()))?; + datetime.format(&format) + .map_err(|e| GpmfError::TimeError(e.into())) +} \ No newline at end of file diff --git a/src/content_types/sensor/mod.rs b/src/content_types/sensor/mod.rs new file mode 100644 index 0000000..c962647 --- /dev/null +++ b/src/content_types/sensor/mod.rs @@ -0,0 +1,18 @@ +//! Covers GoPro 3D sensors. Supported sensors are:. +//! - Accelerometer +//! - Gyroscope +//! - Gravity + +mod sensor_data; +mod sensor_type; +mod sensor_field; +mod sensor_quantifier; +mod orientation; + +// pub use accl::{Acceleration, Accelerometer}; +// pub use gyro::{Rotation, Gyroscope}; +pub use sensor_data::SensorData; +pub use sensor_type::SensorType; +pub use sensor_field::SensorField; +pub use sensor_quantifier::SensorQuantifier; +pub use orientation::Orientation; \ No newline at end of file diff --git a/src/content_types/sensor/orientation.rs b/src/content_types/sensor/orientation.rs new file mode 100644 index 0000000..3602e11 --- /dev/null +++ b/src/content_types/sensor/orientation.rs @@ -0,0 +1,37 @@ +//! In-device sensor orientation. + +/// Physical orientation of the sensor module +/// inside the camera, +/// i.e. the the way the data is +/// stored according to the right-hand +/// rule. +#[derive(Debug)] +pub enum Orientation { + XYZ, + XZY, + YZX, + YXZ, + ZXY, + ZYX, + Invalid +} + +impl Default for Orientation { + fn default() -> Self { + Self::Invalid + } +} + +impl From<&str> for Orientation { + fn from(value: &str) -> Self { + match value.to_lowercase().as_str() { + "xyz" => Self::XYZ, + "xzy" => Self::XZY, + "yzx" => Self::YZX, + "yxz" => Self::YXZ, + "zxy" => Self::ZXY, + "zyx" => Self::ZYX, + _ => Self::Invalid + } + } +} \ No newline at end of file diff --git a/src/content_types/sensor/sensor_data.rs b/src/content_types/sensor/sensor_data.rs new file mode 100644 index 0000000..fe02868 --- /dev/null +++ b/src/content_types/sensor/sensor_data.rs @@ -0,0 +1,114 @@ +use time::Duration; + +use crate::{DeviceName, DataType, FourCC, Stream, Gpmf, SensorType}; + +use super::{SensorField, Orientation, SensorQuantifier}; + +/// Sensor data from a single `DEVC` stream: +/// - Accelerometer, fields are acceleration (m/s2) +/// - Gyroscope, fields are rotation (rad/s) +/// - Gravity vector, fields are direction of gravity in relation to the camera +#[derive(Debug, Default)] +pub struct SensorData { + /// Camera device name + pub device: DeviceName, + /// Accelerometer, gyroscope, gravimeter + pub sensor: SensorType, + /// Units + pub units: Option, + /// Physical quantity + pub quantifier: SensorQuantifier, + /// Sensor orientation + pub orientation: Orientation, + pub fields: Vec, + /// Timestamp relative to video start. + pub timestamp: Option, + /// Duration in video. + pub duration: Option, +} + +impl SensorData { + pub fn new(devc_stream: &Stream, sensor: &SensorType, device: &DeviceName) -> Option { + // Scale, should only be a single value for Gyro + let scale = *devc_stream + .find(&FourCC::SCAL) + .and_then(|s| s.to_f64())? + .first()?; + + // See https://github.com/gopro/gpmf-parser/issues/165#issuecomment-1207241564 + let orientation_str: Option = devc_stream + .find(&FourCC::ORIN) + .and_then(|s| s.first_value()) + .and_then(|s| s.into()); + + let units: Option = devc_stream + .find(&FourCC::SIUN) + .and_then(|s| s.first_value()) + .and_then(|s| s.into()); + + let orientation = match orientation_str { + Some(orin) => Orientation::from(orin.as_str()), + None => Orientation::ZXY + }; + + // Set FourCC for raw data arrays + let sensor_fourcc = match &sensor { + SensorType::Accelerometer => FourCC::ACCL, + SensorType::Gyroscope => FourCC::GYRO, + SensorType::GravityVector => FourCC::GRAV, + SensorType::Unknown => return None + }; + + let sensor_quantifier = SensorQuantifier::from(sensor); + + // Vec containing rotation x, y, z values, + // but order needs to be checked + let sensor_fields = devc_stream.find(&sensor_fourcc) + .and_then(|val| val.to_vec_f64())? + .iter() + // .filter_map(|xyz| SensorField::new(&xyz, scale, &orientation, &sensor_field_type)) + .filter_map(|xyz| SensorField::new(&xyz, scale, &orientation)) + .collect::>(); + + Some(Self{ + device: device.to_owned(), + sensor: sensor.to_owned(), + units, + quantifier:sensor_quantifier, + orientation, + fields: sensor_fields, + timestamp: devc_stream.time_relative(), + duration: devc_stream.time_duration() + }) + } + + pub fn from_gpmf(gpmf: &Gpmf, sensor: &SensorType) -> Vec { + let device_name: Vec = gpmf.device_name() + .iter() + // .map(|n| DeviceName::from_str(n)) + .filter_map(|n| DeviceName::from_str(n)) + .collect(); + // Get camera device name (listed first if GPMF from Karma drone) + // to get data type (free text data identifier is model dependent) + if let Some(device) = device_name.first() { + let data_type = sensor.as_datatype(device); + + let sensor_data = gpmf.filter(&data_type); + + return sensor_data.iter() + .filter_map(|stream| Self::new(stream, sensor, device)) + .collect::>() + } + + // Failure to determine device name returns empty vec + Vec::new() + } + + pub fn as_datatype(&self) -> DataType { + self.sensor.as_datatype(&self.device) + } + + pub fn len(&self) -> usize { + self.fields.len() + } +} diff --git a/src/content_types/sensor/sensor_field.rs b/src/content_types/sensor/sensor_field.rs new file mode 100644 index 0000000..ada71be --- /dev/null +++ b/src/content_types/sensor/sensor_field.rs @@ -0,0 +1,35 @@ +use super::Orientation; + +/// Generic sensor data struct for +/// - Accelerometer (acceleration, m/s2) +/// - Gyroscrope (rotation, rad/s) +/// - Gravity vector (direction of gravity) +#[derive(Debug, Default)] +pub struct SensorField { + pub x: f64, + pub y: f64, + pub z: f64, +} + +impl SensorField { + pub fn new( + xyz: &[f64], + scale: f64, + orientation: &Orientation, + ) -> Option { + let (x, y, z) = match orientation { + Orientation::XYZ => (*xyz.get(0)?, *xyz.get(1)?, *xyz.get(2)?), + Orientation::XZY => (*xyz.get(0)?, *xyz.get(2)?, *xyz.get(1)?), + Orientation::YZX => (*xyz.get(2)?, *xyz.get(0)?, *xyz.get(1)?), + Orientation::YXZ => (*xyz.get(1)?, *xyz.get(0)?, *xyz.get(2)?), + Orientation::ZXY => (*xyz.get(1)?, *xyz.get(2)?, *xyz.get(0)?), + Orientation::ZYX => (*xyz.get(2)?, *xyz.get(1)?, *xyz.get(0)?), + Orientation::Invalid => return None + }; + Some(Self{ + x: x/scale, + y: y/scale, + z: z/scale + }) + } +} \ No newline at end of file diff --git a/src/content_types/sensor/sensor_quantifier.rs b/src/content_types/sensor/sensor_quantifier.rs new file mode 100644 index 0000000..fe9438f --- /dev/null +++ b/src/content_types/sensor/sensor_quantifier.rs @@ -0,0 +1,37 @@ +use crate::SensorType; + +#[derive(Debug, Clone, Copy)] +pub enum SensorQuantifier { + Acceleration, + Rotation, + GravityDirection, + Unknown +} + +impl Default for SensorQuantifier { + fn default() -> Self { + Self::Unknown + } +} + +impl std::fmt::Display for SensorQuantifier { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match &self { + Self::Acceleration => write!(f, "Acceleration"), + Self::Rotation => write!(f, "Rotation"), + Self::GravityDirection => write!(f, "GravityDirection"), + Self::Unknown => write!(f, "Unknown"), + } + } +} + +impl From<&SensorType> for SensorQuantifier { + fn from(value: &SensorType) -> Self { + match &value { + SensorType::Accelerometer => Self::Acceleration, + SensorType::GravityVector => Self::GravityDirection, + SensorType::Gyroscope => Self::Rotation, + SensorType::Unknown => Self::Unknown, + } + } +} \ No newline at end of file diff --git a/src/content_types/sensor/sensor_type.rs b/src/content_types/sensor/sensor_type.rs new file mode 100644 index 0000000..ed1fa4b --- /dev/null +++ b/src/content_types/sensor/sensor_type.rs @@ -0,0 +1,55 @@ +use std::fmt::Display; + +use crate::{DataType, DeviceName}; + +#[derive(Debug, Clone, Copy)] +pub enum SensorType { + Accelerometer, + GravityVector, + Gyroscope, + Unknown +} + +impl Default for SensorType { + fn default() -> Self { + Self::Unknown + } +} + +impl Display for SensorType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SensorType::Accelerometer => write!(f, "Accelerometer"), + SensorType::GravityVector => write!(f, "GravityVector"), + SensorType::Gyroscope => write!(f, "Gyroscope"), + SensorType::Unknown => write!(f, "Unknown"), + } + } +} + +impl SensorType { + /// Convert `SensorType` to `DataType` + pub fn as_datatype(&self, device: &DeviceName) -> DataType { + match &self { + Self::Accelerometer => match device { + DeviceName::Hero5Black | DeviceName::Hero6Black => DataType::AccelerometerUrf, + _ => DataType::Accelerometer + } + Self::GravityVector => DataType::GravityVector, + Self::Gyroscope => match device { + DeviceName::Hero5Black | DeviceName::Hero6Black => DataType::GyroscopeZxy, + _ => DataType::Gyroscope + }, + Self::Unknown => DataType::Other("Unkown".to_owned()) + } + } + + pub fn from_datatype(data_type: &DataType) -> Self { + match &data_type { + DataType::Accelerometer | DataType::AccelerometerUrf => Self::Accelerometer, + DataType::GravityVector => Self::GravityVector, + DataType::Gyroscope | DataType::GyroscopeZxy => Self::Gyroscope, + _ => Self::Unknown + } + } +} \ No newline at end of file diff --git a/src/errors.rs b/src/errors.rs new file mode 100644 index 0000000..1fca15c --- /dev/null +++ b/src/errors.rs @@ -0,0 +1,144 @@ +//! Various GPMF-related errors. + +use std::{fmt, path::PathBuf}; + +/// Various GPMF related read/parse errors. +#[derive(Debug)] +pub enum GpmfError { + /// Error parsing MP4. + Mp4Error(mp4iter::errors::Mp4Error), + /// Error parsing JPEG. + JpegError(jpegiter::JpegError), + /// Failed to locate GoPro offsets in MP4. + NoMp4Offsets, + /// Converted `BinResult` error. + BinReadError(binread::Error), + /// Converted `time::Error` error. + TimeError(time::Error), + /// Converted `Utf8Error`. + Utf8Error(std::string::FromUtf8Error), + /// Parse integer from string error. + ParseIntError(std::num::ParseIntError), + /// Generic GPMF parse error + ParseError, + /// IO error + DowncastIntError(std::num::TryFromIntError), + /// Failed to cast source type into target type. + IOError(std::io::Error), + /// Filesizes of e.g. 0 sized place holders. + ReadMismatch{got: u64, expected: u64}, + /// Seek mismatch. + OffsetMismatch{got: u64, expected: u64}, + /// MP4 0 sized atoms, + /// e.g. 1k Dropbox place holders. + UnexpectedAtomSize{len: u64, offset: u64}, + /// No such atom. + NoSuchAtom(String), + /// MP4 ouf of bounds. + BoundsError((u64, u64)), + /// Filesizes of e.g. 0 sized place holders. + MaxFileSizeExceeded{max: u64, got: u64, path: PathBuf}, + /// Unknown base type when parsing `Values`. + UnknownBaseType(u8), + /// Missing type definition for Complex type (`63`/`?`) + MissingComplexType, + /// Exceeded recurse depth when parsing GPMF into `Stream`s + RecurseDepthExceeded((usize, usize)), + /// Invalid FourCC. For detecting `&[0, 0, 0, 0]`. + /// E.g. GoPro `udta` atom contains + /// mainly undocumented GPMF data and is padded with + /// zeros. + InvalidFourCC, + /// For handling GPMF sources, when e.g. an MP4-file + /// was expected but another file type was passed. + InvalidFileType(PathBuf), + /// Missing path (e.g. no path set for `GoProFile`) + PathNotSet, + /// Model or camera not known, + /// mostly for generic MP4 files with no identifiers. + UknownDevice, + /// No data for requested type (e.g. no GPS logged) + NoData, +} + +impl std::error::Error for GpmfError {} // not required? + +impl fmt::Display for GpmfError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + GpmfError::Mp4Error(err) => write!(f, "{err}"), + GpmfError::JpegError(err) => write!(f, "{err}"), + GpmfError::NoMp4Offsets => write!(f, "Failed to locate GoPro GPMF offsets in MP4."), + GpmfError::BinReadError(err) => write!(f, "{err}"), + GpmfError::TimeError(err) => write!(f, "{err}"), + GpmfError::Utf8Error(err) => write!(f, "{err}"), + GpmfError::ParseIntError(err) => write!(f, "Unable to parse string into integer: {}", err), + GpmfError::ParseError => write!(f, "Failed to parse GPMF data."), + GpmfError::DowncastIntError(err) => write!(f, "Failed to downcast integer: {err}"), + GpmfError::IOError(err) => write!(f, "IO error: {}", err), + GpmfError::ReadMismatch{got, expected} => write!(f, "Read {got} bytes, expected {expected} bytes."), + GpmfError::OffsetMismatch{got, expected} => write!(f, "Moved {got} bytes, expected to move {expected} bytes"), + GpmfError::UnexpectedAtomSize{len, offset} => write!(f, "Unexpected MP4 atom size of {len} bytes @ offset {offset}."), + GpmfError::NoSuchAtom(name) => write!(f, "No such atom {name}."), + GpmfError::BoundsError((got, max)) => write!(f, "Bounds error: tried to read file at {got} with max {max}."), + GpmfError::MaxFileSizeExceeded {max, got, path} => write!(f, "{} ({got} bytes) exceeds maximum file size of {max}.", path.display()), + GpmfError::UnknownBaseType(bt) => write!(f, "Unknown base type {}/'{}'", bt, *bt as char), + GpmfError::MissingComplexType => write!(f, "Missing type definitions for complex type '?'"), + GpmfError::RecurseDepthExceeded((depth, max)) => write!(f, "Recurse depth {depth} exceeds max recurse depth {max}"), + GpmfError::InvalidFourCC => write!(f, "Invalid FourCC"), + GpmfError::InvalidFileType(path) => write!(f, "Invalid file type: '{}'", path.display()), + GpmfError::PathNotSet => write!(f, "Path not set"), + GpmfError::UknownDevice => write!(f, "Unknown device"), + GpmfError::NoData => write!(f, "No data for requested type"), + } + } +} + +/// Converts std::io::Error to GpmfError +impl From for GpmfError { + fn from(err: std::io::Error) -> Self { + GpmfError::IOError(err) + } +} + +/// Converts std::string::FromUtf8Error to GpmfError +/// (`&str` reqiures `std::str::Utf8Error`) +impl From for GpmfError { + fn from(err: std::string::FromUtf8Error) -> GpmfError { + GpmfError::Utf8Error(err) + } +} + +/// Converts std::num::ParseIntError to GpmfError +impl From for GpmfError { + fn from(err: std::num::ParseIntError) -> GpmfError { + GpmfError::ParseIntError(err) + } +} + +/// Converts mp4iter::errors::Mp4Error to GpmfError +impl From for GpmfError { + fn from(err: mp4iter::errors::Mp4Error) -> GpmfError { + GpmfError::Mp4Error(err) + } +} +/// Converts binread::Error to FitError +impl From for GpmfError { + fn from(err: binread::Error) -> GpmfError { + GpmfError::BinReadError(err) + } +} + +/// Converts time::Error to GpmfError +impl From for GpmfError { + fn from(err: time::Error) -> GpmfError { + GpmfError::TimeError(err) + } +} + +/// Converts GpmfError to std::io::Error +impl From for std::io::Error { + fn from(err: GpmfError) -> Self { + std::io::Error::new(std::io::ErrorKind::Other, err) + } +} diff --git a/src/files.rs b/src/files.rs new file mode 100644 index 0000000..cabc3a6 --- /dev/null +++ b/src/files.rs @@ -0,0 +1,35 @@ +use std::{path::Path, ffi::OsStr}; + +/// Matches file extension of `path` +pub(crate) fn has_extension_old(path: &Path, ext: &str) -> bool { + // ensure file extension does not start with '.' + let ext = ext.trim_start_matches("."); + if let Some(path_ext) = path.extension() { + return path_ext.to_ascii_lowercase() == OsStr::new(&ext).to_ascii_lowercase() + } + false +} +/// Matches `path` with extensions in `exts` and returns +/// the first match as a `String`. +pub(crate) fn has_extension(path: &Path, exts: &[&str]) -> Option { + for ext in exts { + // ensure file extension does not start with '.' + let ext = ext.trim_start_matches("."); + if let Some(path_ext) = path.extension() { + if path_ext.to_ascii_lowercase() == OsStr::new(&ext).to_ascii_lowercase() { + return Some(ext.to_owned()) + } + } + } + None +} + +/// Returns file extension as lower case string. +pub(crate) fn fileext_to_lcstring(path: &Path) -> Option { + Some(path.extension()?.to_str()?.to_ascii_lowercase()) +} + +/// Returns filestem as a `String`. +pub(crate) fn filestem_to_string(path: &Path) -> Option { + Some(path.file_stem()?.to_str()?.to_string()) +} diff --git a/src/geo/geojson.rs b/src/geo/geojson.rs new file mode 100644 index 0000000..3b9d158 --- /dev/null +++ b/src/geo/geojson.rs @@ -0,0 +1 @@ +use crate::GoProPoint; \ No newline at end of file diff --git a/src/geo/kml.rs b/src/geo/kml.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/geo/mod.rs b/src/geo/mod.rs new file mode 100644 index 0000000..c874c35 --- /dev/null +++ b/src/geo/mod.rs @@ -0,0 +1,6 @@ +//! UNIMPLEMENTED +//! +//! Exports to KML/GeoJSON. + +pub mod kml; +pub mod geojson; \ No newline at end of file diff --git a/src/gopro/device_id.rs b/src/gopro/device_id.rs new file mode 100644 index 0000000..2e862f2 --- /dev/null +++ b/src/gopro/device_id.rs @@ -0,0 +1,39 @@ +//! GoPro device ID (`DVID`). + +use crate::FourCC; + +/// GoPro device ID. +/// For older devices (Hero5, Fusion?) it seems +/// DVID can be either a `u32` or a `FourCC` (?). +#[derive(Debug, Clone)] +pub enum Dvid { + Uint32(u32), + FourCC(FourCC), +} + +impl Into> for &Dvid { + fn into(self) -> Option { + match self { + Dvid::Uint32(n) => Some(*n), + Dvid::FourCC(_) => None, + } + } +} + +impl Into> for &Dvid { + fn into(self) -> Option { + match self { + Dvid::Uint32(_) => None, + Dvid::FourCC(f) => Some(f.to_owned()), + } + } +} + +impl Into> for &Dvid { + fn into(self) -> Option { + match self { + Dvid::Uint32(_) => None, + Dvid::FourCC(f) => Some(f.to_str().to_owned()), + } + } +} diff --git a/src/gopro/device_name.rs b/src/gopro/device_name.rs new file mode 100644 index 0000000..282c35e --- /dev/null +++ b/src/gopro/device_name.rs @@ -0,0 +1,184 @@ +//! GoPro device name (`DVNM`). + +use std::path::Path; + +use crate::GpmfError; + +/// GoPro camera model. Set in GPMF struct for convenience. +/// Does not yet include all previous models, hence `Other` +// #[derive(Debug, Clone, Eq, Hash)] +// #[derive(Debug, Clone, PartialEq, Ord)] +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DeviceName { + Hero5Black, // DVNM not confirmed + Hero6Black, // DVNM not confirmed + Hero7Black, // DVNM "Hero7 Black" or "HERO7 Black" (MP4 GoPro MET udta>minf atom) + Hero8Black, // probably "Hero7 Black", but not confirmed + Hero9Black, // DVNM "Hero9 Black" or "HERO9 Black" (MP4 GoPro MET udta>minf atom) + Hero10Black, // DVNM "Hero10 Black" or "HERO10 Black" (MP4 GoPro MET udta>minf atom) + Hero11Black, // DVNM "Hero11 Black" or "HERO11 Black" (MP4 GoPro MET udta>minf atom) + Fusion, + GoProMax, + GoProKarma, // DVNM "GoPro Karma v1.0" + whichever device is connected e.g. hero 5. + // other identifiers? Silver ranges etc? + // Other(String), // for models not yet included as enum +} + +impl Default for DeviceName { + fn default() -> Self { + // Self::Other("Unknown".to_owned()) + // Use first GPMF hero as default + Self::Hero5Black + } +} + +impl DeviceName { + /// Try to determine model from start of `mdat`, which contains + /// data/fields similar to those in the `udta` atom. + /// + /// `GPRO` should immediately follow the `mdat` header, + /// then 4 bytes representing size of the section (`u32` Little Endian). + /// Currently using the start of the firmware string as id (e.g. HD8 = Hero8 Black), + /// but the full device name string exists as a string a bit later after other fields. + pub fn from_path(path: &Path) -> Result { + let mut mp4 = mp4iter::Mp4::new(path)?; + Self::from_file(&mut mp4) + // if let Ok(Some(hdr)) = mp4.find("mdat") { + // let cursor = mp4.read_at(hdr.data_offset(), 4)?; + // let fourcc = mp4iter::FourCC::from_slice(&cursor.into_inner()); + // // For all GoPro cameras "GOPR" fourcc follows + // // immediately after header for mdat so far + // if fourcc == mp4iter::FourCC::Custom("GPRO".to_owned()) { + // mp4.seek(4)?; // seek past size for now to use first three chars from firmware (udta FIRM, udta gpmf FMWR) + // let id: String = mp4.read(3)?.into_inner().iter().map(|n| *n as char).collect(); + // // return Ok(Self::from_firmware_id(&id)) + // match Self::from_firmware_id(&id) { + // Some(dvnm) => return Ok(dvnm), + // None => return Err(GpmfError::UknownDevice) + // } + // // Size specified in little endian + // // let size = mp4.read_type_at::(4, hdr.data_offset(), binread::Endian::Little)?; + // // let size = mp4.read_type::(4, binread::Endian::Little)?; // seems correct + // // println!("SIZE: {size}"); + // // let _gopr = mp4.read(size as u64)?; // cursor that shouldn't be more than 1500 bytes containing device name as ascii string + // // println!("GOPR: {_gopr:?}"); + // } + // } + + // Err(GpmfError::UknownDevice) + + // Ok(Self::Other(String::from("Unknown"))) + } + + pub(crate) fn from_file(mp4: &mut mp4iter::Mp4) -> Result { + mp4.reset()?; + if let Ok(Some(hdr)) = mp4.find("mdat") { + let cursor = mp4.read_at(hdr.data_offset(), 4)?; + let fourcc = mp4iter::FourCC::from_slice(&cursor.into_inner()); + // For all GoPro cameras "GOPR" fourcc follows + // immediately after header for mdat so far + if fourcc == mp4iter::FourCC::Custom("GPRO".to_owned()) { + mp4.seek(4)?; // seek past size for now to use first three chars from firmware (udta FIRM, udta gpmf FMWR) + let id: String = mp4.read(3)?.into_inner().iter().map(|n| *n as char).collect(); + // return Ok(Self::from_firmware_id(&id)) + match Self::from_firmware_id(&id) { + Some(dvnm) => return Ok(dvnm), + None => return Err(GpmfError::UknownDevice) + } + // Size specified in little endian + // let size = mp4.read_type_at::(4, hdr.data_offset(), binread::Endian::Little)?; + // let size = mp4.read_type::(4, binread::Endian::Little)?; // seems correct + // println!("SIZE: {size}"); + // let _gopr = mp4.read(size as u64)?; // cursor that shouldn't be more than 1500 bytes containing device name as ascii string + // println!("GOPR: {_gopr:?}"); + } + } + + Err(GpmfError::UknownDevice) + } + + pub fn from_firmware_id(id: &str) -> Option { + match &id[..3] { + "HD5" => Some(Self::Hero5Black), + "HD6" => Some(Self::Hero6Black), + "FS1" => Some(Self::Fusion), + "HD7" => Some(Self::Hero7Black), + "HD8" => Some(Self::Hero8Black), + "HD9" => Some(Self::Hero9Black), // possibly H20 + "H19" => Some(Self::GoProMax), + "H20" => Some(Self::Hero9Black), // possibly HD9, and H20 is another device + "H21" => Some(Self::Hero10Black), + "H22" => Some(Self::Hero11Black), + _ => None + // _ => Self::Other("Unknown".to_owned()) + } + } + + pub fn from_str(model: &str) -> Option { + match model.trim() { + // Hero5 Black identifies itself as "Camera" + // inside GPMF so far. + "Camera" | "Hero5 Black" | "HERO5 Black" => Some(Self::Hero5Black), + "Hero6 Black" | "HERO6 Black" => Some(Self::Hero6Black), + "Hero7 Black" | "HERO7 Black" => Some(Self::Hero7Black), + "Hero8 Black" | "HERO8 Black" => Some(Self::Hero8Black), + "Hero9 Black" | "HERO9 Black" => Some(Self::Hero9Black), + "Hero10 Black" | "HERO10 Black" => Some(Self::Hero10Black), + "Hero11 Black" | "HERO11 Black" => Some(Self::Hero11Black), + "Fusion" | "FUSION" => Some(Self::Fusion), + "GoPro Max" => Some(Self::GoProMax), + "GoPro Karma v1.0" => Some(Self::GoProKarma), + _ => None + // s => Self::Other(s.to_owned()), + } + } + + pub fn to_str(&self) -> &str { + match self { + Self::Hero5Black => "Hero5 Black", // correct device name? + Self::Hero6Black => "Hero6 Black", // correct device name? + Self::Hero7Black => "Hero7 Black", + Self::Hero8Black => "Hero8 Black", + Self::Hero9Black => "Hero9 Black", + Self::Hero10Black => "Hero10 Black", + Self::Hero11Black => "Hero11 Black", + Self::Fusion => "Fusion", + Self::GoProMax => "GoPro Max", + Self::GoProKarma => "GoPro Karma v1.0", // only v1.0 so far + // Self::Other(s) => s, + } + } + + // fn from_arr(arr: [u8; 4]) -> Self { + // match arr { + // b"Camera" => DeviceName::Hero5Black, // correct device name? + // b"Hero6 Black" | "HERO6 Black" => DeviceName::Hero6Black, // correct device name? + // b"Hero7 Black" | "HERO7 Black" => DeviceName::Hero7Black, + // b"Hero8 Black" | "HERO8 Black" => DeviceName::Hero8Black, + // b"Hero9 Black" | "HERO9 Black" => DeviceName::Hero9Black, + // b"Hero10 Black" | "HERO10 Black" => DeviceName::Hero10Black, + // b"Hero11 Black" | "HERO11 Black" => DeviceName::Hero11Black, + // b"Fusion" | "FUSION" => DeviceName::Fusion, + // b"GoPro Max" => DeviceName::GoProMax, + // b"GoPro Karma v1.0" => DeviceName::GoProKarma, + // s => DeviceName::Other(s.to_owned()), + // } + // } + + // pub fn from_cursor(cursor: &mut Cursor>) -> Self { + // let mut bytes = [0_u8; 4]; + // if let Ok(()) = cursor.read_exact(&mut bytes) { + + // DeviceName::Other(String::from("Unknown")) + // } else { + // DeviceName::Other(String::from("Unknown")) + // } + // } + + // Get documented sample frequency for a specific device + // pub fn freq(&self, fourcc: FourCC) { + // match self { + + // } + // } +} diff --git a/src/gopro/file.rs b/src/gopro/file.rs new file mode 100644 index 0000000..2455947 --- /dev/null +++ b/src/gopro/file.rs @@ -0,0 +1,344 @@ +//! GoPro "file", representing an original, unedited video clip of high and/or low resolution, +//! together with derived sequential number and other attributes. +//! +//! Structs for locating and working with MP4-files belonging to the same recording session. + +use std::{path::{Path, PathBuf}, io::{Cursor, copy}}; + +use binread::{BinReaderExt, BinResult}; +use blake3; +use mp4iter::{self, FourCC}; +use time::Duration; + +use crate::{ + GpmfError, + Gpmf, DeviceName, files::fileext_to_lcstring, +}; + +use super::GoProMeta; + +/// Represents an original, unedited GoPro MP4-file. +// #[derive(Debug, Clone, PartialEq, Eq, PartialOrd)] // TODO PartialOrd needed for Ord +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GoProFile { + /// GoPro device name, use of e.g. MUID + /// and present GPMF data may differ + /// depending on model. + pub device: DeviceName, + /// High resolution MP4 (`.MP4`) + pub mp4: Option, + /// Low resolution MP4 (`.LRV`) + pub lrv: Option, + /// Media Unique ID. + /// Used for matching MP4 and LRV clips, + /// and recording sessions. + /// Device dependent. + /// - Hero11: + /// - MP4, LRV both have a value + /// - `MUID` matches for all clips in the same session. + /// - Hero7: + /// - MP4 has a value + /// - LRV unknown + /// - `MUID` differs for all clips in the same session (use `GUMI`). + pub muid: Vec, + /// Global Unique ID. + /// Used for matching MP4 and LRV clips, + /// and recording sessions. + /// Device dependent. + /// + /// - Hero11: + /// - Multi-clip session: + /// - MP4 has a value + /// - LRV always set to `[0, 0, 0, ...]` + /// - `GUMI` differs for MP4 clips in the same session (use `MUID`) + /// - Single-clip session: + /// - MP4 has a value + /// - LRV has a value + /// - `GUMI` matches between MP4 and LRV + /// - Hero7: + /// - Multi-clip session: + /// - MP4 has a value + /// - LRV unknown + /// - `GUMI` matches for clips in the same session (MP4) + pub gumi: Vec, + /// Fingerprint that is supposedly equivalent for + /// high and low resolution video clips. + /// Blake3 hash generated from concatenated `Vec` + /// representing the full GPMF data (uninterpreted). + pub fingerprint: Vec, + // pub fingerprint: Option> +} + +// // TODO need to implement PartialOrd as well... +// impl Ord for GoProFile { +// fn cmp(&self, other: &Self) -> std::cmp::Ordering { +// // get self gpmf -> last timstamp +// // get other gpmf -> first timstamp +// // cmp: last timestamp < first timestamp + +// let ts_self = self.gpmf().map(|g| g.last_timestamp().cloned().unwrap_or_default()); +// let ts_other = other.gpmf().map(|g| g.first_timestamp().cloned().unwrap_or_default()); + +// if let (Ok(t1), Ok(t2)) = (ts_self, ts_other) { +// t2.cmp(&t1) +// } else { +// panic!("Failed to compare GoPro timestamps") +// } +// } +// } + +impl GoProFile { + pub fn new(path: &Path) -> Result { + let mut gopro = Self::default(); + + let mut mp4 = mp4iter::Mp4::new(&path)?; + + // Check if input passes setting device, MUID, GUMI... + gopro.device = Self::device_internal(&mut mp4)?; + gopro.muid = Self::muid_internal(&mut mp4)?; + gopro.gumi = Self::gumi_internal(&mut mp4)?; + + // ...and set path if ok + gopro.set_path(path); + + // Set fingerprint as hash of raw GPMF byte stream + // gopro.fingerprint = GoProFile::fingerprint(path)?; + gopro.fingerprint = GoProFile::fingerprint_internal(&mut mp4)?; + + Ok(gopro) + } + + /// Get video path. + /// Prioritizes high-resolution video. + pub fn path(&self) -> Option { + if self.mp4.is_some() { + self.mp4.to_owned() + } else { + self.lrv.to_owned() + } + } + + /// Calculates a Blake3 checksum from a `Vec` + /// representing the concatenated GPMF byte streams. + /// For use as clip identifier (as opposed to file), + /// to determine which high (`.MP4`) and low-resolution (`.LRV`) + /// clips that correspond to each other. The GPMF data should be + /// identical for high and low resolution clips. + pub fn fingerprint(path: &Path) -> Result, GpmfError> { + // Determine Blake3 hash for Vec + let mut cursor = Gpmf::first_raw(path)?; + let mut hasher = blake3::Hasher::new(); + let _size = copy(&mut cursor, &mut hasher)?; + let hash = hasher.finalize().as_bytes().to_ascii_lowercase(); + + Ok(hash) + } + + /// Calculates a Blake3 checksum from a `Vec` + /// representing the first DEVC container. + /// For use as clip identifier (as opposed to file), + /// to determine which high (`.MP4`) and low-resolution (`.LRV`) + /// clips that correspond to each other. The GPMF data should be + /// identical for high and low resolution clips. + pub fn fingerprint_internal(mp4: &mut mp4iter::Mp4) -> Result, GpmfError> { + // mp4.reset()?; + // let offsets = mp4.offsets("GoPro MET")?; + + // // Create a single Vec from GPMF streams + // let mut bytes: Vec = Vec::new(); + // for offset in offsets.iter() { + // // Read and return data at MP4 offsets + // let cur = mp4.read_at(offset.position as u64, offset.size as u64) + // .map_err(|e| GpmfError::Mp4Error(e))?; + // bytes.extend(cur.into_inner()); // a bit dumb to get out of + // } + + // Determine Blake3 hash for Vec + let mut cursor = Gpmf::first_raw_mp4(mp4)?; + let mut hasher = blake3::Hasher::new(); + let _size = copy(&mut cursor, &mut hasher)?; + let hash = hasher.finalize().as_bytes().to_ascii_lowercase(); + + Ok(hash) + } + + pub fn fingerprint_hex(&self) -> String { + self.fingerprint.iter() + .map(|b| format!("{:02x}", b)) // pad single char hex with 0 + .collect::>() + .join("") + } + + /// Set high or low-resolution path + /// depending on file extention. + // pub fn set_path(&mut self, path: &Path) -> Result<(), GpmfError> { + pub fn set_path(&mut self, path: &Path) { + match fileext_to_lcstring(path).as_deref() { + Some("mp4") => self.mp4 = Some(path.to_owned()), + Some("lrv") => self.lrv = Some(path.to_owned()), + _ => () + } + } + + /// Returns device name, e.g. `Hero11 Black`. + fn device_internal(mp4: &mut mp4iter::Mp4) -> Result { + DeviceName::from_file(mp4) + } + + /// Returns device name, e.g. `Hero11 Black`. + pub fn device(path: &Path) -> Result { + DeviceName::from_path(path) + } + + /// Returns embedded GPMF data. + pub fn gpmf(&self) -> Result { + if let Some(path) = &self.path() { + Gpmf::new(path, false) + } else { + Err(GpmfError::PathNotSet) + } + } + + /// Returns first DEVC stream only for embedded GPMF data. + pub(crate) fn gpmf_first(&self) -> Result { + if let Some(path) = &self.path() { + let mut cursor = Gpmf::first_raw(path)?; + Gpmf::from_cursor(&mut cursor, false) + } else { + Err(GpmfError::PathNotSet) + } + } + + /// Extract custom data in MP4 `udta` container. + /// GoPro stores some device settings and info here, + /// including a mostly undocumented GPMF-stream. + pub fn meta(&self) -> Result { + if let Some(path) = &self.path() { + GoProMeta::new(path, false) + } else { + Err(GpmfError::PathNotSet) + } + } + + /// Media Unique ID + pub fn muid(path: &Path) -> Result, GpmfError> { + let mut mp4 = mp4iter::Mp4::new(path)?; + let udta = mp4.udta()?; + let fourcc = FourCC::from_str("MUID"); + + for field in udta.fields.iter() { + if field.name == fourcc { + let no_of_entries = match ((field.size - 8) % 4, (field.size - 8) / 4) { + (0, n) => n, + (_, n) => panic!("Failed to determine MUID: {n} length field is not 32-bit aligned") + }; + + let mut fld = field.to_owned(); + + return (0..no_of_entries).into_iter() + .map(|_| fld.data.read_le::()) // read LE to match GPMF + .collect::>>() + .map_err(|err| GpmfError::BinReadError(err)) + } + } + + Ok(Vec::new()) + } + + /// Media Unique ID + fn muid_internal(mp4: &mut mp4iter::Mp4) -> Result, GpmfError> { + mp4.reset()?; + let udta = mp4.udta()?; + let fourcc = FourCC::from_str("MUID"); + + for field in udta.fields.iter() { + if field.name == fourcc { + let no_of_entries = match ((field.size - 8) % 4, (field.size - 8) / 4) { + (0, n) => n, + (_, n) => panic!("Failed to determine MUID: {n} length field is not 32-bit aligned") + }; + + let mut fld = field.to_owned(); + + return (0..no_of_entries).into_iter() + .map(|_| fld.data.read_le::()) // read LE to match GPMF + .collect::>>() + .map_err(|err| GpmfError::BinReadError(err)) + } + } + + Ok(Vec::new()) + } + + /// First four four digits of MUID. + /// Panics if MUID contains fewer than four values. + pub fn muid_first(&self) -> &[u32] { + self.muid[..4].as_ref() + } + + /// Last four digits of MUID. + /// Panics if MUID contains fewer than eight values. + pub fn muid_last(&self) -> &[u32] { + self.muid[4..8].as_ref() + } + + /// Global Unique Media ID + pub fn gumi(path: &Path) -> Result, GpmfError> { + // let meta = self.meta()?; + let mut mp4 = mp4iter::Mp4::new(path)?; + let udta = mp4.udta()?; + let fourcc = FourCC::from_str("GUMI"); + + for field in udta.fields.iter() { + if field.name == fourcc { + return Ok(field.to_owned().data.into_inner()) + } + } + + Ok(Vec::new()) + } + /// Global Unique Media ID + fn gumi_internal(mp4: &mut mp4iter::Mp4) -> Result, GpmfError> { + mp4.reset()?; + let udta = mp4.udta()?; + let fourcc = FourCC::from_str("GUMI"); + + for field in udta.fields.iter() { + if field.name == fourcc { + return Ok(field.to_owned().data.into_inner()) + } + } + + Ok(Vec::new()) + } + + /// Returns duration of clip. + pub fn duration(&self) -> Result { + // LRV and MP4 paths will have identical duration so either is fine. + let path = self.path().ok_or(GpmfError::PathNotSet)?; + let mut mp4 = mp4iter::Mp4::new(&path)?; + + mp4.duration().map_err(|err| GpmfError::Mp4Error(err)) + } + + /// Returns duration of clip as milliseconds. + pub fn duration_ms(&self) -> Result { + self.duration()? + .whole_milliseconds() + .try_into() + .map_err(|err| GpmfError::DowncastIntError(err)) + } +} + +impl Default for GoProFile { + fn default() -> Self { + Self { + device: DeviceName::default(), + mp4: None, + lrv: None, + muid: Vec::default(), + gumi: Vec::default(), + fingerprint: Vec::default() + } + } +} \ No newline at end of file diff --git a/src/gopro/meta.rs b/src/gopro/meta.rs new file mode 100644 index 0000000..02a6d01 --- /dev/null +++ b/src/gopro/meta.rs @@ -0,0 +1,68 @@ +//! GoPro MP4 metadata logged in the user data atom `udta`. +//! +//! GoPro embeds undocumented GPMF streams in the `udta` atom +//! that is also extracted. + +use std::path::{Path, PathBuf}; + +use binread::{BinResult, BinReaderExt}; +use mp4iter::{FourCC, Mp4, UdtaField}; + +use crate::{Stream, GpmfError}; + +/// Parsed MP4 `udta` atom. +/// GoPro cameras embed an undocumented +/// GPMF stream in the `udta` atom. +#[derive(Debug, Default)] +pub struct GoProMeta { + pub path: PathBuf, + pub udta: Vec, + pub gpmf: Vec +} + +impl GoProMeta { + /// Extract custom GoPro metadata from MP4 `udta` atom. + /// Mix of "normal" MP4 atom structures and GPMF-stream. + pub fn new(path: &Path, debug: bool) -> Result { + let mut mp4 = Mp4::new(path)?; + let mut udta = mp4.udta()?; + + let mut meta = Self::default(); + meta.path = path.to_owned(); + + // MP4 FourCC, not GPMF FourCC + let fourcc_gpmf = FourCC::from_str("GPMF"); + + for field in udta.fields.iter_mut() { + if fourcc_gpmf == field.name { + meta.gpmf.extend(Stream::new(&mut field.data, None, debug)?); + } else { + meta.udta.push(field.to_owned()) + } + } + + Ok(meta) + } + + pub fn muid(&self) -> Result, GpmfError> { + let fourcc = FourCC::from_str("MUID"); + + for field in self.udta.iter() { + if field.name == fourcc { + let no_of_entries = match ((field.size - 8) % 4, (field.size - 8) / 4) { + (0, n) => n, + (_, n) => panic!("Failed to determine MUID: {n} length field is not 32-bit aligned") + }; + + let mut fld = field.to_owned(); + + return (0..no_of_entries).into_iter() + .map(|_| fld.data.read_le::()) // read LE to match GPMF + .collect::>>() + .map_err(|err| GpmfError::BinReadError(err)) + } + } + + Ok(Vec::new()) + } +} \ No newline at end of file diff --git a/src/gopro/mod.rs b/src/gopro/mod.rs new file mode 100644 index 0000000..eeca4df --- /dev/null +++ b/src/gopro/mod.rs @@ -0,0 +1,13 @@ +//! Various GoPro related structs and methods. + +pub mod device_name; +pub mod device_id; +pub mod file; +pub mod session; +pub mod meta; + +pub use file::GoProFile; +pub use session::GoProSession; +pub use meta::GoProMeta; +pub use device_id::Dvid; +pub use device_name::DeviceName; diff --git a/src/gopro/session.rs b/src/gopro/session.rs new file mode 100644 index 0000000..c96b12c --- /dev/null +++ b/src/gopro/session.rs @@ -0,0 +1,346 @@ +//! GoPro recording session. + +use std::{ + path::{Path, PathBuf}, + collections::HashMap +}; + +use time::{PrimitiveDateTime, Duration}; +use walkdir::WalkDir; + +use crate::{ + Gpmf, + GpmfError, + DeviceName, + files::has_extension, + Gps +}; + +use super::{GoProFile, GoProMeta}; + +#[derive(Debug, Default, Clone, PartialEq)] +pub struct GoProSession(Vec); + +impl GoProSession { + pub fn len(&self) -> usize { + self.0.len() + } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + pub fn add(&mut self, gopro_file: &GoProFile) { + self.0.push(gopro_file.to_owned()); + } + + pub fn append(&mut self, gopro_files: &[GoProFile]) { + self.0.append(&mut gopro_files.to_owned()); + } + + pub fn remove(&mut self, index: usize) { + self.0.remove(index); + } + + pub fn iter(&self) -> impl Iterator { + self.0.iter() + } + + pub fn iter_mut(&mut self) -> impl Iterator { + self.0.iter_mut() + } + + pub fn first(&self) -> Option<&GoProFile> { + self.0.first() + } + + pub fn first_mut(&mut self) -> Option<&mut GoProFile> { + self.0.first_mut() + } + + pub fn last(&self) -> Option<&GoProFile> { + self.0.last() + } + + pub fn last_mut(&mut self) -> Option<&mut GoProFile> { + self.0.last_mut() + } + + /// Parses and merges GPMF-data for all + /// files in session to a single `Gpmf` struct. + pub fn gpmf(&self) -> Result { + let mut gpmf = Gpmf::default(); + for gopro in self.iter() { + gpmf.merge_mut(&mut gopro.gpmf()?); + } + Ok(gpmf) + } + + /// Extracts custom user data in MP4 `udta` + /// atom for all clips. GoPro models later than + /// Hero5 Black embed an undocumented + /// GPMF stream here that is also included. + pub fn meta(&self) -> Vec { + self.0.iter() + .filter_map(|gp| gp.meta().ok()) + .collect() + } + + /// Returns paths to high-resolution MP4-clips if set (`.MP4`). + pub fn mp4(&self) -> Vec { + self.iter() + .filter_map(|f| f.mp4.to_owned()) + .collect() + } + + /// Returns paths to low-resolution MP4-clips if set (`.LRV`). + pub fn lrv(&self) -> Vec { + self.iter() + .filter_map(|f| f.lrv.to_owned()) + .collect() + } + + /// Returns `true` if paths are set for all high-resolution clips in session. + pub fn matched_lo(&self) -> bool { + match self.iter().any(|gp| gp.lrv.is_none()) { + true => false, + false => true + } + } + + /// Returns `true` if paths are set for all low-resolution clip in session. + pub fn matched_hi(&self) -> bool { + match self.iter().any(|gp| gp.mp4.is_none()) { + true => false, + false => true + } + } + + /// Sort GoPro clips in session based on GPS timestamps (meaning + /// GPS is required). No other continuous timeline for the session exists. + /// + /// To speed things up only the first DEVC container is used for sorting. + /// It should be sufficient since even if the device has not yet acquired + /// a GPS lock, the timestamp should still precede those in the clips + /// that follow. + /// + /// `prune = true` prunes files that return an error on parsing the GPMF stream. + /// Will otherwise fail on first corrupt file. + pub fn sort_gps(&mut self, prune: bool) -> Result { + let mut dt_gp: Vec<(PrimitiveDateTime, GoProFile)> = Vec::new(); + let mut remove_index: Vec = Vec::new(); + for (i, gp) in self.0.iter_mut().enumerate() { + // Extract GPS log, and filter out points with bad satellite lock + let mut gps = Gps::default(); + if prune { + // if let Ok(gpmf) = gp.gpmf() { + // TODO testing using only the first DEVC, since timestamp + // TODO while incorrect should still be in chronological order + if let Ok(gpmf) = gp.gpmf_first() { + gps = gpmf.gps().filter(None); + } else { + // Add index of GoProFile for removal for file + // that raised error then continue to next file + remove_index.push(i); + continue; + } + } else { + // gps = gp.gpmf()?.gps().filter(None); + // TODO testing using only the first DEVC + gps = gp.gpmf_first()?.gps().filter(None); + } + // Return error if no points were logged + // If one file in sequence does not contains GPS data, + // neither should any of the other since GoPro logs + // last known location with no satellite lock. + // I.e. no points at all indicates the GPS was turned off. + if gps.len() == 0 { + return Err(GpmfError::NoData) + } + if let Some(t) = gps.first().map(|p| p.datetime) { + dt_gp.push((t, gp.to_owned())) + } + } + + // Pruning added indeces for files that raised errors, if prune = true + for index in remove_index.iter() { + self.remove(*index) + } + + // Sort remaining files by first good GPS datetime in each file. + dt_gp.sort_by_key(|(t, _)| t.to_owned()); + + Ok(Self(dt_gp.iter().map(|(_, gp)| gp.to_owned()).collect::>())) + } + + /// Sort GoPro clips in session based on filename. + /// Presumes clips are named to represent chronological order + /// (GoPro's own file naming convention works). + pub fn sort_filename(&self) -> Result { + // Ensure all paths are set for at least one resolution + let sort_on_hi = match (self.matched_hi(), self.matched_lo()) { + (true, _) => true, + (false, true) => false, + (false, false) => return Err(GpmfError::PathNotSet) + }; + let mut files = self.0.to_owned(); + files.sort_by_key(|gp| { + if sort_on_hi { + gp.mp4.to_owned().unwrap() // checked that path is set above + } else { + gp.lrv.to_owned().unwrap() // checked that path is set above + } + }); + + Ok(Self(files)) + } + + /// Find all clips in session containing `video`. + /// `dir` is the starting point for searching for clips. + /// If `dir` is `None` the parent dir of `video` is used. + pub fn from_path(video: &Path, dir: Option<&Path>, verbose: bool) -> Option { + let indir = match dir { + Some(d) => d, + None => video.parent()? + }; + let sessions = Self::sessions_from_path(indir, Some(video), verbose); + + sessions.first().cloned() + } + + /// Locate and group clips belonging to the same + /// recording session. Only returns unique files: if the same + /// file is encounterd twice it will only yield a single result. + /// I.e. this function is not intended to be a "find all GoPro files", + /// only "find and group unique GoPro files". + pub fn sessions_from_path( + dir: &Path, + video: Option<&Path>, + verbose: bool + ) -> Vec { + // Key = Blake3 hash as Vec of extracted GPMF raw bytes + let mut hash2gopro: HashMap, GoProFile> = HashMap::new(); + + let gopro_in_session = match video { + Some(p) => { + GoProFile::new(p).ok() + }, + _ => None + }; + + let mut count = 0; + + // 1. Go through files, set + for result in WalkDir::new(dir) { + let path = match result { + Ok(f) => f.path().to_owned(), + // Ignore errors, since these are often due to lack of read permissions + Err(_) => continue + }; + + // Currently only know how mp4+lrv matches for hero11, + // and how mp4 (not lrv) matches for hero7 + // As for setting both MP4 and LRV path, + // `GoProFile::new()` checks parent folder only + // The above may mean the same file may be + // processed twice. + // if has_extension(&path, "mp4") | has_extension(&path, "lrv") { + if let Some(ext) = has_extension(&path, &["mp4", "lrv"]) { + if let Ok(gp) = GoProFile::new(&path) { + if verbose { + count += 1; + println!("{:4}. [{:12} {}] {}", + count, + gp.device.to_str(), + ext.to_uppercase(), + path.display()); + } + + if let Some(gp_session) = &gopro_in_session { + if gp.device != gp_session.device { + continue; + } + match gp.device { + DeviceName::Hero11Black => { + if gp.muid != gp_session.muid { + continue; + } + }, + _ => { + if gp.gumi != gp_session.gumi { + continue; + } + } + } + } + + // `set_path()` sets MP4 or LRV path based on file extension + hash2gopro.entry(gp.fingerprint.to_owned()) + .or_insert(gp).set_path(&path); + } + } + } + + // 2. Group files on MUID or GUMI depending on model + + // Group clips with the same full MUID ([u32; 8]) + let mut muid2gopro: HashMap, Vec> = HashMap::new(); + // Group clips with the same full GUMI ([u8; 16]) + let mut gumi2gopro: HashMap, Vec> = HashMap::new(); + for (_, gp) in hash2gopro.iter() { + match gp.device { + // Hero 11 uses the same MUID for clips in the same session. + DeviceName::Hero11Black => muid2gopro + .entry(gp.muid.to_owned()) + .or_insert(Vec::new()) + .push(gp.to_owned()), + // Hero7 uses GUMI. Others unknown, GUMI is a pure guess. + _ => gumi2gopro + .entry(gp.gumi.to_owned()) + .or_insert(Vec::new()) + .push(gp.to_owned()), + }; + } + + if verbose { + println!("Compiling and sorting sessions...") + } + + // Compile all sessions + let mut sessions = muid2gopro.iter() + .map(|(_, sessions1)| GoProSession(sessions1.to_owned())) + .chain( + gumi2gopro.iter() + .map(|(_, sessions2)| GoProSession(sessions2.to_owned())) + ) + .collect::>(); + + // 3. Sort files within groups on GPS datetime to determine sequence + // TODO possible that duplicate files (with different paths) will be included + let sorted_sessions = sessions.iter_mut() + .filter_map(|s| if s.len() == 1 { + // Avoid parsing GPS to sort for single-clip sessions + Some(s.to_owned()) + } else { + s.sort_gps(true).ok() + }) + .collect::>(); + + sorted_sessions + } + + /// Returns duration of session. + pub fn duration(&self) -> Result { + self.iter() + .map(|g| g.duration()) + .sum() + } + + /// Returns duration of session as milliseconds. + pub fn duration_ms(&self) -> Result { + self.duration()? + .whole_milliseconds() + .try_into() + .map_err(|err| GpmfError::DowncastIntError(err)) + } +} \ No newline at end of file diff --git a/src/gpmf/fourcc.rs b/src/gpmf/fourcc.rs new file mode 100644 index 0000000..7953c78 --- /dev/null +++ b/src/gpmf/fourcc.rs @@ -0,0 +1,481 @@ +//! GPMF Four CC, i.e. general stream identifier. +//! Not all are covered or documented, hence `FourCC::Other(String)`. +//! `FourCC::Invalid` is there to check for zero padding in MP4 `udta` atom GPMF streams, +//! which will otherwise erronously be parsed as valid GPMF FourCC. + +use std::io::{Cursor, Read}; + +use crate::GpmfError; + +/// FourCC enum. Descriptions lifted from official GPMF documentation () +#[derive(Debug, Clone, PartialEq)] +pub enum FourCC { + // FOURCC RESERVED FOR GPMF STRUCTURE + /// unique device source for metadata + DEVC, + /// device/track ID + /// Auto generated unique-ID for managing a large number of connect devices, camera, karma and external BLE devices + DVID, + /// device name + /// Display name of the device like "Karma 1.0", this is for communicating to the user the data recorded, so it should be informative. + DVNM, + /// Nested signal stream of metadata/telemetry + /// Metadata streams are each nested with STRM + STRM, + /// Stream name + /// Display name for a stream like "GPS RAW", this is for communicating to the user the data recorded, so it should be informative. + STNM, + /// Comments for any stream + /// Add more human readable information about the stream + RMRK, + /// Scaling factor (divisor) + /// Sensor data often needs to be scaled to be presented with the correct units. SCAL is a divisor. + SCAL, + /// Standard Units (like SI) + /// If the data can be formatted in GPMF's standard units, this is best. E.g. acceleration as "m/s²". SIUN allows for simple format conversions. + SIUN, + /// Display units + /// While SIUN is preferred, not everything communicates well via standard units. E.g. engine speed as "RPM" is more user friendly than "rad/s". + UNIT, + /// Typedefs for complex structures Not everything has a simple repeating type. For complex structure TYPE is used to describe the data packed within each sample. + TYPE, + /// Total Samples delivered Internal field that counts all the sample delivered since record start, and is automatically computed. + TSMP, + /// Time Offset Rare. An internal field that indicates the data is delayed by 'x' seconds. + TIMO, + /// Empty payload count + EMPT, + + // DEVICE/DATA SPECIFIC FOURCC + /// HERO8Black Audio Levels 10Hz dBFS RMS and peak audio levels in dBFS + AALP, + /// Fusion 3-axis accelerometer 200 m/s² Data order -Y,X,Z + /// HERO5BlackAndSession 3-axis accelerometer 200 m/s² Data order Z,X,Y + /// HERO6Black 3-axis accelerometer 200 m/s² Data order Y,-X,Z + ACCL, + /// HERO6Black Auto Low Light frame Duration 24, 25 or 30 (based video frame rate) n/a ALL extended exposure time + ALLD, + /// GoProMAX Camera ORIentation frame rate n/a Quaternions for the camera orientation since capture start + /// HERO8Black Camera ORIentation frame rate n/a Quaternions for the camera orientation since capture start + CORI, + /// GoProMAX Disparity track (360 modes) frame rate n/a 1-D depth map for the objects seen by the two lenses + DISP, + /// HERO6Black Face detection boundaring boxes 12, 12.5 or 15 (based video frame rate) n/a struct ID,x,y,w,h -- not supported in HEVC modes + /// HERO7Black Face boxes and smile confidence at base frame rate 24/25/30 n/a struct ID,x,y,w,h,unused[17],smile + FACE, + /// HERO6Black Faces counted per frame 12, 12.5 or 15 (based video frame rate) n/a Not supported in HEVC modes + /// HERO7Black removed n/a n/a + FCNM, + /// HERO5Black+ latitude, longitude, altitude (WGS 84), 2D ground speed, and 3D speed 18 deg, deg, m, m/s, m/s + GPS5, + /// HERO5Black+ GPS Fix 1 n/a Within the GPS stream: 0 - no lock, 2 or 3 - 2D or 3D Lock + GPSF, + /// HERO5Black+ GPS Precision - Dilution of Precision (DOP x100) 1 n/a Within the GPS stream, under 500 is good. For more information of GPSP, (or DOP) see https://en.wikipedia.org/wiki/Dilution_of_precision_(navigation) + GPSP, + /// HERO5Black UTC time and data from GPS 1 n/a Within the GPS stream + GPSU, + /// Hero 8(?), 9 GPS Altitude, added in v1.50, before used WGS 84 for alt above the ellipsoid + GPSA, + /// GoProMAX GRAvity Vector frame rate n/a Vector for the direction for gravity + /// HERO8Black GRAvity Vector frame rate n/a Vector for the direction for gravity + GRAV, + /// Fusion 3-axis gyroscope 3200 rad/s Data order -Y,X,Z + /// HERO5BlackAndSession 3-axis gyroscope 400 rad/s Data order Z,X,Y + /// HERO6Black 3-axis gyroscope 200 rad/s Data order Y,-X,Z + GYRO, + HUES, // HERO7Black Predominant hues over the frame 8 - 10 n/a struct ubyte hue, ubyte weight, HSV_Hue = hue x 360/255 + // GoProMAX Image ORIentation frame rate n/a Quaternions for the image orientation relative to the camera body + // HERO8Black Image ORIentation frame rate n/a Quaternions for the image orientation relative to the camera body + IORI, + /// HERO6Black Sensor ISO 24, 25 or 30 (based video frame rate) n/a replaces ISOG, has the same function + ISOE, + /// Fusion Image sensor gain increased to 60 n/a per frame exposure metadata + /// HERO5BlackAndSession Image sensor gain 24, 25 or 30 (based video frame rate) n/a HERO5 v2 or greater firmware + ISOG, + /// HERO9Black Low res video frame SKiP frame rate n/a GoPro internal usage. Same as MSKP for the LRV video file (when present.) This improves sync with the main video when using the LRV as a proxy. + LSKP, + /// Fusion magnetometer 24 µT Camera pointing direction + /// GoProMAX MAGNnetometer 24 µT Camera pointing direction x,y,z (valid in v2.0 firmware.) + MAGN, + /// HERO9Black Main video frame SKiP frame rate n/a GoPro internal usage. Number frames skips or duplicated from sensor image captured to encoded frame. Normally 0. This is used for visual effects when precision timing of the video frame is required. + MSKP, + /// HERO8Black Microphone is WET 10Hz n/a marks whether some of the microphones are wet + MWET, + /// HERO7Black Scene classifier in probabilities 8 - 10 n/a FourCC scenes: SNOW, URBAn, INDOor, WATR, VEGEtation, BEACh + /// Hero 6a (not 6), 7, 8, 9 Orientation, accelerometer + ORIN, + /// Hero 6a (not 6), 7, 8 Orientation, accelerometer + ORIO, + /// Hero 6a (not 6), 7, 8 Orientation, accelerometer + MTRX, + /// Scene? + SCEN, + /// Fusion Exposure time increased to 60 s per frame exposure metadata + /// HERO5BlackAndSession Exposure time 24, 25 or 30 (based video frame rate) s HERO5 v2 or greater firmware + SHUT, + /// HERO7Black Sensor Read Out Time at base frame rate 24/25/30 n/a this moves to a global value in HERO8 + SROT, + /// Fusion and later (?) microsecond timestamps 1 µs Increased precision for post stablization + STMP, + /// HERO7Black Image uniformity 8 - 10 range 0 to 1.0 where 1.0 is a solid color + UNIF, + /// HERO6Black White Balance in Kelvin 24, 25 or 30 (based video frame rate) n/a Classic white balance info + WBAL, + /// HERO8Black Wind Processing 10Hz n/a marks whether wind processing is active + WNDM, + /// HERO6Black White Balance RGB gains 24, 25 or 30 (based video frame rate) n/a Geeky white balance info + WRGB, + /// HERO7Black Luma (Y) Average over the frame 8 - 10 n/a range 0 (black) to 255 (white) + YAVG, + + // Content FourCC + /// In GPSA (GPS Altitude) for GPS stream: Mean Sea Level + MSLV, + /// HERO7Black Scene classification Snow + SNOW, + /// HERO7Black Scene classification Urban + URBA, + /// HERO7Black Scene classification Indoors + INDO, + /// HERO7Black Scene classification Water + WATR, + /// HERO7Black Scene classification Vegetation + VEGE, + /// HERO7Black Scene classification Beach + BEAC, + + // MP4 user data atom (`udta`) only + /// MP4 `udta` firmware version + FIRM, + /// MP4 `udta` lens serial number (unconfirmed) + LENS, + /// MP4 `udta` camera (?) + CAME, + /// MP4 `udta` settings (?) + SETT, + /// MP4 `udta` unknown + AMBA, + /// MP4 `udta` unknown + MUID, + /// MP4 `udta` unknown + HMMT, + /// MP4 `udta` unknown + BCID, + /// MP4 `udta` unknown + GUMI, + + // JPEG GPMF FourCC + MINF, + + /// Mainly for checking and invalidating 0-padding + /// in MP4 `udta` GPMF data. + Invalid, + + /// Undocumented FourCC, such as for those found in GoPro MP4 `udta` atom's GPMF section + Other(String), +} + +impl Default for FourCC { + fn default() -> Self { + FourCC::Invalid + } +} + +impl FourCC { + pub fn new(cursor: &mut Cursor>) -> Result { + // pub fn new(cursor: &mut Cursor<&[u8]>) -> Result { + let mut buf = vec![0_u8; 4]; + let _len = cursor.read(&mut buf)?; + // if len != buf.len() { + // return Err(GpmfError::ReadMismatch{got: len as u64, expected: buf.len() as u64}) + // } + + Self::from_slice(&buf) + } + pub fn new2(cursor: &[u8]) -> Result { + // pub fn new(cursor: &mut Cursor<&[u8]>) -> Result { + // let mut buf = vec![0_u8; 4]; + // let len = cursor.read(&mut buf)?; + // if len != buf.len() { + // return Err(GpmfError::ReadMismatch{got: len as u64, expected: buf.len() as u64}) + // } + + Self::from_slice(cursor) + } + + /// Generate FourCC enum from `&str`. + fn from_slice(slice: &[u8]) -> Result { + // assert_eq!( + // slice.len(), + // 4, + // "FourCC must be have length 4." + // ); + + match slice { + // GPMF structural FourCC + b"DEVC" => Ok(FourCC::DEVC), + b"DVID" => Ok(FourCC::DVID), + b"DVNM" => Ok(FourCC::DVNM), + b"STRM" => Ok(FourCC::STRM), + b"STNM" => Ok(FourCC::STNM), + b"RMRK" => Ok(FourCC::RMRK), + b"SCAL" => Ok(FourCC::SCAL), + b"SIUN" => Ok(FourCC::SIUN), + b"UNIT" => Ok(FourCC::UNIT), + b"TYPE" => Ok(FourCC::TYPE), + b"TSMP" => Ok(FourCC::TSMP), + b"TIMO" => Ok(FourCC::TIMO), + b"EMPT" => Ok(FourCC::EMPT), + + // Device/data specific FourCC + b"AALP" => Ok(FourCC::AALP), + b"ACCL" => Ok(FourCC::ACCL), + b"ALLD" => Ok(FourCC::ALLD), + b"CORI" => Ok(FourCC::CORI), + b"DISP" => Ok(FourCC::DISP), + b"FACE" => Ok(FourCC::FACE), + b"FCNM" => Ok(FourCC::FCNM), + b"GPS5" => Ok(FourCC::GPS5), + b"GPSF" => Ok(FourCC::GPSF), + b"GPSP" => Ok(FourCC::GPSP), + b"GPSU" => Ok(FourCC::GPSU), + b"GPSA" => Ok(FourCC::GPSA), + b"GRAV" => Ok(FourCC::GRAV), + b"GYRO" => Ok(FourCC::GYRO), + b"HUES" => Ok(FourCC::HUES), + b"IORI" => Ok(FourCC::IORI), + b"ISOE" => Ok(FourCC::ISOE), + b"ISOG" => Ok(FourCC::ISOG), + b"LSKP" => Ok(FourCC::LSKP), + b"MAGN" => Ok(FourCC::MAGN), + b"MSKP" => Ok(FourCC::MSKP), + b"MWET" => Ok(FourCC::MWET), + b"ORIN" => Ok(FourCC::ORIN), + b"ORIO" => Ok(FourCC::ORIO), + b"MTRX" => Ok(FourCC::MTRX), + b"SCEN" => Ok(FourCC::SCEN), + b"SHUT" => Ok(FourCC::SHUT), + b"SROT" => Ok(FourCC::SROT), + b"STMP" => Ok(FourCC::STMP), + b"UNIF" => Ok(FourCC::UNIF), + b"WBAL" => Ok(FourCC::WBAL), + b"WNDM" => Ok(FourCC::WNDM), + b"WRGB" => Ok(FourCC::WRGB), + b"YAVG" => Ok(FourCC::YAVG), + + // Content FourCC + b"MSLV" => Ok(FourCC::MSLV), + // Scene classifications, Hero7 Black only? Not in Hero8+9 + b"SNOW" => Ok(FourCC::SNOW), + b"URBA" => Ok(FourCC::URBA), + b"INDO" => Ok(FourCC::INDO), + b"WATR" => Ok(FourCC::WATR), + b"VEGE" => Ok(FourCC::VEGE), + b"BEAC" => Ok(FourCC::BEAC), + + // MP4 user data atom (`udta`) only + b"FIRM" => Ok(FourCC::FIRM), + b"LENS" => Ok(FourCC::LENS), + b"CAME" => Ok(FourCC::CAME), + b"SETT" => Ok(FourCC::SETT), + b"AMBA" => Ok(FourCC::AMBA), + b"MUID" => Ok(FourCC::MUID), + b"HMMT" => Ok(FourCC::HMMT), + b"BCID" => Ok(FourCC::BCID), + b"GUMI" => Ok(FourCC::GUMI), + + // JPEG GPMF FourCC + b"MINF" => Ok(FourCC::MINF), + + // GoPro MP4 udta atom contains undocumented + // GPMF data that is zero padded, + // used as check for breaking parse loop + b"\0" | b"\0\0\0\0" => Ok(Self::Invalid), + + // Undocumented FourCC + _ => Ok(FourCC::Other(String::from_utf8_lossy(slice).to_string())), + } + } + + /// Generate FourCC enum from `&str`. + pub fn from_str(fourcc: &str) -> Self { + // NOTE Could be ISO8859-1 values that fit in single byte rather than standard ASCII + assert_eq!( + fourcc.chars().count(), + 4, + "FourCC must be an ASCII string with length 4." + ); + + match fourcc.trim() { + // GPMF structural FourCC + "DEVC" => FourCC::DEVC, + "DVID" => FourCC::DVID, + "DVNM" => FourCC::DVNM, + "STRM" => FourCC::STRM, + "STNM" => FourCC::STNM, + "RMRK" => FourCC::RMRK, + "SCAL" => FourCC::SCAL, + "SIUN" => FourCC::SIUN, + "UNIT" => FourCC::UNIT, + "TYPE" => FourCC::TYPE, + "TSMP" => FourCC::TSMP, + "TIMO" => FourCC::TIMO, + "EMPT" => FourCC::EMPT, + + // Device/data specific FourCC + "AALP" => FourCC::AALP, + "ACCL" => FourCC::ACCL, + "ALLD" => FourCC::ALLD, + "CORI" => FourCC::CORI, + "DISP" => FourCC::DISP, + "FACE" => FourCC::FACE, + "FCNM" => FourCC::FCNM, + "GPS5" => FourCC::GPS5, + "GPSF" => FourCC::GPSF, + "GPSP" => FourCC::GPSP, + "GPSU" => FourCC::GPSU, + "GPSA" => FourCC::GPSA, + "GRAV" => FourCC::GRAV, + "GYRO" => FourCC::GYRO, + "HUES" => FourCC::HUES, + "IORI" => FourCC::IORI, + "ISOE" => FourCC::ISOE, + "ISOG" => FourCC::ISOG, + "LSKP" => FourCC::LSKP, + "MAGN" => FourCC::MAGN, + "MSKP" => FourCC::MSKP, + "MWET" => FourCC::MWET, + "ORIN" => FourCC::ORIN, + "ORIO" => FourCC::ORIO, + "MTRX" => FourCC::MTRX, + "SCEN" => FourCC::SCEN, + "SHUT" => FourCC::SHUT, + "SROT" => FourCC::SROT, + "STMP" => FourCC::STMP, + "UNIF" => FourCC::UNIF, + "WBAL" => FourCC::WBAL, + "WNDM" => FourCC::WNDM, + "WRGB" => FourCC::WRGB, + "YAVG" => FourCC::YAVG, + + // Content FourCC + "MSLV" => FourCC::MSLV, + // Scene classifications, Hero7 Black only? Not in Hero8+9 + "SNOW" => FourCC::SNOW, + "URBA" => FourCC::URBA, + "INDO" => FourCC::INDO, + "WATR" => FourCC::WATR, + "VEGE" => FourCC::VEGE, + "BEAC" => FourCC::BEAC, + + // MP4 user data atom (`udta`) only + "FIRM" => FourCC::FIRM, + "LENS" => FourCC::LENS, + "CAME" => FourCC::CAME, + "SETT" => FourCC::SETT, + "AMBA" => FourCC::AMBA, + "MUID" => FourCC::MUID, + "HMMT" => FourCC::HMMT, + "BCID" => FourCC::BCID, + "GUMI" => FourCC::GUMI, + + // JPEG GPMF FourCC + "MINF" => FourCC::MINF, + + // Undocumented FourCC + _ => FourCC::Other(fourcc.to_owned()), + } + } + + /// Generate `String` from `FourCC`. + pub fn to_str(&self) -> &str { + match self { + // GPMF structural FourCC + FourCC::DEVC => "DEVC", + FourCC::DVID => "DVID", + FourCC::DVNM => "DVNM", + FourCC::STRM => "STRM", + FourCC::STNM => "STNM", + FourCC::RMRK => "RMRK", + FourCC::SCAL => "SCAL", + FourCC::SIUN => "SIUN", + FourCC::UNIT => "UNIT", + FourCC::TYPE => "TYPE", + FourCC::TSMP => "TSMP", + FourCC::TIMO => "TIMO", + FourCC::EMPT => "EMPT", + + // Device/data specific FourCC + FourCC::AALP => "AALP", + FourCC::ACCL => "ACCL", + FourCC::ALLD => "ALLD", + FourCC::CORI => "CORI", + FourCC::DISP => "DISP", + FourCC::FACE => "FACE", + FourCC::FCNM => "FCNM", + FourCC::GPS5 => "GPS5", + FourCC::GPSF => "GPSF", + FourCC::GPSP => "GPSP", + FourCC::GPSU => "GPSU", + FourCC::GPSA => "GPSA", + FourCC::GRAV => "GRAV", + FourCC::GYRO => "GYRO", + FourCC::HUES => "HUES", + FourCC::IORI => "IORI", + FourCC::ISOE => "ISOE", + FourCC::ISOG => "ISOG", + FourCC::LSKP => "LSKP", + FourCC::MAGN => "MAGN", + FourCC::MSKP => "MSKP", + FourCC::MWET => "MWET", + FourCC::ORIN => "ORIN", + FourCC::ORIO => "ORIO", + FourCC::MTRX => "MTRX", + FourCC::SCEN => "SCEN", + FourCC::SHUT => "SHUT", + FourCC::SROT => "SROT", + FourCC::STMP => "STMP", + FourCC::UNIF => "UNIF", + FourCC::WBAL => "WBAL", + FourCC::WNDM => "WNDM", + FourCC::WRGB => "WRGB", + FourCC::YAVG => "YAVG", + + // Content FourCC + // Mean Sea Level (altitude, in GPSA) + FourCC::MSLV => "MSLV", + // Scene classifications + FourCC::SNOW => "SNOW", + FourCC::URBA => "URBA", + FourCC::INDO => "INDO", + FourCC::WATR => "WATR", + FourCC::VEGE => "VEGE", + FourCC::BEAC => "BEAC", + + // MP4 user data atom (`udta`) only + FourCC::FIRM => "FIRM", + FourCC::LENS => "LENS", + FourCC::CAME => "CAME", + FourCC::SETT => "SETT", + FourCC::AMBA => "AMBA", + FourCC::MUID => "MUID", + FourCC::HMMT => "HMMT", + FourCC::BCID => "BCID", + FourCC::GUMI => "GUMI", + + // JPEG GPMF FourCC + FourCC::MINF => "MINF", + + // FourCC if [0, 0, 0, 0, ...] detected + // (MP4 udta atom padding) + FourCC::Invalid => "INVALID_FOURCC", + + // Undocumented FourCC + FourCC::Other(s) => s, + } + } + + pub fn is_invalid(&self) -> bool { + self == &FourCC::Invalid + } +} diff --git a/src/gpmf/gpmf.rs b/src/gpmf/gpmf.rs new file mode 100644 index 0000000..39582a0 --- /dev/null +++ b/src/gpmf/gpmf.rs @@ -0,0 +1,481 @@ +//! GoPro core GPMF struct and methods. +//! +//! Input: +//! - original, unedited GoPro MP4 clips +//! - raw GPMF "files" extracted via e.g. FFmpeg +//! - byte slices +//! - original, unedited GoPro JPEG files +//! +//! Content will vary between devices and data types. +//! Note that timing is derived directly from the MP4 container, meaning GPMF tracks +//! exported with FFmpeg will not have relative time stamps for each data cluster. +//! +//! ```rs +//! use gpmf_rs::Gpmf; +//! use std::path::Path; +//! +//! fn main() -> std::io::Result<()> { +//! let path = Path::new("GOPRO_VIDEO.MP4"); +//! let gpmf = Gpmf::new(&path)?; +//! Ok(()) +//! } +//! ``` + +use std::collections::HashSet; +use std::io::Cursor; +use std::path::{PathBuf, Path}; + +use jpegiter::{Jpeg, JpegTag}; +use rayon::prelude::{ + IntoParallelRefMutIterator, + IndexedParallelIterator, + IntoParallelRefIterator, + ParallelIterator +}; + +use super::{FourCC, Timestamp, Stream}; +use crate::{StreamType, SensorType, SensorData}; +use crate::{ + Gps, + GoProPoint, + DataType, + GpmfError, + gopro::Dvid +}; + +/// Core GPMF struct. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct Gpmf { + /// GPMF streams. + pub streams: Vec, + /// Path/s to the GoPro MP4 source/s + /// the GPMF data was extracted from. + pub source: Vec +} + +// impl From>>> for Gpmf { +// fn from>>>(value: C) -> Result { +// Gpmf::from_cursor(value, false) +// } +// } + +impl Gpmf { + /// GPMF from file. Either an unedited GoPro MP4-file, + /// JPEG-file (WIP, currently n/a), + /// or a "raw" GPMF-file, extracted via FFmpeg. + /// Relative timestamps for all data loads is exclusive + /// to MP4, since these are derived from MP4 timing. + /// + /// ``` + /// use gpmf_rs::Gpmf; + /// use std::path::Path; + /// + /// fn main() -> std::io::Result<()> { + /// let path = Path::new("GOPRO_VIDEO.MP4"); + /// let gpmf = Gpmf::new(&path)?; + /// Ok(()) + /// } + /// ``` + // pub fn new(path: &Path) -> Result { + pub fn new(path: &Path, debug: bool) -> Result { + let ext = path.extension() + .and_then(|e| e.to_str()) + .map(|s| s.to_lowercase()) + .ok_or_else(|| GpmfError::InvalidFileType(path.to_owned()))?; + + match ext.as_ref() { + "mp4" + | "lrv" => Self::from_mp4(path, debug), + "jpg" + | "jpeg" => Self::from_jpg(path, debug), + // Possibly "raw" GPMF-file + _ => Self::from_raw(path, debug) + } + } + + // // Returns the entire GPMF stream unparsed as `Cursor>`. + // pub fn raw(mp4: &mut mp4iter::Mp4) { + + // } + + /// Returns first DEVC stream only and without parsing. + /// + /// Presumed to be unique enough to use as a fingerprint + /// without having to hash the entire GPMF stream or the + /// MP4 file itself. + /// + /// Used for producing a hash that can be stored in a `GoProFile` + /// struct to match high and low resolution clips, or duplicate ones. + pub(crate) fn first_raw(path: &Path) -> Result>, GpmfError> { + let mut mp4 = mp4iter::Mp4::new(path)?; + Self::first_raw_mp4(&mut mp4) + } + + /// Extracts first DEVC stream only and without parsing, + /// then creates and return a Blake3 hash of the raw bytes. + /// + /// Presumed to be unqique enough to use as a fingerprint + /// without having to hash the entire GPMF stream or the + /// MP4 file itself. + /// + /// Used for producing a hash that can be store in a `GoProFile` + /// struct to match, or high and low resolution clips or duplicate ones. + pub(crate) fn first_raw_mp4(mp4: &mut mp4iter::Mp4) -> Result>, GpmfError> { + mp4.reset()?; + let offsets = mp4.offsets("GoPro MET")?; + + if let Some(offset) = offsets.first() { + mp4.read_at(offset.position, offset.size as u64) + .map_err(|err| err.into()) + } else { + Err(GpmfError::NoMp4Offsets) + } + } + + /// Returns the embedded GPMF streams in a GoPro MP4 file. + // pub fn from_mp4(path: &Path) -> Result { + pub fn from_mp4(path: &Path, debug: bool) -> Result { + let mut mp4 = mp4iter::Mp4::new(path)?; + + // TODO 220812 REGRESSION CHECK: DONE. + // TODO Mp4::offsets() 2-3x slower with new code (4GB file), + // TODO though in microsecs: 110-200us old vs 240-600us new. + // 1. Extract position/byte offset, size, and time span for GPMF chunks. + let offsets = mp4.offsets("GoPro MET")?; + + // Faster than a single, serial iter so far. + // 2. Read data at MP4 offsets and generate timestamps serially + let mut timestamps: Vec = Vec::new(); + let mut cursors = offsets.iter() + .map(|o| { + // Create timestamp + let timestamp = timestamps.last() + .map(|t| Timestamp { + relative: t.relative + o.duration, + duration: o.duration, + }).unwrap_or(Timestamp { + relative: 0, + duration: o.duration + }); + timestamps.push(timestamp); + + // Read and return data at MP4 offsets + mp4.read_at(o.position as u64, o.size as u64) + .map_err(|e| GpmfError::Mp4Error(e)) + }) + .collect::, GpmfError>>()?; + + assert_eq!(timestamps.len(), cursors.len(), "Timestamps and cursors differ in number for GPMF"); + + // 3. Parse each data chunk/cursor into Vec. + let streams = cursors.par_iter_mut().zip(timestamps.par_iter()) + .map(|(cursor, t)| { + // let stream = Stream::new(cursor, None) + let stream = Stream::new(cursor, None, debug) + .map(|mut strm| { + // 1-2 streams. 1 for e.g. Hero lineup, 2 for Karma drone (1 for drone, 1 for attached cam) + strm.iter_mut().for_each(|s| s.set_time(t)); + strm + }); + stream + }) + .collect::, GpmfError>>()? // Vec>, need to flatten + .par_iter() + .flatten_iter() // flatten will mix drone data with cam data, perhaps bad idea + .cloned() + .collect::>(); + + Ok(Self{ + streams, + source: vec![path.to_owned()] + }) + } + + /// Returns the embedded GPMF stream in a GoPro photo, JPEG only. + pub fn from_jpg(path: &Path, debug: bool) -> Result { + // Find and extract EXIf chunk with GPMF + let segment = Jpeg::new(path)? + .find(&JpegTag::APP6) + .map_err(|err| GpmfError::JpegError(err))?; + + if let Some(mut app6) = segment { + app6.seek(6); // seek past `GoPro\null` + // return Self::from_cursor(&mut app6.data) + return Self::from_cursor(&mut app6.data, debug) + } else { + Err(GpmfError::InvalidFileType(path.to_owned())) + } + } + + /// Returns GPMF from a "raw" GPMF-file, + /// e.g. the "GoPro MET" track extracted from a GoPro MP4 with FFMpeg. + pub fn from_raw(path: &Path, debug: bool) -> Result { + // TODO do a buffered read instead of arbitrary max size value? + let max_size = 50_000_000_u64; // max in-memory size set to 50MB + let size = path.metadata()?.len(); + + if size > max_size { + return Err(GpmfError::MaxFileSizeExceeded{ + max: max_size, + got: size, + path: path.to_owned() + }) + } + + let mut cursor = Cursor::new(std::fs::read(path)?); + // let streams = Stream::new(&mut cursor, None)?; + let streams = Stream::new(&mut cursor, None, debug)?; + + Ok(Self{ + streams, + source: vec![path.to_owned()] + }) + } + + /// GPMF from byte slice. + // pub fn from_slice(slice: &[u8]) -> Result { + pub fn from_slice(slice: &[u8], debug: bool) -> Result { + let mut cursor = Cursor::new(slice.to_owned()); + // Self::from_cursor(&mut cursor) + Self::from_cursor(&mut cursor, debug) + } + + /// GPMF from `Cursor>`. + // pub fn from_cursor(cursor: &mut Cursor>) -> Result { + pub fn from_cursor(cursor: &mut Cursor>, debug: bool) -> Result { + Ok(Self{ + // streams: Stream::new(cursor, None)?, + streams: Stream::new(cursor, None, debug)?, + source: vec![] + }) + } + + pub fn print(&self) { + self.iter().enumerate() + .for_each(|(i, s)| + s.print(Some(i+1), Some(self.len())) + ) + } + + /// Returns number of `Streams`. + pub fn len(&self) -> usize { + self.streams.len() + } + + pub fn iter(&self) -> impl Iterator { + self.streams.iter() + } + + pub fn iter_mut(&mut self) -> impl Iterator { + self.streams.iter_mut() + } + + pub fn into_iter(self) -> impl IntoIterator { + self.streams.into_iter() + } + + /// Returns first DEVC stream + pub fn first(&self) -> Option<&Stream> { + self.streams.first() + } + + /// Returns last DEVC stream + pub fn last(&self) -> Option<&Stream> { + self.streams.last() + } + + /// Find streams with specified FourCC. + pub fn find(&self, fourcc: &FourCC) -> Option<&Stream> { + for stream in self.iter() { + if stream.fourcc() == fourcc { + return Some(stream) + } + match &stream.streams { + StreamType::Nested(s) => { + for strm in s.iter() { + strm.find(fourcc); + } + }, + StreamType::Values(_) => return None + } + } + + None + } + + /// Append `Stream`s to `self.streams`. + pub fn append(&mut self, streams: &mut Vec) { + self.streams.append(streams) + } + + /// Extend `self.streams`. + pub fn extend(&mut self, streams: &[Stream]) { + self.streams.extend(streams.to_owned()) + } + + /// Merges two GPMF streams, returning the merged stream, + /// leaving `self` untouched. + /// Assumed that specified `gpmf` follows after + /// `self` chronologically. + pub fn merge(&self, gpmf: &Self) -> Self { + let mut merged = self.to_owned(); + merged.merge_mut(&mut gpmf.to_owned()); + merged + } + + /// Merges two GPMF streams in place. + /// Assumed that specified `gpmf` follows after + /// `self` chronologically. + pub fn merge_mut(&mut self, gpmf: &mut Self) { + if let Some(ts) = self.last_timestamp() { + // adds final timestamp of previous gpmf to all timestamps + gpmf.offset_time(&ts); + } + + // Use append() instead? + // https://github.com/rust-lang/rust-clippy/issues/4321#issuecomment-929110184 + self.extend(&gpmf.streams); + self.source.extend(gpmf.source.to_owned()); + } + + /// Filters direct child nodes based on `StreamType`. Not recursive. + pub fn filter(&self, data_type: &DataType) -> Vec { + // self.iter() + self.streams.par_iter() + .flat_map(|s| s.filter(data_type)) + .collect() + } + + /// Filters direct child nodes based on `StreamType` and returns an iterator. Not recursive. + pub fn filter_iter<'a>( + &'a self, + data_type: &'a DataType, + ) -> impl Iterator + 'a { + // self.iter() + self.streams.iter() + .flat_map(move |s| s.filter(data_type)) + } + + /// Returns all unique free text stream descriptions, i.e. `STNM` data. + /// The hierarchy is `DEVC` -> `STRM` -> `STNM`. + pub fn types(&self) -> Vec { + let mut unique: HashSet = HashSet::new(); + for devc in self.streams.iter() { + devc.find_all(&FourCC::STRM).iter() + .filter_map(|s| s.name()) + .for_each(|n| _ = unique.insert(n)); + }; + let mut types = unique.into_iter().collect::>(); + types.sort(); + types + } + + /// Returns summed duration of MP4 sources (longest track). + /// Raises error if sources are not MP4-files + /// (e.g. if source is a raw `.gpmf` extracted via FFmpeg). + pub fn duration(&self) -> Result { + // let dur = self.source.iter() + // .fold(time::Duration::ZERO, |acc, path| acc + mp4iter::Mp4::new(path)?.duration()?); + // Ok(dur) + let mut duration = time::Duration::ZERO; + for path in self.source.iter() { + duration += mp4iter::Mp4::new(path)?.duration()?; + } + Ok(duration) + } + + /// Returns summed duration of MP4 sources (longest track) + /// as milliseconds. + /// Raises error if sources are not MP4-files + /// (e.g. if source is a raw `.gpmf` extracted via FFmpeg). + pub fn duration_ms(&self) -> Result { + Ok((self.duration()?.as_seconds_f64() * 1000.0) as i64) + } + + /// Add time offset to all `DEVC` timestamps + pub fn offset_time(&mut self, time: &Timestamp) { + self.iter_mut() + .for_each(|devc| + devc.time = devc.time.to_owned().map(|t| t.add(time)) + ) + } + + /// Returns first `Timestamp` in GPMF stream. + pub fn first_timestamp(&self) -> Option<&Timestamp> { + self.first() + .and_then(|devc| devc.time.as_ref()) + } + + /// Returns last `Timestamp` in GPMF stream. + pub fn last_timestamp(&self) -> Option<&Timestamp> { + self.last() + .and_then(|devc| devc.time.as_ref()) + } + + /// Device name. Extracted from first `Stream`. + /// The Karma drone has two streams: + /// one for the for the attached camera, + /// another for the drone itself. + /// Attached bluetooth devices may also generate + /// GPMF data. In both cases the camera is so far + /// the first device listed. + /// Hero5 Black (the first GPMF GoPro) identifies + /// itself as `Camera`. + pub fn device_name(&self) -> Vec { + let names_set: HashSet = self.streams.iter() + .filter_map(|s| s.device_name()) + .collect(); + + let mut names = Vec::from_iter(names_set); + names.sort(); + + names + } + + /// Device ID. Extracted from first `Stream`. + pub fn device_id(&self) -> Option { + self.streams + .first() + .and_then(|s| s.device_id()) + } + + /// Returns all GPS streams as Vec`. Each returned point is a processed, + /// linear average of `GPS5`. Should be accurate enough for the 10 or 18Hz GPS + /// used by GoPro, but implementing a latitude dependent average + /// is a future possibility. + pub fn gps(&self) -> Gps { + Gps(self.filter_iter(&DataType::Gps5) + .flat_map(|s| GoProPoint::new(&s)) // TODO which Point to use? + .collect::>()) + } + + pub fn sensor(&self, sensor_type: &SensorType) -> Vec { + SensorData::from_gpmf(self, sensor_type) + } + + // /// Extract custom data in MP4 `udta` container. + // /// GoPro stores some device settings and info here, + // /// including a mostly undocumented GPMF-stream. + // pub fn meta(&self) -> Result<(), GpmfError> { + // if self.source.iter().any(|p| !match_extension(p, "mp4")) { + // return Err(GpmfError::InvalidFileType{expected_ext: String::from("mp4")}) + // } + // for path in self.source.iter() { + // let gpmeta = GoProMeta::new(path)?; + // } + + // Ok(()) + // } + + // /// Derive starting time, i.e. the absolute timestamp for first `DEVC`. + // /// Can only be determined if the GPS was turned on and logged data. + // /// + // /// Convenience method that simply subtracts first `Point`'s `Point.time.instant` from `Point.datetime`. + // /// + // /// Note that this will filter on Gps streams again, + // /// so if you already have a `Gps` struct use `Gps::t0()`, + // /// or do the calucation yourself from `Vec`. + // pub fn t0(&self) -> Option { + // self.gps().t0() + // } +} diff --git a/src/gpmf/header.rs b/src/gpmf/header.rs new file mode 100644 index 0000000..e7d00d0 --- /dev/null +++ b/src/gpmf/header.rs @@ -0,0 +1,146 @@ +//! GPMF header that precedes each GPMF stream. + +use std::{io::Cursor, fmt}; + +use binread::BinReaderExt; + +use super::FourCC; +use crate::GpmfError; + +/// GPMF header. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct Header { + /// FourCC. + pub fourcc: FourCC, + /// Base type. + pub basetype: u8, + /// Base size. + /// Note: this is a `u8` in GPMF spec. + /// Changed to `u16` for more convenient + /// string parsing to avoid casting `u16` as `u8`. + pub basesize: u16, + pub repeats: u16, + pub pad: u8, +} + +impl fmt::Display for Header { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{} basetype: {:4}/{}, basesize: {:2}, repeats: {:2}, pad: {}", + self.fourcc.to_str(), + self.basetype, + self.basetype as char, + self.basesize, + self.repeats, + self.pad, + ) + } +} + +impl Header { + /// GPMF header. + /// Layout: + /// - 0-3: 32-bit FourCC (ascii string) + /// - Structure: + /// - 4: Type (u8) + /// - 5: Structure size (u8 in spec, but stored as u16) + /// - 6-7: Repeat (u16) + /// + /// GPMF strings (e.g. STNM): + /// Between older and newer GoPro devices, strings are + /// sometimes structure size = 1, repeat X, somtimes size = X, repeat 1. + /// To simplify unpacking/parsing strings ('c'/99), structure size is set to u16, + /// to allow Type and structure size to switch places so that all string data + /// becomes structure size X (byte length of utf-8 string), repeat 1, e.g. for older devices: + /// `['G'] ['y'] ['r'] ['o']`, will instead be parsed into `['G', 'y', 'r', 'o']` -> String "Gyro". + /// This is also true for Huffman encoded data loads, but these are currently not supported. + pub fn new(cursor: &mut Cursor>) -> Result { + let fourcc = FourCC::new(cursor)?; + + // check for "\0" and if found set header fourcc as invalid + if fourcc.is_invalid() { + return Ok(Self::default()) + } + + let basetype: u8 = cursor.read_ne()?; + + // Temp valus for GPMF structure size. + // Switch places between size and repeat if + // size is 1. + let tmp_basesize: u8 = cursor.read_ne()?; + let tmp_repeats: u16 = cursor.read_be()?; + + // Check if structure size = 1 and basesize is a char, + // and switch places if so to simplify string parsing. + let (basesize, repeats) = match (basetype, tmp_basesize) { + (b'c', 1) => (tmp_repeats, tmp_basesize as u16), + _ => (tmp_basesize as u16, tmp_repeats) + }; + + // Set padding value for 32-bit alignment (0-3) + let mut pad = 0; + loop { + match (basesize * repeats + pad as u16) % 4 { + 0 => break, + _ => pad += 1, + } + } + + Ok(Self { + fourcc, + basetype, + basesize, + repeats, + pad, + }) + } + + /// Converts header to single-value header with specified type. + /// Used for for complex type (`63`/`?`). + pub fn convert(&self, basetype: &u8) -> Self { + Self { + basetype: *basetype, + basesize: Self::baselen(basetype) as u16, + ..self.to_owned() + } + } + + /// Returns `true` if header Four CC + /// equals `[0, 0, 0, 0]`. + pub fn is_invalid(&self) -> bool { + self.fourcc == FourCC::Invalid + } + + /// Get base length (same as `std::mem::size_of::()`). + /// Only used for COMPLEX BaseTypes. + const fn baselen(basetype: &u8) -> u8 { + match basetype { + // Value::Sint8, Uint8, Ascii + b'b' | b'B' | b'c' | 0 | b'?' => 1, + + // Value::Sint16, Uint16 + b's' | b'S' => 2, + + // Value::Sint32, Sint32, Uint32, FLoat32, Qint32, FourCC + b'l' | b'L' | b'f' | b'q' | b'F' => 4, + + // Value::Float64, Sint64, Uint64, Qint64 + b'd' | b'j' | b'J' | b'Q' => 8, + + // Value::Datetime, Uuid + b'U' | b'G' => 16, + + // nested data/0 (no direct values to parse), or unknown types + _ => 1, + } + } + + /// Returns data size in bytes for the data the header precedes/describes. + /// `aligned = true` returns 32-bit aligned/padded size. + pub fn size(&self, aligned: bool) -> u32 { + let size = self.basesize as u32 * self.repeats as u32; + match aligned { + true => size + self.pad as u32, + false => size, + } + } +} \ No newline at end of file diff --git a/src/gpmf/mod.rs b/src/gpmf/mod.rs new file mode 100644 index 0000000..ecc74a2 --- /dev/null +++ b/src/gpmf/mod.rs @@ -0,0 +1,15 @@ +//! GoPro GPMF data format core structs and methods. + +pub mod gpmf; +pub mod fourcc; +pub mod header; +pub mod stream; +pub mod timestamp; +pub mod value; + +pub use gpmf::Gpmf; +pub use fourcc::FourCC; +pub use stream::{Stream, StreamType}; +pub use timestamp::Timestamp; +pub use value::Value; +pub use header::Header; \ No newline at end of file diff --git a/src/gpmf/stream.rs b/src/gpmf/stream.rs new file mode 100644 index 0000000..6781641 --- /dev/null +++ b/src/gpmf/stream.rs @@ -0,0 +1,525 @@ +//! Core data structure for GPMF streams, containing either more streams or raw values. + +use std::io::{Cursor, Seek, SeekFrom}; + +use crate::{DataType, GpmfError, gopro::Dvid}; +use super::{FourCC, Header, Value, Timestamp}; + +/// Core struct that preserves the GPMF structure. +/// Contains either more `Stream`s, +/// i.e. a container/nested stream (`Header.basetype == 0`), +/// or data values. +#[derive(Debug, Clone, PartialEq)] +pub struct Stream { + pub header: Header, + pub streams: StreamType, + pub time: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum StreamType { + /// Container (Header::basetype = 0). Contains more `Stream`s. + Nested(Box>), + /// Terminal `Stream` nodes. Contain data. + Values(Vec), +} + +impl Stream { + /// Parse GPMF chunk corresponding to one or more `Stream`s recursively. + /// For GoPro MP4-files use `Stream::compile()` instead. + /// + /// For GPMF data extracted with e.g. FFmpeg, the cursor will initially + /// correspond to the full data load (i.e. all `DEVC` containers), + /// whereas for MP4 files it will initially correspond to the number + /// of `DEVC` containers at a specific byte offeset in the MP4. + /// + /// `read_limit` is used to avoid embeddning nested streams/containters that + /// follow directly after each other into other nested streams. + /// Since `Stream::new()` calls itself recusively each container will otherwise + /// emded the next until end of cursor. + // pub fn new(cursor: &mut Cursor>, read_limit: Option) -> Result, GpmfError> { + pub fn new( + cursor: &mut Cursor>, + read_limit: Option, + debug: bool + ) -> Result, GpmfError> { + + // Type definitions (BaseType::TYPE) for BaseType::COMPLEX. 'TYPE' must precede 'COMPLEX', + // e.g. for TYPE: "cF" -> BaseType::COMPLEX(Vec = None; + + let mut streams: Vec = Vec::new(); + + // End position in bytes, with optional read limit equal to current + // cursor position + set limit value. To avoid recusively + // embedding containers that follow directly after each other. + let len = match read_limit { + Some(l) => cursor.position() as usize + l, + None => cursor.get_ref().len() + }; + + // TODO check if read limit should substract header size above or below? works any way right now... + + while cursor.position() < len as u64 { + // 8 byte header + let header = Header::new(cursor)?; + + if debug { + println!("@{} {header:3?} | LEN: {}", cursor.position(), header.size(true) + 8); // position is only offset from start of current DEVC, not entire MP4 + } + + // `FourCC::Invalid` currently a check for `&[0,0,0,0, ...]` + // to be able to break loops in GPMF streams that end with 0-padding. + // So far specific to GPMF streams in MP4 `udta` atoms. + // This will only occur for the final stream/DEVC in udta, + // so the `Header.pad` value is not needed. + if header.is_invalid() { + break + } + + let pad = header.pad; + + match header.basetype { + // 0 = Container/nested stream + 0 => { + // Create new stream after header offset if GPMF chunk is a container. + // Set byte read limit to avoid embedding containers/0 in each other indefinitely if they + // follow directly after each other. + // let stream = Self::new(cursor, Some(header.size(false) as usize))?; + let stream = Self::new(cursor, Some(header.size(false) as usize), debug)?; + + streams.push(Self{ + header, + streams: StreamType::Nested(Box::new(stream)), + time: None + }) + }, + + // Anything else will contain data values + _ => { + let mut values: Vec = Vec::new(); + + // Parse Values in stream. + // First check if stream has no data, + // to avoid parsing further. + if header.basesize * header.repeats == 0 { + values.push(Value::Empty) + } else { + for _ in 0..header.repeats { + values.push(Value::new(cursor, &header, complex.as_deref())?); + } + } + + // Check for complex type definitions + if header.fourcc == FourCC::TYPE { + // TYPE contains a single string for complex types with GPMF type definitions, + // e.g. "bLL" denotes that there are three single values `i8`, `u32`, `u32`. + complex = values.first().and_then(|v| v.into()); + } + + streams.push(Self{ + header, + streams: StreamType::Values(values), + time: None + }) + + } + } + + // Seek forward equal to header padding value (0-3 bytes), + // past 0 padding, for 32-bit alignment. + cursor.seek(SeekFrom::Current(pad as i64))?; + } + + Ok(streams) + } + + /// Set relative timestamp for GPMF stream. + pub fn set_time(&mut self, time: &Timestamp) { + self.time = Some(time.to_owned()); + } + + /// Returns original size in bytes, before being parsed, for current `Stream`. + /// `aligned = true` returns the size including 32-bit alignment padding. + /// Not all streams are padded. + pub fn size(&self, aligned: bool) -> u32 { + self.header.size(aligned) + } + + /// Return number of linked `Stream`s (if a nested stream), + /// or number of `Value`s (if a terminal stream, i.e. contains data). + pub fn len(&self) -> usize { + // or Option? + match &self.streams { + StreamType::Nested(s) => s.len(), + StreamType::Values(v) => v.len(), + } + } + + /// Depending on `StreamType` type. For `StreamType::Nested` check if `Stream` links further + /// `Stream`s, i.e. if `Stream` has any child nodes. + /// For `StreamType::Values` check if any data is contained. + pub fn is_empty(&self) -> bool { + match &self.streams { + StreamType::Nested(s) => s.is_empty(), + StreamType::Values(v) => v.is_empty(), + } + } + + /// Returns Four CC for stream. + pub fn fourcc(&self) -> &FourCC { + &self.header.fourcc + } + + /// Returns `true` if specified Four CC + /// matches that of current `Stream`. + pub fn has_fourcc(&self, fourcc: &FourCC) -> bool { + self.fourcc() == fourcc + } + + /// Returns values for current `Stream`, if present. + /// `None` is returned if `StreamType::Nested`. + pub fn values(&self) -> Option> { + match &self.streams { + StreamType::Nested(_) => None, + StreamType::Values(v) => Some(v.to_owned()), + } + } + + /// Returns first `Value` in a terminal stream (i.e. contains values), + /// `None` is returned if the stream is not a terminal node (i.e. contains more streams). + pub fn first_value(&self) -> Option<&Value> { + match &self.streams { + StreamType::Values(v) => v.first(), + _ => None, + } + } + + /// Returns last `Value` in a terminal stream (i.e. contains values), + /// `None` is returned if the stream is not a terminal node (i.e. contains more streams). + pub fn last_value(&self) -> Option<&Value> { + match &self.streams { + StreamType::Values(v) => v.last(), + _ => None, + } + } + + /// Convenience method that iterates over numerical values in a terminal stream + /// (i.e. contains values) and casts these as `Vec>`, + /// where each "inner" `Vec` represents the values wrapped by a single `Value`. + /// + /// For cases where `Value` is known to wrap multiple values. + /// + /// `None` is returned if the stream is not a terminal node, or if the + /// `Value` does not wrap numerical data. + pub fn to_vec_f64(&self) -> Option>> { + match &self.streams { + StreamType::Nested(_) => None, + StreamType::Values(v) => v.iter() + .map(|b| b.into()) + .collect(), + } + } + + /// Convenience method that iterates over numerical values in a terminal stream + /// (i.e. contains values) and casts these as `Vec`, + /// + /// For cases where `Value` is known to only wrap a single value. + /// + /// `None` is returned if the stream is not a terminal node, or if the + /// `Value` does not wrap numerical data. + pub fn to_f64(&self) -> Option> { + match &self.streams { + StreamType::Nested(_) => None, + StreamType::Values(v) => v.iter() + .map(|b| b.into()) + .collect(), + } + } + + /// Returns first `Stream` in a nested stream (i.e. contains more streams), + /// `None` is returned if the stream is a terminal node (i.e. contains values). + pub fn first_stream(&self) -> Option<&Stream> { + match &self.streams { + StreamType::Nested(s) => s.first(), + _ => None, + } + } + + /// Returns last `Stream` in a nested stream (i.e. contains more streams), + /// `None` is returned if the stream is a terminal node (i.e. contains values). + pub fn last_stream(&self) -> Option<&Stream> { + match &self.streams { + StreamType::Nested(s) => s.last(), + _ => None, + } + } + + // /// Returns the duration in milliseconds for the current stream if set. + // /// For a more accurate media source/s duration, use `Gpmf::duration()`. + // pub fn duration(&self) -> Option { + // Some(self.time.to_owned()?.relative) + // } + + pub fn is_nested(&self) -> bool { + matches!(self.streams, StreamType::Nested(_)) + } + + /// Find first stream with specified FourCC. + /// Matches self and direct decendants. + pub fn find(&self, fourcc: &FourCC) -> Option<&Self> { + match self.has_fourcc(fourcc) { + true => return Some(&self), + false => { + match &self.streams { + StreamType::Nested(streams) => { + streams.iter() + .find(|st| st.has_fourcc(fourcc)) + } + StreamType::Values(_) => None + } + } + } + } + + // /// Find all stream with specified FourCC. Matches current stream + // /// and direct decendants. + // /// If current stream matches, a single `Stream` will be returned. + // pub fn find_all(&self, fourcc: &FourCC) -> Vec { + // match self.has_fourcc(fourcc) { + // true => return vec![self.to_owned()], + // false => { + // if let StreamType::Nested(streams) = &self.streams { + // streams.iter() + // .filter(|st| st.has_fourcc(fourcc)) + // .cloned() + // .collect::>() + // } else { + // Vec::new() + // } + // } + // } + // } + // pub fn find_all(&self, fourcc: &FourCC, recursive: bool) -> Vec { + + /// Find all stream with specified FourCC. Matches current stream + /// and direct decendants. + pub fn find_all(&self, fourcc: &FourCC) -> Vec { + let mut streams: Vec = Vec::new(); + match &self.streams { + StreamType::Values(_) => return Vec::new(), + StreamType::Nested(strms) => { + streams.extend( + strms.iter() + // .inspect(|s| println!("{:?}", s.fourcc())) + .filter(|s| s.has_fourcc(fourcc)) + .cloned() + .collect::>() + ) + // // Check self for match + // if self.has_fourcc(fourcc) { + // // return Some(self) + // streams.push(self.to_owned()); + // // println!("SELF CHECK, PUSHED {fourcc:?}"); + // } + + // // Check child nodes for match, optionally recursive + // for stream in strms.iter() { + // if stream.has_fourcc(fourcc) { + // // return Some(stream) + // streams.push(stream.to_owned()); + // // println!("ITER CHECK, PUSHED {fourcc:?}"); + // } + + // // Check child nodes an additional level down inside loop for a recursive search + // // if recursive { + // // match &stream.find(fourcc, recursive) { + // // Some(s) => return Some(s.to_owned()), + // // None => () + // // } + // // } + // } + + // Return None if all child nodes are exhausted with no match + // None + } + } + streams + } + + pub fn find_all2(&self, fourcc: &FourCC) -> Vec { + match &self.streams { + StreamType::Values(_) => vec![], + StreamType::Nested(streams) => { + streams.iter() + // .inspect(|s| println!("{:?}", s.fourcc())) + .filter(|s| s.has_fourcc(fourcc)) + .cloned() + .collect() + } + } + } + + /// Returns the human redable name of the stream + /// if it is a `STRM`, (stored as string in `STNM`), + /// otherwise `None` is returned. + pub fn name(&self) -> Option { + self.find(&FourCC::STNM) + .and_then(|strm| strm.first_value()) + .and_then(|val| val.into()) + } + + /// Returns duration relative to GPMF start if set. + /// Should be close to video position. + /// + /// > **Note:** All `Stream`s have timestamps derived from + /// the original MP4 (at the `DEVC` container level). + /// The current, official GPMF specification + /// does not implement logging time stamps for individual data points. + /// Thus, GPMF data extracted via e.g. `ffmpeg` or in the MP4 `udta` atom + /// will not and can not have timestamps. + pub fn time_relative(&self) -> Option { + Some(self.time.as_ref()?.to_relative()) + } + + /// Returns duration for current GPMF chunk if set. + /// + /// > **Note:** All `Stream`s have timestamps derived from + /// the original MP4 (at the `DEVC` container level). + /// The current, official GPMF specification + /// does not implement logging time stamps for individual data points. + /// Thus, GPMF data extracted via e.g. `ffmpeg` or in the MP4 `udta` atom + /// will not and can not have timestamps. + pub fn time_duration(&self) -> Option { + Some(self.time.as_ref()?.to_duration()) + } + + pub fn time_duration_ms(&self) -> Option { + Some(self.time.as_ref()?.to_duration()) + } + + /// Find first stream with specified `DataType`. + pub fn filter(&self, content_type: &DataType) -> Vec { + match &self.streams { + StreamType::Values(_) => Vec::new(), // better way? + StreamType::Nested(streams) => { + streams.iter() + .filter_map(|s| { + if s.name() == Some(content_type.to_str().to_owned()) { + Some(Stream { + // Inherit timestamp from parent (only present in DEVC containers), + // since this is otherwise lost. + time: self.time.to_owned(), + ..s.to_owned() + }) + } else { + None + } + }) + .collect() + } + } + } + + /// Find all streams with specified `DataType`. + pub fn filter_all(&self, content_type: &DataType, recursive: bool) -> Option<&Self> { + match &self.streams { + StreamType::Values(_) => return None, + StreamType::Nested(streams) => { + // Check self for match + if self.name().as_deref() == Some(content_type.to_str()) { + return Some(self); + } + + // Check child nodes for match, optionally recursive + for stream in streams.iter() { + if self.name().as_deref() == Some(content_type.to_str()) { + return Some(stream); + } + + // Check child nodes an additional level down inside loop for a recursive search + if recursive { + match &stream.filter_all(content_type, recursive) { + Some(s) => return Some(s.to_owned()), + None => (), + } + } + } + + // Return None if all child nodes are exhausted with no match + None + } + } + } + + /// Returns Device ID (`DVID` stream) if input Stream is a `DEVC` container, or `DVID` stream. + /// If you want to search for Device ID starting from an arbitrary `Stream`, try + /// `Stream::find(&FourCC::DVID)` instead. + pub fn device_id(&self) -> Option { + match &self.fourcc() { + FourCC::DVID => { + self.first_value() + .and_then(|b| b.into()) + // .and_then(|b| b.to_owned().into()) + } + FourCC::DEVC => { + self.find(&FourCC::DVID) + .and_then(|f| f.first_value()) + .and_then(|b| b.into()) + // .and_then(|b| b.to_owned().into()) + } + _ => None, + } + } + + /// Returns Device Name (`DVNM` stream) if input Stream is a `DEVC` container, or `DVNM` stream. + /// If you want to search for Device Name starting from an arbitrary `Stream`, try + /// `Stream::find(&FourCC::DVNM)` instead. + pub fn device_name(&self) -> Option { + match &self.fourcc() { + FourCC::DVNM => { + self.first_value() + .and_then(|b| b.into()) + } + FourCC::DEVC => { + self.find(&FourCC::DVNM) + .and_then(|f| f.first_value()) + .and_then(|b| b.into()) + } + _ => None, + } + } + + /// Print `Stream` contents. + pub fn print(&self, count: Option, size: Option) { + let cnt = count.unwrap_or(1); + let sz = size.unwrap_or(self.len()); + + let prefix = match &self.fourcc() { + FourCC::DEVC => { + format!("[{}/{}] ", cnt, sz) + } + FourCC::DVID | FourCC::DVNM | FourCC::STRM => " ".to_owned(), + _ => " ".to_owned(), + }; + + println!( + "{}{}, NAME: {:?}, TIME: {:?}", + prefix, + &self.header, + &self.name(), + &self.time + ); + + match &self.streams { + StreamType::Nested(stream) => { + stream.iter().for_each(|s| s.print(Some(cnt), Some(sz))) + } + StreamType::Values(values) => { + for v in values.iter() { + println!(" {:?}", v.debug()) + } + } + } + } +} \ No newline at end of file diff --git a/src/gpmf/timestamp.rs b/src/gpmf/timestamp.rs new file mode 100644 index 0000000..f5cf073 --- /dev/null +++ b/src/gpmf/timestamp.rs @@ -0,0 +1,70 @@ +//! Convenience structure for dealing with relative timestamps. + +use time; + +#[derive(Debug, Clone, Default, PartialEq, Eq, PartialOrd)] +/// Timestamp containing relative time in milliseconds from +/// video start and the "duration" (i.e. time until write of next GPMF chunk) +/// of the DEVC the current stream belongs to. +pub struct Timestamp { + /// Duration in milliseconds from video start. + pub relative: u32, + /// Duration in milliseconds for the `DEVC` + /// the current stream belongs to. + pub duration: u32, +} + +impl Ord for Timestamp { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + if self.relative > other.relative { + return std::cmp::Ordering::Greater + } + if self.relative < other.relative { + return std::cmp::Ordering::Less + } + std::cmp::Ordering::Equal + } +} + +impl Timestamp { + /// New Timestamp. `relative` equals time in milliseconds + /// from video start time, + /// `duration` equals "sample duration" in milliseconds + /// for the `Stream` it is attached to. + pub fn new(relative: u32, duration: u32) -> Self { + Timestamp{ + relative, + duration + } + } + + /// Returns `Timestamp.relative` (relative to video start) + /// as `time::Duration`. + pub fn to_relative(&self) -> time::Duration { + time::Duration::milliseconds(self.relative as i64) + } + + /// Returns `Timestamp.duration` (duration of current DEVC chunk) + /// as `time::Duration`. + pub fn to_duration(&self) -> time::Duration { + time::Duration::milliseconds(self.duration as i64) + } + + /// Adds one `Timestamp` to another and returns the resulting `Timestamp`. + /// Only modifies the `relative` field. + pub fn add(&self, timestamp: &Self) -> Self { + Self { + relative: self.relative + timestamp.relative, + ..self.to_owned() + } + } + + /// Substracts one `Timestamp` from another and returns the resulting `Timestamp`. + /// Only modifies the `relative` field. + pub fn sub(&self, timestamp: &Self) -> Self { + Self { + relative: self.relative - timestamp.relative, + ..self.to_owned() + } + } +} \ No newline at end of file diff --git a/src/gpmf/value.rs b/src/gpmf/value.rs new file mode 100644 index 0000000..3d799ed --- /dev/null +++ b/src/gpmf/value.rs @@ -0,0 +1,369 @@ +//! Core structure for GPMF raw data values. + +use std::io::Cursor; + +use binread::{BinReaderExt, BinResult, BinRead}; +use time::{PrimitiveDateTime, format_description}; + +use super::Header; +use crate::{GpmfError, gopro::Dvid, FourCC}; + +/// The core data type/wrapper for GPMF data types. +/// `Vec` was chosen as inner value over `T` for (possibly misguided) +/// performance and boiler plate reasons. +/// +/// A single GPMF stream may contain multiple `Value`s, i.e. `Vec`, +/// which is basically a `Vec>`. +/// +/// Type descriptions are edited versions of GoPro's own, see . +/// +/// Notes: +/// - Type `35`/`#` contains "Huffman compression STRM payloads. 4-CC is compressed as 4-CC '#' " (see above GitHub repo). +/// It is currently parsed into `Vec`, but no further decoding or processing is implemented. +/// - Strings except `Value::Utf8()` variant map to ISO8859-1 as a single-byte (0-255) extension of ascii. I.e. `String::from_utf8(Vec)` would be incorrect or fail for values above 127. See . `u8 as char` is used as a workaround to produce valid UTF-8 string. +/// +/// For the original C source, see: +/// +#[derive(Debug, Clone, PartialEq)] +pub enum Value { + /// Ascii: c/99, single byte 'c' style ASCII character string, char. + /// Optionally NULL terminated. Size/repeat sets the length. + /// NOTE: Strings are defined as ISO8859-1, single-byte "extended ascii", 0-255, + /// which means `String::from_utf8(Vec)` will fail for values above 127. + /// The actual decimal value can be used as `212_u8 as char`. + /// See + String(String), + /// F/70, 32-bit four character key -- FourCC, char fourcc[4] + FourCC(String), + /// G/71, 128-bit ID (like UUID), uint8_t guid[16] + Uuid(Vec), + /// u/117, UTF-8 formatted text string. + /// As the character storage size varies, the size is in bytes, not UTF characters. + Utf8(String), + /// U/85, UTC Date and Time string, char utcdate[16]. + /// Date + UTC Time format yymmddhhmmss.sss - (years 20xx covered) + /// e.g., "181007105554.644" + Datetime(String), + /// b/98, single byte signed integer, int8_t, -128 to 127 + Sint8(Vec), + /// B/66, single byte unsigned integer, uint8_t 0 to 255 + Uint8(Vec), + /// s/115, 16-bit signed integer, int16_t, -32768 to 32768 + Sint16(Vec), + /// S/83, 16-bit unsigned integer, uint16_t, 0 to 65536 + Uint16(Vec), + /// l/108, 32-bit signed integer, int32_t + Sint32(Vec), + /// L/76, 32-bit unsigned integer, uint32_t + Uint32(Vec), + /// f/102, 32-bit float (IEEE 754), float + Float32(Vec), + // https://stackoverflow.com/questions/8638792/how-to-convert-packed-integer-16-16-fixed-point-to-float + // https://en.wikipedia.org/wiki/Q_(number_format) + /// q/113, 32-bit Q Number Q15.16, uint32_t, + /// 16-bit integer (A) with 16-bit fixed point (B) for A.B value. + Qint32(Vec), + /// j/106, 64-bit signed number, int64_t + Sint64(Vec), + /// J/74, 64-bit unsigned number, uint64_t + Uint64(Vec), + /// d/100, 64-bit double precision (IEEE 754), double + Float64(Vec), + /// Q/81, 64-bit Q Number Q31.32, uint64_t, + /// 32-bit integer (A) with 32-bit fixed point (B) for A.B value. + Qint64(Vec), + /// ?/63, data structure is complex, meaning it contains other `Value`s. + /// Defined by a preceding message with fourcc TYPE. + /// Nesting is never deeper than one level, + /// i.e. a complex type will never contain another complex type. + Complex(Vec>), + /// #/35 Huffman compression STRM payloads. + /// `4-CC ` is compressed as + /// `4-CC '#' `. + Compressed(Vec), + /// 0/null, Nested metadata/container, e.g. DEVC, STRM. + Nested, + /// Empty message, e.g. when repeats or basesize is 0 + Empty, + /// For erratic/corrupted data values that could not be read + Invalid, +} + +impl Value { + /// Reads repeated data type `T` in Big Endian (all GPMF data is BE), + /// into `Vec`. + /// + /// With e.g. `Header::basetype = 76/L` (`u32`) we may have: + /// - `Header::basesize = 12` (in bytes) + /// - `std::mem::size_of::() = 4` + /// + /// This yields `12 / std::mem::size_of::() = 3`, + /// i.e. one array with 3 `u32` values: `[1_u32, 2_u32, 3_u32]` + fn read(cursor: &mut Cursor>, header: &Header) -> BinResult> { + // Determine number of values, via type mem size. + // May miss values or ignore insufficient data error + // if integer division results in remainder? + let size = std::mem::size_of::(); + let range = 0..header.basesize as usize / size; + + // Below fails with + // "BinReadError(Io(Error { kind: UnexpectedEof, message: "failed to fill whole buffer" })" + // indicates corrupt data or implementation error? + // IMPL ERROR header.repeats contain weird values... + // let range = 0 .. header.repeats as usize; + + // println!("RANGE {range:?}"); + + range.into_iter() + .map(|_| cursor.read_be::()) + .collect() + } + + // fn type_name(&self) -> &str { + // // std::any::type_name() + // // std::any::type_name_of_val(val) + + // "" + // } + + /// Reads and maps ISO8859-1 single-byte values to a UTF-8 string. + /// Ignores `null` characters. + fn from_iso8859_1(cursor: &mut Cursor>, header: &Header) -> Result { + let bytes = Self::read::(cursor, header)?; + Ok(bytes.iter() + .filter_map(|c| if c != &0 { Some(*c as char) } else { None }) + .collect()) + } + + /// Read and map byte values to a string if these correspond to valid UTF-8. + fn from_utf8(cursor: &mut Cursor>, header: &Header) -> Result { + let bytes = Self::read::(cursor, header)?; + String::from_utf8(bytes).map_err(|e| e.into()) + } + + /// Generates new `Value` enum from cursor. Supports complex types. + /// + /// > IMPORTANT: For GPMF/MP4 32-alignment is necessary. This has + /// > to be done as the final step per GPMF stream (`Stream`) + /// > outside of this method, since a single GPMF stream may translate + /// > into multiple `Value` enums. Byte streams corresponding to multiple + /// > `Value` enums in direct sucession are not 32-bit aligned. + /// > The cursor position must always be 32-aligned before + /// > reading GPMF data into another `Stream`, but NOT + /// > between reading multiple `Value`s inside the same `Stream`. + pub(crate) fn new( + cursor: &mut Cursor>, + header: &Header, + complextype: Option<&str> + ) -> Result { + + let values = match header.basetype { + b'b' => Self::Sint8(Self::read::(cursor, header)?), + b'B' => Self::Uint8(Self::read::(cursor, header)?), + b's' => Self::Sint16(Self::read::(cursor, header)?), + b'S' => Self::Uint16(Self::read::(cursor, header)?), + b'l' => Self::Sint32(Self::read::(cursor, header)?), + b'L' => Self::Uint32(Self::read::(cursor, header)?), + b'f' => Self::Float32(Self::read::(cursor, header)?), + b'd' => Self::Float64(Self::read::(cursor, header)?), + b'j' => Self::Sint64(Self::read::(cursor, header)?), + b'J' => Self::Uint64(Self::read::(cursor, header)?), + b'q' => Self::Qint32(Self::read::(cursor, header)?), + b'Q' => Self::Qint64(Self::read::(cursor, header)?), + // NOTE: 'String's are defined as ISO8859-1: i.e. single-byte "extended ascii", 0-255, + // which means `String::from_utf8(Vec)` fails for values above 127. + // The actual int/decimal values are the same however so using `u8 as char` works. + b'c' => Self::String(Self::from_iso8859_1(cursor, header)?), + // FOURCC has total len 4, assert? + b'F' => Self::FourCC(Self::from_iso8859_1(cursor, header)?), + // Explicit UTF8 string so must validate. + b'u' => Self::Utf8(Self::from_utf8(cursor, header)?), + // DATETIME has total len 16 (ascii), assert? + b'U' => Self::Datetime(Self::from_iso8859_1(cursor, header)?), + // UUID has total len 16, uint8_t, [u8; 16] + b'G' => Self::Uuid(Self::read::(cursor, header)?), + // Huffman compression STRM payloads (just raw data, no decode) + b'#' => Self::Compressed(Self::read::(cursor, header)?), + // Complex basetype, a dynamic, composite basetype that combines other basetypes. + b'?' => { + // Assumed that complex type contains a single ASCII basetype value/char + // for each basetype specified. That is, basesize should always be equal + // to bytesize of type, e.g. 4 for 32-bit value. + // Should only go one recursive level deep. + if let Some(types) = complextype { + + // let complex: Result>, GpmfError> = types.as_bytes().iter() + // .map(|t| Self::new(cursor, &header.complex(t), None)) + // .map(|v| Box::new(v)) + // .collect(); + + let mut complex: Vec> = Vec::new(); + + for t in types.as_bytes().iter() { + // Convert header with type `?` to header with specific type. + let hdr = header.convert(t); + + let value = Self::new(cursor, &hdr, None)?; + + complex.push(Box::new(value)); + } + + Self::Complex(complex) + } else { + return Err(GpmfError::MissingComplexType) + } + }, + // 0, NULL, containers (DEVC, STRM) + 0 => Self::Nested, + b => return Err(GpmfError::UnknownBaseType(b)), + }; + + Ok(values) + } + + pub fn debug(&self) -> &dyn std::fmt::Debug { + match self { + Self::String(v) => v, + Self::Utf8(v) => v, + Self::FourCC(v) => v, + Self::Uuid(v) => v, + Self::Datetime(v) => v, + Self::Sint8(v) => v, + Self::Uint8(v) => v, + Self::Sint16(v) => v, + Self::Uint16(v) => v, + Self::Sint32(v) => v, + Self::Uint32(v) => v, + Self::Float32(v) => v, + Self::Qint32(v) => v, + Self::Sint64(v) => v, + Self::Uint64(v) => v, + Self::Float64(v) => v, + Self::Qint64(v) => v, + // Self::Complex(c) => c.iter().map(|b| b.value()), + Self::Complex(c) => c, + Self::Compressed(c) => c, + v @ Self::Nested => v, + v @ Self::Invalid => v, + v @ Self::Empty => v, + } + } +} + +// Conversions +impl AsRef for Value { + fn as_ref(&self) -> &Self { + self + } +} + +impl Into> for &Value { + fn into(self) -> Option { + match self { + Value::String(s) + | Value::Datetime(s) + | Value::FourCC(s) + | Value::Utf8(s) => Some(s.to_owned()), + _ => None, + } + } +} + +impl Into> for &Value { + fn into(self) -> Option { + match self { + Value::Uint32(d) => Some(Dvid::Uint32(d.first().map(|v| *v)?)), + Value::FourCC(d) => Some(Dvid::FourCC(FourCC::from_str(d))), + _ => None, + } + } +} + +impl Into> for &Value { + fn into(self) -> Option { + match self { + Value::Datetime(dt) => { + // 'time' crate does not parse two-digit years for ambiguity reasons. + // See: https://github.com/time-rs/time/discussions/459 + // GPMF only covers years 2000+ so prefixing datetime string with "20" + // before parse should be ok. + // e.g. "181007105554.644" -> "20181007105554.644" + let format = format_description::parse( + "[year][month][day][hour][minute][second].[subsecond]" + ).ok()?; + PrimitiveDateTime::parse(&format!("20{dt}"), &format).ok() + } + _ => None, + } + } +} + +impl Into> for &Value { + fn into(self) -> Option { + match self { + Value::Uint16(n) => n.first().cloned(), + _ => None + } + } +} + +impl Into> for &Value { + fn into(self) -> Option { + match self { + Value::Uint32(n) => n.first().cloned(), + _ => None + } + } +} + +impl Into>> for &Value { + fn into(self) -> Option> { + match self { + Value::Sint8(n) => Some(n.iter().map(|v| f64::from(*v)).collect()), + Value::Uint8(n) => Some(n.iter().map(|v| f64::from(*v)).collect()), + Value::Sint16(n) => Some(n.iter().map(|v| f64::from(*v)).collect()), + Value::Uint16(n) => Some(n.iter().map(|v| f64::from(*v)).collect()), + Value::Sint32(n) => Some(n.iter().map(|v| f64::from(*v)).collect()), + Value::Uint32(n) => Some(n.iter().map(|v| f64::from(*v)).collect()), + Value::Float32(n) => Some(n.iter().map(|v| f64::from(*v)).collect()), + Value::Uint64(n) => Some(n.iter().map(|v| *v as f64).collect::>()), + Value::Sint64(n) => Some(n.iter().map(|v| *v as f64).collect::>()), + Value::Float64(n) => Some(n.to_owned()), + Value::Qint32(n) => { + // Q15.16 format -> div by 2^15 + Some(n.iter() + .map(|v| *v as f64 / (2_u16).pow(15) as f64) + .collect::>()) + } + Value::Qint64(n) => { + // Q31.32 format -> div by 2^31 + Some(n.iter() + .map(|v| *v as f64 / (2_u32).pow(31) as f64) + .collect::>()) + } + _ => None, + } + } +} + +impl From<&Value> for Option { + fn from(value: &Value) -> Option { + match value { + Value::Sint8(n) => n.first().map(|v| f64::from(*v)), + Value::Uint8(n) => n.first().map(|v| f64::from(*v)), + Value::Sint16(n) => n.first().map(|v| f64::from(*v)), + Value::Uint16(n) => n.first().map(|v| f64::from(*v)), + Value::Sint32(n) => n.first().map(|v| f64::from(*v)), + Value::Uint32(n) => n.first().map(|v| f64::from(*v)), + Value::Float32(n) => n.first().map(|v| f64::from(*v)), + Value::Uint64(n) => n.first().map(|v| *v as f64), + Value::Sint64(n) => n.first().map(|v| *v as f64), + Value::Float64(n) => n.first().cloned(), + // Q15.16 format -> div by 2^15 + Value::Qint32(n) => n.first().map(|v| *v as f64 / (2_u16).pow(15) as f64), + // Q31.32 format -> div by 2^31 + Value::Qint64(n) => n.first().map(|v| *v as f64 / (2_u32).pow(31) as f64), + _ => None, + } + } +} \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..af6296e --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,35 @@ +//! Parse GoPro GPMF data. Returned in unprocessed form for most data types. +//! Processing of GPS data is supported, +//! whereas processing of sensor data into more common forms will be added gradually. +//! +//! ```rs +//! use gpmf_rs::Gpmf; +//! use std::path::Path; +//! +//! fn main() -> std::io::Result<()> { +//! let path = Path::new("GOPRO_VIDEO.MP4"); +//! let gpmf = Gpmf::new(&path)?; +//! Ok(()) +//! } +//! ``` + +pub mod gpmf; +pub (crate) mod files; +mod errors; +mod content_types; +mod gopro; +mod geo; + +pub use gpmf::{ + Gpmf, + FourCC, + Stream, + StreamType, + Timestamp +}; +pub use content_types::{DataType,Gps, GoProPoint}; +pub use content_types::sensor::{SensorData, SensorType}; +pub use errors::GpmfError; +pub use gopro::GoProFile; +pub use gopro::GoProSession; +pub use gopro::DeviceName;