Skip to main content

renderbox_sdk/ops/
video.rs

1use std::collections::BTreeMap;
2
3use renderbox_dsl::ParamValue;
4
5use crate::ops::{add_video_filter, params, ExprNum, OptParam};
6use crate::sorts::Video;
7use crate::graph::Stream;
8
9// ---------------------------------------------------------------------------
10// Geometry / format
11// ---------------------------------------------------------------------------
12
13pub fn scale(w: u32, h: u32) -> impl Fn(Stream<Video>) -> Stream<Video> {
14    move |input| add_video_filter(input, "scale", params! { "w" => w, "h" => h })
15}
16
17pub fn crop(w: u32, h: u32) -> impl Fn(Stream<Video>) -> Stream<Video> {
18    move |input| add_video_filter(input, "crop", params! { "w" => w, "h" => h })
19}
20
21pub fn crop_at(w: u32, h: u32, x: u32, y: u32) -> impl Fn(Stream<Video>) -> Stream<Video> {
22    move |input| {
23        add_video_filter(input, "crop", params! { "w" => w, "h" => h, "x" => x, "y" => y })
24    }
25}
26
27pub fn pad(w: u32, h: u32) -> impl Fn(Stream<Video>) -> Stream<Video> {
28    move |input| {
29        add_video_filter(
30            input,
31            "pad",
32            params! { "w" => ExprNum(w as f64), "h" => ExprNum(h as f64) },
33        )
34    }
35}
36
37pub fn pad_at(
38    w: u32,
39    h: u32,
40    x: u32,
41    y: u32,
42    color: &str,
43) -> impl Fn(Stream<Video>) -> Stream<Video> {
44    let color = color.to_string();
45    move |input| {
46        add_video_filter(
47            input,
48            "pad",
49            params! { "w" => ExprNum(w as f64), "h" => ExprNum(h as f64), "x" => ExprNum(x as f64), "y" => ExprNum(y as f64), "color" => color.as_str() },
50        )
51    }
52}
53
54pub fn rotate(angle: f64) -> impl Fn(Stream<Video>) -> Stream<Video> {
55    move |input| add_video_filter(input, "rotate", params! { "angle" => angle })
56}
57
58pub fn hflip() -> impl Fn(Stream<Video>) -> Stream<Video> {
59    move |input| add_video_filter(input, "hflip", params! {})
60}
61
62pub fn vflip() -> impl Fn(Stream<Video>) -> Stream<Video> {
63    move |input| add_video_filter(input, "vflip", params! {})
64}
65
66#[derive(Debug, Clone, Copy)]
67pub enum Transpose {
68    CClockFlip,
69    Clock,
70    CClock,
71    ClockFlip,
72}
73
74impl Transpose {
75    fn as_value(&self) -> i32 {
76        match self {
77            Transpose::CClockFlip => 0,
78            Transpose::Clock => 1,
79            Transpose::CClock => 2,
80            Transpose::ClockFlip => 3,
81        }
82    }
83}
84
85pub fn transpose(dir: Transpose) -> impl Fn(Stream<Video>) -> Stream<Video> {
86    move |input| add_video_filter(input, "transpose", params! { "dir" => dir.as_value() })
87}
88
89pub fn format(pix_fmt: &str) -> impl Fn(Stream<Video>) -> Stream<Video> {
90    let pix_fmt = pix_fmt.to_string();
91    move |input| add_video_filter(input, "format", params! { "pix_fmts" => pix_fmt.as_str() })
92}
93
94// ---------------------------------------------------------------------------
95// Color / grading
96// ---------------------------------------------------------------------------
97
98#[derive(Default)]
99pub struct Eq {
100    pub brightness: Option<f64>,
101    pub contrast: Option<f64>,
102    pub saturation: Option<f64>,
103    pub gamma: Option<f64>,
104}
105
106pub fn eq(opts: Eq) -> impl Fn(Stream<Video>) -> Stream<Video> {
107    move |input| {
108        let mut p = BTreeMap::new();
109        opts.brightness.insert_into("brightness", &mut p);
110        opts.contrast.insert_into("contrast", &mut p);
111        opts.saturation.insert_into("saturation", &mut p);
112        opts.gamma.insert_into("gamma", &mut p);
113        add_video_filter(input, "eq", p)
114    }
115}
116
117#[derive(Default)]
118pub struct Hue {
119    pub h: Option<f64>,
120    pub s: Option<f64>,
121    pub b: Option<f64>,
122}
123
124pub fn hue(opts: Hue) -> impl Fn(Stream<Video>) -> Stream<Video> {
125    move |input| {
126        let mut p = BTreeMap::new();
127        opts.h.insert_into("h", &mut p);
128        opts.s.insert_into("s", &mut p);
129        opts.b.insert_into("b", &mut p);
130        add_video_filter(input, "hue", p)
131    }
132}
133
134#[derive(Debug, Clone, Copy)]
135pub enum CurvePreset {
136    None,
137    ColorNegative,
138    CrossProcess,
139    Darker,
140    IncreaseContrast,
141    Lighter,
142    LinearContrast,
143    MediumContrast,
144    Negative,
145    StrongContrast,
146    Vintage,
147}
148
149impl CurvePreset {
150    fn as_str(&self) -> &'static str {
151        match self {
152            CurvePreset::None => "none",
153            CurvePreset::ColorNegative => "color_negative",
154            CurvePreset::CrossProcess => "cross_process",
155            CurvePreset::Darker => "darker",
156            CurvePreset::IncreaseContrast => "increase_contrast",
157            CurvePreset::Lighter => "lighter",
158            CurvePreset::LinearContrast => "linear_contrast",
159            CurvePreset::MediumContrast => "medium_contrast",
160            CurvePreset::Negative => "negative",
161            CurvePreset::StrongContrast => "strong_contrast",
162            CurvePreset::Vintage => "vintage",
163        }
164    }
165}
166
167pub fn curves(preset: CurvePreset) -> impl Fn(Stream<Video>) -> Stream<Video> {
168    move |input| add_video_filter(input, "curves", params! { "preset" => preset.as_str() })
169}
170
171pub fn lut3d(file: &str) -> impl Fn(Stream<Video>) -> Stream<Video> {
172    let file = file.to_string();
173    move |input| add_video_filter(input, "lut3d", params! { "file" => file.as_str() })
174}
175
176pub fn colorgrade(lut: &str) -> impl Fn(Stream<Video>) -> Stream<Video> {
177    let lut = lut.to_string();
178    move |input| add_video_filter(input, "colorgrade", params! { "lut" => lut.as_str() })
179}
180
181#[derive(Default)]
182pub struct ColorBalance {
183    pub rs: Option<f64>,
184    pub gs: Option<f64>,
185    pub bs: Option<f64>,
186    pub rm: Option<f64>,
187    pub gm: Option<f64>,
188    pub bm: Option<f64>,
189    pub rh: Option<f64>,
190    pub gh: Option<f64>,
191    pub bh: Option<f64>,
192}
193
194pub fn colorbalance(opts: ColorBalance) -> impl Fn(Stream<Video>) -> Stream<Video> {
195    move |input| {
196        let mut p = BTreeMap::new();
197        opts.rs.insert_into("rs", &mut p);
198        opts.gs.insert_into("gs", &mut p);
199        opts.bs.insert_into("bs", &mut p);
200        opts.rm.insert_into("rm", &mut p);
201        opts.gm.insert_into("gm", &mut p);
202        opts.bm.insert_into("bm", &mut p);
203        opts.rh.insert_into("rh", &mut p);
204        opts.gh.insert_into("gh", &mut p);
205        opts.bh.insert_into("bh", &mut p);
206        add_video_filter(input, "colorbalance", p)
207    }
208}
209
210pub fn colortemperature(temperature: f64) -> impl Fn(Stream<Video>) -> Stream<Video> {
211    move |input| {
212        add_video_filter(input, "colortemperature", params! { "temperature" => temperature })
213    }
214}
215
216#[derive(Default)]
217pub struct ColorChannelMixer {
218    pub rr: Option<f64>,
219    pub rg: Option<f64>,
220    pub rb: Option<f64>,
221    pub gr: Option<f64>,
222    pub gg: Option<f64>,
223    pub gb: Option<f64>,
224    pub br: Option<f64>,
225    pub bg: Option<f64>,
226    pub bb: Option<f64>,
227}
228
229pub fn colorchannelmixer(opts: ColorChannelMixer) -> impl Fn(Stream<Video>) -> Stream<Video> {
230    move |input| {
231        let mut p = BTreeMap::new();
232        opts.rr.insert_into("rr", &mut p);
233        opts.rg.insert_into("rg", &mut p);
234        opts.rb.insert_into("rb", &mut p);
235        opts.gr.insert_into("gr", &mut p);
236        opts.gg.insert_into("gg", &mut p);
237        opts.gb.insert_into("gb", &mut p);
238        opts.br.insert_into("br", &mut p);
239        opts.bg.insert_into("bg", &mut p);
240        opts.bb.insert_into("bb", &mut p);
241        add_video_filter(input, "colorchannelmixer", p)
242    }
243}
244
245// ---------------------------------------------------------------------------
246// Blur / sharpness
247// ---------------------------------------------------------------------------
248
249pub fn gblur(sigma: f64) -> impl Fn(Stream<Video>) -> Stream<Video> {
250    move |input| add_video_filter(input, "gblur", params! { "sigma" => sigma })
251}
252
253pub fn boxblur(luma_radius: &str) -> impl Fn(Stream<Video>) -> Stream<Video> {
254    let luma_radius = luma_radius.to_string();
255    move |input| {
256        add_video_filter(input, "boxblur", params! { "luma_radius" => luma_radius.as_str() })
257    }
258}
259
260#[derive(Default)]
261pub struct Unsharp {
262    pub luma_msize_x: Option<u32>,
263    pub luma_msize_y: Option<u32>,
264    pub luma_amount: Option<f64>,
265}
266
267pub fn unsharp(opts: Unsharp) -> impl Fn(Stream<Video>) -> Stream<Video> {
268    move |input| {
269        let mut p = BTreeMap::new();
270        opts.luma_msize_x.insert_into("luma_msize_x", &mut p);
271        opts.luma_msize_y.insert_into("luma_msize_y", &mut p);
272        opts.luma_amount.insert_into("luma_amount", &mut p);
273        add_video_filter(input, "unsharp", p)
274    }
275}
276
277pub fn bilateral(sigma_s: f64, sigma_r: f64) -> impl Fn(Stream<Video>) -> Stream<Video> {
278    move |input| {
279        add_video_filter(
280            input,
281            "bilateral",
282            params! { "sigmaS" => sigma_s, "sigmaR" => sigma_r },
283        )
284    }
285}
286
287pub fn nlmeans(s: f64) -> impl Fn(Stream<Video>) -> Stream<Video> {
288    move |input| add_video_filter(input, "nlmeans", params! { "s" => s })
289}
290
291pub fn denoise(strength: f64) -> impl Fn(Stream<Video>) -> Stream<Video> {
292    move |input| add_video_filter(input, "denoise", params! { "strength" => strength })
293}
294
295pub fn hqdn3d(luma_spatial: f64) -> impl Fn(Stream<Video>) -> Stream<Video> {
296    move |input| {
297        add_video_filter(input, "hqdn3d", params! { "luma_spatial" => luma_spatial })
298    }
299}
300
301// ---------------------------------------------------------------------------
302// Temporal
303// ---------------------------------------------------------------------------
304
305#[derive(Debug, Clone, Copy)]
306pub enum FadeType {
307    In,
308    Out,
309}
310
311impl FadeType {
312    fn as_str(&self) -> &'static str {
313        match self {
314            FadeType::In => "in",
315            FadeType::Out => "out",
316        }
317    }
318}
319
320#[derive(Default)]
321pub struct Fade {
322    pub fade_type: Option<FadeType>,
323    pub start: Option<f64>,
324    pub duration: Option<f64>,
325    pub color: Option<String>,
326    pub alpha: Option<f64>,
327}
328
329pub fn fade(opts: Fade) -> impl Fn(Stream<Video>) -> Stream<Video> {
330    move |input| {
331        let mut p = BTreeMap::new();
332        if let Some(t) = &opts.fade_type {
333            p.insert("type".into(), ParamValue::String(t.as_str().into()));
334        }
335        opts.start.insert_into("st", &mut p);
336        opts.duration.insert_into("d", &mut p);
337        opts.color.as_deref().insert_into("color", &mut p);
338        opts.alpha.insert_into("alpha", &mut p);
339        add_video_filter(input, "fade", p)
340    }
341}
342
343pub fn setpts(expr: &str) -> impl Fn(Stream<Video>) -> Stream<Video> {
344    let expr = expr.to_string();
345    move |input| add_video_filter(input, "setpts", params! { "expr" => expr.as_str() })
346}
347
348pub fn trim(start: f64, end: f64) -> impl Fn(Stream<Video>) -> Stream<Video> {
349    move |input| add_video_filter(input, "trim", params! { "start" => start, "end" => end })
350}
351
352pub fn fps(rate: f64) -> impl Fn(Stream<Video>) -> Stream<Video> {
353    move |input| add_video_filter(input, "fps", params! { "fps" => rate })
354}
355
356pub fn r#loop(count: i32, size: u32, start: u32) -> impl Fn(Stream<Video>) -> Stream<Video> {
357    move |input| {
358        add_video_filter(
359            input,
360            "loop",
361            params! { "loop" => count, "size" => size, "start" => start },
362        )
363    }
364}
365
366pub fn reverse() -> impl Fn(Stream<Video>) -> Stream<Video> {
367    move |input| add_video_filter(input, "reverse", params! {})
368}
369
370pub fn select(expr: &str) -> impl Fn(Stream<Video>) -> Stream<Video> {
371    let expr = expr.to_string();
372    move |input| add_video_filter(input, "select", params! { "expr" => expr.as_str() })
373}
374
375pub fn framestep(step: u32) -> impl Fn(Stream<Video>) -> Stream<Video> {
376    move |input| add_video_filter(input, "framestep", params! { "step" => step })
377}
378
379// ---------------------------------------------------------------------------
380// Text / overlay
381// ---------------------------------------------------------------------------
382
383#[derive(Default)]
384pub struct DrawText {
385    pub text: Option<String>,
386    pub textfile: Option<String>,
387    pub fontfile: Option<String>,
388    pub fontsize: Option<f64>,
389    pub fontcolor: Option<String>,
390    pub x: Option<String>,
391    pub y: Option<String>,
392    pub boxcolor: Option<String>,
393    pub borderw: Option<f64>,
394    pub shadowcolor: Option<String>,
395    pub shadowx: Option<f64>,
396    pub shadowy: Option<f64>,
397    pub alpha: Option<f64>,
398    pub font: Option<String>,
399    pub line_spacing: Option<f64>,
400    pub enable: Option<String>,
401}
402
403pub fn drawtext(opts: DrawText) -> impl Fn(Stream<Video>) -> Stream<Video> {
404    move |input| {
405        let mut p = BTreeMap::new();
406        opts.text.as_deref().insert_into("text", &mut p);
407        opts.textfile.as_deref().insert_into("textfile", &mut p);
408        opts.fontfile.as_deref().insert_into("fontfile", &mut p);
409        opts.fontsize.insert_into("fontsize", &mut p);
410        opts.fontcolor.as_deref().insert_into("fontcolor", &mut p);
411        opts.x.as_deref().insert_into("x", &mut p);
412        opts.y.as_deref().insert_into("y", &mut p);
413        opts.boxcolor.as_deref().insert_into("boxcolor", &mut p);
414        opts.borderw.insert_into("borderw", &mut p);
415        opts.shadowcolor
416            .as_deref()
417            .insert_into("shadowcolor", &mut p);
418        opts.shadowx.insert_into("shadowx", &mut p);
419        opts.shadowy.insert_into("shadowy", &mut p);
420        opts.alpha.insert_into("alpha", &mut p);
421        opts.font.as_deref().insert_into("font", &mut p);
422        opts.line_spacing.insert_into("line_spacing", &mut p);
423        opts.enable.as_deref().insert_into("enable", &mut p);
424        add_video_filter(input, "drawtext", p)
425    }
426}
427
428pub fn drawbox(
429    x: u32,
430    y: u32,
431    w: u32,
432    h: u32,
433    color: &str,
434    thickness: u32,
435) -> impl Fn(Stream<Video>) -> Stream<Video> {
436    let color = color.to_string();
437    move |input| {
438        add_video_filter(
439            input,
440            "drawbox",
441            params! {
442                "x" => x, "y" => y, "w" => w, "h" => h,
443                "color" => color.as_str(), "t" => thickness
444            },
445        )
446    }
447}
448
449pub fn subtitles(filename: &str) -> impl Fn(Stream<Video>) -> Stream<Video> {
450    let filename = filename.to_string();
451    move |input| {
452        add_video_filter(input, "subtitles", params! { "filename" => filename.as_str() })
453    }
454}
455
456// ---------------------------------------------------------------------------
457// Remaining P2 filters — compact definitions
458// ---------------------------------------------------------------------------
459
460pub fn zoompan(zoom: &str, d: u32, s: &str) -> impl Fn(Stream<Video>) -> Stream<Video> {
461    let zoom = zoom.to_string();
462    let s = s.to_string();
463    move |input| {
464        add_video_filter(
465            input,
466            "zoompan",
467            params! { "zoom" => zoom.as_str(), "d" => d, "s" => s.as_str() },
468        )
469    }
470}
471
472pub fn chromakey(color: &str, similarity: f64, blend: f64) -> impl Fn(Stream<Video>) -> Stream<Video> {
473    let color = color.to_string();
474    move |input| {
475        add_video_filter(
476            input,
477            "chromakey",
478            params! { "color" => color.as_str(), "similarity" => similarity, "blend" => blend },
479        )
480    }
481}
482
483pub fn colorkey(color: &str, similarity: f64, blend: f64) -> impl Fn(Stream<Video>) -> Stream<Video> {
484    let color = color.to_string();
485    move |input| {
486        add_video_filter(
487            input,
488            "colorkey",
489            params! { "color" => color.as_str(), "similarity" => similarity, "blend" => blend },
490        )
491    }
492}
493
494pub fn deband() -> impl Fn(Stream<Video>) -> Stream<Video> {
495    move |input| add_video_filter(input, "deband", params! {})
496}
497
498pub fn vignette() -> impl Fn(Stream<Video>) -> Stream<Video> {
499    move |input| add_video_filter(input, "vignette", params! {})
500}
501
502pub fn noise(alls: u32) -> impl Fn(Stream<Video>) -> Stream<Video> {
503    move |input| add_video_filter(input, "noise", params! { "alls" => alls })
504}
505
506pub fn edgedetect() -> impl Fn(Stream<Video>) -> Stream<Video> {
507    move |input| add_video_filter(input, "edgedetect", params! {})
508}
509
510pub fn denoise_nlmeans(s: f64) -> impl Fn(Stream<Video>) -> Stream<Video> {
511    nlmeans(s)
512}
513
514pub fn grayscale() -> impl Fn(Stream<Video>) -> Stream<Video> {
515    format("gray")
516}
517
518pub fn negate() -> impl Fn(Stream<Video>) -> Stream<Video> {
519    move |input| add_video_filter(input, "negate", params! {})
520}
521
522pub fn minterpolate(fps: f64) -> impl Fn(Stream<Video>) -> Stream<Video> {
523    move |input| add_video_filter(input, "minterpolate", params! { "fps" => fps })
524}
525
526pub fn deshake() -> impl Fn(Stream<Video>) -> Stream<Video> {
527    move |input| add_video_filter(input, "deshake", params! {})
528}
529
530pub fn deflicker() -> impl Fn(Stream<Video>) -> Stream<Video> {
531    move |input| add_video_filter(input, "deflicker", params! {})
532}
533
534#[cfg(test)]
535mod tests {
536    use super::*;
537    use crate::graph::input;
538
539    #[test]
540    fn scale_produces_correct_opnode() {
541        let (v, _a) = input("test.mp4");
542        let v = v.pipe(scale(1920, 1080));
543        let graph = v.graph.borrow();
544        let node = graph.arena.get(v.node_id);
545        match node {
546            renderbox_dsl::OpNode::Filter { name, params, .. } => {
547                assert_eq!(name, "scale");
548                assert_eq!(params["w"], ParamValue::Number(1920.0));
549                assert_eq!(params["h"], ParamValue::Number(1080.0));
550            }
551            other => panic!("expected Filter, got {:?}", other),
552        }
553    }
554
555    #[test]
556    fn hflip_produces_paramless_filter() {
557        let (v, _a) = input("test.mp4");
558        let v = v.pipe(hflip());
559        let graph = v.graph.borrow();
560        let node = graph.arena.get(v.node_id);
561        match node {
562            renderbox_dsl::OpNode::Filter { name, params, .. } => {
563                assert_eq!(name, "hflip");
564                assert!(params.is_empty());
565            }
566            other => panic!("expected Filter, got {:?}", other),
567        }
568    }
569
570    #[test]
571    fn curves_enum_param() {
572        let (v, _a) = input("test.mp4");
573        let v = v.pipe(curves(CurvePreset::Vintage));
574        let graph = v.graph.borrow();
575        let node = graph.arena.get(v.node_id);
576        match node {
577            renderbox_dsl::OpNode::Filter { name, params, .. } => {
578                assert_eq!(name, "curves");
579                assert_eq!(
580                    params["preset"],
581                    ParamValue::String("vintage".into())
582                );
583            }
584            other => panic!("expected Filter, got {:?}", other),
585        }
586    }
587
588    #[test]
589    fn eq_optional_params() {
590        let (v, _a) = input("test.mp4");
591        let v = v.pipe(eq(Eq {
592            brightness: Some(0.1),
593            contrast: Some(1.5),
594            ..Default::default()
595        }));
596        let graph = v.graph.borrow();
597        let node = graph.arena.get(v.node_id);
598        match node {
599            renderbox_dsl::OpNode::Filter { name, params, .. } => {
600                assert_eq!(name, "eq");
601                assert_eq!(params["brightness"], ParamValue::Number(0.1));
602                assert_eq!(params["contrast"], ParamValue::Number(1.5));
603                assert!(!params.contains_key("saturation"));
604                assert!(!params.contains_key("gamma"));
605            }
606            other => panic!("expected Filter, got {:?}", other),
607        }
608    }
609
610    #[test]
611    fn transpose_integer_enum() {
612        let (v, _a) = input("test.mp4");
613        let v = v.pipe(transpose(Transpose::Clock));
614        let graph = v.graph.borrow();
615        match graph.arena.get(v.node_id) {
616            renderbox_dsl::OpNode::Filter { name, params, .. } => {
617                assert_eq!(name, "transpose");
618                assert_eq!(params["dir"], ParamValue::Number(1.0));
619            }
620            other => panic!("expected Filter, got {:?}", other),
621        }
622    }
623
624    #[test]
625    fn pad_at_uses_expr_num() {
626        let (v, _a) = input("test.mp4");
627        let v = v.pipe(pad_at(1920, 1200, 0, 60, "black"));
628        let graph = v.graph.borrow();
629        match graph.arena.get(v.node_id) {
630            renderbox_dsl::OpNode::Filter { name, params, .. } => {
631                assert_eq!(name, "pad");
632                assert_eq!(params["w"], ParamValue::String("1920.0".into()));
633                assert_eq!(params["h"], ParamValue::String("1200.0".into()));
634                assert_eq!(params["x"], ParamValue::String("0.0".into()));
635                assert_eq!(params["y"], ParamValue::String("60.0".into()));
636                assert_eq!(params["color"], ParamValue::String("black".into()));
637            }
638            other => panic!("expected Filter, got {:?}", other),
639        }
640    }
641
642    #[test]
643    fn fade_builder_with_type() {
644        let (v, _a) = input("test.mp4");
645        let v = v.pipe(fade(Fade {
646            fade_type: Some(FadeType::In),
647            start: Some(0.0),
648            duration: Some(2.0),
649            ..Default::default()
650        }));
651        let graph = v.graph.borrow();
652        match graph.arena.get(v.node_id) {
653            renderbox_dsl::OpNode::Filter { name, params, .. } => {
654                assert_eq!(name, "fade");
655                assert_eq!(params["type"], ParamValue::String("in".into()));
656                assert_eq!(params["st"], ParamValue::Number(0.0));
657                assert_eq!(params["d"], ParamValue::Number(2.0));
658                assert!(!params.contains_key("color"));
659                assert!(!params.contains_key("alpha"));
660            }
661            other => panic!("expected Filter, got {:?}", other),
662        }
663    }
664
665    #[test]
666    fn drawtext_mixed_params() {
667        let (v, _a) = input("test.mp4");
668        let v = v.pipe(drawtext(DrawText {
669            text: Some("Hello".into()),
670            fontsize: Some(48.0),
671            fontcolor: Some("white".into()),
672            x: Some("(w-text_w)/2".into()),
673            y: Some("h-th-10".into()),
674            ..Default::default()
675        }));
676        let graph = v.graph.borrow();
677        match graph.arena.get(v.node_id) {
678            renderbox_dsl::OpNode::Filter { name, params, .. } => {
679                assert_eq!(name, "drawtext");
680                assert_eq!(params["text"], ParamValue::String("Hello".into()));
681                assert_eq!(params["fontsize"], ParamValue::Number(48.0));
682                assert_eq!(params["fontcolor"], ParamValue::String("white".into()));
683                assert_eq!(params["x"], ParamValue::String("(w-text_w)/2".into()));
684                assert!(!params.contains_key("textfile"));
685                assert!(!params.contains_key("shadowcolor"));
686            }
687            other => panic!("expected Filter, got {:?}", other),
688        }
689    }
690
691    #[test]
692    fn setpts_expression_string() {
693        let (v, _a) = input("test.mp4");
694        let v = v.pipe(setpts("2.0*PTS"));
695        let graph = v.graph.borrow();
696        match graph.arena.get(v.node_id) {
697            renderbox_dsl::OpNode::Filter { name, params, .. } => {
698                assert_eq!(name, "setpts");
699                assert_eq!(params["expr"], ParamValue::String("2.0*PTS".into()));
700            }
701            other => panic!("expected Filter, got {:?}", other),
702        }
703    }
704
705    #[test]
706    fn filter_pipeline_chains() {
707        let (v, a) = input("test.mp4");
708        let v = v
709            .pipe(scale(1920, 1080))
710            .pipe(curves(CurvePreset::Vintage))
711            .pipe(gblur(2.0));
712        let bytes = crate::graph::output("out.mp4", v, a).build().unwrap();
713        assert!(!bytes.is_empty());
714    }
715}