1use std::ops::Range;
10use std::path::{Path, PathBuf};
11use std::sync::Arc;
12use std::time::Duration;
13
14use crate::extra::{self, Filter};
15
16pub const FILTER_TABLE_REVISION: u32 = 1;
19
20pub const MAX_ROLL: u32 = 10_000;
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
25pub enum Hw {
26 #[default]
28 Cpu,
29 Cuda,
31 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
55pub enum Clock {
56 #[default]
58 Layer,
59 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#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct Node {
82 pub graph: String,
85 pub hw: Hw,
86 pub clock: Clock,
87 pub preroll: Option<u32>,
89 pub postroll: Option<u32>,
90 pub whole_clip: Option<bool>,
91 pub free_timing: bool,
94 pub files: Vec<PathBuf>,
97 pub run: String,
99 pub labelled: bool,
102}
103
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
106pub struct Reach {
107 pub preroll: u32,
109 pub postroll: u32,
111 pub whole_clip: bool,
113}
114
115impl Reach {
116 pub fn stateful(self) -> bool {
118 self.preroll > 0 || self.postroll > 0 || self.whole_clip
119 }
120}
121
122const 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
132fn 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
170fn ident(s: &str) -> bool {
172 !s.is_empty() && s.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'_')
173}
174
175fn 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 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 at += 1;
222 }
223 Ok(out)
224}
225
226#[derive(Debug, Clone, PartialEq)]
228struct Parsed {
229 filter: Filter,
231 opts: Option<Range<usize>>,
233 chain: usize,
235 labels_in: Vec<String>,
237 labels_out: Vec<String>,
238}
239
240fn 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
255fn 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
317fn 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
326pub fn labelled(graph: &str) -> Result<bool, String> {
332 Ok(is_labelled(&parse_graph(graph)?))
333}
334
335pub fn parse(graph: &str) -> Result<Vec<Filter>, String> {
340 Ok(parse_graph(graph)?.into_iter().map(|p| p.filter).collect())
341}
342fn 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
357pub fn changes_timing(name: &str) -> bool {
360 matches!(
361 name,
362 "minterpolate" | "framerate" | "mpdecimate" | "decimate" | "pullup"
363 )
364}
365
366#[derive(Debug, Clone, Copy, PartialEq, Eq)]
368pub enum Use {
369 Read,
371 ReadDir,
373 Write,
375}
376
377pub type FileOpt = (&'static [&'static str], Option<usize>, Use);
380
381pub 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
578fn 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
610fn 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
624fn 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}
662pub 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
704fn 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
720fn 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
730fn 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 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 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 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
863const MAX_TREE_FILES: usize = 4096;
865const MAX_TREE_BYTES: u64 = 1 << 30;
867
868fn 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 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#[derive(Debug, Clone, PartialEq)]
912pub struct Prep {
913 pub graph: String,
914 pub labelled: bool,
916 pub hw: Hw,
917 pub free_timing: bool,
918 pub input: PathBuf,
920 pub dims: (u32, u32),
922 pub start: Duration,
924 pub map: Arc<[i64]>,
926 pub rate: f64,
928 pub pts: i64,
930 pub store: Range<usize>,
933 pub show: Range<usize>,
935 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 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 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 "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 #[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 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 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 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 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 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 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 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 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}