Skip to main content

ffcine_core/
camera.rs

1//! Camera input profiles: which colour-in a source needs, picked from the probe, and the camera
2//! LUTs (log -> Rec.709) that go with them.
3//!
4//! | profile | detected from | colour-in |
5//! |---|---|---|
6//! | `hdr` | HLG/PQ transfer (iPhone HLG + Dolby Vision 8.4, Canon HDR PQ), or BT.2020/P3 primaries with a bt709 transfer | libplacebo tone + gamut mapping from the tags, no LUT |
7//! | `applelog` | BT.2020 primaries, untagged transfer (iPhone `ProRes` Log) | analytic Apple Log -> Rec.709 |
8//! | `clog3` | the same on a Canon file (`CAEP` brand); else only by override | analytic Canon Log 3 / Cinema Gamut -> Rec.709 |
9//! | `dlog`, `dlog-vivid` | 10-bit, SDR-tagged, DJI encoder tag (Osmo Pocket 4) | DJI's official Pocket 4 D-Log -> Rec.709 LUT (vendor file, [`VENDOR_LUTS`]); `dlog` falls back to the analytic D-Log / D-Gamut transform |
10//! | `dlogm`, `dlogm-vivid` | the same from an Osmo Pocket 3 | DJI's D-Log M -> Rec.709 LUT (vendor file only: no published curve) |
11//! | `rec709` | anything else | honour the tags |
12//!
13//! DJI files are tagged bt709 whether they're log or "normal" 10-bit; the colour mode is in the
14//! `djmd` CAM meta protobuf, which [`crate::profile`] reads ([`Profile::detect_file`]). Without
15//! it (not ISO BMFF, or no CAM meta) a 10-bit DJI clip is taken as log (how DJI log footage is usually shot).
16
17// Colour maths: matrix entries and chromaticities read best as single letters.
18#![allow(clippy::many_single_char_names)]
19
20use std::fmt;
21use std::path::{Path, PathBuf};
22use std::str::FromStr;
23
24use crate::color::{Primaries, Transfer, named_enum};
25use crate::lut::Lut;
26use crate::probe::MediaInfo;
27use crate::profile::{Confidence, PictureProfile};
28use crate::{Error, Result};
29
30named_enum!(
31    /// A source's camera colour profile.
32    Profile {
33        /// Display-referred SDR: honour the tags.
34        Rec709 = "rec709",
35        /// HLG / PQ / Dolby Vision or wide-gamut SDR: libplacebo tone and gamut mapping.
36        Hdr = "hdr",
37        /// DJI D-Log / D-Gamut (Osmo Pocket 4) through DJI's neutral Rec.709 LUT, else analytic.
38        DjiDlog = "dlog",
39        /// DJI D-Log through DJI's vivid Rec.709 LUT.
40        DjiDlogVivid = "dlog-vivid",
41        /// DJI D-Log M (Osmo Pocket 3) through DJI's neutral Rec.709 LUT.
42        DjiDlogM = "dlogm",
43        /// DJI D-Log M through DJI's vivid Rec.709 LUT.
44        DjiDlogMVivid = "dlogm-vivid",
45        /// Canon Log 3 (Cinema Gamut).
46        CanonClog3 = "clog3",
47        /// Apple Log (BT.2020).
48        AppleLog = "applelog",
49    }
50);
51
52/// Where vendor LUT files are looked up, in order: `$FFCINE_LUT_DIR` (`:`-separated), then
53/// `$XDG_DATA_HOME/ffcine/luts` (or `~/.local/share/ffcine/luts`).
54pub fn lut_dirs() -> Vec<PathBuf> {
55    let mut v: Vec<PathBuf> = std::env::var_os("FFCINE_LUT_DIR")
56        .map(|d| std::env::split_paths(&d).collect())
57        .unwrap_or_default();
58    if let Some(d) = std::env::var_os("XDG_DATA_HOME")
59        .map(PathBuf::from)
60        .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/share")))
61    {
62        v.push(d.join("ffcine").join("luts"));
63    }
64    v
65}
66
67/// What a profile does to the source before the grade.
68#[derive(Debug, Clone, PartialEq)]
69pub struct CameraLut {
70    /// Log -> Rec.709 (BT.1886) on the decoded, range-expanded R'G'B' (`LutType::Normalized`).
71    pub lut: Lut,
72    /// Re-tag the source as Rec.709 primaries and transfer, so libplacebo treats the LUT's output
73    /// as Rec.709 and converts nothing more (the matrix and range tags stay).
74    pub primaries: Primaries,
75    pub transfer: Transfer,
76}
77
78impl Profile {
79    /// The camera profile for a container-level picture profile ([`crate::profile`]); `None`
80    /// where ffcine has none yet (D-Log2, unknown).
81    pub fn from_picture(p: PictureProfile) -> Option<Self> {
82        Some(match p {
83            PictureProfile::Rec709 => Self::Rec709,
84            PictureProfile::Dlog => Self::DjiDlog,
85            PictureProfile::DlogM => Self::DjiDlogM,
86            PictureProfile::Hlg | PictureProfile::Pq => Self::Hdr,
87            PictureProfile::CLog3 => Self::CanonClog3,
88            PictureProfile::AppleLog => Self::AppleLog,
89            _ => return None,
90        })
91    }
92
93    /// The profile for a probed file: [`crate::profile::detect`] on its ISO BMFF metadata (DJI's
94    /// CAM meta colour mode, Canon and Apple tags; what the ingest catalog records) when that is
95    /// at least medium confidence, else [`Profile::detect`] on the probe.
96    pub fn detect_file(info: &MediaInfo) -> Self {
97        crate::bmff::read(&info.path)
98            .ok()
99            .map(|m| crate::profile::detect(&m))
100            .filter(|g| g.confidence != Confidence::Low)
101            .and_then(|g| Self::from_picture(g.profile))
102            .unwrap_or_else(|| Self::detect(info))
103    }
104
105    /// The profile the probe suggests (see the module docs).
106    pub fn detect(info: &MediaInfo) -> Self {
107        let Some((_, v)) = info.video() else {
108            return Self::Rec709;
109        };
110        let c = v.color;
111        let tag = |k: &str| info.tags.get(k).map_or("", String::as_str);
112        let canon = tag("compatible_brands").contains("CAEP") || tag("major_brand") == "CAEP";
113        let dji = tag("encoder").starts_with("DJI");
114        if c.is_hdr() {
115            return Self::Hdr;
116        }
117        let wide = matches!(c.primaries, Primaries::Bt2020 | Primaries::DisplayP3);
118        if c.primaries == Primaries::Bt2020 && c.transfer == Transfer::Unknown {
119            return if canon {
120                Self::CanonClog3
121            } else {
122                Self::AppleLog
123            };
124        }
125        if wide && c.transfer == Transfer::Bt709 {
126            return Self::Hdr;
127        }
128        if dji && v.pixel_format.contains("10") {
129            let enc = tag("encoder").replace(' ', "");
130            return if enc.contains("Pocket3") {
131                Self::DjiDlogM
132            } else {
133                Self::DjiDlog
134            };
135        }
136        Self::Rec709
137    }
138
139    /// The camera LUT for this profile, `None` for profiles libplacebo handles from the tags.
140    /// Vendor files are searched in `dirs` (see [`lut_dirs`]); Apple Log and Canon Log 3 fall
141    /// back to analytic LUTs, D-Log M has no published curve and needs DJI's file.
142    pub fn camera_lut(self, dirs: &[PathBuf]) -> Result<Option<CameraLut>> {
143        let lut = match self {
144            Self::Rec709 | Self::Hdr => return Ok(None),
145            Self::DjiDlog => match find(dirs, self.vendor_names()) {
146                Some(p) => Lut::from_cube_file(p)?,
147                None => analytic(
148                    33,
149                    dlog_decode,
150                    &rgb_to_rgb(&D_GAMUT, &BT709),
151                    0.53,
152                    "DJI D-Log / D-Gamut",
153                ),
154            },
155            Self::DjiDlogVivid | Self::DjiDlogM | Self::DjiDlogMVivid => {
156                let names = self.vendor_names();
157                let path = find(dirs, names).ok_or_else(|| Error::Invalid {
158                    what: "camera LUT (DJI's .cube is not in the LUT dirs: run `ffcine fetch-luts`, \
159                           or download it from https://www.dji.com/downloads and set FFCINE_LUT_DIR)",
160                    value: format!("{self}: tried {names:?} in {dirs:?}"),
161                })?;
162                Lut::from_cube_file(path)?
163            }
164            Self::AppleLog => match find(dirs, self.vendor_names()) {
165                Some(p) => Lut::from_cube_file(p)?,
166                None => analytic(33, apple_log_decode, &BT2020_TO_BT709, 0.8, "Apple Log"),
167            },
168            _ => match find(dirs, self.vendor_names()) {
169                Some(p) => Lut::from_cube_file(p)?,
170                None => analytic(
171                    33,
172                    clog3_decode,
173                    &rgb_to_rgb(&CINEMA_GAMUT, &BT709),
174                    0.8,
175                    "Canon Log 3",
176                ),
177            },
178        };
179        Ok(Some(CameraLut {
180            lut,
181            primaries: Primaries::Bt709,
182            transfer: Transfer::Bt709,
183        }))
184    }
185
186    /// The camera LUT for an HDR deliverable: log -> BT.2020 PQ, mastered to `peak` cd/m², from
187    /// the analytic curves; vendor LUTs only reach Rec.709. `None` for
188    /// profiles libplacebo maps from the tags; an error for D-Log M (no published curve).
189    pub fn camera_lut_hdr(self, peak: f64) -> Result<Option<CameraLut>> {
190        let lut = match self {
191            Self::Rec709 | Self::Hdr => return Ok(None),
192            Self::DjiDlog => analytic_pq(
193                33,
194                dlog_decode,
195                &rgb_to_rgb(&D_GAMUT, &BT2020),
196                0.53 / 0.8,
197                peak,
198                "DJI D-Log / D-Gamut",
199            ),
200            Self::AppleLog => analytic_pq(
201                33,
202                apple_log_decode,
203                &rgb_to_rgb(&BT2020, &BT2020),
204                1.0,
205                peak,
206                "Apple Log",
207            ),
208            Self::CanonClog3 => analytic_pq(
209                33,
210                clog3_decode,
211                &rgb_to_rgb(&CINEMA_GAMUT, &BT2020),
212                1.0,
213                peak,
214                "Canon Log 3",
215            ),
216            _ => {
217                return Err(Error::Invalid {
218                    what: "camera profile for HDR (DJI publishes no D-Log M curve, only Rec.709 LUTs)",
219                    value: self.to_string(),
220                });
221            }
222        };
223        Ok(Some(CameraLut {
224            lut,
225            primaries: Primaries::Bt2020,
226            transfer: Transfer::Pq,
227        }))
228    }
229
230    /// File names a vendor LUT for this profile may have in a LUT dir, preferred first.
231    pub fn vendor_names(self) -> &'static [&'static str] {
232        match self {
233            Self::DjiDlog => &[
234                "dlog_to_rec709.cube",
235                "DJI OSMO Pocket 4 D-Log to Rec.709 V2.0.cube",
236                "DJI OSMO Pocket 4 D-Log to Rec.709 V1.0.cube",
237                "DJI Osmo Pocket 4 D-Log to Rec.709 V1.0.cube",
238            ],
239            Self::DjiDlogVivid => &[
240                "dlog_to_rec709_vivid.cube",
241                "DJI OSMO Pocket 4 D-Log to Rec.709 vivid V2.0.cube",
242                "DJI OSMO Pocket 4 D-Log to Rec.709 vivid V1.0.cube",
243                "DJI Osmo Pocket 4 D-Log to Rec.709 vivid V1.0.cube",
244            ],
245            Self::DjiDlogM => &[
246                "dlogm_to_rec709.cube",
247                "DJI OSMO Pocket 3 D-Log M to Rec.709 V1.cube",
248            ],
249            Self::DjiDlogMVivid => &[
250                "dlogm_to_rec709_vivid.cube",
251                "DJI OSMO Pocket 3 D-Log M to Rec.709 vivid V1.cube",
252            ],
253            Self::AppleLog => &["applelog_to_rec709.cube", "AppleLogToRec709-v1.0.cube"],
254            Self::CanonClog3 => &[
255                "clog3_to_rec709.cube",
256                "CinemaGamut_CanonLog3-to-BT709_WideDR_65_FF_Ver.2.0.cube",
257            ],
258            _ => &[],
259        }
260    }
261}
262
263/// DJI's official camera LUTs: (file name, URL). DJI states no redistribution terms, so they
264/// aren't vendored; `ffcine fetch-luts` downloads them into the first [`lut_dirs`] entry.
265pub const VENDOR_LUTS: &[(&str, &str)] = &[
266    (
267        "DJI OSMO Pocket 4 D-Log to Rec.709 V2.0.cube",
268        "https://terra-1-g.djicdn.com/851d20f7b9f64838a34cd02351370894/HG214%20Lut/DJI%20OSMO%20Pocket%204%20D-Log%20to%20Rec.709%20V2.0.cube",
269    ),
270    (
271        "DJI OSMO Pocket 4 D-Log to Rec.709 vivid V2.0.cube",
272        "https://terra-1-g.djicdn.com/851d20f7b9f64838a34cd02351370894/HG214%20Lut/DJI%20OSMO%20Pocket%204%20D-Log%20to%20Rec.709%20vivid%20V2.0.cube",
273    ),
274];
275
276fn find(dirs: &[PathBuf], names: &[&str]) -> Option<PathBuf> {
277    names
278        .iter()
279        .flat_map(|n| dirs.iter().map(move |d| d.join(n)))
280        .find(|p| p.is_file())
281}
282
283/// A chromaticity set: red, green, blue, white (CIE xy).
284pub type Chromaticities = [[f64; 2]; 4];
285
286const D65: [f64; 2] = [0.3127, 0.3290];
287pub const BT709: Chromaticities = [[0.64, 0.33], [0.30, 0.60], [0.15, 0.06], D65];
288pub const BT2020: Chromaticities = [[0.708, 0.292], [0.170, 0.797], [0.131, 0.046], D65];
289/// DJI D-Gamut.
290pub const D_GAMUT: Chromaticities = [[0.71, 0.31], [0.21, 0.88], [0.09, -0.08], D65];
291/// Canon Cinema Gamut.
292pub const CINEMA_GAMUT: Chromaticities = [[0.74, 0.27], [0.17, 1.14], [0.08, -0.10], D65];
293
294type M3 = [[f64; 3]; 3];
295
296/// BT.2020 -> BT.709 as the prototype's `applelog_lut.py` has it (4 decimals).
297const BT2020_TO_BT709: M3 = [
298    [1.6605, -0.5876, -0.0728],
299    [-0.1246, 1.1329, -0.0083],
300    [-0.0182, -0.1006, 1.1187],
301];
302
303fn mul(a: &M3, b: &M3) -> M3 {
304    let mut m = [[0.0; 3]; 3];
305    for (i, row) in m.iter_mut().enumerate() {
306        for (j, v) in row.iter_mut().enumerate() {
307            *v = (0..3).map(|k| a[i][k] * b[k][j]).sum();
308        }
309    }
310    m
311}
312
313fn inv(m: &M3) -> M3 {
314    let [[a, b, c], [d, e, f], [g, h, i]] = *m;
315    let det = a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g);
316    [
317        [
318            (e * i - f * h) / det,
319            (c * h - b * i) / det,
320            (b * f - c * e) / det,
321        ],
322        [
323            (f * g - d * i) / det,
324            (a * i - c * g) / det,
325            (c * d - a * f) / det,
326        ],
327        [
328            (d * h - e * g) / det,
329            (b * g - a * h) / det,
330            (a * e - b * d) / det,
331        ],
332    ]
333}
334
335/// Linear RGB -> XYZ for a set of primaries (SMPTE RP 177).
336pub fn rgb_to_xyz(c: &Chromaticities) -> [[f64; 3]; 3] {
337    let xyz = |[x, y]: [f64; 2]| [x / y, 1.0, (1.0 - x - y) / y];
338    let [r, g, b, w] = c.map(xyz);
339    let p = [[r[0], g[0], b[0]], [r[1], g[1], b[1]], [r[2], g[2], b[2]]];
340    let pi = inv(&p);
341    let s: [f64; 3] = std::array::from_fn(|i| (0..3).map(|k| pi[i][k] * w[k]).sum());
342    std::array::from_fn(|i| std::array::from_fn(|j| p[i][j] * s[j]))
343}
344
345/// Linear RGB in `from` -> linear RGB in `to` (same white, no adaptation).
346pub fn rgb_to_rgb(from: &Chromaticities, to: &Chromaticities) -> [[f64; 3]; 3] {
347    mul(&inv(&rgb_to_xyz(to)), &rgb_to_xyz(from))
348}
349
350/// Apple Log code value -> scene linear (Apple Log Profile white paper).
351pub fn apple_log_decode(p: f64) -> f64 {
352    const R0: f64 = -0.056_410_88;
353    const RT: f64 = 0.01;
354    const C: f64 = 47.287_112_36;
355    const BETA: f64 = 0.009_640_52;
356    const GAMMA: f64 = 0.085_504_79;
357    const DELTA: f64 = 0.693_369_45;
358    let pt = C * (RT - R0).powi(2);
359    if p >= pt {
360        2f64.powf((p - DELTA) / GAMMA) - BETA
361    } else if p > 0.0 {
362        (p / C).sqrt() + R0
363    } else {
364        R0
365    }
366}
367
368/// DJI D-Log code value -> scene linear (DJI's "D-Log and D-Gamut" white paper, the curve
369/// Resolve's colour space transform uses).
370pub fn dlog_decode(y: f64) -> f64 {
371    if y <= 0.14 {
372        (y - 0.0929) / 6.025
373    } else {
374        (10f64.powf(3.896_16 * y - 2.277_52) - 0.0108) / 0.9892
375    }
376}
377
378/// Canon Log 3 (range-expanded) code value -> scene linear, 0.18 = mid grey (Canon's formula,
379/// reflectance in, as colour-science's `log_decoding_CanonLog3`).
380pub fn clog3_decode(y: f64) -> f64 {
381    let x = if y < 0.097_465_473 {
382        -(10f64.powf((0.127_839_01 - y) / 0.367_268_45) - 1.0) / 14.983_25
383    } else if y <= 0.152_778_91 {
384        (y - 0.125_122_19) / 1.975_479_8
385    } else {
386        (10f64.powf((y - 0.122_405_37) / 0.367_268_45) - 1.0) / 14.983_25
387    };
388    x * 0.9
389}
390
391/// Narkowicz's ACES filmic fit: the prototype's gentle display tone map for log footage.
392fn filmic(x: f64) -> f64 {
393    let x = x.max(0.0);
394    ((x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14)).clamp(0.0, 1.0)
395}
396
397/// A log -> Rec.709 LUT: decode, gamut matrix to BT.709, exposure, filmic tone map, BT.1886
398/// (2.4) encode. The prototype's `applelog_lut.py`, generalised over the curve and gamut.
399/// `exposure` scales scene linear into the curve: 0.8 is the prototype's `applelog_lut.py`
400/// default; D-Log uses 0.53, which puts mid grey where DJI's own V2.0 LUT does.
401fn analytic(n: usize, decode: fn(f64) -> f64, gamut: &M3, exposure: f64, title: &str) -> Lut {
402    let mut lut = Lut::identity(n);
403    for e in &mut lut.data {
404        let lin = e.map(|v| decode(f64::from(v)));
405        *e = std::array::from_fn(|i| {
406            let v: f64 = (0..3).map(|k| gamut[i][k] * lin[k]).sum();
407            #[allow(clippy::cast_possible_truncation)]
408            let out = filmic(v * exposure).powf(1.0 / 2.4) as f32;
409            out
410        });
411    }
412    lut.title = Some(format!("{title} to Rec.709 (ffcine analytic)"));
413    lut
414}
415
416/// SMPTE ST 2084 (PQ) inverse EOTF: cd/m² -> signal.
417pub fn pq_encode(nits: f64) -> f64 {
418    const M1: f64 = 2610.0 / 16384.0;
419    const M2: f64 = 2523.0 / 4096.0 * 128.0;
420    const C1: f64 = 3424.0 / 4096.0;
421    const C2: f64 = 2413.0 / 4096.0 * 32.0;
422    const C3: f64 = 2392.0 / 4096.0 * 32.0;
423    let y = (nits / 10_000.0).clamp(0.0, 1.0).powf(M1);
424    ((C1 + C2 * y) / (1.0 + C3 * y)).powf(M2)
425}
426
427/// Scene-linear 18 % grey on an HDR display: 26 cd/m² (ITU-R BT.2408, with diffuse white at
428/// 203 cd/m²).
429const HDR_GREY_NITS: f64 = 26.0;
430/// Where the highlight roll-off starts: BT.2408's diffuse white.
431const HDR_KNEE_NITS: f64 = 203.0;
432
433/// Scene linear (0.18 = grey) -> display cd/m²: grey at 26 cd/m², linear to the knee,
434/// then an exponential shoulder that reaches `peak` asymptotically.
435pub fn hdr_display_nits(x: f64, peak: f64) -> f64 {
436    let l = x.max(0.0) * HDR_GREY_NITS / 0.18;
437    let knee = HDR_KNEE_NITS.min(0.75 * peak);
438    if l <= knee {
439        l
440    } else {
441        let room = peak - knee;
442        knee + room * (1.0 - (-(l - knee) / room).exp())
443    }
444}
445
446/// A log -> BT.2020 PQ LUT: decode, gamut matrix to BT.2020, `exposure`, [`hdr_display_nits`],
447/// PQ encode. The HDR twin of the Rec.709 analytic LUTs; `exposure` 1 keeps 18 % grey at grey.
448fn analytic_pq(
449    n: usize,
450    decode: fn(f64) -> f64,
451    gamut: &M3,
452    exposure: f64,
453    peak: f64,
454    title: &str,
455) -> Lut {
456    let mut lut = Lut::identity(n);
457    for e in &mut lut.data {
458        let lin = e.map(|v| decode(f64::from(v)));
459        *e = std::array::from_fn(|i| {
460            let v: f64 = (0..3).map(|k| gamut[i][k] * lin[k]).sum();
461            #[allow(clippy::cast_possible_truncation)]
462            let out = pq_encode(hdr_display_nits(v * exposure, peak)) as f32;
463            out
464        });
465    }
466    lut.title = Some(format!(
467        "{title} to BT.2020 PQ {peak} nits (ffcine analytic)"
468    ));
469    lut
470}
471
472/// Per-clip profile choice: `auto` (probe) or a fixed [`Profile`].
473#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
474pub enum ProfileChoice {
475    #[default]
476    Auto,
477    Fixed(Profile),
478}
479
480impl ProfileChoice {
481    pub fn resolve(self, info: &MediaInfo) -> Profile {
482        match self {
483            Self::Auto => Profile::detect_file(info),
484            Self::Fixed(p) => p,
485        }
486    }
487}
488
489impl FromStr for ProfileChoice {
490    type Err = Error;
491    fn from_str(s: &str) -> Result<Self> {
492        if s == "auto" {
493            Ok(Self::Auto)
494        } else {
495            s.parse().map(Self::Fixed)
496        }
497    }
498}
499
500impl fmt::Display for ProfileChoice {
501    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
502        match self {
503            Self::Auto => f.write_str("auto"),
504            Self::Fixed(p) => p.fmt(f),
505        }
506    }
507}
508
509/// Writes `lut` as a `.cube` file (for inspection, or to hand an analytic LUT to other tools).
510pub fn write_cube(lut: &Lut, path: &Path) -> Result<()> {
511    lut.write_cube(path)
512}
513
514#[cfg(test)]
515mod tests {
516    use super::*;
517    use crate::ColorSpace;
518    use crate::color::{Matrix, Range};
519    use crate::probe::{StreamInfo, StreamKind, VideoInfo};
520
521    fn info(color: ColorSpace, pix: &str, tags: &[(&str, &str)]) -> MediaInfo {
522        MediaInfo {
523            path: "x".into(),
524            format: "mov".into(),
525            duration: None,
526            bit_rate: None,
527            streams: vec![StreamInfo {
528                index: 0,
529                codec: "hevc".into(),
530                bit_rate: None,
531                kind: StreamKind::Video(VideoInfo {
532                    width: 3840,
533                    height: 2160,
534                    pixel_format: pix.into(),
535                    frame_rate: None,
536                    frames: None,
537                    color,
538                }),
539            }],
540            tags: tags
541                .iter()
542                .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
543                .collect(),
544        }
545    }
546
547    #[test]
548    fn detects_profiles() {
549        let sdr = ColorSpace::BT709_TV;
550        let dji = [("encoder", "DJI OsmoPocket4")];
551        assert_eq!(
552            Profile::detect(&info(sdr, "yuv420p10le", &dji)),
553            Profile::DjiDlog
554        );
555        assert_eq!(
556            Profile::detect(&info(sdr, "yuv420p10le", &[("encoder", "DJI OsmoPocket3")])),
557            Profile::DjiDlogM
558        );
559        assert_eq!(
560            Profile::detect(&info(sdr, "yuv420p", &dji)),
561            Profile::Rec709
562        );
563        let hlg = ColorSpace {
564            primaries: Primaries::Bt2020,
565            transfer: Transfer::Hlg,
566            matrix: Matrix::Bt2020Ncl,
567            range: Range::Limited,
568        };
569        assert_eq!(
570            Profile::detect(&info(hlg, "yuv420p10le", &dji)),
571            Profile::Hdr
572        );
573        let log = ColorSpace {
574            transfer: Transfer::Unknown,
575            ..hlg
576        };
577        assert_eq!(
578            Profile::detect(&info(log, "yuv422p10le", &[])),
579            Profile::AppleLog
580        );
581        assert_eq!(
582            Profile::detect(&info(
583                log,
584                "yuv422p10le",
585                &[("compatible_brands", "mp42CAEP")]
586            )),
587            Profile::CanonClog3
588        );
589        let pq = ColorSpace {
590            transfer: Transfer::Pq,
591            ..hlg
592        };
593        assert_eq!(
594            Profile::detect(&info(pq, "yuv422p10le", &[("compatible_brands", "CAEP")])),
595            Profile::Hdr
596        );
597        assert_eq!(
598            "dlogm-vivid".parse::<ProfileChoice>().unwrap(),
599            ProfileChoice::Fixed(Profile::DjiDlogMVivid)
600        );
601    }
602
603    #[test]
604    fn picture_profiles_map() {
605        assert_eq!(
606            Profile::from_picture(PictureProfile::Dlog),
607            Some(Profile::DjiDlog)
608        );
609        assert_eq!(Profile::from_picture(PictureProfile::Dlog2), None);
610        // Not a file: falls back to the probe.
611        let i = info(
612            ColorSpace::BT709_TV,
613            "yuv420p10le",
614            &[("encoder", "DJI OsmoPocket4")],
615        );
616        assert_eq!(Profile::detect_file(&i), Profile::DjiDlog);
617    }
618
619    #[test]
620    fn curves() {
621        // Apple Log: 18 % grey encodes to about 0.4882 (white paper table).
622        assert!((apple_log_decode(0.488_2) - 0.18).abs() < 0.002);
623        // D-Log: 18 % grey is about 0.398, continuous at the knee.
624        assert!((dlog_decode(0.3983) - 0.18).abs() < 0.002);
625        assert!((dlog_decode(0.14 - 1e-9) - dlog_decode(0.14 + 1e-9)).abs() < 1e-3);
626        // Canon Log 3: 18 % grey is 34.3 % (full range), 90 % white 56.5 %.
627        assert!((clog3_decode(0.343_4) - 0.18).abs() < 0.001);
628        assert!((clog3_decode(0.564_5) - 0.9).abs() < 0.002);
629        // Continuous at the linear segment's ends.
630        for y in [0.097_465_473, 0.152_778_91] {
631            assert!((clog3_decode(y - 1e-9) - clog3_decode(y + 1e-9)).abs() < 1e-6);
632        }
633    }
634
635    #[test]
636    fn gamut_matrices() {
637        let m = rgb_to_rgb(&BT2020, &BT709);
638        for i in 0..3 {
639            for j in 0..3 {
640                assert!((m[i][j] - BT2020_TO_BT709[i][j]).abs() < 1e-3, "{m:?}");
641            }
642            // White stays white.
643            assert!((m[i].iter().sum::<f64>() - 1.0).abs() < 1e-9);
644        }
645    }
646
647    #[test]
648    fn hdr_luts() {
649        // PQ: 100 cd/m² is about 0.508, 1000 about 0.752, 10000 is 1.
650        assert!((pq_encode(100.0) - 0.508).abs() < 0.001);
651        assert!((pq_encode(1000.0) - 0.752).abs() < 0.001);
652        assert!((pq_encode(10_000.0) - 1.0).abs() < 1e-9);
653        // Grey at 26 nits, monotonic, never past the peak.
654        assert!((hdr_display_nits(0.18, 1000.0) - 26.0).abs() < 1e-9);
655        let mut last = 0.0;
656        for i in 0..200 {
657            let n = hdr_display_nits(f64::from(i) * 0.5, 1000.0);
658            assert!(n >= last && n < 1000.0);
659            last = n;
660        }
661        let c = Profile::CanonClog3.camera_lut_hdr(1000.0).unwrap().unwrap();
662        assert_eq!((c.primaries, c.transfer), (Primaries::Bt2020, Transfer::Pq));
663        // Canon Log 3 grey (0.3434) lands on 26 nits.
664        let grey = c.lut.sample([0.343_4; 3]);
665        assert!(
666            (f64::from(grey[1]) - pq_encode(26.0)).abs() < 0.01,
667            "{grey:?}"
668        );
669        assert!(Profile::DjiDlog.camera_lut_hdr(1000.0).unwrap().is_some());
670        assert!(Profile::DjiDlogM.camera_lut_hdr(1000.0).is_err());
671        assert!(Profile::Hdr.camera_lut_hdr(1000.0).unwrap().is_none());
672    }
673
674    #[test]
675    fn analytic_luts() {
676        let lut = Profile::AppleLog.camera_lut(&[]).unwrap().unwrap().lut;
677        assert_eq!(lut.size, 33);
678        // Monotonic on the grey axis, black near 0, mid grey around the middle.
679        let grey: Vec<f32> = (0..33).map(|i| lut.at(i, i, i)[1]).collect();
680        assert!(grey.windows(2).all(|w| w[1] >= w[0]));
681        assert!(grey[0] < 0.02);
682        let c = Profile::CanonClog3.camera_lut(&[]).unwrap().unwrap();
683        assert_eq!(c.primaries, Primaries::Bt709);
684        assert!(Profile::Hdr.camera_lut(&[]).unwrap().is_none());
685        assert!(Profile::DjiDlogM.camera_lut(&[]).is_err());
686        assert!(Profile::DjiDlog.camera_lut(&[]).unwrap().is_some());
687    }
688}