Skip to main content

ffcine_timeline/
project.rs

1//! Project-level ffcine metadata (`Timeline.metadata.ffcine.project`) and grade layering.
2//!
3//! Schema `ffcine.project/1`, see `docs/timeline.md`. A clip's grade resolves as
4//!
5//! ```text
6//! project.grade  <-  looks[look]  <-  source-kind rule  <-  clip's ffcine.color.grade/1
7//! ```
8//!
9//! where `look` is the clip's `look`, else `project.default_look`, and the source-kind rule is the
10//! prototype's "light" treatment of non-log footage (iPhone, AI): no LUT, gamma, lift, gain or
11//! denoise, and warmth/contrast/saturation scaled by `rules.light`. Each layer only sets the
12//! fields it has (`GradeParams` is all-`Option`).
13
14use std::collections::BTreeMap;
15
16use serde::{Deserialize, Serialize};
17use serde_json::{Map, Value};
18
19/// Schema tag of [`Project`].
20pub const PROJECT_SCHEMA: &str = "ffcine.project/1";
21/// Schema tag of a clip's grade effect ([`ClipGrade`]).
22pub const GRADE_SCHEMA: &str = "ffcine.color.grade/1";
23
24/// A partial grade: base, look or per-clip override. Units follow the prototype's ffmpeg chain
25/// (`render.py` `grade_filters`).
26#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
27#[serde(default)]
28pub struct GradeParams {
29    /// Camera profile (`ffcine_core::camera::Profile` name: `dlogm`, `dlogm-vivid`, `clog3`,
30    /// `applelog`, `hdr`, `rec709`) or `auto` (from the probe, the default). Picks the camera
31    /// LUT applied before the grade.
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub camera: Option<String>,
34    /// LUT alias from `project.luts`, a `.cube` path, or `"none"`.
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub lut: Option<String>,
37    /// LUT mix with identity, 0..1.
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub lut_strength: Option<f64>,
40    /// Per-channel multiplier before the LUT (log footage only unless set on the clip).
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub rgb_gain: Option<[f64; 3]>,
43    /// + warmer mids/highs, - cooler (`colorbalance`).
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub warmth: Option<f64>,
46    /// + magenta, - green.
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub tint: Option<f64>,
49    /// Stops, before the LUT.
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub exposure: Option<f64>,
52    /// S-curve strength, 0 = off.
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub contrast: Option<f64>,
55    /// >1 brightens mids.
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub gamma: Option<f64>,
58    /// Raises blacks, 0..0.05.
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub lift: Option<f64>,
61    /// Highlight gain.
62    #[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    /// Denoise strength (`hqdn3d` d; `nlmeans` s = 0.2 d), 0 = off. Settable in looks.
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub denoise: Option<f64>,
69    /// Unsharp amount, 0 = off. Settable in looks.
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub sharpen: Option<f64>,
72    /// Raw ffmpeg filters after the grade.
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub extra: Option<Vec<String>>,
75    /// Replaces `extra` under the light (non-log) rule.
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub extra_light: Option<Vec<String>>,
78    /// Fields this version doesn't know, kept as-is.
79    #[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    /// Sets every field `top` has.
93    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    /// The prototype's treatment of non-log footage at strength `k` (`render.py` `Project.grade`).
102    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/// What kind of source a clip is, for the source-kind grade rules.
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub enum SourceKind {
123    /// Log footage that takes the camera LUT (DJI D-Log M).
124    Log,
125    /// Display-referred footage (iPhone, AI, stills): the light rule.
126    Display,
127}
128
129/// Per-source-kind rules.
130#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
131#[serde(default)]
132pub struct Rules {
133    /// Strength of the look on display-referred footage (prototype default 0.6).
134    #[serde(skip_serializing_if = "Option::is_none")]
135    pub light: Option<f64>,
136    /// iPhone yellow-green tamer LUT: `{lut, rot, knee, amount}`.
137    #[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/// `project.mix`: the timeline's mix settings, read by `ffcine audio` and `ffcine render`
144/// (`docs/audio.md`). Unset fields keep `ffcine-audio`'s defaults (in brackets); the CLI's
145/// flags override them.
146#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
147#[serde(default)]
148pub struct MixParams {
149    /// Master loudness, LUFS [-14].
150    #[serde(skip_serializing_if = "Option::is_none")]
151    pub lufs: Option<f64>,
152    /// `false` leaves the master loudness as mixed (still limited) [true].
153    #[serde(skip_serializing_if = "Option::is_none")]
154    pub normalize: Option<bool>,
155    /// Master true-peak ceiling, dBTP [-1].
156    #[serde(skip_serializing_if = "Option::is_none")]
157    pub true_peak: Option<f64>,
158    /// Limiter this far under `true_peak`, dB [default: 2].
159    #[serde(skip_serializing_if = "Option::is_none")]
160    pub headroom: Option<f64>,
161    /// Master fade-out at the end, s [default: 0.6].
162    #[serde(skip_serializing_if = "Option::is_none")]
163    pub fade_out: Option<f64>,
164    /// R128 target of `dialogue` clips, LUFS [-18].
165    #[serde(skip_serializing_if = "Option::is_none")]
166    pub dialogue_lufs: Option<f64>,
167    /// R128 target of `nat` clips, LUFS [-22].
168    #[serde(skip_serializing_if = "Option::is_none")]
169    pub nat_lufs: Option<f64>,
170    /// Clamp of a dialogue clip's R128 gain, dB [-10, 14].
171    #[serde(skip_serializing_if = "Option::is_none")]
172    pub gain_range: Option<[f64; 2]>,
173    /// Crossfade at dialogue cuts, s [default: 0.125].
174    #[serde(skip_serializing_if = "Option::is_none")]
175    pub xfade: Option<f64>,
176    /// Music dip under `dialogue` clips without their own `duck_db`, dB [-14].
177    #[serde(skip_serializing_if = "Option::is_none")]
178    pub duck_db: Option<f64>,
179    /// Music dip under `nat` clips, dB [-7].
180    #[serde(skip_serializing_if = "Option::is_none")]
181    pub nat_duck_db: Option<f64>,
182    /// Duck look-ahead and hold, s [0.25, 0.45].
183    #[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    /// Duck only while the dialogue is audible [false].
188    #[serde(skip_serializing_if = "Option::is_none")]
189    pub sidechain: Option<bool>,
190    /// Dialogue presence threshold of `sidechain`, dBFS [-40].
191    #[serde(skip_serializing_if = "Option::is_none")]
192    pub duck_threshold: Option<f64>,
193    /// Clean-chain strength of dialogue clips without `ffcine.audio.clean/1` [default: 0.6].
194    #[serde(skip_serializing_if = "Option::is_none")]
195    pub clean: Option<f64>,
196    /// The same for `nat` clips [default: 0].
197    #[serde(skip_serializing_if = "Option::is_none")]
198    pub clean_nat: Option<f64>,
199    /// High-pass of that default chain, Hz [default: 80].
200    #[serde(skip_serializing_if = "Option::is_none")]
201    pub highpass: Option<f64>,
202    /// Bus settings by name: `dialogue`, `music`, `ambience`, `sfx`, and any other name is an
203    /// aux bus fed by sends.
204    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
205    pub buses: BTreeMap<String, BusParams>,
206    /// Fields this version doesn't know, kept as-is.
207    #[serde(flatten)]
208    pub other: Map<String, Value>,
209}
210
211/// One entry of `project.mix.buses`.
212#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
213#[serde(default)]
214pub struct BusParams {
215    /// Fader, dB (default 0): after the bus's `filter`, before its ducking.
216    #[serde(skip_serializing_if = "Option::is_none")]
217    pub gain_db: Option<f64>,
218    /// libavfilter chain over the bus (on an aux, after its `reverb`).
219    #[serde(skip_serializing_if = "Option::is_none")]
220    pub filter: Option<String>,
221    /// Feeds to aux buses (dialogue, music, ambience and sfx only).
222    #[serde(skip_serializing_if = "Vec::is_empty")]
223    pub sends: Vec<BusSend>,
224    /// Aux only: a reverb return.
225    #[serde(skip_serializing_if = "Option::is_none")]
226    pub reverb: Option<BusReverb>,
227    /// Aux only: a key bus. It isn't heard; while it is loud it ducks its targets.
228    #[serde(skip_serializing_if = "Option::is_none")]
229    pub duck: Option<BusDuck>,
230}
231
232/// A feed from a bus to an aux bus.
233#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
234#[serde(default)]
235pub struct BusSend {
236    /// The aux bus.
237    pub to: String,
238    /// Send level, dB (default 0).
239    pub gain_db: f64,
240    /// Tap before the bus's fader and ducking, not after [false].
241    #[serde(skip_serializing_if = "std::ops::Not::not")]
242    pub pre: bool,
243}
244
245/// `reverb` of an aux bus.
246#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
247#[serde(default)]
248pub struct BusReverb {
249    /// Decay, 0..=1 [0.5].
250    pub room: f64,
251    /// High-frequency damping, 0..=1 [0.5].
252    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/// `duck` of an aux bus.
265#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
266#[serde(default)]
267pub struct BusDuck {
268    /// The buses it ducks (of dialogue, music, ambience and sfx).
269    pub targets: Vec<String>,
270    /// How far, dB [-12].
271    pub depth_db: f64,
272    /// Ducks while the key's 10 ms RMS is over this, dBFS [-40].
273    pub threshold_db: f64,
274    /// Look-ahead and hold, s [0.04, 0.35].
275    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/// `Timeline.metadata.ffcine.project`.
298#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
299pub struct Project {
300    pub schema: String,
301    /// Frame rate of the timeline (the render rate).
302    #[serde(default, skip_serializing_if = "Option::is_none")]
303    pub rate: Option<f64>,
304    /// Base grade.
305    #[serde(default)]
306    pub grade: GradeParams,
307    /// Look used by clips that don't name one.
308    #[serde(default, skip_serializing_if = "Option::is_none")]
309    pub default_look: Option<String>,
310    #[serde(default)]
311    pub looks: BTreeMap<String, GradeParams>,
312    /// LUT aliases -> paths (relative to the timeline file).
313    #[serde(default)]
314    pub luts: BTreeMap<String, String>,
315    /// `.hook` shader aliases -> paths.
316    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
317    pub shaders: BTreeMap<String, String>,
318    #[serde(default)]
319    pub rules: Rules,
320    /// Text styles by name (`ffcine.text` clips pick one with `style`).
321    #[serde(default, skip_serializing_if = "Map::is_empty")]
322    pub styles: Map<String, Value>,
323    /// Output frame of `ffcine render` (aspect, per-quality size and punch cap).
324    #[serde(default, skip_serializing_if = "Output::is_empty")]
325    pub output: Output,
326    /// Mix settings.
327    #[serde(default, skip_serializing_if = "MixParams::is_empty")]
328    pub mix: MixParams,
329    #[serde(flatten)]
330    pub other: Map<String, Value>,
331}
332
333/// `project.output`: what `ffcine render` renders the timeline into. `ffcine render
334/// --render-profile` overrides `aspect`.
335#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
336#[serde(default)]
337pub struct Output {
338    /// Render profile: `landscape` (16:9), `reel` (9:16), `feed` (4:5), `square` (1:1), `WxH`
339    /// or `name=WxH`. Unset: landscape.
340    #[serde(skip_serializing_if = "Option::is_none")]
341    pub aspect: Option<String>,
342    /// The `preview` render.
343    #[serde(skip_serializing_if = "OutputMode::is_empty")]
344    pub preview: OutputMode,
345    /// The `master` render.
346    #[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/// `project.output.preview` / `.master`.
359#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
360#[serde(default)]
361pub struct OutputMode {
362    /// `[w, h]` in pixels; unset: the aspect at 1080 (preview) or 2160 (master) on the short side.
363    #[serde(skip_serializing_if = "Option::is_none")]
364    pub size: Option<[u32; 2]>,
365    /// Cap on reframe punch-ins (`edit.yaml` `output.<mode>.max_punch`, default 1.4).
366    #[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/// Parameters of a clip's `ffcine.color.grade/1` effect: a look and a partial override.
396#[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/// Errors from [`Project::resolve_grade`].
405#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
406pub enum GradeError {
407    #[error("unknown look {0:?}")]
408    UnknownLook(String),
409}
410
411impl Project {
412    /// The layered grade for a clip (see the module docs). The result still has `lut` as an
413    /// alias; [`Project::lut_path`] resolves it.
414    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    /// The `.cube` path for a `lut` value: an alias, a path, or `None` for `"none"` / unset.
440    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        // A clip override wins over the light rule, as in the prototype.
510        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}