use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::Duration;
use ff_filter::{
AnimationTrack, BlendMode, CompositeOp, FilterGraph, FilterStep, RealtimeLayer,
RealtimeLayerDescriptor, XfadeTransition,
};
use ff_format::{Color, PixelFormat, TextSpec, VideoFrame};
use crate::error::TimelineError;
use crate::ids::{ClipId, GroupId};
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum ClipSource {
File(PathBuf),
Text(TextSpec),
Solid(Color),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum FitMode {
Fill,
Fit,
Stretch,
#[default]
None,
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Clip {
pub id: ClipId,
pub group: Option<GroupId>,
pub source: ClipSource,
pub in_point: Option<Duration>,
pub out_point: Option<Duration>,
pub offset: Duration,
pub metadata: HashMap<String, String>,
pub transition: Option<XfadeTransition>,
pub transition_duration: Duration,
pub volume_db: f64,
pub volume_track: Option<AnimationTrack<f64>>,
pub pitch: f64,
pub pitch_track: Option<AnimationTrack<f64>>,
pub fade_in: Duration,
pub fade_out: Duration,
pub brightness: f32,
pub contrast: f32,
pub saturation: f32,
pub opacity: f32,
pub opacity_track: Option<AnimationTrack<f64>>,
pub x: f64,
pub y: f64,
pub x_track: Option<AnimationTrack<f64>>,
pub y_track: Option<AnimationTrack<f64>>,
pub scale: f64,
pub scale_track: Option<AnimationTrack<f64>>,
pub rotation: f64,
pub rotation_track: Option<AnimationTrack<f64>>,
pub fit: FitMode,
pub blend_mode: BlendMode,
pub composite_op: CompositeOp,
pub speed: f64,
pub proxy: Option<PathBuf>,
pub video_effects: Vec<FilterStep>,
pub audio_effects: Vec<FilterStep>,
}
impl Clip {
pub fn new(source: impl AsRef<Path>) -> Self {
Self::from_source(ClipSource::File(source.as_ref().to_path_buf()))
}
pub fn text(spec: TextSpec) -> Self {
Self::from_source(ClipSource::Text(spec))
}
pub fn solid(color: Color) -> Self {
Self::from_source(ClipSource::Solid(color))
}
#[must_use]
pub fn source_path(&self) -> Option<&Path> {
match &self.source {
ClipSource::File(path) => Some(path.as_path()),
ClipSource::Text(_) | ClipSource::Solid(_) => None,
}
}
fn from_source(source: ClipSource) -> Self {
Self {
id: ClipId::UNSET,
group: None,
source,
in_point: None,
out_point: None,
offset: Duration::ZERO,
metadata: HashMap::new(),
transition: None,
transition_duration: Duration::ZERO,
volume_db: 0.0,
volume_track: None,
pitch: 0.0,
pitch_track: None,
fade_in: Duration::ZERO,
fade_out: Duration::ZERO,
brightness: 0.0,
contrast: 1.0,
saturation: 1.0,
opacity: 1.0,
opacity_track: None,
x: 0.0,
y: 0.0,
x_track: None,
y_track: None,
scale: 1.0,
scale_track: None,
rotation: 0.0,
rotation_track: None,
fit: FitMode::None,
blend_mode: BlendMode::Normal,
composite_op: CompositeOp::Over,
speed: 1.0,
proxy: None,
video_effects: Vec::new(),
audio_effects: Vec::new(),
}
}
#[must_use]
pub fn with_video_effect(mut self, step: FilterStep) -> Self {
self.video_effects.push(step);
self
}
#[must_use]
pub fn video_effect_chain(&self) -> Vec<FilterStep> {
let mut steps = Vec::new();
#[allow(clippy::float_cmp)]
let neutral = self.brightness == 0.0 && self.contrast == 1.0 && self.saturation == 1.0;
if !neutral {
steps.push(FilterStep::Eq {
brightness: self.brightness,
contrast: self.contrast,
saturation: self.saturation,
});
}
steps.extend(self.video_effects.iter().cloned());
steps
}
pub fn apply_video_effects(&self, frame: &VideoFrame) -> Result<VideoFrame, TimelineError> {
self.video_effect_renderer(frame.format())?.render(frame)
}
pub fn video_effect_renderer(
&self,
input_format: PixelFormat,
) -> Result<VideoEffectRenderer, TimelineError> {
VideoEffectRenderer::new(self, input_format)
}
#[must_use]
pub fn realtime_layer(
&self,
width: u32,
height: u32,
pixel_format: PixelFormat,
) -> RealtimeLayer {
RealtimeLayer::with_dimensions(
self.realtime_layer_descriptor(),
width,
height,
pixel_format,
)
}
#[must_use]
pub fn realtime_layer_descriptor(&self) -> RealtimeLayerDescriptor {
crate::derive::realtime_descriptor(self, 0, &std::collections::HashMap::new(), 0, 0)
}
#[must_use]
pub fn with_audio_effect(mut self, step: FilterStep) -> Self {
self.audio_effects.push(step);
self
}
#[must_use]
pub fn proxy(self, proxy: impl AsRef<Path>) -> Self {
Self {
proxy: Some(proxy.as_ref().to_path_buf()),
..self
}
}
#[must_use]
pub fn trim(self, in_point: Duration, out_point: Duration) -> Self {
Self {
in_point: Some(in_point),
out_point: Some(out_point),
..self
}
}
#[must_use]
pub fn offset(self, offset: Duration) -> Self {
Self { offset, ..self }
}
#[must_use]
pub fn with_transition(self, kind: XfadeTransition, duration: Duration) -> Self {
Self {
transition: Some(kind),
transition_duration: duration,
..self
}
}
#[must_use]
pub fn volume(self, db: f64) -> Self {
Self {
volume_db: db,
..self
}
}
#[must_use]
pub fn with_volume_track(self, track: AnimationTrack<f64>) -> Self {
Self {
volume_track: Some(track),
..self
}
}
#[must_use]
pub fn with_pitch(self, semitones: f64) -> Self {
Self {
pitch: semitones,
..self
}
}
#[must_use]
pub fn with_pitch_track(self, track: AnimationTrack<f64>) -> Self {
Self {
pitch_track: Some(track),
..self
}
}
#[must_use]
pub fn with_fade_in(self, duration: Duration) -> Self {
Self {
fade_in: duration,
..self
}
}
#[must_use]
pub fn with_fade_out(self, duration: Duration) -> Self {
Self {
fade_out: duration,
..self
}
}
#[must_use]
pub fn with_color_correction(self, brightness: f32, contrast: f32, saturation: f32) -> Self {
Self {
brightness,
contrast,
saturation,
..self
}
}
#[must_use]
pub fn with_opacity(self, opacity: f32) -> Self {
Self {
opacity: opacity.clamp(0.0, 1.0),
..self
}
}
#[must_use]
pub fn with_opacity_track(self, track: AnimationTrack<f64>) -> Self {
Self {
opacity_track: Some(track),
..self
}
}
#[must_use]
pub fn with_position(self, x: f64, y: f64) -> Self {
Self { x, y, ..self }
}
#[must_use]
pub fn with_x_track(self, track: AnimationTrack<f64>) -> Self {
Self {
x_track: Some(track),
..self
}
}
#[must_use]
pub fn with_y_track(self, track: AnimationTrack<f64>) -> Self {
Self {
y_track: Some(track),
..self
}
}
#[must_use]
pub fn with_scale(self, scale: f64) -> Self {
Self { scale, ..self }
}
#[must_use]
pub fn with_scale_track(self, track: AnimationTrack<f64>) -> Self {
Self {
scale_track: Some(track),
..self
}
}
#[must_use]
pub fn with_rotation(self, rotation: f64) -> Self {
Self { rotation, ..self }
}
#[must_use]
pub fn with_rotation_track(self, track: AnimationTrack<f64>) -> Self {
Self {
rotation_track: Some(track),
..self
}
}
#[must_use]
pub fn with_fit(self, fit: FitMode) -> Self {
Self { fit, ..self }
}
#[must_use]
pub fn with_blend_mode(self, mode: BlendMode) -> Self {
Self {
blend_mode: mode,
..self
}
}
#[must_use]
pub fn with_composite_op(self, op: CompositeOp) -> Self {
Self {
composite_op: op,
..self
}
}
#[must_use]
pub fn with_speed(self, speed: f64) -> Self {
Self { speed, ..self }
}
pub fn duration(&self) -> Option<Duration> {
match (self.in_point, self.out_point) {
(Some(in_pt), Some(out_pt)) => out_pt.checked_sub(in_pt),
_ => None,
}
}
}
pub struct VideoEffectRenderer {
graph: FilterGraph,
}
impl VideoEffectRenderer {
pub fn new(clip: &Clip, input_format: PixelFormat) -> Result<Self, TimelineError> {
let mut builder = FilterGraph::builder().format(vec![PixelFormat::Yuv420p], vec![], vec![]);
for step in clip.video_effect_chain() {
builder = builder.add_step(step);
}
let graph = builder.format(vec![input_format], vec![], vec![]).build()?;
Ok(Self { graph })
}
pub fn render(&mut self, frame: &VideoFrame) -> Result<VideoFrame, TimelineError> {
self.graph.push_video(0, frame)?;
self.graph
.pull_video()?
.ok_or(TimelineError::Filter(ff_filter::FilterError::ProcessFailed))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn clip_new_should_have_zero_offset() {
let clip = Clip::new("video.mp4");
assert_eq!(clip.offset, Duration::ZERO);
assert!(clip.in_point.is_none());
assert!(clip.out_point.is_none());
assert!(clip.metadata.is_empty());
}
#[test]
fn clip_new_should_default_transition_to_none() {
let clip = Clip::new("video.mp4");
assert!(clip.transition.is_none());
assert_eq!(clip.transition_duration, Duration::ZERO);
}
#[test]
fn clip_with_transition_should_set_fields() {
use ff_filter::XfadeTransition;
let clip = Clip::new("video.mp4")
.with_transition(XfadeTransition::Fade, Duration::from_millis(500));
assert_eq!(clip.transition, Some(XfadeTransition::Fade));
assert_eq!(clip.transition_duration, Duration::from_millis(500));
}
#[test]
fn clip_trim_should_set_in_out_points() {
let clip = Clip::new("video.mp4").trim(Duration::from_secs(3), Duration::from_secs(9));
assert_eq!(clip.in_point, Some(Duration::from_secs(3)));
assert_eq!(clip.out_point, Some(Duration::from_secs(9)));
}
#[test]
fn clip_duration_should_return_none_when_out_point_unset() {
let clip = Clip::new("video.mp4");
assert!(clip.duration().is_none());
}
#[test]
fn clip_duration_should_return_difference_when_both_points_set() {
let clip = Clip::new("video.mp4").trim(Duration::from_secs(2), Duration::from_secs(10));
assert_eq!(clip.duration(), Some(Duration::from_secs(8)));
}
#[test]
fn clip_new_should_default_volume_db_to_zero() {
let clip = Clip::new("audio.wav");
assert_eq!(clip.volume_db, 0.0);
}
#[test]
fn clip_volume_should_set_volume_db() {
let clip = Clip::new("audio.wav").volume(-6.0);
assert_eq!(clip.volume_db, -6.0);
}
#[test]
fn clip_volume_positive_should_set_volume_db() {
let clip = Clip::new("audio.wav").volume(3.0);
assert_eq!(clip.volume_db, 3.0);
}
#[test]
fn clip_new_should_default_fade_fields_to_zero() {
let clip = Clip::new("audio.wav");
assert_eq!(clip.fade_in, Duration::ZERO);
assert_eq!(clip.fade_out, Duration::ZERO);
}
#[test]
fn clip_with_fade_in_should_set_fade_in() {
let clip = Clip::new("audio.wav").with_fade_in(Duration::from_secs(2));
assert_eq!(clip.fade_in, Duration::from_secs(2));
assert_eq!(clip.fade_out, Duration::ZERO);
}
#[test]
fn clip_with_fade_out_should_set_fade_out() {
let clip = Clip::new("audio.wav")
.trim(Duration::ZERO, Duration::from_secs(10))
.with_fade_out(Duration::from_secs(1));
assert_eq!(clip.fade_out, Duration::from_secs(1));
assert_eq!(clip.fade_in, Duration::ZERO);
}
#[test]
fn clip_fade_in_and_fade_out_can_be_chained() {
let clip = Clip::new("audio.wav")
.trim(Duration::ZERO, Duration::from_secs(10))
.with_fade_in(Duration::from_millis(500))
.with_fade_out(Duration::from_millis(500));
assert_eq!(clip.fade_in, Duration::from_millis(500));
assert_eq!(clip.fade_out, Duration::from_millis(500));
}
#[test]
fn clip_new_should_default_color_correction_to_neutral() {
let clip = Clip::new("video.mp4");
assert_eq!(clip.brightness, 0.0);
assert_eq!(clip.contrast, 1.0);
assert_eq!(clip.saturation, 1.0);
}
#[test]
fn clip_with_color_correction_should_set_fields() {
let clip = Clip::new("scene.mp4").with_color_correction(0.1, 1.2, 0.9);
assert_eq!(clip.brightness, 0.1);
assert_eq!(clip.contrast, 1.2);
assert_eq!(clip.saturation, 0.9);
}
#[test]
fn clip_new_should_default_speed_to_one() {
let clip = Clip::new("video.mp4");
assert_eq!(clip.speed, 1.0);
}
#[test]
fn clip_with_speed_should_set_speed() {
let clip = Clip::new("video.mp4").with_speed(2.0);
assert_eq!(clip.speed, 2.0);
}
#[test]
fn clip_with_speed_slow_motion_should_set_speed() {
let clip = Clip::new("video.mp4").with_speed(0.5);
assert_eq!(clip.speed, 0.5);
}
#[test]
fn clip_new_should_default_opacity_to_one() {
let clip = Clip::new("video.mp4");
assert_eq!(clip.opacity, 1.0);
}
#[test]
fn clip_with_opacity_should_set_opacity() {
let clip = Clip::new("overlay.mp4").with_opacity(0.5);
assert_eq!(clip.opacity, 0.5);
}
#[test]
fn clip_with_opacity_should_clamp_above_one() {
let clip = Clip::new("overlay.mp4").with_opacity(1.5);
assert_eq!(clip.opacity, 1.0);
}
#[test]
fn clip_with_opacity_should_clamp_below_zero() {
let clip = Clip::new("overlay.mp4").with_opacity(-0.5);
assert_eq!(clip.opacity, 0.0);
}
#[test]
fn clip_new_should_default_opacity_track_to_none() {
let clip = Clip::new("video.mp4");
assert!(clip.opacity_track.is_none());
}
#[test]
fn clip_with_opacity_track_should_store_track() {
use ff_filter::{AnimationTrack, Easing};
let track = AnimationTrack::fade(
0.0,
1.0,
Duration::ZERO,
Duration::from_secs(1),
Easing::Linear,
);
let clip = Clip::new("overlay.mp4").with_opacity_track(track);
let stored = clip.opacity_track.expect("track stored");
assert!((stored.value_at(Duration::from_millis(500)) - 0.5).abs() < 1e-9);
}
#[test]
fn clip_new_should_default_position_to_zero() {
let clip = Clip::new("video.mp4");
assert_eq!((clip.x, clip.y), (0.0, 0.0));
assert!(clip.x_track.is_none() && clip.y_track.is_none());
}
#[test]
fn clip_with_position_should_set_x_y() {
let clip = Clip::new("pip.mp4").with_position(100.0, 50.0);
assert_eq!((clip.x, clip.y), (100.0, 50.0));
}
#[test]
fn clip_with_x_track_should_store_track() {
use ff_filter::{AnimationTrack, Easing};
let track = AnimationTrack::fade(
0.0,
640.0,
Duration::ZERO,
Duration::from_secs(2),
Easing::Linear,
);
let clip = Clip::new("pip.mp4").with_x_track(track);
let stored = clip.x_track.expect("x track stored");
assert!((stored.value_at(Duration::from_secs(1)) - 320.0).abs() < 1e-9);
}
#[test]
fn clip_new_should_default_scale_and_rotation() {
let clip = Clip::new("video.mp4");
assert!((clip.scale - 1.0).abs() < f64::EPSILON);
assert!((clip.rotation - 0.0).abs() < f64::EPSILON);
assert!(clip.scale_track.is_none() && clip.rotation_track.is_none());
}
#[test]
fn clip_with_scale_should_set_scale() {
let clip = Clip::new("pip.mp4").with_scale(0.5);
assert!((clip.scale - 0.5).abs() < f64::EPSILON);
}
#[test]
fn clip_with_rotation_should_set_rotation() {
let clip = Clip::new("pip.mp4").with_rotation(45.0);
assert!((clip.rotation - 45.0).abs() < f64::EPSILON);
}
#[test]
fn clip_with_scale_track_should_store_track() {
use ff_filter::{AnimationTrack, Easing};
let track = AnimationTrack::fade(
1.0,
2.0,
Duration::ZERO,
Duration::from_secs(2),
Easing::Linear,
);
let clip = Clip::new("pip.mp4").with_scale_track(track);
let stored = clip.scale_track.expect("scale track stored");
assert!((stored.value_at(Duration::from_secs(1)) - 1.5).abs() < 1e-9);
}
#[test]
fn clip_with_rotation_track_should_store_track() {
use ff_filter::{AnimationTrack, Easing};
let track = AnimationTrack::fade(
0.0,
90.0,
Duration::ZERO,
Duration::from_secs(2),
Easing::Linear,
);
let clip = Clip::new("pip.mp4").with_rotation_track(track);
let stored = clip.rotation_track.expect("rotation track stored");
assert!((stored.value_at(Duration::from_secs(1)) - 45.0).abs() < 1e-9);
}
#[test]
fn clip_new_should_default_fit_none() {
let clip = Clip::new("a.mp4");
assert_eq!(clip.fit, FitMode::None);
}
#[test]
fn clip_with_fit_should_set_fit() {
let clip = Clip::new("a.mp4").with_fit(FitMode::Fill);
assert_eq!(clip.fit, FitMode::Fill);
}
#[test]
fn clip_with_volume_track_should_store_track() {
use ff_filter::{AnimationTrack, Easing};
let track = AnimationTrack::fade(
0.0,
-12.0,
Duration::ZERO,
Duration::from_secs(2),
Easing::Linear,
);
let clip = Clip::new("narration.wav").with_volume_track(track);
let stored = clip.volume_track.expect("volume track stored");
assert!((stored.value_at(Duration::from_secs(1)) - (-6.0)).abs() < 1e-9);
}
#[test]
fn clip_new_should_default_pitch_to_zero() {
let clip = Clip::new("a.wav");
assert_eq!(clip.pitch, 0.0);
assert!(clip.pitch_track.is_none());
}
#[test]
fn clip_with_pitch_should_set_pitch() {
let clip = Clip::new("a.wav").with_pitch(7.0);
assert_eq!(clip.pitch, 7.0);
}
#[test]
fn clip_with_pitch_track_should_store_track() {
use ff_filter::{AnimationTrack, Easing};
let track = AnimationTrack::fade(
2.0,
12.0,
Duration::ZERO,
Duration::from_secs(2),
Easing::Linear,
);
let clip = Clip::new("a.wav").with_pitch_track(track);
let stored = clip.pitch_track.expect("pitch track stored");
assert!((stored.value_at(Duration::from_secs(1)) - 7.0).abs() < 1e-9);
}
#[test]
fn clip_new_should_default_composite_op_to_over() {
use ff_filter::CompositeOp;
let clip = Clip::new("video.mp4");
assert_eq!(clip.composite_op, CompositeOp::Over);
}
#[test]
fn clip_with_composite_op_should_set_composite_op() {
use ff_filter::CompositeOp;
let clip = Clip::new("overlay.mp4").with_composite_op(CompositeOp::Atop);
assert_eq!(clip.composite_op, CompositeOp::Atop);
}
#[test]
fn clip_blend_mode_and_composite_op_are_independent() {
use ff_filter::{BlendMode, CompositeOp};
let clip = Clip::new("overlay.mp4")
.with_blend_mode(BlendMode::Multiply)
.with_composite_op(CompositeOp::Atop);
assert_eq!(clip.blend_mode, BlendMode::Multiply);
assert_eq!(clip.composite_op, CompositeOp::Atop);
}
#[test]
fn clip_new_should_default_blend_mode_to_normal() {
use ff_filter::BlendMode;
let clip = Clip::new("video.mp4");
assert_eq!(clip.blend_mode, BlendMode::Normal);
}
#[test]
fn clip_with_blend_mode_should_set_blend_mode() {
use ff_filter::BlendMode;
let clip = Clip::new("overlay.mp4").with_blend_mode(BlendMode::Multiply);
assert_eq!(clip.blend_mode, BlendMode::Multiply);
}
#[test]
fn clip_with_blend_mode_screen_should_set_blend_mode() {
use ff_filter::BlendMode;
let clip = Clip::new("overlay.mp4").with_blend_mode(BlendMode::Screen);
assert_eq!(clip.blend_mode, BlendMode::Screen);
}
#[test]
fn clip_new_source_should_be_a_file() {
let clip = Clip::new("video.mp4");
assert!(matches!(clip.source, ClipSource::File(_)));
assert_eq!(clip.source_path().and_then(Path::to_str), Some("video.mp4"));
}
#[test]
fn clip_text_source_should_be_a_text_variant() {
let clip = Clip::text(TextSpec::new("hello"));
match &clip.source {
ClipSource::Text(spec) => assert_eq!(spec.text, "hello"),
other => panic!("expected Text source, got {other:?}"),
}
assert_eq!(
clip.source_path(),
None,
"generated source has no file path"
);
}
#[test]
fn clip_solid_source_should_be_a_solid_variant() {
let clip = Clip::solid(Color::rgb(10, 20, 30));
match &clip.source {
ClipSource::Solid(color) => assert_eq!(*color, Color::rgb(10, 20, 30)),
other => panic!("expected Solid source, got {other:?}"),
}
assert_eq!(
clip.source_path(),
None,
"generated source has no file path"
);
}
#[test]
fn video_effect_chain_neutral_with_no_effects_should_be_empty() {
let clip = Clip::new("v.mp4");
assert!(clip.video_effect_chain().is_empty());
}
#[test]
fn video_effect_chain_should_insert_eq_when_colour_corrected() {
let clip = Clip::new("v.mp4").with_color_correction(0.1, 1.2, 0.9);
assert!(matches!(
clip.video_effect_chain().as_slice(),
[FilterStep::Eq { .. }]
));
}
#[test]
fn video_effect_chain_should_append_video_effects_after_eq() {
let clip = Clip::new("v.mp4")
.with_color_correction(0.1, 1.0, 1.0)
.with_video_effect(FilterStep::Hue { degrees: 30.0 });
assert!(matches!(
clip.video_effect_chain().as_slice(),
[FilterStep::Eq { .. }, FilterStep::Hue { .. }]
));
}
#[test]
fn video_effect_chain_should_exclude_speed() {
let clip = Clip::new("v.mp4").with_speed(2.0);
assert!(clip.video_effect_chain().is_empty());
}
#[test]
fn apply_video_effects_should_return_frame_in_input_format() {
let frame = VideoFrame::from_rgba(4, 4, vec![128u8; 4 * 4 * 4]).unwrap();
let clip = Clip::new("v.mp4").with_color_correction(0.1, 1.1, 1.0);
match clip.apply_video_effects(&frame) {
Ok(out) => {
assert_eq!(out.format(), PixelFormat::Rgba);
assert_eq!(out.width(), 4);
assert_eq!(out.height(), 4);
}
Err(e) => println!("Skipping: {e}"),
}
}
#[test]
fn video_effect_renderer_should_reuse_graph_across_frames() {
let clip = Clip::new("v.mp4").with_color_correction(0.1, 1.1, 1.0);
let mut renderer = match clip.video_effect_renderer(PixelFormat::Rgba) {
Ok(r) => r,
Err(e) => {
println!("Skipping: {e}");
return;
}
};
for _ in 0..3 {
let frame = VideoFrame::from_rgba(4, 4, vec![128u8; 4 * 4 * 4]).unwrap();
match renderer.render(&frame) {
Ok(out) => {
assert_eq!(out.format(), PixelFormat::Rgba);
assert_eq!(out.width(), 4);
assert_eq!(out.height(), 4);
}
Err(e) => {
println!("Skipping: {e}");
return;
}
}
}
}
#[test]
fn realtime_layer_should_map_clip_fields() {
use ff_filter::BlendMode;
let clip = Clip::new("v.mp4")
.with_color_correction(0.1, 1.0, 1.0)
.with_video_effect(FilterStep::Hue { degrees: 30.0 })
.with_opacity(0.5)
.with_blend_mode(BlendMode::Screen);
let layer = clip.realtime_layer(640, 480, PixelFormat::Yuv420p);
assert_eq!(layer.width, 640);
assert_eq!(layer.height, 480);
assert_eq!(layer.pixel_format, PixelFormat::Yuv420p);
assert!(
matches!(layer.opacity, ff_filter::AnimatedValue::Static(v) if (v - 0.5).abs() < 1e-6)
);
assert_eq!(layer.blend_mode, BlendMode::Screen);
assert!(matches!(
layer.effects.as_slice(),
[FilterStep::Eq { .. }, FilterStep::Hue { .. }]
));
}
}