summaryrefslogtreecommitdiff
path: root/rust/crates/timeline
diff options
context:
space:
mode:
Diffstat (limited to 'rust/crates/timeline')
-rw-r--r--rust/crates/timeline/src/placement/apply.rs14
-rw-r--r--rust/crates/timeline/src/placement/mod.rs8
-rw-r--r--rust/crates/timeline/tests/compute_resize.rs375
-rw-r--r--rust/crates/timeline/tests/placement_resolve.rs624
-rw-r--r--rust/crates/timeline/tests/retime.rs111
-rw-r--r--rust/crates/timeline/tests/split.rs187
-rw-r--r--rust/crates/timeline/tests/update_pipeline.rs145
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);
+}