use std::time::{Duration, Instant};
use ff_decode::{SeekMode, VideoDecoder};
use ff_encode::VideoEncoder;
use ff_filter::{AnimatedValue, BlendMode, CompositeOp, VideoLayer, XfadeTransition};
use ff_format::{PixelFormat, VideoFrame};
use ff_pipeline::Progress;
use ff_render::BlendMode as RenderBlendMode;
use crate::clip::Clip;
use crate::derive;
use crate::error::TimelineError;
use crate::gpu::{GpuEffect, GpuLayerPlan, GpuMapping, map_scene};
use crate::gpu_compositor::GpuCompositor;
use crate::gpu_transition::{GpuTransition, map_transition};
use crate::track::Track;
fn export_maps_to_gpu(kind: XfadeTransition) -> bool {
!matches!(kind, XfadeTransition::Dissolve) && map_transition(kind).is_some()
}
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
fn window_frames(d: Duration, frame_rate: f64) -> u64 {
(d.as_secs_f64() * frame_rate).round().max(0.0) as u64
}
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
fn budget_frames(clip: &Clip, frame_rate: f64) -> Option<u64> {
clip.duration()
.map(|d| (d.as_secs_f64() * frame_rate).round().max(0.0) as u64)
}
fn transitionless_layer(clip: &Clip, track: &Track, canvas: (u32, u32)) -> VideoLayer {
if clip.transition.is_none() {
return derive::video_layer(
clip,
0,
&track.automation,
canvas.0,
canvas.1,
&derive::Placement::default(),
None,
);
}
let mut without = clip.clone();
without.transition = None;
derive::video_layer(
&without,
0,
&track.automation,
canvas.0,
canvas.1,
&derive::Placement::default(),
None,
)
}
pub(crate) fn eligible_tracks(
video_tracks: &[Track],
lavfi_overlay: Option<&str>,
any_video_solo: bool,
canvas: (u32, u32),
frame_rate: f64,
) -> Option<Vec<usize>> {
if lavfi_overlay.is_some() {
return None;
}
let active: Vec<(usize, &Track)> = video_tracks
.iter()
.enumerate()
.filter(|(_, t)| t.is_active(any_video_solo))
.collect();
if active.is_empty() {
return None;
}
let stacked = active.len() > 1;
for (stack_pos, (_, track)) in active.iter().enumerate() {
eligible_one_track(track, stack_pos == 0, stacked, canvas, frame_rate)?;
}
Some(active.into_iter().map(|(idx, _)| idx).collect())
}
fn eligible_one_track(
track: &Track,
is_base: bool,
stacked: bool,
canvas: (u32, u32),
frame_rate: f64,
) -> Option<()> {
if track.clips.is_empty() {
return None;
}
if !is_base && track.clips.iter().skip(1).any(|c| c.transition.is_some()) {
return None;
}
let mut blendable = vec![false; track.clips.len()];
for (i, clip) in track.clips.iter().enumerate() {
clip.source_path()?;
if (clip.speed - 1.0).abs() > 1e-9 {
return None;
}
if clip.rotation.abs() > f64::EPSILON
|| clip.rotation_track.is_some()
|| track.automation.rotation.is_some()
{
return None;
}
let layer = transitionless_layer(clip, track, canvas);
let GpuMapping::Gpu(plan) = map_scene(std::slice::from_ref(&layer), canvas, Duration::ZERO)
else {
return None;
};
if stacked
&& plan
.layers
.iter()
.any(|l| l.effects.iter().any(is_stateful_effect))
{
return None;
}
blendable[i] = plan
.layers
.iter()
.all(|l| is_neutral_composite(l) && !l.effects.iter().any(is_stateful_effect));
}
let last = track.clips.len() - 1;
for i in 1..track.clips.len() {
if track.clips[i].transition.is_some()
&& (is_placed(&track.clips[i - 1], track) || is_placed(&track.clips[i], track))
{
return None;
}
if track.clips[i].transition.is_some()
&& !eligible_transition(
&track.clips[i - 1],
&track.clips[i],
blendable[i - 1] && blendable[i],
frame_rate,
)
{
return None;
}
}
let mut expected = Duration::ZERO;
for (i, clip) in track.clips.iter().enumerate() {
if clip.offset != expected {
return None;
}
match clip.duration() {
Some(d) => expected += d,
None if i == last => {}
None => return None,
}
}
for clip in &track.clips {
let src = clip.source_path()?;
let Ok(decoder) = VideoDecoder::open(src).build() else {
return None;
};
let src_fps = decoder.frame_rate();
if !src_fps.is_finite() || src_fps <= 0.0 {
return None;
}
}
Some(())
}
fn is_stateful_effect(effect: &GpuEffect) -> bool {
matches!(effect, GpuEffect::MotionBlur { .. })
}
fn is_neutral_composite(plan: &GpuLayerPlan) -> bool {
(plan.opacity - 1.0).abs() < 1e-6 && plan.blend_mode == RenderBlendMode::Normal
}
fn is_placed(clip: &Clip, track: &Track) -> bool {
clip.x.abs() > f64::EPSILON
|| clip.y.abs() > f64::EPSILON
|| (clip.scale - 1.0).abs() > f64::EPSILON
|| clip.rotation.abs() > f64::EPSILON
|| clip.x_track.is_some()
|| clip.y_track.is_some()
|| clip.scale_track.is_some()
|| clip.rotation_track.is_some()
|| track.automation.x.is_some()
|| track.automation.y.is_some()
|| track.automation.scale_x.is_some()
|| track.automation.scale_y.is_some()
|| track.automation.rotation.is_some()
}
fn eligible_transition(outgoing: &Clip, incoming: &Clip, blendable: bool, frame_rate: f64) -> bool {
if !blendable {
return false;
}
let Some(kind) = incoming.transition else {
return false;
};
if !export_maps_to_gpu(kind) {
return false;
}
let (Some(incoming_budget), Some(_outgoing_budget)) = (
budget_frames(incoming, frame_rate),
budget_frames(outgoing, frame_rate),
) else {
return false;
};
let authored = window_frames(incoming.transition_duration, frame_rate);
if authored < 1 || authored > incoming_budget {
return false;
}
window_frames(
crate::transition::effective_duration(outgoing, incoming),
frame_rate,
) == authored
}
fn transition_window(
incoming: &Clip,
effective: Duration,
frame_rate: f64,
) -> Result<u64, TimelineError> {
let Some(kind) = incoming.transition else {
return Ok(0);
};
if !export_maps_to_gpu(kind) {
return Err(TimelineError::TimelineRenderFailed {
reason: format!(
"gpu export: transition {kind:?} has no GPU node (precluded by eligibility)"
),
});
}
Ok(window_frames(effective, frame_rate))
}
#[allow(clippy::cast_precision_loss)] fn clip_output_time(k: u64, out_fps: f64) -> Duration {
Duration::from_secs_f64(k as f64 / out_fps)
}
enum Pulled<'a> {
Owned(VideoFrame),
Held(&'a VideoFrame),
}
struct ClipSource {
decoder: VideoDecoder,
budget: Option<u64>,
produced: u64,
frame_rate: f64,
one_to_one: bool,
base: Duration,
held: Option<VideoFrame>,
held_at: Duration,
pending: Option<(VideoFrame, Duration)>,
eof: bool,
}
impl ClipSource {
fn open(clip: &Clip, frame_rate: f64) -> Result<Self, TimelineError> {
let src = clip
.source_path()
.ok_or_else(|| TimelineError::TimelineRenderFailed {
reason: "gpu export: clip lost its file source".to_string(),
})?;
let mut decoder = VideoDecoder::open(src)
.output_format(PixelFormat::Rgba)
.build()?;
let src_fps = {
let f = decoder.frame_rate();
if f.is_finite() && f > 0.0 {
f
} else {
frame_rate
}
};
if let Some(in_point) = clip.in_point {
decoder.seek(in_point, SeekMode::Exact)?;
}
Ok(Self {
decoder,
budget: budget_frames(clip, frame_rate),
produced: 0,
frame_rate,
one_to_one: (src_fps - frame_rate).abs() <= 1e-3,
base: clip.in_point.unwrap_or(Duration::ZERO),
held: None,
held_at: Duration::ZERO,
pending: None,
eof: false,
})
}
fn allow_handle(&mut self, frames: u64) {
if let Some(budget) = self.budget.as_mut() {
*budget = budget.saturating_add(frames);
}
}
fn next(&mut self) -> Result<Option<Pulled<'_>>, TimelineError> {
if self.budget.is_some_and(|b| self.produced >= b) {
return Ok(None);
}
if self.one_to_one {
let Some(frame) = self.decoder.decode_one()? else {
return Ok(None);
};
self.produced += 1;
return Ok(Some(Pulled::Owned(frame)));
}
let want = clip_output_time(self.produced, self.frame_rate);
loop {
if let Some((frame, at)) = self.pending.take() {
if self.held.is_none() || at <= want {
self.held = Some(frame);
self.held_at = at;
continue;
}
self.pending = Some((frame, at));
break;
}
if self.eof {
break;
}
match self.decoder.decode_one()? {
Some(frame) => {
let at = frame.timestamp().as_duration().saturating_sub(self.base);
self.pending = Some((frame, at));
}
None => self.eof = true,
}
}
if self.held.is_none() {
return Ok(None); }
if self.eof && self.pending.is_none() && want > self.held_at {
return Ok(None);
}
self.produced += 1;
Ok(self.held.as_ref().map(Pulled::Held))
}
}
fn composite_pulled(
core: &mut GpuCompositor,
layer: &VideoLayer,
pulled: Pulled<'_>,
canvas: (u32, u32),
t: Duration,
) -> Option<(Vec<u8>, u32, u32)> {
match pulled {
Pulled::Owned(frame) => core.composite_owned(vec![(layer, frame)], canvas, t),
Pulled::Held(frame) => core.composite(&[(layer, frame)], canvas, t),
}
}
fn fell_back(what: &str) -> TimelineError {
TimelineError::TimelineRenderFailed {
reason: format!("gpu export: {what} fell back mid-export (precluded by eligibility)"),
}
}
#[allow(clippy::too_many_arguments)]
struct TrackSource<'a> {
track: &'a Track,
canvas: (u32, u32),
frame_rate: f64,
is_base: bool,
boundaries: Vec<Duration>,
clip_idx: usize,
cur: ClipSource,
cur_layer: VideoLayer,
stack_layer: VideoLayer,
inc: Option<(ClipSource, VideoLayer)>,
node: Option<GpuTransition>,
window: u64,
window_pos: u64,
done: bool,
last_dims: Option<(u32, u32)>,
}
impl<'a> TrackSource<'a> {
fn open(
track: &'a Track,
is_base: bool,
canvas: (u32, u32),
frame_rate: f64,
) -> Result<Option<Self>, TimelineError> {
let Some(first) = track.clips.first() else {
return Ok(None);
};
let cur_layer = transitionless_layer(first, track, canvas);
Ok(Some(Self {
track,
canvas,
frame_rate,
is_base,
boundaries: crate::transition::effective_durations(&track.clips),
clip_idx: 0,
cur: ClipSource::open(first, frame_rate)?,
stack_layer: stack_layer_for(is_base, &cur_layer),
cur_layer,
inc: None,
node: None,
window: 0,
window_pos: 0,
done: false,
last_dims: None,
}))
}
fn layer(&self) -> &VideoLayer {
&self.stack_layer
}
fn refresh_stack_layer(&mut self) {
self.stack_layer = stack_layer_for(self.is_base, &self.cur_layer);
}
fn advance(&mut self, core: &mut GpuCompositor) -> Result<bool, TimelineError> {
let Some(next) = self.track.clips.get(self.clip_idx + 1) else {
self.done = true;
return Ok(false);
};
let window = transition_window(next, self.boundaries[self.clip_idx + 1], self.frame_rate)?;
self.cur.allow_handle(window);
let inc = ClipSource::open(next, self.frame_rate)?;
let inc_layer = transitionless_layer(next, self.track, self.canvas);
core.reset_effect_cache();
self.node = next.transition.and_then(map_transition);
self.window = window;
self.window_pos = 0;
if window == 0 {
self.cur = inc;
self.cur_layer = inc_layer;
self.refresh_stack_layer();
self.clip_idx += 1;
} else {
self.inc = Some((inc, inc_layer));
}
Ok(true)
}
fn close_window(&mut self) {
if let Some((inc, inc_layer)) = self.inc.take() {
self.cur = inc;
self.cur_layer = inc_layer;
self.refresh_stack_layer();
self.clip_idx += 1;
}
self.window = 0;
self.window_pos = 0;
self.node = None;
}
fn next(
&mut self,
core: &mut GpuCompositor,
t: Duration,
) -> Result<Option<VideoFrame>, TimelineError> {
loop {
if self.done {
return Ok(None);
}
if self.window_pos < self.window && self.inc.is_some() {
if let Some(frame) = self.blend_one(core, t)? {
return Ok(Some(frame));
}
self.close_window();
continue;
}
if self.window_pos >= self.window && self.inc.is_some() {
self.close_window();
continue;
}
match self.cur.next()? {
Some(pulled) => {
if !self.is_base {
let frame = match pulled {
Pulled::Owned(frame) => frame,
Pulled::Held(frame) => frame.clone(),
};
self.last_dims = Some((frame.width(), frame.height()));
return Ok(Some(frame));
}
let composited =
composite_pulled(core, &self.cur_layer, pulled, self.canvas, t)
.ok_or_else(|| fell_back("a track's clip"))?;
self.last_dims = Some((composited.1, composited.2));
return Ok(Some(wrap_rgba(composited)?));
}
None => {
if !self.advance(core)? {
return Ok(None);
}
}
}
}
}
fn blend_one(
&mut self,
core: &mut GpuCompositor,
t: Duration,
) -> Result<Option<VideoFrame>, TimelineError> {
let Some(node) = self.node else {
return Err(TimelineError::TimelineRenderFailed {
reason: "gpu export: transitioned clip lost its GPU node".to_string(),
});
};
let Some(outgoing) = self.cur.next()? else {
return Ok(None);
};
let (a_rgba, w, h) = composite_pulled(core, &self.cur_layer, outgoing, self.canvas, t)
.ok_or_else(|| fell_back("the outgoing clip"))?;
let Some((inc, inc_layer)) = self.inc.as_mut() else {
return Ok(None);
};
let Some(incoming) = inc.next()? else {
return Ok(None);
};
let (b_rgba, _, _) = composite_pulled(core, inc_layer, incoming, self.canvas, t)
.ok_or_else(|| fell_back("the incoming clip"))?;
#[allow(clippy::cast_precision_loss)] let progress = self.window_pos as f32 / self.window as f32;
let blended = core
.transition(node, progress, &a_rgba, b_rgba, w, h)
.ok_or_else(|| TimelineError::TimelineRenderFailed {
reason: format!("gpu export: the transition blend failed at progress {progress}"),
})?;
self.window_pos += 1;
self.last_dims = Some((w, h));
Ok(Some(wrap_rgba((blended, w, h))?))
}
}
fn stack_layer_for(is_base: bool, cur_layer: &VideoLayer) -> VideoLayer {
if is_base {
composited_base_layer(cur_layer)
} else {
cur_layer.clone()
}
}
fn composited_base_layer(layer: &VideoLayer) -> VideoLayer {
let mut neutral = layer.clone();
neutral.effects.clear();
neutral.opacity = AnimatedValue::Static(1.0);
neutral.blend_mode = BlendMode::Normal;
neutral.composite_op = CompositeOp::Over;
neutral.x = AnimatedValue::Static(0.0);
neutral.y = AnimatedValue::Static(0.0);
neutral.scale_x = AnimatedValue::Static(1.0);
neutral.scale_y = AnimatedValue::Static(1.0);
neutral.rotation = AnimatedValue::Static(0.0);
neutral
}
fn transparent_frame(w: u32, h: u32) -> Result<VideoFrame, TimelineError> {
VideoFrame::from_rgba(w, h, vec![0u8; (w as usize) * (h as usize) * 4]).map_err(|e| {
TimelineError::TimelineRenderFailed {
reason: format!("gpu export: could not build a placeholder frame: {e}"),
}
})
}
fn wrap_rgba((rgba, w, h): (Vec<u8>, u32, u32)) -> Result<VideoFrame, TimelineError> {
VideoFrame::from_rgba(w, h, rgba).map_err(|e| TimelineError::TimelineRenderFailed {
reason: format!("gpu export: could not wrap a composited frame: {e}"),
})
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn drain_video_gpu(
tracks: &[&Track],
canvas: (u32, u32),
frame_rate: f64,
encoder: &mut VideoEncoder,
core: &mut GpuCompositor,
on_progress: &(impl Fn(&Progress) -> bool + Send),
start: Instant,
total_frames: Option<u64>,
) -> Result<(), TimelineError> {
let mut sources: Vec<TrackSource<'_>> = Vec::with_capacity(tracks.len());
for track in tracks {
let is_base = sources.is_empty();
if let Some(ts) = TrackSource::open(track, is_base, canvas, frame_rate)? {
sources.push(ts);
}
}
let Some(top) = sources.len().checked_sub(1) else {
return Ok(());
};
core.reset_effect_cache();
let mut video_idx: u32 = 0;
loop {
let t = output_time(video_idx, frame_rate);
let mut pulled: Vec<Option<VideoFrame>> = Vec::with_capacity(sources.len());
for ts in &mut sources {
pulled.push(ts.next(core, t)?);
}
if pulled[top].is_none() {
break;
}
let mut layers: Vec<(&VideoLayer, VideoFrame)> = Vec::with_capacity(sources.len());
for (ts, frame) in sources.iter().zip(pulled) {
let frame = if let Some(f) = frame {
f
} else {
let (w, h) = ts.last_dims.unwrap_or(canvas);
transparent_frame(w, h)?
};
layers.push((ts.layer(), frame));
}
let composited = if layers.len() == 1 {
let (_, frame) = layers.pop().unwrap_or_else(|| unreachable!());
let (w, h) = (frame.width(), frame.height());
(frame.data(), w, h)
} else {
core.composite_owned(layers, canvas, t)
.ok_or_else(|| fell_back("the track stack"))?
};
emit_frame(
Some(composited),
encoder,
&mut video_idx,
on_progress,
start,
total_frames,
)?;
}
Ok(())
}
#[allow(clippy::cast_precision_loss)] fn output_time(video_idx: u32, frame_rate: f64) -> Duration {
Duration::from_secs_f64(f64::from(video_idx) / frame_rate)
}
fn emit_frame(
composited: Option<(Vec<u8>, u32, u32)>,
encoder: &mut VideoEncoder,
video_idx: &mut u32,
on_progress: &(impl Fn(&Progress) -> bool + Send),
start: Instant,
total_frames: Option<u64>,
) -> Result<(), TimelineError> {
let (rgba, w, h) = composited.ok_or_else(|| TimelineError::TimelineRenderFailed {
reason: "gpu export: a frame fell back mid-export (precluded by eligibility)".to_string(),
})?;
let out =
VideoFrame::from_rgba(w, h, rgba).map_err(|e| TimelineError::TimelineRenderFailed {
reason: format!("gpu export: readback frame invalid: {e}"),
})?;
encoder.push_video(&out)?;
*video_idx = video_idx.saturating_add(1);
let progress = Progress {
frames_processed: u64::from(*video_idx),
total_frames,
elapsed: start.elapsed(),
};
if !on_progress(&progress) {
return Err(TimelineError::Cancelled);
}
Ok(())
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use std::time::Duration;
use ff_filter::{BlendMode, FilterStep, XfadeTransition};
use ff_format::Color;
use super::*;
use crate::{Clip, Timeline};
fn square_timeline(clips: Vec<Clip>) -> Timeline {
Timeline::builder()
.canvas(64, 64)
.frame_rate(30.0)
.video_track(clips)
.build()
.unwrap()
}
fn two_track_timeline(base: Vec<Clip>, over: Vec<Clip>) -> Timeline {
Timeline::builder()
.canvas(64, 64)
.frame_rate(30.0)
.video_track(base)
.video_track(over)
.build()
.unwrap()
}
#[test]
fn eligible_tracks_should_accept_two_active_video_tracks() {
let src = std::env::temp_dir().join("avio_eligible_mt_probe.mp4");
if !probe_source_or_skip(&src, 64, 64, 30.0) {
return;
}
let t = two_track_timeline(
vec![Clip::new(&src)],
vec![Clip::new(&src).with_position(10.0, 4.0).with_scale(0.5)],
);
assert_eq!(
eligible(&t),
Some(vec![0, 1]),
"two file-source tracks must both route to the GPU"
);
}
#[test]
fn eligible_tracks_should_reject_a_transition_on_a_non_base_track() {
let src = std::env::temp_dir().join("avio_eligible_mt_xfade_probe.mp4");
if !probe_source_or_skip(&src, 64, 64, 30.0) {
return;
}
let over = vec![
placed(src.to_str().unwrap(), 0.0, 1.0),
placed(src.to_str().unwrap(), 1.0, 1.0)
.with_transition(XfadeTransition::Fade, Duration::from_millis(200)),
];
let t = two_track_timeline(vec![placed(src.to_str().unwrap(), 0.0, 2.0)], over);
assert_eq!(
eligible(&t),
None,
"a transition on an overlay track must keep the export on the CPU"
);
}
#[test]
fn eligible_tracks_should_still_accept_a_transition_on_the_base_track() {
let src = std::env::temp_dir().join("avio_eligible_mt_base_xfade_probe.mp4");
if !probe_source_or_skip(&src, 64, 64, 30.0) {
return;
}
let base = vec![
placed(src.to_str().unwrap(), 0.0, 1.0),
placed(src.to_str().unwrap(), 1.0, 1.0)
.with_transition(XfadeTransition::Fade, Duration::from_millis(200)),
];
let t = two_track_timeline(base, vec![placed(src.to_str().unwrap(), 0.0, 2.0)]);
assert_eq!(
eligible(&t),
Some(vec![0, 1]),
"a transition on the base track is still rendered by the drain"
);
}
#[test]
fn eligible_tracks_should_reject_a_stateful_effect_once_a_second_track_is_stacked() {
let src = std::env::temp_dir().join("avio_eligible_mt_stateful_probe.mp4");
if !probe_source_or_skip(&src, 64, 64, 30.0) {
return;
}
let blurred = || {
Clip::new(&src).with_video_effect(FilterStep::MotionBlur {
shutter_angle_degrees: 180.0,
sub_frames: 4,
})
};
assert_eq!(
eligible(&square_timeline(vec![blurred()])),
Some(vec![0]),
"the control must be eligible on its own, or this test proves nothing"
);
let t = two_track_timeline(vec![blurred()], vec![Clip::new(&src)]);
let stacked = eligible(&t);
let _ = std::fs::remove_file(&src);
assert_eq!(
stacked, None,
"a stateful effect must keep a stacked export on the CPU"
);
}
#[test]
fn eligible_tracks_should_still_accept_a_stateless_effect_when_stacked() {
let src = std::env::temp_dir().join("avio_eligible_mt_stateless_probe.mp4");
if !probe_source_or_skip(&src, 64, 64, 30.0) {
return;
}
let t = two_track_timeline(
vec![Clip::new(&src).with_video_effect(FilterStep::Hue { degrees: 60.0 })],
vec![Clip::new(&src).with_opacity(0.5)],
);
let stacked = eligible(&t);
let _ = std::fs::remove_file(&src);
assert_eq!(
stacked,
Some(vec![0, 1]),
"a stateless effect and an overlay opacity are both rendered by the stack"
);
}
#[test]
fn eligible_tracks_should_reject_when_any_track_is_ineligible() {
let src = std::env::temp_dir().join("avio_eligible_mt_bad_probe.mp4");
if !probe_source_or_skip(&src, 64, 64, 30.0) {
return;
}
let good = two_track_timeline(vec![Clip::new(&src)], vec![Clip::new(&src)]);
assert!(
eligible(&good).is_some(),
"the control case must be eligible, or this test proves nothing"
);
let t = two_track_timeline(
vec![Clip::new(&src)],
vec![{
let mut c = Clip::new(&src);
c.speed = 2.0;
c
}],
);
assert_eq!(eligible(&t), None, "one ineligible track fails the export");
}
fn eligible(timeline: &Timeline) -> Option<Vec<usize>> {
eligible_tracks(
&timeline.video_tracks,
timeline.lavfi_overlay.as_deref(),
timeline.video_tracks.iter().any(|t| t.solo),
(timeline.canvas_width, timeline.canvas_height),
timeline.frame_rate,
)
}
fn placed(path: &str, at: f64, secs: f64) -> Clip {
Clip::new(path)
.offset(Duration::from_secs_f64(at))
.trim(Duration::ZERO, Duration::from_secs_f64(secs))
}
fn conform_plan(src_pts: &[Duration], out_fps: f64, outputs: u64) -> Vec<usize> {
(0..outputs)
.map(|k| {
let want = clip_output_time(k, out_fps);
src_pts.iter().rposition(|at| *at <= want).unwrap_or(0)
})
.collect()
}
fn pts_at(fps: f64, count: usize) -> Vec<Duration> {
#[allow(clippy::cast_precision_loss)]
(0..count)
.map(|i| Duration::from_secs_f64(i as f64 / fps))
.collect()
}
#[test]
fn conform_should_repeat_frames_when_source_is_slower() {
let plan = conform_plan(&pts_at(24.0, 6), 30.0, 7);
assert_eq!(plan, [0, 0, 1, 2, 3, 4, 4]);
}
#[test]
fn conform_should_skip_frames_when_source_is_faster() {
let plan = conform_plan(&pts_at(60.0, 9), 30.0, 5);
assert_eq!(plan, [0, 2, 4, 6, 8]);
}
#[test]
fn conform_should_be_identity_at_matching_rates() {
let plan = conform_plan(&pts_at(30.0, 5), 30.0, 5);
assert_eq!(plan, [0, 1, 2, 3, 4]);
}
#[test]
fn conform_should_ignore_a_misreported_container_rate() {
let plan = conform_plan(&pts_at(30.0, 15), 30.0, 15);
assert_eq!(plan, (0..15).collect::<Vec<_>>());
}
fn encode_probe_source(path: &std::path::Path, w: u32, h: u32, fps: f64) -> Option<()> {
use ff_encode::{VideoCodec, VideoEncoder};
use ff_format::{PixelFormat as PF, VideoFrame};
let mut enc = VideoEncoder::create(path)
.video(w, h, fps)
.video_codec(VideoCodec::Mpeg4)
.build()
.ok()?;
for i in 0..60 {
let frame = VideoFrame::new_black(w, h, PF::Yuv420p, i);
enc.push_video(&frame).ok()?;
}
enc.finish().ok()?;
Some(())
}
fn probe_source_or_skip(src: &std::path::Path, w: u32, h: u32, fps: f64) -> bool {
let _ = std::fs::remove_file(src);
if encode_probe_source(src, w, h, fps).is_none() {
return false;
}
if VideoDecoder::open(src).build().is_err() {
let _ = std::fs::remove_file(src);
return false;
}
true
}
#[test]
fn eligible_track_should_accept_a_source_whose_rate_differs_from_the_timeline() {
let src = std::env::temp_dir().join("avio_eligible_24fps_probe.mp4");
if !probe_source_or_skip(&src, 64, 64, 24.0) {
return;
}
let t = square_timeline(vec![Clip::new(&src)]); let eligible_now = eligible(&t);
let _ = std::fs::remove_file(&src);
assert_eq!(
eligible_now,
Some(vec![0]),
"a 24 fps source in a 30 fps timeline must be GPU-eligible after #1660"
);
}
#[test]
fn eligible_track_should_accept_a_source_whose_aspect_differs_from_the_canvas() {
let src = std::env::temp_dir().join("avio_eligible_169_probe.mp4");
if !probe_source_or_skip(&src, 64, 36, 30.0) {
return;
}
let t = square_timeline(vec![Clip::new(&src)]); let eligible_now = eligible(&t);
let _ = std::fs::remove_file(&src);
assert_eq!(
eligible_now,
Some(vec![0]),
"a 16:9 source on a square canvas must be GPU-eligible after #1661"
);
}
#[test]
fn eligible_track_should_reject_a_generated_source() {
let t = square_timeline(vec![
Clip::solid(Color::rgb(1, 2, 3)).trim(Duration::ZERO, Duration::from_secs(1)),
]);
assert!(eligible(&t).is_none());
}
#[test]
fn window_frames_should_match_the_cpu_route_measurement() {
let window = window_frames(Duration::from_millis(500), 30.0);
assert_eq!(window, 15);
assert_eq!(30 + 30, 60);
}
#[test]
fn window_frames_should_round_a_sub_frame_duration_to_zero() {
assert_eq!(window_frames(Duration::from_millis(10), 30.0), 0);
}
#[test]
fn export_maps_to_gpu_should_accept_every_libm_independent_kind() {
for kind in [
XfadeTransition::Fade,
XfadeTransition::WipeLeft,
XfadeTransition::WipeRight,
XfadeTransition::WipeUp,
XfadeTransition::WipeDown,
XfadeTransition::FadeBlack,
XfadeTransition::FadeWhite,
] {
assert!(
export_maps_to_gpu(kind),
"{kind:?} agrees with the CPU export and must render on the GPU"
);
}
}
#[test]
fn export_maps_to_gpu_should_reject_dissolve_despite_it_mapping() {
assert!(
map_transition(XfadeTransition::Dissolve).is_some(),
"Dissolve still maps to a node -- the preview and the parity suites use it"
);
assert!(
!export_maps_to_gpu(XfadeTransition::Dissolve),
"Dissolve must stay on the CPU export"
);
}
#[test]
fn export_maps_to_gpu_should_reject_a_kind_with_no_node() {
for kind in [
XfadeTransition::SlideLeft,
XfadeTransition::CircleOpen,
XfadeTransition::FadeGrays,
XfadeTransition::Pixelize,
] {
assert!(!export_maps_to_gpu(kind), "{kind:?} has no GPU node");
}
}
#[test]
fn eligible_track_should_accept_a_fade_into_the_last_clip() {
let src = std::env::temp_dir().join("avio_eligible_fade_probe.mp4");
if !probe_source_or_skip(&src, 64, 64, 30.0) {
return;
}
let path = src.to_string_lossy().into_owned();
let t = square_timeline(vec![
placed(&path, 0.0, 1.0),
placed(&path, 1.0, 1.0)
.with_transition(XfadeTransition::Fade, Duration::from_millis(500)),
]);
let eligible_now = eligible(&t);
let _ = std::fs::remove_file(&src);
assert_eq!(
eligible_now,
Some(vec![0]),
"a Fade into the last clip must be GPU-eligible after #1659"
);
}
#[test]
fn eligible_track_should_accept_a_transition_on_a_middle_clip() {
let src = std::env::temp_dir().join("avio_eligible_middle_tr_probe.mp4");
if !probe_source_or_skip(&src, 64, 64, 30.0) {
return;
}
let path = src.to_string_lossy().into_owned();
let t = square_timeline(vec![
placed(&path, 0.0, 1.0),
placed(&path, 1.0, 1.0)
.with_transition(XfadeTransition::Fade, Duration::from_millis(500)),
placed(&path, 2.0, 1.0),
]);
let eligible_now = eligible(&t);
let _ = std::fs::remove_file(&src);
assert_eq!(
eligible_now,
Some(vec![0]),
"a transition on a middle clip must be GPU-eligible once placement preserves \
the timeline length"
);
}
#[test]
fn eligible_track_should_reject_a_transition_with_no_handle_to_feed_it() {
let src = std::env::temp_dir().join("avio_eligible_no_handle_probe.mp4");
if !probe_source_or_skip(&src, 64, 64, 30.0) {
return;
}
let path = src.to_string_lossy().into_owned();
let Ok(info) = ff_probe::open(&src) else {
let _ = std::fs::remove_file(&src);
return;
};
let flush = info.duration().as_secs_f64();
let t = square_timeline(vec![
placed(&path, 0.0, flush),
placed(&path, flush, 1.0)
.with_transition(XfadeTransition::Fade, Duration::from_millis(500)),
]);
let eligible_now = eligible(&t);
let _ = std::fs::remove_file(&src);
assert!(
eligible_now.is_none(),
"with no handle the transition clamps to a hard cut, which the GPU route \
leaves to the CPU one"
);
}
#[test]
fn eligible_track_should_ignore_a_transition_on_the_first_clip() {
let src = std::env::temp_dir().join("avio_eligible_first_tr_probe.mp4");
if !probe_source_or_skip(&src, 64, 64, 30.0) {
return;
}
let path = src.to_string_lossy().into_owned();
let t = square_timeline(vec![
placed(&path, 0.0, 1.0)
.with_transition(XfadeTransition::Fade, Duration::from_millis(500)),
]);
let eligible_now = eligible(&t);
let _ = std::fs::remove_file(&src);
assert_eq!(eligible_now, Some(vec![0]));
}
#[test]
fn eligible_track_should_reject_a_transition_kind_with_no_gpu_node() {
let t = square_timeline(vec![
placed("a.mp4", 0.0, 1.0),
placed("b.mp4", 1.0, 1.0)
.with_transition(XfadeTransition::SlideLeft, Duration::from_millis(500)),
]);
assert!(eligible(&t).is_none());
}
#[test]
fn eligible_track_should_accept_a_transition_longer_than_the_outgoing_clip_body() {
let src = std::env::temp_dir().join("avio_eligible_short_body_probe.mp4");
if !probe_source_or_skip(&src, 64, 64, 30.0) {
return;
}
let path = src.to_string_lossy().into_owned();
let t = square_timeline(vec![
placed(&path, 0.0, 0.3),
placed(&path, 0.3, 1.0)
.with_transition(XfadeTransition::Fade, Duration::from_millis(500)),
]);
let eligible_now = eligible(&t);
let _ = std::fs::remove_file(&src);
assert_eq!(
eligible_now,
Some(vec![0]),
"the outgoing clip's body no longer bounds the window; its handle does"
);
}
#[test]
fn eligible_track_should_reject_a_sub_frame_transition() {
let t = square_timeline(vec![
placed("a.mp4", 0.0, 1.0),
placed("b.mp4", 1.0, 1.0)
.with_transition(XfadeTransition::Fade, Duration::from_millis(10)),
]);
assert!(eligible(&t).is_none());
}
#[test]
fn eligible_track_should_reject_a_transition_into_a_clip_of_unknown_duration() {
let t = square_timeline(vec![
placed("a.mp4", 0.0, 1.0),
Clip::new("b.mp4")
.offset(Duration::from_secs(1))
.with_transition(XfadeTransition::Fade, Duration::from_millis(500)),
]);
assert!(eligible(&t).is_none());
}
#[test]
fn eligible_track_should_reject_a_transition_beside_a_stateful_effect() {
let t = square_timeline(vec![
placed("a.mp4", 0.0, 1.0).with_video_effect(FilterStep::MotionBlur {
shutter_angle_degrees: 180.0,
sub_frames: 4,
}),
placed("b.mp4", 1.0, 1.0)
.with_transition(XfadeTransition::Fade, Duration::from_millis(500)),
]);
assert!(eligible(&t).is_none());
}
#[test]
fn eligible_track_should_reject_a_transition_beside_a_transparent_clip() {
let t = square_timeline(vec![
placed("a.mp4", 0.0, 1.0),
placed("b.mp4", 1.0, 1.0)
.with_opacity(0.5)
.with_transition(XfadeTransition::Fade, Duration::from_millis(500)),
]);
assert!(eligible(&t).is_none());
}
#[test]
fn eligible_track_should_reject_a_transition_beside_a_non_normal_blend() {
let t = square_timeline(vec![
placed("a.mp4", 0.0, 1.0).with_blend_mode(BlendMode::Multiply),
placed("b.mp4", 1.0, 1.0)
.with_transition(XfadeTransition::Fade, Duration::from_millis(500)),
]);
assert!(eligible(&t).is_none());
}
#[test]
fn eligible_track_should_accept_a_transparent_clip_without_a_transition() {
let src = std::env::temp_dir().join("avio_eligible_opacity_probe.mp4");
if !probe_source_or_skip(&src, 64, 64, 30.0) {
return;
}
let path = src.to_string_lossy().into_owned();
let t = square_timeline(vec![
placed(&path, 0.0, 1.0).with_opacity(0.5),
placed(&path, 1.0, 1.0),
]);
let eligible_now = eligible(&t);
let _ = std::fs::remove_file(&src);
assert_eq!(eligible_now, Some(vec![0]));
}
#[test]
fn eligible_track_should_accept_a_stateful_effect_without_a_transition() {
let src = std::env::temp_dir().join("avio_eligible_motionblur_probe.mp4");
if !probe_source_or_skip(&src, 64, 64, 30.0) {
return;
}
let path = src.to_string_lossy().into_owned();
let t = square_timeline(vec![
placed(&path, 0.0, 1.0).with_video_effect(FilterStep::MotionBlur {
shutter_angle_degrees: 180.0,
sub_frames: 4,
}),
placed(&path, 1.0, 1.0),
]);
let eligible_now = eligible(&t);
let _ = std::fs::remove_file(&src);
assert_eq!(eligible_now, Some(vec![0]));
}
#[test]
fn eligible_track_should_reject_non_unity_speed() {
let t = square_timeline(vec![Clip::new("a.mp4").with_speed(2.0)]);
assert!(eligible(&t).is_none());
}
#[test]
fn eligible_track_should_reject_a_rotated_clip_and_accept_a_scaled_one() {
let src = std::env::temp_dir().join("avio_eligible_rotation_probe.mp4");
if !probe_source_or_skip(&src, 64, 64, 30.0) {
return;
}
let scaled = square_timeline(vec![Clip::new(&src).with_scale(0.5)]);
assert_eq!(
eligible(&scaled),
Some(vec![0]),
"a scaled clip is placed by the shared core and stays on the GPU route"
);
let spun = square_timeline(vec![Clip::new(&src).with_rotation(30.0)]);
assert!(
eligible(&spun).is_none(),
"a rotated clip has no GPU placement and must take the CPU route"
);
}
#[test]
fn eligible_tracks_should_reject_a_base_track_transition_between_placed_clips() {
let src = std::env::temp_dir().join("avio_eligible_placed_xfade_probe.mp4");
if !probe_source_or_skip(&src, 64, 64, 30.0) {
return;
}
let path = src.to_str().unwrap();
let fade = |c: Clip| c.with_transition(XfadeTransition::Fade, Duration::from_millis(200));
let plain = vec![placed(path, 0.0, 1.0), fade(placed(path, 1.0, 1.0))];
assert_eq!(
eligible(&square_timeline(plain)),
Some(vec![0]),
"an unplaced pair keeps the transition on the GPU route"
);
let outgoing_placed = vec![
placed(path, 0.0, 1.0).with_position(10.0, 4.0),
fade(placed(path, 1.0, 1.0)),
];
assert!(
eligible(&square_timeline(outgoing_placed)).is_none(),
"a placed outgoing clip must send the transition to the CPU route"
);
let incoming_scaled = vec![
placed(path, 0.0, 1.0),
fade(placed(path, 1.0, 1.0).with_scale(0.5)),
];
assert!(
eligible(&square_timeline(incoming_scaled)).is_none(),
"a scaled incoming clip must send the transition to the CPU route"
);
}
#[test]
fn eligible_track_should_reject_a_leading_gap() {
let t = square_timeline(vec![
Clip::new("a.mp4")
.trim(Duration::ZERO, Duration::from_secs(1))
.offset(Duration::from_secs(1)),
]);
assert!(eligible(&t).is_none());
}
#[test]
fn eligible_track_should_reject_an_inter_clip_gap() {
let t = square_timeline(vec![
Clip::new("a.mp4").trim(Duration::ZERO, Duration::from_secs(1)),
Clip::new("b.mp4")
.trim(Duration::ZERO, Duration::from_secs(1))
.offset(Duration::from_secs(2)),
]);
assert!(eligible(&t).is_none());
}
#[test]
fn eligible_track_should_reject_an_interior_clip_of_unknown_duration() {
let t = square_timeline(vec![
Clip::new("a.mp4"), Clip::new("b.mp4").offset(Duration::from_secs(1)),
]);
assert!(eligible(&t).is_none());
}
#[test]
fn eligible_track_should_reject_a_lavfi_overlay() {
let mut t = square_timeline(vec![Clip::new("a.mp4")]);
t.lavfi_overlay = Some("color=red".to_string());
assert!(eligible(&t).is_none());
}
#[test]
fn eligible_tracks_should_reject_a_source_that_cannot_be_opened() {
let t = Timeline::builder()
.canvas(64, 64)
.frame_rate(30.0)
.video_track(vec![Clip::new("a.mp4")])
.video_track(vec![Clip::new("b.mp4")])
.build()
.unwrap();
assert!(eligible(&t).is_none());
}
}