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}