ffcine-render 0.3.0

ffcine render path: Pipeline, Grade, encoder settings, and the ffmpeg + libplacebo (Vulkan) backend
//! libplacebo user shaders (mpv `.hook` format) generated by ffcine.

/// Parameters of [`look`]: the prototype's post-LUT grade (`render.py` `grade_filters`).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LookParams {
    /// Stops, in display-linear light (BT.1886 2.4) ahead of the rest of the look.
    pub exposure: f32,
    pub warmth: f32,
    pub tint: f32,
    pub contrast: f32,
    pub gamma: f32,
    pub lift: f32,
    pub gain: f32,
    pub saturation: f32,
}

impl Default for LookParams {
    fn default() -> Self {
        Self {
            exposure: 0.0,
            warmth: 0.0,
            tint: 0.0,
            contrast: 0.0,
            gamma: 1.0,
            lift: 0.0,
            gain: 1.0,
            saturation: 1.0,
        }
    }
}

impl LookParams {
    pub fn is_identity(&self) -> bool {
        *self == Self::default()
    }

    /// `(name, value)` for the [`look`] hook's `//!PARAM`s.
    pub fn params(&self) -> Vec<(String, f64)> {
        [
            ("exposure", self.exposure),
            ("warmth", self.warmth),
            ("tint", self.tint),
            ("contrast", self.contrast),
            ("gamma", self.gamma),
            ("lift", self.lift),
            ("gain", self.gain),
            ("saturation", self.saturation),
        ]
        .into_iter()
        .map(|(k, v)| (k.to_owned(), f64::from(v)))
        .collect()
    }

    /// The [`look`] hook on one gamma-encoded RGB value, on the CPU (the LUT baker's look).
    #[allow(clippy::many_single_char_names)] // the shader's names
    pub fn apply(&self, rgb: [f32; 3]) -> [f32; 3] {
        use std::f32::consts::PI;
        let mut v = rgb.map(|c| c.clamp(0.0, 1.0));
        if self.exposure != 0.0 {
            let k = self.exposure.exp2();
            v = v.map(|c| (c.powf(2.4) * k).powf(1.0 / 2.4).clamp(0.0, 1.0));
        }
        let l = f32::midpoint(v[0].max(v[1]).max(v[2]), v[0].min(v[1]).min(v[2]));
        let (a, b, k) = (4.0, 0.333, 0.7);
        let ws = ((b - l) * a + 0.5).clamp(0.0, 1.0) * k;
        let wm =
            ((l - b) * a + 0.5).clamp(0.0, 1.0) * ((1.0 - l - b) * a + 0.5).clamp(0.0, 1.0) * k;
        let wh = ((l + b - 1.0) * a + 0.5).clamp(0.0, 1.0) * k;
        let (w, t) = (self.warmth, self.tint);
        let shift = [
            (ws * 0.4 + wm + wh * 0.6) * w,
            -wm * t,
            -(ws * 0.4 + wm + wh * 0.6) * w,
        ];
        let y = std::array::from_fn::<f32, 3, _>(|c| {
            let x = (v[c] + shift[c])
                .clamp(0.0, 1.0)
                .powf(1.0 / self.gamma.max(0.01));
            let x = x - self.contrast / PI * 0.5 * (2.0 * PI * x).sin();
            (self.lift + (self.gain - self.lift) * x).clamp(0.0, 1.0)
        });
        let luma = 0.2126 * y[0] + 0.7152 * y[1] + 0.0722 * y[2];
        y.map(|c| (luma + (c - luma) * self.saturation).clamp(0.0, 1.0))
    }
}

/// The prototype's post-LUT chain as one `PREOUTPUT` hook (gamma-encoded RGB after the LUT),
/// its parameters as `//!PARAM`s so every variant shares one compiled shader: `exposure` (stops,
/// a gain in display-linear light, gamma 2.4), then `colorbalance` (`rs/bs = ±0.4 w`, `rm = w`, `gm = −tint`, `bm = −w`, `rh/bh = ±0.6 w`, `pl=0`,
/// ffmpeg's shadow/mid/highlight weights), then `curves` (`y = x^(1/gamma)`,
/// `y −= contrast/π · ½ sin 2πy`, `y = lift + (gain − lift) y`, evaluated exactly instead of
/// through 17 spline points), then the Rec.709-luma saturation matrix.
pub fn look() -> &'static str {
    r"//!PARAM exposure
//!TYPE float
0.0
//!PARAM warmth
//!TYPE float
0.0
//!PARAM tint
//!TYPE float
0.0
//!PARAM contrast
//!TYPE float
0.0
//!PARAM gamma
//!TYPE float
//!MINIMUM 0.01
1.0
//!PARAM lift
//!TYPE float
0.0
//!PARAM gain
//!TYPE float
1.0
//!PARAM saturation
//!TYPE float
1.0
//!HOOK PREOUTPUT
//!BIND HOOKED
//!DESC ffcine look (exposure, colorbalance, curves, saturation)
vec4 hook() {
    vec4 c = HOOKED_texOff(vec2(0.0));
    vec3 v = clamp(c.rgb, 0.0, 1.0);
    if (exposure != 0.0)
        v = clamp(pow(pow(v, vec3(2.4)) * exp2(exposure), vec3(1.0 / 2.4)), 0.0, 1.0);
    float l = 0.5 * (max(v.r, max(v.g, v.b)) + min(v.r, min(v.g, v.b)));
    const float a = 4.0, b = 0.333, k = 0.7;
    float ws = clamp((b - l) * a + 0.5, 0.0, 1.0) * k;
    float wm = clamp((l - b) * a + 0.5, 0.0, 1.0) * clamp((1.0 - l - b) * a + 0.5, 0.0, 1.0) * k;
    float wh = clamp((l + b - 1.0) * a + 0.5, 0.0, 1.0) * k;
    v += ws * vec3(0.4 * warmth, 0.0, -0.4 * warmth);
    v += wm * vec3(warmth, -tint, -warmth);
    v += wh * vec3(0.6 * warmth, 0.0, -0.6 * warmth);
    v = clamp(v, 0.0, 1.0);
    vec3 y = pow(v, vec3(1.0 / gamma));
    y -= contrast / 3.14159265 * 0.5 * sin(6.28318531 * y);
    y = clamp(lift + (gain - lift) * y, 0.0, 1.0);
    float luma = dot(y, vec3(0.2126, 0.7152, 0.0722));
    c.rgb = clamp(mix(vec3(luma), y, saturation), 0.0, 1.0);
    return c;
}
"
}

/// ffmpeg's `unsharp=5:5:amount` (luma only) as a `PREOUTPUT` hook: after scaling and the LUT, on
/// gamma-encoded RGB, where the CPU chain runs it. The 5x5 kernel is the separable binomial
/// `[1 4 6 4 1]/16` blur, `out = in + amount * (in - blur)` on BT.601 luma (what ffmpeg's
/// auto-inserted RGB -> YUV conversion uses); adding the same delta to R, G and B moves Y only.
///
/// `None` for `amount == 0`.
pub fn unsharp(amount: f32) -> Option<String> {
    if amount == 0.0 {
        return None;
    }
    Some(format!(
        r"//!HOOK PREOUTPUT
//!BIND HOOKED
//!DESC unsharp 5x5 luma amount={amount}
vec4 hook() {{
    const vec3 k = vec3(0.299, 0.587, 0.114);
    const float w[5] = float[5](1.0, 4.0, 6.0, 4.0, 1.0);
    vec4 c = HOOKED_texOff(vec2(0.0));
    float b = 0.0;
    for (int j = -2; j <= 2; j++)
        for (int i = -2; i <= 2; i++)
            b += w[i + 2] * w[j + 2] * dot(HOOKED_texOff(vec2(float(i), float(j))).rgb, k);
    c.rgb += {amount:.4} * (dot(c.rgb, k) - b / 256.0);
    return c;
}}
"
    ))
}

/// Per-shot finishing after the look, as one `PREOUTPUT` hook (the prototype's `post` filters in
/// `render.py`): `darken` (RGB multiplier), ffmpeg's `vignette` (`angle` in radians; the factor
/// is `cos(angle · d)^4` with `d` the distance from the middle over the half diagonal, 0 past it),
/// the shutter `flash` (`lift`: blacks raised towards white, `out = lift + (1 - lift) in`, as
/// `colorlevels romin`), then a `fade` towards `fade_r/g/b` (`fade` 1 = the picture, 0 = the colour,
/// ffmpeg's `fade` on gamma-encoded values). `fade` and `lift` are per-frame tracks.
pub fn finish() -> &'static str {
    r"//!PARAM darken
//!TYPE float
1.0
//!PARAM vignette
//!TYPE float
0.0
//!PARAM lift
//!TYPE float
0.0
//!PARAM fade
//!TYPE float
1.0
//!PARAM fade_r
//!TYPE float
0.0
//!PARAM fade_g
//!TYPE float
0.0
//!PARAM fade_b
//!TYPE float
0.0
//!HOOK PREOUTPUT
//!BIND HOOKED
//!DESC ffcine finish (darken, vignette, flash, fade)
vec4 hook() {
    vec4 c = HOOKED_texOff(vec2(0.0));
    vec3 v = c.rgb * darken;
    if (vignette > 0.0) {
        vec2 h = 0.5 * HOOKED_size;
        float d = length(HOOKED_pos * HOOKED_size - h) / length(h);
        float k = d > 1.0 ? 0.0 : cos(vignette * d);
        v *= k * k * k * k;
    }
    v = lift + (1.0 - lift) * v;
    c.rgb = mix(vec3(fade_r, fade_g, fade_b), v, fade);
    return c;
}
"
}

/// Alpha for a [`crate::Layer`]: `opacity` times a rounded-rectangle mask with corner `radius`
/// in layer pixels (antialiased over one pixel), as an `OUTPUT` hook. Both can be per-frame
/// tracks.
pub fn alpha() -> &'static str {
    r"//!PARAM opacity
//!TYPE float
1.0
//!PARAM radius
//!TYPE float
0.0
//!HOOK OUTPUT
//!BIND HOOKED
//!DESC ffcine layer alpha (opacity, rounded corners)
vec4 hook() {
    vec4 c = HOOKED_texOff(vec2(0.0));
    vec2 h = 0.5 * HOOKED_size;
    vec2 p = HOOKED_pos * HOOKED_size - h;
    float r = clamp(radius, 0.0, min(h.x, h.y));
    vec2 q = abs(p) - h + vec2(r);
    float d = length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r;
    float a = clamp(0.5 - d, 0.0, 1.0) * clamp(opacity, 0.0, 1.0);
    c.a *= a;
    return c;
}
"
}

/// Directional motion blur for whip transitions: `blur` output pixels along `(dir_x, dir_y)`
/// (32 taps, box), a `PREOUTPUT` hook. `blur` is a per-frame track; 0 passes the picture.
pub fn motion_blur() -> &'static str {
    r"//!PARAM blur
//!TYPE float
0.0
//!PARAM dir_x
//!TYPE float
1.0
//!PARAM dir_y
//!TYPE float
0.0
//!HOOK PREOUTPUT
//!BIND HOOKED
//!DESC ffcine motion blur
vec4 hook() {
    if (blur < 0.5)
        return HOOKED_texOff(vec2(0.0));
    vec4 s = vec4(0.0);
    for (int i = 0; i < 32; i++) {
        float u = (float(i) / 31.0 - 0.5) * blur;
        s += HOOKED_texOff(vec2(dir_x, dir_y) * u);
    }
    return s / 32.0;
}
"
}

/// A generated background replacing the picture (split-screen backdrop, `render.py`
/// `layout_bg`): the colour `bg_r/g/b`, darkened towards the edges by
/// `1 − vignette · ((1.5 (x − ½))² + (1.8 (y − ½))²)`.
pub fn background() -> &'static str {
    r"//!PARAM bg_r
//!TYPE float
0.0
//!PARAM bg_g
//!TYPE float
0.0
//!PARAM bg_b
//!TYPE float
0.0
//!PARAM vignette
//!TYPE float
0.0
//!HOOK PREOUTPUT
//!BIND HOOKED
//!DESC ffcine background
vec4 hook() {
    vec2 u = HOOKED_pos - 0.5;
    float k = 1.0 - vignette * (pow(1.5 * u.x, 2.0) + pow(1.8 * u.y, 2.0));
    return vec4(clamp(vec3(bg_r, bg_g, bg_b) * k, 0.0, 1.0), 1.0);
}
"
}

#[cfg(test)]
mod tests {
    use super::LookParams;

    #[test]
    fn look_on_the_cpu() {
        let id = LookParams::default();
        for v in [[0.0, 0.5, 1.0], [0.2, 0.3, 0.9]] {
            let o = id.apply(v);
            assert!((0..3).all(|c| (o[c] - v[c]).abs() < 1e-6), "{o:?}");
        }
        // +1 stop doubles display-linear light.
        let up = LookParams {
            exposure: 1.0,
            ..id
        }
        .apply([0.4; 3]);
        assert!((up[0].powf(2.4) - 2.0 * 0.4f32.powf(2.4)).abs() < 1e-5);
        // Warmth pushes red up and blue down; saturation 0 is grey.
        let warm = LookParams { warmth: 0.1, ..id }.apply([0.5; 3]);
        assert!(warm[0] > 0.5 && warm[2] < 0.5);
        let grey = LookParams {
            saturation: 0.0,
            ..id
        }
        .apply([0.8, 0.2, 0.1]);
        assert!((grey[0] - grey[2]).abs() < 1e-6);
    }

    #[test]
    fn unsharp_hook() {
        assert!(super::unsharp(0.0).is_none());
        let s = super::unsharp(0.6).unwrap();
        assert!(s.starts_with("//!HOOK PREOUTPUT"));
        assert!(s.contains("c.rgb += 0.6000 *"));
    }
}