1#![allow(
10 clippy::many_single_char_names,
11 clippy::similar_names,
12 clippy::cast_possible_truncation,
13 clippy::doc_markdown
14)] use std::fmt;
17use std::str::FromStr;
18
19use crate::Error;
20use crate::bmff::{Mp4Info, Track};
21
22crate::named_enum!(
23 PictureProfile {
25 Rec709 = "rec709",
27 Dlog = "dji-dlog",
29 DlogM = "dji-dlog-m",
31 Dlog2 = "dji-dlog2",
33 Hlg = "hlg",
35 Pq = "pq",
37 CLog3 = "clog3",
39 AppleLog = "apple-log",
41 Unknown = "unknown",
42 }
43);
44
45crate::named_enum!(
46 Confidence {
48 High = "high",
50 Medium = "medium",
52 Low = "low",
54 }
55);
56
57impl PictureProfile {
58 pub const fn is_log(self) -> bool {
60 matches!(
61 self,
62 Self::Dlog | Self::DlogM | Self::Dlog2 | Self::CLog3 | Self::AppleLog
63 )
64 }
65
66 pub const fn is_hdr(self) -> bool {
68 matches!(self, Self::Hlg | Self::Pq)
69 }
70}
71
72#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct ProfileGuess {
75 pub profile: PictureProfile,
76 pub confidence: Confidence,
77 pub dolby_vision: bool,
79 pub evidence: String,
81}
82
83impl ProfileGuess {
84 fn new(profile: PictureProfile, confidence: Confidence, evidence: impl Into<String>) -> Self {
85 Self {
86 profile,
87 confidence,
88 dolby_vision: false,
89 evidence: evidence.into(),
90 }
91 }
92}
93
94const TRC_PQ: u16 = 16;
96const TRC_HLG: u16 = 18;
97const PRIM_BT2020: u16 = 9;
98
99pub fn detect(info: &Mp4Info) -> ProfileGuess {
101 let Some(v) = info.video() else {
102 return ProfileGuess::new(PictureProfile::Unknown, Confidence::Low, "no video track");
103 };
104 let mut g = detect_video(info, v);
105 g.dolby_vision = v.dolby_vision;
106 g
107}
108
109fn detect_video(info: &Mp4Info, v: &Track) -> ProfileGuess {
110 use Confidence::{High, Low, Medium};
111 use PictureProfile as P;
112 if let Some(c) = v.colr {
113 match c.transfer {
114 TRC_HLG => return ProfileGuess::new(P::Hlg, High, "colr transfer 18 (arib-std-b67)"),
115 TRC_PQ => return ProfileGuess::new(P::Pq, High, "colr transfer 16 (smpte2084)"),
116 _ => {}
117 }
118 }
119 if let Some(meta) = &info.dji_meta {
120 return match dji_color_mode(meta) {
121 Some(2) => dji_log(info, meta),
122 Some(1) => {
123 ProfileGuess::new(P::Hlg, Medium, "dji cam-meta color mode 1 (HLG, untagged)")
124 }
125 None | Some(0) => {
126 ProfileGuess::new(P::Rec709, Medium, "dji cam-meta: no color mode (Normal)")
127 }
128 Some(n) => ProfileGuess::new(
129 P::Unknown,
130 Low,
131 format!("dji cam-meta color mode {n} (unmapped)"),
132 ),
133 };
134 }
135 let model = info.model().unwrap_or_default();
136 let depth = v.bit_depth.unwrap_or(8);
137 if model.contains("iPhone") || info.keys.keys().any(|k| k.starts_with("com.apple.")) {
138 if info
139 .keys
140 .iter()
141 .any(|(k, val)| has_apple_log(k) || has_apple_log(val))
142 {
143 return ProfileGuess::new(P::AppleLog, High, "apple metadata names Apple Log");
144 }
145 if let Some(c) = v.colr
146 && c.primaries == PRIM_BT2020
147 && !matches!(c.transfer, 1 | 6 | 14 | 15)
148 {
149 return ProfileGuess::new(
150 P::AppleLog,
151 Medium,
152 format!("iphone bt2020 with non-standard transfer {}", c.transfer),
153 );
154 }
155 return ProfileGuess::new(P::Rec709, Medium, "iphone SDR tags");
156 }
157 if model.starts_with("Canon") || info.udta.get("manu").is_some_and(|m| m == "Canon") {
158 if depth >= 10 {
159 return ProfileGuess::new(P::CLog3, Medium, "canon 10-bit without an HDR tag");
160 }
161 return ProfileGuess::new(P::Rec709, High, "canon 8-bit (log needs 10-bit)");
162 }
163 match v.colr {
164 Some(c) if depth == 8 && c.transfer == 1 => {
165 ProfileGuess::new(P::Rec709, Medium, "8-bit, colr bt709")
166 }
167 _ if depth == 8 => ProfileGuess::new(P::Rec709, Low, "8-bit, no profile metadata"),
168 _ => ProfileGuess::new(P::Unknown, Low, format!("{depth}-bit, no profile metadata")),
169 }
170}
171
172fn dji_log(info: &Mp4Info, meta: &[u8]) -> ProfileGuess {
174 let model = dji_header(meta)
175 .map(|(m, ..)| m)
176 .filter(|m| !m.is_empty())
177 .or_else(|| info.model().map(str::to_owned))
178 .unwrap_or_default();
179 let m = model.to_ascii_lowercase().replace([' ', '-', '_'], "");
180 let (p, c, what) = if m.contains("pocket4p") || m.contains("pocket4pro") {
181 (PictureProfile::Dlog2, Confidence::Medium, "D-Log2")
182 } else if m.contains("pocket4") {
183 (PictureProfile::Dlog, Confidence::High, "D-Log")
184 } else if m.contains("pocket3") || m.contains("mini4") || m.contains("osmoaction") {
185 (PictureProfile::DlogM, Confidence::Medium, "D-Log M")
186 } else {
187 (
188 PictureProfile::Dlog,
189 Confidence::Medium,
190 "DJI log, model not mapped",
191 )
192 };
193 ProfileGuess::new(
194 p,
195 c,
196 format!("dji cam-meta color mode 2 on {model} ({what})"),
197 )
198}
199
200pub fn camera_lut(profile: PictureProfile, model: Option<&str>) -> Option<String> {
202 let m = model
203 .unwrap_or_default()
204 .to_ascii_lowercase()
205 .replace([' ', '-', '_'], "");
206 Some(match profile {
207 PictureProfile::Dlog if m.contains("pocket4") => "dji-pocket4-dlog".into(),
208 PictureProfile::Dlog => "dji-dlog".into(),
209 PictureProfile::DlogM if m.contains("pocket3") => "dji-pocket3-dlog-m".into(),
210 PictureProfile::DlogM => "dji-dlog-m".into(),
211 PictureProfile::Dlog2 => "dji-pocket4p-dlog2".into(),
212 PictureProfile::CLog3 => "canon-clog3".into(),
213 PictureProfile::AppleLog => "apple-log".into(),
214 _ => return None,
215 })
216}
217
218fn has_apple_log(s: &str) -> bool {
219 let s = s.to_ascii_lowercase().replace([' ', '-', '_', '.'], "");
220 s.contains("applelog")
221}
222
223pub fn dji_color_mode(header: &[u8]) -> Option<u64> {
228 let stream = pb_field(header, 2)?;
229 let device = pb_field(stream, 2)?;
230 let pic = pb_field(device, 3)?;
231 pb_varint_field(pic, 1)
232}
233
234pub fn dji_header(header: &[u8]) -> Option<(String, Option<f32>, Option<u64>)> {
236 let model = pb_field(header, 1)
237 .and_then(|h| pb_field(h, 1))
238 .and_then(|h| pb_field(h, 10))
239 .map(|b| String::from_utf8_lossy(b).into_owned())
240 .unwrap_or_default();
241 let video = pb_field(header, 2).and_then(|s| pb_field(s, 3));
242 let fps = video
243 .and_then(|v| pb_fixed32_field(v, 3))
244 .map(f32::from_bits);
245 let depth = video.and_then(|v| pb_varint_field(v, 5));
246 Some((model, fps, depth))
247}
248
249fn varint(b: &[u8], i: &mut usize) -> Option<u64> {
250 let mut r = 0u64;
251 for shift in (0..64).step_by(7) {
252 let c = *b.get(*i)?;
253 *i += 1;
254 r |= u64::from(c & 0x7f) << shift;
255 if c < 0x80 {
256 return Some(r);
257 }
258 }
259 None
260}
261
262enum Wire<'a> {
263 Varint(u64),
264 Fixed32(u32),
265 Bytes(&'a [u8]),
266 Other,
267}
268
269fn pb_fields(b: &[u8]) -> Option<Vec<(u64, Wire<'_>)>> {
271 let mut out = Vec::new();
272 let mut i = 0;
273 while i < b.len() {
274 let key = varint(b, &mut i)?;
275 let w = match key & 7 {
276 0 => Wire::Varint(varint(b, &mut i)?),
277 1 => {
278 i += 8;
279 Wire::Other
280 }
281 2 => {
282 let n = usize::try_from(varint(b, &mut i)?).ok()?;
283 let s = b.get(i..i.checked_add(n)?)?;
284 i += n;
285 Wire::Bytes(s)
286 }
287 5 => {
288 let s = b.get(i..i + 4)?;
289 i += 4;
290 Wire::Fixed32(u32::from_le_bytes([s[0], s[1], s[2], s[3]]))
291 }
292 _ => return None,
293 };
294 if i > b.len() {
295 return None;
296 }
297 out.push((key >> 3, w));
298 }
299 Some(out)
300}
301
302fn pb_field(b: &[u8], n: u64) -> Option<&[u8]> {
303 pb_fields(b)?.into_iter().find_map(|(k, w)| match w {
304 Wire::Bytes(s) if k == n => Some(s),
305 _ => None,
306 })
307}
308
309fn pb_varint_field(b: &[u8], n: u64) -> Option<u64> {
310 pb_fields(b)?.into_iter().find_map(|(k, w)| match w {
311 Wire::Varint(v) if k == n => Some(v),
312 _ => None,
313 })
314}
315
316fn pb_fixed32_field(b: &[u8], n: u64) -> Option<u32> {
317 pb_fields(b)?.into_iter().find_map(|(k, w)| match w {
318 Wire::Fixed32(v) if k == n => Some(v),
319 _ => None,
320 })
321}
322
323#[cfg(test)]
324pub(crate) mod tests {
325 use super::*;
326 use crate::bmff::{Colr, Track};
327
328 fn len_field(n: u8, body: &[u8]) -> Vec<u8> {
329 let mut v = vec![(n << 3) | 2, u8::try_from(body.len()).expect("short")];
330 v.extend_from_slice(body);
331 v
332 }
333
334 pub fn dji_header_bytes(mode: Option<u8>) -> Vec<u8> {
337 let model = len_field(1, &len_field(10, b"DJI OsmoPocket4"));
338 let pic = mode.map(|m| vec![0x08, m]).unwrap_or_default();
339 let device = [len_field(4, b"DJI OP-041"), len_field(3, &pic)].concat();
340 let mut video = vec![0x08, 0x80, 0x1e, 0x10, 0x90, 0x10, 0x1d];
341 video.extend_from_slice(&59.94f32.to_le_bytes());
342 video.extend_from_slice(&[0x28, 10]);
343 let stream = [len_field(2, &device), len_field(3, &video)].concat();
344 [len_field(1, &model), len_field(2, &stream)].concat()
345 }
346
347 fn info(track: Track) -> Mp4Info {
348 Mp4Info {
349 tracks: vec![track],
350 ..Mp4Info::default()
351 }
352 }
353
354 fn video(depth: u8, transfer: u16, primaries: u16) -> Track {
355 Track {
356 handler: "vide".into(),
357 codec: "hvc1".into(),
358 bit_depth: Some(depth),
359 colr: Some(Colr {
360 primaries,
361 transfer,
362 matrix: 1,
363 full_range: Some(false),
364 }),
365 ..Track::default()
366 }
367 }
368
369 #[test]
370 fn dji_mode_field() {
371 assert_eq!(dji_color_mode(&dji_header_bytes(Some(2))), Some(2));
372 assert_eq!(dji_color_mode(&dji_header_bytes(None)), None);
373 let (model, fps, depth) = dji_header(&dji_header_bytes(None)).unwrap();
374 assert_eq!(model, "DJI OsmoPocket4");
375 assert!((fps.unwrap() - 59.94).abs() < 1e-3);
376 assert_eq!(depth, Some(10));
377 assert_eq!(dji_color_mode(b"\xff\xff"), None);
378 }
379
380 #[test]
381 fn dji_log_is_tagged_bt709_but_detected() {
382 let mut i = info(video(10, 1, 1));
383 i.dji_meta = Some(dji_header_bytes(Some(2)));
384 let g = detect(&i);
385 assert_eq!(
386 (g.profile, g.confidence),
387 (PictureProfile::Dlog, Confidence::High)
388 );
389 assert_eq!(
390 camera_lut(g.profile, Some("DJI OsmoPocket4")).as_deref(),
391 Some("dji-pocket4-dlog")
392 );
393 i.dji_meta = Some(dji_header_bytes(None));
394 assert_eq!(detect(&i).profile, PictureProfile::Rec709);
395 }
396
397 #[test]
398 fn hdr_tags_win() {
399 let mut i = info(video(10, TRC_HLG, 9));
400 i.dji_meta = Some(dji_header_bytes(Some(2)));
401 assert_eq!(detect(&i).profile, PictureProfile::Hlg);
402 let mut i = info(video(10, TRC_PQ, 9));
403 i.udta.insert("modl".into(), "Canon EOS R10".into());
404 assert_eq!(detect(&i).profile, PictureProfile::Pq);
405 }
406
407 #[test]
408 fn canon_and_iphone_rules() {
409 let mut i = info(video(8, 1, 1));
410 i.udta.insert("modl".into(), "Canon EOS R10".into());
411 assert_eq!(detect(&i).profile, PictureProfile::Rec709);
412 i.tracks[0].bit_depth = Some(10);
413 assert_eq!(detect(&i).profile, PictureProfile::CLog3);
414
415 let mut i = info(video(10, 2, 9));
416 i.keys
417 .insert("com.apple.quicktime.model".into(), "iPhone 16 Pro".into());
418 assert_eq!(detect(&i).profile, PictureProfile::AppleLog);
419 i.tracks[0].dolby_vision = true;
420 i.tracks[0].colr = Some(Colr {
421 primaries: 9,
422 transfer: TRC_HLG,
423 matrix: 9,
424 full_range: None,
425 });
426 let g = detect(&i);
427 assert_eq!(g.profile, PictureProfile::Hlg);
428 assert!(g.dolby_vision);
429 }
430}