Skip to main content

ffcine_render/
filter_node.rs

1//! The libavfilter escape hatch (`docs/compositing.md#libavfilter-escape-hatch`): a raw
2//! libavfilter graph run over a layer's frames in its prep, before the GPU pass.
3//!
4//! This module is the backend-neutral half: the node ([`Node`]), the statefulness table
5//! ([`reach`], under [`FILTER_TABLE_REVISION`]) that says how many frames before and after a
6//! piece a graph reads, the `n` lint, and the [`Prep`] a lowered layer carries for the renderer
7//! to run (`ffcine-assembly` runs it through the `ffmpeg` CLI into a lossless intermediate).
8
9use std::ops::Range;
10use std::path::{Path, PathBuf};
11use std::sync::Arc;
12use std::time::Duration;
13
14use crate::extra::{self, Filter};
15
16/// The statefulness table's revision, in the key of every stateful node: bump it when
17/// [`reach`] changes, which re-renders stateful nodes only.
18pub const FILTER_TABLE_REVISION: u32 = 1;
19
20/// The largest pre-roll or post-roll, frames.
21pub const MAX_ROLL: u32 = 10_000;
22
23/// Where the graph's frames live.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
25pub enum Hw {
26    /// Software frames (the default).
27    #[default]
28    Cpu,
29    /// Uploaded to CUDA for `*_cuda` filters, downloaded after.
30    Cuda,
31    /// Uploaded to Vulkan for `*_vulkan` filters, downloaded after.
32    Vulkan,
33}
34
35impl Hw {
36    pub fn parse(s: &str) -> Option<Self> {
37        Some(match s {
38            "cpu" => Self::Cpu,
39            "cuda" => Self::Cuda,
40            "vulkan" => Self::Vulkan,
41            _ => return None,
42        })
43    }
44    pub fn as_str(self) -> &'static str {
45        match self {
46            Self::Cpu => "cpu",
47            Self::Cuda => "cuda",
48            Self::Vulkan => "vulkan",
49        }
50    }
51}
52
53/// Which time the graph's frames are stamped with.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
55pub enum Clock {
56    /// Layer time `lt` (0 at the layer's `in`): moving the layer changes nothing.
57    #[default]
58    Layer,
59    /// Comp time `t`.
60    Comp,
61}
62
63impl Clock {
64    pub fn parse(s: &str) -> Option<Self> {
65        Some(match s {
66            "layer" => Self::Layer,
67            "comp" => Self::Comp,
68            _ => return None,
69        })
70    }
71    pub fn as_str(self) -> &'static str {
72        match self {
73            Self::Layer => "layer",
74            Self::Comp => "comp",
75        }
76    }
77}
78
79/// A raw libavfilter graph on a layer (`filter`).
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct Node {
82    /// The graph text, verbatim. A graph of several chains (`;`), or one with link labels,
83    /// names its input `[in]` and its output `[out]`.
84    pub graph: String,
85    pub hw: Hw,
86    pub clock: Clock,
87    /// Overrides of the table's reach.
88    pub preroll: Option<u32>,
89    pub postroll: Option<u32>,
90    pub whole_clip: Option<bool>,
91    /// `timing: free`: the graph may change frame timing or count; the layer takes what comes
92    /// (run over the whole clip, no guarantee across pieces).
93    pub free_timing: bool,
94    /// The files the graph reads ([`ALLOWED`]), absolute, in graph order: set by
95    /// [`locate`](Self::locate).
96    pub files: Vec<PathBuf>,
97    /// The graph as ffmpeg runs it: `graph` with each file it reads made absolute.
98    pub run: String,
99    /// Whether `run` names its ends `[in]` and `[out]` (several chains, or link labels), as the
100    /// tokenizer reads it: set by [`locate`](Self::locate).
101    pub labelled: bool,
102}
103
104/// How far a graph reads around the frames it outputs.
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
106pub struct Reach {
107    /// Frames before a piece the graph needs to have seen.
108    pub preroll: u32,
109    /// Frames after a piece.
110    pub postroll: u32,
111    /// Only the whole layer in one run matches an unsplit render.
112    pub whole_clip: bool,
113}
114
115impl Reach {
116    /// Whether any frame's output depends on another frame.
117    pub fn stateful(self) -> bool {
118        self.preroll > 0 || self.postroll > 0 || self.whole_clip
119    }
120}
121
122/// ffmpeg's whitespace (`WHITESPACES`).
123const WS: &[u8] = b" \n\t\r";
124
125fn skip_ws(b: &[u8], mut i: usize) -> usize {
126    while b.get(i).is_some_and(|c| WS.contains(c)) {
127        i += 1;
128    }
129    i
130}
131
132/// libavutil's `av_get_token`, from `text[*at..]`: skips leading whitespace and reads up to an
133/// unquoted, unescaped byte of `term`, removing one level of `'...'` quoting and `\` escapes and
134/// trimming trailing whitespace that isn't escaped or quoted. Stricter than ffmpeg, which takes
135/// both literally: an unterminated quote or a trailing backslash is an error.
136fn token(text: &[u8], at: &mut usize, term: &[u8]) -> Result<String, String> {
137    let mut pos = skip_ws(text, *at);
138    let (mut out, mut end) = (Vec::new(), 0);
139    while pos < text.len() && !term.contains(&text[pos]) {
140        let ch = text[pos];
141        pos += 1;
142        if ch == b'\\' {
143            let Some(&next) = text.get(pos) else {
144                return Err("a trailing backslash".into());
145            };
146            out.push(next);
147            pos += 1;
148            end = out.len();
149        } else if ch == b'\'' {
150            while pos < text.len() && text[pos] != b'\'' {
151                out.push(text[pos]);
152                pos += 1;
153            }
154            if pos == text.len() {
155                return Err("an unterminated quote".into());
156            }
157            pos += 1;
158            end = out.len();
159        } else {
160            out.push(ch);
161        }
162    }
163    while out.len() > end && out.last().is_some_and(|ch| WS.contains(ch)) {
164        out.pop();
165    }
166    *at = pos;
167    String::from_utf8(out).map_err(|_| "not UTF-8".into())
168}
169
170/// A plain option or filter name: `[A-Za-z0-9_]+`.
171fn ident(s: &str) -> bool {
172    !s.is_empty() && s.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'_')
173}
174
175/// The second pass, libavfilter's `ff_filter_opt_parse` over a filter's options (already
176/// through [`token`] once): `key=value` pairs split at `:`, each value unescaped and unquoted
177/// again, positional values first. Stricter than ffmpeg (fails closed): a key must be a plain
178/// name ([`ident`]; ffmpeg also takes `-`, `.` and `/`, and its CLI reads a `/key`'s value from a
179/// file), a key may be set once, and a positional value may not hold `=`.
180fn options(opts: &str) -> Result<Vec<(Option<String>, String)>, String> {
181    let bytes = opts.as_bytes();
182    let (mut at, mut out) = (0, Vec::<(Option<String>, String)>::new());
183    while at < bytes.len() {
184        // libavutil's `get_key`: key characters, whitespace, `=`.
185        let ks = skip_ws(bytes, at);
186        let mut pos = ks;
187        while bytes
188            .get(pos)
189            .is_some_and(|&ch| ch.is_ascii_alphanumeric() || b"-_/.".contains(&ch))
190        {
191            pos += 1;
192        }
193        let ke = pos;
194        let pos = skip_ws(bytes, pos);
195        let key = (bytes.get(pos) == Some(&b'=')).then(|| {
196            at = pos + 1;
197            &opts[ks..ke]
198        });
199        let start = at;
200        let val = token(bytes, &mut at, b":").map_err(|err| format!("{opts:?}: {err}"))?;
201        if let Some(name) = key {
202            if !ident(name) {
203                return Err(format!("{name:?}: not a plain option name"));
204            }
205            if out.iter().any(|(other, _)| other.as_deref() == Some(name)) {
206                return Err(format!("{name}: set twice"));
207            }
208        } else {
209            if out.iter().any(|(name, _)| name.is_some()) {
210                return Err(format!("{val:?}: a positional value after a named one"));
211            }
212            if bytes[start..at].contains(&b'=') {
213                return Err(format!(
214                    "{:?}: an `=` in a positional value (name the option with a plain name)",
215                    &opts[start..at]
216                ));
217            }
218        }
219        out.push((key.map(str::to_owned), val));
220        // The `:`.
221        at += 1;
222    }
223    Ok(out)
224}
225
226/// One filter of a graph as libavfilter reads it.
227#[derive(Debug, Clone, PartialEq)]
228struct Parsed {
229    /// Its name (`@instance` dropped) and options, both passes applied.
230    filter: Filter,
231    /// Where its options are in the graph text (after the `=`).
232    opts: Option<Range<usize>>,
233    /// Its chain's index.
234    chain: usize,
235    /// The link labels before it and after it.
236    labels_in: Vec<String>,
237    labels_out: Vec<String>,
238}
239
240/// Link labels (`[a][b]`), as libavfilter's `linklabels_parse` reads them.
241fn labels(b: &[u8], i: &mut usize) -> Result<Vec<String>, String> {
242    let mut v = Vec::new();
243    while b.get(*i) == Some(&b'[') {
244        *i += 1;
245        let l = token(b, i, b"]")?;
246        if l.is_empty() || b.get(*i) != Some(&b']') {
247            return Err("an empty or unclosed link label".into());
248        }
249        v.push(l);
250        *i = skip_ws(b, *i + 1);
251    }
252    Ok(v)
253}
254
255/// A graph's filters, as libavfilter's `avfilter_graph_segment_parse` splits it: chains at `;`,
256/// filters at `,`, the name and the options token through [`token`], then the options through
257/// [`options`].
258fn parse_graph(graph: &str) -> Result<Vec<Parsed>, String> {
259    let b = graph.as_bytes();
260    let mut i = skip_ws(b, 0);
261    if b[i..].starts_with(b"sws_flags=") {
262        return Err("sws_flags: not allowed in a filter node".into());
263    }
264    let (mut out, mut chain) = (Vec::new(), 0);
265    while i < b.len() {
266        loop {
267            let labels_in = labels(b, &mut i)?;
268            let name = token(b, &mut i, b"=,;[")?;
269            let name = name.split_once('@').map_or(name.as_str(), |(n, _)| n);
270            if !ident(name) {
271                return Err(format!("{name:?}: not a filter"));
272            }
273            let (mut opts, mut args) = (None, Vec::new());
274            if b.get(i) == Some(&b'=') {
275                i += 1;
276                let start = i;
277                let t = token(b, &mut i, b"[],;").map_err(|e| format!("{name}: {e}"))?;
278                opts = Some(start..i);
279                args = options(&t).map_err(|e| format!("{name}: {e}"))?;
280            }
281            let labels_out = labels(b, &mut i)?;
282            i = skip_ws(b, i);
283            out.push(Parsed {
284                filter: Filter {
285                    name: name.to_owned(),
286                    args,
287                },
288                opts,
289                chain,
290                labels_in,
291                labels_out,
292            });
293            match b.get(i) {
294                None => break,
295                Some(b',') => i = skip_ws(b, i + 1),
296                Some(b';') => {
297                    i = skip_ws(b, i + 1);
298                    break;
299                }
300                Some(_) => {
301                    return Err(format!("{:?}: trailing text after a filter", &graph[i..]));
302                }
303            }
304            if i == b.len() {
305                break;
306            }
307        }
308        i = skip_ws(b, i);
309        chain += 1;
310    }
311    if out.is_empty() {
312        return Err("an empty graph".into());
313    }
314    Ok(out)
315}
316
317/// Whether a graph, read as libavfilter reads it, has several chains or link labels: then it
318/// names its ends `[in]` and `[out]` and runs in the multi-chain wrapper.
319fn is_labelled(parsed: &[Parsed]) -> bool {
320    parsed.last().is_some_and(|p| p.chain > 0)
321        || parsed
322            .iter()
323            .any(|p| !p.labels_in.is_empty() || !p.labels_out.is_empty())
324}
325
326/// Whether `graph` names its ends `[in]` and `[out]`: it has several chains or link labels, as
327/// libavfilter reads it (a quoted or escaped `;` or `[` is text, not structure).
328///
329/// # Errors
330/// Text that isn't a filter graph.
331pub fn labelled(graph: &str) -> Result<bool, String> {
332    Ok(is_labelled(&parse_graph(graph)?))
333}
334
335/// The filters of a graph, link labels dropped, read as libavfilter reads them.
336///
337/// # Errors
338/// Text that isn't a filter graph.
339pub fn parse(graph: &str) -> Result<Vec<Filter>, String> {
340    Ok(parse_graph(graph)?.into_iter().map(|p| p.filter).collect())
341}
342/// A number option of `f` (a constant expression), or `max` if it isn't one (generous: extra
343/// frames never change the output), or `default` if absent.
344fn opt(f: &Filter, names: &[&str], pos: Option<usize>, default: u32, max: u32) -> u32 {
345    match f.get(names, pos) {
346        None => default,
347        Some(v) => extra::eval(v)
348            .filter(|x| x.is_finite() && *x >= 0.0)
349            .map_or(max, |x| {
350                #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
351                let x = x.ceil().min(f64::from(max)) as u32;
352                x
353            }),
354    }
355}
356
357/// Whether filter `name` changes frame timing or count (`minterpolate`, `framerate`,
358/// `mpdecimate`, `decimate`, `pullup`).
359pub fn changes_timing(name: &str) -> bool {
360    matches!(
361        name,
362        "minterpolate" | "framerate" | "mpdecimate" | "decimate" | "pullup"
363    )
364}
365
366/// What a filter does with the file an option names.
367#[derive(Debug, Clone, Copy, PartialEq, Eq)]
368pub enum Use {
369    /// Reads it: resolved against the comp's directory, its contents keyed.
370    Read,
371    /// Reads the files under that directory, recursively (`fontsdir`): each one's contents keyed.
372    ReadDir,
373    /// Writes it: refused.
374    Write,
375}
376
377/// An option naming a file: its names (aliases), its positional index (counting an option and
378/// its aliases once, as libavfilter does), what the filter does with it.
379pub type FileOpt = (&'static [&'static str], Option<usize>, Use);
380
381/// The filters a filter node may run, with the options of each that name files: everything
382/// else is refused at comp check. Listed are pixel filters with no file, URL, device or plugin
383/// options (`ffmpeg -h filter=NAME`), plus the readers whose files are keyed. Sources (`movie`),
384/// command feeds (`sendcmd`, `zmq`), writers (`vidstabdetect`, `deshake`, `psnr`, `ssim*`,
385/// `metadata`, `paletteuse`), and plugin or code loaders (`frei0r`, `ladspa`, `lv2`, `dnn_*`,
386/// `libvmaf`, `libplacebo`, `program_opencl`, ...) aren't, so they're refused.
387pub const ALLOWED: &[(&str, &[FileOpt])] = &[
388    ("amplify", &[]),
389    (
390        "ass",
391        &[
392            (&["filename", "f"], Some(0), Use::Read),
393            (&["fontsdir"], Some(2), Use::ReadDir),
394        ],
395    ),
396    ("atadenoise", &[]),
397    ("avgblur", &[]),
398    ("avgblur_vulkan", &[]),
399    ("backgroundkey", &[]),
400    ("bilateral", &[]),
401    ("bilateral_cuda", &[]),
402    ("blend", &[]),
403    ("blend_vulkan", &[]),
404    ("bm3d", &[]),
405    ("boxblur", &[]),
406    ("bwdif", &[]),
407    ("bwdif_cuda", &[]),
408    ("bwdif_vulkan", &[]),
409    ("cas", &[]),
410    ("chromaber_vulkan", &[]),
411    ("chromahold", &[]),
412    ("chromakey", &[]),
413    ("chromakey_cuda", &[]),
414    ("chromashift", &[]),
415    ("colorbalance", &[]),
416    ("colorchannelmixer", &[]),
417    ("colorcontrast", &[]),
418    ("colorcorrect", &[]),
419    ("colorhold", &[]),
420    ("colorize", &[]),
421    ("colorkey", &[]),
422    ("colorlevels", &[]),
423    ("colormatrix", &[]),
424    ("colorspace", &[]),
425    ("colorspace_cuda", &[]),
426    ("colortemperature", &[]),
427    ("convolution", &[]),
428    ("copy", &[]),
429    ("crop", &[]),
430    (
431        "curves",
432        &[
433            (&["psfile"], Some(6), Use::Read),
434            (&["plot"], Some(7), Use::Write),
435        ],
436    ),
437    ("dblur", &[]),
438    ("dctdnoiz", &[]),
439    ("deband", &[]),
440    ("deblock", &[]),
441    ("decimate", &[]),
442    ("deflate", &[]),
443    ("deflicker", &[]),
444    ("despill", &[]),
445    ("dilation", &[]),
446    ("drawbox", &[]),
447    ("drawgrid", &[]),
448    (
449        "drawtext",
450        &[
451            (&["fontfile"], Some(0), Use::Read),
452            (&["textfile"], Some(2), Use::Read),
453        ],
454    ),
455    ("edgedetect", &[]),
456    ("eq", &[]),
457    ("erosion", &[]),
458    ("estdif", &[]),
459    ("exposure", &[]),
460    ("extractplanes", &[]),
461    ("fade", &[]),
462    ("fftdnoiz", &[]),
463    ("fftfilt", &[]),
464    ("fieldmatch", &[]),
465    ("fillborders", &[]),
466    ("flip_vulkan", &[]),
467    ("format", &[]),
468    ("framerate", &[]),
469    ("freezedetect", &[]),
470    ("fspp", &[]),
471    ("gblur", &[]),
472    ("gblur_vulkan", &[]),
473    ("geq", &[]),
474    ("gradfun", &[]),
475    ("grayworld", &[]),
476    ("guided", &[]),
477    ("haldclut", &[]),
478    ("hflip", &[]),
479    ("hflip_vulkan", &[]),
480    ("histeq", &[]),
481    ("hqdn3d", &[]),
482    ("hsvhold", &[]),
483    ("hsvkey", &[]),
484    ("hue", &[]),
485    ("huesaturation", &[]),
486    ("hwdownload", &[]),
487    ("hwupload", &[]),
488    ("hwupload_cuda", &[]),
489    ("inflate", &[]),
490    ("kerndeint", &[]),
491    ("kirsch", &[]),
492    ("lagfun", &[]),
493    ("lenscorrection", &[]),
494    ("limiter", &[]),
495    ("lumakey", &[]),
496    ("lut", &[]),
497    ("lut1d", &[(&["file"], Some(0), Use::Read)]),
498    ("lut2", &[]),
499    ("lut3d", &[(&["file"], Some(0), Use::Read)]),
500    ("lutrgb", &[]),
501    ("lutyuv", &[]),
502    ("maskedmerge", &[]),
503    ("maskfun", &[]),
504    ("median", &[]),
505    ("mergeplanes", &[]),
506    ("minterpolate", &[]),
507    ("monochrome", &[]),
508    ("mpdecimate", &[]),
509    ("negate", &[]),
510    ("nlmeans", &[]),
511    ("nlmeans_vulkan", &[]),
512    ("noise", &[]),
513    ("normalize", &[]),
514    ("null", &[]),
515    ("overlay", &[]),
516    ("overlay_cuda", &[]),
517    ("overlay_vulkan", &[]),
518    ("owdenoise", &[]),
519    ("pad", &[]),
520    ("perspective", &[]),
521    ("photosensitivity", &[]),
522    ("pp7", &[]),
523    ("prewitt", &[]),
524    ("pseudocolor", &[]),
525    ("pullup", &[]),
526    ("removegrain", &[]),
527    ("removelogo", &[(&["filename", "f"], Some(0), Use::Read)]),
528    ("rgbashift", &[]),
529    ("roberts", &[]),
530    ("rotate", &[]),
531    ("sab", &[]),
532    ("scale", &[]),
533    ("scale_cuda", &[]),
534    ("scale_vulkan", &[]),
535    ("scdet", &[]),
536    ("scharr", &[]),
537    ("selectivecolor", &[(&["psfile"], Some(10), Use::Read)]),
538    ("setdar", &[]),
539    ("setparams", &[]),
540    ("setrange", &[]),
541    ("setsar", &[]),
542    ("shuffleplanes", &[]),
543    ("smartblur", &[]),
544    ("sobel", &[]),
545    ("split", &[]),
546    ("spp", &[]),
547    (
548        "subtitles",
549        &[
550            (&["filename", "f"], Some(0), Use::Read),
551            (&["fontsdir"], Some(2), Use::ReadDir),
552        ],
553    ),
554    ("swapuv", &[]),
555    ("tblend", &[]),
556    ("threshold", &[]),
557    ("tlut2", &[]),
558    ("tmedian", &[]),
559    ("tmidequalizer", &[]),
560    ("tmix", &[]),
561    ("tonemap", &[]),
562    ("transpose", &[]),
563    ("transpose_vulkan", &[]),
564    ("unsharp", &[]),
565    ("vaguedenoiser", &[]),
566    ("varblur", &[]),
567    ("vflip", &[]),
568    ("vflip_vulkan", &[]),
569    ("vibrance", &[]),
570    ("vidstabtransform", &[(&["input"], Some(0), Use::Read)]),
571    ("vignette", &[]),
572    ("w3fdif", &[]),
573    ("yadif", &[]),
574    ("yadif_cuda", &[]),
575    ("zscale", &[]),
576];
577
578/// The table's file options of `f` that it sets: their index into `f.args` and use, in order.
579///
580/// # Errors
581/// One set twice (named and positional, or through two aliases).
582fn file_args(f: &Filter) -> Result<Vec<(usize, Use)>, String> {
583    let Some((_, opts)) = ALLOWED.iter().find(|(n, _)| *n == f.name) else {
584        return Ok(Vec::new());
585    };
586    let mut out = Vec::new();
587    for (keys, pos, u) in *opts {
588        let mut at = f
589            .args
590            .iter()
591            .enumerate()
592            .filter(|(_, (k, _))| k.as_deref().is_some_and(|k| keys.contains(&k)))
593            .map(|(i, _)| i)
594            .collect::<Vec<_>>();
595        if let Some(p) = pos
596            && f.args.get(*p).is_some_and(|(k, _)| k.is_none())
597        {
598            at.push(*p);
599        }
600        match at[..] {
601            [] => {}
602            [i] => out.push((i, *u)),
603            _ => return Err(format!("{}: {} set twice", f.name, keys[0])),
604        }
605    }
606    out.sort_unstable_by_key(|(i, _)| *i);
607    Ok(out)
608}
609
610/// Escapes `chars`, `\` and whitespace at either end of `s` with backslashes: one level of
611/// ffmpeg's quoting, which [`token`] undoes.
612fn escape(s: &str, chars: &str) -> String {
613    let n = s.chars().count();
614    let mut out = String::with_capacity(s.len());
615    for (i, c) in s.chars().enumerate() {
616        if c == '\\' || chars.contains(c) || ((i == 0 || i + 1 == n) && " \n\t\r".contains(c)) {
617            out.push('\\');
618        }
619        out.push(c);
620    }
621    out
622}
623
624/// `graph` with each file option's value `v` replaced by `with(v)` (a plain value, escaped
625/// here). Filters without one keep their text byte for byte; those with one get their options
626/// rewritten, every value escaped for both passes. Checked by reading the result back.
627fn rewrite(
628    graph: &str,
629    mut with: impl FnMut(&Filter, &str, Use) -> Result<String, String>,
630) -> Result<String, String> {
631    let parsed = parse_graph(graph)?;
632    let (mut out, mut want, mut last) = (String::new(), Vec::new(), 0);
633    for p in parsed {
634        let mut f = p.filter;
635        let idx = file_args(&f)?;
636        if let Some(r) = p.opts.filter(|_| !idx.is_empty()) {
637            for (i, u) in idx {
638                let v = with(&f, &f.args[i].1, u)?;
639                f.args[i].1 = v;
640            }
641            let text = f
642                .args
643                .iter()
644                .map(|(k, v)| {
645                    let v = escape(v, "':=");
646                    k.as_ref().map_or(v.clone(), |k| format!("{k}={v}"))
647                })
648                .collect::<Vec<_>>()
649                .join(":");
650            out.push_str(&graph[last..r.start]);
651            out.push_str(&escape(&text, "'[],;"));
652            last = r.end;
653        }
654        want.push(f);
655    }
656    out.push_str(&graph[last..]);
657    if parse(&out)? != want {
658        return Err("the graph's file paths can't be written back into it".into());
659    }
660    Ok(out)
661}
662/// One filter's reach, from the statefulness table (unknown filters are stateless).
663pub fn reach(f: &Filter) -> Reach {
664    let w = |pre, post| Reach {
665        preroll: pre,
666        postroll: post,
667        whole_clip: false,
668    };
669    match f.name.as_str() {
670        "tmix" => w(opt(f, &["frames"], Some(0), 3, 1024).saturating_sub(1), 0),
671        "tblend" | "scdet" => w(1, 0),
672        "deflicker" => {
673            let s = opt(f, &["size", "s"], Some(0), 5, 129);
674            w(s, s)
675        }
676        "atadenoise" => {
677            let s = opt(f, &["s"], None, 9, 99).div_ceil(2);
678            w(s, s)
679        }
680        "tmedian" => {
681            let r = opt(f, &["radius"], Some(0), 1, 127);
682            w(r, r)
683        }
684        "tmidequalizer" => {
685            let r = opt(f, &["radius"], Some(0), 5, 127);
686            w(r, r)
687        }
688        "fftdnoiz" => w(opt(f, &["prev"], None, 0, 1), opt(f, &["next"], None, 0, 1)),
689        "yadif" | "bwdif" | "w3fdif" | "yadif_cuda" | "bwdif_cuda" | "bwdif_vulkan" => w(1, 1),
690        "fieldmatch" => w(2, 2),
691        "lagfun" | "hqdn3d" | "vidstabtransform" | "deshake" | "normalize" | "photosensitivity"
692        | "freezedetect" => Reach {
693            whole_clip: true,
694            ..Reach::default()
695        },
696        n if changes_timing(n) => Reach {
697            whole_clip: true,
698            ..Reach::default()
699        },
700        _ => Reach::default(),
701    }
702}
703
704/// Whether option values of filter `f` read state that restarts in every piece: the frame
705/// counter (`n`, `N` as `setpts` and `select` spell it, drawtext's `%{n}` and `%{frame_num}`), or
706/// `select`'s memory of earlier frames (`prev_*`, `scene`).
707fn uses_n(f: &Filter) -> bool {
708    let select = matches!(f.name.as_str(), "select" | "aselect");
709    f.args.iter().any(|(_, v)| {
710        v.split(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
711            .any(|t| {
712                t == "n"
713                    || t == "N"
714                    || t == "frame_num"
715                    || (select && (t == "scene" || t.starts_with("prev_")))
716            })
717    })
718}
719
720/// Whether `v` names something by URL (`scheme://`).
721fn is_url(v: &str) -> bool {
722    v.split_once("://").is_some_and(|(s, _)| {
723        let s = s
724            .rsplit(|c: char| !(c.is_ascii_alphanumeric() || "+-.".contains(c)))
725            .next();
726        s.is_some_and(|s| !s.is_empty())
727    })
728}
729
730/// Refuses filters not on the allowlist ([`ALLOWED`]), options that write files, and values
731/// naming URLs (`docs/compositing.md#libavfilter-escape-hatch`).
732fn policy(f: &Filter) -> Result<(), String> {
733    if !ALLOWED.iter().any(|(n, _)| *n == f.name) {
734        return Err(format!(
735            "{}: not on the filter node's allowlist (pixel filters, and readers whose files are \
736             keyed; see docs/compositing.md#libavfilter-escape-hatch)",
737            f.name
738        ));
739    }
740    if let Some((i, _)) = file_args(f)?.into_iter().find(|(_, u)| *u == Use::Write) {
741        let k = f.args[i].0.as_deref().unwrap_or("its positional argument");
742        return Err(format!(
743            "{}: {k} writes a file outside the render: not allowed in a filter node",
744            f.name
745        ));
746    }
747    // drawtext's text is drawn, never opened.
748    if let Some((k, _)) = f
749        .args
750        .iter()
751        .filter(|(k, _)| !(f.name == "drawtext" && k.as_deref() == Some("text")))
752        .find(|(_, v)| is_url(v))
753    {
754        return Err(format!(
755            "{}: {} names a URL: a filter node reads local files only",
756            f.name,
757            k.as_deref().unwrap_or("an argument")
758        ));
759    }
760    Ok(())
761}
762
763impl Node {
764    /// The node's reach: the table's over its filters (summed, generously), overridden by its
765    /// own `preroll`, `postroll` and `whole_clip`.
766    ///
767    /// # Errors
768    /// A graph that doesn't parse, a timing-changing filter without `timing: free`, or a
769    /// frame counter (`n`) outside a whole-clip node.
770    pub fn resolve(&self) -> Result<Reach, String> {
771        let parsed = parse_graph(&self.graph)?;
772        if is_labelled(&parsed)
773            && !(parsed.iter().any(|p| p.labels_in.iter().any(|l| l == "in"))
774                && parsed
775                    .iter()
776                    .any(|p| p.labels_out.iter().any(|l| l == "out")))
777        {
778            return Err(
779                "a graph of several chains, or with link labels, names its input [in] and \
780                 output [out]"
781                    .into(),
782            );
783        }
784        let filters: Vec<Filter> = parsed.into_iter().map(|p| p.filter).collect();
785        let mut r = Reach::default();
786        for f in &filters {
787            policy(f)?;
788            if changes_timing(&f.name) && !self.free_timing {
789                return Err(format!(
790                    "{} changes frame timing: set \"timing\": \"free\" to take whatever it \
791                     outputs (run over the whole clip)",
792                    f.name
793                ));
794            }
795            let x = reach(f);
796            r.preroll = r.preroll.saturating_add(x.preroll).min(MAX_ROLL);
797            r.postroll = r.postroll.saturating_add(x.postroll).min(MAX_ROLL);
798            r.whole_clip |= x.whole_clip;
799        }
800        r.whole_clip |= self.free_timing;
801        if let Some(p) = self.preroll {
802            r.preroll = p;
803        }
804        if let Some(p) = self.postroll {
805            r.postroll = p;
806        }
807        if let Some(w) = self.whole_clip {
808            r.whole_clip = w;
809        }
810        if !r.whole_clip
811            && let Some(f) = filters.iter().find(|f| uses_n(f))
812        {
813            return Err(format!(
814                "{}: the frame counter n (and select's prev_*/scene) restarts in every piece; \
815                 use t*FRAME_RATE (the node's clock) or \"whole_clip\": true",
816                f.name
817            ));
818        }
819        Ok(r)
820    }
821
822    /// Resolves the files the graph reads ([`ALLOWED`]) against `dir` (the comp file's
823    /// directory), into [`files`](Self::files) and [`run`](Self::run).
824    ///
825    /// # Errors
826    /// A graph that doesn't parse, or a file that isn't there.
827    pub fn locate(&mut self, dir: &Path) -> Result<(), String> {
828        for f in parse(&self.graph)? {
829            if f.name == "vidstabtransform" && file_args(&f)?.is_empty() {
830                return Err(
831                    "vidstabtransform: name its transforms file with input= (resolved \
832                            against the comp's directory)"
833                        .into(),
834                );
835            }
836        }
837        let mut files = Vec::new();
838        let run = rewrite(&self.graph, |f, v, u| {
839            let p = dir.join(v.trim());
840            let p = std::path::absolute(&p).unwrap_or(p);
841            match u {
842                Use::Read if p.is_file() => files.push(p.clone()),
843                Use::ReadDir if p.is_dir() => {
844                    files.extend(tree(&p).map_err(|e| format!("{}: {e}", f.name))?);
845                }
846                Use::Read => return Err(format!("{}: {}: no such file", f.name, p.display())),
847                Use::ReadDir => {
848                    return Err(format!("{}: {}: no such directory", f.name, p.display()));
849                }
850                Use::Write => return Err(format!("{}: writes files", f.name)),
851            }
852            p.to_str()
853                .map(str::to_owned)
854                .ok_or_else(|| format!("{}: {}: not UTF-8", f.name, p.display()))
855        })?;
856        self.labelled = is_labelled(&parse_graph(&run)?);
857        self.files = files;
858        self.run = run;
859        Ok(())
860    }
861}
862
863/// Files under a directory a filter reads (`fontsdir`), at most.
864const MAX_TREE_FILES: usize = 4096;
865/// Their total size, at most (bytes).
866const MAX_TREE_BYTES: u64 = 1 << 30;
867
868/// The files under `dir`, recursively (libass and fontconfig search subdirectories), sorted,
869/// files only (links followed).
870///
871/// # Errors
872/// An unreadable directory, or more than [`MAX_TREE_FILES`] files or [`MAX_TREE_BYTES`] bytes.
873fn tree(dir: &Path) -> Result<Vec<PathBuf>, String> {
874    let (mut out, mut todo, mut bytes, mut dirs) = (Vec::new(), vec![dir.to_owned()], 0u64, 0);
875    while let Some(d) = todo.pop() {
876        // Counts directories too: a link loop ends here.
877        dirs += 1;
878        if dirs > MAX_TREE_FILES {
879            return Err(format!(
880                "{}: more than {MAX_TREE_FILES} directories",
881                dir.display()
882            ));
883        }
884        let rd = std::fs::read_dir(&d).map_err(|e| format!("{}: {e}", d.display()))?;
885        for e in rd {
886            let p = e.map_err(|e| format!("{}: {e}", d.display()))?.path();
887            let m = std::fs::metadata(&p).map_err(|e| format!("{}: {e}", p.display()))?;
888            if m.is_dir() {
889                todo.push(p);
890            } else if m.is_file() {
891                bytes += m.len();
892                out.push(p);
893                if out.len() > MAX_TREE_FILES || bytes > MAX_TREE_BYTES {
894                    return Err(format!(
895                        "{}: more than {MAX_TREE_FILES} files or {} MiB: point it at a \
896                         smaller directory",
897                        dir.display(),
898                        MAX_TREE_BYTES >> 20
899                    ));
900                }
901            }
902        }
903    }
904    out.sort();
905    Ok(out)
906}
907
908/// A filter node's run for one piece of a layer, for the renderer to make before the GPU pass:
909/// feed `map`'s source frames at `rate`, stamped from `pts`, through `graph`; keep its outputs
910/// `store` in a lossless intermediate named by `key`; show the intermediate's frames `show`.
911#[derive(Debug, Clone, PartialEq)]
912pub struct Prep {
913    pub graph: String,
914    /// The graph names its ends `[in]` and `[out]` ([`Node::labelled`]).
915    pub labelled: bool,
916    pub hw: Hw,
917    pub free_timing: bool,
918    /// The source video.
919    pub input: PathBuf,
920    /// Its display size (upright), px: the graph must keep it.
921    pub dims: (u32, u32),
922    /// Where decoding starts (half a frame before the first frame `map` reads).
923    pub start: Duration,
924    /// The source frame of each fed frame, counted from the first frame at or after `start`.
925    pub map: Arc<[i64]>,
926    /// The fed (canvas) frame rate.
927    pub rate: f64,
928    /// The first fed frame's index on the node's clock (its PTS in frames).
929    pub pts: i64,
930    /// The graph's outputs kept (pre-roll and post-roll dropped; all of them for a whole-clip
931    /// node, whose intermediate every piece shares).
932    pub store: Range<usize>,
933    /// The intermediate's frames the piece shows.
934    pub show: Range<usize>,
935    /// The intermediate's name: a digest of the node's recipe and `store`.
936    pub key: String,
937}
938
939#[cfg(test)]
940mod tests {
941    use super::*;
942
943    fn node(g: &str) -> Node {
944        Node {
945            graph: g.into(),
946            hw: Hw::Cpu,
947            clock: Clock::Layer,
948            preroll: None,
949            postroll: None,
950            whole_clip: None,
951            free_timing: false,
952            files: Vec::new(),
953            run: g.into(),
954            labelled: labelled(g).unwrap_or_default(),
955        }
956    }
957
958    #[test]
959    fn table() {
960        let r = |g: &str| node(g).resolve().unwrap();
961        assert_eq!(r("eq=gamma=1.1"), Reach::default());
962        assert!(!r("eq=gamma=1.1,hue=s=0").stateful());
963        assert_eq!(r("tmix=frames=5").preroll, 4);
964        assert_eq!(r("tmix=5").preroll, 4);
965        assert_eq!(r("tmix").preroll, 2);
966        assert_eq!(r("tmix=frames=nope").preroll, 1023);
967        let d = r("deflicker=size=7,eq=gamma=1.05");
968        assert_eq!((d.preroll, d.postroll, d.whole_clip), (7, 7, false));
969        assert_eq!(r("deflicker").postroll, 5);
970        assert_eq!(r("atadenoise").preroll, 5);
971        assert_eq!(r("tmedian=radius=3").postroll, 3);
972        assert_eq!(r("fftdnoiz=prev=1").preroll, 1);
973        assert_eq!(r("yadif,tblend").preroll, 2);
974        assert!(r("hqdn3d").whole_clip);
975        assert!(r("lagfun=decay=0.9").whole_clip);
976        // Overrides.
977        let mut n = node("tmix=frames=5");
978        n.preroll = Some(10);
979        n.postroll = Some(2);
980        n.whole_clip = Some(true);
981        assert_eq!(
982            n.resolve().unwrap(),
983            Reach {
984                preroll: 10,
985                postroll: 2,
986                whole_clip: true
987            }
988        );
989    }
990
991    #[test]
992    fn timing_and_n() {
993        let e = node("minterpolate=fps=60").resolve().unwrap_err();
994        assert!(e.contains("timing"), "{e}");
995        let mut n = node("minterpolate=fps=60");
996        n.free_timing = true;
997        assert!(n.resolve().unwrap().whole_clip);
998        let e = node("eq=eval=frame:brightness='0.01*n'")
999            .resolve()
1000            .unwrap_err();
1001        assert!(e.contains("t*FRAME_RATE"), "{e}");
1002        assert!(node("setpts=N/30/TB").resolve().is_err());
1003        assert!(
1004            node("eq=eval=frame:brightness='0.1*sin(t)'")
1005                .resolve()
1006                .is_ok()
1007        );
1008        let mut n = node("eq=eval=frame:brightness='0.01*n'");
1009        n.whole_clip = Some(true);
1010        assert!(n.resolve().is_ok());
1011        assert!(node("split[a][b];[a][b]blend").resolve().is_err());
1012        assert!(
1013            node("[in]split[a][b];[a][b]blend=all_mode=average[out]")
1014                .resolve()
1015                .is_ok()
1016        );
1017        assert!(node("").resolve().is_err());
1018    }
1019
1020    #[test]
1021    fn chains_and_labels_come_from_the_tokenizer() {
1022        for g in [
1023            "drawtext=text='a;b'",
1024            "drawtext=text='[in] [out]'",
1025            "drawtext=text=a\\;b",
1026            "eq=gamma=1.1,drawtext=text='x;[y]'",
1027        ] {
1028            assert!(!labelled(g).unwrap(), "{g}");
1029            node(g).resolve().unwrap_or_else(|e| panic!("{g}: {e}"));
1030        }
1031        for g in [
1032            "[in]split[a][b];[a][b]blend=all_mode=average[out]",
1033            "[in]eq[out]",
1034        ] {
1035            assert!(labelled(g).unwrap(), "{g}");
1036            node(g).resolve().unwrap_or_else(|e| panic!("{g}: {e}"));
1037        }
1038        // Quoted labels aren't labels.
1039        let e = node("split[a][b];[a][b]blend,drawtext=text='[in] [out]'")
1040            .resolve()
1041            .unwrap_err();
1042        assert!(e.contains("[in]"), "{e}");
1043        assert!(node("[x]eq[out]").resolve().is_err());
1044    }
1045
1046    #[test]
1047    fn policy_refuses_writers_sources_and_urls() {
1048        let e = |g: &str| {
1049            node(g)
1050                .resolve()
1051                .err()
1052                .unwrap_or_else(|| panic!("{g}: accepted"))
1053        };
1054        let ok = |g: &str| {
1055            node(g).resolve().unwrap_or_else(|e| panic!("{g}: {e}"));
1056        };
1057        assert!(e("movie=a.mov[m];[in][m]overlay[out]").contains("allowlist"));
1058        assert!(e("[in]eq,movie=b.mkv[out]").contains("movie"));
1059        assert!(e("metadata=mode=print:file=log.txt").contains("allowlist"));
1060        assert!(e("metadata=mode=print").contains("allowlist"));
1061        assert!(e("ssim=stats.log").contains("allowlist"));
1062        assert!(e("vidstabdetect=result=t.trf").contains("allowlist"));
1063        assert!(e("sendcmd=f=cmds.txt,eq").contains("allowlist"));
1064        for g in [
1065            "paletteuse=debug_kdtree=k.dot",
1066            "deshake=filename=d.log",
1067            "deshake_opencl=filename=d.log",
1068            "ssim360=stats_file=s.log",
1069            "frei0r=filter_name=x",
1070            "ladspa=file=x",
1071            "lv2=plugin=x",
1072            "dnn_processing=model=m",
1073            "libvmaf",
1074            "libplacebo=custom_shader_path=s.hook",
1075            "program_opencl=source=k.cl",
1076            "showcqt",
1077            "fsync=file=f",
1078            // An escaped or instance-named name is still the name.
1079            "mov\\ie=a.mov",
1080            "'movie'=a.mov",
1081            "movie@x=a.mov",
1082        ] {
1083            assert!(e(g).contains("allowlist"), "{g}: {}", e(g));
1084        }
1085        assert!(e("curves=preset=vintage:plot=p.json").contains("plot"));
1086        assert!(e("curves=vintage:::::::p.json").contains("positional"));
1087        assert!(e("lut3d=file=https\\\\\\://example.com/a.cube").contains("URL"));
1088        assert!(e("lut3d=file=https\\://example.com/a.cube").contains("positional"));
1089        assert!(e("subtitles='http\\://example.com/a.srt'").contains("URL"));
1090        assert!(e("scdet,select='gt(scene,0.3)'").contains("allowlist"));
1091        assert!(e("drawtext=text='%{frame_num}'").contains("restarts"));
1092        ok("drawtext=text='see https\\://example.com'");
1093        assert_eq!(node("scdet").resolve().unwrap().preroll, 1);
1094        assert!(node("freezedetect").resolve().unwrap().whole_clip);
1095        assert_eq!(node("fieldmatch").resolve().unwrap().postroll, 2);
1096        assert!(e("pullup").contains("timing"));
1097    }
1098
1099    /// Option text libavfilter reads differently from a one-pass parser: the graph parser
1100    /// unescapes once, the option parser again.
1101    #[test]
1102    fn two_passes_like_libavfilter() {
1103        let e = |g: &str| {
1104            node(g)
1105                .resolve()
1106                .err()
1107                .unwrap_or_else(|| panic!("{g}: accepted"))
1108        };
1109        let ok = |g: &str| {
1110            node(g).resolve().unwrap_or_else(|e| panic!("{g}: {e}"));
1111        };
1112        // The bypasses: a hidden `file=` (pass 1 turns `\:` into `:`) and an escaped key.
1113        for g in [
1114            "metadata=mode=print\\:file=/tmp/x",
1115            "metadata=mode=print:fi\\le=/tmp/x",
1116            "metadata=mode=print:'file'=/tmp/x",
1117            "metadata=mode=print:'fi'le=/tmp/x",
1118            "metadata=mode=print\\\\:file=/tmp/x",
1119            "metadata='mode=print:file=/tmp/x'",
1120        ] {
1121            assert!(e(g).contains("allowlist"), "{g}: {}", e(g));
1122        }
1123        // The same tricks on an allowed filter reach its write option, or a key that isn't
1124        // a plain name.
1125        for g in [
1126            "curves=preset=vintage\\:plot=/tmp/x",
1127            "curves=preset=vintage:pl\\ot=/tmp/x",
1128            "curves=preset=vintage:'plot'=/tmp/x",
1129            "curves='preset=vintage:plot=/tmp/x'",
1130        ] {
1131            assert!(e(g).contains("plot"), "{g}: {}", e(g));
1132        }
1133        for g in [
1134            "eq=gamma=1.1:/gamma=g.txt",
1135            "eq=gamma=1.1:\\/gamma=g.txt",
1136            "eq=gamma=1.1:ga-mma=1",
1137            "eq=gamma=1.1:g.amma=1",
1138            "eq=gamma=1.1:=1",
1139        ] {
1140            assert!(e(g).contains("plain option name"), "{g}: {}", e(g));
1141        }
1142        // Pass 2 sees a quoted key (pass 1 kept the quotes): ffmpeg takes it as a value.
1143        assert!(e("eq=gamma=1.1:\\'brightness\\'=0.1").contains("positional"));
1144        assert!(e("eq=\\'brightness\\'=0.1").contains("`=`"));
1145        assert!(e("eq=gamma=1.1:gamma=1.2").contains("set twice"));
1146        assert!(e("lut3d=a.cube:file=b.cube").contains("set twice"));
1147        assert!(e("subtitles=filename=a.srt:f=b.srt").contains("set twice"));
1148        // Unterminated quotes and trailing backslashes, in either pass.
1149        assert!(e("eq=brightness='0.1").contains("unterminated quote"));
1150        assert!(e("eq=brightness=\\'0.1").contains("unterminated quote"));
1151        assert!(e("eq=brightness=0.1\\").contains("trailing backslash"));
1152        assert!(e("eq=brightness=0.1\\\\").contains("trailing backslash"));
1153        assert!(e("sws_flags=bicubic;eq").contains("sws_flags"));
1154        assert!(e("eq=gamma=1]").contains("trailing"));
1155        // Legit quoting still passes, and values come out as ffmpeg sees them.
1156        ok("eq=brightness='0.1*sin(2*PI*t)'");
1157        ok("eq=eval=frame:brightness=0.1*sin(2*PI*t):contrast='1.1'");
1158        ok("eq=eval=frame:brightness=0.1*mod(t\\,2)");
1159        ok("drawtext=text='10\\:00, [ok]; fine':x=10:y=h-20");
1160        ok("drawtext=text=a\\\\\\:b");
1161        ok("[in] eq = gamma = 1.1 [x] ; [x] hue@h=s=0 [out]");
1162        ok("eq=gamma=1.1,");
1163        let f = parse("eq=brightness='0.1*sin(2*PI*t)':contrast=1\\,1").unwrap();
1164        assert_eq!(
1165            f[0].args,
1166            [
1167                (Some("brightness".into()), "0.1*sin(2*PI*t)".into()),
1168                (Some("contrast".into()), "1,1".into())
1169            ]
1170        );
1171        let f = parse("drawtext=text='10\\:00, [ok]'").unwrap();
1172        assert_eq!(f[0].args[0].1, "10:00, [ok]");
1173        let f = parse("drawtext=text=a\\\\\\:b:x=1").unwrap();
1174        assert_eq!(f[0].args[0].1, "a:b");
1175        let f = parse("[in]hue@h=s=0[out]").unwrap();
1176        assert_eq!(f[0].name, "hue");
1177        // Positional indices: drawtext's third is textfile, subtitles' third fontsdir.
1178        let f = parse("drawtext=f.ttf:hi:t.txt").unwrap();
1179        assert_eq!(file_args(&f[0]).unwrap(), [(0, Use::Read), (2, Use::Read)]);
1180        let f = parse("subtitles=a.srt:640x480:fonts").unwrap();
1181        assert_eq!(
1182            file_args(&f[0]).unwrap(),
1183            [(0, Use::Read), (2, Use::ReadDir)]
1184        );
1185    }
1186
1187    #[test]
1188    fn escape_round_trips() {
1189        for v in [
1190            "plain",
1191            "/a b/c,d[e]f;g:h=i'j\\k",
1192            " lead",
1193            "trail ",
1194            " both\t",
1195            "\\",
1196            "''",
1197        ] {
1198            let text = format!("lut3d=file={}", escape(&escape(v, "':="), "'[],;"));
1199            let f = parse(&text).unwrap();
1200            assert_eq!(f[0].args, [(Some("file".into()), v.into())], "{text}");
1201        }
1202    }
1203
1204    #[test]
1205    fn locate_makes_reads_absolute() {
1206        let dir = std::env::temp_dir().join(format!("ffcine-locate-{}", std::process::id()));
1207        std::fs::create_dir_all(dir.join("my luts")).unwrap();
1208        std::fs::write(dir.join("my luts/a,b.cube"), "LUT_3D_SIZE 2\n").unwrap();
1209        std::fs::write(dir.join("t.txt"), "hi").unwrap();
1210        let mut n = node(
1211            "[in]lut3d='my luts/a,b.cube':interp=tetrahedral[x];[x]drawtext=textfile=t.txt:x=1[out]",
1212        );
1213        n.locate(&dir).unwrap();
1214        assert!(n.labelled);
1215        assert_eq!(n.files, [dir.join("my luts/a,b.cube"), dir.join("t.txt")]);
1216        let d = escape(&escape(&dir.to_string_lossy(), "':="), "'[],;");
1217        assert_eq!(
1218            n.run,
1219            format!(
1220                "[in]lut3d={d}/my luts/a\\,b.cube:interp=tetrahedral[x];\
1221                 [x]drawtext=textfile={d}/t.txt:x=1[out]"
1222            )
1223        );
1224        // Unchanged without reads.
1225        let mut n = node("eq=gamma=1.1, hue=s=0");
1226        n.locate(&dir).unwrap();
1227        assert_eq!(n.run, "eq=gamma=1.1, hue=s=0");
1228        assert!(!n.labelled);
1229        // fontsdir: every file under it, subdirectories too, sorted.
1230        std::fs::create_dir_all(dir.join("fonts/sub")).unwrap();
1231        std::fs::write(dir.join("fonts/a.ttf"), "a").unwrap();
1232        std::fs::write(dir.join("fonts/sub/b.otf"), "b").unwrap();
1233        std::fs::write(dir.join("s.srt"), "").unwrap();
1234        let mut n = node("subtitles=s.srt:fontsdir=fonts");
1235        n.locate(&dir).unwrap();
1236        assert_eq!(
1237            n.files,
1238            [
1239                dir.join("s.srt"),
1240                dir.join("fonts/a.ttf"),
1241                dir.join("fonts/sub/b.otf")
1242            ]
1243        );
1244        let e = node("lut3d=missing.cube").locate(&dir).unwrap_err();
1245        assert!(e.contains("no such file"), "{e}");
1246        let e = node("vidstabtransform=smoothing=10")
1247            .locate(&dir)
1248            .unwrap_err();
1249        assert!(e.contains("input="), "{e}");
1250        std::fs::remove_dir_all(&dir).unwrap();
1251    }
1252}