Skip to main content

ffcine_render/
extra.rs

1//! A grade's raw ffmpeg filters (`extra`, after the grade) as GPU hooks: the colour filters the
2//! prototype's edits use, parsed from ffmpeg's filter syntax (`name=k=v:k=v`, positional
3//! arguments, comma-separated chains) and run as `PREOUTPUT` hooks in chain order, so nothing
4//! falls back to the CPU.
5//!
6//! Supported: `eq` (contrast, brightness, saturation, gamma, `gamma_r/g/b`, `gamma_weight`), `hue`
7//! (h, s, H, b), `colorbalance` (rs..bh, pl), `curves` (preset, master/red/green/blue/all point
8//! lists, natural spline), `unsharp` (luma and chroma sizes and amounts), `vignette` (angle) and
9//! `colorlevels` (per-channel input and output black and white points).
10//! `eq`, `hue` and `unsharp` work on BT.601 limited-range YUV code values, as ffmpeg's
11//! auto-inserted conversion from RGB gives them; `vignette` and `colorlevels` work on RGB.
12//! [`hooks`] reports anything else and doesn't apply it; [`strict_hooks`] and [`filter_hook`]
13//! (comp layers' `filters`) refuse it. `colorlevels` is layer-only for now: [`hooks`] (timeline
14//! `extra`, whose cache keys predate it) still skips it.
15
16use crate::Hook;
17
18/// One filter of a chain: its name and arguments, `(None, value)` for positional ones.
19#[derive(Debug, Clone, PartialEq)]
20pub struct Filter {
21    pub name: String,
22    pub args: Vec<(Option<String>, String)>,
23}
24
25impl Filter {
26    /// The value of option `names[0]` (or an alias in `names`), or positional argument `pos`.
27    pub(crate) fn get(&self, names: &[&str], pos: Option<usize>) -> Option<&str> {
28        self.args
29            .iter()
30            .find(|(k, _)| k.as_deref().is_some_and(|k| names.contains(&k)))
31            .or_else(|| pos.and_then(|p| self.args.iter().filter(|(k, _)| k.is_none()).nth(p)))
32            .map(|(_, v)| v.as_str())
33    }
34}
35
36/// Splits `s` at top-level `sep`, honouring `'...'` quoting and `\` escapes (kept for the next
37/// level).
38pub(crate) fn split(s: &str, sep: char) -> Vec<String> {
39    let mut out = vec![String::new()];
40    let (mut quoted, mut escaped) = (false, false);
41    for c in s.chars() {
42        let cur = out.last_mut().expect("non-empty");
43        if escaped {
44            cur.push(c);
45            escaped = false;
46        } else if c == '\\' {
47            cur.push(c);
48            escaped = true;
49        } else if c == '\'' {
50            cur.push(c);
51            quoted = !quoted;
52        } else if c == sep && !quoted {
53            out.push(String::new());
54        } else {
55            cur.push(c);
56        }
57    }
58    out
59}
60
61/// Removes one level of quoting and escaping.
62fn unquote(s: &str) -> String {
63    let mut out = String::new();
64    let (mut quoted, mut escaped) = (false, false);
65    for c in s.trim().chars() {
66        if escaped {
67            out.push(c);
68            escaped = false;
69        } else if c == '\\' && !quoted {
70            escaped = true;
71        } else if c == '\'' {
72            quoted = !quoted;
73        } else {
74            out.push(c);
75        }
76    }
77    out
78}
79
80/// Parses a filter chain (`eq=contrast=1.1:saturation=0.9,hue=s=0`). Link labels (`[in]`) are
81/// dropped; graphs (`;`) are an error.
82pub fn parse(chain: &str) -> Result<Vec<Filter>, String> {
83    if split(chain, ';').len() > 1 {
84        return Err(format!("{chain:?}: only a linear chain is supported"));
85    }
86    let mut out = Vec::new();
87    for f in split(chain, ',') {
88        let mut f = f.trim();
89        while let Some(rest) = f.strip_prefix('[') {
90            f = rest.split_once(']').map_or("", |(_, r)| r).trim_start();
91        }
92        while f.ends_with(']') {
93            f = f.rsplit_once('[').map_or("", |(l, _)| l).trim_end();
94        }
95        if f.is_empty() {
96            continue;
97        }
98        let (name, args) = f.split_once('=').unwrap_or((f, ""));
99        let name = name.trim();
100        if name.is_empty() || !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
101            return Err(format!("{f:?}: not a filter"));
102        }
103        let args = if args.is_empty() {
104            Vec::new()
105        } else {
106            split(args, ':')
107                .into_iter()
108                .map(|a| {
109                    // `k=v` unless the `=` is quoted or escaped.
110                    match split(&a, '=').as_slice() {
111                        [k, _, ..] if k.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') => {
112                            let v = &a[k.len() + 1..];
113                            (Some(k.clone()), unquote(v))
114                        }
115                        _ => (None, unquote(&a)),
116                    }
117                })
118                .collect()
119        };
120        out.push(Filter {
121            name: name.to_owned(),
122            args,
123        });
124    }
125    Ok(out)
126}
127
128/// Evaluates a constant ffmpeg expression: numbers, `PI`, `PHI`, `E`, `+ - * /`, parentheses.
129pub fn eval(s: &str) -> Option<f64> {
130    struct P<'a> {
131        s: &'a [u8],
132        i: usize,
133    }
134    impl P<'_> {
135        fn ws(&mut self) {
136            while self.s.get(self.i).is_some_and(u8::is_ascii_whitespace) {
137                self.i += 1;
138            }
139        }
140        fn eat(&mut self, c: u8) -> bool {
141            self.ws();
142            let ok = self.s.get(self.i) == Some(&c);
143            self.i += usize::from(ok);
144            ok
145        }
146        fn sum(&mut self) -> Option<f64> {
147            let mut v = self.product()?;
148            loop {
149                if self.eat(b'+') {
150                    v += self.product()?;
151                } else if self.eat(b'-') {
152                    v -= self.product()?;
153                } else {
154                    return Some(v);
155                }
156            }
157        }
158        fn product(&mut self) -> Option<f64> {
159            let mut v = self.unary()?;
160            loop {
161                if self.eat(b'*') {
162                    v *= self.unary()?;
163                } else if self.eat(b'/') {
164                    v /= self.unary()?;
165                } else {
166                    return Some(v);
167                }
168            }
169        }
170        fn unary(&mut self) -> Option<f64> {
171            if self.eat(b'-') {
172                return self.unary().map(|v| -v);
173            }
174            if self.eat(b'+') {
175                return self.unary();
176            }
177            if self.eat(b'(') {
178                let v = self.sum()?;
179                return self.eat(b')').then_some(v);
180            }
181            self.ws();
182            let start = self.i;
183            while self
184                .s
185                .get(self.i)
186                .is_some_and(|c| c.is_ascii_alphanumeric() || *c == b'.')
187                || (self.i > start
188                    && matches!(self.s.get(self.i), Some(b'+' | b'-'))
189                    && matches!(self.s.get(self.i - 1), Some(b'e' | b'E'))
190                    && self.s[start].is_ascii_digit())
191            {
192                self.i += 1;
193            }
194            let tok = std::str::from_utf8(&self.s[start..self.i]).ok()?;
195            match tok {
196                "PI" => Some(std::f64::consts::PI),
197                "E" => Some(std::f64::consts::E),
198                "PHI" => Some(1.618_033_988_749_895),
199                _ => tok.parse().ok(),
200            }
201        }
202    }
203    let mut p = P {
204        s: s.trim().as_bytes(),
205        i: 0,
206    };
207    let v = p.sum()?;
208    p.ws();
209    (p.i == p.s.len() && v.is_finite()).then_some(v)
210}
211
212/// Filters only [`strict_hooks`] and [`filter_hook`] apply: [`hooks`] skips them as before they
213/// were supported, since timeline cache keys hash the `extra` strings and applying them there
214/// would change pixels under unchanged keys (that needs a `RENDER_REVISION` bump).
215const LAYER_ONLY: &[&str] = &["colorlevels"];
216
217/// The hooks for a grade's `extra` filter strings, in order, and what isn't applied.
218pub fn hooks(extra: &[String]) -> (Vec<Hook>, Vec<String>) {
219    let mut hooks = Vec::new();
220    let mut skipped = Vec::new();
221    for chain in extra {
222        match parse(chain) {
223            Ok(fs) => {
224                for f in fs {
225                    if LAYER_ONLY.contains(&f.name.as_str()) {
226                        skipped.push(format!("extra filter {} not supported", f.name));
227                        continue;
228                    }
229                    match hook(&f) {
230                        Ok((h, warn)) => {
231                            hooks.extend(h);
232                            skipped.extend(warn);
233                        }
234                        Err(e) => skipped.push(e),
235                    }
236                }
237            }
238            Err(e) => skipped.push(format!("extra filter {e}")),
239        }
240    }
241    (hooks, skipped)
242}
243
244/// The hooks for filter chains, in order; an error for anything [`hooks`] would skip or warn
245/// about (comp layers' `filters`: never silently dropped).
246pub fn strict_hooks(chains: &[String]) -> Result<Vec<Hook>, String> {
247    let mut out = Vec::new();
248    for chain in chains {
249        for f in parse(chain).map_err(|e| format!("filter {e}"))? {
250            out.push(filter_hook(&f)?);
251        }
252    }
253    Ok(out)
254}
255
256/// One filter's hook; an error if it isn't supported, or any of its options isn't.
257pub fn filter_hook(f: &Filter) -> Result<Hook, String> {
258    let (h, warn) = hook(f)?;
259    if let Some(w) = warn.into_iter().next() {
260        return Err(w);
261    }
262    h.ok_or_else(|| format!("filter {}: not applied", f.name))
263}
264
265/// Options of each supported filter, in ffmpeg's (positional) order, with aliases.
266const EQ: &[&[&str]] = &[
267    &["contrast"],
268    &["brightness"],
269    &["saturation"],
270    &["gamma"],
271    &["gamma_r"],
272    &["gamma_g"],
273    &["gamma_b"],
274    &["gamma_weight"],
275];
276const HUE: &[&[&str]] = &[&["h"], &["s"], &["H"], &["b"]];
277const COLORBALANCE: &[&[&str]] = &[
278    &["rs"],
279    &["gs"],
280    &["bs"],
281    &["rm"],
282    &["gm"],
283    &["bm"],
284    &["rh"],
285    &["gh"],
286    &["bh"],
287    &["pl"],
288];
289const CURVES: &[&[&str]] = &[
290    &["preset"],
291    &["master", "m"],
292    &["red", "r"],
293    &["green", "g"],
294    &["blue", "b"],
295    &["all"],
296];
297const UNSHARP: &[&[&str]] = &[
298    &["luma_msize_x", "lx"],
299    &["luma_msize_y", "ly"],
300    &["luma_amount", "la"],
301    &["chroma_msize_x", "cx"],
302    &["chroma_msize_y", "cy"],
303    &["chroma_amount", "ca"],
304];
305const VIGNETTE: &[&[&str]] = &[&["angle", "a"]];
306const COLORLEVELS: &[&[&str]] = &[
307    &["rimin"],
308    &["gimin"],
309    &["bimin"],
310    &["aimin"],
311    &["rimax"],
312    &["gimax"],
313    &["bimax"],
314    &["aimax"],
315    &["romin"],
316    &["gomin"],
317    &["bomin"],
318    &["aomin"],
319    &["romax"],
320    &["gomax"],
321    &["bomax"],
322    &["aomax"],
323    &["preserve"],
324];
325
326/// Numeric option `i` of `opts` with default `d`; an error if it isn't a constant.
327fn num(f: &Filter, opts: &[&[&str]], i: usize, d: f64) -> Result<f64, String> {
328    f.get(opts[i], Some(i)).map_or(Ok(d), |v| {
329        eval(v).ok_or_else(|| {
330            format!(
331                "extra filter {}: {}={v} is not a constant",
332                f.name, opts[i][0]
333            )
334        })
335    })
336}
337
338// One arm per filter: a table in code.
339#[allow(clippy::too_many_lines)]
340fn hook(f: &Filter) -> Result<(Option<Hook>, Vec<String>), String> {
341    let opts = match f.name.as_str() {
342        "eq" => EQ,
343        "hue" => HUE,
344        "colorbalance" => COLORBALANCE,
345        "curves" => CURVES,
346        "unsharp" => UNSHARP,
347        "vignette" => VIGNETTE,
348        "colorlevels" => COLORLEVELS,
349        n => return Err(format!("extra filter {n} not supported")),
350    };
351    let mut warn = Vec::new();
352    let positional = f.args.iter().filter(|(k, _)| k.is_none()).count();
353    if positional > opts.len() {
354        warn.push(format!("extra filter {}: too many arguments", f.name));
355    }
356    for (k, _) in &f.args {
357        if let Some(k) = k
358            && !opts.iter().any(|o| o.contains(&k.as_str()))
359        {
360            warn.push(format!("extra filter {}: option {k} not supported", f.name));
361        }
362    }
363    let n = |i: usize, d: f64| num(f, opts, i, d);
364    let hook = |source: String, params: Vec<(&str, f64)>| Hook {
365        name: format!("extra {}", f.name),
366        source,
367        params: params.into_iter().map(|(k, v)| (k.to_owned(), v)).collect(),
368        tracks: Vec::new(),
369    };
370    let h = match f.name.as_str() {
371        "eq" => {
372            let (gamma, gr, gg, gb) = (n(3, 1.0)?, n(4, 1.0)?, n(5, 1.0)?, n(6, 1.0)?);
373            hook(
374                eq(),
375                vec![
376                    ("contrast", n(0, 1.0)?),
377                    ("brightness", n(1, 0.0)?),
378                    ("saturation", n(2, 1.0)?),
379                    // vf_eq's per-plane gammas.
380                    ("gamma_y", gamma * gg),
381                    ("gamma_u", (gb / gg).sqrt()),
382                    ("gamma_v", (gr / gg).sqrt()),
383                    ("gamma_weight", n(7, 1.0)?),
384                ],
385            )
386        }
387        "hue" => {
388            let h = match f.get(opts[2], None) {
389                Some(_) => n(2, 0.0)?,
390                None => n(0, 0.0)?.to_radians(),
391            };
392            hook(
393                hue(),
394                vec![("hue", h), ("sat", n(1, 1.0)?), ("bright", n(3, 0.0)?)],
395            )
396        }
397        "colorbalance" => {
398            let names = ["rs", "gs", "bs", "rm", "gm", "bm", "rh", "gh", "bh", "pl"];
399            let mut p = Vec::new();
400            for (i, k) in names.iter().enumerate() {
401                p.push((*k, n(i, 0.0)?));
402            }
403            hook(colorbalance().to_owned(), p)
404        }
405        "curves" => {
406            let c = Curves::from_filter(f)?;
407            hook(curves_hook(&c), Vec::new())
408        }
409        "unsharp" => {
410            let size = |i: usize| -> Result<u32, String> {
411                let v = n(i, 5.0)?;
412                #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
413                let s = v.round().clamp(3.0, 23.0) as u32;
414                if s.is_multiple_of(2) {
415                    return Err(format!("extra filter unsharp: size {s} must be odd"));
416                }
417                Ok(s)
418            };
419            let sizes = [size(0)?, size(1)?, size(3)?, size(4)?];
420            hook(
421                unsharp(sizes),
422                vec![("luma", n(2, 1.0)?), ("chroma", n(5, 0.0)?)],
423            )
424        }
425        "vignette" => hook(
426            vignette().to_owned(),
427            vec![("angle", n(0, std::f64::consts::PI / 5.0)?)],
428        ),
429        "colorlevels" => {
430            // ffmpeg's defaults; negative input points are its per-frame auto levels.
431            let mut p = Vec::new();
432            for (i, (k, d)) in [("imin", 0.0), ("imax", 1.0), ("omin", 0.0), ("omax", 1.0)]
433                .into_iter()
434                .enumerate()
435            {
436                for (c, ch) in ["r", "g", "b"].into_iter().enumerate() {
437                    let v = n(4 * i + c, d)?;
438                    if i < 2 && v < 0.0 {
439                        return Err(format!(
440                            "extra filter colorlevels: {ch}{k}={v} (auto levels) is not supported"
441                        ));
442                    }
443                    p.push((format!("{ch}{k}"), v));
444                }
445                if (n(4 * i + 3, d)? - d).abs() > 0.0 {
446                    warn.push(format!(
447                        "extra filter colorlevels: option a{k} not supported (alpha)"
448                    ));
449                }
450            }
451            if f.get(opts[16], Some(16))
452                .is_some_and(|v| v != "0" && v != "none")
453            {
454                warn.push("extra filter colorlevels: option preserve not supported".into());
455            }
456            Hook {
457                name: "extra colorlevels".into(),
458                source: colorlevels().to_owned(),
459                params: p,
460                tracks: Vec::new(),
461            }
462        }
463        _ => unreachable!("checked above"),
464    };
465    Ok((Some(h), warn))
466}
467
468/// BT.601 YUV <-> RGB in the hooks below (`ycc`: Y, Cb, Cr as limited-range code values / 255).
469const YUV: &str = r"
470#define TO_YCC(v) vec3((16.0 + 219.0 * dot(v, vec3(0.299, 0.587, 0.114))) / 255.0, \
471    (128.0 + 224.0 * (v.b - dot(v, vec3(0.299, 0.587, 0.114))) / 1.772) / 255.0, \
472    (128.0 + 224.0 * (v.r - dot(v, vec3(0.299, 0.587, 0.114))) / 1.402) / 255.0)
473#define FROM_YCC(q) (vec3((q.x * 255.0 - 16.0) / 219.0) + mat3(0.0, -0.344136, 1.772, \
474    1.402, -0.714136, 0.0, 0.0, 0.0, 0.0) * vec3((q.yz * 255.0 - 128.0) / 224.0, 0.0))
475";
476
477/// ffmpeg's `eq` on BT.601 code values: per plane `v = k (v − ½) + ½ + b` (luma: contrast and
478/// brightness; chroma: saturation), then `v (1 − w) + v^(1/gamma) w`, 0 below 0, 1 above.
479pub fn eq() -> String {
480    format!(
481        r"//!PARAM contrast
482//!TYPE float
4831.0
484//!PARAM brightness
485//!TYPE float
4860.0
487//!PARAM saturation
488//!TYPE float
4891.0
490//!PARAM gamma_y
491//!TYPE float
492//!MINIMUM 0.01
4931.0
494//!PARAM gamma_u
495//!TYPE float
496//!MINIMUM 0.01
4971.0
498//!PARAM gamma_v
499//!TYPE float
500//!MINIMUM 0.01
5011.0
502//!PARAM gamma_weight
503//!TYPE float
5041.0
505//!HOOK PREOUTPUT
506//!BIND HOOKED
507//!DESC ffcine extra eq
508{YUV}
509vec4 hook() {{
510    vec4 c = HOOKED_texOff(vec2(0.0));
511    vec3 q = TO_YCC(clamp(c.rgb, 0.0, 1.0));
512    vec3 x = vec3(contrast, saturation, saturation) * (q - 0.5) + 0.5 + vec3(brightness, 0.0, 0.0);
513    vec3 g = vec3(gamma_y, gamma_u, gamma_v);
514    vec3 p = max(x, 0.0);
515    p = p * (1.0 - gamma_weight) + pow(p, 1.0 / g) * gamma_weight;
516    q = clamp(p, 0.0, 1.0) * vec3(greaterThan(x, vec3(0.0)));
517    c.rgb = clamp(FROM_YCC(q), 0.0, 1.0);
518    return c;
519}}
520"
521    )
522}
523
524/// ffmpeg's `hue`: chroma rotated by `hue` radians and scaled by `sat`, luma code values moved
525/// by `bright` × 25.5 / 255.
526pub fn hue() -> String {
527    format!(
528        r"//!PARAM hue
529//!TYPE float
5300.0
531//!PARAM sat
532//!TYPE float
5331.0
534//!PARAM bright
535//!TYPE float
5360.0
537//!HOOK PREOUTPUT
538//!BIND HOOKED
539//!DESC ffcine extra hue
540{YUV}
541vec4 hook() {{
542    vec4 c = HOOKED_texOff(vec2(0.0));
543    vec3 q = TO_YCC(clamp(c.rgb, 0.0, 1.0));
544    vec2 uv = q.yz - 128.0 / 255.0;
545    float s = sin(hue) * sat, k = cos(hue) * sat;
546    q.yz = clamp(vec2(k * uv.x - s * uv.y, s * uv.x + k * uv.y) + 128.0 / 255.0, 0.0, 1.0);
547    q.x = clamp(q.x + bright * 0.1, 0.0, 1.0);
548    c.rgb = clamp(FROM_YCC(q), 0.0, 1.0);
549    return c;
550}}
551"
552    )
553}
554
555/// ffmpeg's `colorbalance`: shadows/midtones/highlights offsets per channel weighted by the
556/// input's `max + min` (ffmpeg's weights, see [`crate::shaders::look`]), and with `pl` ffmpeg's
557/// lightness preservation (HSL, at the input's lightness).
558pub fn colorbalance() -> &'static str {
559    r"//!PARAM rs
560//!TYPE float
5610.0
562//!PARAM gs
563//!TYPE float
5640.0
565//!PARAM bs
566//!TYPE float
5670.0
568//!PARAM rm
569//!TYPE float
5700.0
571//!PARAM gm
572//!TYPE float
5730.0
574//!PARAM bm
575//!TYPE float
5760.0
577//!PARAM rh
578//!TYPE float
5790.0
580//!PARAM gh
581//!TYPE float
5820.0
583//!PARAM bh
584//!TYPE float
5850.0
586//!PARAM pl
587//!TYPE float
5880.0
589//!HOOK PREOUTPUT
590//!BIND HOOKED
591//!DESC ffcine extra colorbalance
592vec4 hook() {
593    vec4 c = HOOKED_texOff(vec2(0.0));
594    vec3 v = clamp(c.rgb, 0.0, 1.0);
595    // vf_colorbalance weighs by max + min (twice the HSL lightness).
596    float m2 = max(v.r, max(v.g, v.b)) + min(v.r, min(v.g, v.b));
597    float l = 0.5 * m2;
598    const float a = 4.0, b = 0.333, k = 0.7;
599    float ws = clamp((b - m2) * a + 0.5, 0.0, 1.0) * k;
600    float wm = clamp((m2 - b) * a + 0.5, 0.0, 1.0) * clamp((1.0 - m2 - b) * a + 0.5, 0.0, 1.0) * k;
601    float wh = clamp((m2 + b - 1.0) * a + 0.5, 0.0, 1.0) * k;
602    vec3 o = clamp(v + ws * vec3(rs, gs, bs) + wm * vec3(rm, gm, bm) + wh * vec3(rh, gh, bh),
603        0.0, 1.0);
604    if (pl > 0.5) {
605        // vf_colorbalance's preservel: the result's HSL hue, its saturation over the input's
606        // lightness (0 if a channel is at 0 or 1), at the input's lightness.
607        float mx = max(o.r, max(o.g, o.b)), mn = min(o.r, min(o.g, o.b));
608        float h = 0.0;
609        if (mx > mn) {
610            if (mx == o.r)
611                h = 60.0 * ((o.g - o.b) / (mx - mn));
612            else if (mx == o.g)
613                h = 60.0 * (2.0 + (o.b - o.r) / (mx - mn));
614            else
615                h = 60.0 * (4.0 + (o.r - o.g) / (mx - mn));
616        }
617        if (h < 0.0)
618            h += 360.0;
619        float s = mx == 1.0 || mn == 0.0 ? 0.0 : (mx - mn) / (1.0 - abs(2.0 * l - 1.0));
620        float q = s * min(l, 1.0 - l);
621        vec3 kk = mod(vec3(0.0, 8.0, 4.0) + h / 30.0, 12.0);
622        o = clamp(l - q * max(min(min(kk - 3.0, 9.0 - kk), 1.0), -1.0), 0.0, 1.0);
623    }
624    c.rgb = o;
625    return c;
626}
627"
628}
629
630/// ffmpeg's `unsharp` with luma `lx x ly` and chroma `cx x cy` matrices (`sizes`), the binomial
631/// blur ffmpeg's repeated `[1 2 1]` passes give, amounts `luma` and `chroma` as params: luma on
632/// BT.601 Y (the same delta on R, G and B), chroma on the rest of the pixel.
633pub fn unsharp(sizes: [u32; 4]) -> String {
634    let weights = |n: u32| -> String {
635        let mut w = vec![1.0f64];
636        for _ in 1..n {
637            let mut next = vec![1.0; w.len() + 1];
638            for i in 1..w.len() {
639                next[i] = w[i - 1] + w[i];
640            }
641            w = next;
642        }
643        let sum: f64 = w.iter().sum();
644        w.iter()
645            .map(|x| format!("{:.8}", x / sum))
646            .collect::<Vec<_>>()
647            .join(", ")
648    };
649    let [lx, ly, cx, cy] = sizes;
650    let (lw_x, lw_y, cw_x, cw_y) = (weights(lx), weights(ly), weights(cx), weights(cy));
651    let (lrx, lry, crx, cry) = (lx / 2, ly / 2, cx / 2, cy / 2);
652    format!(
653        r"//!PARAM luma
654//!TYPE float
6551.0
656//!PARAM chroma
657//!TYPE float
6580.0
659//!HOOK PREOUTPUT
660//!BIND HOOKED
661//!DESC ffcine extra unsharp {lx}x{ly} {cx}x{cy}
662vec4 hook() {{
663    const vec3 k = vec3(0.299, 0.587, 0.114);
664    const float lwx[{lx}] = float[{lx}]({lw_x});
665    const float lwy[{ly}] = float[{ly}]({lw_y});
666    const float cwx[{cx}] = float[{cx}]({cw_x});
667    const float cwy[{cy}] = float[{cy}]({cw_y});
668    vec4 c = HOOKED_texOff(vec2(0.0));
669    float y = dot(c.rgb, k);
670    vec3 d = c.rgb - y;
671    float yb = 0.0;
672    if (luma != 0.0)
673        for (int j = -{lry}; j <= {lry}; j++)
674            for (int i = -{lrx}; i <= {lrx}; i++)
675                yb += lwx[i + {lrx}] * lwy[j + {lry}]
676                    * dot(HOOKED_texOff(vec2(float(i), float(j))).rgb, k);
677    vec3 db = vec3(0.0);
678    if (chroma != 0.0)
679        for (int j = -{cry}; j <= {cry}; j++)
680            for (int i = -{crx}; i <= {crx}; i++) {{
681                vec3 s = HOOKED_texOff(vec2(float(i), float(j))).rgb;
682                db += cwx[i + {crx}] * cwy[j + {cry}] * (s - dot(s, k));
683            }}
684    if (luma != 0.0)
685        c.rgb += luma * (y - yb);
686    if (chroma != 0.0)
687        c.rgb += chroma * (d - db);
688    return c;
689}}
690"
691    )
692}
693
694/// ffmpeg's `vignette`: RGB times `cos(angle · d)^4`, `d` the distance from the middle over the
695/// half diagonal.
696pub fn vignette() -> &'static str {
697    r"//!PARAM angle
698//!TYPE float
6990.6283185
700//!HOOK PREOUTPUT
701//!BIND HOOKED
702//!DESC ffcine extra vignette
703vec4 hook() {
704    vec4 c = HOOKED_texOff(vec2(0.0));
705    vec2 h = 0.5 * HOOKED_size;
706    float d = length(HOOKED_pos * HOOKED_size - h) / length(h);
707    float k = d > 1.0 ? 0.0 : cos(angle * d);
708    c.rgb *= k * k * k * k;
709    return c;
710}
711"
712}
713
714/// ffmpeg's `colorlevels` on RGB: per channel `(v − imin) / (imax − imin) · (omax − omin) +
715/// omin`, clamped to 0..1.
716pub fn colorlevels() -> &'static str {
717    r"//!PARAM rimin
718//!TYPE float
7190.0
720//!PARAM gimin
721//!TYPE float
7220.0
723//!PARAM bimin
724//!TYPE float
7250.0
726//!PARAM rimax
727//!TYPE float
7281.0
729//!PARAM gimax
730//!TYPE float
7311.0
732//!PARAM bimax
733//!TYPE float
7341.0
735//!PARAM romin
736//!TYPE float
7370.0
738//!PARAM gomin
739//!TYPE float
7400.0
741//!PARAM bomin
742//!TYPE float
7430.0
744//!PARAM romax
745//!TYPE float
7461.0
747//!PARAM gomax
748//!TYPE float
7491.0
750//!PARAM bomax
751//!TYPE float
7521.0
753//!HOOK PREOUTPUT
754//!BIND HOOKED
755//!DESC ffcine extra colorlevels
756vec4 hook() {
757    vec4 c = HOOKED_texOff(vec2(0.0));
758    vec3 imin = vec3(rimin, gimin, bimin);
759    vec3 imax = vec3(rimax, gimax, bimax);
760    vec3 omin = vec3(romin, gomin, bomin);
761    vec3 omax = vec3(romax, gomax, bomax);
762    vec3 k = (omax - omin) / max(imax - imin, vec3(1e-6));
763    c.rgb = clamp((clamp(c.rgb, 0.0, 1.0) - imin) * k + omin, 0.0, 1.0);
764    return c;
765}
766"
767}
768
769/// ffmpeg's `curves` presets: (red, green, blue, master) point lists.
770const PRESETS: &[(&str, [Option<&str>; 4])] = &[
771    (
772        "color_negative",
773        [
774            Some("0.129/1 0.466/0.498 0.725/0"),
775            Some("0.109/1 0.301/0.498 0.517/0"),
776            Some("0.098/1 0.235/0.498 0.423/0"),
777            None,
778        ],
779    ),
780    (
781        "cross_process",
782        [
783            Some("0/0 0.25/0.156 0.501/0.501 0.686/0.745 1/1"),
784            Some("0/0 0.25/0.188 0.38/0.501 0.745/0.815 1/0.815"),
785            Some("0/0 0.231/0.094 0.709/0.874 1/1"),
786            None,
787        ],
788    ),
789    ("darker", [None, None, None, Some("0/0 0.5/0.4 1/1")]),
790    (
791        "increase_contrast",
792        [
793            None,
794            None,
795            None,
796            Some("0/0 0.149/0.066 0.831/0.905 0.905/0.98 1/1"),
797        ],
798    ),
799    ("lighter", [None, None, None, Some("0/0 0.4/0.5 1/1")]),
800    (
801        "linear_contrast",
802        [None, None, None, Some("0/0 0.305/0.286 0.694/0.713 1/1")],
803    ),
804    (
805        "medium_contrast",
806        [None, None, None, Some("0/0 0.286/0.219 0.639/0.643 1/1")],
807    ),
808    ("negative", [None, None, None, Some("0/1 1/0")]),
809    (
810        "strong_contrast",
811        [
812            None,
813            None,
814            None,
815            Some("0/0 0.301/0.196 0.592/0.6 0.686/0.737 1/1"),
816        ],
817    ),
818    (
819        "vintage",
820        [
821            Some("0/0.11 0.42/0.51 1/0.95"),
822            Some("0/0 0.50/0.48 1/1"),
823            Some("0/0.22 0.49/0.44 1/0.8"),
824            None,
825        ],
826    ),
827];
828
829/// Samples per channel of a [`Curves`] table.
830pub const CURVE_SAMPLES: usize = 256;
831
832/// `curves` as per-channel tables over `0..=1` with the master curve folded in.
833#[derive(Debug, Clone, PartialEq)]
834pub struct Curves {
835    pub rgb: [Vec<f32>; 3],
836}
837
838impl Curves {
839    fn from_filter(f: &Filter) -> Result<Self, String> {
840        let bad = |e: String| format!("extra filter curves: {e}");
841        let preset = match f.get(CURVES[0], Some(0)) {
842            None | Some("none") => [None; 4],
843            Some(p) => {
844                PRESETS
845                    .iter()
846                    .find(|(n, _)| *n == p)
847                    .ok_or_else(|| bad(format!("unknown preset {p}")))?
848                    .1
849            }
850        };
851        let all = f.get(CURVES[5], None);
852        let mut lists = [None; 4];
853        for (c, opt) in [(0, 2), (1, 3), (2, 4), (3, 1)] {
854            lists[c] = f
855                .get(CURVES[opt], None)
856                .or(if c < 3 { all } else { None })
857                .or(preset[c]);
858        }
859        let table = |s: Option<&str>| -> Result<Vec<f32>, String> {
860            let pts = points(s.unwrap_or("0/0 1/1")).map_err(bad)?;
861            Ok(spline(&pts, CURVE_SAMPLES))
862        };
863        let master = table(lists[3])?;
864        let mut rgb: [Vec<f32>; 3] = Default::default();
865        for (c, out) in rgb.iter_mut().enumerate() {
866            *out = table(lists[c])?
867                .into_iter()
868                .map(|v| lerp_table(&master, v))
869                .collect();
870        }
871        Ok(Self { rgb })
872    }
873}
874
875/// `x/y x/y ...` key points, x strictly increasing.
876fn points(s: &str) -> Result<Vec<(f64, f64)>, String> {
877    let mut v: Vec<(f64, f64)> = Vec::new();
878    for p in s.split_whitespace() {
879        let (x, y) = p
880            .split_once('/')
881            .ok_or_else(|| format!("point {p:?} is not x/y"))?;
882        let (x, y): (f64, f64) = (
883            x.parse().map_err(|_| format!("point {p:?}"))?,
884            y.parse().map_err(|_| format!("point {p:?}"))?,
885        );
886        if v.last().is_some_and(|l| l.0 >= x) {
887            return Err(format!("points {s:?} must have increasing x"));
888        }
889        v.push((x.clamp(0.0, 1.0), y.clamp(0.0, 1.0)));
890    }
891    if v.is_empty() {
892        return Err("no points".into());
893    }
894    Ok(v)
895}
896
897/// A natural cubic spline through `pts` sampled at `n` points over `0..=1`, flat outside the
898/// first and last point, clipped to `0..=1` (ffmpeg's `interp=natural`).
899#[allow(
900    clippy::cast_precision_loss,
901    clippy::cast_possible_truncation,
902    clippy::many_single_char_names
903)]
904fn spline(pts: &[(f64, f64)], n: usize) -> Vec<f32> {
905    let k = pts.len();
906    // Second derivatives, natural ends (Thomas algorithm).
907    let mut m = vec![0.0; k];
908    if k > 2 {
909        let h: Vec<f64> = pts.windows(2).map(|w| w[1].0 - w[0].0).collect();
910        let mut c = vec![0.0; k];
911        let mut d = vec![0.0; k];
912        for i in 1..k - 1 {
913            let a = h[i - 1];
914            let b = 2.0 * (h[i - 1] + h[i]);
915            let cc = h[i];
916            let r = 6.0 * ((pts[i + 1].1 - pts[i].1) / h[i] - (pts[i].1 - pts[i - 1].1) / h[i - 1]);
917            let den = b - a * c[i - 1];
918            c[i] = cc / den;
919            d[i] = (r - a * d[i - 1]) / den;
920        }
921        for i in (1..k - 1).rev() {
922            m[i] = d[i] - c[i] * m[i + 1];
923        }
924    }
925    (0..n)
926        .map(|s| {
927            let x = s as f64 / (n - 1) as f64;
928            let y = if k == 1 || x <= pts[0].0 {
929                pts[0].1
930            } else if x >= pts[k - 1].0 {
931                pts[k - 1].1
932            } else {
933                let i = pts.windows(2).position(|w| x < w[1].0).unwrap_or(k - 2);
934                let (x0, y0, x1, y1) = (pts[i].0, pts[i].1, pts[i + 1].0, pts[i + 1].1);
935                let h = x1 - x0;
936                let (a, b) = ((x1 - x) / h, (x - x0) / h);
937                a * y0
938                    + b * y1
939                    + ((a * a * a - a) * m[i] + (b * b * b - b) * m[i + 1]) * h * h / 6.0
940            };
941            y.clamp(0.0, 1.0) as f32
942        })
943        .collect()
944}
945
946/// Linear lookup of `x` in `0..=1` in a table sampled evenly over `0..=1`.
947#[allow(
948    clippy::cast_precision_loss,
949    clippy::cast_possible_truncation,
950    clippy::cast_sign_loss,
951    clippy::many_single_char_names
952)]
953fn lerp_table(t: &[f32], x: f32) -> f32 {
954    let p = x.clamp(0.0, 1.0) * (t.len() - 1) as f32;
955    let i = (p.floor() as usize).min(t.len() - 2);
956    let f = p - i as f32;
957    t[i] + (t[i + 1] - t[i]) * f
958}
959
960/// The [`Curves`] tables as a hook (constant arrays, linear between samples).
961fn curves_hook(c: &Curves) -> String {
962    let arr = |v: &[f32]| {
963        v.iter()
964            .map(|x| format!("{x:.6}"))
965            .collect::<Vec<_>>()
966            .join(",")
967    };
968    let n = CURVE_SAMPLES;
969    format!(
970        r"//!HOOK PREOUTPUT
971//!BIND HOOKED
972//!DESC ffcine extra curves
973const float cr[{n}] = float[{n}]({});
974const float cg[{n}] = float[{n}]({});
975const float cb[{n}] = float[{n}]({});
976vec4 hook() {{
977    vec4 c = HOOKED_texOff(vec2(0.0));
978    vec3 p = clamp(c.rgb, 0.0, 1.0) * {last}.0;
979    ivec3 i = min(ivec3(p), ivec3({n2}));
980    vec3 f = p - vec3(i);
981    c.rgb = vec3(mix(cr[i.r], cr[i.r + 1], f.r), mix(cg[i.g], cg[i.g + 1], f.g),
982        mix(cb[i.b], cb[i.b + 1], f.b));
983    return c;
984}}
985",
986        arr(&c.rgb[0]),
987        arr(&c.rgb[1]),
988        arr(&c.rgb[2]),
989        last = n - 1,
990        n2 = n - 2,
991    )
992}
993
994#[cfg(test)]
995mod tests {
996    use super::*;
997
998    fn param(h: &Hook, k: &str) -> f64 {
999        h.params
1000            .iter()
1001            .find(|(n, _)| n == k)
1002            .unwrap_or_else(|| panic!("{k} in {:?}", h.params))
1003            .1
1004    }
1005
1006    #[test]
1007    fn parses_ffmpeg_syntax() {
1008        let f = parse("eq=1.2:0.05:saturation=0.9 , hue=h=30:s=1.1,[x]unsharp[y]").unwrap();
1009        assert_eq!(f.len(), 3);
1010        assert_eq!(f[0].name, "eq");
1011        assert_eq!(
1012            f[0].args,
1013            [
1014                (None, "1.2".to_owned()),
1015                (None, "0.05".to_owned()),
1016                (Some("saturation".to_owned()), "0.9".to_owned())
1017            ]
1018        );
1019        assert_eq!(f[0].get(EQ[1], Some(1)), Some("0.05"));
1020        assert_eq!(f[1].get(HUE[0], Some(0)), Some("30"));
1021        assert_eq!(f[2].name, "unsharp");
1022        assert_eq!(f[2].args, []);
1023        // Quotes keep ':' and ',' inside a value; escapes too.
1024        let f = parse(r"curves=r='0/0 0.5/0.6 1/1':g=0/0\ 1/0.9,eq=gamma=1.1").unwrap();
1025        assert_eq!(f[0].get(CURVES[2], None), Some("0/0 0.5/0.6 1/1"));
1026        assert_eq!(f[0].get(CURVES[3], None), Some("0/0 1/0.9"));
1027        assert_eq!(f[1].name, "eq");
1028        let f = parse("drawtext=text='a:b, c'").unwrap();
1029        assert_eq!(f.len(), 1);
1030        assert_eq!(f[0].get(&["text"], None), Some("a:b, c"));
1031        assert!(parse("[a]split[b];[b]null").is_err());
1032    }
1033
1034    #[test]
1035    fn evaluates_constants() {
1036        assert_eq!(eval("1.5"), Some(1.5));
1037        assert_eq!(eval("-2e-1"), Some(-0.2));
1038        assert!((eval("PI/4").unwrap() - std::f64::consts::FRAC_PI_4).abs() < 1e-12);
1039        assert_eq!(eval("2*(1+0.5)"), Some(3.0));
1040        assert_eq!(eval("t*2"), None);
1041        assert_eq!(eval("1+"), None);
1042    }
1043
1044    #[test]
1045    fn hook_params() {
1046        let (h, warn) = hooks(&[
1047            "eq=contrast=1.1:brightness=0.02:saturation=1.2:gamma=0.9:gamma_r=1.2".into(),
1048            "hue=h=90:s=0.5:b=1,colorbalance=rs=0.1:bh=-0.05:pl=1".into(),
1049            "unsharp=7:5:0.8,vignette=PI/4".into(),
1050        ]);
1051        assert_eq!(warn, Vec::<String>::new());
1052        let names: Vec<_> = h.iter().map(|h| h.name.as_str()).collect();
1053        assert_eq!(
1054            names,
1055            [
1056                "extra eq",
1057                "extra hue",
1058                "extra colorbalance",
1059                "extra unsharp",
1060                "extra vignette"
1061            ]
1062        );
1063        assert_eq!(param(&h[0], "contrast"), 1.1);
1064        assert_eq!(param(&h[0], "brightness"), 0.02);
1065        assert_eq!(param(&h[0], "saturation"), 1.2);
1066        assert_eq!(param(&h[0], "gamma_y"), 0.9);
1067        assert!((param(&h[0], "gamma_v") - 1.2f64.sqrt()).abs() < 1e-12);
1068        assert_eq!(param(&h[0], "gamma_u"), 1.0);
1069        assert!((param(&h[1], "hue") - std::f64::consts::FRAC_PI_2).abs() < 1e-12);
1070        assert_eq!(param(&h[1], "sat"), 0.5);
1071        assert_eq!(param(&h[1], "bright"), 1.0);
1072        assert_eq!(param(&h[2], "rs"), 0.1);
1073        assert_eq!(param(&h[2], "bh"), -0.05);
1074        assert_eq!(param(&h[2], "pl"), 1.0);
1075        assert_eq!(param(&h[2], "gm"), 0.0);
1076        assert_eq!(param(&h[3], "luma"), 0.8);
1077        assert_eq!(param(&h[3], "chroma"), 0.0);
1078        assert!(h[3].source.contains("unsharp 7x5 5x5"));
1079        // 7 taps: binomial 1 6 15 20 15 6 1 over 64.
1080        assert!(h[3].source.contains("0.31250000"));
1081        assert!((param(&h[4], "angle") - std::f64::consts::FRAC_PI_4).abs() < 1e-12);
1082        // Every hook declares the params it's given.
1083        for hk in &h {
1084            for (k, _) in &hk.params {
1085                assert!(hk.source.contains(&format!("//!PARAM {k}\n")), "{k}");
1086            }
1087        }
1088        // H (radians) wins over h.
1089        let (h, _) = hooks(&["hue=H=1.0:h=90".into()]);
1090        assert_eq!(param(&h[0], "hue"), 1.0);
1091    }
1092
1093    #[test]
1094    fn reports_what_isnt_applied() {
1095        let (h, warn) = hooks(&[
1096            "hqdn3d=4,eq=contrast=1.1:eval=frame".into(),
1097            "hue=h=t*10".into(),
1098            "a;b".into(),
1099        ]);
1100        assert_eq!(h.len(), 1);
1101        assert_eq!(
1102            warn,
1103            [
1104                "extra filter hqdn3d not supported",
1105                "extra filter eq: option eval not supported",
1106                "extra filter hue: h=t*10 is not a constant",
1107                "extra filter \"a;b\": only a linear chain is supported",
1108            ]
1109        );
1110    }
1111
1112    #[test]
1113    fn colorlevels_params() {
1114        let h =
1115            strict_hooks(&["colorlevels=rimin=0.1:gimin=0.05:romax=0.9:bimax=0.8".into()]).unwrap();
1116        assert_eq!(h.len(), 1);
1117        assert_eq!(param(&h[0], "rimin"), 0.1);
1118        assert_eq!(param(&h[0], "gimin"), 0.05);
1119        assert_eq!(param(&h[0], "bimin"), 0.0);
1120        assert_eq!(param(&h[0], "bimax"), 0.8);
1121        assert_eq!(param(&h[0], "romax"), 0.9);
1122        assert_eq!(param(&h[0], "gomax"), 1.0);
1123        for (k, _) in &h[0].params {
1124            assert!(h[0].source.contains(&format!("//!PARAM {k}\n")), "{k}");
1125        }
1126        // Positional: rimin, gimin, bimin, aimin, rimax ...
1127        let h = strict_hooks(&["colorlevels=0.2:0:0:0:0.9".into()]).unwrap();
1128        assert_eq!(param(&h[0], "rimin"), 0.2);
1129        assert_eq!(param(&h[0], "rimax"), 0.9);
1130        for bad in [
1131            "colorlevels=rimin=-1",
1132            "colorlevels=aomax=0.5",
1133            "colorlevels=preserve=lum",
1134        ] {
1135            assert!(strict_hooks(&[bad.into()]).is_err(), "{bad}");
1136        }
1137    }
1138
1139    #[test]
1140    fn timeline_extra_skips_colorlevels() {
1141        let (h, warn) = hooks(&["eq=contrast=1.1,colorlevels=rimin=0.1".into()]);
1142        assert_eq!(h.len(), 1);
1143        assert_eq!(h[0].name, "extra eq");
1144        assert_eq!(warn, ["extra filter colorlevels not supported"]);
1145        assert_eq!(
1146            strict_hooks(&["colorlevels=rimin=0.1".into()])
1147                .unwrap()
1148                .len(),
1149            1
1150        );
1151    }
1152
1153    #[test]
1154    fn strict_refuses_what_hooks_skip() {
1155        assert!(strict_hooks(&["eq=contrast=1.1".into()]).is_ok());
1156        for bad in [
1157            "hqdn3d=4",
1158            "eq=contrast=1.1:eval=frame",
1159            "hue=h=t*10",
1160            "a;b",
1161        ] {
1162            assert!(strict_hooks(&[bad.into()]).is_err(), "{bad}");
1163        }
1164    }
1165
1166    #[test]
1167    #[allow(clippy::many_single_char_names)]
1168    fn curves_tables() {
1169        // Two points: a straight line.
1170        let t = spline(&[(0.0, 0.0), (1.0, 0.5)], 5);
1171        assert_eq!(t, [0.0, 0.125, 0.25, 0.375, 0.5]);
1172        // Through every key point, flat outside them.
1173        let pts = points("0.1/0.2 0.4/0.5 0.9/0.8").unwrap();
1174        let t = spline(&pts, 11);
1175        assert!((t[0] - 0.2).abs() < 1e-6 && (t[1] - 0.2).abs() < 1e-6);
1176        assert!((t[4] - 0.5).abs() < 1e-6);
1177        assert!((t[9] - 0.8).abs() < 1e-6 && (t[10] - 0.8).abs() < 1e-6);
1178        // The natural spline bends between points (a symmetric S here would be a line).
1179        let s = spline(&points("0/0 0.5/0.7 1/1").unwrap(), 5);
1180        assert!(s[1] > 0.35 && s[1] < 0.5, "{s:?}");
1181
1182        let (h, w) = hooks(&["curves=preset=lighter".into()]);
1183        assert!(w.is_empty(), "{w:?}");
1184        assert!(h[0].source.contains("const float cr[256]"));
1185        let f = &parse("curves=lighter").unwrap()[0];
1186        let c = Curves::from_filter(f).unwrap();
1187        // Master 0.4 -> 0.5 on every channel.
1188        let at = |v: &[f32], x: f32| lerp_table(v, x);
1189        for ch in &c.rgb {
1190            assert!((at(ch, 0.4) - 0.5).abs() < 2e-3, "{}", at(ch, 0.4));
1191            assert!(at(ch, 0.0).abs() < 1e-6 && (at(ch, 1.0) - 1.0).abs() < 1e-6);
1192        }
1193        // Explicit points override the preset's; `all` fills unset channels.
1194        let f = &parse("curves=vintage:r=0/0\\ 1/1:all=0/1\\ 1/0").unwrap()[0];
1195        let c = Curves::from_filter(f).unwrap();
1196        assert!((at(&c.rgb[0], 0.3) - 0.3).abs() < 1e-3);
1197        assert!((at(&c.rgb[1], 0.3) - 0.7).abs() < 1e-3);
1198        assert!(hooks(&["curves=preset=nope".into()]).1[0].contains("unknown preset"));
1199    }
1200}