1use crate::Hook;
17
18#[derive(Debug, Clone, PartialEq)]
20pub struct Filter {
21 pub name: String,
22 pub args: Vec<(Option<String>, String)>,
23}
24
25impl Filter {
26 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
36pub(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
61fn 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
80pub 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 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
128pub 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
212const LAYER_ONLY: &[&str] = &["colorlevels"];
216
217pub 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
244pub 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
256pub 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
265const 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
326fn 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#[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 ("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 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
468const 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
477pub 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
524pub 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
555pub 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
630pub 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
694pub 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
714pub 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
769const 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
829pub const CURVE_SAMPLES: usize = 256;
831
832#[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
875fn 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#[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 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#[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
960fn 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 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 assert!(h[3].source.contains("0.31250000"));
1081 assert!((param(&h[4], "angle") - std::f64::consts::FRAC_PI_4).abs() < 1e-12);
1082 for hk in &h {
1084 for (k, _) in &hk.params {
1085 assert!(hk.source.contains(&format!("//!PARAM {k}\n")), "{k}");
1086 }
1087 }
1088 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 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 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 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 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 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 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}