diff options
Diffstat (limited to 'rust/crates/timeline')
| -rw-r--r-- | rust/crates/timeline/src/placement/apply.rs | 14 | ||||
| -rw-r--r-- | rust/crates/timeline/src/placement/mod.rs | 8 | ||||
| -rw-r--r-- | rust/crates/timeline/tests/compute_resize.rs | 375 | ||||
| -rw-r--r-- | rust/crates/timeline/tests/placement_resolve.rs | 624 | ||||
| -rw-r--r-- | rust/crates/timeline/tests/retime.rs | 111 | ||||
| -rw-r--r-- | rust/crates/timeline/tests/split.rs | 187 | ||||
| -rw-r--r-- | rust/crates/timeline/tests/update_pipeline.rs | 145 |
7 files changed, 1446 insertions, 18 deletions
diff --git a/rust/crates/timeline/src/placement/apply.rs b/rust/crates/timeline/src/placement/apply.rs index 4e044a68..56c3fd0a 100644 --- a/rust/crates/timeline/src/placement/apply.rs +++ b/rust/crates/timeline/src/placement/apply.rs @@ -46,8 +46,8 @@ pub fn apply_placement(params: &ApplyPlacementParams) -> Option<PlacementApplied let insert_index = params .new_track_insert_index_override .unwrap_or(*insert_index); - let new_track = build_empty_track(new_track_id.clone(), *track_type, None) - .with_elements(params.elements.clone()); + let mut new_track = build_empty_track(new_track_id.clone(), *track_type, None); + *new_track.elements_mut() = params.elements.clone(); let updated_tracks = if *track_type == TrackType::Audio { SceneTracks { @@ -89,13 +89,3 @@ fn insert_into_audio_tracks(tracks: &SceneTracks, insert_index: usize, track: Tr next } -trait WithElements { - fn with_elements(self, elements: Vec<Element>) -> Self; -} - -impl WithElements for Track { - fn with_elements(mut self, elements: Vec<Element>) -> Self { - *self.elements_mut() = elements; - self - } -} diff --git a/rust/crates/timeline/src/placement/mod.rs b/rust/crates/timeline/src/placement/mod.rs index 8ad5710e..4eb2660e 100644 --- a/rust/crates/timeline/src/placement/mod.rs +++ b/rust/crates/timeline/src/placement/mod.rs @@ -1,4 +1,4 @@ -use scene::{Element, SceneTracks, Track}; +use scene::{SceneTracks, Track}; use time::MediaTime; use crate::types::{ElementType, TrackType}; @@ -21,7 +21,7 @@ pub use insert_index::{ }; pub use main_track::{MAIN_TRACK_NAME, enforce_main_track_start, get_earliest_main_track_element}; pub use overlap::can_place_time_spans_on_track; -pub use resolve::resolve_track_placement; +pub use resolve::{ResolveTrackPlacementParams, resolve_track_placement}; pub use track_factory::build_empty_track; #[derive(Clone, Debug, PartialEq)] @@ -98,7 +98,3 @@ pub fn ordered_tracks(tracks: &SceneTracks) -> Vec<&Track> { .chain(tracks.audio.iter()) .collect() } - -pub fn track_elements(track: &Track) -> &[Element] { - track.elements() -} diff --git a/rust/crates/timeline/tests/compute_resize.rs b/rust/crates/timeline/tests/compute_resize.rs new file mode 100644 index 00000000..4392e0a1 --- /dev/null +++ b/rust/crates/timeline/tests/compute_resize.rs @@ -0,0 +1,375 @@ +use scene::{ + BaseTimelineElement, Element, ElementRef, ImageElement, ParamValue, ParamValues, SceneTracks, + Track, VideoElement, +}; +use time::{FrameRate, MediaTime}; +use timeline::resize::{ + GroupResizeMember, GroupResizePushTarget, GroupResizeResult, GroupResizeUpdate, ResizeSide, + build_resize_members, compute_group_resize, +}; +use timeline::track_element_update::update_element_in_scene_tracks; + +const FPS: FrameRate = FrameRate::new(30, 1); + +fn seconds(value: f64) -> MediaTime { + MediaTime::from_seconds_f64(value).unwrap() +} + +fn build_push_target(element_id: &str, start_time: MediaTime, duration: MediaTime) -> GroupResizePushTarget { + GroupResizePushTarget { + track_id: "track-1".to_string(), + element_id: element_id.to_string(), + start_time, + duration, + trim_start: MediaTime::ZERO, + trim_end: MediaTime::ZERO, + } +} + +fn build_static_member( + start_time: MediaTime, + duration: MediaTime, + left_neighbor_bound: Option<MediaTime>, + right_neighbor_bound: Option<MediaTime>, + right_push_chain: Option<Vec<GroupResizePushTarget>>, +) -> GroupResizeMember { + GroupResizeMember { + track_id: "track-1".to_string(), + element_id: "element-1".to_string(), + start_time, + duration, + trim_start: MediaTime::ZERO, + trim_end: MediaTime::ZERO, + source_duration: None, + retime: None, + left_neighbor_bound, + right_neighbor_bound, + right_push_chain, + } +} + +fn build_video_member( + start_time: MediaTime, + duration: MediaTime, + source_duration: MediaTime, + right_neighbor_bound: Option<MediaTime>, +) -> GroupResizeMember { + GroupResizeMember { + track_id: "track-1".to_string(), + element_id: "element-1".to_string(), + start_time, + duration, + trim_start: MediaTime::ZERO, + trim_end: MediaTime::ZERO, + source_duration: Some(source_duration), + retime: None, + left_neighbor_bound: None, + right_neighbor_bound, + right_push_chain: None, + } +} + +fn find_update<'a>(result: &'a GroupResizeResult, element_id: &str) -> Option<&'a GroupResizeUpdate> { + result + .updates + .iter() + .find(|update| update.element_id == element_id) +} + +#[test] +fn extends_into_the_gap_before_pushing_anything() { + let member = build_static_member( + MediaTime::ZERO, + seconds(5.0), + None, + Some(seconds(10.0)), + Some(vec![build_push_target("neighbor-1", seconds(10.0), seconds(10.0))]), + ); + + let result = compute_group_resize(&[member], ResizeSide::Right, seconds(3.0), FPS); + + assert_eq!(result.delta_time, seconds(3.0)); + assert_eq!(result.updates.len(), 1); + assert_eq!( + find_update(&result, "element-1").unwrap().patch.duration, + seconds(8.0) + ); +} + +#[test] +fn pushes_the_flush_right_neighbor_by_the_overflow() { + let member = build_static_member( + MediaTime::ZERO, + seconds(5.0), + None, + Some(seconds(5.0)), + Some(vec![build_push_target("neighbor-1", seconds(5.0), seconds(10.0))]), + ); + + let result = compute_group_resize(&[member], ResizeSide::Right, seconds(3.0), FPS); + + assert_eq!(result.delta_time, seconds(3.0)); + assert_eq!(result.updates.len(), 2); + assert_eq!( + find_update(&result, "element-1").unwrap().patch.duration, + seconds(8.0) + ); + let push = find_update(&result, "neighbor-1").unwrap(); + assert_eq!(push.patch.start_time, seconds(8.0)); + assert_eq!(push.patch.duration, seconds(10.0)); + assert_eq!(push.patch.trim_start, MediaTime::ZERO); + assert_eq!(push.patch.trim_end, MediaTime::ZERO); +} + +#[test] +fn pushes_a_chain_of_several_neighbors_by_the_same_overflow() { + let member = build_static_member( + MediaTime::ZERO, + seconds(5.0), + None, + Some(seconds(5.0)), + Some(vec![ + build_push_target("neighbor-1", seconds(5.0), seconds(10.0)), + build_push_target("neighbor-2", seconds(15.0), seconds(5.0)), + build_push_target("neighbor-3", seconds(25.0), seconds(2.0)), + ]), + ); + + let result = compute_group_resize(&[member], ResizeSide::Right, seconds(4.0), FPS); + + assert_eq!(result.delta_time, seconds(4.0)); + assert_eq!(result.updates.len(), 4); + assert_eq!( + find_update(&result, "neighbor-1").unwrap().patch.start_time, + seconds(9.0) + ); + assert_eq!( + find_update(&result, "neighbor-2").unwrap().patch.start_time, + seconds(19.0) + ); + assert_eq!( + find_update(&result, "neighbor-3").unwrap().patch.start_time, + seconds(29.0) + ); +} + +#[test] +fn push_stops_at_the_end_of_the_track() { + let member = build_static_member(MediaTime::ZERO, seconds(5.0), None, None, Some(Vec::new())); + + let result = compute_group_resize(&[member], ResizeSide::Right, seconds(3.0), FPS); + + assert_eq!(result.delta_time, seconds(3.0)); + assert_eq!(result.updates.len(), 1); + assert_eq!( + find_update(&result, "element-1").unwrap().patch.duration, + seconds(8.0) + ); +} + +#[test] +fn video_element_clamps_at_its_right_neighbor_instead_of_pushing() { + let member = build_video_member( + MediaTime::ZERO, + seconds(5.0), + seconds(6.0), + Some(seconds(5.0)), + ); + + let result = compute_group_resize(&[member], ResizeSide::Right, seconds(10.0), FPS); + + assert_eq!(result.delta_time, MediaTime::ZERO); + assert_eq!(result.updates.len(), 1); + assert_eq!( + find_update(&result, "element-1").unwrap().patch.duration, + seconds(5.0) + ); +} + +#[test] +fn video_element_clamps_at_its_source_extent_when_there_is_no_neighbor() { + let member = build_video_member(MediaTime::ZERO, seconds(5.0), seconds(6.0), None); + + let result = compute_group_resize(&[member], ResizeSide::Right, seconds(10.0), FPS); + + assert_eq!(result.delta_time, seconds(1.0)); + assert_eq!(result.updates.len(), 1); + assert_eq!( + find_update(&result, "element-1").unwrap().patch.duration, + seconds(6.0) + ); +} + +#[test] +fn static_element_without_a_push_chain_keeps_the_old_neighbor_clamp() { + let member = build_static_member(MediaTime::ZERO, seconds(5.0), None, Some(seconds(5.0)), None); + + let result = compute_group_resize(&[member], ResizeSide::Right, seconds(3.0), FPS); + + assert_eq!(result.delta_time, MediaTime::ZERO); + assert_eq!(result.updates.len(), 1); +} + +#[test] +fn shortening_still_respects_the_one_frame_minimum_duration() { + let one_frame = MediaTime::from_ticks((time::TICKS_PER_SECOND as f64 / 30.0).round() as i64); + let member = build_static_member(MediaTime::ZERO, seconds(5.0), None, None, Some(Vec::new())); + + let result = compute_group_resize(&[member], ResizeSide::Right, seconds(-10.0), FPS); + + assert_eq!(result.updates.len(), 1); + assert_eq!( + find_update(&result, "element-1").unwrap().patch.duration, + one_frame + ); +} + +#[test] +fn left_edge_resize_still_clamps_at_the_left_neighbor_and_pushes_nothing() { + let member = build_static_member( + seconds(5.0), + seconds(5.0), + Some(seconds(2.0)), + None, + Some(vec![build_push_target("neighbor-1", seconds(10.0), seconds(10.0))]), + ); + + let result = compute_group_resize(&[member], ResizeSide::Left, seconds(-10.0), FPS); + + assert_eq!(result.delta_time, seconds(-3.0)); + assert_eq!(result.updates.len(), 1); + let update = find_update(&result, "element-1").unwrap(); + assert_eq!(update.patch.start_time, seconds(2.0)); + assert_eq!(update.patch.duration, seconds(8.0)); +} + +fn visual_params() -> ParamValues { + ParamValues::from([ + ("transform.positionX".to_string(), ParamValue::Number(0.0)), + ("transform.positionY".to_string(), ParamValue::Number(0.0)), + ("transform.scaleX".to_string(), ParamValue::Number(1.0)), + ("transform.scaleY".to_string(), ParamValue::Number(1.0)), + ("transform.rotate".to_string(), ParamValue::Number(0.0)), + ("opacity".to_string(), ParamValue::Number(1.0)), + ]) +} + +fn base_element(id: &str, start_time: MediaTime, duration: MediaTime) -> BaseTimelineElement { + BaseTimelineElement { + id: id.to_string(), + name: id.to_string(), + start_time, + duration, + trim_start: MediaTime::ZERO, + trim_end: MediaTime::ZERO, + source_duration: None, + animations: None, + params: visual_params(), + } +} + +const IMAGE_DURATION: fn() -> MediaTime = || seconds(5.0); +const VIDEO_DURATION: fn() -> MediaTime = || seconds(10.0); +const TAIL_DURATION: fn() -> MediaTime = || seconds(5.0); +const PUSH_DELTA: fn() -> MediaTime = || seconds(3.0); + +fn build_image_element() -> Element { + Element::Image(ImageElement { + base: base_element("image-1", MediaTime::ZERO, IMAGE_DURATION()), + media_id: "media-image-1".to_string(), + hidden: None, + effects: None, + masks: None, + }) +} + +fn build_video_element(id: &str, start_time: MediaTime, duration: MediaTime) -> Element { + Element::Video(VideoElement { + base: BaseTimelineElement { + source_duration: Some(duration), + ..base_element(id, start_time, duration) + }, + media_id: format!("media-{id}"), + is_source_audio_enabled: None, + hidden: None, + retime: None, + effects: None, + masks: None, + }) +} + +fn seed_tracks() -> (SceneTracks, String) { + let main = Track::Video { + id: "main-track".to_string(), + name: "Main Track".to_string(), + elements: vec![ + build_image_element(), + build_video_element("video-1", IMAGE_DURATION(), VIDEO_DURATION()), + build_video_element("video-2", IMAGE_DURATION() + VIDEO_DURATION(), TAIL_DURATION()), + ], + muted: false, + hidden: false, + }; + ( + SceneTracks { + overlay: Vec::new(), + main, + audio: Vec::new(), + }, + "main-track".to_string(), + ) +} + +fn get_element<'a>(tracks: &'a SceneTracks, element_id: &str) -> Option<&'a Element> { + tracks + .main + .elements() + .iter() + .find(|element| element.base().id == element_id) +} + +#[test] +fn resize_and_push_land_in_one_applied_update() { + let (tracks, track_id) = seed_tracks(); + + let members = build_resize_members( + &tracks, + &[ElementRef { + track_id: track_id.clone(), + element_id: "image-1".to_string(), + }], + ); + let result = compute_group_resize(&members, ResizeSide::Right, PUSH_DELTA(), FPS); + + let mut updated = tracks.clone(); + for update in &result.updates { + updated = update_element_in_scene_tracks( + &updated, + &update.track_id, + &update.element_id, + |element| { + let mut next = element.clone(); + let base = next.base_mut(); + base.trim_start = update.patch.trim_start; + base.trim_end = update.patch.trim_end; + base.start_time = update.patch.start_time; + base.duration = update.patch.duration; + next + }, + None, + ); + } + + assert_eq!( + get_element(&updated, "image-1").unwrap().base().duration, + IMAGE_DURATION() + PUSH_DELTA() + ); + assert_eq!( + get_element(&updated, "video-1").unwrap().base().start_time, + IMAGE_DURATION() + PUSH_DELTA() + ); + assert_eq!( + get_element(&updated, "video-2").unwrap().base().start_time, + IMAGE_DURATION() + VIDEO_DURATION() + PUSH_DELTA() + ); +} diff --git a/rust/crates/timeline/tests/placement_resolve.rs b/rust/crates/timeline/tests/placement_resolve.rs new file mode 100644 index 00000000..f79b00dd --- /dev/null +++ b/rust/crates/timeline/tests/placement_resolve.rs @@ -0,0 +1,624 @@ +use scene::{ + AudioElement, BaseTimelineElement, Element, GraphicElement, ParamValue, ParamValues, + SceneTracks, TextElement, Track, UploadAudioElement, VideoElement, +}; +use time::MediaTime; +use timeline::placement::{ + InsertDirection, PlacementResult, PlacementStrategy, PlacementSubject, PlacementTimeSpan, + ResolveTrackPlacementParams, VerticalDragDirection, resolve_track_placement, +}; +use timeline::types::{ElementType, TrackType}; + +fn ticks(value: i64) -> MediaTime { + MediaTime::from_ticks(value) +} + +fn base_element(id: &str, start_time: i64, duration: i64) -> BaseTimelineElement { + BaseTimelineElement { + id: id.to_string(), + name: id.to_string(), + start_time: ticks(start_time), + duration: ticks(duration), + trim_start: MediaTime::ZERO, + trim_end: MediaTime::ZERO, + source_duration: None, + animations: None, + params: ParamValues::new(), + } +} + +fn visual_params() -> ParamValues { + ParamValues::from([ + ("transform.positionX".to_string(), ParamValue::Number(0.0)), + ("transform.positionY".to_string(), ParamValue::Number(0.0)), + ("transform.scaleX".to_string(), ParamValue::Number(1.0)), + ("transform.scaleY".to_string(), ParamValue::Number(1.0)), + ("transform.rotate".to_string(), ParamValue::Number(0.0)), + ("opacity".to_string(), ParamValue::Number(1.0)), + ]) +} + +fn build_element(element_type: ElementType, id: &str, start_time: i64, duration: i64) -> Element { + match element_type { + ElementType::Audio => Element::Audio(AudioElement::Upload(UploadAudioElement { + base: BaseTimelineElement { + params: ParamValues::from([ + ("volume".to_string(), ParamValue::Number(1.0)), + ("muted".to_string(), ParamValue::Bool(false)), + ]), + ..base_element(id, start_time, duration) + }, + retime: None, + media_id: format!("media-{id}"), + })), + ElementType::Graphic => Element::Graphic(GraphicElement { + base: BaseTimelineElement { + params: visual_params(), + ..base_element(id, start_time, duration) + }, + definition_id: format!("graphic-{id}"), + hidden: None, + effects: None, + masks: None, + }), + ElementType::Text => Element::Text(TextElement { + base: BaseTimelineElement { + params: visual_params(), + ..base_element(id, start_time, duration) + }, + hidden: None, + effects: None, + }), + ElementType::Video => Element::Video(VideoElement { + base: BaseTimelineElement { + params: visual_params(), + ..base_element(id, start_time, duration) + }, + media_id: format!("media-{id}"), + is_source_audio_enabled: None, + hidden: None, + retime: None, + effects: None, + masks: None, + }), + _ => panic!("unsupported test element type"), + } +} + +fn build_track(track_type: TrackType, id: &str, elements: Vec<Element>) -> Track { + match track_type { + TrackType::Audio => Track::Audio { + id: id.to_string(), + name: id.to_string(), + elements, + muted: false, + }, + TrackType::Graphic => Track::Graphic { + id: id.to_string(), + name: id.to_string(), + elements, + hidden: false, + }, + TrackType::Text => Track::Text { + id: id.to_string(), + name: id.to_string(), + elements, + hidden: false, + }, + TrackType::Video => Track::Video { + id: id.to_string(), + name: id.to_string(), + elements, + muted: false, + hidden: false, + }, + TrackType::Effect => Track::Effect { + id: id.to_string(), + name: id.to_string(), + elements, + hidden: false, + }, + } +} + +fn build_time_span(start_time: i64, duration: i64, exclude_element_id: Option<&str>) -> PlacementTimeSpan { + PlacementTimeSpan { + start_time: ticks(start_time), + duration: ticks(duration), + exclude_element_id: exclude_element_id.map(str::to_string), + } +} + +fn build_scene_tracks(overlay: Vec<Track>, main: Option<Track>, audio: Vec<Track>) -> SceneTracks { + SceneTracks { + overlay, + main: main.unwrap_or_else(|| build_track(TrackType::Video, "video-main", Vec::new())), + audio, + } +} + +fn resolve( + tracks: SceneTracks, + element_type: ElementType, + time_spans: Vec<PlacementTimeSpan>, + strategy: PlacementStrategy, +) -> Option<PlacementResult> { + resolve_track_placement(&ResolveTrackPlacementParams { + tracks, + subject: PlacementSubject::ElementType(element_type), + time_spans, + strategy, + }) +} + +#[test] +fn explicit_returns_the_requested_compatible_track() { + let tracks = build_scene_tracks( + vec![build_track(TrackType::Text, "text-1", Vec::new())], + None, + Vec::new(), + ); + + let result = resolve( + tracks, + ElementType::Text, + vec![build_time_span(2, 3, None)], + PlacementStrategy::Explicit { + track_id: "text-1".to_string(), + }, + ); + + assert_eq!( + result, + Some(PlacementResult::ExistingTrack { + track_id: "text-1".to_string(), + track_index: 0, + track_type: TrackType::Text, + adjusted_start_time: None, + }) + ); +} + +#[test] +fn explicit_rejects_missing_and_incompatible_tracks() { + let tracks = || { + build_scene_tracks( + Vec::new(), + Some(build_track(TrackType::Video, "video-1", Vec::new())), + Vec::new(), + ) + }; + + let missing = resolve( + tracks(), + ElementType::Text, + vec![build_time_span(0, 1, None)], + PlacementStrategy::Explicit { + track_id: "missing".to_string(), + }, + ); + assert_eq!(missing, None); + + let incompatible = resolve( + tracks(), + ElementType::Text, + vec![build_time_span(0, 1, None)], + PlacementStrategy::Explicit { + track_id: "video-1".to_string(), + }, + ); + assert_eq!(incompatible, None); +} + +#[test] +fn first_available_picks_the_first_compatible_track_without_overlap() { + let tracks = build_scene_tracks( + vec![ + build_track( + TrackType::Text, + "text-1", + vec![build_element(ElementType::Text, "a", 0, 5)], + ), + build_track(TrackType::Text, "text-2", Vec::new()), + ], + None, + Vec::new(), + ); + + let result = resolve( + tracks, + ElementType::Text, + vec![build_time_span(2, 1, None)], + PlacementStrategy::FirstAvailable, + ); + + assert_eq!( + result, + Some(PlacementResult::ExistingTrack { + track_id: "text-2".to_string(), + track_index: 1, + track_type: TrackType::Text, + adjusted_start_time: None, + }) + ); +} + +#[test] +fn first_available_creates_a_new_track_when_all_compatible_tracks_are_full() { + let tracks = build_scene_tracks( + vec![build_track( + TrackType::Graphic, + "graphic-1", + vec![build_element(ElementType::Graphic, "a", 0, 5)], + )], + Some(build_track(TrackType::Video, "video-main", Vec::new())), + vec![build_track(TrackType::Audio, "audio-1", Vec::new())], + ); + + let result = resolve( + tracks, + ElementType::Graphic, + vec![build_time_span(1, 1, None)], + PlacementStrategy::FirstAvailable, + ); + + assert_eq!( + result, + Some(PlacementResult::NewTrack { + track_type: TrackType::Graphic, + insert_index: 0, + insert_position: None, + }) + ); +} + +#[test] +fn prefer_index_uses_the_preferred_track_when_it_fits() { + let tracks = build_scene_tracks( + Vec::new(), + None, + vec![build_track(TrackType::Audio, "audio-1", Vec::new())], + ); + + let result = resolve( + tracks, + ElementType::Audio, + vec![build_time_span(3, 2, None)], + PlacementStrategy::PreferIndex { + track_index: 0, + hover_direction: InsertDirection::Below, + vertical_drag_direction: None, + create_new_track_only: false, + }, + ); + + assert_eq!( + result, + Some(PlacementResult::NewTrack { + track_type: TrackType::Audio, + insert_index: 1, + insert_position: Some(InsertDirection::Below), + }) + ); +} + +#[test] +fn prefer_index_creates_a_new_overlay_track_above_the_main_track() { + let tracks = build_scene_tracks( + Vec::new(), + Some(build_track(TrackType::Video, "video-main", Vec::new())), + vec![build_track(TrackType::Audio, "audio-1", Vec::new())], + ); + + let result = resolve( + tracks, + ElementType::Graphic, + vec![build_time_span(1, 2, None)], + PlacementStrategy::PreferIndex { + track_index: 1, + hover_direction: InsertDirection::Below, + vertical_drag_direction: None, + create_new_track_only: false, + }, + ); + + assert_eq!( + result, + Some(PlacementResult::NewTrack { + track_type: TrackType::Graphic, + insert_index: 0, + insert_position: Some(InsertDirection::Above), + }) + ); +} + +#[test] +fn prefer_index_keeps_audio_tracks_below_the_main_track() { + let tracks = build_scene_tracks( + vec![build_track(TrackType::Text, "text-1", Vec::new())], + Some(build_track(TrackType::Video, "video-main", Vec::new())), + vec![build_track(TrackType::Audio, "audio-1", Vec::new())], + ); + + let result = resolve( + tracks, + ElementType::Audio, + vec![build_time_span(0, 1, None)], + PlacementStrategy::PreferIndex { + track_index: 0, + hover_direction: InsertDirection::Above, + vertical_drag_direction: None, + create_new_track_only: true, + }, + ); + + assert_eq!( + result, + Some(PlacementResult::NewTrack { + track_type: TrackType::Audio, + insert_index: 2, + insert_position: Some(InsertDirection::Below), + }) + ); +} + +#[test] +fn above_source_tries_the_track_above_source_then_any_compatible_track() { + let tracks = build_scene_tracks( + vec![ + build_track(TrackType::Text, "text-top", Vec::new()), + build_track( + TrackType::Text, + "text-middle", + vec![build_element(ElementType::Text, "a", 0, 5)], + ), + build_track(TrackType::Text, "text-source", Vec::new()), + ], + None, + Vec::new(), + ); + + let result = resolve( + tracks, + ElementType::Text, + vec![build_time_span(1, 1, None)], + PlacementStrategy::AboveSource { + source_track_index: 2, + }, + ); + + assert_eq!( + result, + Some(PlacementResult::ExistingTrack { + track_id: "text-top".to_string(), + track_index: 0, + track_type: TrackType::Text, + adjusted_start_time: None, + }) + ); +} + +#[test] +fn above_source_creates_a_new_overlay_track_in_the_overlay_zone_when_none_fit() { + let tracks = build_scene_tracks( + vec![ + build_track( + TrackType::Text, + "text-top", + vec![build_element(ElementType::Text, "a", 0, 5)], + ), + build_track( + TrackType::Text, + "text-source", + vec![build_element(ElementType::Text, "b", 0, 5)], + ), + ], + None, + Vec::new(), + ); + + let result = resolve( + tracks, + ElementType::Text, + vec![build_time_span(1, 1, None)], + PlacementStrategy::AboveSource { + source_track_index: 1, + }, + ); + + assert_eq!( + result, + Some(PlacementResult::NewTrack { + track_type: TrackType::Text, + insert_index: 0, + insert_position: None, + }) + ); +} + +#[test] +fn always_new_honors_highest_and_default_insertion_rules() { + use timeline::placement::NewTrackPosition; + + let tracks = || { + build_scene_tracks( + Vec::new(), + Some(build_track(TrackType::Video, "video-main", Vec::new())), + vec![build_track(TrackType::Audio, "audio-1", Vec::new())], + ) + }; + + let highest = resolve( + tracks(), + ElementType::Audio, + Vec::new(), + PlacementStrategy::AlwaysNew { + position: NewTrackPosition::Highest, + }, + ); + assert_eq!( + highest, + Some(PlacementResult::NewTrack { + track_type: TrackType::Audio, + insert_index: 1, + insert_position: None, + }) + ); + + let default = resolve( + tracks(), + ElementType::Audio, + Vec::new(), + PlacementStrategy::AlwaysNew { + position: NewTrackPosition::Default, + }, + ); + assert_eq!( + default, + Some(PlacementResult::NewTrack { + track_type: TrackType::Audio, + insert_index: 2, + insert_position: None, + }) + ); +} + +#[test] +fn batch_time_spans_reject_tracks_when_any_span_overlaps() { + let tracks = build_scene_tracks( + Vec::new(), + None, + vec![build_track( + TrackType::Audio, + "audio-1", + vec![ + build_element(ElementType::Audio, "a", 0, 2), + build_element(ElementType::Audio, "b", 5, 2), + ], + )], + ); + + let result = resolve( + tracks, + ElementType::Audio, + vec![build_time_span(3, 1, None), build_time_span(6, 1, None)], + PlacementStrategy::FirstAvailable, + ); + + assert_eq!( + result, + Some(PlacementResult::NewTrack { + track_type: TrackType::Audio, + insert_index: 1, + insert_position: None, + }) + ); +} + +#[test] +fn handles_main_only_timelines_and_track_type_derivation() { + use timeline::placement::NewTrackPosition; + + let main_only = resolve( + build_scene_tracks(Vec::new(), None, Vec::new()), + ElementType::Video, + vec![build_time_span(0, 3, None)], + PlacementStrategy::PreferIndex { + track_index: 0, + hover_direction: InsertDirection::Below, + vertical_drag_direction: None, + create_new_track_only: true, + }, + ); + assert_eq!( + main_only, + Some(PlacementResult::NewTrack { + track_type: TrackType::Video, + insert_index: 0, + insert_position: Some(InsertDirection::Above), + }) + ); + + let audio_default = resolve( + build_scene_tracks( + Vec::new(), + None, + vec![build_track(TrackType::Audio, "audio-1", Vec::new())], + ), + ElementType::Audio, + Vec::new(), + PlacementStrategy::AlwaysNew { + position: NewTrackPosition::Default, + }, + ); + assert_eq!( + audio_default, + Some(PlacementResult::NewTrack { + track_type: TrackType::Audio, + insert_index: 2, + insert_position: None, + }) + ); +} + +#[test] +fn existing_track_on_main_video_includes_adjusted_start_time_when_start_snaps() { + let tracks = build_scene_tracks( + Vec::new(), + Some(build_track( + TrackType::Video, + "video-main", + vec![build_element(ElementType::Video, "a", 5, 5)], + )), + Vec::new(), + ); + + let result = resolve( + tracks, + ElementType::Video, + vec![build_time_span(2, 2, None)], + PlacementStrategy::Explicit { + track_id: "video-main".to_string(), + }, + ); + + assert_eq!( + result, + Some(PlacementResult::ExistingTrack { + track_id: "video-main".to_string(), + track_index: 0, + track_type: TrackType::Video, + adjusted_start_time: Some(MediaTime::ZERO), + }) + ); +} + +#[test] +fn prefer_index_uses_vertical_drag_direction_when_hovered_track_is_incompatible() { + let tracks = build_scene_tracks( + vec![build_track(TrackType::Text, "text-1", Vec::new())], + Some(build_track(TrackType::Video, "video-main", Vec::new())), + vec![build_track(TrackType::Audio, "audio-1", Vec::new())], + ); + + let result = resolve( + tracks, + ElementType::Audio, + vec![build_time_span(0, 1, None)], + PlacementStrategy::PreferIndex { + track_index: 0, + hover_direction: InsertDirection::Above, + vertical_drag_direction: Some(VerticalDragDirection::Down), + create_new_track_only: false, + }, + ); + + assert_eq!( + result, + Some(PlacementResult::NewTrack { + track_type: TrackType::Audio, + insert_index: 2, + insert_position: Some(InsertDirection::Below), + }) + ); +} diff --git a/rust/crates/timeline/tests/retime.rs b/rust/crates/timeline/tests/retime.rs new file mode 100644 index 00000000..b495fec6 --- /dev/null +++ b/rust/crates/timeline/tests/retime.rs @@ -0,0 +1,111 @@ +use scene::RetimeConfig; +use timeline::retime::{ + clamp_retime_rate, get_clip_time_at_source_time, get_effective_rate_at, + get_source_span_at_clip_time, get_source_time_at_clip_time, + get_timeline_duration_for_source_span, split_retime_at_clip_time, +}; + +const TWO_X: RetimeConfig = RetimeConfig { + rate: 2.0, + maintain_pitch: None, +}; +const HALF_X: RetimeConfig = RetimeConfig { + rate: 0.5, + maintain_pitch: None, +}; + +#[test] +fn maps_clip_time_to_source_time_at_2x_speed() { + assert_eq!(get_source_time_at_clip_time(5.0, Some(&TWO_X)), 10.0); +} + +#[test] +fn maps_clip_time_to_source_time_at_half_speed() { + assert_eq!(get_source_time_at_clip_time(4.0, Some(&HALF_X)), 2.0); +} + +#[test] +fn returns_clip_time_unchanged_when_no_retime() { + assert_eq!(get_source_time_at_clip_time(7.0, None), 7.0); +} + +#[test] +fn inverts_source_time_back_to_clip_time_at_2x_speed() { + assert_eq!(get_clip_time_at_source_time(10.0, Some(&TWO_X)), 5.0); +} + +#[test] +fn returns_effective_rate() { + assert_eq!(get_effective_rate_at(Some(&TWO_X)), 2.0); + assert_eq!(get_effective_rate_at(None), 1.0); +} + +#[test] +fn derives_timeline_duration_for_a_visible_source_span() { + assert_eq!( + get_timeline_duration_for_source_span(10.0, Some(&TWO_X)), + 5.0 + ); + assert_eq!( + get_timeline_duration_for_source_span(10.0, Some(&HALF_X)), + 20.0 + ); +} + +#[test] +fn clamps_invalid_rates_to_one() { + let zero = RetimeConfig { + rate: 0.0, + maintain_pitch: None, + }; + let negative = RetimeConfig { + rate: -1.0, + maintain_pitch: None, + }; + assert_eq!(get_source_time_at_clip_time(5.0, Some(&zero)), 5.0); + assert_eq!(get_source_time_at_clip_time(5.0, Some(&negative)), 5.0); + assert_eq!(clamp_retime_rate(f64::NAN), 1.0); + assert_eq!(clamp_retime_rate(f64::INFINITY), 1.0); +} + +#[test] +fn caps_retime_rates_above_5x() { + let hundred = RetimeConfig { + rate: 100.0, + maintain_pitch: None, + }; + assert_eq!(get_source_time_at_clip_time(5.0, Some(&hundred)), 25.0); + assert_eq!( + get_timeline_duration_for_source_span(10.0, Some(&hundred)), + 2.0 + ); +} + +#[test] +fn measures_source_span_at_a_clip_time() { + assert_eq!(get_source_span_at_clip_time(5.0, Some(&TWO_X)), 10.0); +} + +#[test] +fn returns_zero_for_non_positive_clip_time() { + assert_eq!(get_source_span_at_clip_time(0.0, None), 0.0); + assert_eq!(get_source_span_at_clip_time(-1.0, None), 0.0); +} + +#[test] +fn passes_the_same_retime_to_both_halves_when_splitting() { + let retime = RetimeConfig { + rate: 1.5, + maintain_pitch: None, + }; + let (left, right) = split_retime_at_clip_time(Some(&retime)); + assert_eq!(left, Some(retime)); + assert_eq!(right, Some(retime)); +} + +#[test] +fn returns_none_on_both_sides_when_no_retime() { + let (left, right) = split_retime_at_clip_time(None); + assert_eq!(left, None); + assert_eq!(right, None); +} diff --git a/rust/crates/timeline/tests/split.rs b/rust/crates/timeline/tests/split.rs new file mode 100644 index 00000000..ee9d4558 --- /dev/null +++ b/rust/crates/timeline/tests/split.rs @@ -0,0 +1,187 @@ +use scene::{ + BaseTimelineElement, Element, ElementRef, ParamValue, ParamValues, RetimeConfig, SceneTracks, + Track, VideoElement, +}; +use time::MediaTime; +use timeline::split::{RetainSide, split_elements}; + +fn seconds(value: f64) -> MediaTime { + MediaTime::from_seconds_f64(value).unwrap() +} + +fn build_video_element(id: &str, duration: MediaTime, retime: Option<RetimeConfig>) -> Element { + Element::Video(VideoElement { + base: BaseTimelineElement { + id: id.to_string(), + name: id.to_string(), + start_time: MediaTime::ZERO, + duration, + trim_start: MediaTime::ZERO, + trim_end: MediaTime::ZERO, + source_duration: None, + animations: None, + params: ParamValues::from([ + ("transform.positionX".to_string(), ParamValue::Number(0.0)), + ("transform.positionY".to_string(), ParamValue::Number(0.0)), + ("transform.scaleX".to_string(), ParamValue::Number(1.0)), + ("transform.scaleY".to_string(), ParamValue::Number(1.0)), + ("transform.rotate".to_string(), ParamValue::Number(0.0)), + ("opacity".to_string(), ParamValue::Number(1.0)), + ]), + }, + media_id: format!("media-{id}"), + is_source_audio_enabled: None, + hidden: None, + retime, + effects: None, + masks: None, + }) +} + +fn seed_tracks(element: Element) -> (SceneTracks, ElementRef) { + let reference = ElementRef { + track_id: "main-track".to_string(), + element_id: element.base().id.clone(), + }; + ( + SceneTracks { + overlay: Vec::new(), + main: Track::Video { + id: "main-track".to_string(), + name: "Main Track".to_string(), + elements: vec![element], + muted: false, + hidden: false, + }, + audio: Vec::new(), + }, + reference, + ) +} + +#[test] +fn split_produces_left_and_right_halves_with_shifted_trims() { + let (tracks, reference) = seed_tracks(build_video_element("video-1", seconds(10.0), None)); + + let result = split_elements(&tracks, &[reference], seconds(4.0), RetainSide::Both); + + assert_eq!(result.right_side_elements.len(), 1); + let elements = result.tracks.main.elements(); + assert_eq!(elements.len(), 2); + + let left = &elements[0]; + assert_eq!(left.base().id, "video-1"); + assert_eq!(left.base().name, "video-1 (left)"); + assert_eq!(left.base().start_time, MediaTime::ZERO); + assert_eq!(left.base().duration, seconds(4.0)); + assert_eq!(left.base().trim_start, MediaTime::ZERO); + assert_eq!(left.base().trim_end, seconds(6.0)); + + let right = &elements[1]; + assert_eq!(right.base().id, result.right_side_elements[0].element_id); + assert_eq!(right.base().name, "video-1 (right)"); + assert_eq!(right.base().start_time, seconds(4.0)); + assert_eq!(right.base().duration, seconds(6.0)); + assert_eq!(right.base().trim_start, seconds(4.0)); + assert_eq!(right.base().trim_end, MediaTime::ZERO); +} + +#[test] +fn split_scales_source_spans_by_the_retime_rate() { + let retime = RetimeConfig { + rate: 2.0, + maintain_pitch: None, + }; + let (tracks, reference) = + seed_tracks(build_video_element("video-1", seconds(10.0), Some(retime))); + + let result = split_elements(&tracks, &[reference], seconds(4.0), RetainSide::Both); + + let elements = result.tracks.main.elements(); + let left = &elements[0]; + let right = &elements[1]; + assert_eq!(left.base().trim_end, seconds(12.0)); + assert_eq!(right.base().trim_start, seconds(8.0)); + assert_eq!( + left.base().trim_start + left.base().duration * 2 + left.base().trim_end, + right.base().trim_start + right.base().duration * 2 + right.base().trim_end + ); + assert_eq!(timeline::element_retime(left), Some(retime)); + assert_eq!(timeline::element_retime(right), Some(retime)); +} + +#[test] +fn retain_left_keeps_only_the_left_half() { + let (tracks, reference) = seed_tracks(build_video_element("video-1", seconds(10.0), None)); + + let result = split_elements(&tracks, &[reference], seconds(4.0), RetainSide::Left); + + assert_eq!(result.right_side_elements.len(), 0); + let elements = result.tracks.main.elements(); + assert_eq!(elements.len(), 1); + assert_eq!(elements[0].base().id, "video-1"); + assert_eq!(elements[0].base().duration, seconds(4.0)); + assert_eq!(elements[0].base().trim_end, seconds(6.0)); +} + +#[test] +fn retain_right_keeps_only_the_right_half_with_a_new_id() { + let (tracks, reference) = seed_tracks(build_video_element("video-1", seconds(10.0), None)); + + let result = split_elements(&tracks, &[reference], seconds(4.0), RetainSide::Right); + + assert_eq!(result.right_side_elements.len(), 1); + let elements = result.tracks.main.elements(); + assert_eq!(elements.len(), 1); + let right = &elements[0]; + assert_ne!(right.base().id, "video-1"); + assert_eq!(right.base().id, result.right_side_elements[0].element_id); + assert_eq!(right.base().start_time, seconds(4.0)); + assert_eq!(right.base().duration, seconds(6.0)); + assert_eq!(right.base().trim_start, seconds(4.0)); +} + +#[test] +fn split_outside_the_element_bounds_is_a_no_op() { + let (tracks, reference) = seed_tracks(build_video_element("video-1", seconds(10.0), None)); + + for split_time in [MediaTime::ZERO, seconds(10.0), seconds(12.0)] { + let result = split_elements( + &tracks, + std::slice::from_ref(&reference), + split_time, + RetainSide::Both, + ); + assert_eq!(result.right_side_elements.len(), 0); + assert_eq!(result.tracks.main.elements().len(), 1); + assert_eq!( + result.tracks.main.elements()[0].base().duration, + seconds(10.0) + ); + } +} + +#[test] +fn shifting_the_right_side_and_inserting_into_the_gap_keeps_layout_consistent() { + let (tracks, reference) = seed_tracks(build_video_element("video-1", seconds(10.0), None)); + + let result = split_elements(&tracks, &[reference], seconds(4.0), RetainSide::Both); + let right_ref = result.right_side_elements[0].clone(); + + let shifted = timeline::update_element_in_scene_tracks( + &result.tracks, + &right_ref.track_id, + &right_ref.element_id, + |element| { + let mut next = element.clone(); + next.base_mut().start_time = element.base().start_time + seconds(3.0); + next + }, + None, + ); + + let elements = shifted.main.elements(); + assert_eq!(elements[0].base().duration, seconds(4.0)); + assert_eq!(elements[1].base().start_time, seconds(7.0)); + assert_eq!(elements[1].base().duration, seconds(6.0)); +} diff --git a/rust/crates/timeline/tests/update_pipeline.rs b/rust/crates/timeline/tests/update_pipeline.rs new file mode 100644 index 00000000..a7f6f339 --- /dev/null +++ b/rust/crates/timeline/tests/update_pipeline.rs @@ -0,0 +1,145 @@ +use scene::{ + BaseTimelineElement, Element, ParamValue, ParamValues, RetimeConfig, SceneTracks, Track, + VideoElement, +}; +use time::MediaTime; +use timeline::update_pipeline::{ElementPatch, ElementUpdateContext, apply_element_update}; + +fn build_video_element() -> Element { + Element::Video(VideoElement { + base: BaseTimelineElement { + id: "video-1".to_string(), + name: "Video 1".to_string(), + start_time: MediaTime::ZERO, + duration: MediaTime::from_ticks(10), + trim_start: MediaTime::ZERO, + trim_end: MediaTime::ZERO, + source_duration: None, + animations: None, + params: ParamValues::from([ + ("transform.positionX".to_string(), ParamValue::Number(0.0)), + ("transform.positionY".to_string(), ParamValue::Number(0.0)), + ("transform.scaleX".to_string(), ParamValue::Number(1.0)), + ("transform.scaleY".to_string(), ParamValue::Number(1.0)), + ("transform.rotate".to_string(), ParamValue::Number(0.0)), + ("opacity".to_string(), ParamValue::Number(1.0)), + ]), + }, + media_id: "media-1".to_string(), + is_source_audio_enabled: None, + hidden: None, + retime: None, + effects: None, + masks: None, + }) +} + +fn build_tracks(element: Element) -> SceneTracks { + SceneTracks { + overlay: Vec::new(), + main: Track::Video { + id: "main-track".to_string(), + name: "Main".to_string(), + elements: vec![element], + muted: false, + hidden: false, + }, + audio: Vec::new(), + } +} + +#[test] +fn rounds_retimed_durations_back_to_integer_media_time() { + let element = build_video_element(); + let tracks = build_tracks(element.clone()); + + let updated = apply_element_update( + &element, + &ElementPatch { + retime: Some(Some(RetimeConfig { + rate: 1.5, + maintain_pitch: None, + })), + ..ElementPatch::default() + }, + &ElementUpdateContext { + tracks: &tracks, + track_id: tracks.main.id(), + }, + ); + + assert_eq!(updated.base().duration, MediaTime::from_ticks(7)); + assert_eq!(timeline::element_retime(&updated).unwrap().rate, 1.5); +} + +#[test] +fn start_time_on_main_track_snaps_to_zero_before_the_earliest_element() { + let earliest = { + let mut element = build_video_element(); + element.base_mut().id = "video-2".to_string(); + element.base_mut().start_time = MediaTime::from_ticks(100); + element + }; + let element = build_video_element(); + let tracks = build_tracks(earliest); + let tracks = SceneTracks { + main: Track::Video { + id: "main-track".to_string(), + name: "Main".to_string(), + elements: vec![tracks.main.elements()[0].clone(), element.clone()], + muted: false, + hidden: false, + }, + ..tracks + }; + + let updated = apply_element_update( + &element, + &ElementPatch { + start_time: Some(MediaTime::from_ticks(50)), + ..ElementPatch::default() + }, + &ElementUpdateContext { + tracks: &tracks, + track_id: "main-track", + }, + ); + + assert_eq!(updated.base().start_time, MediaTime::ZERO); +} + +#[test] +fn start_time_on_overlay_track_only_clamps_negative_values() { + let element = build_video_element(); + let tracks = SceneTracks { + overlay: vec![Track::Video { + id: "overlay-track".to_string(), + name: "Overlay".to_string(), + elements: vec![element.clone()], + muted: false, + hidden: false, + }], + main: Track::Video { + id: "main-track".to_string(), + name: "Main".to_string(), + elements: Vec::new(), + muted: false, + hidden: false, + }, + audio: Vec::new(), + }; + + let updated = apply_element_update( + &element, + &ElementPatch { + start_time: Some(MediaTime::from_ticks(-20)), + ..ElementPatch::default() + }, + &ElementUpdateContext { + tracks: &tracks, + track_id: "overlay-track", + }, + ); + + assert_eq!(updated.base().start_time, MediaTime::ZERO); +} |
