Skip to main content

ffcine_render/
shaders.rs

1//! libplacebo user shaders (mpv `.hook` format) generated by ffcine.
2
3/// Parameters of [`look`]: the prototype's post-LUT grade (`render.py` `grade_filters`).
4#[derive(Debug, Clone, Copy, PartialEq)]
5pub struct LookParams {
6    /// Stops, in display-linear light (BT.1886 2.4) ahead of the rest of the look.
7    pub exposure: f32,
8    pub warmth: f32,
9    pub tint: f32,
10    pub contrast: f32,
11    pub gamma: f32,
12    pub lift: f32,
13    pub gain: f32,
14    pub saturation: f32,
15}
16
17impl Default for LookParams {
18    fn default() -> Self {
19        Self {
20            exposure: 0.0,
21            warmth: 0.0,
22            tint: 0.0,
23            contrast: 0.0,
24            gamma: 1.0,
25            lift: 0.0,
26            gain: 1.0,
27            saturation: 1.0,
28        }
29    }
30}
31
32impl LookParams {
33    pub fn is_identity(&self) -> bool {
34        *self == Self::default()
35    }
36
37    /// `(name, value)` for the [`look`] hook's `//!PARAM`s.
38    pub fn params(&self) -> Vec<(String, f64)> {
39        [
40            ("exposure", self.exposure),
41            ("warmth", self.warmth),
42            ("tint", self.tint),
43            ("contrast", self.contrast),
44            ("gamma", self.gamma),
45            ("lift", self.lift),
46            ("gain", self.gain),
47            ("saturation", self.saturation),
48        ]
49        .into_iter()
50        .map(|(k, v)| (k.to_owned(), f64::from(v)))
51        .collect()
52    }
53
54    /// The [`look`] hook on one gamma-encoded RGB value, on the CPU (the LUT baker's look).
55    #[allow(clippy::many_single_char_names)] // the shader's names
56    pub fn apply(&self, rgb: [f32; 3]) -> [f32; 3] {
57        use std::f32::consts::PI;
58        let mut v = rgb.map(|c| c.clamp(0.0, 1.0));
59        if self.exposure != 0.0 {
60            let k = self.exposure.exp2();
61            v = v.map(|c| (c.powf(2.4) * k).powf(1.0 / 2.4).clamp(0.0, 1.0));
62        }
63        let l = f32::midpoint(v[0].max(v[1]).max(v[2]), v[0].min(v[1]).min(v[2]));
64        let (a, b, k) = (4.0, 0.333, 0.7);
65        let ws = ((b - l) * a + 0.5).clamp(0.0, 1.0) * k;
66        let wm =
67            ((l - b) * a + 0.5).clamp(0.0, 1.0) * ((1.0 - l - b) * a + 0.5).clamp(0.0, 1.0) * k;
68        let wh = ((l + b - 1.0) * a + 0.5).clamp(0.0, 1.0) * k;
69        let (w, t) = (self.warmth, self.tint);
70        let shift = [
71            (ws * 0.4 + wm + wh * 0.6) * w,
72            -wm * t,
73            -(ws * 0.4 + wm + wh * 0.6) * w,
74        ];
75        let y = std::array::from_fn::<f32, 3, _>(|c| {
76            let x = (v[c] + shift[c])
77                .clamp(0.0, 1.0)
78                .powf(1.0 / self.gamma.max(0.01));
79            let x = x - self.contrast / PI * 0.5 * (2.0 * PI * x).sin();
80            (self.lift + (self.gain - self.lift) * x).clamp(0.0, 1.0)
81        });
82        let luma = 0.2126 * y[0] + 0.7152 * y[1] + 0.0722 * y[2];
83        y.map(|c| (luma + (c - luma) * self.saturation).clamp(0.0, 1.0))
84    }
85}
86
87/// The prototype's post-LUT chain as one `PREOUTPUT` hook (gamma-encoded RGB after the LUT),
88/// its parameters as `//!PARAM`s so every variant shares one compiled shader: `exposure` (stops,
89/// 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`,
90/// ffmpeg's shadow/mid/highlight weights), then `curves` (`y = x^(1/gamma)`,
91/// `y −= contrast/π · ½ sin 2πy`, `y = lift + (gain − lift) y`, evaluated exactly instead of
92/// through 17 spline points), then the Rec.709-luma saturation matrix.
93pub fn look() -> &'static str {
94    r"//!PARAM exposure
95//!TYPE float
960.0
97//!PARAM warmth
98//!TYPE float
990.0
100//!PARAM tint
101//!TYPE float
1020.0
103//!PARAM contrast
104//!TYPE float
1050.0
106//!PARAM gamma
107//!TYPE float
108//!MINIMUM 0.01
1091.0
110//!PARAM lift
111//!TYPE float
1120.0
113//!PARAM gain
114//!TYPE float
1151.0
116//!PARAM saturation
117//!TYPE float
1181.0
119//!HOOK PREOUTPUT
120//!BIND HOOKED
121//!DESC ffcine look (exposure, colorbalance, curves, saturation)
122vec4 hook() {
123    vec4 c = HOOKED_texOff(vec2(0.0));
124    vec3 v = clamp(c.rgb, 0.0, 1.0);
125    if (exposure != 0.0)
126        v = clamp(pow(pow(v, vec3(2.4)) * exp2(exposure), vec3(1.0 / 2.4)), 0.0, 1.0);
127    float l = 0.5 * (max(v.r, max(v.g, v.b)) + min(v.r, min(v.g, v.b)));
128    const float a = 4.0, b = 0.333, k = 0.7;
129    float ws = clamp((b - l) * a + 0.5, 0.0, 1.0) * k;
130    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;
131    float wh = clamp((l + b - 1.0) * a + 0.5, 0.0, 1.0) * k;
132    v += ws * vec3(0.4 * warmth, 0.0, -0.4 * warmth);
133    v += wm * vec3(warmth, -tint, -warmth);
134    v += wh * vec3(0.6 * warmth, 0.0, -0.6 * warmth);
135    v = clamp(v, 0.0, 1.0);
136    vec3 y = pow(v, vec3(1.0 / gamma));
137    y -= contrast / 3.14159265 * 0.5 * sin(6.28318531 * y);
138    y = clamp(lift + (gain - lift) * y, 0.0, 1.0);
139    float luma = dot(y, vec3(0.2126, 0.7152, 0.0722));
140    c.rgb = clamp(mix(vec3(luma), y, saturation), 0.0, 1.0);
141    return c;
142}
143"
144}
145
146/// ffmpeg's `unsharp=5:5:amount` (luma only) as a `PREOUTPUT` hook: after scaling and the LUT, on
147/// gamma-encoded RGB, where the CPU chain runs it. The 5x5 kernel is the separable binomial
148/// `[1 4 6 4 1]/16` blur, `out = in + amount * (in - blur)` on BT.601 luma (what ffmpeg's
149/// auto-inserted RGB -> YUV conversion uses); adding the same delta to R, G and B moves Y only.
150///
151/// `None` for `amount == 0`.
152pub fn unsharp(amount: f32) -> Option<String> {
153    if amount == 0.0 {
154        return None;
155    }
156    Some(format!(
157        r"//!HOOK PREOUTPUT
158//!BIND HOOKED
159//!DESC unsharp 5x5 luma amount={amount}
160vec4 hook() {{
161    const vec3 k = vec3(0.299, 0.587, 0.114);
162    const float w[5] = float[5](1.0, 4.0, 6.0, 4.0, 1.0);
163    vec4 c = HOOKED_texOff(vec2(0.0));
164    float b = 0.0;
165    for (int j = -2; j <= 2; j++)
166        for (int i = -2; i <= 2; i++)
167            b += w[i + 2] * w[j + 2] * dot(HOOKED_texOff(vec2(float(i), float(j))).rgb, k);
168    c.rgb += {amount:.4} * (dot(c.rgb, k) - b / 256.0);
169    return c;
170}}
171"
172    ))
173}
174
175/// Per-shot finishing after the look, as one `PREOUTPUT` hook (the prototype's `post` filters in
176/// `render.py`): `darken` (RGB multiplier), ffmpeg's `vignette` (`angle` in radians; the factor
177/// is `cos(angle · d)^4` with `d` the distance from the middle over the half diagonal, 0 past it),
178/// the shutter `flash` (`lift`: blacks raised towards white, `out = lift + (1 - lift) in`, as
179/// `colorlevels romin`), then a `fade` towards `fade_r/g/b` (`fade` 1 = the picture, 0 = the colour,
180/// ffmpeg's `fade` on gamma-encoded values). `fade` and `lift` are per-frame tracks.
181pub fn finish() -> &'static str {
182    r"//!PARAM darken
183//!TYPE float
1841.0
185//!PARAM vignette
186//!TYPE float
1870.0
188//!PARAM lift
189//!TYPE float
1900.0
191//!PARAM fade
192//!TYPE float
1931.0
194//!PARAM fade_r
195//!TYPE float
1960.0
197//!PARAM fade_g
198//!TYPE float
1990.0
200//!PARAM fade_b
201//!TYPE float
2020.0
203//!HOOK PREOUTPUT
204//!BIND HOOKED
205//!DESC ffcine finish (darken, vignette, flash, fade)
206vec4 hook() {
207    vec4 c = HOOKED_texOff(vec2(0.0));
208    vec3 v = c.rgb * darken;
209    if (vignette > 0.0) {
210        vec2 h = 0.5 * HOOKED_size;
211        float d = length(HOOKED_pos * HOOKED_size - h) / length(h);
212        float k = d > 1.0 ? 0.0 : cos(vignette * d);
213        v *= k * k * k * k;
214    }
215    v = lift + (1.0 - lift) * v;
216    c.rgb = mix(vec3(fade_r, fade_g, fade_b), v, fade);
217    return c;
218}
219"
220}
221
222/// Alpha for a [`crate::Layer`]: `opacity` times a rounded-rectangle mask with corner `radius`
223/// in layer pixels (antialiased over one pixel), as an `OUTPUT` hook. Both can be per-frame
224/// tracks.
225pub fn alpha() -> &'static str {
226    r"//!PARAM opacity
227//!TYPE float
2281.0
229//!PARAM radius
230//!TYPE float
2310.0
232//!HOOK OUTPUT
233//!BIND HOOKED
234//!DESC ffcine layer alpha (opacity, rounded corners)
235vec4 hook() {
236    vec4 c = HOOKED_texOff(vec2(0.0));
237    vec2 h = 0.5 * HOOKED_size;
238    vec2 p = HOOKED_pos * HOOKED_size - h;
239    float r = clamp(radius, 0.0, min(h.x, h.y));
240    vec2 q = abs(p) - h + vec2(r);
241    float d = length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r;
242    float a = clamp(0.5 - d, 0.0, 1.0) * clamp(opacity, 0.0, 1.0);
243    c.a *= a;
244    return c;
245}
246"
247}
248
249/// Alpha for a comp layer (`ffcine-comp`, `docs/compositing.md#placement`): [`alpha`]'s
250/// `opacity` and rounded corners (`radius`, texture pixels), times a shape mask: `shape` 0 none,
251/// 1 rectangle, 2 ellipse, inside `mask_x/y/w/h` (fractions of the texture), with a soft edge
252/// `feather` texture pixels wide centred on the shape's edge (antialiased over one pixel at 0),
253/// and `invert` (1 keeps the outside). `radius` and `feather` are horizontal texture pixels; `ky`
254/// is how many vertical texture pixels make one horizontal (a layer scaled unevenly). An `OUTPUT`
255/// hook; every value can be a per-frame track.
256pub fn layer_alpha() -> &'static str {
257    r"//!PARAM opacity
258//!TYPE float
2591.0
260//!PARAM ky
261//!TYPE float
262//!MINIMUM 0.001
2631.0
264//!PARAM radius
265//!TYPE float
2660.0
267//!PARAM shape
268//!TYPE float
2690.0
270//!PARAM mask_x
271//!TYPE float
2720.0
273//!PARAM mask_y
274//!TYPE float
2750.0
276//!PARAM mask_w
277//!TYPE float
2781.0
279//!PARAM mask_h
280//!TYPE float
2811.0
282//!PARAM feather
283//!TYPE float
2840.0
285//!PARAM invert
286//!TYPE float
2870.0
288//!HOOK OUTPUT
289//!BIND HOOKED
290//!DESC ffcine comp layer alpha (opacity, rounded corners, mask)
291vec4 hook() {
292    vec4 c = HOOKED_texOff(vec2(0.0));
293    // In horizontal texture pixels.
294    vec2 size = HOOKED_size / vec2(1.0, ky);
295    vec2 h = 0.5 * size;
296    vec2 p = HOOKED_pos * size - h;
297    float r = clamp(radius, 0.0, min(h.x, h.y));
298    vec2 q = abs(p) - h + vec2(r);
299    float d = length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r;
300    float a = clamp(0.5 - d, 0.0, 1.0) * clamp(opacity, 0.0, 1.0);
301    if (shape > 0.5) {
302        vec2 mh = max(0.5 * vec2(mask_w, mask_h) * size, vec2(1e-3));
303        vec2 mp = HOOKED_pos * size - vec2(mask_x, mask_y) * size - mh;
304        float md;
305        if (shape < 1.5) {
306            vec2 mq = abs(mp) - mh;
307            md = length(max(mq, 0.0)) + min(max(mq.x, mq.y), 0.0);
308        } else {
309            // The implicit (|p/h| - 1) over its gradient: px from the edge near it, so the
310            // soft edge is `feather` px wide all round, not only on the short axis.
311            float l = length(mp / mh);
312            vec2 g = mp / (mh * mh);
313            md = l < 1e-4 ? -min(mh.x, mh.y) : (l - 1.0) * l / max(length(g), 1e-9);
314        }
315        float m = clamp(0.5 - md / max(feather, 1.0), 0.0, 1.0);
316        a *= invert > 0.5 ? 1.0 - m : m;
317    }
318    c.a *= a;
319    return c;
320}
321"
322}
323
324/// A solid colour layer's picture (`ffcine-comp`): `solid_r/g/b` (gamma-encoded output values,
325/// as [`background`]) replacing the picture, a `PREOUTPUT` hook ahead of the finish.
326pub fn solid() -> &'static str {
327    r"//!PARAM solid_r
328//!TYPE float
3290.0
330//!PARAM solid_g
331//!TYPE float
3320.0
333//!PARAM solid_b
334//!TYPE float
3350.0
336//!HOOK PREOUTPUT
337//!BIND HOOKED
338//!DESC ffcine solid
339vec4 hook() {
340    return vec4(clamp(vec3(solid_r, solid_g, solid_b), 0.0, 1.0), 1.0);
341}
342"
343}
344
345/// Directional motion blur for whip transitions: `blur` output pixels along `(dir_x, dir_y)`
346/// (32 taps, box), a `PREOUTPUT` hook. `blur` is a per-frame track; 0 passes the picture.
347pub fn motion_blur() -> &'static str {
348    r"//!PARAM blur
349//!TYPE float
3500.0
351//!PARAM dir_x
352//!TYPE float
3531.0
354//!PARAM dir_y
355//!TYPE float
3560.0
357//!HOOK PREOUTPUT
358//!BIND HOOKED
359//!DESC ffcine motion blur
360vec4 hook() {
361    if (blur < 0.5)
362        return HOOKED_texOff(vec2(0.0));
363    vec4 s = vec4(0.0);
364    for (int i = 0; i < 32; i++) {
365        float u = (float(i) / 31.0 - 0.5) * blur;
366        s += HOOKED_texOff(vec2(dir_x, dir_y) * u);
367    }
368    return s / 32.0;
369}
370"
371}
372
373/// A generated background replacing the picture (split-screen backdrop, `render.py`
374/// `layout_bg`): the colour `bg_r/g/b`, darkened towards the edges by
375/// `1 − vignette · ((1.5 (x − ½))² + (1.8 (y − ½))²)`.
376pub fn background() -> &'static str {
377    r"//!PARAM bg_r
378//!TYPE float
3790.0
380//!PARAM bg_g
381//!TYPE float
3820.0
383//!PARAM bg_b
384//!TYPE float
3850.0
386//!PARAM vignette
387//!TYPE float
3880.0
389//!HOOK PREOUTPUT
390//!BIND HOOKED
391//!DESC ffcine background
392vec4 hook() {
393    vec2 u = HOOKED_pos - 0.5;
394    float k = 1.0 - vignette * (pow(1.5 * u.x, 2.0) + pow(1.8 * u.y, 2.0));
395    return vec4(clamp(vec3(bg_r, bg_g, bg_b) * k, 0.0, 1.0), 1.0);
396}
397"
398}
399
400#[cfg(test)]
401mod tests {
402    use super::LookParams;
403
404    #[test]
405    fn look_on_the_cpu() {
406        let id = LookParams::default();
407        for v in [[0.0, 0.5, 1.0], [0.2, 0.3, 0.9]] {
408            let o = id.apply(v);
409            assert!((0..3).all(|c| (o[c] - v[c]).abs() < 1e-6), "{o:?}");
410        }
411        // +1 stop doubles display-linear light.
412        let up = LookParams {
413            exposure: 1.0,
414            ..id
415        }
416        .apply([0.4; 3]);
417        assert!((up[0].powf(2.4) - 2.0 * 0.4f32.powf(2.4)).abs() < 1e-5);
418        // Warmth pushes red up and blue down; saturation 0 is grey.
419        let warm = LookParams { warmth: 0.1, ..id }.apply([0.5; 3]);
420        assert!(warm[0] > 0.5 && warm[2] < 0.5);
421        let grey = LookParams {
422            saturation: 0.0,
423            ..id
424        }
425        .apply([0.8, 0.2, 0.1]);
426        assert!((grey[0] - grey[2]).abs() < 1e-6);
427    }
428
429    #[test]
430    fn unsharp_hook() {
431        assert!(super::unsharp(0.0).is_none());
432        let s = super::unsharp(0.6).unwrap();
433        assert!(s.starts_with("//!HOOK PREOUTPUT"));
434        assert!(s.contains("c.rgb += 0.6000 *"));
435    }
436}