diff options
Diffstat (limited to 'rust/crates/timeline/src')
23 files changed, 2486 insertions, 0 deletions
diff --git a/rust/crates/timeline/src/audio_separation.rs b/rust/crates/timeline/src/audio_separation.rs new file mode 100644 index 00000000..67494380 --- /dev/null +++ b/rust/crates/timeline/src/audio_separation.rs @@ -0,0 +1,97 @@ +use scene::{Element, ElementAnimations, ParamValue, ParamValues, UploadAudioElement, VideoElement}; + +use crate::defaults::DEFAULT_VOLUME; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct MediaAudioState { + pub has_audio: Option<bool>, +} + +pub fn is_source_audio_enabled(element: &VideoElement) -> bool { + element.is_source_audio_enabled != Some(false) +} + +pub fn is_source_audio_separated(element: &VideoElement) -> bool { + !is_source_audio_enabled(element) +} + +pub fn can_extract_source_audio(element: &Element, media_asset: Option<&MediaAudioState>) -> bool { + let Element::Video(video) = element else { + return false; + }; + is_source_audio_enabled(video) && media_asset.is_some_and(|media| media.has_audio != Some(false)) +} + +pub fn can_recover_source_audio(element: &Element) -> bool { + let Element::Video(video) = element else { + return false; + }; + is_source_audio_separated(video) +} + +pub fn can_toggle_source_audio(element: &Element, media_asset: Option<&MediaAudioState>) -> bool { + can_recover_source_audio(element) || can_extract_source_audio(element, media_asset) +} + +pub fn does_element_have_enabled_audio( + element: &Element, + media_asset: Option<&MediaAudioState>, +) -> bool { + match element { + Element::Audio(_) => true, + Element::Video(video) => { + media_asset.is_some_and(|media| media.has_audio != Some(false)) + && is_source_audio_enabled(video) + } + _ => false, + } +} + +pub fn build_separated_audio_element( + source_element: &VideoElement, + id: impl Into<String>, +) -> UploadAudioElement { + let source_base = &source_element.base; + + let mut params = ParamValues::new(); + let volume = match source_base.params.get("volume") { + Some(ParamValue::Number(volume)) => *volume, + _ => DEFAULT_VOLUME, + }; + params.insert("volume".to_string(), ParamValue::Number(volume)); + params.insert( + "muted".to_string(), + ParamValue::Bool(source_base.params.get("muted") == Some(&ParamValue::Bool(true))), + ); + + UploadAudioElement { + base: scene::BaseTimelineElement { + id: id.into(), + name: source_base.name.clone(), + duration: source_base.duration, + start_time: source_base.start_time, + trim_start: source_base.trim_start, + trim_end: source_base.trim_end, + source_duration: source_base.source_duration, + animations: clone_volume_animations(source_base.animations.as_ref()), + params, + }, + retime: source_element.retime, + media_id: source_element.media_id.clone(), + } +} + +pub fn get_source_audio_action_label(element: &VideoElement) -> &'static str { + if is_source_audio_separated(element) { + "Recover audio" + } else { + "Extract audio" + } +} + +fn clone_volume_animations(animations: Option<&ElementAnimations>) -> Option<ElementAnimations> { + let volume_data = animations?.get("volume")?; + let mut volume_only = ElementAnimations::new(); + volume_only.insert("volume".to_string(), volume_data.clone()); + animation::clone_animations(Some(&volume_only), true) +} diff --git a/rust/crates/timeline/src/creation.rs b/rust/crates/timeline/src/creation.rs new file mode 100644 index 00000000..a399d3d7 --- /dev/null +++ b/rust/crates/timeline/src/creation.rs @@ -0,0 +1,11 @@ +use time::{MediaTime, TICKS_PER_SECOND}; + +pub const DEFAULT_NEW_ELEMENT_DURATION: MediaTime = + MediaTime::from_ticks(5 * TICKS_PER_SECOND); + +pub fn to_element_duration_ticks(seconds: Option<f64>) -> Option<MediaTime> { + match seconds { + None => Some(DEFAULT_NEW_ELEMENT_DURATION), + Some(seconds) => MediaTime::from_seconds_f64(seconds), + } +} diff --git a/rust/crates/timeline/src/defaults.rs b/rust/crates/timeline/src/defaults.rs new file mode 100644 index 00000000..504bab92 --- /dev/null +++ b/rust/crates/timeline/src/defaults.rs @@ -0,0 +1,130 @@ +use scene::{ParamValue, ParamValues, TextElement}; +use time::MediaTime; + +use crate::creation::DEFAULT_NEW_ELEMENT_DURATION; + +pub const DEFAULT_OPACITY: f64 = 1.0; +pub const DEFAULT_BLEND_MODE: &str = "normal"; +pub const DEFAULT_VOLUME: f64 = 0.0; +pub const DEFAULT_TRANSFORM_SCALE: f64 = 1.0; +pub const DEFAULT_TRANSFORM_POSITION: f64 = 0.0; +pub const DEFAULT_TRANSFORM_ROTATE: f64 = 0.0; + +pub const DEFAULT_TEXT_LETTER_SPACING: f64 = 0.0; +pub const DEFAULT_TEXT_LINE_HEIGHT: f64 = 1.2; + +#[derive(Clone, Debug, PartialEq)] +pub struct DefaultTextBackground { + pub enabled: bool, + pub color: String, + pub corner_radius: f64, + pub padding_x: f64, + pub padding_y: f64, + pub offset_x: f64, + pub offset_y: f64, +} + +pub fn default_text_background() -> DefaultTextBackground { + DefaultTextBackground { + enabled: false, + color: "#000000".to_string(), + corner_radius: 0.0, + padding_x: 30.0, + padding_y: 42.0, + offset_x: 0.0, + offset_y: 0.0, + } +} + +pub fn default_text_params() -> ParamValues { + let background = default_text_background(); + ParamValues::from([ + ("content".to_string(), ParamValue::String("Default text".to_string())), + ("fontSize".to_string(), ParamValue::Number(15.0)), + ("fontFamily".to_string(), ParamValue::String("Arial".to_string())), + ("color".to_string(), ParamValue::String("#ffffff".to_string())), + ("textAlign".to_string(), ParamValue::String("center".to_string())), + ("fontWeight".to_string(), ParamValue::String("normal".to_string())), + ("fontStyle".to_string(), ParamValue::String("normal".to_string())), + ("textDecoration".to_string(), ParamValue::String("none".to_string())), + ( + "letterSpacing".to_string(), + ParamValue::Number(DEFAULT_TEXT_LETTER_SPACING), + ), + ( + "lineHeight".to_string(), + ParamValue::Number(DEFAULT_TEXT_LINE_HEIGHT), + ), + ( + "background.enabled".to_string(), + ParamValue::Bool(background.enabled), + ), + ( + "background.color".to_string(), + ParamValue::String(background.color), + ), + ( + "background.cornerRadius".to_string(), + ParamValue::Number(background.corner_radius), + ), + ( + "background.paddingX".to_string(), + ParamValue::Number(background.padding_x), + ), + ( + "background.paddingY".to_string(), + ParamValue::Number(background.padding_y), + ), + ( + "background.offsetX".to_string(), + ParamValue::Number(background.offset_x), + ), + ( + "background.offsetY".to_string(), + ParamValue::Number(background.offset_y), + ), + ( + "transform.positionX".to_string(), + ParamValue::Number(DEFAULT_TRANSFORM_POSITION), + ), + ( + "transform.positionY".to_string(), + ParamValue::Number(DEFAULT_TRANSFORM_POSITION), + ), + ( + "transform.scaleX".to_string(), + ParamValue::Number(DEFAULT_TRANSFORM_SCALE), + ), + ( + "transform.scaleY".to_string(), + ParamValue::Number(DEFAULT_TRANSFORM_SCALE), + ), + ( + "transform.rotate".to_string(), + ParamValue::Number(DEFAULT_TRANSFORM_ROTATE), + ), + ("opacity".to_string(), ParamValue::Number(DEFAULT_OPACITY)), + ( + "blendMode".to_string(), + ParamValue::String(DEFAULT_BLEND_MODE.to_string()), + ), + ]) +} + +pub fn default_text_element(id: impl Into<String>) -> TextElement { + TextElement { + base: scene::BaseTimelineElement { + id: id.into(), + name: "Text".to_string(), + duration: DEFAULT_NEW_ELEMENT_DURATION, + start_time: MediaTime::ZERO, + trim_start: MediaTime::ZERO, + trim_end: MediaTime::ZERO, + source_duration: None, + animations: None, + params: default_text_params(), + }, + hidden: None, + effects: None, + } +} diff --git a/rust/crates/timeline/src/drag_utils.rs b/rust/crates/timeline/src/drag_utils.rs new file mode 100644 index 00000000..6dcc6c6f --- /dev/null +++ b/rust/crates/timeline/src/drag_utils.rs @@ -0,0 +1,14 @@ +use time::{MediaTime, TICKS_PER_SECOND}; + +use crate::snap::BASE_TIMELINE_PIXELS_PER_SECOND; + +pub fn get_mouse_time_from_client_x( + client_x: f64, + container_left: f64, + zoom_level: f64, + scroll_left: f64, +) -> MediaTime { + let mouse_x = client_x - container_left + scroll_left; + let seconds = (mouse_x / (BASE_TIMELINE_PIXELS_PER_SECOND * zoom_level)).max(0.0); + MediaTime::from_ticks((seconds * TICKS_PER_SECOND as f64).round() as i64) +} diff --git a/rust/crates/timeline/src/lib.rs b/rust/crates/timeline/src/lib.rs new file mode 100644 index 00000000..e724b5bc --- /dev/null +++ b/rust/crates/timeline/src/lib.rs @@ -0,0 +1,96 @@ +use scene::{AudioElement, Element, RetimeConfig, SceneTracks}; +use time::MediaTime; + +pub mod audio_separation; +pub mod creation; +pub mod defaults; +pub mod drag_utils; +pub mod placement; +pub mod resize; +pub mod retime; +pub mod ripple; +pub mod scenes; +pub mod snap; +pub mod split; +pub mod track_capabilities; +pub mod track_element_update; +pub mod types; +pub mod update_pipeline; + +pub use audio_separation::*; +pub use creation::*; +pub use track_capabilities::*; +pub use track_element_update::*; +pub use types::{ElementType, TrackType}; + +pub fn calculate_total_duration(tracks: &SceneTracks) -> MediaTime { + placement::ordered_tracks(tracks) + .iter() + .flat_map(|track| track.elements().iter()) + .map(|element| element.base().start_time + element.base().duration) + .fold(MediaTime::ZERO, MediaTime::max) +} + +pub fn is_visual_element(element: &Element) -> bool { + matches!( + element, + Element::Video(_) + | Element::Image(_) + | Element::Text(_) + | Element::Sticker(_) + | Element::Graphic(_) + ) +} + +pub fn is_maskable_element(element: &Element) -> bool { + matches!(element, Element::Video(_) | Element::Image(_) | Element::Graphic(_)) +} + +pub fn is_retimable_element(element: &Element) -> bool { + matches!(element, Element::Video(_) | Element::Audio(_)) +} + +pub fn can_element_have_audio(element: &Element) -> bool { + matches!(element, Element::Audio(_) | Element::Video(_)) +} + +pub fn can_element_be_hidden(element: &Element) -> bool { + is_visual_element(element) +} + +pub fn has_element_effects(element: &Element) -> bool { + let effects = match element { + Element::Video(video) => video.effects.as_ref(), + Element::Image(image) => image.effects.as_ref(), + Element::Text(text) => text.effects.as_ref(), + Element::Sticker(sticker) => sticker.effects.as_ref(), + Element::Graphic(graphic) => graphic.effects.as_ref(), + _ => None, + }; + effects.is_some_and(|effects| !effects.is_empty()) +} + +pub fn has_media_id(element: &Element) -> bool { + matches!( + element, + Element::Audio(AudioElement::Upload(_)) | Element::Video(_) | Element::Image(_) + ) +} + +pub fn element_retime(element: &Element) -> Option<RetimeConfig> { + match element { + Element::Video(video) => video.retime, + Element::Audio(AudioElement::Upload(audio)) => audio.retime, + Element::Audio(AudioElement::Library(audio)) => audio.retime, + _ => None, + } +} + +pub fn set_element_retime(element: &mut Element, retime: Option<RetimeConfig>) { + match element { + Element::Video(video) => video.retime = retime, + Element::Audio(AudioElement::Upload(audio)) => audio.retime = retime, + Element::Audio(AudioElement::Library(audio)) => audio.retime = retime, + _ => {} + } +} diff --git a/rust/crates/timeline/src/placement/apply.rs b/rust/crates/timeline/src/placement/apply.rs new file mode 100644 index 00000000..4e044a68 --- /dev/null +++ b/rust/crates/timeline/src/placement/apply.rs @@ -0,0 +1,101 @@ +use scene::{Element, SceneTracks, Track}; + +use super::track_factory::build_empty_track; +use super::{PlacementResult, ordered_tracks}; +use crate::track_element_update::update_track_in_scene_tracks; +use crate::types::TrackType; + +pub struct PlacementApplied { + pub updated_tracks: SceneTracks, + pub target_track_id: String, +} + +pub struct ApplyPlacementParams { + pub tracks: SceneTracks, + pub placement_result: PlacementResult, + pub elements: Vec<Element>, + pub new_track_id: String, + pub new_track_insert_index_override: Option<usize>, +} + +pub fn apply_placement(params: &ApplyPlacementParams) -> Option<PlacementApplied> { + let tracks = ¶ms.tracks; + match ¶ms.placement_result { + PlacementResult::ExistingTrack { track_index, .. } => { + let ordered = ordered_tracks(tracks); + let target_track = ordered.get(*track_index)?; + + let updated_tracks = + update_track_in_scene_tracks(tracks, target_track.id(), |track| { + let mut next = track.clone(); + next.elements_mut().extend(params.elements.iter().cloned()); + next + }); + + Some(PlacementApplied { + updated_tracks, + target_track_id: target_track.id().to_string(), + }) + } + PlacementResult::NewTrack { + track_type, + insert_index, + .. + } => { + let new_track_id = params.new_track_id.clone(); + 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 updated_tracks = if *track_type == TrackType::Audio { + SceneTracks { + audio: insert_into_audio_tracks(tracks, insert_index, new_track), + ..tracks.clone() + } + } else { + SceneTracks { + overlay: insert_into_overlay_tracks(tracks, insert_index, new_track), + ..tracks.clone() + } + }; + + Some(PlacementApplied { + updated_tracks, + target_track_id: new_track_id, + }) + } + } +} + +fn insert_into_overlay_tracks( + tracks: &SceneTracks, + insert_index: usize, + track: Track, +) -> Vec<Track> { + let normalized = insert_index.min(tracks.overlay.len()); + let mut next = tracks.overlay.clone(); + next.insert(normalized, track); + next +} + +fn insert_into_audio_tracks(tracks: &SceneTracks, insert_index: usize, track: Track) -> Vec<Track> { + let audio_index = insert_index + .saturating_sub(tracks.overlay.len() + 1) + .min(tracks.audio.len()); + let mut next = tracks.audio.clone(); + next.insert(audio_index, track); + 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/compatibility.rs b/rust/crates/timeline/src/placement/compatibility.rs new file mode 100644 index 00000000..9512af49 --- /dev/null +++ b/rust/crates/timeline/src/placement/compatibility.rs @@ -0,0 +1,43 @@ +use crate::types::{ElementType, TrackType}; + +pub fn get_track_type_for_element_type(element_type: ElementType) -> TrackType { + match element_type { + ElementType::Audio => TrackType::Audio, + ElementType::Text => TrackType::Text, + ElementType::Sticker | ElementType::Graphic => TrackType::Graphic, + ElementType::Effect => TrackType::Effect, + ElementType::Video | ElementType::Image => TrackType::Video, + } +} + +pub fn can_element_go_on_track(element_type: ElementType, track_type: TrackType) -> bool { + get_track_type_for_element_type(element_type) == track_type +} + +pub struct TrackCompatibility { + pub is_valid: bool, + pub error_message: Option<String>, +} + +pub fn validate_element_track_compatibility( + element_type: ElementType, + track_type: TrackType, +) -> TrackCompatibility { + let is_valid = can_element_go_on_track(element_type, track_type); + + if !is_valid { + return TrackCompatibility { + is_valid: false, + error_message: Some(format!( + "{} elements cannot be placed on {} tracks", + element_type.as_str(), + track_type.as_str(), + )), + }; + } + + TrackCompatibility { + is_valid: true, + error_message: None, + } +} diff --git a/rust/crates/timeline/src/placement/insert_index.rs b/rust/crates/timeline/src/placement/insert_index.rs new file mode 100644 index 00000000..b0feaec6 --- /dev/null +++ b/rust/crates/timeline/src/placement/insert_index.rs @@ -0,0 +1,81 @@ +use scene::SceneTracks; + +use super::InsertDirection; +use crate::types::TrackType; + +pub fn get_default_insert_index_for_track(tracks: &SceneTracks, track_type: TrackType) -> usize { + if track_type == TrackType::Audio { + return tracks.overlay.len() + 1 + tracks.audio.len(); + } + + if track_type == TrackType::Effect { + return 0; + } + + tracks.overlay.len() +} + +pub fn get_highest_insert_index_for_track(tracks: &SceneTracks, track_type: TrackType) -> usize { + if track_type == TrackType::Audio { + return tracks.overlay.len() + 1; + } + + 0 +} + +pub struct PreferredNewTrackPlacement { + pub insert_index: usize, + pub insert_position: Option<InsertDirection>, +} + +pub fn resolve_preferred_new_track_placement( + tracks: &SceneTracks, + track_type: TrackType, + preferred_index: usize, + direction: InsertDirection, +) -> PreferredNewTrackPlacement { + let track_count = tracks.overlay.len() + 1 + tracks.audio.len(); + if track_count == 0 { + return PreferredNewTrackPlacement { + insert_index: 0, + insert_position: (track_type == TrackType::Audio) + .then_some(InsertDirection::Below), + }; + } + + let safe_preferred_index = preferred_index.min(track_count - 1); + let main_track_index = tracks.overlay.len(); + + if track_type == TrackType::Audio { + if safe_preferred_index <= main_track_index { + return PreferredNewTrackPlacement { + insert_index: main_track_index + 1, + insert_position: Some(InsertDirection::Below), + }; + } + + return PreferredNewTrackPlacement { + insert_index: match direction { + InsertDirection::Above => safe_preferred_index, + InsertDirection::Below => safe_preferred_index + 1, + }, + insert_position: Some(direction), + }; + } + + let insert_index = match direction { + InsertDirection::Above => safe_preferred_index, + InsertDirection::Below => safe_preferred_index + 1, + }; + if insert_index > main_track_index { + return PreferredNewTrackPlacement { + insert_index: main_track_index, + insert_position: Some(InsertDirection::Above), + }; + } + + PreferredNewTrackPlacement { + insert_index, + insert_position: Some(direction), + } +} diff --git a/rust/crates/timeline/src/placement/main_track.rs b/rust/crates/timeline/src/placement/main_track.rs new file mode 100644 index 00000000..01d91d4f --- /dev/null +++ b/rust/crates/timeline/src/placement/main_track.rs @@ -0,0 +1,44 @@ +use scene::{Element, SceneTracks, Track}; +use time::MediaTime; + +pub const MAIN_TRACK_NAME: &str = "Main Track"; + +pub fn get_earliest_main_track_element<'a>( + main_track: &'a Track, + exclude_element_id: Option<&str>, +) -> Option<&'a Element> { + main_track + .elements() + .iter() + .filter(|element| exclude_element_id != Some(element.base().id.as_str())) + .reduce(|earliest, element| { + if element.base().start_time < earliest.base().start_time { + element + } else { + earliest + } + }) +} + +pub fn enforce_main_track_start( + tracks: &SceneTracks, + target_track_id: &str, + requested_start_time: MediaTime, + exclude_element_id: Option<&str>, +) -> MediaTime { + if tracks.main.id() != target_track_id { + return requested_start_time; + } + + let Some(earliest_element) = + get_earliest_main_track_element(&tracks.main, exclude_element_id) + else { + return MediaTime::ZERO; + }; + + if requested_start_time <= earliest_element.base().start_time { + return MediaTime::ZERO; + } + + requested_start_time +} diff --git a/rust/crates/timeline/src/placement/mod.rs b/rust/crates/timeline/src/placement/mod.rs new file mode 100644 index 00000000..8ad5710e --- /dev/null +++ b/rust/crates/timeline/src/placement/mod.rs @@ -0,0 +1,104 @@ +use scene::{Element, SceneTracks, Track}; +use time::MediaTime; + +use crate::types::{ElementType, TrackType}; + +pub mod apply; +pub mod compatibility; +pub mod insert_index; +pub mod main_track; +pub mod overlap; +pub mod resolve; +pub mod track_factory; + +pub use apply::{PlacementApplied, apply_placement}; +pub use compatibility::{ + can_element_go_on_track, get_track_type_for_element_type, validate_element_track_compatibility, +}; +pub use insert_index::{ + get_default_insert_index_for_track, get_highest_insert_index_for_track, + resolve_preferred_new_track_placement, +}; +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 track_factory::build_empty_track; + +#[derive(Clone, Debug, PartialEq)] +pub struct PlacementTimeSpan { + pub start_time: MediaTime, + pub duration: MediaTime, + pub exclude_element_id: Option<String>, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum PlacementSubject { + ElementType(ElementType), + TrackType(TrackType), +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum InsertDirection { + Above, + Below, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum VerticalDragDirection { + Up, + Down, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum NewTrackPosition { + Highest, + Default, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum PlacementStrategy { + Explicit { + track_id: String, + }, + FirstAvailable, + PreferIndex { + track_index: usize, + hover_direction: InsertDirection, + vertical_drag_direction: Option<VerticalDragDirection>, + create_new_track_only: bool, + }, + AboveSource { + source_track_index: usize, + }, + AlwaysNew { + position: NewTrackPosition, + }, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum PlacementResult { + ExistingTrack { + track_id: String, + track_index: usize, + track_type: TrackType, + adjusted_start_time: Option<MediaTime>, + }, + NewTrack { + track_type: TrackType, + insert_index: usize, + insert_position: Option<InsertDirection>, + }, +} + +pub fn ordered_tracks(tracks: &SceneTracks) -> Vec<&Track> { + tracks + .overlay + .iter() + .chain(std::iter::once(&tracks.main)) + .chain(tracks.audio.iter()) + .collect() +} + +pub fn track_elements(track: &Track) -> &[Element] { + track.elements() +} diff --git a/rust/crates/timeline/src/placement/overlap.rs b/rust/crates/timeline/src/placement/overlap.rs new file mode 100644 index 00000000..98055e9a --- /dev/null +++ b/rust/crates/timeline/src/placement/overlap.rs @@ -0,0 +1,34 @@ +use scene::Element; +use time::MediaTime; + +use super::PlacementTimeSpan; + +fn would_element_overlap( + elements: &[Element], + start_time: MediaTime, + end_time: MediaTime, + exclude_element_id: Option<&str>, +) -> bool { + elements.iter().any(|element| { + if exclude_element_id == Some(element.base().id.as_str()) { + return false; + } + + let element_end = element.base().start_time + element.base().duration; + start_time < element_end && end_time > element.base().start_time + }) +} + +pub fn can_place_time_spans_on_track( + elements: &[Element], + time_spans: &[PlacementTimeSpan], +) -> bool { + time_spans.iter().all(|span| { + !would_element_overlap( + elements, + span.start_time, + span.start_time + span.duration, + span.exclude_element_id.as_deref(), + ) + }) +} diff --git a/rust/crates/timeline/src/placement/resolve.rs b/rust/crates/timeline/src/placement/resolve.rs new file mode 100644 index 00000000..e95bc93c --- /dev/null +++ b/rust/crates/timeline/src/placement/resolve.rs @@ -0,0 +1,223 @@ +use scene::{SceneTracks, Track}; +use time::MediaTime; + +use super::compatibility::get_track_type_for_element_type; +use super::insert_index::{ + get_default_insert_index_for_track, get_highest_insert_index_for_track, + resolve_preferred_new_track_placement, +}; +use super::main_track::enforce_main_track_start; +use super::overlap::can_place_time_spans_on_track; +use super::{ + InsertDirection, NewTrackPosition, PlacementResult, PlacementStrategy, PlacementSubject, + PlacementTimeSpan, VerticalDragDirection, ordered_tracks, +}; +use crate::types::TrackType; + +pub struct ResolveTrackPlacementParams { + pub tracks: SceneTracks, + pub subject: PlacementSubject, + pub time_spans: Vec<PlacementTimeSpan>, + pub strategy: PlacementStrategy, +} + +fn build_existing_track_result( + track: &Track, + track_index: usize, + tracks: &SceneTracks, + time_spans: &[PlacementTimeSpan], +) -> PlacementResult { + let first_span = time_spans.first(); + let requested_start_time = first_span + .map(|span| span.start_time) + .unwrap_or(MediaTime::ZERO); + let adjusted_start_time = enforce_main_track_start( + tracks, + track.id(), + requested_start_time, + first_span.and_then(|span| span.exclude_element_id.as_deref()), + ); + + PlacementResult::ExistingTrack { + track_id: track.id().to_string(), + track_index, + track_type: TrackType::of_track(track), + adjusted_start_time: (adjusted_start_time != requested_start_time) + .then_some(adjusted_start_time), + } +} + +fn build_new_track_result( + track_type: TrackType, + insert_index: usize, + insert_position: Option<InsertDirection>, +) -> PlacementResult { + PlacementResult::NewTrack { + track_type, + insert_index, + insert_position, + } +} + +fn find_first_available_track_index( + tracks: &[&Track], + track_type: TrackType, + time_spans: &[PlacementTimeSpan], +) -> Option<usize> { + tracks.iter().position(|track| { + TrackType::of_track(track) == track_type + && can_place_time_spans_on_track(track.elements(), time_spans) + }) +} + +fn resolve_always_new_track( + tracks: &SceneTracks, + track_type: TrackType, + position: NewTrackPosition, +) -> PlacementResult { + let insert_index = match position { + NewTrackPosition::Highest => get_highest_insert_index_for_track(tracks, track_type), + NewTrackPosition::Default => get_default_insert_index_for_track(tracks, track_type), + }; + + build_new_track_result(track_type, insert_index, None) +} + +fn get_insert_direction( + hover_direction: InsertDirection, + vertical_drag_direction: Option<VerticalDragDirection>, +) -> InsertDirection { + match vertical_drag_direction { + Some(VerticalDragDirection::Up) => InsertDirection::Above, + Some(VerticalDragDirection::Down) => InsertDirection::Below, + None => hover_direction, + } +} + +pub fn resolve_track_placement(params: &ResolveTrackPlacementParams) -> Option<PlacementResult> { + let tracks = ¶ms.tracks; + let ordered = ordered_tracks(tracks); + let track_type = match params.subject { + PlacementSubject::TrackType(track_type) => track_type, + PlacementSubject::ElementType(element_type) => { + get_track_type_for_element_type(element_type) + } + }; + let time_spans = ¶ms.time_spans; + + match ¶ms.strategy { + PlacementStrategy::Explicit { track_id } => { + let track_index = ordered.iter().position(|track| track.id() == track_id)?; + let track = ordered[track_index]; + if TrackType::of_track(track) != track_type { + return None; + } + + Some(build_existing_track_result( + track, + track_index, + tracks, + time_spans, + )) + } + PlacementStrategy::FirstAvailable => { + if let Some(existing_index) = + find_first_available_track_index(&ordered, track_type, time_spans) + { + return Some(build_existing_track_result( + ordered[existing_index], + existing_index, + tracks, + time_spans, + )); + } + + Some(resolve_always_new_track( + tracks, + track_type, + NewTrackPosition::Highest, + )) + } + PlacementStrategy::PreferIndex { + track_index, + hover_direction, + vertical_drag_direction, + create_new_track_only, + } => { + let preferred_track = ordered.get(*track_index); + let is_preferred_compatible = preferred_track + .is_some_and(|track| TrackType::of_track(track) == track_type); + let can_use_existing = !create_new_track_only + && is_preferred_compatible + && preferred_track.is_some_and(|track| { + can_place_time_spans_on_track(track.elements(), time_spans) + }); + if can_use_existing { + return Some(build_existing_track_result( + preferred_track?, + *track_index, + tracks, + time_spans, + )); + } + + let direction = get_insert_direction( + *hover_direction, + if is_preferred_compatible { + None + } else { + *vertical_drag_direction + }, + ); + let placement = resolve_preferred_new_track_placement( + tracks, + track_type, + *track_index, + direction, + ); + Some(build_new_track_result( + track_type, + placement.insert_index, + placement.insert_position, + )) + } + PlacementStrategy::AboveSource { source_track_index } => { + let above_track = source_track_index + .checked_sub(1) + .and_then(|above_index| ordered.get(above_index)); + if let Some(above_track) = above_track + && TrackType::of_track(above_track) == track_type + && can_place_time_spans_on_track(above_track.elements(), time_spans) + { + return Some(build_existing_track_result( + above_track, + source_track_index - 1, + tracks, + time_spans, + )); + } + + if let Some(first_available_index) = + find_first_available_track_index(&ordered, track_type, time_spans) + { + return Some(build_existing_track_result( + ordered[first_available_index], + first_available_index, + tracks, + time_spans, + )); + } + + Some(build_new_track_result( + track_type, + get_highest_insert_index_for_track(tracks, track_type), + None, + )) + } + PlacementStrategy::AlwaysNew { position } => Some(resolve_always_new_track( + tracks, + track_type, + *position, + )), + } +} diff --git a/rust/crates/timeline/src/placement/track_factory.rs b/rust/crates/timeline/src/placement/track_factory.rs new file mode 100644 index 00000000..0b86ecf2 --- /dev/null +++ b/rust/crates/timeline/src/placement/track_factory.rs @@ -0,0 +1,62 @@ +use scene::Track; + +use crate::types::TrackType; + +pub const DEFAULT_VIDEO_TRACK_NAME: &str = "Video track"; +pub const DEFAULT_TEXT_TRACK_NAME: &str = "Text track"; +pub const DEFAULT_AUDIO_TRACK_NAME: &str = "Audio track"; +pub const DEFAULT_GRAPHIC_TRACK_NAME: &str = "Graphic track"; +pub const DEFAULT_EFFECT_TRACK_NAME: &str = "Effect track"; + +pub fn default_track_name(track_type: TrackType) -> &'static str { + match track_type { + TrackType::Video => DEFAULT_VIDEO_TRACK_NAME, + TrackType::Text => DEFAULT_TEXT_TRACK_NAME, + TrackType::Audio => DEFAULT_AUDIO_TRACK_NAME, + TrackType::Graphic => DEFAULT_GRAPHIC_TRACK_NAME, + TrackType::Effect => DEFAULT_EFFECT_TRACK_NAME, + } +} + +pub fn build_empty_track( + id: impl Into<String>, + track_type: TrackType, + name: Option<String>, +) -> Track { + let id = id.into(); + let name = name.unwrap_or_else(|| default_track_name(track_type).to_string()); + + match track_type { + TrackType::Video => Track::Video { + id, + name, + elements: Vec::new(), + muted: false, + hidden: false, + }, + TrackType::Text => Track::Text { + id, + name, + elements: Vec::new(), + hidden: false, + }, + TrackType::Audio => Track::Audio { + id, + name, + elements: Vec::new(), + muted: false, + }, + TrackType::Graphic => Track::Graphic { + id, + name, + elements: Vec::new(), + hidden: false, + }, + TrackType::Effect => Track::Effect { + id, + name, + elements: Vec::new(), + hidden: false, + }, + } +} diff --git a/rust/crates/timeline/src/resize.rs b/rust/crates/timeline/src/resize.rs new file mode 100644 index 00000000..51478a09 --- /dev/null +++ b/rust/crates/timeline/src/resize.rs @@ -0,0 +1,401 @@ +use scene::{ElementRef, RetimeConfig, SceneTracks}; +use time::{FrameRate, MediaTime, TICKS_PER_SECOND}; + +use crate::retime::{ + get_source_span_at_clip_time, get_timeline_duration_for_source_span, round_media_time, +}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ResizeSide { + Left, + Right, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct GroupResizePushTarget { + pub track_id: String, + pub element_id: String, + pub start_time: MediaTime, + pub duration: MediaTime, + pub trim_start: MediaTime, + pub trim_end: MediaTime, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct GroupResizeMember { + pub track_id: String, + pub element_id: String, + pub start_time: MediaTime, + pub duration: MediaTime, + pub trim_start: MediaTime, + pub trim_end: MediaTime, + pub source_duration: Option<MediaTime>, + pub retime: Option<RetimeConfig>, + pub left_neighbor_bound: Option<MediaTime>, + pub right_neighbor_bound: Option<MediaTime>, + pub right_push_chain: Option<Vec<GroupResizePushTarget>>, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct GroupResizePatch { + pub trim_start: MediaTime, + pub trim_end: MediaTime, + pub start_time: MediaTime, + pub duration: MediaTime, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct GroupResizeUpdate { + pub track_id: String, + pub element_id: String, + pub patch: GroupResizePatch, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct GroupResizeResult { + pub delta_time: MediaTime, + pub updates: Vec<GroupResizeUpdate>, +} + +pub fn compute_group_resize( + members: &[GroupResizeMember], + side: ResizeSide, + delta_time: MediaTime, + fps: FrameRate, +) -> GroupResizeResult { + if members.is_empty() { + return GroupResizeResult { + delta_time: MediaTime::ZERO, + updates: Vec::new(), + }; + } + + let min_duration = MediaTime::from_ticks( + ((TICKS_PER_SECOND as f64 * fps.denominator as f64) / fps.numerator as f64).round() as i64, + ); + let mut minimum_delta_time = get_minimum_allowed_delta_time(&members[0], side, min_duration); + let mut maximum_delta_time = get_maximum_allowed_delta_time(&members[0], side, min_duration); + + for member in &members[1..] { + minimum_delta_time = + minimum_delta_time.max(get_minimum_allowed_delta_time(member, side, min_duration)); + if let Some(member_maximum) = get_maximum_allowed_delta_time(member, side, min_duration) { + maximum_delta_time = Some(match maximum_delta_time { + None => member_maximum, + Some(current) => current.min(member_maximum), + }); + } + } + + let clamped_delta_time = match maximum_delta_time { + None => minimum_delta_time.max(delta_time), + Some(maximum) => delta_time.clamp(minimum_delta_time, maximum), + }; + + let snapped_delta_time = clamped_delta_time + .round_to_frame(fps) + .unwrap_or(clamped_delta_time); + let final_delta_time = match maximum_delta_time { + None => minimum_delta_time.max(snapped_delta_time), + Some(maximum) => snapped_delta_time.clamp(minimum_delta_time, maximum), + }; + + let mut updates: Vec<GroupResizeUpdate> = members + .iter() + .map(|member| build_resize_update(member, side, final_delta_time)) + .collect(); + updates.extend(build_ripple_push_updates(members, side, final_delta_time)); + + GroupResizeResult { + delta_time: final_delta_time, + updates, + } +} + +fn build_ripple_push_updates( + members: &[GroupResizeMember], + side: ResizeSide, + delta_time: MediaTime, +) -> Vec<GroupResizeUpdate> { + if side != ResizeSide::Right || delta_time <= MediaTime::ZERO { + return Vec::new(); + } + + let mut overflow_by_target: Vec<(GroupResizePushTarget, MediaTime)> = Vec::new(); + for member in members { + let (Some(right_neighbor_bound), Some(push_chain)) = + (member.right_neighbor_bound, &member.right_push_chain) + else { + continue; + }; + if member.source_duration.is_some() { + continue; + } + + let neighbor_ceiling = right_neighbor_bound - (member.start_time + member.duration); + let overflow = delta_time - neighbor_ceiling; + if overflow <= MediaTime::ZERO { + continue; + } + for target in push_chain { + match overflow_by_target + .iter_mut() + .find(|(existing, _)| existing.element_id == target.element_id) + { + Some((_, existing_overflow)) => { + if overflow > *existing_overflow { + *existing_overflow = overflow; + } + } + None => overflow_by_target.push((target.clone(), overflow)), + } + } + } + + overflow_by_target + .into_iter() + .map(|(target, overflow)| GroupResizeUpdate { + track_id: target.track_id, + element_id: target.element_id, + patch: GroupResizePatch { + trim_start: target.trim_start, + trim_end: target.trim_end, + start_time: target.start_time + overflow, + duration: target.duration, + }, + }) + .collect() +} + +fn build_resize_update( + member: &GroupResizeMember, + side: ResizeSide, + delta_time: MediaTime, +) -> GroupResizeUpdate { + let source_delta = get_source_delta_for_clip_delta(member, delta_time); + + match side { + ResizeSide::Left => GroupResizeUpdate { + track_id: member.track_id.clone(), + element_id: member.element_id.clone(), + patch: GroupResizePatch { + trim_start: MediaTime::ZERO.max(member.trim_start + source_delta), + trim_end: member.trim_end, + start_time: member.start_time + delta_time, + duration: member.duration - delta_time, + }, + }, + ResizeSide::Right => GroupResizeUpdate { + track_id: member.track_id.clone(), + element_id: member.element_id.clone(), + patch: GroupResizePatch { + trim_start: member.trim_start, + trim_end: MediaTime::ZERO.max(member.trim_end - source_delta), + start_time: member.start_time, + duration: member.duration + delta_time, + }, + }, + } +} + +fn get_minimum_allowed_delta_time( + member: &GroupResizeMember, + side: ResizeSide, + min_duration: MediaTime, +) -> MediaTime { + if side == ResizeSide::Right { + return min_duration - member.duration; + } + + let left_neighbor_floor = match member.left_neighbor_bound { + Some(bound) => bound - member.start_time, + None => MediaTime::ZERO - member.start_time, + }; + if member.source_duration.is_none() { + return left_neighbor_floor; + } + + let maximum_source_extension = get_duration_for_visible_source_span( + member, + get_visible_source_span_for_duration(member, member.duration) + member.trim_start, + ) - member.duration; + left_neighbor_floor.max(MediaTime::ZERO - maximum_source_extension) +} + +fn get_maximum_allowed_delta_time( + member: &GroupResizeMember, + side: ResizeSide, + min_duration: MediaTime, +) -> Option<MediaTime> { + if side == ResizeSide::Left { + return Some(member.duration - min_duration); + } + + let right_neighbor_ceiling = member + .right_neighbor_bound + .map(|bound| bound - (member.start_time + member.duration)); + if member.source_duration.is_none() { + return if member.right_push_chain.is_none() { + right_neighbor_ceiling + } else { + None + }; + } + + let maximum_visible_source_span = get_source_duration(member) - member.trim_start; + let maximum_duration = get_duration_for_visible_source_span(member, maximum_visible_source_span); + let source_duration_ceiling = maximum_duration - member.duration; + Some(match right_neighbor_ceiling { + None => source_duration_ceiling, + Some(ceiling) => ceiling.min(source_duration_ceiling), + }) +} + +fn get_source_delta_for_clip_delta(member: &GroupResizeMember, clip_delta: MediaTime) -> MediaTime { + let Some(retime) = &member.retime else { + return clip_delta; + }; + + let source_delta = if clip_delta >= MediaTime::ZERO { + get_source_span_at_clip_time(clip_delta.as_ticks() as f64, Some(retime)) + } else { + -get_source_span_at_clip_time(clip_delta.as_ticks().unsigned_abs() as f64, Some(retime)) + }; + round_media_time(source_delta) +} + +fn get_visible_source_span_for_duration(member: &GroupResizeMember, duration: MediaTime) -> MediaTime { + let Some(retime) = &member.retime else { + return duration; + }; + + round_media_time(get_source_span_at_clip_time( + duration.as_ticks() as f64, + Some(retime), + )) +} + +fn get_duration_for_visible_source_span( + member: &GroupResizeMember, + source_span: MediaTime, +) -> MediaTime { + let Some(retime) = &member.retime else { + return source_span; + }; + + round_media_time(get_timeline_duration_for_source_span( + source_span.as_ticks() as f64, + Some(retime), + )) +} + +fn get_source_duration(member: &GroupResizeMember) -> MediaTime { + if let Some(source_duration) = member.source_duration { + return source_duration; + } + + member.trim_start + + get_visible_source_span_for_duration(member, member.duration) + + member.trim_end +} + +pub fn build_resize_members( + tracks: &SceneTracks, + selected_elements: &[ElementRef], +) -> Vec<GroupResizeMember> { + let selected_ids: Vec<&str> = selected_elements + .iter() + .map(|reference| reference.element_id.as_str()) + .collect(); + + let mut members = Vec::new(); + for reference in selected_elements { + let Some(track) = crate::track_element_update::find_track_in_scene_tracks( + tracks, + &reference.track_id, + ) else { + continue; + }; + let Some(element) = track + .elements() + .iter() + .find(|element| element.base().id == reference.element_id) + else { + continue; + }; + + let base = element.base(); + let other_elements: Vec<&scene::Element> = track + .elements() + .iter() + .filter(|candidate| !selected_ids.contains(&candidate.base().id.as_str())) + .collect(); + + let left_neighbor_bound = other_elements + .iter() + .filter(|candidate| { + candidate.base().start_time + candidate.base().duration <= base.start_time + }) + .map(|candidate| candidate.base().start_time + candidate.base().duration) + .reduce(MediaTime::max); + + let mut right_elements: Vec<&scene::Element> = other_elements + .iter() + .filter(|candidate| candidate.base().start_time >= base.start_time + base.duration) + .copied() + .collect(); + let right_neighbor_bound = right_elements + .iter() + .map(|candidate| candidate.base().start_time) + .reduce(MediaTime::min); + + let right_push_chain = if base.source_duration.is_none() { + right_elements.sort_by_key(|candidate| candidate.base().start_time); + Some( + right_elements + .iter() + .map(|candidate| GroupResizePushTarget { + track_id: reference.track_id.clone(), + element_id: candidate.base().id.clone(), + start_time: candidate.base().start_time, + duration: candidate.base().duration, + trim_start: candidate.base().trim_start, + trim_end: candidate.base().trim_end, + }) + .collect(), + ) + } else { + None + }; + + members.push(GroupResizeMember { + track_id: reference.track_id.clone(), + element_id: reference.element_id.clone(), + start_time: base.start_time, + duration: base.duration, + trim_start: base.trim_start, + trim_end: base.trim_end, + source_duration: base.source_duration, + retime: crate::element_retime(element), + left_neighbor_bound, + right_neighbor_bound, + right_push_chain, + }); + } + + members +} + +pub fn has_resize_changes(members: &[GroupResizeMember], result: &GroupResizeResult) -> bool { + result.updates.iter().any(|update| { + let member = members + .iter() + .find(|member| member.element_id == update.element_id); + member.is_some_and(|member| { + member.trim_start != update.patch.trim_start + || member.trim_end != update.patch.trim_end + || member.start_time != update.patch.start_time + || member.duration != update.patch.duration + }) + }) +} diff --git a/rust/crates/timeline/src/retime.rs b/rust/crates/timeline/src/retime.rs new file mode 100644 index 00000000..7215f2ec --- /dev/null +++ b/rust/crates/timeline/src/retime.rs @@ -0,0 +1,71 @@ +use scene::RetimeConfig; +use time::MediaTime; + +pub const DEFAULT_RETIME_RATE: f64 = 1.0; +pub const MIN_RETIME_RATE: f64 = 0.01; +pub const MAX_RETIME_RATE: f64 = 5.0; + +pub fn clamp_retime_rate(rate: f64) -> f64 { + if !rate.is_finite() || rate <= 0.0 { + return DEFAULT_RETIME_RATE; + } + + rate.clamp(MIN_RETIME_RATE, MAX_RETIME_RATE) +} + +pub fn can_maintain_pitch(rate: f64) -> bool { + rate.is_finite() && rate > 0.0 +} + +pub fn should_maintain_pitch(rate: f64, maintain_pitch: Option<bool>) -> bool { + maintain_pitch == Some(true) && can_maintain_pitch(rate) +} + +fn safe_rate(retime: Option<&RetimeConfig>) -> f64 { + clamp_retime_rate(retime.map(|retime| retime.rate).unwrap_or(1.0)) +} + +pub fn get_source_time_at_clip_time(clip_time: f64, retime: Option<&RetimeConfig>) -> f64 { + clip_time * safe_rate(retime) +} + +pub fn get_clip_time_at_source_time(source_time: f64, retime: Option<&RetimeConfig>) -> f64 { + source_time / safe_rate(retime) +} + +pub fn get_effective_rate_at(retime: Option<&RetimeConfig>) -> f64 { + safe_rate(retime) +} + +pub fn get_timeline_duration_for_source_span( + source_span: f64, + retime: Option<&RetimeConfig>, +) -> f64 { + if source_span <= 0.0 { + return 0.0; + } + source_span / safe_rate(retime) +} + +pub fn get_source_span_at_clip_time(clip_time: f64, retime: Option<&RetimeConfig>) -> f64 { + get_source_time_at_clip_time(clip_time, retime).max(0.0) +} + +pub fn split_retime_at_clip_time( + retime: Option<&RetimeConfig>, +) -> (Option<RetimeConfig>, Option<RetimeConfig>) { + (retime.copied(), retime.copied()) +} + +pub fn adjust_retime_for_trim_change(retime: Option<&RetimeConfig>) -> Option<RetimeConfig> { + retime.copied() +} + +pub fn round_media_time(time: f64) -> MediaTime { + let rounded_magnitude = time.abs().round(); + if rounded_magnitude == 0.0 { + return MediaTime::ZERO; + } + let magnitude = rounded_magnitude as i64; + MediaTime::from_ticks(if time < 0.0 { -magnitude } else { magnitude }) +} diff --git a/rust/crates/timeline/src/ripple.rs b/rust/crates/timeline/src/ripple.rs new file mode 100644 index 00000000..0ed88fc4 --- /dev/null +++ b/rust/crates/timeline/src/ripple.rs @@ -0,0 +1,265 @@ +use std::collections::{HashMap, HashSet}; + +use scene::{Element, SceneTracks}; +use time::MediaTime; + +#[derive(Clone, Debug, PartialEq)] +pub struct RippleAdjustment { + pub track_id: String, + pub after_time: MediaTime, + pub shift_amount: MediaTime, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +struct Interval { + start_time: MediaTime, + end_time: MediaTime, +} + +#[derive(Clone, Copy, Debug)] +struct ElementSpan { + start_time: MediaTime, + end_time: MediaTime, +} + +pub fn compute_ripple_adjustments( + before_tracks: &SceneTracks, + after_tracks: &SceneTracks, +) -> Vec<RippleAdjustment> { + let before_list = crate::placement::ordered_tracks(before_tracks); + let after_list = crate::placement::ordered_tracks(after_tracks); + let after_tracks_by_id: HashMap<&str, &scene::Track> = after_list + .iter() + .map(|track| (track.id(), *track)) + .collect(); + let all_after_element_ids: HashSet<&str> = after_list + .iter() + .flat_map(|track| track.elements().iter().map(|element| element.base().id.as_str())) + .collect(); + + let mut adjustments = Vec::new(); + for before_track in before_list { + let after_elements = after_tracks_by_id + .get(before_track.id()) + .map(|track| track.elements()) + .unwrap_or(&[]); + adjustments.extend(compute_track_ripple_adjustments( + before_track.id(), + before_track.elements(), + after_elements, + &all_after_element_ids, + )); + } + adjustments +} + +fn compute_track_ripple_adjustments( + track_id: &str, + before_elements: &[Element], + after_elements: &[Element], + all_after_element_ids: &HashSet<&str>, +) -> Vec<RippleAdjustment> { + let before_spans = build_element_span_map(before_elements); + let after_spans = build_element_span_map(after_elements); + let (vacated, joined) = collect_track_intervals(&before_spans, &after_spans, all_after_element_ids); + let freed = subtract_interval_sets(&vacated, &joined); + build_adjustments(track_id, &freed) +} + +fn build_element_span_map(elements: &[Element]) -> HashMap<String, ElementSpan> { + elements + .iter() + .map(|element| { + ( + element.base().id.clone(), + ElementSpan { + start_time: element.base().start_time, + end_time: element.base().start_time + element.base().duration, + }, + ) + }) + .collect() +} + +fn collect_track_intervals( + before_spans: &HashMap<String, ElementSpan>, + after_spans: &HashMap<String, ElementSpan>, + all_after_element_ids: &HashSet<&str>, +) -> (Vec<Interval>, Vec<Interval>) { + let mut vacated = Vec::new(); + let mut joined = Vec::new(); + + for (id, before_span) in before_spans { + match after_spans.get(id) { + None => { + if !all_after_element_ids.contains(id.as_str()) { + push_interval(&mut vacated, before_span.start_time, before_span.end_time); + } + } + Some(after_span) => { + if before_span.end_time > after_span.end_time { + push_interval(&mut vacated, after_span.end_time, before_span.end_time); + } + } + } + } + + for (id, after_span) in after_spans { + if before_spans.contains_key(id) { + continue; + } + push_interval(&mut joined, after_span.start_time, after_span.end_time); + } + + (normalize_intervals(vacated), normalize_intervals(joined)) +} + +fn build_adjustments(track_id: &str, intervals: &[Interval]) -> Vec<RippleAdjustment> { + intervals + .iter() + .filter_map(|interval| { + let shift_amount = interval.end_time - interval.start_time; + if shift_amount <= MediaTime::ZERO { + return None; + } + Some(RippleAdjustment { + track_id: track_id.to_string(), + after_time: interval.end_time, + shift_amount, + }) + }) + .collect() +} + +fn subtract_interval_sets(source: &[Interval], overlapping: &[Interval]) -> Vec<Interval> { + let normalized_source = normalize_intervals(source.to_vec()); + let normalized_overlapping = normalize_intervals(overlapping.to_vec()); + + normalized_source + .iter() + .flat_map(|source_interval| subtract_single_interval(*source_interval, &normalized_overlapping)) + .collect() +} + +fn normalize_intervals(intervals: Vec<Interval>) -> Vec<Interval> { + let mut valid = Vec::new(); + for interval in intervals { + push_interval(&mut valid, interval.start_time, interval.end_time); + } + valid.sort_by_key(|interval| interval.start_time); + + let mut merged: Vec<Interval> = Vec::new(); + for interval in valid { + match merged.last_mut() { + Some(previous) if interval.start_time <= previous.end_time => { + previous.end_time = previous.end_time.max(interval.end_time); + } + _ => merged.push(interval), + } + } + merged +} + +fn subtract_single_interval(source: Interval, overlapping: &[Interval]) -> Vec<Interval> { + let mut remaining = vec![source]; + + for overlapping_interval in overlapping { + let mut next = Vec::new(); + for interval in remaining { + if overlapping_interval.end_time <= interval.start_time + || overlapping_interval.start_time >= interval.end_time + { + next.push(interval); + continue; + } + + push_interval(&mut next, interval.start_time, overlapping_interval.start_time); + push_interval(&mut next, overlapping_interval.end_time, interval.end_time); + } + remaining = next; + + if remaining.is_empty() { + return Vec::new(); + } + } + + remaining +} + +fn push_interval(intervals: &mut Vec<Interval>, start_time: MediaTime, end_time: MediaTime) { + if end_time <= start_time { + return; + } + intervals.push(Interval { start_time, end_time }); +} + +pub fn ripple_shift_elements( + elements: &[Element], + after_time: MediaTime, + shift_amount: MediaTime, +) -> Vec<Element> { + elements + .iter() + .map(|element| { + if element.base().start_time >= after_time { + let mut shifted = element.clone(); + shifted.base_mut().start_time = element.base().start_time - shift_amount; + shifted + } else { + element.clone() + } + }) + .collect() +} + +pub fn apply_ripple_adjustments( + tracks: &SceneTracks, + adjustments: &[RippleAdjustment], +) -> SceneTracks { + if adjustments.is_empty() { + return tracks.clone(); + } + + let mut adjustments_by_track: HashMap<&str, Vec<&RippleAdjustment>> = HashMap::new(); + for adjustment in adjustments { + adjustments_by_track + .entry(adjustment.track_id.as_str()) + .or_default() + .push(adjustment); + } + + let apply_to_track = |track: &scene::Track| { + let track_adjustments = adjustments_by_track + .get(track.id()) + .map(Vec::as_slice) + .unwrap_or(&[]); + apply_track_ripple_adjustments(track, track_adjustments) + }; + + SceneTracks { + overlay: tracks.overlay.iter().map(apply_to_track).collect(), + main: apply_to_track(&tracks.main), + audio: tracks.audio.iter().map(apply_to_track).collect(), + } +} + +fn apply_track_ripple_adjustments( + track: &scene::Track, + adjustments: &[&RippleAdjustment], +) -> scene::Track { + if adjustments.is_empty() { + return track.clone(); + } + + let mut sorted: Vec<&RippleAdjustment> = adjustments.to_vec(); + sorted.sort_by_key(|adjustment| std::cmp::Reverse(adjustment.after_time)); + + let mut elements = track.elements().to_vec(); + for adjustment in sorted { + elements = ripple_shift_elements(&elements, adjustment.after_time, adjustment.shift_amount); + } + + let mut next = track.clone(); + *next.elements_mut() = elements; + next +} diff --git a/rust/crates/timeline/src/scenes.rs b/rust/crates/timeline/src/scenes.rs new file mode 100644 index 00000000..8a7a5c4f --- /dev/null +++ b/rust/crates/timeline/src/scenes.rs @@ -0,0 +1,120 @@ +use chrono::{DateTime, Utc}; +use scene::{Scene, SceneTracks}; +use time::MediaTime; + +use crate::placement::MAIN_TRACK_NAME; +use crate::placement::track_factory::build_empty_track; +use crate::types::TrackType; + +pub fn get_main_scene(scenes: &[Scene]) -> Option<&Scene> { + scenes.iter().find(|scene| scene.is_main) +} + +pub fn ensure_main_scene(scenes: &[Scene], next_id: &mut dyn FnMut() -> String, now: DateTime<Utc>) -> Vec<Scene> { + if scenes.iter().any(|scene| scene.is_main) { + return scenes.to_vec(); + } + + let main_scene = build_default_scene( + next_id(), + next_id(), + "Main scene".to_string(), + true, + now, + ); + let mut next = Vec::with_capacity(scenes.len() + 1); + next.push(main_scene); + next.extend(scenes.iter().cloned()); + next +} + +pub fn build_default_scene( + id: impl Into<String>, + main_track_id: impl Into<String>, + name: String, + is_main: bool, + now: DateTime<Utc>, +) -> Scene { + Scene { + id: id.into(), + name, + is_main, + tracks: SceneTracks { + overlay: Vec::new(), + main: build_empty_track( + main_track_id, + TrackType::Video, + Some(MAIN_TRACK_NAME.to_string()), + ), + audio: Vec::new(), + }, + bookmarks: Vec::new(), + created_at: now, + updated_at: now, + } +} + +pub struct SceneDeleteCheck { + pub can_delete: bool, + pub reason: Option<String>, +} + +pub fn can_delete_scene(scene: &Scene) -> SceneDeleteCheck { + if scene.is_main { + return SceneDeleteCheck { + can_delete: false, + reason: Some("Cannot delete main scene".to_string()), + }; + } + SceneDeleteCheck { + can_delete: true, + reason: None, + } +} + +pub fn get_fallback_scene_after_delete<'a>( + scenes: &'a [Scene], + deleted_scene_id: &str, + current_scene_id: Option<&str>, +) -> Option<&'a Scene> { + if current_scene_id != Some(deleted_scene_id) { + return scenes.iter().find(|scene| Some(scene.id.as_str()) == current_scene_id); + } + get_main_scene(scenes) +} + +pub fn find_current_scene<'a>( + scenes: &'a [Scene], + current_scene_id: &str, +) -> Option<&'a Scene> { + scenes + .iter() + .find(|scene| scene.id == current_scene_id) + .or_else(|| get_main_scene(scenes)) + .or_else(|| scenes.first()) +} + +pub fn get_project_duration_from_scenes(scenes: &[Scene]) -> MediaTime { + let Some(main_scene) = get_main_scene(scenes).or_else(|| scenes.first()) else { + return MediaTime::ZERO; + }; + + crate::calculate_total_duration(&main_scene.tracks) +} + +pub fn update_scene_in_array( + scenes: &[Scene], + scene_id: &str, + update: impl Fn(&Scene) -> Scene, +) -> Vec<Scene> { + scenes + .iter() + .map(|scene| { + if scene.id == scene_id { + update(scene) + } else { + scene.clone() + } + }) + .collect() +} diff --git a/rust/crates/timeline/src/snap.rs b/rust/crates/timeline/src/snap.rs new file mode 100644 index 00000000..1133726f --- /dev/null +++ b/rust/crates/timeline/src/snap.rs @@ -0,0 +1,113 @@ +use std::collections::HashSet; + +use scene::SceneTracks; +use time::MediaTime; + +pub const BASE_TIMELINE_PIXELS_PER_SECOND: f64 = 50.0; +pub const TIMELINE_ZOOM_MIN: f64 = 0.1; +pub const TIMELINE_ZOOM_MAX: f64 = 100.0; +pub const DEFAULT_TIMELINE_SNAP_THRESHOLD_PX: f64 = 10.0; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SnapPointType { + ElementStart, + ElementEnd, + Playhead, + Bookmark, + Keyframe, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct SnapPoint { + pub time: MediaTime, + pub kind: SnapPointType, + pub element_id: Option<String>, + pub track_id: Option<String>, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct SnapResult { + pub snapped_time: MediaTime, + pub snap_point: Option<SnapPoint>, + pub snap_distance: f64, +} + +pub fn resolve_timeline_snap( + target_time: MediaTime, + snap_points: &[SnapPoint], + max_snap_distance: f64, +) -> SnapResult { + let mut closest_snap_point: Option<&SnapPoint> = None; + let mut closest_distance = f64::INFINITY; + + for snap_point in snap_points { + let distance = (target_time - snap_point.time).as_ticks().unsigned_abs() as f64; + if distance <= max_snap_distance && distance < closest_distance { + closest_distance = distance; + closest_snap_point = Some(snap_point); + } + } + + SnapResult { + snapped_time: closest_snap_point + .map(|snap_point| snap_point.time) + .unwrap_or(target_time), + snap_point: closest_snap_point.cloned(), + snap_distance: closest_distance, + } +} + +pub fn get_timeline_snap_threshold_in_ticks( + zoom_level: f64, + snap_threshold_px: Option<f64>, +) -> f64 { + let threshold_px = snap_threshold_px.unwrap_or(DEFAULT_TIMELINE_SNAP_THRESHOLD_PX); + let pixels_per_second = BASE_TIMELINE_PIXELS_PER_SECOND * zoom_level; + (threshold_px / pixels_per_second) * time::TICKS_PER_SECOND as f64 +} + +pub fn build_timeline_snap_points( + sources: impl IntoIterator<Item = Vec<SnapPoint>>, +) -> Vec<SnapPoint> { + sources.into_iter().flatten().collect() +} + +pub fn get_element_edge_snap_points( + tracks: &SceneTracks, + exclude_element_ids: Option<&HashSet<&str>>, +) -> Vec<SnapPoint> { + let mut snap_points = Vec::new(); + + for track in crate::placement::ordered_tracks(tracks) { + for element in track.elements() { + let base = element.base(); + if exclude_element_ids.is_some_and(|ids| ids.contains(base.id.as_str())) { + continue; + } + + snap_points.push(SnapPoint { + time: base.start_time, + kind: SnapPointType::ElementStart, + element_id: Some(base.id.clone()), + track_id: Some(track.id().to_string()), + }); + snap_points.push(SnapPoint { + time: base.start_time + base.duration, + kind: SnapPointType::ElementEnd, + element_id: Some(base.id.clone()), + track_id: Some(track.id().to_string()), + }); + } + } + + snap_points +} + +pub fn get_playhead_snap_points(playhead_time: MediaTime) -> Vec<SnapPoint> { + vec![SnapPoint { + time: playhead_time, + kind: SnapPointType::Playhead, + element_id: None, + track_id: None, + }] +} diff --git a/rust/crates/timeline/src/split.rs b/rust/crates/timeline/src/split.rs new file mode 100644 index 00000000..5e7332b1 --- /dev/null +++ b/rust/crates/timeline/src/split.rs @@ -0,0 +1,143 @@ +use scene::{Element, ElementRef, SceneTracks}; +use time::MediaTime; + +use crate::retime::{get_source_span_at_clip_time, round_media_time}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RetainSide { + Both, + Left, + Right, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct SplitElementsResult { + pub tracks: SceneTracks, + pub right_side_elements: Vec<ElementRef>, +} + +pub fn split_elements( + tracks: &SceneTracks, + targets: &[ElementRef], + split_time: MediaTime, + retain_side: RetainSide, +) -> SplitElementsResult { + let mut right_side_elements = Vec::new(); + + let split_track = |track: &scene::Track, right_side_elements: &mut Vec<ElementRef>| { + let has_targets = targets.iter().any(|target| target.track_id == track.id()); + if !has_targets { + return track.clone(); + } + + let elements = track + .elements() + .iter() + .flat_map(|element| { + let should_split = targets + .iter() + .any(|target| target.element_id == element.base().id); + if !should_split { + return vec![element.clone()]; + } + split_element(track.id(), element, split_time, retain_side, right_side_elements) + }) + .collect(); + + let mut next = track.clone(); + *next.elements_mut() = elements; + next + }; + + let updated = SceneTracks { + overlay: tracks + .overlay + .iter() + .map(|track| split_track(track, &mut right_side_elements)) + .collect(), + main: split_track(&tracks.main, &mut right_side_elements), + audio: tracks + .audio + .iter() + .map(|track| split_track(track, &mut right_side_elements)) + .collect(), + }; + + SplitElementsResult { + tracks: updated, + right_side_elements, + } +} + +fn split_element( + track_id: &str, + element: &Element, + split_time: MediaTime, + retain_side: RetainSide, + right_side_elements: &mut Vec<ElementRef>, +) -> Vec<Element> { + let base = element.base(); + let effective_start = base.start_time; + let effective_end = base.start_time + base.duration; + + if split_time <= effective_start || split_time >= effective_end { + return vec![element.clone()]; + } + + let relative_time = split_time - base.start_time; + let left_visible_duration = relative_time; + let right_visible_duration = base.duration - relative_time; + let retime = crate::element_retime(element); + + let left_source_span = round_media_time(get_source_span_at_clip_time( + left_visible_duration.as_ticks() as f64, + retime.as_ref(), + )); + let total_source_span = round_media_time(get_source_span_at_clip_time( + base.duration.as_ticks() as f64, + retime.as_ref(), + )); + let right_source_span = total_source_span - left_source_span; + + let (left_animations, right_animations) = animation::split_animations_at_time_with_options( + base.animations.as_ref(), + relative_time, + true, + ); + + let left_trim_end = base.trim_end + right_source_span; + let right_trim_start = base.trim_start + left_source_span; + + let build_left = || { + let mut left = element.clone(); + let left_base = left.base_mut(); + left_base.duration = left_visible_duration; + left_base.trim_end = left_trim_end; + left_base.name = format!("{} (left)", left_base.name); + left_base.animations = left_animations; + left + }; + + let build_right = |right_side_elements: &mut Vec<ElementRef>| { + let new_id = animation::generate_uuid(); + right_side_elements.push(ElementRef { + track_id: track_id.to_string(), + element_id: new_id.clone(), + }); + let mut right = element.clone(); + let right_base = right.base_mut(); + right_base.id = new_id; + right_base.start_time = split_time; + right_base.duration = right_visible_duration; + right_base.trim_start = right_trim_start; + right_base.name = format!("{} (right)", right_base.name); + right_base.animations = right_animations; + right + }; + + match retain_side { + RetainSide::Left => vec![build_left()], + RetainSide::Right => vec![build_right(right_side_elements)], + RetainSide::Both => vec![build_left(), build_right(right_side_elements)], + } +} diff --git a/rust/crates/timeline/src/track_capabilities.rs b/rust/crates/timeline/src/track_capabilities.rs new file mode 100644 index 00000000..fe93316f --- /dev/null +++ b/rust/crates/timeline/src/track_capabilities.rs @@ -0,0 +1,9 @@ +use scene::Track; + +pub fn can_track_have_audio(track: &Track) -> bool { + matches!(track, Track::Audio { .. } | Track::Video { .. }) +} + +pub fn can_track_be_hidden(track: &Track) -> bool { + !matches!(track, Track::Audio { .. }) +} diff --git a/rust/crates/timeline/src/track_element_update.rs b/rust/crates/timeline/src/track_element_update.rs new file mode 100644 index 00000000..98395ab0 --- /dev/null +++ b/rust/crates/timeline/src/track_element_update.rs @@ -0,0 +1,97 @@ +use scene::{Element, SceneTracks, Track}; + +pub fn find_track_in_scene_tracks<'a>(tracks: &'a SceneTracks, track_id: &str) -> Option<&'a Track> { + if tracks.main.id() == track_id { + return Some(&tracks.main); + } + + tracks + .overlay + .iter() + .chain(tracks.audio.iter()) + .find(|track| track.id() == track_id) +} + +pub fn update_track_in_scene_tracks( + tracks: &SceneTracks, + track_id: &str, + update: impl Fn(&Track) -> Track, +) -> SceneTracks { + if tracks.main.id() == track_id { + return SceneTracks { + main: update(&tracks.main), + ..tracks.clone() + }; + } + + if tracks.overlay.iter().any(|track| track.id() == track_id) { + return SceneTracks { + overlay: tracks + .overlay + .iter() + .map(|track| { + if track.id() == track_id { + update(track) + } else { + track.clone() + } + }) + .collect(), + ..tracks.clone() + }; + } + + if tracks.audio.iter().any(|track| track.id() == track_id) { + return SceneTracks { + audio: tracks + .audio + .iter() + .map(|track| { + if track.id() == track_id { + update(track) + } else { + track.clone() + } + }) + .collect(), + ..tracks.clone() + }; + } + + tracks.clone() +} + +fn with_replaced_elements(track: &Track, elements: Vec<Element>) -> Track { + let mut next = track.clone(); + *next.elements_mut() = elements; + next +} + +pub fn update_element_in_scene_tracks( + tracks: &SceneTracks, + track_id: &str, + element_id: &str, + update: impl Fn(&Element) -> Element, + element_predicate: Option<&dyn Fn(&Element) -> bool>, +) -> SceneTracks { + let update_in_track = |track: &Track| { + let elements = track + .elements() + .iter() + .map(|element| { + if element.base().id != element_id { + return element.clone(); + } + if let Some(predicate) = element_predicate + && !predicate(element) + { + return element.clone(); + } + update(element) + }) + .collect(); + with_replaced_elements(track, elements) + }; + + update_track_in_scene_tracks(tracks, track_id, update_in_track) +} diff --git a/rust/crates/timeline/src/types.rs b/rust/crates/timeline/src/types.rs new file mode 100644 index 00000000..b142c2ea --- /dev/null +++ b/rust/crates/timeline/src/types.rs @@ -0,0 +1,69 @@ +use scene::{Element, Track}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] +pub enum TrackType { + Video, + Text, + Audio, + Graphic, + Effect, +} + +impl TrackType { + pub fn as_str(self) -> &'static str { + match self { + TrackType::Video => "video", + TrackType::Text => "text", + TrackType::Audio => "audio", + TrackType::Graphic => "graphic", + TrackType::Effect => "effect", + } + } + + pub fn of_track(track: &Track) -> TrackType { + match track { + Track::Video { .. } => TrackType::Video, + Track::Text { .. } => TrackType::Text, + Track::Audio { .. } => TrackType::Audio, + Track::Graphic { .. } => TrackType::Graphic, + Track::Effect { .. } => TrackType::Effect, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] +pub enum ElementType { + Audio, + Video, + Image, + Text, + Sticker, + Graphic, + Effect, +} + +impl ElementType { + pub fn as_str(self) -> &'static str { + match self { + ElementType::Audio => "audio", + ElementType::Video => "video", + ElementType::Image => "image", + ElementType::Text => "text", + ElementType::Sticker => "sticker", + ElementType::Graphic => "graphic", + ElementType::Effect => "effect", + } + } + + pub fn of_element(element: &Element) -> ElementType { + match element { + Element::Audio(_) => ElementType::Audio, + Element::Video(_) => ElementType::Video, + Element::Image(_) => ElementType::Image, + Element::Text(_) => ElementType::Text, + Element::Sticker(_) => ElementType::Sticker, + Element::Graphic(_) => ElementType::Graphic, + Element::Effect(_) => ElementType::Effect, + } + } +} diff --git a/rust/crates/timeline/src/update_pipeline.rs b/rust/crates/timeline/src/update_pipeline.rs new file mode 100644 index 00000000..83b03d85 --- /dev/null +++ b/rust/crates/timeline/src/update_pipeline.rs @@ -0,0 +1,158 @@ +use std::collections::HashSet; + +use scene::{Element, ElementAnimations, ParamValues, RetimeConfig, SceneTracks}; +use time::MediaTime; + +use crate::retime::{ + clamp_retime_rate, get_source_span_at_clip_time, get_timeline_duration_for_source_span, + round_media_time, +}; + +#[derive(Clone, Debug, Default)] +pub struct ElementPatch { + pub name: Option<String>, + pub duration: Option<MediaTime>, + pub start_time: Option<MediaTime>, + pub trim_start: Option<MediaTime>, + pub trim_end: Option<MediaTime>, + pub source_duration: Option<Option<MediaTime>>, + pub animations: Option<Option<ElementAnimations>>, + pub params: Option<ParamValues>, + pub retime: Option<Option<RetimeConfig>>, +} + +pub struct ElementUpdateContext<'a> { + pub tracks: &'a SceneTracks, + pub track_id: &'a str, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] +enum UpdateField { + Retime, + Duration, + StartTime, +} + +pub fn apply_element_update( + element: &Element, + patch: &ElementPatch, + context: &ElementUpdateContext, +) -> Element { + let mut next_element = merge_patch(element, patch); + let mut changed_fields: HashSet<UpdateField> = HashSet::new(); + if patch.retime.is_some() { + changed_fields.insert(UpdateField::Retime); + } + if patch.duration.is_some() { + changed_fields.insert(UpdateField::Duration); + } + if patch.start_time.is_some() { + changed_fields.insert(UpdateField::StartTime); + } + + if changed_fields.contains(&UpdateField::Retime) + && let Some(patch_retime) = &patch.retime + && crate::is_retimable_element(&next_element) + { + let next_retime = patch_retime.map(|retime| RetimeConfig { + rate: clamp_retime_rate(retime.rate), + maintain_pitch: retime.maintain_pitch, + }); + + let source_duration = get_source_duration(element); + let visible_source_span = (source_duration + - next_element.base().trim_start.as_ticks() as f64 + - next_element.base().trim_end.as_ticks() as f64) + .max(0.0); + let next_duration = round_media_time(get_timeline_duration_for_source_span( + visible_source_span, + next_retime.as_ref(), + )); + + crate::set_element_retime(&mut next_element, next_retime); + next_element.base_mut().duration = next_duration; + changed_fields.insert(UpdateField::Duration); + } + + if changed_fields.contains(&UpdateField::Duration) { + next_element.base_mut().animations = animation::clamp_animations_to_duration( + next_element.base().animations.as_ref(), + next_element.base().duration, + ); + } + + if changed_fields.contains(&UpdateField::StartTime) { + let requested_start_time = next_element.base().start_time.max(MediaTime::ZERO); + if context.track_id != context.tracks.main.id() { + next_element.base_mut().start_time = requested_start_time; + } else { + let earliest = context + .tracks + .main + .elements() + .iter() + .filter(|candidate| candidate.base().id != next_element.base().id) + .reduce(|earliest, candidate| { + if candidate.base().start_time < earliest.base().start_time { + candidate + } else { + earliest + } + }); + + next_element.base_mut().start_time = match earliest { + Some(earliest) if requested_start_time > earliest.base().start_time => { + requested_start_time + } + _ => MediaTime::ZERO, + }; + } + } + + next_element +} + +fn merge_patch(element: &Element, patch: &ElementPatch) -> Element { + let mut next = element.clone(); + let base = next.base_mut(); + if let Some(name) = &patch.name { + base.name = name.clone(); + } + if let Some(duration) = patch.duration { + base.duration = duration; + } + if let Some(start_time) = patch.start_time { + base.start_time = start_time; + } + if let Some(trim_start) = patch.trim_start { + base.trim_start = trim_start; + } + if let Some(trim_end) = patch.trim_end { + base.trim_end = trim_end; + } + if let Some(source_duration) = patch.source_duration { + base.source_duration = source_duration; + } + if let Some(animations) = &patch.animations { + base.animations = animations.clone(); + } + if let Some(params) = &patch.params { + base.params.extend(params.clone()); + } + if let Some(retime) = &patch.retime { + crate::set_element_retime(&mut next, *retime); + } + next +} + +fn get_source_duration(element: &Element) -> f64 { + let base = element.base(); + if let Some(source_duration) = base.source_duration { + return source_duration.as_ticks() as f64; + } + + let retime = crate::element_retime(element); + base.trim_start.as_ticks() as f64 + + get_source_span_at_clip_time(base.duration.as_ticks() as f64, retime.as_ref()) + + base.trim_end.as_ticks() as f64 +} |
