Skip to main content

ff_pipeline/
clip.rs

1//! Timeline clip data type.
2//!
3//! This module provides [`Clip`], a plain Rust value type representing a single
4//! media clip on a timeline. `Clip` holds no `FFmpeg` context; it is interpreted
5//! by `Timeline::render()` at call time to build filter graphs.
6
7use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9use std::time::Duration;
10
11use ff_filter::{BlendMode, CompositeOp, FilterGraph, FilterStep, XfadeTransition};
12use ff_format::{PixelFormat, VideoFrame};
13
14use crate::error::PipelineError;
15
16/// A single media clip on a timeline.
17///
18/// `Clip` is a plain Rust value type — it holds no `FFmpeg` context. All fields
19/// are public so callers can inspect them directly. `Timeline::render()` interprets
20/// the clip's fields to build filter graphs at call time.
21///
22/// # Examples
23///
24/// ```
25/// use ff_pipeline::Clip;
26/// use std::time::Duration;
27///
28/// let clip = Clip::new("intro.mp4")
29///     .trim(Duration::from_secs(2), Duration::from_secs(10))
30///     .offset(Duration::from_secs(5));
31///
32/// assert_eq!(clip.duration(), Some(Duration::from_secs(8)));
33/// ```
34#[derive(Debug, Clone)]
35pub struct Clip {
36    /// Path to the source media file.
37    pub source: PathBuf,
38    /// Start point within the source file. `None` = beginning of file.
39    pub in_point: Option<Duration>,
40    /// End point within the source file. `None` = end of file.
41    pub out_point: Option<Duration>,
42    /// Start offset on the timeline (`Duration::ZERO` = beginning of composition).
43    pub timeline_offset: Duration,
44    /// Arbitrary key/value metadata attached to this clip.
45    pub metadata: HashMap<String, String>,
46    /// Transition applied at the start of this clip (from the previous clip on the same track).
47    /// `None` = hard cut. Ignored for the first clip on a track.
48    pub transition: Option<XfadeTransition>,
49    /// Duration of the transition overlap. Ignored when `transition` is `None`.
50    pub transition_duration: Duration,
51    /// Per-clip volume adjustment in dB applied during audio mixing (`0.0` = unity gain).
52    ///
53    /// This value is independent of any track-level volume animation. When non-zero
54    /// the clip's own gain overrides the track-level value; set to `0.0` to defer
55    /// to the track level.
56    ///
57    /// Defaults to `0.0`.
58    pub volume_db: f64,
59    /// Audio fade-in duration at the start of the clip (`Duration::ZERO` = no fade).
60    ///
61    /// When non-zero, a linear ramp from silence to the clip's volume level is
62    /// applied over this duration, starting at the clip's in-point.
63    ///
64    /// Defaults to `Duration::ZERO`.
65    pub fade_in: Duration,
66    /// Audio fade-out duration at the end of the clip (`Duration::ZERO` = no fade).
67    ///
68    /// When non-zero, a linear ramp from the clip's volume level to silence is
69    /// applied over this duration, ending at the clip's out-point.
70    /// Requires `out_point` to be set or the source file to be probeable so the
71    /// `afade` start offset can be computed. Omitted with `log::warn!` at render
72    /// time if the clip duration cannot be determined.
73    ///
74    /// Defaults to `Duration::ZERO`.
75    pub fade_out: Duration,
76    /// Per-clip brightness adjustment. Range: −1.0..=1.0. Default: 0.0 (no change).
77    ///
78    /// Applied via the `eq` video filter during `Timeline::render()`.
79    /// Neutral value (`0.0`) produces bit-identical output to the no-eq path.
80    pub brightness: f32,
81    /// Per-clip contrast adjustment. Range: 0.0..=3.0. Default: 1.0 (no change).
82    ///
83    /// Applied via the `eq` video filter during `Timeline::render()`.
84    /// Neutral value (`1.0`) produces bit-identical output to the no-eq path.
85    pub contrast: f32,
86    /// Per-clip saturation adjustment. Range: 0.0..=3.0. Default: 1.0 (no change).
87    ///
88    /// Applied via the `eq` video filter during `Timeline::render()`.
89    /// Neutral value (`1.0`) produces bit-identical output to the no-eq path.
90    pub saturation: f32,
91    /// Per-clip overlay opacity applied when this clip is composited over a lower layer.
92    /// Range: `0.0` (fully transparent) to `1.0` (fully opaque). Default: `1.0`.
93    ///
94    /// For [`BlendMode::Normal`], opacity is applied via a `colorchannelmixer` filter before
95    /// the overlay. For photographic blend modes, it is forwarded to the `blend` filter's
96    /// `all_opacity` parameter.
97    ///
98    /// Neutral value (`1.0`) produces bit-identical output to the no-opacity path.
99    pub opacity: f32,
100    /// Blend mode for compositing this clip over the layer(s) below it.
101    /// Default: [`BlendMode::Normal`] (standard alpha-over composite).
102    ///
103    /// [`BlendMode::Normal`] uses `FFmpeg`'s `overlay` filter. All other variants use `FFmpeg`'s
104    /// `blend` filter with the corresponding `all_mode`.
105    ///
106    /// Colour blend only — applies when [`composite_op`](Self::composite_op) is
107    /// [`CompositeOp::Over`] (the default).
108    pub blend_mode: BlendMode,
109    /// Porter-Duff alpha-compositing operator for placing this clip over the layer(s) below.
110    /// Default: [`CompositeOp::Over`] (standard alpha-over).
111    ///
112    /// Independent of [`blend_mode`](Self::blend_mode): `Over` keeps the colour-blend
113    /// compositing, while `Under`/`In`/`Out`/`Atop`/`Xor` composite the clip via the
114    /// corresponding Porter-Duff operator (the colour `blend_mode` is not applied then).
115    pub composite_op: CompositeOp,
116    /// Per-clip playback speed multiplier. Range: 0.1..=100.0. Default: 1.0 (normal speed).
117    ///
118    /// Applied via `setpts=PTS/{speed}` on the video stream and a chain of `atempo` filters
119    /// on the audio stream during `Timeline::render()`.
120    /// Neutral value (`1.0`) produces bit-identical output to the no-speed path.
121    ///
122    /// # Examples
123    ///
124    /// ```
125    /// use ff_pipeline::Clip;
126    ///
127    /// let clip = Clip::new("scene.mp4").with_speed(2.0);
128    /// assert_eq!(clip.speed, 2.0);
129    /// ```
130    pub speed: f64,
131    /// Optional low-resolution proxy file to decode from instead of `source`.
132    ///
133    /// When `Some`, `Timeline::render()` decodes video frames from this proxy and
134    /// scales them up to the original `source` resolution, producing full-resolution
135    /// output while rendering from a smaller, faster-to-decode file. The original
136    /// `source` must still be probeable so its resolution can be determined; if the
137    /// probe fails, the proxy is ignored and `source` is used directly.
138    ///
139    /// Defaults to `None`.
140    ///
141    /// # Examples
142    ///
143    /// ```
144    /// use ff_pipeline::Clip;
145    ///
146    /// let clip = Clip::new("scene.mp4").proxy("scene_proxy_quarter.mp4");
147    /// assert!(clip.proxy.is_some());
148    /// ```
149    pub proxy: Option<PathBuf>,
150    /// Ordered per-clip video filter steps applied to the clip's video layer.
151    ///
152    /// Applied during `Timeline::render()` after the built-in speed and
153    /// colour-correction steps, allowing callers to attach any video
154    /// [`FilterStep`] (e.g. `Lut3d`, `Curves`, `ChromaKey`, `GBlur`) to a
155    /// single clip. An empty vec (the default) is a no-op.
156    pub video_effects: Vec<FilterStep>,
157    /// Ordered per-clip audio filter steps applied to the clip's audio track.
158    ///
159    /// Applied during the audio mix after the built-in speed and fade steps,
160    /// allowing callers to attach any audio [`FilterStep`] (e.g. `ACompressor`,
161    /// `ParametricEq`, `NoiseReduce`) to a single clip. An empty vec (the
162    /// default) is a no-op.
163    pub audio_effects: Vec<FilterStep>,
164}
165
166impl Clip {
167    /// Creates a new clip from a source path with no trim points and zero timeline offset.
168    pub fn new(source: impl AsRef<Path>) -> Self {
169        Self {
170            source: source.as_ref().to_path_buf(),
171            in_point: None,
172            out_point: None,
173            timeline_offset: Duration::ZERO,
174            metadata: HashMap::new(),
175            transition: None,
176            transition_duration: Duration::ZERO,
177            volume_db: 0.0,
178            fade_in: Duration::ZERO,
179            fade_out: Duration::ZERO,
180            brightness: 0.0,
181            contrast: 1.0,
182            saturation: 1.0,
183            opacity: 1.0,
184            blend_mode: BlendMode::Normal,
185            composite_op: CompositeOp::Over,
186            speed: 1.0,
187            proxy: None,
188            video_effects: Vec::new(),
189            audio_effects: Vec::new(),
190        }
191    }
192
193    /// Attaches a video [`FilterStep`] to this clip and returns the updated clip.
194    ///
195    /// Steps are applied in the order added, after the built-in speed and
196    /// colour-correction steps, during `Timeline::render()`.
197    ///
198    /// # Examples
199    ///
200    /// ```
201    /// use ff_pipeline::Clip;
202    /// use ff_filter::FilterStep;
203    ///
204    /// let clip = Clip::new("scene.mp4")
205    ///     .with_video_effect(FilterStep::Lut3d { path: "look.cube".into() });
206    /// assert_eq!(clip.video_effects.len(), 1);
207    /// ```
208    #[must_use]
209    pub fn with_video_effect(mut self, step: FilterStep) -> Self {
210        self.video_effects.push(step);
211        self
212    }
213
214    /// Returns the pixel-domain video effect chain that `Timeline::render()`
215    /// applies to this clip's layer: the `eq` colour-correction step (included
216    /// only when brightness/contrast/saturation are non-neutral) followed by the
217    /// caller-attached [`video_effects`](Self::video_effects), in order.
218    ///
219    /// Temporal steps such as `Speed` are intentionally excluded — they affect
220    /// timing, not a single frame's pixels. This is the exact list
221    /// [`apply_video_effects`](Self::apply_video_effects) runs, and the same one
222    /// `Timeline::render()` builds for the clip's layer, so a preview built from
223    /// it matches the rendered output.
224    ///
225    /// # Examples
226    ///
227    /// ```
228    /// use ff_pipeline::Clip;
229    /// use ff_filter::FilterStep;
230    ///
231    /// let clip = Clip::new("scene.mp4")
232    ///     .with_color_correction(0.1, 1.2, 1.0)
233    ///     .with_video_effect(FilterStep::Hue { degrees: 30.0 });
234    /// let chain = clip.video_effect_chain();
235    /// assert!(matches!(
236    ///     chain.as_slice(),
237    ///     [FilterStep::Eq { .. }, FilterStep::Hue { .. }]
238    /// ));
239    /// ```
240    #[must_use]
241    pub fn video_effect_chain(&self) -> Vec<FilterStep> {
242        let mut steps = Vec::new();
243        #[allow(clippy::float_cmp)]
244        let neutral = self.brightness == 0.0 && self.contrast == 1.0 && self.saturation == 1.0;
245        if !neutral {
246            steps.push(FilterStep::Eq {
247                brightness: self.brightness,
248                contrast: self.contrast,
249                saturation: self.saturation,
250            });
251        }
252        steps.extend(self.video_effects.iter().cloned());
253        steps
254    }
255
256    /// Applies this clip's video effect chain to a single frame using the same
257    /// steps and `yuv420p` working space as `Timeline::render()`, so a host can
258    /// show a preview that matches the exported result (within 4:2:0 chroma
259    /// rounding) without reimplementing any filter.
260    ///
261    /// The chain is [`video_effect_chain`](Self::video_effect_chain). The input
262    /// frame is converted to `yuv420p` before the chain runs — matching the
263    /// composition colour space, so YUV-domain filters such as `hue` and `eq`
264    /// behave identically to the export — then back to its original pixel format,
265    /// so the returned frame has the same format as `frame`.
266    ///
267    /// This is a one-shot convenience that builds a fresh [`VideoEffectRenderer`]
268    /// per call. For real-time preview, build a [`video_effect_renderer`] once and
269    /// reuse it across frames to avoid rebuilding the filter graph (and re-loading
270    /// any `lut3d` file) every frame.
271    ///
272    /// [`video_effect_renderer`]: Self::video_effect_renderer
273    ///
274    /// # Errors
275    ///
276    /// Returns [`PipelineError::Filter`] if the filter graph cannot be built or
277    /// the frame cannot be processed.
278    pub fn apply_video_effects(&self, frame: &VideoFrame) -> Result<VideoFrame, PipelineError> {
279        self.video_effect_renderer(frame.format())?.render(frame)
280    }
281
282    /// Builds a reusable [`VideoEffectRenderer`] for this clip's effect chain.
283    ///
284    /// Hold the returned renderer and call [`VideoEffectRenderer::render`] per
285    /// frame to avoid rebuilding the filter graph (and re-loading any `lut3d`
286    /// file) on every frame — the right choice for real-time preview. Frames
287    /// passed to `render` must be in `input_format`. For a one-shot apply, use
288    /// [`apply_video_effects`](Self::apply_video_effects).
289    ///
290    /// # Errors
291    ///
292    /// Returns [`PipelineError::Filter`] if the filter graph cannot be built.
293    pub fn video_effect_renderer(
294        &self,
295        input_format: PixelFormat,
296    ) -> Result<VideoEffectRenderer, PipelineError> {
297        VideoEffectRenderer::new(self, input_format)
298    }
299
300    /// Attaches an audio [`FilterStep`] to this clip and returns the updated clip.
301    ///
302    /// Steps are applied in the order added, after the built-in speed and fade
303    /// steps, during the audio mix.
304    #[must_use]
305    pub fn with_audio_effect(mut self, step: FilterStep) -> Self {
306        self.audio_effects.push(step);
307        self
308    }
309
310    /// Sets a low-resolution proxy file to decode from and returns the updated clip.
311    ///
312    /// During `Timeline::render()` frames are decoded from `proxy` and scaled up to
313    /// the original `source` resolution. See [`Clip::proxy`](Self::proxy).
314    #[must_use]
315    pub fn proxy(self, proxy: impl AsRef<Path>) -> Self {
316        Self {
317            proxy: Some(proxy.as_ref().to_path_buf()),
318            ..self
319        }
320    }
321
322    /// Sets the in/out trim points and returns the updated clip.
323    #[must_use]
324    pub fn trim(self, in_point: Duration, out_point: Duration) -> Self {
325        Self {
326            in_point: Some(in_point),
327            out_point: Some(out_point),
328            ..self
329        }
330    }
331
332    /// Sets the timeline start offset and returns the updated clip.
333    #[must_use]
334    pub fn offset(self, timeline_offset: Duration) -> Self {
335        Self {
336            timeline_offset,
337            ..self
338        }
339    }
340
341    /// Sets the visual transition from the previous clip into this one and returns
342    /// the updated clip.
343    ///
344    /// The transition is applied at the boundary where the preceding clip ends and
345    /// this clip begins. For the first clip on a track `transition` is ignored.
346    ///
347    /// # Example
348    ///
349    /// ```
350    /// use ff_pipeline::Clip;
351    /// use ff_filter::XfadeTransition;
352    /// use std::time::Duration;
353    ///
354    /// let clip = Clip::new("b.mp4")
355    ///     .with_transition(XfadeTransition::Fade, Duration::from_millis(500));
356    ///
357    /// assert_eq!(clip.transition, Some(XfadeTransition::Fade));
358    /// assert_eq!(clip.transition_duration, Duration::from_millis(500));
359    /// ```
360    #[must_use]
361    pub fn with_transition(self, kind: XfadeTransition, duration: Duration) -> Self {
362        Self {
363            transition: Some(kind),
364            transition_duration: duration,
365            ..self
366        }
367    }
368
369    /// Sets the per-clip volume adjustment in dB and returns the updated clip.
370    ///
371    /// `0.0` is unity gain (no change). Positive values increase volume; negative
372    /// values reduce it. When set to a non-zero value this overrides the track-level
373    /// volume animation for this clip during rendering.
374    ///
375    /// # Example
376    ///
377    /// ```
378    /// use ff_pipeline::Clip;
379    ///
380    /// let clip = Clip::new("narration.wav").volume(-6.0);
381    /// assert_eq!(clip.volume_db, -6.0);
382    /// ```
383    #[must_use]
384    pub fn volume(self, db: f64) -> Self {
385        Self {
386            volume_db: db,
387            ..self
388        }
389    }
390
391    /// Sets the audio fade-in duration and returns the updated clip.
392    ///
393    /// The fade starts at the beginning of the clip and ramps from silence to the
394    /// clip's volume level over `duration`. `Duration::ZERO` disables the fade.
395    ///
396    /// # Example
397    ///
398    /// ```
399    /// use ff_pipeline::Clip;
400    /// use std::time::Duration;
401    ///
402    /// let clip = Clip::new("narration.wav").with_fade_in(Duration::from_secs(2));
403    /// assert_eq!(clip.fade_in, Duration::from_secs(2));
404    /// ```
405    #[must_use]
406    pub fn with_fade_in(self, duration: Duration) -> Self {
407        Self {
408            fade_in: duration,
409            ..self
410        }
411    }
412
413    /// Sets the audio fade-out duration and returns the updated clip.
414    ///
415    /// The fade starts `duration` before the end of the clip and ramps to silence.
416    /// Requires `out_point` to be set or the source file to be probeable; omitted
417    /// with `log::warn!` at render time if the clip duration cannot be determined.
418    /// `Duration::ZERO` disables the fade.
419    ///
420    /// # Example
421    ///
422    /// ```
423    /// use ff_pipeline::Clip;
424    /// use std::time::Duration;
425    ///
426    /// let clip = Clip::new("narration.wav")
427    ///     .trim(Duration::from_secs(0), Duration::from_secs(10))
428    ///     .with_fade_out(Duration::from_secs(1));
429    /// assert_eq!(clip.fade_out, Duration::from_secs(1));
430    /// ```
431    #[must_use]
432    pub fn with_fade_out(self, duration: Duration) -> Self {
433        Self {
434            fade_out: duration,
435            ..self
436        }
437    }
438
439    /// Sets per-clip color correction and returns the updated clip.
440    ///
441    /// The three parameters map directly to the `FFmpeg` `eq` filter:
442    /// - `brightness`: −1.0..=1.0, where `0.0` is no change.
443    /// - `contrast`:    0.0..=3.0, where `1.0` is no change.
444    /// - `saturation`:  0.0..=3.0, where `1.0` is no change.
445    ///
446    /// Neutral values (`brightness = 0.0`, `contrast = 1.0`, `saturation = 1.0`)
447    /// produce bit-identical output to the no-eq render path — the `eq` filter is
448    /// only inserted when at least one value differs from its neutral default.
449    ///
450    /// # Example
451    ///
452    /// ```
453    /// use ff_pipeline::Clip;
454    ///
455    /// let clip = Clip::new("scene.mp4").with_color_correction(0.1, 1.2, 0.9);
456    /// assert_eq!(clip.brightness, 0.1);
457    /// assert_eq!(clip.contrast, 1.2);
458    /// assert_eq!(clip.saturation, 0.9);
459    /// ```
460    #[must_use]
461    pub fn with_color_correction(self, brightness: f32, contrast: f32, saturation: f32) -> Self {
462        Self {
463            brightness,
464            contrast,
465            saturation,
466            ..self
467        }
468    }
469
470    /// Sets the overlay opacity and returns the updated clip.
471    ///
472    /// `opacity` is clamped to `[0.0, 1.0]`.  The neutral value (`1.0`) produces
473    /// bit-identical output to the no-opacity path.
474    ///
475    /// # Example
476    ///
477    /// ```
478    /// use ff_pipeline::Clip;
479    ///
480    /// let clip = Clip::new("overlay.mp4").with_opacity(0.5);
481    /// assert_eq!(clip.opacity, 0.5);
482    /// ```
483    #[must_use]
484    pub fn with_opacity(self, opacity: f32) -> Self {
485        Self {
486            opacity: opacity.clamp(0.0, 1.0),
487            ..self
488        }
489    }
490
491    /// Sets the blend mode for compositing this clip over the layer below and returns
492    /// the updated clip.
493    ///
494    /// [`BlendMode::Normal`] (the default) uses `FFmpeg`'s `overlay` filter.  All other
495    /// variants use `FFmpeg`'s `blend` filter with the corresponding `all_mode`.
496    ///
497    /// # Example
498    ///
499    /// ```
500    /// use ff_pipeline::Clip;
501    /// use ff_filter::BlendMode;
502    ///
503    /// let clip = Clip::new("overlay.mp4").with_blend_mode(BlendMode::Multiply);
504    /// assert_eq!(clip.blend_mode, BlendMode::Multiply);
505    /// ```
506    #[must_use]
507    pub fn with_blend_mode(self, mode: BlendMode) -> Self {
508        Self {
509            blend_mode: mode,
510            ..self
511        }
512    }
513
514    /// Sets the Porter-Duff [`CompositeOp`] for this clip and returns the updated clip.
515    ///
516    /// Independent of [`with_blend_mode`](Self::with_blend_mode); the default is
517    /// [`CompositeOp::Over`] (standard alpha-over).
518    ///
519    /// # Examples
520    ///
521    /// ```
522    /// use ff_pipeline::Clip;
523    /// use ff_filter::CompositeOp;
524    ///
525    /// let clip = Clip::new("overlay.mp4").with_composite_op(CompositeOp::Atop);
526    /// assert_eq!(clip.composite_op, CompositeOp::Atop);
527    /// ```
528    #[must_use]
529    pub fn with_composite_op(self, op: CompositeOp) -> Self {
530        Self {
531            composite_op: op,
532            ..self
533        }
534    }
535
536    /// Sets the per-clip playback speed multiplier and returns the updated clip.
537    ///
538    /// Values greater than `1.0` produce fast motion; values less than `1.0` produce slow
539    /// motion. The speed is applied via `setpts=PTS/{speed}` on the video stream and a chain
540    /// of `atempo` filters on the audio stream during `Timeline::render()`.
541    ///
542    /// The neutral value (`1.0`) produces bit-identical output to the no-speed path.
543    ///
544    /// # Example
545    ///
546    /// ```
547    /// use ff_pipeline::Clip;
548    ///
549    /// let clip = Clip::new("scene.mp4").with_speed(2.0);
550    /// assert_eq!(clip.speed, 2.0);
551    /// ```
552    #[must_use]
553    pub fn with_speed(self, speed: f64) -> Self {
554        Self { speed, ..self }
555    }
556
557    /// Returns `out_point - in_point` when both are `Some`, otherwise `None`.
558    ///
559    /// Does not open the source file.
560    pub fn duration(&self) -> Option<Duration> {
561        match (self.in_point, self.out_point) {
562            (Some(in_pt), Some(out_pt)) => out_pt.checked_sub(in_pt),
563            _ => None,
564        }
565    }
566}
567
568/// Reusable single-frame renderer for a [`Clip`]'s video effect chain.
569///
570/// Built once via [`Clip::video_effect_renderer`]; holds one [`FilterGraph`]
571/// configured with the same `yuv420p` working space and [`Clip::video_effect_chain`]
572/// that `Timeline::render()` uses, so a host preview matches the exported result.
573/// Feed frames through [`render`](Self::render) repeatedly — the graph (and any
574/// `lut3d` `.cube` file it loads) is built once, not per frame, which is the right
575/// choice for real-time preview.
576///
577/// All frames passed to `render` must share the pixel format (the `input_format`
578/// given at construction) and the dimensions of the first rendered frame. Build a
579/// new renderer if the grade, format, or frame size changes.
580pub struct VideoEffectRenderer {
581    graph: FilterGraph,
582}
583
584impl VideoEffectRenderer {
585    /// Builds a renderer for `clip`'s current effect chain. Frames passed to
586    /// [`render`](Self::render) must be in `input_format`; the output is returned
587    /// in the same format.
588    ///
589    /// The graph itself is configured lazily from the first frame's dimensions on
590    /// the initial [`render`](Self::render) call.
591    ///
592    /// # Errors
593    ///
594    /// Returns [`PipelineError::Filter`] if the filter graph cannot be built.
595    pub fn new(clip: &Clip, input_format: PixelFormat) -> Result<Self, PipelineError> {
596        let mut builder = FilterGraph::builder().format(vec![PixelFormat::Yuv420p], vec![], vec![]);
597        for step in clip.video_effect_chain() {
598            builder = builder.add_step(step);
599        }
600        let graph = builder.format(vec![input_format], vec![], vec![]).build()?;
601        Ok(Self { graph })
602    }
603
604    /// Applies the effect chain to one frame, reusing the built graph.
605    ///
606    /// `frame` must match the `input_format` and dimensions established by the
607    /// first rendered frame. The returned frame has the same pixel format as the
608    /// input. The frame's own timestamp is forwarded to the graph (as
609    /// `Timeline::render()` does); the effect chain is pixel-domain and does not
610    /// depend on PTS ordering.
611    ///
612    /// # Errors
613    ///
614    /// Returns [`PipelineError::Filter`] if the frame cannot be processed.
615    pub fn render(&mut self, frame: &VideoFrame) -> Result<VideoFrame, PipelineError> {
616        self.graph.push_video(0, frame)?;
617        self.graph
618            .pull_video()?
619            .ok_or(PipelineError::Filter(ff_filter::FilterError::ProcessFailed))
620    }
621}
622
623#[cfg(test)]
624mod tests {
625    use super::*;
626
627    #[test]
628    fn clip_new_should_have_zero_offset() {
629        let clip = Clip::new("video.mp4");
630        assert_eq!(clip.timeline_offset, Duration::ZERO);
631        assert!(clip.in_point.is_none());
632        assert!(clip.out_point.is_none());
633        assert!(clip.metadata.is_empty());
634    }
635
636    #[test]
637    fn clip_new_should_default_transition_to_none() {
638        let clip = Clip::new("video.mp4");
639        assert!(clip.transition.is_none());
640        assert_eq!(clip.transition_duration, Duration::ZERO);
641    }
642
643    #[test]
644    fn clip_with_transition_should_set_fields() {
645        use ff_filter::XfadeTransition;
646        let clip = Clip::new("video.mp4")
647            .with_transition(XfadeTransition::Fade, Duration::from_millis(500));
648        assert_eq!(clip.transition, Some(XfadeTransition::Fade));
649        assert_eq!(clip.transition_duration, Duration::from_millis(500));
650    }
651
652    #[test]
653    fn clip_trim_should_set_in_out_points() {
654        let clip = Clip::new("video.mp4").trim(Duration::from_secs(3), Duration::from_secs(9));
655        assert_eq!(clip.in_point, Some(Duration::from_secs(3)));
656        assert_eq!(clip.out_point, Some(Duration::from_secs(9)));
657    }
658
659    #[test]
660    fn clip_duration_should_return_none_when_out_point_unset() {
661        let clip = Clip::new("video.mp4");
662        assert!(clip.duration().is_none());
663    }
664
665    #[test]
666    fn clip_duration_should_return_difference_when_both_points_set() {
667        let clip = Clip::new("video.mp4").trim(Duration::from_secs(2), Duration::from_secs(10));
668        assert_eq!(clip.duration(), Some(Duration::from_secs(8)));
669    }
670
671    #[test]
672    fn clip_new_should_default_volume_db_to_zero() {
673        let clip = Clip::new("audio.wav");
674        assert_eq!(clip.volume_db, 0.0);
675    }
676
677    #[test]
678    fn clip_volume_should_set_volume_db() {
679        let clip = Clip::new("audio.wav").volume(-6.0);
680        assert_eq!(clip.volume_db, -6.0);
681    }
682
683    #[test]
684    fn clip_volume_positive_should_set_volume_db() {
685        let clip = Clip::new("audio.wav").volume(3.0);
686        assert_eq!(clip.volume_db, 3.0);
687    }
688
689    #[test]
690    fn clip_new_should_default_fade_fields_to_zero() {
691        let clip = Clip::new("audio.wav");
692        assert_eq!(clip.fade_in, Duration::ZERO);
693        assert_eq!(clip.fade_out, Duration::ZERO);
694    }
695
696    #[test]
697    fn clip_with_fade_in_should_set_fade_in() {
698        let clip = Clip::new("audio.wav").with_fade_in(Duration::from_secs(2));
699        assert_eq!(clip.fade_in, Duration::from_secs(2));
700        assert_eq!(clip.fade_out, Duration::ZERO);
701    }
702
703    #[test]
704    fn clip_with_fade_out_should_set_fade_out() {
705        let clip = Clip::new("audio.wav")
706            .trim(Duration::ZERO, Duration::from_secs(10))
707            .with_fade_out(Duration::from_secs(1));
708        assert_eq!(clip.fade_out, Duration::from_secs(1));
709        assert_eq!(clip.fade_in, Duration::ZERO);
710    }
711
712    #[test]
713    fn clip_fade_in_and_fade_out_can_be_chained() {
714        let clip = Clip::new("audio.wav")
715            .trim(Duration::ZERO, Duration::from_secs(10))
716            .with_fade_in(Duration::from_millis(500))
717            .with_fade_out(Duration::from_millis(500));
718        assert_eq!(clip.fade_in, Duration::from_millis(500));
719        assert_eq!(clip.fade_out, Duration::from_millis(500));
720    }
721
722    #[test]
723    fn clip_new_should_default_color_correction_to_neutral() {
724        let clip = Clip::new("video.mp4");
725        assert_eq!(clip.brightness, 0.0);
726        assert_eq!(clip.contrast, 1.0);
727        assert_eq!(clip.saturation, 1.0);
728    }
729
730    #[test]
731    fn clip_with_color_correction_should_set_fields() {
732        let clip = Clip::new("scene.mp4").with_color_correction(0.1, 1.2, 0.9);
733        assert_eq!(clip.brightness, 0.1);
734        assert_eq!(clip.contrast, 1.2);
735        assert_eq!(clip.saturation, 0.9);
736    }
737
738    #[test]
739    fn clip_new_should_default_speed_to_one() {
740        let clip = Clip::new("video.mp4");
741        assert_eq!(clip.speed, 1.0);
742    }
743
744    #[test]
745    fn clip_with_speed_should_set_speed() {
746        let clip = Clip::new("video.mp4").with_speed(2.0);
747        assert_eq!(clip.speed, 2.0);
748    }
749
750    #[test]
751    fn clip_with_speed_slow_motion_should_set_speed() {
752        let clip = Clip::new("video.mp4").with_speed(0.5);
753        assert_eq!(clip.speed, 0.5);
754    }
755
756    #[test]
757    fn clip_new_should_default_opacity_to_one() {
758        let clip = Clip::new("video.mp4");
759        assert_eq!(clip.opacity, 1.0);
760    }
761
762    #[test]
763    fn clip_with_opacity_should_set_opacity() {
764        let clip = Clip::new("overlay.mp4").with_opacity(0.5);
765        assert_eq!(clip.opacity, 0.5);
766    }
767
768    #[test]
769    fn clip_with_opacity_should_clamp_above_one() {
770        let clip = Clip::new("overlay.mp4").with_opacity(1.5);
771        assert_eq!(clip.opacity, 1.0);
772    }
773
774    #[test]
775    fn clip_with_opacity_should_clamp_below_zero() {
776        let clip = Clip::new("overlay.mp4").with_opacity(-0.5);
777        assert_eq!(clip.opacity, 0.0);
778    }
779
780    #[test]
781    fn clip_new_should_default_composite_op_to_over() {
782        use ff_filter::CompositeOp;
783        let clip = Clip::new("video.mp4");
784        assert_eq!(clip.composite_op, CompositeOp::Over);
785    }
786
787    #[test]
788    fn clip_with_composite_op_should_set_composite_op() {
789        use ff_filter::CompositeOp;
790        let clip = Clip::new("overlay.mp4").with_composite_op(CompositeOp::Atop);
791        assert_eq!(clip.composite_op, CompositeOp::Atop);
792    }
793
794    #[test]
795    fn clip_blend_mode_and_composite_op_are_independent() {
796        use ff_filter::{BlendMode, CompositeOp};
797        let clip = Clip::new("overlay.mp4")
798            .with_blend_mode(BlendMode::Multiply)
799            .with_composite_op(CompositeOp::Atop);
800        assert_eq!(clip.blend_mode, BlendMode::Multiply);
801        assert_eq!(clip.composite_op, CompositeOp::Atop);
802    }
803
804    #[test]
805    fn clip_new_should_default_blend_mode_to_normal() {
806        use ff_filter::BlendMode;
807        let clip = Clip::new("video.mp4");
808        assert_eq!(clip.blend_mode, BlendMode::Normal);
809    }
810
811    #[test]
812    fn clip_with_blend_mode_should_set_blend_mode() {
813        use ff_filter::BlendMode;
814        let clip = Clip::new("overlay.mp4").with_blend_mode(BlendMode::Multiply);
815        assert_eq!(clip.blend_mode, BlendMode::Multiply);
816    }
817
818    #[test]
819    fn clip_with_blend_mode_screen_should_set_blend_mode() {
820        use ff_filter::BlendMode;
821        let clip = Clip::new("overlay.mp4").with_blend_mode(BlendMode::Screen);
822        assert_eq!(clip.blend_mode, BlendMode::Screen);
823    }
824
825    #[test]
826    fn video_effect_chain_neutral_with_no_effects_should_be_empty() {
827        let clip = Clip::new("v.mp4");
828        assert!(clip.video_effect_chain().is_empty());
829    }
830
831    #[test]
832    fn video_effect_chain_should_insert_eq_when_colour_corrected() {
833        let clip = Clip::new("v.mp4").with_color_correction(0.1, 1.2, 0.9);
834        assert!(matches!(
835            clip.video_effect_chain().as_slice(),
836            [FilterStep::Eq { .. }]
837        ));
838    }
839
840    #[test]
841    fn video_effect_chain_should_append_video_effects_after_eq() {
842        let clip = Clip::new("v.mp4")
843            .with_color_correction(0.1, 1.0, 1.0)
844            .with_video_effect(FilterStep::Hue { degrees: 30.0 });
845        assert!(matches!(
846            clip.video_effect_chain().as_slice(),
847            [FilterStep::Eq { .. }, FilterStep::Hue { .. }]
848        ));
849    }
850
851    #[test]
852    fn video_effect_chain_should_exclude_speed() {
853        // Speed is a temporal step and is not part of the pixel-domain chain.
854        let clip = Clip::new("v.mp4").with_speed(2.0);
855        assert!(clip.video_effect_chain().is_empty());
856    }
857
858    #[test]
859    fn apply_video_effects_should_return_frame_in_input_format() {
860        // 4×4 RGBA (even dims for yuv420p); skip-guard on FFmpeg availability.
861        let frame = VideoFrame::from_rgba(4, 4, vec![128u8; 4 * 4 * 4]).unwrap();
862        let clip = Clip::new("v.mp4").with_color_correction(0.1, 1.1, 1.0);
863        match clip.apply_video_effects(&frame) {
864            Ok(out) => {
865                assert_eq!(out.format(), PixelFormat::Rgba);
866                assert_eq!(out.width(), 4);
867                assert_eq!(out.height(), 4);
868            }
869            Err(e) => println!("Skipping: {e}"),
870        }
871    }
872
873    #[test]
874    fn video_effect_renderer_should_reuse_graph_across_frames() {
875        // One renderer built once, fed several frames — exercises graph reuse
876        // without rebuilding. Skip-guard on FFmpeg availability.
877        let clip = Clip::new("v.mp4").with_color_correction(0.1, 1.1, 1.0);
878        let mut renderer = match clip.video_effect_renderer(PixelFormat::Rgba) {
879            Ok(r) => r,
880            Err(e) => {
881                println!("Skipping: {e}");
882                return;
883            }
884        };
885        for _ in 0..3 {
886            let frame = VideoFrame::from_rgba(4, 4, vec![128u8; 4 * 4 * 4]).unwrap();
887            match renderer.render(&frame) {
888                Ok(out) => {
889                    assert_eq!(out.format(), PixelFormat::Rgba);
890                    assert_eq!(out.width(), 4);
891                    assert_eq!(out.height(), 4);
892                }
893                Err(e) => {
894                    println!("Skipping: {e}");
895                    return;
896                }
897            }
898        }
899    }
900}