#![allow(clippy::many_single_char_names)]
use std::fmt;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use crate::color::{Primaries, Transfer, named_enum};
use crate::lut::Lut;
use crate::probe::MediaInfo;
use crate::profile::{Confidence, PictureProfile};
use crate::{Error, Result};
named_enum!(
Profile {
Rec709 = "rec709",
Hdr = "hdr",
DjiDlog = "dlog",
DjiDlogVivid = "dlog-vivid",
DjiDlogM = "dlogm",
DjiDlogMVivid = "dlogm-vivid",
CanonClog3 = "clog3",
AppleLog = "applelog",
}
);
pub fn lut_dirs() -> Vec<PathBuf> {
let mut v: Vec<PathBuf> = std::env::var_os("FFCINE_LUT_DIR")
.map(|d| std::env::split_paths(&d).collect())
.unwrap_or_default();
if let Some(d) = std::env::var_os("XDG_DATA_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/share")))
{
v.push(d.join("ffcine").join("luts"));
}
v
}
#[derive(Debug, Clone, PartialEq)]
pub struct CameraLut {
pub lut: Lut,
pub primaries: Primaries,
pub transfer: Transfer,
}
impl Profile {
pub fn from_picture(p: PictureProfile) -> Option<Self> {
Some(match p {
PictureProfile::Rec709 => Self::Rec709,
PictureProfile::Dlog => Self::DjiDlog,
PictureProfile::DlogM => Self::DjiDlogM,
PictureProfile::Hlg | PictureProfile::Pq => Self::Hdr,
PictureProfile::CLog3 => Self::CanonClog3,
PictureProfile::AppleLog => Self::AppleLog,
_ => return None,
})
}
pub fn detect_file(info: &MediaInfo) -> Self {
crate::bmff::read(&info.path)
.ok()
.map(|m| crate::profile::detect(&m))
.filter(|g| g.confidence != Confidence::Low)
.and_then(|g| Self::from_picture(g.profile))
.unwrap_or_else(|| Self::detect(info))
}
pub fn detect(info: &MediaInfo) -> Self {
let Some((_, v)) = info.video() else {
return Self::Rec709;
};
let c = v.color;
let tag = |k: &str| info.tags.get(k).map_or("", String::as_str);
let canon = tag("compatible_brands").contains("CAEP") || tag("major_brand") == "CAEP";
let dji = tag("encoder").starts_with("DJI");
if c.is_hdr() {
return Self::Hdr;
}
let wide = matches!(c.primaries, Primaries::Bt2020 | Primaries::DisplayP3);
if c.primaries == Primaries::Bt2020 && c.transfer == Transfer::Unknown {
return if canon {
Self::CanonClog3
} else {
Self::AppleLog
};
}
if wide && c.transfer == Transfer::Bt709 {
return Self::Hdr;
}
if dji && v.pixel_format.contains("10") {
let enc = tag("encoder").replace(' ', "");
return if enc.contains("Pocket3") {
Self::DjiDlogM
} else {
Self::DjiDlog
};
}
Self::Rec709
}
pub fn camera_lut(self, dirs: &[PathBuf]) -> Result<Option<CameraLut>> {
let lut = match self {
Self::Rec709 | Self::Hdr => return Ok(None),
Self::DjiDlog => match find(dirs, self.vendor_names()) {
Some(p) => Lut::from_cube_file(p)?,
None => analytic(
33,
dlog_decode,
&rgb_to_rgb(&D_GAMUT, &BT709),
0.53,
"DJI D-Log / D-Gamut",
),
},
Self::DjiDlogVivid | Self::DjiDlogM | Self::DjiDlogMVivid => {
let names = self.vendor_names();
let path = find(dirs, names).ok_or_else(|| Error::Invalid {
what: "camera LUT (DJI's .cube is not in the LUT dirs: run `ffcine fetch-luts`, \
or download it from https://www.dji.com/downloads and set FFCINE_LUT_DIR)",
value: format!("{self}: tried {names:?} in {dirs:?}"),
})?;
Lut::from_cube_file(path)?
}
Self::AppleLog => match find(dirs, self.vendor_names()) {
Some(p) => Lut::from_cube_file(p)?,
None => analytic(33, apple_log_decode, &BT2020_TO_BT709, 0.8, "Apple Log"),
},
_ => match find(dirs, self.vendor_names()) {
Some(p) => Lut::from_cube_file(p)?,
None => analytic(
33,
clog3_decode,
&rgb_to_rgb(&CINEMA_GAMUT, &BT709),
0.8,
"Canon Log 3",
),
},
};
Ok(Some(CameraLut {
lut,
primaries: Primaries::Bt709,
transfer: Transfer::Bt709,
}))
}
pub fn vendor_names(self) -> &'static [&'static str] {
match self {
Self::DjiDlog => &[
"dlog_to_rec709.cube",
"DJI OSMO Pocket 4 D-Log to Rec.709 V2.0.cube",
"DJI OSMO Pocket 4 D-Log to Rec.709 V1.0.cube",
"DJI Osmo Pocket 4 D-Log to Rec.709 V1.0.cube",
],
Self::DjiDlogVivid => &[
"dlog_to_rec709_vivid.cube",
"DJI OSMO Pocket 4 D-Log to Rec.709 vivid V2.0.cube",
"DJI OSMO Pocket 4 D-Log to Rec.709 vivid V1.0.cube",
"DJI Osmo Pocket 4 D-Log to Rec.709 vivid V1.0.cube",
],
Self::DjiDlogM => &[
"dlogm_to_rec709.cube",
"DJI OSMO Pocket 3 D-Log M to Rec.709 V1.cube",
],
Self::DjiDlogMVivid => &[
"dlogm_to_rec709_vivid.cube",
"DJI OSMO Pocket 3 D-Log M to Rec.709 vivid V1.cube",
],
Self::AppleLog => &["applelog_to_rec709.cube", "AppleLogToRec709-v1.0.cube"],
Self::CanonClog3 => &[
"clog3_to_rec709.cube",
"CinemaGamut_CanonLog3-to-BT709_WideDR_65_FF_Ver.2.0.cube",
],
_ => &[],
}
}
}
pub const VENDOR_LUTS: &[(&str, &str)] = &[
(
"DJI OSMO Pocket 4 D-Log to Rec.709 V2.0.cube",
"https://terra-1-g.djicdn.com/851d20f7b9f64838a34cd02351370894/HG214%20Lut/DJI%20OSMO%20Pocket%204%20D-Log%20to%20Rec.709%20V2.0.cube",
),
(
"DJI OSMO Pocket 4 D-Log to Rec.709 vivid V2.0.cube",
"https://terra-1-g.djicdn.com/851d20f7b9f64838a34cd02351370894/HG214%20Lut/DJI%20OSMO%20Pocket%204%20D-Log%20to%20Rec.709%20vivid%20V2.0.cube",
),
];
fn find(dirs: &[PathBuf], names: &[&str]) -> Option<PathBuf> {
names
.iter()
.flat_map(|n| dirs.iter().map(move |d| d.join(n)))
.find(|p| p.is_file())
}
pub type Chromaticities = [[f64; 2]; 4];
const D65: [f64; 2] = [0.3127, 0.3290];
pub const BT709: Chromaticities = [[0.64, 0.33], [0.30, 0.60], [0.15, 0.06], D65];
pub const BT2020: Chromaticities = [[0.708, 0.292], [0.170, 0.797], [0.131, 0.046], D65];
pub const D_GAMUT: Chromaticities = [[0.71, 0.31], [0.21, 0.88], [0.09, -0.08], D65];
pub const CINEMA_GAMUT: Chromaticities = [[0.74, 0.27], [0.17, 1.14], [0.08, -0.10], D65];
type M3 = [[f64; 3]; 3];
const BT2020_TO_BT709: M3 = [
[1.6605, -0.5876, -0.0728],
[-0.1246, 1.1329, -0.0083],
[-0.0182, -0.1006, 1.1187],
];
fn mul(a: &M3, b: &M3) -> M3 {
let mut m = [[0.0; 3]; 3];
for (i, row) in m.iter_mut().enumerate() {
for (j, v) in row.iter_mut().enumerate() {
*v = (0..3).map(|k| a[i][k] * b[k][j]).sum();
}
}
m
}
fn inv(m: &M3) -> M3 {
let [[a, b, c], [d, e, f], [g, h, i]] = *m;
let det = a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g);
[
[
(e * i - f * h) / det,
(c * h - b * i) / det,
(b * f - c * e) / det,
],
[
(f * g - d * i) / det,
(a * i - c * g) / det,
(c * d - a * f) / det,
],
[
(d * h - e * g) / det,
(b * g - a * h) / det,
(a * e - b * d) / det,
],
]
}
pub fn rgb_to_xyz(c: &Chromaticities) -> [[f64; 3]; 3] {
let xyz = |[x, y]: [f64; 2]| [x / y, 1.0, (1.0 - x - y) / y];
let [r, g, b, w] = c.map(xyz);
let p = [[r[0], g[0], b[0]], [r[1], g[1], b[1]], [r[2], g[2], b[2]]];
let pi = inv(&p);
let s: [f64; 3] = std::array::from_fn(|i| (0..3).map(|k| pi[i][k] * w[k]).sum());
std::array::from_fn(|i| std::array::from_fn(|j| p[i][j] * s[j]))
}
pub fn rgb_to_rgb(from: &Chromaticities, to: &Chromaticities) -> [[f64; 3]; 3] {
mul(&inv(&rgb_to_xyz(to)), &rgb_to_xyz(from))
}
pub fn apple_log_decode(p: f64) -> f64 {
const R0: f64 = -0.056_410_88;
const RT: f64 = 0.01;
const C: f64 = 47.287_112_36;
const BETA: f64 = 0.009_640_52;
const GAMMA: f64 = 0.085_504_79;
const DELTA: f64 = 0.693_369_45;
let pt = C * (RT - R0).powi(2);
if p >= pt {
2f64.powf((p - DELTA) / GAMMA) - BETA
} else if p > 0.0 {
(p / C).sqrt() + R0
} else {
R0
}
}
pub fn dlog_decode(y: f64) -> f64 {
if y <= 0.14 {
(y - 0.0929) / 6.025
} else {
(10f64.powf(3.896_16 * y - 2.277_52) - 0.0108) / 0.9892
}
}
pub fn clog3_decode(y: f64) -> f64 {
let x = if y < 0.097_465_473 {
-(10f64.powf((0.127_839_01 - y) / 0.367_268_45) - 1.0) / 14.983_25
} else if y <= 0.152_778_91 {
(y - 0.125_122_19) / 1.975_479_8
} else {
(10f64.powf((y - 0.122_405_37) / 0.367_268_45) - 1.0) / 14.983_25
};
x * 0.9
}
fn filmic(x: f64) -> f64 {
let x = x.max(0.0);
((x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14)).clamp(0.0, 1.0)
}
fn analytic(n: usize, decode: fn(f64) -> f64, gamut: &M3, exposure: f64, title: &str) -> Lut {
let mut lut = Lut::identity(n);
for e in &mut lut.data {
let lin = e.map(|v| decode(f64::from(v)));
*e = std::array::from_fn(|i| {
let v: f64 = (0..3).map(|k| gamut[i][k] * lin[k]).sum();
#[allow(clippy::cast_possible_truncation)]
let out = filmic(v * exposure).powf(1.0 / 2.4) as f32;
out
});
}
lut.title = Some(format!("{title} to Rec.709 (ffcine analytic)"));
lut
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ProfileChoice {
#[default]
Auto,
Fixed(Profile),
}
impl ProfileChoice {
pub fn resolve(self, info: &MediaInfo) -> Profile {
match self {
Self::Auto => Profile::detect_file(info),
Self::Fixed(p) => p,
}
}
}
impl FromStr for ProfileChoice {
type Err = Error;
fn from_str(s: &str) -> Result<Self> {
if s == "auto" {
Ok(Self::Auto)
} else {
s.parse().map(Self::Fixed)
}
}
}
impl fmt::Display for ProfileChoice {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Auto => f.write_str("auto"),
Self::Fixed(p) => p.fmt(f),
}
}
}
pub fn write_cube(lut: &Lut, path: &Path) -> Result<()> {
use std::fmt::Write as _;
let mut s = String::new();
if let Some(t) = &lut.title {
let _ = writeln!(s, "TITLE \"{t}\"");
}
let _ = writeln!(s, "LUT_3D_SIZE {}", lut.size);
for [r, g, b] in &lut.data {
let _ = writeln!(s, "{r:.6} {g:.6} {b:.6}");
}
std::fs::write(path, s).map_err(|source| Error::Io {
path: path.to_owned(),
source,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ColorSpace;
use crate::color::{Matrix, Range};
use crate::probe::{StreamInfo, StreamKind, VideoInfo};
fn info(color: ColorSpace, pix: &str, tags: &[(&str, &str)]) -> MediaInfo {
MediaInfo {
path: "x".into(),
format: "mov".into(),
duration: None,
bit_rate: None,
streams: vec![StreamInfo {
index: 0,
codec: "hevc".into(),
bit_rate: None,
kind: StreamKind::Video(VideoInfo {
width: 3840,
height: 2160,
pixel_format: pix.into(),
frame_rate: None,
frames: None,
color,
}),
}],
tags: tags
.iter()
.map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
.collect(),
}
}
#[test]
fn detects_profiles() {
let sdr = ColorSpace::BT709_TV;
let dji = [("encoder", "DJI OsmoPocket4")];
assert_eq!(
Profile::detect(&info(sdr, "yuv420p10le", &dji)),
Profile::DjiDlog
);
assert_eq!(
Profile::detect(&info(sdr, "yuv420p10le", &[("encoder", "DJI OsmoPocket3")])),
Profile::DjiDlogM
);
assert_eq!(
Profile::detect(&info(sdr, "yuv420p", &dji)),
Profile::Rec709
);
let hlg = ColorSpace {
primaries: Primaries::Bt2020,
transfer: Transfer::Hlg,
matrix: Matrix::Bt2020Ncl,
range: Range::Limited,
};
assert_eq!(
Profile::detect(&info(hlg, "yuv420p10le", &dji)),
Profile::Hdr
);
let log = ColorSpace {
transfer: Transfer::Unknown,
..hlg
};
assert_eq!(
Profile::detect(&info(log, "yuv422p10le", &[])),
Profile::AppleLog
);
assert_eq!(
Profile::detect(&info(
log,
"yuv422p10le",
&[("compatible_brands", "mp42CAEP")]
)),
Profile::CanonClog3
);
let pq = ColorSpace {
transfer: Transfer::Pq,
..hlg
};
assert_eq!(
Profile::detect(&info(pq, "yuv422p10le", &[("compatible_brands", "CAEP")])),
Profile::Hdr
);
assert_eq!(
"dlogm-vivid".parse::<ProfileChoice>().unwrap(),
ProfileChoice::Fixed(Profile::DjiDlogMVivid)
);
}
#[test]
fn picture_profiles_map() {
assert_eq!(
Profile::from_picture(PictureProfile::Dlog),
Some(Profile::DjiDlog)
);
assert_eq!(Profile::from_picture(PictureProfile::Dlog2), None);
let i = info(
ColorSpace::BT709_TV,
"yuv420p10le",
&[("encoder", "DJI OsmoPocket4")],
);
assert_eq!(Profile::detect_file(&i), Profile::DjiDlog);
}
#[test]
fn curves() {
assert!((apple_log_decode(0.488_2) - 0.18).abs() < 0.002);
assert!((dlog_decode(0.3983) - 0.18).abs() < 0.002);
assert!((dlog_decode(0.14 - 1e-9) - dlog_decode(0.14 + 1e-9)).abs() < 1e-3);
assert!((clog3_decode(0.343_4) - 0.18).abs() < 0.001);
assert!((clog3_decode(0.564_5) - 0.9).abs() < 0.002);
for y in [0.097_465_473, 0.152_778_91] {
assert!((clog3_decode(y - 1e-9) - clog3_decode(y + 1e-9)).abs() < 1e-6);
}
}
#[test]
fn gamut_matrices() {
let m = rgb_to_rgb(&BT2020, &BT709);
for i in 0..3 {
for j in 0..3 {
assert!((m[i][j] - BT2020_TO_BT709[i][j]).abs() < 1e-3, "{m:?}");
}
assert!((m[i].iter().sum::<f64>() - 1.0).abs() < 1e-9);
}
}
#[test]
fn analytic_luts() {
let lut = Profile::AppleLog.camera_lut(&[]).unwrap().unwrap().lut;
assert_eq!(lut.size, 33);
let grey: Vec<f32> = (0..33).map(|i| lut.at(i, i, i)[1]).collect();
assert!(grey.windows(2).all(|w| w[1] >= w[0]));
assert!(grey[0] < 0.02);
let c = Profile::CanonClog3.camera_lut(&[]).unwrap().unwrap();
assert_eq!(c.primaries, Primaries::Bt709);
assert!(Profile::Hdr.camera_lut(&[]).unwrap().is_none());
assert!(Profile::DjiDlogM.camera_lut(&[]).is_err());
assert!(Profile::DjiDlog.camera_lut(&[]).unwrap().is_some());
}
}