Skip to main content

ffcine_render/
grade.rs

1//! The grade: everything libplacebo does to a frame between decode and encode.
2
3use std::fmt;
4use std::path::PathBuf;
5use std::str::FromStr;
6
7use crate::{ColorSpace, Error};
8use ffcine_core::color::named_enum;
9use ffcine_core::color::{HdrMetadata, Matrix, Primaries, Range, Transfer};
10use ffcine_core::geom::Mat3;
11use ffcine_core::lut::{Lut, LutType};
12pub use ffcine_filters::{Denoise, Grain, Spatial, Temporal};
13
14named_enum!(
15    /// HDR -> SDR tone curve.
16    ToneMapping {
17        Auto = "auto",
18        /// ITU-R BT.2390 EETF (the prototype's choice for iPhone HLG/Dolby Vision).
19        Bt2390 = "bt.2390",
20        Bt2446a = "bt.2446a",
21        Spline = "spline",
22        Reinhard = "reinhard",
23        Mobius = "mobius",
24        Hable = "hable",
25        Clip = "clip",
26        /// SMPTE ST 2094-40 (HDR10+) curve, from dynamic metadata when the source has it.
27        St2094_40 = "st2094-40",
28        /// SMPTE ST 2094-10 (Dolby Vision) curve.
29        St2094_10 = "st2094-10",
30        Gamma = "gamma",
31        Linear = "linear",
32    }
33);
34
35named_enum!(
36    /// How out-of-gamut colours are brought into the target gamut.
37    GamutMapping {
38        Perceptual = "perceptual",
39        Relative = "relative",
40        Saturation = "saturation",
41        Absolute = "absolute",
42        Desaturate = "desaturate",
43        Darken = "darken",
44        Clip = "clip",
45        /// Soft-knee clip in IPT, hue-preserving.
46        Softclip = "softclip",
47        /// Like `perceptual`, but keeps the highlights' saturation.
48        Highlight = "highlight",
49        /// Linear chroma compression of the whole gamut.
50        Linear = "linear",
51    }
52);
53
54/// Tuning for libplacebo's tone and gamut mapping (`pl_color_map_params`). The defaults are the
55/// prototype's (libavfilter `libplacebo` with its defaults).
56#[derive(Debug, Clone, Copy, PartialEq)]
57pub struct ColorMapTuning {
58    /// HDR contrast recovery strength, 0 = off.
59    pub contrast_recovery: f32,
60    /// How strongly `perceptual` gamut mapping desaturates in-gamut colours near the boundary.
61    pub perceptual_strength: f32,
62    /// Fraction of the gamut `perceptual` leaves untouched.
63    pub perceptual_deadzone: f32,
64}
65
66impl Default for ColorMapTuning {
67    fn default() -> Self {
68        Self {
69            contrast_recovery: 0.30,
70            perceptual_strength: 0.80,
71            perceptual_deadzone: 0.30,
72        }
73    }
74}
75
76named_enum!(
77    /// Resampling filter.
78    Scaler {
79        Lanczos = "lanczos",
80        EwaLanczos = "ewa_lanczos",
81        /// Sharpened EWA Lanczos (the design's upscaler for reframes).
82        EwaLanczosSharp = "ewa_lanczossharp",
83        Spline36 = "spline36",
84        Mitchell = "mitchell",
85        Bicubic = "bicubic",
86        Bilinear = "bilinear",
87        Nearest = "nearest",
88    }
89);
90
91/// Scaling behaviour. The default matches swscale (lanczos, gamma light, no sigmoid) so GPU and
92/// CPU geometry agree.
93#[derive(Debug, Clone, Copy, PartialEq)]
94pub struct Scaling {
95    pub upscaler: Scaler,
96    pub downscaler: Scaler,
97    /// Scale in linear light (libplacebo's default; off in the prototype).
98    pub linear: bool,
99    /// Sigmoidized upscaling.
100    pub sigmoid: bool,
101}
102
103impl Default for Scaling {
104    fn default() -> Self {
105        Self {
106            upscaler: Scaler::Lanczos,
107            downscaler: Scaler::Lanczos,
108            linear: false,
109            sigmoid: false,
110        }
111    }
112}
113
114/// Output size in pixels.
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
116pub struct Size {
117    pub width: u32,
118    pub height: u32,
119}
120
121impl FromStr for Size {
122    type Err = Error;
123    /// `1920x1080`.
124    fn from_str(s: &str) -> Result<Self, Error> {
125        let invalid = || Error::Invalid {
126            what: "size (WxH)",
127            value: s.to_owned(),
128        };
129        let (w, h) = s.split_once('x').ok_or_else(invalid)?;
130        Ok(Self {
131            width: w.parse().map_err(|_| invalid())?,
132            height: h.parse().map_err(|_| invalid())?,
133        })
134    }
135}
136
137impl fmt::Display for Size {
138    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
139        write!(f, "{}x{}", self.width, self.height)
140    }
141}
142
143/// A rectangle in pixels: a source crop in input pixels, or a placement in output pixels.
144///
145/// Fractional values are kept as is: libplacebo resamples sub-pixel windows, so keyframed crops
146/// (`Grade::crops`, from `ffcine-reframe`) move smoothly instead of stepping a pixel at a time.
147#[derive(Debug, Clone, Copy, PartialEq)]
148pub struct Crop {
149    pub x: f32,
150    pub y: f32,
151    pub width: f32,
152    pub height: f32,
153}
154
155impl FromStr for Crop {
156    type Err = Error;
157    /// `w:h:x:y`, like ffmpeg's `crop` filter.
158    fn from_str(s: &str) -> Result<Self, Error> {
159        let invalid = || Error::Invalid {
160            what: "crop (w:h:x:y)",
161            value: s.to_owned(),
162        };
163        let v: Vec<f32> = s
164            .split(':')
165            .map(str::parse)
166            .collect::<Result<_, _>>()
167            .map_err(|_| invalid())?;
168        match v[..] {
169            [width, height, x, y] => Ok(Self {
170                x,
171                y,
172                width,
173                height,
174            }),
175            _ => Err(invalid()),
176        }
177    }
178}
179
180named_enum!(
181    /// Dither algorithm for the output quantization.
182    DitherMethod {
183        /// Blue noise (libplacebo's and the prototype's default).
184        Blue = "blue",
185        Ordered = "ordered",
186        White = "white",
187    }
188);
189
190/// Dithering to the output bit depth.
191#[derive(Debug, Clone, Copy, PartialEq)]
192pub struct Dither {
193    pub method: DitherMethod,
194    /// Vary the pattern per frame.
195    pub temporal: bool,
196}
197
198impl Default for Dither {
199    fn default() -> Self {
200        Self {
201            method: DitherMethod::Blue,
202            temporal: false,
203        }
204    }
205}
206
207/// libplacebo's debanding on the source planes (`pl_deband_params`).
208#[derive(Debug, Clone, Copy, PartialEq)]
209pub struct Deband {
210    pub iterations: u32,
211    pub threshold: f32,
212    pub radius: f32,
213    pub grain: f32,
214}
215
216impl Default for Deband {
217    /// libavfilter's `libplacebo` defaults (`deband=1`).
218    fn default() -> Self {
219        Self {
220            iterations: 1,
221            threshold: 4.0,
222            radius: 16.0,
223            grain: 6.0,
224        }
225    }
226}
227
228/// Overrides for the source's colour tags (the prototype's `setparams` before the grade).
229#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
230pub struct ColorOverride {
231    pub primaries: Option<Primaries>,
232    pub transfer: Option<Transfer>,
233    pub matrix: Option<Matrix>,
234    pub range: Option<Range>,
235}
236
237impl FromStr for ColorOverride {
238    type Err = Error;
239    /// `matrix/primaries/transfer/range` as printed by `ffcine probe`, `-` keeps a tag:
240    /// `bt709/bt709/bt709/tv`, `-/bt709/bt709/-`.
241    #[allow(clippy::many_single_char_names)]
242    fn from_str(s: &str) -> Result<Self, Error> {
243        fn opt<T: FromStr<Err = Error>>(v: &str) -> Result<Option<T>, Error> {
244            if v == "-" {
245                Ok(None)
246            } else {
247                v.parse().map(Some)
248            }
249        }
250        let v: Vec<&str> = s.split('/').collect();
251        match v[..] {
252            [m, p, t, r] => Ok(Self {
253                matrix: opt(m)?,
254                primaries: opt(p)?,
255                transfer: opt(t)?,
256                range: opt(r)?,
257            }),
258            _ => Err(Error::Invalid {
259                what: "colour override (matrix/primaries/transfer/range)",
260                value: s.to_owned(),
261            }),
262        }
263    }
264}
265
266/// A LUT and how to apply it.
267#[derive(Debug, Clone, PartialEq)]
268pub struct LutStage {
269    pub lut: Lut,
270    pub kind: LutType,
271}
272
273/// A per-frame warp in the grade pass (the GPU stabilizer's output): for output frame `i`,
274/// `transforms[i]` maps output pixels to source pixels, in source pixel coordinates before the
275/// crop (the last one repeats past the end). Applied at libplacebo's `MAIN` stage with a
276/// Catmull-Rom bicubic, black outside the source; the grade's crop is folded into it.
277#[derive(Debug, Clone, PartialEq, Default)]
278pub struct Warp {
279    pub transforms: Vec<Mat3>,
280}
281
282/// An inline mpv `.hook` shader and the values of its `//!PARAM`s.
283#[derive(Debug, Clone, PartialEq, Default)]
284pub struct Hook {
285    /// For error messages.
286    pub name: String,
287    /// The shader text.
288    pub source: String,
289    /// `//!PARAM` values set before the first frame (integer params are rounded).
290    pub params: Vec<(String, f64)>,
291    /// `//!PARAM` values per output frame.
292    pub tracks: Vec<ParamTrack>,
293}
294
295/// One `//!PARAM`'s value per output frame: frame `i` uses `values[i]`, the last one holds past
296/// the end.
297#[derive(Debug, Clone, PartialEq, Default)]
298pub struct ParamTrack {
299    pub name: String,
300    pub values: Vec<f32>,
301}
302
303impl ParamTrack {
304    pub fn at(&self, frame: usize) -> Option<f32> {
305        self.values.get(frame).or(self.values.last()).copied()
306    }
307}
308
309impl Grade {
310    /// Bakes a camera LUT and a normalized grade LUT into one source LUT ([`crate::bake`]), so
311    /// the grade LUT runs before the look hooks, as it does without a camera LUT, instead of on
312    /// the target after them. The lattice is the larger of the two, at least 33.
313    pub fn merge_luts(&mut self) {
314        let (Some(cam), Some(lut)) = (&self.input_lut, &self.lut) else {
315            return;
316        };
317        if cam.kind != LutType::Normalized || lut.kind != LutType::Normalized {
318            return;
319        }
320        let size = cam.lut.size.max(lut.lut.size).max(33);
321        let baked = crate::bake::Bake {
322            camera: Some(&cam.lut),
323            lut: Some(&lut.lut),
324            look: crate::shaders::LookParams::default(),
325        }
326        .bake(size);
327        self.input_lut = Some(LutStage {
328            lut: baked,
329            kind: LutType::Normalized,
330        });
331        self.lut = None;
332    }
333
334    /// Whether frames `a` and `b` of this pass over an unchanging input (a still) render the
335    /// same pixels: the same crop and hook values, and nothing that moves by itself from frame
336    /// to frame (a warp, temporal dither, grain, a hook reading the frame index, shader files).
337    /// The renderer then reuses the layer's texture instead of rendering it again.
338    pub fn same_frame(&self, a: usize, b: usize) -> bool {
339        self.crop_at(a) == self.crop_at(b)
340            && self.warp.is_none()
341            && self.grain.is_none()
342            && self.shaders.is_empty()
343            && self.dither.is_none_or(|d| !d.temporal)
344            && self.hooks.iter().all(|h| {
345                !h.source
346                    .contains(&format!("//!PARAM {}", ffcine_stabilize::hook::FRAME_PARAM))
347                    && h.tracks.iter().all(|t| t.at(a) == t.at(b))
348            })
349    }
350
351    /// The source crop for output frame `i`: `crops[i]` (the last one past the end), else `crop`.
352    pub fn crop_at(&self, i: usize) -> Option<Crop> {
353        self.crops
354            .get(i)
355            .or(self.crops.last())
356            .copied()
357            .or(self.crop)
358    }
359}
360
361/// What fills the output around a placed picture ([`Grade::place`]).
362#[derive(Debug, Clone, Copy, PartialEq, Default)]
363pub enum Border {
364    #[default]
365    Black,
366    /// A blurred copy of the picture scaled to cover the frame (libplacebo's `PL_CLEAR_BLUR`, in
367    /// the same pass); the radius is in pixels of the placed picture.
368    Blur(f32),
369}
370
371/// One libplacebo render pass: colour conversion (incl. tone and gamut mapping), crop/scale, LUT
372/// and user shaders, ending in `output`.
373#[derive(Debug, Clone, PartialEq)]
374pub struct Grade {
375    /// Source colour tags to override before anything else.
376    pub input_color: ColorOverride,
377    /// Camera LUT (log -> Rec.709, [`ffcine_core::camera`]) on the source. With one set, `lut`
378    /// moves to the output side (libplacebo's target LUT), so it grades the converted picture.
379    pub input_lut: Option<LutStage>,
380    pub lut: Option<LutStage>,
381    /// Per-channel gain on the decoded source RGB ahead of every LUT, clamped to 0..1 (ffmpeg's
382    /// `colorchannelmixer rr:gg:bb` before `lut3d`). Folded into the first (normalized) LUT, or
383    /// an identity one without.
384    pub rgb_gain: Option<[f32; 3]>,
385    pub tone_mapping: ToneMapping,
386    pub gamut_mapping: GamutMapping,
387    pub color_map: ColorMapTuning,
388    /// Measure the HDR peak per frame (`pl_peak_detect`) instead of trusting metadata.
389    pub peak_detect: bool,
390    /// `None` disables debanding.
391    pub deband: Option<Deband>,
392    /// `None` truncates to the output depth.
393    pub dither: Option<Dither>,
394    pub crop: Option<Crop>,
395    /// Stabilization warp, before the crop.
396    pub warp: Option<Warp>,
397    /// Per-output-frame source crops, overriding `crop`: frame `i` uses `crops[i]`, the last one
398    /// holds past the end (keyframed punch-ins, pushes, pans and aspect windows).
399    pub crops: Vec<Crop>,
400    /// Where the picture lands in the output, in output pixels (`fit: contain`); the rest of the
401    /// frame is black. `None` fills the frame.
402    pub place: Option<Crop>,
403    /// What fills the frame around a `place`d picture.
404    pub border: Border,
405    /// `None` keeps the input size.
406    pub size: Option<Size>,
407    pub scaling: Scaling,
408    /// ffmpeg `unsharp=5:5:amount` equivalent, after the LUT (see [`crate::shaders::unsharp`]).
409    pub sharpen: f32,
410    /// Inline mpv `.hook` shaders (generated or vendored, see `ffcine-hooks`), after `sharpen`
411    /// and before `shaders`. libplacebo runs each at the stage it hooks; within a stage they run in
412    /// this order.
413    pub hooks: Vec<Hook>,
414    /// Extra mpv `.hook` shader files, applied in order.
415    pub shaders: Vec<PathBuf>,
416    /// Temporal + spatial denoise on the decoded planes, before the pass (`ffcine-filters`).
417    pub denoise: Option<Denoise>,
418    /// Film grain on the graded output (`ffcine-filters`).
419    pub grain: Option<Grain>,
420    pub output: ColorSpace,
421    /// HDR deliverable metadata, with a PQ or HLG `output`: libplacebo maps to its peak, and it
422    /// goes out as the stream's mastering display and content light levels. The source's own HDR
423    /// side data and Dolby Vision RPU never reach the output.
424    pub output_hdr: Option<HdrMetadata>,
425}
426
427impl Default for Grade {
428    fn default() -> Self {
429        Self {
430            input_color: ColorOverride::default(),
431            input_lut: None,
432            lut: None,
433            rgb_gain: None,
434            tone_mapping: ToneMapping::Bt2390,
435            gamut_mapping: GamutMapping::Perceptual,
436            color_map: ColorMapTuning::default(),
437            peak_detect: true,
438            deband: None,
439            dither: Some(Dither::default()),
440            crop: None,
441            warp: None,
442            crops: Vec::new(),
443            place: None,
444            border: Border::Black,
445            size: None,
446            scaling: Scaling::default(),
447            sharpen: 0.0,
448            hooks: Vec::new(),
449            shaders: Vec::new(),
450            denoise: None,
451            grain: None,
452            output: ColorSpace::BT709_TV,
453            output_hdr: None,
454        }
455    }
456}
457
458#[cfg(test)]
459mod same_frame_tests {
460    use super::*;
461
462    #[test]
463    fn same_frame_follows_tracks_and_crops() {
464        let mut g = Grade {
465            dither: None,
466            hooks: vec![Hook {
467                name: "alpha".into(),
468                source: crate::shaders::layer_alpha().into(),
469                params: Vec::new(),
470                tracks: vec![ParamTrack {
471                    name: "opacity".into(),
472                    values: vec![0.0, 0.5, 1.0, 1.0],
473                }],
474            }],
475            ..Grade::default()
476        };
477        assert!(!g.same_frame(0, 1));
478        assert!(g.same_frame(2, 3) && g.same_frame(3, 9));
479        let c = |x| Crop {
480            x,
481            y: 0.0,
482            width: 10.0,
483            height: 10.0,
484        };
485        g.crops = vec![c(0.0), c(0.0), c(0.0), c(1.0)];
486        assert!(!g.same_frame(2, 3));
487        g.crops.clear();
488        g.dither = Some(Dither {
489            temporal: true,
490            ..Dither::default()
491        });
492        assert!(!g.same_frame(2, 3));
493    }
494}
495
496#[cfg(test)]
497mod tests {
498    use super::*;
499
500    #[test]
501    fn merges_camera_and_grade_luts() {
502        let mut half = Lut::identity(5);
503        for e in &mut half.data {
504            *e = e.map(|v| v * 0.5);
505        }
506        let stage = |lut| LutStage {
507            lut,
508            kind: LutType::Normalized,
509        };
510        let mut g = Grade {
511            input_lut: Some(stage(half.clone())),
512            lut: Some(stage(half)),
513            ..Grade::default()
514        };
515        g.merge_luts();
516        assert!(g.lut.is_none());
517        let m = g.input_lut.unwrap().lut;
518        assert_eq!(m.size, 33);
519        assert!((m.sample([1.0, 0.5, 0.0])[0] - 0.25).abs() < 1e-5);
520    }
521
522    #[test]
523    fn parse_size_and_crop() {
524        assert_eq!(
525            "3840x2160".parse::<Size>().unwrap(),
526            Size {
527                width: 3840,
528                height: 2160
529            }
530        );
531        assert!("3840".parse::<Size>().is_err());
532        let c: Crop = "1920:1080:10:20".parse().unwrap();
533        assert_eq!((c.width, c.x), (1920.0, 10.0));
534        assert!("1:2:3".parse::<Crop>().is_err());
535        let o: ColorOverride = "-/bt709/bt709/tv".parse().unwrap();
536        assert_eq!(o.matrix, None);
537        assert_eq!(o.range, Some(Range::Limited));
538        assert!("bt709/bt709".parse::<ColorOverride>().is_err());
539        assert_eq!(
540            "bt.2390".parse::<ToneMapping>().unwrap(),
541            ToneMapping::Bt2390
542        );
543        assert_eq!(
544            "st2094-40".parse::<ToneMapping>().unwrap(),
545            ToneMapping::St2094_40
546        );
547        assert_eq!(
548            "softclip".parse::<GamutMapping>().unwrap(),
549            GamutMapping::Softclip
550        );
551    }
552}