diff options
| author | cashmere <cashmere@autistici.org> | 2026-07-28 19:41:16 +0200 |
|---|---|---|
| committer | cashmere <cashmere@autistici.org> | 2026-07-28 19:41:16 +0200 |
| commit | f5a82152394fde4d8f740f664ab7737b82861618 (patch) | |
| tree | 5a51b181abead1ce03d2dbfc12b77918d3be0e1b /rust/crates/timeline/src/placement | |
| parent | e837e7504379e100ff3b56282577ec67bb88bec9 (diff) | |
| download | opencut-f5a82152394fde4d8f740f664ab7737b82861618.tar.gz | |
feat: GPUI desktop port foundation
- scene crate: full project/scene/element model, serde round-trip
proven against the web SerializedProject shape
- transfer crate: .ocp zip container, cross-compat with web fflate
archives proven in both directions
- storage crate: filesystem project store replacing IndexedDB,
.ocp import/export
- media crate: ffmpeg-next 8 decoder (frames, audio, thumbnails,
waveform summaries)
- playback crate: wall-clock anchored, frame-rounded playback clock
- gpu crate: native texture readback; compositor renders headless
offscreen on Vulkan (spike test)
- flake devshell: rust toolchain, GPUI native deps, ffmpeg dev,
bindgen clang args; GPUI hello-world builds
- port plan in notes/gpui-port.md
Diffstat (limited to 'rust/crates/timeline/src/placement')
| -rw-r--r-- | rust/crates/timeline/src/placement/apply.rs | 101 | ||||
| -rw-r--r-- | rust/crates/timeline/src/placement/compatibility.rs | 43 | ||||
| -rw-r--r-- | rust/crates/timeline/src/placement/insert_index.rs | 81 | ||||
| -rw-r--r-- | rust/crates/timeline/src/placement/main_track.rs | 44 | ||||
| -rw-r--r-- | rust/crates/timeline/src/placement/mod.rs | 104 | ||||
| -rw-r--r-- | rust/crates/timeline/src/placement/overlap.rs | 34 | ||||
| -rw-r--r-- | rust/crates/timeline/src/placement/resolve.rs | 223 | ||||
| -rw-r--r-- | rust/crates/timeline/src/placement/track_factory.rs | 62 |
8 files changed, 692 insertions, 0 deletions
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, + }, + } +} |
