1use std::collections::BTreeMap;
15
16use serde::{Deserialize, Serialize};
17use serde_json::{Map, Value};
18
19pub const PROJECT_SCHEMA: &str = "ffcine.project/1";
21pub const GRADE_SCHEMA: &str = "ffcine.color.grade/1";
23
24#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
27#[serde(default)]
28pub struct GradeParams {
29 #[serde(skip_serializing_if = "Option::is_none")]
33 pub camera: Option<String>,
34 #[serde(skip_serializing_if = "Option::is_none")]
36 pub lut: Option<String>,
37 #[serde(skip_serializing_if = "Option::is_none")]
39 pub lut_strength: Option<f64>,
40 #[serde(skip_serializing_if = "Option::is_none")]
42 pub rgb_gain: Option<[f64; 3]>,
43 #[serde(skip_serializing_if = "Option::is_none")]
45 pub warmth: Option<f64>,
46 #[serde(skip_serializing_if = "Option::is_none")]
48 pub tint: Option<f64>,
49 #[serde(skip_serializing_if = "Option::is_none")]
51 pub exposure: Option<f64>,
52 #[serde(skip_serializing_if = "Option::is_none")]
54 pub contrast: Option<f64>,
55 #[serde(skip_serializing_if = "Option::is_none")]
57 pub gamma: Option<f64>,
58 #[serde(skip_serializing_if = "Option::is_none")]
60 pub lift: Option<f64>,
61 #[serde(skip_serializing_if = "Option::is_none")]
63 pub gain: Option<f64>,
64 #[serde(skip_serializing_if = "Option::is_none")]
65 pub saturation: Option<f64>,
66 #[serde(skip_serializing_if = "Option::is_none")]
68 pub denoise: Option<f64>,
69 #[serde(skip_serializing_if = "Option::is_none")]
71 pub sharpen: Option<f64>,
72 #[serde(skip_serializing_if = "Option::is_none")]
74 pub extra: Option<Vec<String>>,
75 #[serde(skip_serializing_if = "Option::is_none")]
77 pub extra_light: Option<Vec<String>>,
78 #[serde(flatten)]
80 pub other: Map<String, Value>,
81}
82
83macro_rules! overlay_fields {
84 ($self:ident, $top:ident; $($f:ident),*) => {
85 $(if $top.$f.is_some() {
86 $self.$f.clone_from(&$top.$f);
87 })*
88 };
89}
90
91impl GradeParams {
92 pub fn overlay(&mut self, top: &GradeParams) {
94 overlay_fields!(self, top; camera, lut, lut_strength, rgb_gain, warmth, tint, exposure, contrast,
95 gamma, lift, gain, saturation, denoise, sharpen, extra, extra_light);
96 for (k, v) in &top.other {
97 self.other.insert(k.clone(), v.clone());
98 }
99 }
100
101 pub fn apply_light(&mut self, k: f64) {
103 self.exposure = None;
104 self.rgb_gain = None;
105 self.lut_strength = None;
106 self.lut = Some("none".into());
107 self.gamma = Some(1.0);
108 self.lift = Some(0.0);
109 self.gain = Some(1.0);
110 self.denoise = Some(0.0);
111 self.warmth = Some(self.warmth.unwrap_or(0.0) * k);
112 self.contrast = Some(self.contrast.unwrap_or(0.0) * k);
113 self.saturation = Some(1.0 + (self.saturation.unwrap_or(1.0) - 1.0) * k);
114 if self.extra_light.is_some() {
115 self.extra.clone_from(&self.extra_light);
116 }
117 }
118}
119
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub enum SourceKind {
123 Log,
125 Display,
127}
128
129#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
131#[serde(default)]
132pub struct Rules {
133 #[serde(skip_serializing_if = "Option::is_none")]
135 pub light: Option<f64>,
136 #[serde(skip_serializing_if = "Option::is_none")]
138 pub iphone_tame: Option<Value>,
139 #[serde(flatten)]
140 pub other: Map<String, Value>,
141}
142
143#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
147#[serde(default)]
148pub struct MixParams {
149 #[serde(skip_serializing_if = "Option::is_none")]
151 pub lufs: Option<f64>,
152 #[serde(skip_serializing_if = "Option::is_none")]
154 pub normalize: Option<bool>,
155 #[serde(skip_serializing_if = "Option::is_none")]
157 pub true_peak: Option<f64>,
158 #[serde(skip_serializing_if = "Option::is_none")]
160 pub headroom: Option<f64>,
161 #[serde(skip_serializing_if = "Option::is_none")]
163 pub fade_out: Option<f64>,
164 #[serde(skip_serializing_if = "Option::is_none")]
166 pub dialogue_lufs: Option<f64>,
167 #[serde(skip_serializing_if = "Option::is_none")]
169 pub nat_lufs: Option<f64>,
170 #[serde(skip_serializing_if = "Option::is_none")]
172 pub gain_range: Option<[f64; 2]>,
173 #[serde(skip_serializing_if = "Option::is_none")]
175 pub xfade: Option<f64>,
176 #[serde(skip_serializing_if = "Option::is_none")]
178 pub duck_db: Option<f64>,
179 #[serde(skip_serializing_if = "Option::is_none")]
181 pub nat_duck_db: Option<f64>,
182 #[serde(skip_serializing_if = "Option::is_none")]
184 pub duck_attack: Option<f64>,
185 #[serde(skip_serializing_if = "Option::is_none")]
186 pub duck_release: Option<f64>,
187 #[serde(skip_serializing_if = "Option::is_none")]
189 pub sidechain: Option<bool>,
190 #[serde(skip_serializing_if = "Option::is_none")]
192 pub duck_threshold: Option<f64>,
193 #[serde(skip_serializing_if = "Option::is_none")]
195 pub clean: Option<f64>,
196 #[serde(skip_serializing_if = "Option::is_none")]
198 pub clean_nat: Option<f64>,
199 #[serde(skip_serializing_if = "Option::is_none")]
201 pub highpass: Option<f64>,
202 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
205 pub buses: BTreeMap<String, BusParams>,
206 #[serde(flatten)]
208 pub other: Map<String, Value>,
209}
210
211#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
213#[serde(default)]
214pub struct BusParams {
215 #[serde(skip_serializing_if = "Option::is_none")]
217 pub gain_db: Option<f64>,
218 #[serde(skip_serializing_if = "Option::is_none")]
220 pub filter: Option<String>,
221 #[serde(skip_serializing_if = "Vec::is_empty")]
223 pub sends: Vec<BusSend>,
224 #[serde(skip_serializing_if = "Option::is_none")]
226 pub reverb: Option<BusReverb>,
227 #[serde(skip_serializing_if = "Option::is_none")]
229 pub duck: Option<BusDuck>,
230}
231
232#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
234#[serde(default)]
235pub struct BusSend {
236 pub to: String,
238 pub gain_db: f64,
240 #[serde(skip_serializing_if = "std::ops::Not::not")]
242 pub pre: bool,
243}
244
245#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
247#[serde(default)]
248pub struct BusReverb {
249 pub room: f64,
251 pub damp: f64,
253}
254
255impl Default for BusReverb {
256 fn default() -> Self {
257 Self {
258 room: 0.5,
259 damp: 0.5,
260 }
261 }
262}
263
264#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
266#[serde(default)]
267pub struct BusDuck {
268 pub targets: Vec<String>,
270 pub depth_db: f64,
272 pub threshold_db: f64,
274 pub attack: f64,
276 pub release: f64,
277}
278
279impl Default for BusDuck {
280 fn default() -> Self {
281 Self {
282 targets: Vec::new(),
283 depth_db: -12.0,
284 threshold_db: -40.0,
285 attack: 0.04,
286 release: 0.35,
287 }
288 }
289}
290
291impl MixParams {
292 pub fn is_empty(&self) -> bool {
293 self == &Self::default()
294 }
295}
296
297#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
299pub struct Project {
300 pub schema: String,
301 #[serde(default, skip_serializing_if = "Option::is_none")]
303 pub rate: Option<f64>,
304 #[serde(default)]
306 pub grade: GradeParams,
307 #[serde(default, skip_serializing_if = "Option::is_none")]
309 pub default_look: Option<String>,
310 #[serde(default)]
311 pub looks: BTreeMap<String, GradeParams>,
312 #[serde(default)]
314 pub luts: BTreeMap<String, String>,
315 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
317 pub shaders: BTreeMap<String, String>,
318 #[serde(default)]
319 pub rules: Rules,
320 #[serde(default, skip_serializing_if = "Map::is_empty")]
322 pub styles: Map<String, Value>,
323 #[serde(default, skip_serializing_if = "Output::is_empty")]
325 pub output: Output,
326 #[serde(default, skip_serializing_if = "MixParams::is_empty")]
328 pub mix: MixParams,
329 #[serde(flatten)]
330 pub other: Map<String, Value>,
331}
332
333#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
336#[serde(default)]
337pub struct Output {
338 #[serde(skip_serializing_if = "Option::is_none")]
341 pub aspect: Option<String>,
342 #[serde(skip_serializing_if = "OutputMode::is_empty")]
344 pub preview: OutputMode,
345 #[serde(skip_serializing_if = "OutputMode::is_empty")]
347 pub master: OutputMode,
348 #[serde(flatten)]
349 pub other: Map<String, Value>,
350}
351
352impl Output {
353 pub fn is_empty(&self) -> bool {
354 *self == Self::default()
355 }
356}
357
358#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
360#[serde(default)]
361pub struct OutputMode {
362 #[serde(skip_serializing_if = "Option::is_none")]
364 pub size: Option<[u32; 2]>,
365 #[serde(skip_serializing_if = "Option::is_none")]
367 pub max_punch: Option<f64>,
368}
369
370impl OutputMode {
371 pub fn is_empty(&self) -> bool {
372 *self == Self::default()
373 }
374}
375
376impl Default for Project {
377 fn default() -> Self {
378 Self {
379 schema: PROJECT_SCHEMA.into(),
380 rate: None,
381 grade: GradeParams::default(),
382 default_look: None,
383 looks: BTreeMap::new(),
384 luts: BTreeMap::new(),
385 shaders: BTreeMap::new(),
386 rules: Rules::default(),
387 styles: Map::new(),
388 output: Output::default(),
389 mix: MixParams::default(),
390 other: Map::new(),
391 }
392 }
393}
394
395#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
397pub struct ClipGrade {
398 #[serde(default, skip_serializing_if = "Option::is_none")]
399 pub look: Option<String>,
400 #[serde(flatten)]
401 pub params: GradeParams,
402}
403
404#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
406pub enum GradeError {
407 #[error("unknown look {0:?}")]
408 UnknownLook(String),
409}
410
411impl Project {
412 pub fn resolve_grade(
415 &self,
416 clip: Option<&ClipGrade>,
417 kind: SourceKind,
418 ) -> Result<GradeParams, GradeError> {
419 let mut g = self.grade.clone();
420 let look = clip
421 .and_then(|c| c.look.as_deref())
422 .or(self.default_look.as_deref());
423 if let Some(name) = look {
424 let l = self
425 .looks
426 .get(name)
427 .ok_or_else(|| GradeError::UnknownLook(name.to_owned()))?;
428 g.overlay(l);
429 }
430 if kind == SourceKind::Display {
431 g.apply_light(self.rules.light.unwrap_or(0.6));
432 }
433 if let Some(c) = clip {
434 g.overlay(&c.params);
435 }
436 Ok(g)
437 }
438
439 pub fn lut_path<'a>(&'a self, lut: Option<&'a str>) -> Option<&'a str> {
441 match lut {
442 None | Some("" | "none") => None,
443 Some(name) => Some(self.luts.get(name).map_or(name, String::as_str)),
444 }
445 }
446}
447
448#[cfg(test)]
449mod tests {
450 use super::*;
451 use serde_json::json;
452
453 fn project() -> Project {
454 serde_json::from_value(json!({
455 "schema": PROJECT_SCHEMA,
456 "grade": {"lut": "normal", "warmth": 0.0, "gamma": 1.0, "saturation": 1.0, "denoise": 0},
457 "default_look": "day",
458 "looks": {
459 "day": {"warmth": 0.022, "contrast": 0.16, "saturation": 1.05,
460 "extra": ["a", "b"], "extra_light": ["b"]},
461 "night": {"gamma": 1.22, "denoise": 3},
462 },
463 "luts": {"normal": "/luts/n.cube"},
464 "rules": {"light": 0.5},
465 }))
466 .expect("project")
467 }
468
469 #[test]
470 fn layers_base_look_override() {
471 let p = project();
472 let clip = ClipGrade {
473 look: Some("night".into()),
474 params: GradeParams {
475 gamma: Some(1.3),
476 ..GradeParams::default()
477 },
478 };
479 let g = p
480 .resolve_grade(Some(&clip), SourceKind::Log)
481 .expect("grade");
482 assert_eq!(g.gamma, Some(1.3));
483 assert_eq!(g.denoise, Some(3.0));
484 assert_eq!(g.warmth, Some(0.0));
485 assert_eq!(p.lut_path(g.lut.as_deref()), Some("/luts/n.cube"));
486
487 let g = p.resolve_grade(None, SourceKind::Log).expect("grade");
488 assert_eq!(g.warmth, Some(0.022));
489 assert_eq!(g.extra, Some(vec!["a".to_owned(), "b".to_owned()]));
490 }
491
492 #[test]
493 fn light_rule_then_override() {
494 let p = project();
495 let clip = ClipGrade {
496 params: GradeParams {
497 lut: Some("normal".into()),
498 ..GradeParams::default()
499 },
500 ..ClipGrade::default()
501 };
502 let g = p
503 .resolve_grade(Some(&clip), SourceKind::Display)
504 .expect("grade");
505 assert!((g.warmth.unwrap_or_default() - 0.011).abs() < 1e-12);
506 assert!((g.saturation.unwrap_or_default() - 1.025).abs() < 1e-12);
507 assert_eq!(g.gamma, Some(1.0));
508 assert_eq!(g.extra, Some(vec!["b".to_owned()]));
509 assert_eq!(g.lut.as_deref(), Some("normal"));
511 }
512
513 #[test]
514 fn unknown_look() {
515 let p = project();
516 let clip = ClipGrade {
517 look: Some("noir".into()),
518 ..ClipGrade::default()
519 };
520 assert_eq!(
521 p.resolve_grade(Some(&clip), SourceKind::Log),
522 Err(GradeError::UnknownLook("noir".into()))
523 );
524 }
525
526 #[test]
527 fn grade_effect_round_trips_unknown_fields() {
528 let v = json!({"look": "day", "gamma": 1.2, "future": {"x": 1}});
529 let g: ClipGrade = serde_json::from_value(v.clone()).expect("grade");
530 assert_eq!(g.params.other["future"], json!({"x": 1}));
531 assert_eq!(serde_json::to_value(&g).expect("json"), v);
532 }
533}