Skip to main content

ffcine_core/
profile.rs

1//! Picture profile detection: which camera curve a clip was shot in (DJI D-Log, D-Log M,
2//! D-Log2, HLG, PQ, Canon Log, Apple Log, or plain Rec.709), from container metadata. The grade picks the camera
3//! LUT from this, so each answer carries a [`Confidence`] and the evidence it came from.
4//!
5//! Why not just the colour tags: DJI writes `bt709/bt709/bt709` in `colr` for D-Log and
6//! Normal alike, so the tags only settle HDR transfers (HLG, PQ). For DJI the colour mode lives
7//! in the `CAM meta` (`djmd`) track's protobuf clip header; see [`dji_color_mode`].
8
9#![allow(
10    clippy::many_single_char_names,
11    clippy::similar_names,
12    clippy::cast_possible_truncation,
13    clippy::doc_markdown
14)] // binary parsing; box sizes are small; QuickTime, BMFF are names
15
16use std::fmt;
17use std::str::FromStr;
18
19use crate::Error;
20use crate::bmff::{Mp4Info, Track};
21
22crate::named_enum!(
23    /// The tone curve / gamut a clip was recorded in.
24    PictureProfile {
25        /// Display-referred SDR (DJI "Normal", Canon standard picture styles, iPhone SDR).
26        Rec709 = "rec709",
27        /// DJI D-Log with D-Gamut, 10-bit (Osmo Pocket 4).
28        Dlog = "dji-dlog",
29        /// DJI D-Log M (Osmo Pocket 3, Action, Mini drones).
30        DlogM = "dji-dlog-m",
31        /// DJI D-Log2 (Osmo Pocket 4P).
32        Dlog2 = "dji-dlog2",
33        /// ARIB STD-B67 hybrid log-gamma, BT.2020 (DJI HLG, iPhone HDR / Dolby Vision 8.4).
34        Hlg = "hlg",
35        /// SMPTE ST 2084, BT.2020 (Canon HDR PQ).
36        Pq = "pq",
37        /// Canon Log 3 (needs a 10-bit body; the R10 and R50 don't have it).
38        CLog3 = "clog3",
39        /// Apple Log (iPhone 15 Pro and later, ProRes or HEVC).
40        AppleLog = "apple-log",
41        Unknown = "unknown",
42    }
43);
44
45crate::named_enum!(
46    /// How sure the detection is.
47    Confidence {
48        /// Explicit metadata: an HDR transfer tag or the maker's own colour-mode field.
49        High = "high",
50        /// Inferred from codec, bit depth and model rules (e.g. Canon 10-bit without PQ).
51        Medium = "medium",
52        /// A guess: image statistics or no evidence at all. Check before grading.
53        Low = "low",
54    }
55);
56
57impl PictureProfile {
58    /// Log footage takes the camera LUT; the rest takes the display-referred ("light") rule.
59    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    /// HDR transfer (passthrough or tone-map to SDR).
67    pub const fn is_hdr(self) -> bool {
68        matches!(self, Self::Hlg | Self::Pq)
69    }
70}
71
72/// A detection result.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct ProfileGuess {
75    pub profile: PictureProfile,
76    pub confidence: Confidence,
77    /// Dolby Vision configuration present (iPhone DV is profile 8.4 on an HLG base).
78    pub dolby_vision: bool,
79    /// What it was decided from, for the catalog (`dji cam-meta color mode 2`, `colr transfer 18`).
80    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
94/// ITU-T H.273 transfer characteristics.
95const TRC_PQ: u16 = 16;
96const TRC_HLG: u16 = 18;
97const PRIM_BT2020: u16 = 9;
98
99/// Detects the picture profile from container metadata alone.
100pub 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
172/// DJI colour mode 2 is the body's log curve, which depends on the model.
173fn 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
200/// The per-camera LUT key the grade looks up: log curves are per body.
201pub 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
223/// DJI's colour mode from a `CAM meta` clip header (protobuf `dvtm_*.proto`, first `djmd`
224/// sample): field 2 (stream) -> 2 (device) -> 3 (picture settings) -> 1. Measured on Osmo
225/// Pocket 4 footage (2026): `2` on every D-Log clip (lifted blacks, ~5-15% of normal
226/// saturation), absent on Normal clips. `1` is assumed HLG (not yet seen untagged).
227pub 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
234/// DJI clip header fields, for the catalog: (model, fps, bit depth) from field 1.1.10 and 2.3.
235pub 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
269/// Walks top-level protobuf fields; `None` on malformed input.
270fn 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    /// A DJI clip header with `2.2.3.1 = mode` (or no 2.2.3 content when `None`), shaped like
335    /// the Osmo Pocket 4's.
336    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}