use std::time::Duration;
use ff_filter::BlendMode;
use thiserror::Error;
use crate::clip::Clip;
use crate::ids::{ClipId, GroupId, MarkerId, TrackId, TrackKind};
use crate::marker::Marker;
use crate::timeline::Timeline;
use crate::track::Track;
#[derive(Debug, Clone, PartialEq)]
pub enum ClipProperty {
Opacity(f32),
Speed(f64),
BlendMode(BlendMode),
VolumeDb(f64),
Position {
x: f64,
y: f64,
},
}
#[derive(Debug, Clone)]
pub enum Command {
AddClip {
track: TrackId,
clip: Box<Clip>,
},
RemoveClip {
clip: ClipId,
},
MoveClip {
clip: ClipId,
offset: Duration,
},
TrimClip {
clip: ClipId,
in_point: Option<Duration>,
out_point: Option<Duration>,
},
SetClipProperty {
clip: ClipId,
property: ClipProperty,
},
SetClip {
clip: ClipId,
value: Box<Clip>,
},
SplitClip {
clip: ClipId,
at: Duration,
},
MoveClipToTrack {
clip: ClipId,
to: TrackId,
offset: Duration,
},
RippleDelete {
clip: ClipId,
},
RippleTrim {
clip: ClipId,
in_point: Option<Duration>,
out_point: Option<Duration>,
},
GroupClips {
clips: Vec<ClipId>,
},
UngroupClips {
clip: ClipId,
},
AddTrack {
kind: TrackKind,
},
RemoveTrack {
track: TrackId,
},
AddMarker {
marker: Marker,
},
RemoveMarker {
marker: MarkerId,
},
MoveMarker {
marker: MarkerId,
pts: Duration,
},
SetCanvas {
width: u32,
height: u32,
},
SetFrameRate {
fps: f64,
},
Batch(Vec<Command>),
}
#[derive(Debug, Error, PartialEq)]
pub enum EditError {
#[error("no track with id {id:?}")]
TrackNotFound {
id: TrackId,
},
#[error("no clip with id {id:?}")]
ClipNotFound {
id: ClipId,
},
#[error("no marker with id {id:?}")]
MarkerNotFound {
id: MarkerId,
},
#[error("clip id mismatch: expected {expected:?}, value has {found:?}")]
ClipIdMismatch {
expected: ClipId,
found: ClipId,
},
#[error("split point {at:?} is not inside clip {clip:?}")]
SplitOutOfRange {
clip: ClipId,
at: Duration,
},
#[error("invalid canvas: {width}x{height}")]
InvalidCanvas {
width: u32,
height: u32,
},
#[error("invalid frame rate: {0}")]
InvalidFrameRate(f64),
}
pub fn apply(timeline: &Timeline, command: &Command) -> Result<Timeline, EditError> {
let mut next = timeline.clone();
match command {
Command::AddClip { track, clip } => {
let id = ClipId::from_raw(next.next_clip_id);
let mut new_clip = (**clip).clone();
new_clip.id = id;
new_clip.group = None;
let tr =
find_track_mut(&mut next, *track).ok_or(EditError::TrackNotFound { id: *track })?;
tr.clips.push(new_clip);
next.next_clip_id += 1;
}
Command::RemoveClip { clip } => {
if !remove_clip(&mut next, *clip) {
return Err(EditError::ClipNotFound { id: *clip });
}
}
Command::MoveClip { clip, offset } => {
let c = find_clip_mut(&mut next, *clip).ok_or(EditError::ClipNotFound { id: *clip })?;
let old_offset = c.offset;
let group = c.group;
c.offset = *offset;
if let Some(g) = group {
shift_group_offsets(&mut next, g, *clip, old_offset, *offset);
}
}
Command::TrimClip {
clip,
in_point,
out_point,
} => {
let c = find_clip_mut(&mut next, *clip).ok_or(EditError::ClipNotFound { id: *clip })?;
c.in_point = *in_point;
c.out_point = *out_point;
}
Command::SetClipProperty { clip, property } => {
let c = find_clip_mut(&mut next, *clip).ok_or(EditError::ClipNotFound { id: *clip })?;
match property {
ClipProperty::Opacity(v) => c.opacity = v.clamp(0.0, 1.0),
ClipProperty::Speed(v) => c.speed = *v,
ClipProperty::BlendMode(v) => c.blend_mode = *v,
ClipProperty::VolumeDb(v) => c.volume_db = *v,
ClipProperty::Position { x, y } => {
c.x = *x;
c.y = *y;
}
}
}
Command::SetClip { clip, value } => {
if value.id.is_set() && value.id != *clip {
return Err(EditError::ClipIdMismatch {
expected: *clip,
found: value.id,
});
}
let target =
find_clip_mut(&mut next, *clip).ok_or(EditError::ClipNotFound { id: *clip })?;
let mut new_value = (**value).clone();
new_value.id = *clip; *target = new_value;
}
Command::SplitClip { clip, at } => {
let right_id = ClipId::from_raw(next.next_clip_id);
let (clips, idx) = find_clip_track_mut(&mut next, *clip)
.ok_or(EditError::ClipNotFound { id: *clip })?;
let (left, mut right) =
split_clip(&clips[idx], *at).ok_or(EditError::SplitOutOfRange {
clip: *clip,
at: *at,
})?;
right.id = right_id;
clips[idx] = left;
clips.insert(idx + 1, right);
next.next_clip_id += 1;
}
Command::MoveClipToTrack { clip, to, offset } => {
if find_track_mut(&mut next, *to).is_none() {
return Err(EditError::TrackNotFound { id: *to });
}
let mut moved =
take_clip(&mut next, *clip).ok_or(EditError::ClipNotFound { id: *clip })?;
let old_offset = moved.offset;
let group = moved.group;
moved.offset = *offset;
find_track_mut(&mut next, *to)
.ok_or(EditError::TrackNotFound { id: *to })?
.clips
.push(moved);
if let Some(g) = group {
shift_group_offsets(&mut next, g, *clip, old_offset, *offset);
}
}
Command::RippleDelete { clip } => {
let group = find_clip_mut(&mut next, *clip)
.ok_or(EditError::ClipNotFound { id: *clip })?
.group;
let targets: Vec<ClipId> = match group {
Some(g) => collect_group_ids(&next, g),
None => vec![*clip],
};
for target in targets {
ripple_delete_one(&mut next, target);
}
}
Command::RippleTrim {
clip,
in_point,
out_point,
} => {
let (clips, idx) = find_clip_track_mut(&mut next, *clip)
.ok_or(EditError::ClipNotFound { id: *clip })?;
let clip_offset = clips[idx].offset;
let old_footprint = clip_footprint(&clips[idx]);
clips[idx].in_point = *in_point;
clips[idx].out_point = *out_point;
let new_footprint = clip_footprint(&clips[idx]);
if let (Some(old), Some(new)) = (old_footprint, new_footprint) {
for c in clips.iter_mut() {
if c.offset > clip_offset {
c.offset = if new >= old {
c.offset.saturating_add(new.saturating_sub(old))
} else {
c.offset.saturating_sub(old.saturating_sub(new))
};
}
}
}
}
Command::GroupClips { clips } => {
if !clips.is_empty() {
let g = GroupId::from_raw(next.next_group_id);
for id in clips {
find_clip_mut(&mut next, *id)
.ok_or(EditError::ClipNotFound { id: *id })?
.group = Some(g);
}
next.next_group_id += 1;
}
}
Command::UngroupClips { clip } => {
let group = find_clip_mut(&mut next, *clip)
.ok_or(EditError::ClipNotFound { id: *clip })?
.group;
if let Some(g) = group {
for c in all_clips_mut(&mut next) {
if c.group == Some(g) {
c.group = None;
}
}
}
}
Command::AddTrack { kind } => {
let id = TrackId::from_raw(next.next_track_id);
let mut tr = Track::new(Vec::new());
tr.id = id;
tracks_mut(&mut next, *kind).push(tr);
next.next_track_id += 1;
}
Command::RemoveTrack { track } => {
if !remove_track(&mut next, *track) {
return Err(EditError::TrackNotFound { id: *track });
}
}
Command::AddMarker { marker } => {
let mut marker = marker.clone();
marker.id = MarkerId::from_raw(next.next_marker_id);
next.markers.push(marker);
next.next_marker_id += 1;
}
Command::RemoveMarker { marker } => {
let before = next.markers.len();
next.markers.retain(|m| m.id != *marker);
if next.markers.len() == before {
return Err(EditError::MarkerNotFound { id: *marker });
}
}
Command::MoveMarker { marker, pts } => {
let m = next
.markers
.iter_mut()
.find(|m| m.id == *marker)
.ok_or(EditError::MarkerNotFound { id: *marker })?;
m.pts = *pts;
}
Command::SetCanvas { width, height } => {
if *width == 0 || *height == 0 {
return Err(EditError::InvalidCanvas {
width: *width,
height: *height,
});
}
next.canvas_width = *width;
next.canvas_height = *height;
next.canvas_explicit = true;
}
Command::SetFrameRate { fps } => {
if *fps <= 0.0 {
return Err(EditError::InvalidFrameRate(*fps));
}
next.frame_rate = *fps;
}
Command::Batch(commands) => {
for command in commands {
next = apply(&next, command)?;
}
}
}
Ok(next)
}
fn tracks_mut(timeline: &mut Timeline, kind: TrackKind) -> &mut Vec<Track> {
match kind {
TrackKind::Video => &mut timeline.video_tracks,
TrackKind::Audio => &mut timeline.audio_tracks,
}
}
fn find_track_mut(timeline: &mut Timeline, id: TrackId) -> Option<&mut Track> {
if let Some(tr) = timeline.video_tracks.iter_mut().find(|tr| tr.id == id) {
return Some(tr);
}
timeline.audio_tracks.iter_mut().find(|tr| tr.id == id)
}
fn find_clip_mut(timeline: &mut Timeline, id: ClipId) -> Option<&mut Clip> {
for tr in &mut timeline.video_tracks {
if let Some(c) = tr.clips.iter_mut().find(|c| c.id == id) {
return Some(c);
}
}
for tr in &mut timeline.audio_tracks {
if let Some(c) = tr.clips.iter_mut().find(|c| c.id == id) {
return Some(c);
}
}
None
}
fn remove_clip(timeline: &mut Timeline, id: ClipId) -> bool {
for tr in timeline
.video_tracks
.iter_mut()
.chain(timeline.audio_tracks.iter_mut())
{
if let Some(pos) = tr.clips.iter().position(|c| c.id == id) {
tr.clips.remove(pos);
return true;
}
}
false
}
fn remove_track(timeline: &mut Timeline, id: TrackId) -> bool {
if let Some(pos) = timeline.video_tracks.iter().position(|tr| tr.id == id) {
timeline.video_tracks.remove(pos);
return true;
}
if let Some(pos) = timeline.audio_tracks.iter().position(|tr| tr.id == id) {
timeline.audio_tracks.remove(pos);
return true;
}
false
}
fn find_clip_track_mut(timeline: &mut Timeline, id: ClipId) -> Option<(&mut Vec<Clip>, usize)> {
for track in &mut timeline.video_tracks {
if let Some(idx) = track.clips.iter().position(|c| c.id == id) {
return Some((&mut track.clips, idx));
}
}
for track in &mut timeline.audio_tracks {
if let Some(idx) = track.clips.iter().position(|c| c.id == id) {
return Some((&mut track.clips, idx));
}
}
None
}
fn all_clips_mut(timeline: &mut Timeline) -> impl Iterator<Item = &mut Clip> {
timeline
.video_tracks
.iter_mut()
.chain(timeline.audio_tracks.iter_mut())
.flat_map(|tr| tr.clips.iter_mut())
}
fn collect_group_ids(timeline: &Timeline, group: GroupId) -> Vec<ClipId> {
timeline
.video_tracks
.iter()
.chain(timeline.audio_tracks.iter())
.flat_map(|tr| tr.clips.iter())
.filter(|c| c.group == Some(group))
.map(|c| c.id)
.collect()
}
fn shift_group_offsets(
timeline: &mut Timeline,
group: GroupId,
except: ClipId,
old: Duration,
new: Duration,
) {
for c in all_clips_mut(timeline) {
if c.id != except && c.group == Some(group) {
c.offset = if new >= old {
c.offset.saturating_add(new.saturating_sub(old))
} else {
c.offset.saturating_sub(old.saturating_sub(new))
};
}
}
}
fn ripple_delete_one(timeline: &mut Timeline, clip: ClipId) {
let Some((clips, idx)) = find_clip_track_mut(timeline, clip) else {
return;
};
let removed_offset = clips[idx].offset;
let footprint = clip_footprint(&clips[idx]);
clips.remove(idx);
if let Some(shift) = footprint {
for c in clips.iter_mut() {
if c.offset > removed_offset {
c.offset = c.offset.saturating_sub(shift);
}
}
}
}
fn split_clip(orig: &Clip, at: Duration) -> Option<(Clip, Clip)> {
let elapsed = at.checked_sub(orig.offset)?;
if elapsed.is_zero() {
return None;
}
let in_pt = orig.in_point.unwrap_or(Duration::ZERO);
let source_advance = Duration::try_from_secs_f64(elapsed.as_secs_f64() * orig.speed).ok()?;
let source_split = in_pt.checked_add(source_advance)?;
if source_split <= in_pt {
return None; }
if let Some(out) = orig.out_point
&& source_split >= out
{
return None; }
let mut left = orig.clone();
left.out_point = Some(source_split);
left.fade_out = Duration::ZERO;
let mut right = orig.clone();
right.in_point = Some(source_split);
right.offset = at;
right.transition = None; right.transition_duration = Duration::ZERO;
right.fade_in = Duration::ZERO;
Some((left, right))
}
fn take_clip(timeline: &mut Timeline, id: ClipId) -> Option<Clip> {
for tr in timeline
.video_tracks
.iter_mut()
.chain(timeline.audio_tracks.iter_mut())
{
if let Some(pos) = tr.clips.iter().position(|c| c.id == id) {
return Some(tr.clips.remove(pos));
}
}
None
}
pub(crate) fn clip_footprint(clip: &Clip) -> Option<Duration> {
let source = clip.duration()?;
Duration::try_from_secs_f64(source.as_secs_f64() / clip.speed.max(0.01)).ok()
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
fn timeline_with(n: usize) -> Timeline {
let clips: Vec<Clip> = (0..n).map(|i| Clip::new(format!("clip{i}.mp4"))).collect();
Timeline::builder()
.canvas(1920, 1080)
.frame_rate(30.0)
.video_track(clips)
.build()
.unwrap()
}
fn track0(t: &Timeline) -> TrackId {
t.video_tracks()[0].id
}
fn clip_id(t: &Timeline, i: usize) -> ClipId {
t.video_tracks()[0].clips[i].id
}
fn two_clips_at(off0: u64, off1: u64) -> Timeline {
Timeline::builder()
.canvas(1920, 1080)
.frame_rate(30.0)
.video_track(vec![
Clip::new("a.mp4").offset(Duration::from_secs(off0)),
Clip::new("b.mp4").offset(Duration::from_secs(off1)),
])
.build()
.unwrap()
}
#[test]
fn build_should_assign_set_and_unique_ids() {
let t = timeline_with(3);
let track = &t.video_tracks()[0];
assert!(track.id.is_set());
let ids: Vec<ClipId> = track.clips.iter().map(|c| c.id).collect();
assert!(ids.iter().all(|id| id.is_set()));
let mut sorted = ids.clone();
sorted.sort();
sorted.dedup();
assert_eq!(sorted.len(), ids.len(), "clip ids must be unique");
}
#[test]
fn apply_add_clip_should_append_with_fresh_id() {
let t = timeline_with(1);
let out = apply(
&t,
&Command::AddClip {
track: track0(&t),
clip: Box::new(Clip::new("added.mp4")),
},
)
.unwrap();
assert_eq!(out.video_tracks()[0].clips.len(), 2);
let added = &out.video_tracks()[0].clips[1];
assert_eq!(
added.source_path().and_then(std::path::Path::to_str),
Some("added.mp4")
);
assert!(added.id.is_set());
assert_ne!(added.id, clip_id(&t, 0), "new clip must get a distinct id");
}
#[test]
fn apply_remove_clip_should_drop_it_by_id() {
let t = timeline_with(2);
let out = apply(
&t,
&Command::RemoveClip {
clip: clip_id(&t, 0),
},
)
.unwrap();
assert_eq!(out.video_tracks()[0].clips.len(), 1);
assert_eq!(
out.video_tracks()[0].clips[0]
.source_path()
.and_then(std::path::Path::to_str),
Some("clip1.mp4")
);
}
#[test]
fn apply_move_clip_should_set_offset() {
let t = timeline_with(1);
let out = apply(
&t,
&Command::MoveClip {
clip: clip_id(&t, 0),
offset: Duration::from_secs(3),
},
)
.unwrap();
assert_eq!(
out.video_tracks()[0].clips[0].offset,
Duration::from_secs(3)
);
}
#[test]
fn apply_trim_clip_should_set_in_out() {
let t = timeline_with(1);
let out = apply(
&t,
&Command::TrimClip {
clip: clip_id(&t, 0),
in_point: Some(Duration::from_secs(1)),
out_point: Some(Duration::from_secs(4)),
},
)
.unwrap();
assert_eq!(
out.video_tracks()[0].clips[0].in_point,
Some(Duration::from_secs(1))
);
assert_eq!(
out.video_tracks()[0].clips[0].out_point,
Some(Duration::from_secs(4))
);
}
#[test]
fn apply_set_clip_property_should_update_field() {
let t = timeline_with(1);
let out = apply(
&t,
&Command::SetClipProperty {
clip: clip_id(&t, 0),
property: ClipProperty::Opacity(0.25),
},
)
.unwrap();
assert!((out.video_tracks()[0].clips[0].opacity - 0.25).abs() < f32::EPSILON);
}
#[test]
fn apply_set_opacity_should_clamp_to_range() {
let t = timeline_with(1);
let out = apply(
&t,
&Command::SetClipProperty {
clip: clip_id(&t, 0),
property: ClipProperty::Opacity(2.0),
},
)
.unwrap();
assert!((out.video_tracks()[0].clips[0].opacity - 1.0).abs() < f32::EPSILON);
}
#[test]
fn apply_add_track_should_append_empty_track_with_fresh_id() {
let t = timeline_with(1);
let out = apply(
&t,
&Command::AddTrack {
kind: TrackKind::Video,
},
)
.unwrap();
assert_eq!(out.video_tracks().len(), 2);
let added = &out.video_tracks()[1];
assert!(added.clips.is_empty());
assert!(added.id.is_set());
assert_ne!(added.id, track0(&t), "new track must get a distinct id");
}
#[test]
fn apply_remove_track_should_drop_it_by_id() {
let t = apply(
&timeline_with(1),
&Command::AddTrack {
kind: TrackKind::Video,
},
)
.unwrap();
let second = t.video_tracks()[1].id;
let out = apply(&t, &Command::RemoveTrack { track: second }).unwrap();
assert_eq!(out.video_tracks().len(), 1);
}
#[test]
fn apply_should_preserve_clip_ids_across_an_unrelated_edit() {
let t = timeline_with(2);
let ids_before: Vec<ClipId> = t.video_tracks()[0].clips.iter().map(|c| c.id).collect();
let out = apply(&t, &Command::SetFrameRate { fps: 24.0 }).unwrap();
let ids_after: Vec<ClipId> = out.video_tracks()[0].clips.iter().map(|c| c.id).collect();
assert_eq!(
ids_before, ids_after,
"unrelated edit must not renumber clips"
);
}
#[test]
fn apply_set_canvas_should_update_dims_and_mark_explicit() {
let t = timeline_with(1);
let out = apply(
&t,
&Command::SetCanvas {
width: 1280,
height: 720,
},
)
.unwrap();
assert_eq!(out.canvas_width(), 1280);
assert_eq!(out.canvas_height(), 720);
assert_eq!(out.explicit_canvas(), Some((1280, 720)));
}
#[test]
fn apply_set_frame_rate_should_update_fps() {
let t = timeline_with(1);
let out = apply(&t, &Command::SetFrameRate { fps: 24.0 }).unwrap();
assert!((out.frame_rate() - 24.0).abs() < f64::EPSILON);
}
#[test]
fn apply_should_not_mutate_the_input() {
let t = timeline_with(1);
let before = t.video_tracks()[0].clips.len();
let _ = apply(
&t,
&Command::AddClip {
track: track0(&t),
clip: Box::new(Clip::new("x.mp4")),
},
)
.unwrap();
assert_eq!(
t.video_tracks()[0].clips.len(),
before,
"input timeline must be unchanged"
);
}
#[test]
fn apply_unknown_track_should_err() {
let t = timeline_with(1);
let err = apply(
&t,
&Command::AddClip {
track: TrackId::UNSET,
clip: Box::new(Clip::new("x.mp4")),
},
)
.unwrap_err();
assert_eq!(err, EditError::TrackNotFound { id: TrackId::UNSET });
}
#[test]
fn apply_unknown_clip_should_err() {
let t = timeline_with(1);
let err = apply(
&t,
&Command::RemoveClip {
clip: ClipId::UNSET,
},
)
.unwrap_err();
assert_eq!(err, EditError::ClipNotFound { id: ClipId::UNSET });
}
#[test]
fn apply_add_clip_repeated_should_mint_distinct_never_reused_ids() {
let t = timeline_with(1);
let a = apply(
&t,
&Command::AddClip {
track: track0(&t),
clip: Box::new(Clip::new("a.mp4")),
},
)
.unwrap();
let id_a = a.video_tracks()[0].clips[1].id;
let b = apply(&a, &Command::RemoveClip { clip: id_a }).unwrap();
let c = apply(
&b,
&Command::AddClip {
track: track0(&b),
clip: Box::new(Clip::new("b.mp4")),
},
)
.unwrap();
let id_c = c.video_tracks()[0].clips[1].id;
assert_ne!(
id_a, id_c,
"a removed id must not be reused along a linear history"
);
}
#[test]
fn apply_remove_unknown_track_should_err() {
let t = timeline_with(1);
let err = apply(
&t,
&Command::RemoveTrack {
track: TrackId::UNSET,
},
)
.unwrap_err();
assert_eq!(err, EditError::TrackNotFound { id: TrackId::UNSET });
}
#[test]
fn apply_move_unknown_clip_should_err() {
let t = timeline_with(1);
let err = apply(
&t,
&Command::MoveClip {
clip: ClipId::UNSET,
offset: Duration::from_secs(1),
},
)
.unwrap_err();
assert_eq!(err, EditError::ClipNotFound { id: ClipId::UNSET });
}
#[test]
fn build_should_assign_unique_ids_across_all_tracks() {
let t = Timeline::builder()
.canvas(1920, 1080)
.frame_rate(30.0)
.video_track(vec![Clip::new("v0.mp4"), Clip::new("v1.mp4")])
.video_track(vec![Clip::new("v2.mp4")])
.audio_track(vec![Clip::new("a0.mp3")])
.build()
.unwrap();
let mut track_ids: Vec<TrackId> = t
.video_tracks()
.iter()
.chain(t.audio_tracks().iter())
.map(|tr| tr.id)
.collect();
let n_tracks = track_ids.len();
track_ids.sort();
track_ids.dedup();
assert_eq!(
track_ids.len(),
n_tracks,
"track ids unique across video+audio"
);
let mut clip_ids: Vec<ClipId> = t
.video_tracks()
.iter()
.chain(t.audio_tracks().iter())
.flat_map(|tr| tr.clips.iter().map(|c| c.id))
.collect();
let n_clips = clip_ids.len();
clip_ids.sort();
clip_ids.dedup();
assert_eq!(clip_ids.len(), n_clips, "clip ids unique across all tracks");
}
#[test]
fn apply_invalid_frame_rate_should_err() {
let t = timeline_with(1);
let err = apply(&t, &Command::SetFrameRate { fps: 0.0 }).unwrap_err();
assert_eq!(err, EditError::InvalidFrameRate(0.0));
}
#[test]
fn apply_invalid_canvas_should_err() {
let t = timeline_with(1);
let err = apply(
&t,
&Command::SetCanvas {
width: 0,
height: 720,
},
)
.unwrap_err();
assert_eq!(
err,
EditError::InvalidCanvas {
width: 0,
height: 720
}
);
}
#[test]
fn apply_batch_should_apply_all_sub_commands() {
let t = timeline_with(1);
let out = apply(
&t,
&Command::Batch(vec![
Command::AddClip {
track: track0(&t),
clip: Box::new(Clip::new("a.mp4")),
},
Command::SetFrameRate { fps: 24.0 },
]),
)
.unwrap();
assert_eq!(out.video_tracks()[0].clips.len(), 2);
assert!((out.frame_rate() - 24.0).abs() < f64::EPSILON);
}
#[test]
fn apply_batch_should_be_atomic_on_failure() {
let t = timeline_with(1);
let err = apply(
&t,
&Command::Batch(vec![
Command::AddClip {
track: track0(&t),
clip: Box::new(Clip::new("a.mp4")),
},
Command::RemoveClip {
clip: ClipId::UNSET,
},
Command::SetFrameRate { fps: 24.0 },
]),
)
.unwrap_err();
assert_eq!(err, EditError::ClipNotFound { id: ClipId::UNSET });
assert_eq!(t.video_tracks()[0].clips.len(), 1);
assert!((t.frame_rate() - 30.0).abs() < f64::EPSILON);
}
#[test]
fn apply_empty_batch_should_be_a_no_op() {
let t = timeline_with(1);
let out = apply(&t, &Command::Batch(vec![])).unwrap();
assert_eq!(out.video_tracks()[0].clips.len(), 1);
assert!((out.frame_rate() - 30.0).abs() < f64::EPSILON);
}
#[test]
fn apply_nested_batch_should_apply() {
let t = timeline_with(1);
let out = apply(
&t,
&Command::Batch(vec![Command::Batch(vec![Command::AddClip {
track: track0(&t),
clip: Box::new(Clip::new("a.mp4")),
}])]),
)
.unwrap();
assert_eq!(out.video_tracks()[0].clips.len(), 2);
}
#[test]
fn apply_batch_of_two_add_clip_should_mint_distinct_ids() {
let t = timeline_with(1);
let out = apply(
&t,
&Command::Batch(vec![
Command::AddClip {
track: track0(&t),
clip: Box::new(Clip::new("a.mp4")),
},
Command::AddClip {
track: track0(&t),
clip: Box::new(Clip::new("b.mp4")),
},
]),
)
.unwrap();
let clips = &out.video_tracks()[0].clips;
assert_eq!(clips.len(), 3);
assert_ne!(clips[1].id, clips[2].id, "batch must mint distinct ids");
assert!(clips[1].id.is_set() && clips[2].id.is_set());
}
#[test]
fn apply_set_clip_should_replace_the_whole_value_and_preserve_id() {
let t = timeline_with(1);
let id = clip_id(&t, 0);
let mut patch = Clip::new("patched.mp4");
patch.brightness = 0.5;
patch.fade_in = Duration::from_secs(1);
patch.speed = 2.0;
let out = apply(
&t,
&Command::SetClip {
clip: id,
value: Box::new(patch),
},
)
.unwrap();
let c = &out.video_tracks()[0].clips[0];
assert_eq!(
c.source_path().and_then(std::path::Path::to_str),
Some("patched.mp4")
);
assert!((c.brightness - 0.5).abs() < f32::EPSILON);
assert_eq!(c.fade_in, Duration::from_secs(1));
assert!((c.speed - 2.0).abs() < f64::EPSILON);
assert_eq!(c.id, id, "SetClip preserves the clip id");
}
#[test]
fn apply_set_clip_should_accept_a_matching_value_id() {
let t = timeline_with(1);
let id = clip_id(&t, 0);
let mut patch = Clip::new("patched.mp4");
patch.id = id; let out = apply(
&t,
&Command::SetClip {
clip: id,
value: Box::new(patch),
},
)
.unwrap();
assert_eq!(
out.video_tracks()[0].clips[0]
.source_path()
.and_then(std::path::Path::to_str),
Some("patched.mp4")
);
}
#[test]
fn apply_set_clip_should_reject_a_mismatched_value_id() {
let t = timeline_with(2);
let id0 = clip_id(&t, 0);
let id1 = clip_id(&t, 1);
let mut patch = Clip::new("patched.mp4");
patch.id = id1; let err = apply(
&t,
&Command::SetClip {
clip: id0,
value: Box::new(patch),
},
)
.unwrap_err();
assert_eq!(
err,
EditError::ClipIdMismatch {
expected: id0,
found: id1,
}
);
assert_eq!(
t.video_tracks()[0].clips[0]
.source_path()
.and_then(std::path::Path::to_str),
Some("clip0.mp4")
);
}
#[test]
fn apply_set_clip_unknown_clip_should_err() {
let t = timeline_with(1);
let err = apply(
&t,
&Command::SetClip {
clip: ClipId::UNSET,
value: Box::new(Clip::new("x.mp4")),
},
)
.unwrap_err();
assert_eq!(err, EditError::ClipNotFound { id: ClipId::UNSET });
}
#[test]
fn apply_set_clip_should_replace_a_clip_on_an_audio_track() {
let t = Timeline::builder()
.canvas(1920, 1080)
.frame_rate(30.0)
.video_track(vec![Clip::new("v.mp4")])
.audio_track(vec![Clip::new("a.mp3")])
.build()
.unwrap();
let id = t.audio_tracks()[0].clips[0].id;
let out = apply(
&t,
&Command::SetClip {
clip: id,
value: Box::new(Clip::new("patched.mp3")),
},
)
.unwrap();
assert_eq!(
out.audio_tracks()[0].clips[0]
.source_path()
.and_then(std::path::Path::to_str),
Some("patched.mp3")
);
assert_eq!(out.audio_tracks()[0].clips[0].id, id);
}
fn split_setup(clip: Clip) -> (Timeline, ClipId) {
let t = Timeline::builder()
.canvas(1920, 1080)
.frame_rate(30.0)
.video_track(vec![clip])
.build()
.unwrap();
let id = t.video_tracks()[0].clips[0].id;
(t, id)
}
#[test]
fn apply_split_clip_should_produce_two_contiguous_clips() {
let (t, id) = split_setup(Clip::new("a.mp4").trim(Duration::ZERO, Duration::from_secs(10)));
let out = apply(
&t,
&Command::SplitClip {
clip: id,
at: Duration::from_secs(4),
},
)
.unwrap();
let clips = &out.video_tracks()[0].clips;
assert_eq!(clips.len(), 2);
assert_eq!(clips[0].id, id);
assert_eq!(clips[0].offset, Duration::ZERO);
assert_eq!(clips[0].out_point, Some(Duration::from_secs(4)));
assert!(clips[1].id.is_set());
assert_ne!(clips[1].id, id);
assert_eq!(clips[1].offset, Duration::from_secs(4));
assert_eq!(clips[1].in_point, Some(Duration::from_secs(4)));
assert_eq!(clips[1].out_point, Some(Duration::from_secs(10)));
}
#[test]
fn apply_split_clip_should_preserve_properties_on_both_halves() {
let mut clip = Clip::new("a.mp4").trim(Duration::ZERO, Duration::from_secs(10));
clip.brightness = 0.5;
clip.volume_db = -6.0;
let (t, id) = split_setup(clip);
let out = apply(
&t,
&Command::SplitClip {
clip: id,
at: Duration::from_secs(4),
},
)
.unwrap();
for c in &out.video_tracks()[0].clips {
assert!((c.brightness - 0.5).abs() < f32::EPSILON);
assert!((c.volume_db + 6.0).abs() < f64::EPSILON);
}
}
#[test]
fn apply_split_clip_should_move_fades_and_transition() {
use ff_filter::XfadeTransition;
let mut clip = Clip::new("a.mp4").trim(Duration::ZERO, Duration::from_secs(10));
clip.fade_in = Duration::from_millis(500);
clip.fade_out = Duration::from_millis(800);
clip.transition = Some(XfadeTransition::Fade);
clip.transition_duration = Duration::from_millis(300);
let (t, id) = split_setup(clip);
let out = apply(
&t,
&Command::SplitClip {
clip: id,
at: Duration::from_secs(4),
},
)
.unwrap();
let clips = &out.video_tracks()[0].clips;
assert_eq!(clips[0].fade_in, Duration::from_millis(500));
assert_eq!(clips[0].transition, Some(XfadeTransition::Fade));
assert_eq!(clips[0].fade_out, Duration::ZERO);
assert_eq!(clips[1].fade_in, Duration::ZERO);
assert_eq!(clips[1].transition, None);
assert_eq!(clips[1].fade_out, Duration::from_millis(800));
}
#[test]
fn apply_split_clip_should_map_source_position_with_speed() {
let mut clip = Clip::new("a.mp4").trim(Duration::ZERO, Duration::from_secs(10));
clip.speed = 2.0;
let (t, id) = split_setup(clip);
let out = apply(
&t,
&Command::SplitClip {
clip: id,
at: Duration::from_secs(2),
},
)
.unwrap();
let clips = &out.video_tracks()[0].clips;
assert_eq!(clips[0].out_point, Some(Duration::from_secs(4)));
assert_eq!(clips[1].in_point, Some(Duration::from_secs(4)));
assert_eq!(clips[1].offset, Duration::from_secs(2));
}
#[test]
fn apply_split_clip_should_split_an_open_ended_clip() {
let (t, id) = split_setup(Clip::new("a.mp4"));
let out = apply(
&t,
&Command::SplitClip {
clip: id,
at: Duration::from_secs(3),
},
)
.unwrap();
let clips = &out.video_tracks()[0].clips;
assert_eq!(clips[0].out_point, Some(Duration::from_secs(3)));
assert_eq!(clips[1].in_point, Some(Duration::from_secs(3)));
assert_eq!(clips[1].out_point, None, "the right half still runs to EOF");
assert_eq!(clips[1].offset, Duration::from_secs(3));
}
#[test]
fn apply_split_clip_at_the_start_should_err() {
let (t, id) = split_setup(Clip::new("a.mp4").trim(Duration::ZERO, Duration::from_secs(10)));
let err = apply(
&t,
&Command::SplitClip {
clip: id,
at: Duration::ZERO,
},
)
.unwrap_err();
assert_eq!(
err,
EditError::SplitOutOfRange {
clip: id,
at: Duration::ZERO
}
);
}
#[test]
fn apply_split_clip_past_the_end_should_err() {
let (t, id) = split_setup(Clip::new("a.mp4").trim(Duration::ZERO, Duration::from_secs(10)));
let err = apply(
&t,
&Command::SplitClip {
clip: id,
at: Duration::from_secs(10),
},
)
.unwrap_err();
assert_eq!(
err,
EditError::SplitOutOfRange {
clip: id,
at: Duration::from_secs(10)
}
);
}
#[test]
fn apply_split_unknown_clip_should_err() {
let (t, _id) =
split_setup(Clip::new("a.mp4").trim(Duration::ZERO, Duration::from_secs(10)));
let err = apply(
&t,
&Command::SplitClip {
clip: ClipId::UNSET,
at: Duration::from_secs(4),
},
)
.unwrap_err();
assert_eq!(err, EditError::ClipNotFound { id: ClipId::UNSET });
}
#[test]
fn apply_split_clip_should_split_a_clip_on_an_audio_track() {
let t = Timeline::builder()
.canvas(1920, 1080)
.frame_rate(30.0)
.video_track(vec![Clip::new("v.mp4")])
.audio_track(vec![
Clip::new("a.mp3").trim(Duration::ZERO, Duration::from_secs(8)),
])
.build()
.unwrap();
let id = t.audio_tracks()[0].clips[0].id;
let out = apply(
&t,
&Command::SplitClip {
clip: id,
at: Duration::from_secs(3),
},
)
.unwrap();
let clips = &out.audio_tracks()[0].clips;
assert_eq!(clips.len(), 2);
assert_eq!(clips[0].out_point, Some(Duration::from_secs(3)));
assert_eq!(clips[1].in_point, Some(Duration::from_secs(3)));
assert_eq!(clips[1].out_point, Some(Duration::from_secs(8)));
assert!(clips[1].id.is_set());
assert_ne!(clips[1].id, id);
}
#[test]
fn apply_move_clip_to_track_should_preserve_id_and_properties() {
let mut clip = Clip::new("a.mp4");
clip.brightness = 0.5;
let t = Timeline::builder()
.canvas(1920, 1080)
.frame_rate(30.0)
.video_track(vec![clip])
.video_track(vec![]) .build()
.unwrap();
let clip_id = t.video_tracks()[0].clips[0].id;
let to = t.video_tracks()[1].id;
let out = apply(
&t,
&Command::MoveClipToTrack {
clip: clip_id,
to,
offset: Duration::from_secs(5),
},
)
.unwrap();
assert!(out.video_tracks()[0].clips.is_empty());
let moved = &out.video_tracks()[1].clips[0];
assert_eq!(moved.id, clip_id, "the id is preserved across the move");
assert!((moved.brightness - 0.5).abs() < f32::EPSILON);
assert_eq!(moved.offset, Duration::from_secs(5));
}
#[test]
fn apply_move_clip_to_missing_track_should_err_and_not_change() {
let (t, id) = split_setup(Clip::new("a.mp4"));
let err = apply(
&t,
&Command::MoveClipToTrack {
clip: id,
to: TrackId::UNSET,
offset: Duration::from_secs(1),
},
)
.unwrap_err();
assert_eq!(err, EditError::TrackNotFound { id: TrackId::UNSET });
assert_eq!(t.video_tracks()[0].clips.len(), 1, "timeline unchanged");
}
#[test]
fn apply_move_missing_clip_should_err() {
let (t, _id) = split_setup(Clip::new("a.mp4"));
let to = t.video_tracks()[0].id;
let err = apply(
&t,
&Command::MoveClipToTrack {
clip: ClipId::UNSET,
to,
offset: Duration::ZERO,
},
)
.unwrap_err();
assert_eq!(err, EditError::ClipNotFound { id: ClipId::UNSET });
}
#[test]
fn apply_move_clip_to_track_should_preserve_effects_and_transition() {
use ff_filter::{FilterStep, XfadeTransition};
let mut clip = Clip::new("a.mp4");
clip.transition = Some(XfadeTransition::Fade);
clip.transition_duration = Duration::from_millis(300);
clip.video_effects.push(FilterStep::Lut3d {
path: "look.cube".into(),
});
let t = Timeline::builder()
.canvas(1920, 1080)
.frame_rate(30.0)
.video_track(vec![clip])
.video_track(vec![])
.build()
.unwrap();
let clip_id = t.video_tracks()[0].clips[0].id;
let to = t.video_tracks()[1].id;
let out = apply(
&t,
&Command::MoveClipToTrack {
clip: clip_id,
to,
offset: Duration::ZERO,
},
)
.unwrap();
let moved = &out.video_tracks()[1].clips[0];
assert_eq!(moved.transition, Some(XfadeTransition::Fade));
assert_eq!(moved.transition_duration, Duration::from_millis(300));
assert_eq!(moved.video_effects.len(), 1);
}
#[test]
fn apply_move_clip_same_track_should_re_offset() {
let (t, id) = split_setup(Clip::new("a.mp4"));
let to = t.video_tracks()[0].id;
let out = apply(
&t,
&Command::MoveClipToTrack {
clip: id,
to,
offset: Duration::from_secs(7),
},
)
.unwrap();
assert_eq!(out.video_tracks()[0].clips.len(), 1);
assert_eq!(
out.video_tracks()[0].clips[0].offset,
Duration::from_secs(7)
);
assert_eq!(out.video_tracks()[0].clips[0].id, id);
}
fn ripple_setup() -> Timeline {
let mk = |name: &str, off: u64| {
Clip::new(name)
.trim(Duration::ZERO, Duration::from_secs(4))
.offset(Duration::from_secs(off))
};
Timeline::builder()
.canvas(1920, 1080)
.frame_rate(30.0)
.video_track(vec![mk("a.mp4", 0), mk("b.mp4", 4), mk("c.mp4", 8)])
.build()
.unwrap()
}
#[test]
fn apply_ripple_delete_should_close_the_gap() {
let t = ripple_setup();
let b_id = t.video_tracks()[0].clips[1].id;
let out = apply(&t, &Command::RippleDelete { clip: b_id }).unwrap();
let clips = &out.video_tracks()[0].clips;
assert_eq!(clips.len(), 2);
assert_eq!(
clips[0].source_path().and_then(std::path::Path::to_str),
Some("a.mp4")
);
assert_eq!(clips[0].offset, Duration::ZERO);
assert_eq!(
clips[1].source_path().and_then(std::path::Path::to_str),
Some("c.mp4")
);
assert_eq!(clips[1].offset, Duration::from_secs(4));
}
#[test]
fn apply_ripple_delete_should_not_disturb_other_tracks() {
let mk = |name: &str, off: u64| {
Clip::new(name)
.trim(Duration::ZERO, Duration::from_secs(4))
.offset(Duration::from_secs(off))
};
let t = Timeline::builder()
.canvas(1920, 1080)
.frame_rate(30.0)
.video_track(vec![mk("a.mp4", 0), mk("b.mp4", 4)])
.video_track(vec![Clip::new("o.mp4").offset(Duration::from_secs(4))])
.build()
.unwrap();
let a_id = t.video_tracks()[0].clips[0].id;
let out = apply(&t, &Command::RippleDelete { clip: a_id }).unwrap();
assert_eq!(out.video_tracks()[0].clips[0].offset, Duration::ZERO);
assert_eq!(
out.video_tracks()[1].clips[0].offset,
Duration::from_secs(4),
"the other track is untouched"
);
}
#[test]
fn apply_ripple_delete_should_shift_by_speed_scaled_footprint() {
let mut a = Clip::new("a.mp4")
.trim(Duration::ZERO, Duration::from_secs(10))
.offset(Duration::ZERO);
a.speed = 2.0;
let t = Timeline::builder()
.canvas(1920, 1080)
.frame_rate(30.0)
.video_track(vec![a, Clip::new("b.mp4").offset(Duration::from_secs(5))])
.build()
.unwrap();
let a_id = t.video_tracks()[0].clips[0].id;
let out = apply(&t, &Command::RippleDelete { clip: a_id }).unwrap();
assert_eq!(
out.video_tracks()[0].clips[0].offset,
Duration::ZERO,
"shift uses the speed-scaled footprint (5), not the source duration (10)"
);
}
#[test]
fn apply_ripple_delete_open_ended_should_just_remove() {
let t = Timeline::builder()
.canvas(1920, 1080)
.frame_rate(30.0)
.video_track(vec![
Clip::new("a.mp4").offset(Duration::ZERO),
Clip::new("b.mp4").offset(Duration::from_secs(5)),
])
.build()
.unwrap();
let a_id = t.video_tracks()[0].clips[0].id;
let out = apply(&t, &Command::RippleDelete { clip: a_id }).unwrap();
let clips = &out.video_tracks()[0].clips;
assert_eq!(clips.len(), 1);
assert_eq!(
clips[0].source_path().and_then(std::path::Path::to_str),
Some("b.mp4")
);
assert_eq!(
clips[0].offset,
Duration::from_secs(5),
"no shift when the removed clip's footprint is unknown"
);
}
#[test]
fn apply_ripple_trim_shorter_should_close_the_gap() {
let t = ripple_setup();
let b_id = t.video_tracks()[0].clips[1].id;
let out = apply(
&t,
&Command::RippleTrim {
clip: b_id,
in_point: Some(Duration::ZERO),
out_point: Some(Duration::from_secs(2)),
},
)
.unwrap();
let clips = &out.video_tracks()[0].clips;
assert_eq!(clips[0].offset, Duration::ZERO, "a unchanged");
assert_eq!(
clips[1].offset,
Duration::from_secs(4),
"b offset unchanged"
);
assert_eq!(
clips[1].out_point,
Some(Duration::from_secs(2)),
"b trimmed"
);
assert_eq!(
clips[2].offset,
Duration::from_secs(6),
"c pulled left by 2"
);
}
#[test]
fn apply_ripple_trim_longer_should_push_later_clips() {
let t = ripple_setup();
let b_id = t.video_tracks()[0].clips[1].id;
let out = apply(
&t,
&Command::RippleTrim {
clip: b_id,
in_point: Some(Duration::ZERO),
out_point: Some(Duration::from_secs(6)),
},
)
.unwrap();
let clips = &out.video_tracks()[0].clips;
assert_eq!(
clips[2].offset,
Duration::from_secs(10),
"c pushed right by 2"
);
}
#[test]
fn apply_ripple_trim_should_not_disturb_other_tracks() {
let mk = |name: &str, off: u64| {
Clip::new(name)
.trim(Duration::ZERO, Duration::from_secs(4))
.offset(Duration::from_secs(off))
};
let t = Timeline::builder()
.canvas(1920, 1080)
.frame_rate(30.0)
.video_track(vec![mk("a.mp4", 0), mk("b.mp4", 4)])
.video_track(vec![Clip::new("o.mp4").offset(Duration::from_secs(4))])
.build()
.unwrap();
let a_id = t.video_tracks()[0].clips[0].id;
let out = apply(
&t,
&Command::RippleTrim {
clip: a_id,
in_point: Some(Duration::ZERO),
out_point: Some(Duration::from_secs(2)),
},
)
.unwrap();
assert_eq!(
out.video_tracks()[0].clips[1].offset,
Duration::from_secs(2),
"b pulled left by 2"
);
assert_eq!(
out.video_tracks()[1].clips[0].offset,
Duration::from_secs(4),
"the other track is untouched"
);
}
#[test]
fn apply_ripple_trim_should_shift_by_speed_scaled_footprint() {
let mut a = Clip::new("a.mp4")
.trim(Duration::ZERO, Duration::from_secs(10))
.offset(Duration::ZERO);
a.speed = 2.0;
let t = Timeline::builder()
.canvas(1920, 1080)
.frame_rate(30.0)
.video_track(vec![a, Clip::new("b.mp4").offset(Duration::from_secs(5))])
.build()
.unwrap();
let a_id = t.video_tracks()[0].clips[0].id;
let out = apply(
&t,
&Command::RippleTrim {
clip: a_id,
in_point: Some(Duration::ZERO),
out_point: Some(Duration::from_secs(4)),
},
)
.unwrap();
assert_eq!(
out.video_tracks()[0].clips[1].offset,
Duration::from_secs(2),
"shift uses the speed-scaled footprint delta (5 -> 2 = -3), not the source delta"
);
}
#[test]
fn apply_ripple_trim_unknown_footprint_should_trim_without_shifting() {
let t = ripple_setup();
let b_id = t.video_tracks()[0].clips[1].id;
let out = apply(
&t,
&Command::RippleTrim {
clip: b_id,
in_point: Some(Duration::ZERO),
out_point: None,
},
)
.unwrap();
let clips = &out.video_tracks()[0].clips;
assert_eq!(clips[1].out_point, None, "b trim applied");
assert_eq!(
clips[2].offset,
Duration::from_secs(8),
"c not shifted when the new footprint is unknown"
);
}
#[test]
fn apply_ripple_trim_missing_clip_should_err() {
let t = ripple_setup();
let missing = ClipId::from_raw(9999);
let err = apply(
&t,
&Command::RippleTrim {
clip: missing,
in_point: Some(Duration::ZERO),
out_point: Some(Duration::from_secs(2)),
},
)
.unwrap_err();
assert_eq!(err, EditError::ClipNotFound { id: missing });
}
#[test]
fn apply_ripple_trim_head_should_shift_by_footprint_delta() {
let t = ripple_setup();
let b_id = t.video_tracks()[0].clips[1].id;
let out = apply(
&t,
&Command::RippleTrim {
clip: b_id,
in_point: Some(Duration::from_secs(2)),
out_point: Some(Duration::from_secs(4)),
},
)
.unwrap();
let clips = &out.video_tracks()[0].clips;
assert_eq!(
clips[1].offset,
Duration::from_secs(4),
"b offset unchanged"
);
assert_eq!(
clips[1].in_point,
Some(Duration::from_secs(2)),
"b head trimmed"
);
assert_eq!(
clips[2].offset,
Duration::from_secs(6),
"c pulled left by 2"
);
}
#[test]
fn apply_ripple_trim_from_unbounded_should_trim_without_shifting() {
let t = Timeline::builder()
.canvas(1920, 1080)
.frame_rate(30.0)
.video_track(vec![
Clip::new("a.mp4").offset(Duration::ZERO),
Clip::new("b.mp4").offset(Duration::from_secs(5)),
])
.build()
.unwrap();
let a_id = t.video_tracks()[0].clips[0].id;
let out = apply(
&t,
&Command::RippleTrim {
clip: a_id,
in_point: Some(Duration::ZERO),
out_point: Some(Duration::from_secs(2)),
},
)
.unwrap();
let clips = &out.video_tracks()[0].clips;
assert_eq!(
clips[0].out_point,
Some(Duration::from_secs(2)),
"a trim applied"
);
assert_eq!(
clips[1].offset,
Duration::from_secs(5),
"no shift when the old footprint was unknown"
);
}
#[test]
fn apply_ripple_delete_missing_clip_should_err() {
let (t, _id) = split_setup(Clip::new("a.mp4"));
let err = apply(
&t,
&Command::RippleDelete {
clip: ClipId::UNSET,
},
)
.unwrap_err();
assert_eq!(err, EditError::ClipNotFound { id: ClipId::UNSET });
}
#[test]
fn apply_add_marker_should_append_with_fresh_id() {
let t = timeline_with(1);
let out = apply(
&t,
&Command::AddMarker {
marker: Marker::new(Duration::from_secs(2)).with_name("intro"),
},
)
.unwrap();
assert_eq!(out.markers().len(), 1);
let m = &out.markers()[0];
assert!(m.id.is_set(), "an added marker gets a set id");
assert_eq!(m.pts, Duration::from_secs(2));
assert_eq!(m.name.as_deref(), Some("intro"));
}
#[test]
fn apply_add_marker_should_stamp_fresh_id_over_incoming() {
let t = timeline_with(1);
let mut incoming = Marker::new(Duration::ZERO);
incoming.id = MarkerId::from_raw(999);
let out = apply(&t, &Command::AddMarker { marker: incoming }).unwrap();
assert_ne!(
out.markers()[0].id,
MarkerId::from_raw(999),
"the incoming id is replaced with a fresh one"
);
assert!(out.markers()[0].id.is_set());
}
#[test]
fn apply_add_marker_should_assign_distinct_ids() {
let t = timeline_with(1);
let t = apply(
&t,
&Command::AddMarker {
marker: Marker::new(Duration::from_secs(1)),
},
)
.unwrap();
let t = apply(
&t,
&Command::AddMarker {
marker: Marker::new(Duration::from_secs(2)),
},
)
.unwrap();
assert_ne!(
t.markers()[0].id,
t.markers()[1].id,
"each marker gets a distinct id"
);
}
#[test]
fn apply_remove_marker_should_drop_it_by_id() {
let t = timeline_with(1);
let t = apply(
&t,
&Command::AddMarker {
marker: Marker::new(Duration::from_secs(1)),
},
)
.unwrap();
let id = t.markers()[0].id;
let out = apply(&t, &Command::RemoveMarker { marker: id }).unwrap();
assert!(out.markers().is_empty());
}
#[test]
fn apply_remove_marker_missing_should_err() {
let t = timeline_with(1);
let missing = MarkerId::from_raw(9999);
let err = apply(&t, &Command::RemoveMarker { marker: missing }).unwrap_err();
assert_eq!(err, EditError::MarkerNotFound { id: missing });
}
#[test]
fn apply_move_marker_should_set_pts() {
let t = timeline_with(1);
let t = apply(
&t,
&Command::AddMarker {
marker: Marker::new(Duration::from_secs(1)),
},
)
.unwrap();
let id = t.markers()[0].id;
let out = apply(
&t,
&Command::MoveMarker {
marker: id,
pts: Duration::from_secs(5),
},
)
.unwrap();
assert_eq!(out.markers()[0].pts, Duration::from_secs(5));
assert_eq!(out.markers()[0].id, id, "the marker id is unchanged");
}
#[test]
fn apply_move_marker_missing_should_err() {
let t = timeline_with(1);
let missing = MarkerId::from_raw(9999);
let err = apply(
&t,
&Command::MoveMarker {
marker: missing,
pts: Duration::from_secs(1),
},
)
.unwrap_err();
assert_eq!(err, EditError::MarkerNotFound { id: missing });
}
#[test]
fn apply_add_marker_should_not_change_clips() {
let t = timeline_with(2);
let before: Vec<_> = t.video_tracks()[0].clips.iter().map(|c| c.id).collect();
let out = apply(
&t,
&Command::AddMarker {
marker: Marker::new(Duration::ZERO),
},
)
.unwrap();
let after: Vec<_> = out.video_tracks()[0].clips.iter().map(|c| c.id).collect();
assert_eq!(before, after, "adding a marker does not touch clips");
}
#[test]
fn apply_group_clips_should_link_members_with_one_fresh_group() {
let t = timeline_with(3);
let (a, b) = (clip_id(&t, 0), clip_id(&t, 1));
let out = apply(&t, &Command::GroupClips { clips: vec![a, b] }).unwrap();
let ga = out.video_tracks()[0].clips[0].group;
let gb = out.video_tracks()[0].clips[1].group;
let gc = out.video_tracks()[0].clips[2].group;
assert!(
ga.is_some() && ga == gb,
"grouped members share one group id"
);
assert!(ga.unwrap().is_set());
assert_eq!(gc, None, "an unnamed clip stays ungrouped");
}
#[test]
fn apply_group_clips_missing_clip_should_err_and_change_nothing() {
let t = timeline_with(2);
let a = clip_id(&t, 0);
let res = apply(
&t,
&Command::GroupClips {
clips: vec![a, ClipId::UNSET],
},
);
assert!(matches!(res, Err(EditError::ClipNotFound { .. })));
}
#[test]
fn apply_group_clips_should_reassign_an_already_grouped_clip() {
let t = timeline_with(3);
let (a, b, c) = (clip_id(&t, 0), clip_id(&t, 1), clip_id(&t, 2));
let t = apply(&t, &Command::GroupClips { clips: vec![a, b] }).unwrap();
let out = apply(&t, &Command::GroupClips { clips: vec![b, c] }).unwrap();
let ga = out.video_tracks()[0].clips[0].group;
let gb = out.video_tracks()[0].clips[1].group;
let gc = out.video_tracks()[0].clips[2].group;
assert_ne!(gb, ga, "b left a's group");
assert_eq!(gb, gc, "b joined c's group");
}
#[test]
fn apply_ungroup_clips_should_clear_group_of_all_members() {
let t = timeline_with(2);
let (a, b) = (clip_id(&t, 0), clip_id(&t, 1));
let t = apply(&t, &Command::GroupClips { clips: vec![a, b] }).unwrap();
let out = apply(&t, &Command::UngroupClips { clip: a }).unwrap();
assert_eq!(out.video_tracks()[0].clips[0].group, None);
assert_eq!(out.video_tracks()[0].clips[1].group, None);
}
#[test]
fn apply_ungroup_clips_ungrouped_should_be_noop() {
let t = timeline_with(1);
let a = clip_id(&t, 0);
let out = apply(&t, &Command::UngroupClips { clip: a }).unwrap();
assert_eq!(out.video_tracks()[0].clips[0].group, None);
}
#[test]
fn apply_move_clip_should_carry_grouped_member_by_the_same_delta() {
let t = two_clips_at(0, 10);
let (a, b) = (clip_id(&t, 0), clip_id(&t, 1));
let t = apply(&t, &Command::GroupClips { clips: vec![a, b] }).unwrap();
let out = apply(
&t,
&Command::MoveClip {
clip: a,
offset: Duration::from_secs(5),
},
)
.unwrap();
assert_eq!(
out.video_tracks()[0].clips[0].offset,
Duration::from_secs(5)
);
assert_eq!(
out.video_tracks()[0].clips[1].offset,
Duration::from_secs(15)
);
let out2 = apply(
&out,
&Command::MoveClip {
clip: a,
offset: Duration::from_secs(2),
},
)
.unwrap();
assert_eq!(
out2.video_tracks()[0].clips[1].offset,
Duration::from_secs(12)
);
}
#[test]
fn apply_move_clip_ungrouped_should_not_shift_other_clips() {
let t = two_clips_at(0, 10);
let a = clip_id(&t, 0);
let out = apply(
&t,
&Command::MoveClip {
clip: a,
offset: Duration::from_secs(5),
},
)
.unwrap();
assert_eq!(
out.video_tracks()[0].clips[1].offset,
Duration::from_secs(10),
"a non-grouped move leaves other clips put"
);
}
#[test]
fn apply_move_clip_to_track_should_carry_grouped_member_and_keep_its_track() {
let t = Timeline::builder()
.canvas(1920, 1080)
.frame_rate(30.0)
.video_track(vec![Clip::new("v.mp4")])
.audio_track(vec![Clip::new("a.mp3")])
.build()
.unwrap();
let v = t.video_tracks()[0].clips[0].id;
let a = t.audio_tracks()[0].clips[0].id;
let t = apply(&t, &Command::GroupClips { clips: vec![v, a] }).unwrap();
let t = apply(
&t,
&Command::AddTrack {
kind: TrackKind::Video,
},
)
.unwrap();
let dest = t.video_tracks()[1].id;
let out = apply(
&t,
&Command::MoveClipToTrack {
clip: v,
to: dest,
offset: Duration::from_secs(5),
},
)
.unwrap();
assert_eq!(out.video_tracks()[1].clips.len(), 1);
assert_eq!(
out.video_tracks()[1].clips[0].offset,
Duration::from_secs(5)
);
assert_eq!(out.audio_tracks()[0].clips.len(), 1);
assert_eq!(
out.audio_tracks()[0].clips[0].offset,
Duration::from_secs(5)
);
}
#[test]
fn apply_ripple_delete_should_remove_the_whole_group() {
let t = Timeline::builder()
.canvas(1920, 1080)
.frame_rate(30.0)
.video_track(vec![
Clip::new("v.mp4"),
Clip::new("w.mp4").offset(Duration::from_secs(30)),
])
.audio_track(vec![Clip::new("a.mp3")])
.build()
.unwrap();
let v = t.video_tracks()[0].clips[0].id;
let a = t.audio_tracks()[0].clips[0].id;
let t = apply(&t, &Command::GroupClips { clips: vec![v, a] }).unwrap();
let out = apply(&t, &Command::RippleDelete { clip: v }).unwrap();
assert_eq!(
out.video_tracks()[0].clips.len(),
1,
"grouped video clip removed, ungrouped one kept"
);
assert_eq!(
out.audio_tracks()[0].clips.len(),
0,
"grouped audio clip removed"
);
}
#[test]
fn apply_trim_clip_should_not_propagate_to_grouped_member() {
let t = timeline_with(2);
let (a, b) = (clip_id(&t, 0), clip_id(&t, 1));
let t = apply(&t, &Command::GroupClips { clips: vec![a, b] }).unwrap();
let out = apply(
&t,
&Command::TrimClip {
clip: a,
in_point: Some(Duration::from_secs(1)),
out_point: Some(Duration::from_secs(3)),
},
)
.unwrap();
assert_eq!(out.video_tracks()[0].clips[1].in_point, None);
assert_eq!(out.video_tracks()[0].clips[1].out_point, None);
}
#[test]
fn apply_split_clip_should_keep_both_halves_in_the_group() {
let t = Timeline::builder()
.canvas(1920, 1080)
.frame_rate(30.0)
.video_track(vec![
Clip::new("v.mp4").trim(Duration::ZERO, Duration::from_secs(10)),
])
.build()
.unwrap();
let v = t.video_tracks()[0].clips[0].id;
let t = apply(&t, &Command::GroupClips { clips: vec![v] }).unwrap();
let g = t.video_tracks()[0].clips[0].group;
assert!(g.is_some());
let out = apply(
&t,
&Command::SplitClip {
clip: v,
at: Duration::from_secs(4),
},
)
.unwrap();
assert_eq!(out.video_tracks()[0].clips.len(), 2);
assert_eq!(out.video_tracks()[0].clips[0].group, g);
assert_eq!(
out.video_tracks()[0].clips[1].group,
g,
"the right half inherits the group"
);
}
}