#![allow(clippy::unwrap_used)]
mod fixtures;
use std::time::Duration;
use ff_filter::{
AnimatedValue, MultiTrackComposer, VideoLayer,
animation::{AnimationTrack, Easing, Keyframe},
};
use fixtures::{FileGuard, make_source_file, test_output_path};
const CANVAS_W: u32 = 1920;
const CANVAS_H: u32 = 1080;
const MARKER_W: u32 = 10;
const MARKER_H: u32 = 10;
const FPS: f64 = 30.0;
const FRAME_COUNT: usize = 60;
const X_FROM: f64 = 0.0;
const X_TO: f64 = 1910.0;
fn bezier_css_ease_standalone(norm_t: f64) -> f64 {
if norm_t <= 0.0 {
return 0.0;
}
if norm_t >= 1.0 {
return 1.0;
}
const CX: f64 = 0.75;
const BX: f64 = -0.75;
const AX: f64 = 1.0;
const CY: f64 = 0.3;
const BY: f64 = 2.4;
const AY: f64 = -1.7;
let bezier_x = |s: f64| ((AX * s + BX) * s + CX) * s;
let bezier_dx = |s: f64| (3.0 * AX * s + 2.0 * BX) * s + CX;
let bezier_y = |s: f64| ((AY * s + BY) * s + CY) * s;
let mut s = norm_t;
for _ in 0..20 {
let x = bezier_x(s);
let dx = bezier_dx(s);
if dx.abs() < 1e-12 {
break;
}
let delta = (x - norm_t) / dx;
s -= delta;
s = s.clamp(0.0, 1.0);
if delta.abs() < 1e-10 {
break;
}
}
bezier_y(s)
}
fn build_bezier_reference() -> [f64; FRAME_COUNT] {
let mut refs = [0.0_f64; FRAME_COUNT];
for i in 0..FRAME_COUNT {
let norm_t = i as f64 / (FRAME_COUNT as f64 - 1.0);
refs[i] = bezier_css_ease_standalone(norm_t) * X_TO;
}
refs
}
#[test]
#[ignore = "requires FFmpeg filter graph; run with -- --include-ignored"]
fn bezier_position_animation_should_match_reference_curve() {
let reference = build_bezier_reference();
let bg_path = test_output_path("anim_bg_1920x1080.mp4");
let marker_path = test_output_path("anim_marker_10x10.mp4");
let _bg_guard = FileGuard::new(bg_path.clone());
let _marker_guard = FileGuard::new(marker_path.clone());
if make_source_file(&bg_path, CANVAS_W, CANVAS_H, FPS, FRAME_COUNT, 16, 128, 128).is_none() {
return;
}
if make_source_file(
&marker_path,
MARKER_W,
MARKER_H,
FPS,
FRAME_COUNT,
235,
128,
128,
)
.is_none()
{
return;
}
let end_pts = Duration::from_secs_f64((FRAME_COUNT as f64 - 1.0) / FPS);
let bezier_track = AnimationTrack::new()
.push(Keyframe::new(
Duration::ZERO,
X_FROM,
Easing::Bezier {
p1: (0.25, 0.1),
p2: (0.25, 1.0),
},
))
.push(Keyframe::new(end_pts, X_TO, Easing::Linear));
let mut composer = match MultiTrackComposer::new(CANVAS_W, CANVAS_H)
.add_layer(VideoLayer {
source: bg_path.clone(),
x: AnimatedValue::Static(0.0),
y: AnimatedValue::Static(0.0),
scale_x: AnimatedValue::Static(1.0),
scale_y: AnimatedValue::Static(1.0),
rotation: AnimatedValue::Static(0.0),
opacity: AnimatedValue::Static(1.0),
z_order: 0,
time_offset: Duration::ZERO,
in_point: None,
out_point: None,
in_transition: None,
effects: vec![],
})
.add_layer(VideoLayer {
source: marker_path.clone(),
x: AnimatedValue::Track(bezier_track),
y: AnimatedValue::Static(0.0),
scale_x: AnimatedValue::Static(1.0),
scale_y: AnimatedValue::Static(1.0),
rotation: AnimatedValue::Static(0.0),
opacity: AnimatedValue::Static(1.0),
z_order: 1,
time_offset: Duration::ZERO,
in_point: None,
out_point: None,
in_transition: None,
effects: vec![],
})
.build()
{
Ok(g) => g,
Err(e) => {
println!("Skipping: MultiTrackComposer::build failed: {e}");
return;
}
};
for i in 0..FRAME_COUNT {
let pts = Duration::from_secs_f64(i as f64 / FPS);
composer.tick(pts);
let frame = match composer.pull_video() {
Ok(Some(f)) => f,
Ok(None) => {
println!("Skipping: composer ended early at frame {i}");
return;
}
Err(e) => {
println!("Skipping: pull_video failed at frame {i}: {e}");
return;
}
};
if frame.width() != CANVAS_W || frame.height() != CANVAS_H {
println!(
"Skipping: unexpected dimensions {}x{} at frame {i}",
frame.width(),
frame.height()
);
return;
}
let stride = frame.stride(0).unwrap_or(CANVAS_W as usize);
let y_plane = match frame.plane(0) {
Some(p) => p,
None => {
println!("Skipping: Y-plane unavailable at frame {i}");
return;
}
};
let row_start = 5 * stride;
let row_end = (row_start + CANVAS_W as usize).min(y_plane.len());
let row = &y_plane[row_start..row_end];
let detected_x = row.iter().position(|&p| p > 128).unwrap_or(0) as f64;
let expected_x = reference[i];
assert!(
(detected_x - expected_x).abs() <= 2.0,
"frame {i} (pts={:.4}s): detected x={detected_x:.1} expected {expected_x:.1} \
diff={:.2} tolerance=±2.0",
pts.as_secs_f64(),
(detected_x - expected_x).abs()
);
}
}