1#![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 Profile {
33 Rec709 = "rec709",
35 Hdr = "hdr",
37 DjiDlog = "dlog",
39 DjiDlogVivid = "dlog-vivid",
41 DjiDlogM = "dlogm",
43 DjiDlogMVivid = "dlogm-vivid",
45 CanonClog3 = "clog3",
47 AppleLog = "applelog",
49 }
50);
51
52pub 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#[derive(Debug, Clone, PartialEq)]
69pub struct CameraLut {
70 pub lut: Lut,
72 pub primaries: Primaries,
75 pub transfer: Transfer,
76}
77
78impl Profile {
79 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 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 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 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 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 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
263pub 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
283pub 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];
289pub const D_GAMUT: Chromaticities = [[0.71, 0.31], [0.21, 0.88], [0.09, -0.08], D65];
291pub const CINEMA_GAMUT: Chromaticities = [[0.74, 0.27], [0.17, 1.14], [0.08, -0.10], D65];
293
294type M3 = [[f64; 3]; 3];
295
296const 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
335pub 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
345pub fn rgb_to_rgb(from: &Chromaticities, to: &Chromaticities) -> [[f64; 3]; 3] {
347 mul(&inv(&rgb_to_xyz(to)), &rgb_to_xyz(from))
348}
349
350pub 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
368pub 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
378pub 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
391fn 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
397fn 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
416pub 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
427const HDR_GREY_NITS: f64 = 26.0;
430const HDR_KNEE_NITS: f64 = 203.0;
432
433pub 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
446fn 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#[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
509pub 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 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 assert!((apple_log_decode(0.488_2) - 0.18).abs() < 0.002);
623 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 assert!((clog3_decode(0.343_4) - 0.18).abs() < 0.001);
628 assert!((clog3_decode(0.564_5) - 0.9).abs() < 0.002);
629 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 assert!((m[i].iter().sum::<f64>() - 1.0).abs() < 1e-9);
644 }
645 }
646
647 #[test]
648 fn hdr_luts() {
649 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 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 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 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}