use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
pub const PROJECT_SCHEMA: &str = "ffcine.project/1";
pub const GRADE_SCHEMA: &str = "ffcine.color.grade/1";
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct GradeParams {
#[serde(skip_serializing_if = "Option::is_none")]
pub camera: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub lut: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub lut_strength: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rgb_gain: Option<[f64; 3]>,
#[serde(skip_serializing_if = "Option::is_none")]
pub warmth: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tint: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub exposure: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub contrast: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub gamma: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub lift: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub gain: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub saturation: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub denoise: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sharpen: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub extra: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub extra_light: Option<Vec<String>>,
#[serde(flatten)]
pub other: Map<String, Value>,
}
macro_rules! overlay_fields {
($self:ident, $top:ident; $($f:ident),*) => {
$(if $top.$f.is_some() {
$self.$f.clone_from(&$top.$f);
})*
};
}
impl GradeParams {
pub fn overlay(&mut self, top: &GradeParams) {
overlay_fields!(self, top; camera, lut, lut_strength, rgb_gain, warmth, tint, exposure, contrast,
gamma, lift, gain, saturation, denoise, sharpen, extra, extra_light);
for (k, v) in &top.other {
self.other.insert(k.clone(), v.clone());
}
}
pub fn apply_light(&mut self, k: f64) {
self.exposure = None;
self.rgb_gain = None;
self.lut_strength = None;
self.lut = Some("none".into());
self.gamma = Some(1.0);
self.lift = Some(0.0);
self.gain = Some(1.0);
self.denoise = Some(0.0);
self.warmth = Some(self.warmth.unwrap_or(0.0) * k);
self.contrast = Some(self.contrast.unwrap_or(0.0) * k);
self.saturation = Some(1.0 + (self.saturation.unwrap_or(1.0) - 1.0) * k);
if self.extra_light.is_some() {
self.extra.clone_from(&self.extra_light);
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SourceKind {
Log,
Display,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Rules {
#[serde(skip_serializing_if = "Option::is_none")]
pub light: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub iphone_tame: Option<Value>,
#[serde(flatten)]
pub other: Map<String, Value>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct MixParams {
#[serde(skip_serializing_if = "Option::is_none")]
pub lufs: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub normalize: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub true_peak: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub headroom: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub fade_out: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dialogue_lufs: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub nat_lufs: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub gain_range: Option<[f64; 2]>,
#[serde(skip_serializing_if = "Option::is_none")]
pub xfade: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub duck_db: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub nat_duck_db: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub duck_attack: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub duck_release: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sidechain: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub duck_threshold: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub clean: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub clean_nat: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub highpass: Option<f64>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub buses: BTreeMap<String, BusParams>,
#[serde(flatten)]
pub other: Map<String, Value>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct BusParams {
#[serde(skip_serializing_if = "Option::is_none")]
pub gain_db: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub filter: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub sends: Vec<BusSend>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reverb: Option<BusReverb>,
#[serde(skip_serializing_if = "Option::is_none")]
pub duck: Option<BusDuck>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct BusSend {
pub to: String,
pub gain_db: f64,
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub pre: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct BusReverb {
pub room: f64,
pub damp: f64,
}
impl Default for BusReverb {
fn default() -> Self {
Self {
room: 0.5,
damp: 0.5,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct BusDuck {
pub targets: Vec<String>,
pub depth_db: f64,
pub threshold_db: f64,
pub attack: f64,
pub release: f64,
}
impl Default for BusDuck {
fn default() -> Self {
Self {
targets: Vec::new(),
depth_db: -12.0,
threshold_db: -40.0,
attack: 0.04,
release: 0.35,
}
}
}
impl MixParams {
pub fn is_empty(&self) -> bool {
self == &Self::default()
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Project {
pub schema: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rate: Option<f64>,
#[serde(default)]
pub grade: GradeParams,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_look: Option<String>,
#[serde(default)]
pub looks: BTreeMap<String, GradeParams>,
#[serde(default)]
pub luts: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub shaders: BTreeMap<String, String>,
#[serde(default)]
pub rules: Rules,
#[serde(default, skip_serializing_if = "Map::is_empty")]
pub styles: Map<String, Value>,
#[serde(default, skip_serializing_if = "Output::is_empty")]
pub output: Output,
#[serde(default, skip_serializing_if = "MixParams::is_empty")]
pub mix: MixParams,
#[serde(flatten)]
pub other: Map<String, Value>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Output {
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect: Option<String>,
#[serde(skip_serializing_if = "OutputMode::is_empty")]
pub preview: OutputMode,
#[serde(skip_serializing_if = "OutputMode::is_empty")]
pub master: OutputMode,
#[serde(flatten)]
pub other: Map<String, Value>,
}
impl Output {
pub fn is_empty(&self) -> bool {
*self == Self::default()
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct OutputMode {
#[serde(skip_serializing_if = "Option::is_none")]
pub size: Option<[u32; 2]>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_punch: Option<f64>,
}
impl OutputMode {
pub fn is_empty(&self) -> bool {
*self == Self::default()
}
}
impl Default for Project {
fn default() -> Self {
Self {
schema: PROJECT_SCHEMA.into(),
rate: None,
grade: GradeParams::default(),
default_look: None,
looks: BTreeMap::new(),
luts: BTreeMap::new(),
shaders: BTreeMap::new(),
rules: Rules::default(),
styles: Map::new(),
output: Output::default(),
mix: MixParams::default(),
other: Map::new(),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct ClipGrade {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub look: Option<String>,
#[serde(flatten)]
pub params: GradeParams,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum GradeError {
#[error("unknown look {0:?}")]
UnknownLook(String),
}
impl Project {
pub fn resolve_grade(
&self,
clip: Option<&ClipGrade>,
kind: SourceKind,
) -> Result<GradeParams, GradeError> {
let mut g = self.grade.clone();
let look = clip
.and_then(|c| c.look.as_deref())
.or(self.default_look.as_deref());
if let Some(name) = look {
let l = self
.looks
.get(name)
.ok_or_else(|| GradeError::UnknownLook(name.to_owned()))?;
g.overlay(l);
}
if kind == SourceKind::Display {
g.apply_light(self.rules.light.unwrap_or(0.6));
}
if let Some(c) = clip {
g.overlay(&c.params);
}
Ok(g)
}
pub fn lut_path<'a>(&'a self, lut: Option<&'a str>) -> Option<&'a str> {
match lut {
None | Some("" | "none") => None,
Some(name) => Some(self.luts.get(name).map_or(name, String::as_str)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn project() -> Project {
serde_json::from_value(json!({
"schema": PROJECT_SCHEMA,
"grade": {"lut": "normal", "warmth": 0.0, "gamma": 1.0, "saturation": 1.0, "denoise": 0},
"default_look": "day",
"looks": {
"day": {"warmth": 0.022, "contrast": 0.16, "saturation": 1.05,
"extra": ["a", "b"], "extra_light": ["b"]},
"night": {"gamma": 1.22, "denoise": 3},
},
"luts": {"normal": "/luts/n.cube"},
"rules": {"light": 0.5},
}))
.expect("project")
}
#[test]
fn layers_base_look_override() {
let p = project();
let clip = ClipGrade {
look: Some("night".into()),
params: GradeParams {
gamma: Some(1.3),
..GradeParams::default()
},
};
let g = p
.resolve_grade(Some(&clip), SourceKind::Log)
.expect("grade");
assert_eq!(g.gamma, Some(1.3));
assert_eq!(g.denoise, Some(3.0));
assert_eq!(g.warmth, Some(0.0));
assert_eq!(p.lut_path(g.lut.as_deref()), Some("/luts/n.cube"));
let g = p.resolve_grade(None, SourceKind::Log).expect("grade");
assert_eq!(g.warmth, Some(0.022));
assert_eq!(g.extra, Some(vec!["a".to_owned(), "b".to_owned()]));
}
#[test]
fn light_rule_then_override() {
let p = project();
let clip = ClipGrade {
params: GradeParams {
lut: Some("normal".into()),
..GradeParams::default()
},
..ClipGrade::default()
};
let g = p
.resolve_grade(Some(&clip), SourceKind::Display)
.expect("grade");
assert!((g.warmth.unwrap_or_default() - 0.011).abs() < 1e-12);
assert!((g.saturation.unwrap_or_default() - 1.025).abs() < 1e-12);
assert_eq!(g.gamma, Some(1.0));
assert_eq!(g.extra, Some(vec!["b".to_owned()]));
assert_eq!(g.lut.as_deref(), Some("normal"));
}
#[test]
fn unknown_look() {
let p = project();
let clip = ClipGrade {
look: Some("noir".into()),
..ClipGrade::default()
};
assert_eq!(
p.resolve_grade(Some(&clip), SourceKind::Log),
Err(GradeError::UnknownLook("noir".into()))
);
}
#[test]
fn grade_effect_round_trips_unknown_fields() {
let v = json!({"look": "day", "gamma": 1.2, "future": {"x": 1}});
let g: ClipGrade = serde_json::from_value(v.clone()).expect("grade");
assert_eq!(g.params.other["future"], json!({"x": 1}));
assert_eq!(serde_json::to_value(&g).expect("json"), v);
}
}