summaryrefslogtreecommitdiff
path: root/rust/crates/timeline
diff options
context:
space:
mode:
Diffstat (limited to 'rust/crates/timeline')
-rw-r--r--rust/crates/timeline/Cargo.toml12
-rw-r--r--rust/crates/timeline/src/audio_separation.rs97
-rw-r--r--rust/crates/timeline/src/creation.rs11
-rw-r--r--rust/crates/timeline/src/defaults.rs130
-rw-r--r--rust/crates/timeline/src/drag_utils.rs14
-rw-r--r--rust/crates/timeline/src/lib.rs96
-rw-r--r--rust/crates/timeline/src/placement/apply.rs101
-rw-r--r--rust/crates/timeline/src/placement/compatibility.rs43
-rw-r--r--rust/crates/timeline/src/placement/insert_index.rs81
-rw-r--r--rust/crates/timeline/src/placement/main_track.rs44
-rw-r--r--rust/crates/timeline/src/placement/mod.rs104
-rw-r--r--rust/crates/timeline/src/placement/overlap.rs34
-rw-r--r--rust/crates/timeline/src/placement/resolve.rs223
-rw-r--r--rust/crates/timeline/src/placement/track_factory.rs62
-rw-r--r--rust/crates/timeline/src/resize.rs401
-rw-r--r--rust/crates/timeline/src/retime.rs71
-rw-r--r--rust/crates/timeline/src/ripple.rs265
-rw-r--r--rust/crates/timeline/src/scenes.rs120
-rw-r--r--rust/crates/timeline/src/snap.rs113
-rw-r--r--rust/crates/timeline/src/split.rs143
-rw-r--r--rust/crates/timeline/src/track_capabilities.rs9
-rw-r--r--rust/crates/timeline/src/track_element_update.rs97
-rw-r--r--rust/crates/timeline/src/types.rs69
-rw-r--r--rust/crates/timeline/src/update_pipeline.rs158
24 files changed, 2498 insertions, 0 deletions
diff --git a/rust/crates/timeline/Cargo.toml b/rust/crates/timeline/Cargo.toml
new file mode 100644
index 00000000..30df07bd
--- /dev/null
+++ b/rust/crates/timeline/Cargo.toml
@@ -0,0 +1,12 @@
+[package]
+name = "timeline"
+version = "0.1.0"
+edition = "2024"
+
+[dependencies]
+animation = { version = "0.1.0", path = "../animation" }
+chrono = { version = "0.4", features = ["serde"] }
+scene = { version = "0.1.0", path = "../scene" }
+serde = { version = "1.0", features = ["derive"] }
+serde_json = "1.0"
+time = { version = "0.1.0", path = "../time" }
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 = &params.tracks;
+ match &params.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 = &params.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 = &params.time_spans;
+
+ match &params.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
+}