diff options
| author | cashmere <cashmere@autistici.org> | 2026-07-28 20:14:51 +0200 |
|---|---|---|
| committer | cashmere <cashmere@autistici.org> | 2026-07-28 20:14:51 +0200 |
| commit | 32438ce25f69de99d2523b98237f62cfa90890d7 (patch) | |
| tree | 3a712c347dec0889bdea3328b4c817c2d9392b27 /rust/crates/commands | |
| parent | f5a82152394fde4d8f740f664ab7737b82861618 (diff) | |
| download | opencut-32438ce25f69de99d2523b98237f62cfa90890d7.tar.gz | |
feat: port domain crates to Rust (session 1 complete)
All green: cargo test --workspace (~300 tests, 0 failures).
- timeline (45): placement, resize/ripple, split, snap, retime,
audio separation, scene helpers
- animation (73): bezier solver, channel sampling, keyframe ops,
split/clamp/clone, param definitions
- commands (9): Command trait, BatchCommand, history, 19 concrete
commands, freeze-frame port 1:1
- audio (22): audible collection, range mixing, mastering limiter,
rms/peak buckets
- text (19): cosmic-text layout/measure/raster, backgrounds,
decorations
- graphics (52): full CSS gradient parser, tiny-skia raster,
4 shape definitions
- effect-defs (16): blur definition, registry, pass resolution
- mask-defs (19): all 9 mask renderers via tiny-skia
- renderer (6): node model + scene builder port
- desktop: GPUI projects screen wired to ProjectStore
- scene: factory (web defaults: v32, Main Track, 1920x1080, 30fps)
Diffstat (limited to 'rust/crates/commands')
| -rw-r--r-- | rust/crates/commands/src/scene/create_scene.rs | 8 | ||||
| -rw-r--r-- | rust/crates/commands/src/timeline/element/delete_elements.rs | 5 | ||||
| -rw-r--r-- | rust/crates/commands/src/timeline/element/duplicate_elements.rs | 131 | ||||
| -rw-r--r-- | rust/crates/commands/src/timeline/element/insert_element.rs | 195 | ||||
| -rw-r--r-- | rust/crates/commands/src/timeline/element/mod.rs | 12 | ||||
| -rw-r--r-- | rust/crates/commands/src/timeline/element/move_elements.rs | 14 | ||||
| -rw-r--r-- | rust/crates/commands/src/timeline/element/split_elements.rs | 170 | ||||
| -rw-r--r-- | rust/crates/commands/src/timeline/element/update_elements.rs | 7 | ||||
| -rw-r--r-- | rust/crates/commands/src/timeline/mod.rs | 2 | ||||
| -rw-r--r-- | rust/crates/commands/src/timeline/track/add_track.rs | 2 | ||||
| -rw-r--r-- | rust/crates/commands/tests/common/mod.rs | 118 | ||||
| -rw-r--r-- | rust/crates/commands/tests/framework.rs | 162 | ||||
| -rw-r--r-- | rust/crates/commands/tests/freeze_frame.rs | 155 |
13 files changed, 810 insertions, 171 deletions
diff --git a/rust/crates/commands/src/scene/create_scene.rs b/rust/crates/commands/src/scene/create_scene.rs index 1f2de02b..b109e0ce 100644 --- a/rust/crates/commands/src/scene/create_scene.rs +++ b/rust/crates/commands/src/scene/create_scene.rs @@ -31,7 +31,13 @@ impl Command for CreateSceneCommand { ) -> Result<Option<CommandResult>, CommandError> { self.saved_scenes = Some(project.scenes.clone()); - let created = timeline::scenes::build_default_scene(&self.name, self.is_main); + let created = timeline::scenes::build_default_scene( + uuid::Uuid::new_v4().to_string(), + uuid::Uuid::new_v4().to_string(), + self.name.clone(), + self.is_main, + chrono::Utc::now(), + ); self.created_scene_id = Some(created.id.clone()); project.scenes.push(created); Ok(None) diff --git a/rust/crates/commands/src/timeline/element/delete_elements.rs b/rust/crates/commands/src/timeline/element/delete_elements.rs index bdcb5b6d..191ad4a8 100644 --- a/rust/crates/commands/src/timeline/element/delete_elements.rs +++ b/rust/crates/commands/src/timeline/element/delete_elements.rs @@ -4,13 +4,14 @@ use crate::command::{Command, CommandError, CommandResult}; use crate::project_ext::active_scene_mut; fn remove_track_elements(track: &mut Track, elements: &[ElementRef]) { - if !elements.iter().any(|target| target.track_id == track.id()) { + let track_id = track.id().to_string(); + if !elements.iter().any(|target| target.track_id == track_id) { return; } track.elements_mut().retain(|element| { !elements .iter() - .any(|target| target.track_id == track.id() && target.element_id == element.base().id) + .any(|target| target.track_id == track_id && target.element_id == element.base().id) }); } diff --git a/rust/crates/commands/src/timeline/element/duplicate_elements.rs b/rust/crates/commands/src/timeline/element/duplicate_elements.rs new file mode 100644 index 00000000..a77436ce --- /dev/null +++ b/rust/crates/commands/src/timeline/element/duplicate_elements.rs @@ -0,0 +1,131 @@ +use scene::{Element, ElementRef, Project, SceneTracks}; +use timeline::TrackType; +use timeline::placement::apply::ApplyPlacementParams; +use timeline::placement::{ + NewTrackPosition, PlacementResult, PlacementStrategy, PlacementSubject, + ResolveTrackPlacementParams, apply_placement, ordered_tracks, resolve_track_placement, +}; +use uuid::Uuid; + +use crate::command::{Command, CommandError, CommandResult}; +use crate::project_ext::active_scene_mut; + +fn build_duplicate_element(element: &Element, id: String) -> Element { + let mut duplicate = element.clone(); + { + let base = duplicate.base_mut(); + base.id = id; + base.name = format!("{} (copy)", base.name); + base.animations = animation::clone_animations(base.animations.as_ref(), true); + } + duplicate +} + +pub struct DuplicateElementsCommand { + elements: Vec<ElementRef>, + duplicated: Vec<ElementRef>, + saved_state: Option<SceneTracks>, +} + +impl DuplicateElementsCommand { + pub fn new(elements: Vec<ElementRef>) -> Self { + Self { + elements, + duplicated: Vec::new(), + saved_state: None, + } + } + + pub fn duplicated_elements(&self) -> &[ElementRef] { + &self.duplicated + } +} + +impl Command for DuplicateElementsCommand { + fn execute( + &mut self, + project: &mut Project, + ) -> Result<Option<CommandResult>, CommandError> { + let Some(scene) = active_scene_mut(project) else { + return Ok(None); + }; + + self.saved_state = Some(scene.tracks.clone()); + self.duplicated.clear(); + + let saved_state = scene.tracks.clone(); + let mut updated_tracks = scene.tracks.clone(); + + for track in ordered_tracks(&saved_state) { + let new_elements: Vec<Element> = track + .elements() + .iter() + .filter(|element| { + self.elements.iter().any(|target| { + target.track_id == track.id() && target.element_id == element.base().id + }) + }) + .map(|element| build_duplicate_element(element, Uuid::new_v4().to_string())) + .collect(); + + if new_elements.is_empty() { + continue; + } + + let placement_result = resolve_track_placement(&ResolveTrackPlacementParams { + tracks: updated_tracks.clone(), + subject: PlacementSubject::TrackType(TrackType::of_track(track)), + time_spans: Vec::new(), + strategy: PlacementStrategy::AlwaysNew { + position: NewTrackPosition::Highest, + }, + }); + + let Some(placement_result @ PlacementResult::NewTrack { .. }) = placement_result + else { + continue; + }; + + let new_element_ids: Vec<String> = new_elements + .iter() + .map(|element| element.base().id.clone()) + .collect(); + + let Some(applied) = apply_placement(&ApplyPlacementParams { + tracks: updated_tracks.clone(), + placement_result, + elements: new_elements, + new_track_id: Uuid::new_v4().to_string(), + new_track_insert_index_override: None, + }) else { + continue; + }; + + updated_tracks = applied.updated_tracks; + self.duplicated.extend(new_element_ids.into_iter().map(|element_id| { + ElementRef { + track_id: applied.target_track_id.clone(), + element_id, + } + })); + } + + scene.tracks = updated_tracks; + + if self.duplicated.is_empty() { + return Ok(None); + } + Ok(Some(CommandResult::element_selection( + self.duplicated.clone(), + ))) + } + + fn undo(&mut self, project: &mut Project) { + let Some(saved) = &self.saved_state else { + return; + }; + if let Some(scene) = active_scene_mut(project) { + scene.tracks = saved.clone(); + } + } +} diff --git a/rust/crates/commands/src/timeline/element/insert_element.rs b/rust/crates/commands/src/timeline/element/insert_element.rs new file mode 100644 index 00000000..a2584c53 --- /dev/null +++ b/rust/crates/commands/src/timeline/element/insert_element.rs @@ -0,0 +1,195 @@ +use scene::{AudioElement, Element, ElementRef, ParamValue, Project, SceneTracks}; +use timeline::placement::apply::ApplyPlacementParams; +use timeline::placement::{ + PlacementResult, PlacementStrategy, PlacementSubject, PlacementTimeSpan, + ResolveTrackPlacementParams, apply_placement, can_element_go_on_track, + resolve_track_placement, +}; +use timeline::{ElementType, TrackType}; +use uuid::Uuid; + +use crate::command::{Command, CommandError, CommandResult}; +use crate::project_ext::active_scene_mut; + +#[derive(Clone, Debug, PartialEq)] +pub enum InsertElementPlacement { + Explicit { + track_id: String, + }, + Auto { + track_type: Option<TrackType>, + insert_index: Option<usize>, + }, +} + +fn requires_media_id(element: &Element) -> bool { + matches!( + element, + Element::Video(_) | Element::Image(_) | Element::Audio(AudioElement::Upload(_)) + ) +} + +fn has_media_id_value(element: &Element) -> bool { + match element { + Element::Video(video) => !video.media_id.is_empty(), + Element::Image(image) => !image.media_id.is_empty(), + Element::Audio(AudioElement::Upload(upload)) => !upload.media_id.is_empty(), + _ => false, + } +} + +fn validate_element_basics(element: &Element) -> bool { + if requires_media_id(element) && !has_media_id_value(element) { + return false; + } + + match element { + Element::Audio(AudioElement::Library(library)) => !library.source_url.is_empty(), + Element::Sticker(sticker) => !sticker.sticker_id.is_empty(), + Element::Graphic(graphic) => !graphic.definition_id.is_empty(), + Element::Text(text) => matches!( + text.base.params.get("content"), + Some(ParamValue::String(content)) if !content.is_empty() + ), + Element::Effect(effect) => !effect.effect_type.is_empty(), + _ => true, + } +} + +pub struct InsertElementCommand { + element: Element, + placement: InsertElementPlacement, + element_id: String, + target_track_id: Option<String>, + saved_state: Option<SceneTracks>, +} + +impl InsertElementCommand { + pub fn new(element: Element, placement: InsertElementPlacement) -> Self { + Self { + element, + placement, + element_id: Uuid::new_v4().to_string(), + target_track_id: None, + saved_state: None, + } + } + + pub fn element_id(&self) -> &str { + &self.element_id + } + + pub fn track_id(&self) -> Option<&str> { + self.target_track_id.as_deref() + } + + fn apply_placement_result( + &self, + tracks: &SceneTracks, + element: &Element, + ) -> Option<(SceneTracks, String)> { + let element_type = ElementType::of_element(element); + + if let InsertElementPlacement::Auto { + track_type: Some(track_type), + .. + } = &self.placement + && !can_element_go_on_track(element_type, *track_type) + { + return None; + } + + let subject = match &self.placement { + InsertElementPlacement::Auto { + track_type: Some(track_type), + .. + } => PlacementSubject::TrackType(*track_type), + _ => PlacementSubject::ElementType(element_type), + }; + + let strategy = match &self.placement { + InsertElementPlacement::Explicit { track_id } => PlacementStrategy::Explicit { + track_id: track_id.clone(), + }, + InsertElementPlacement::Auto { .. } => PlacementStrategy::FirstAvailable, + }; + + let placement_result = resolve_track_placement(&ResolveTrackPlacementParams { + tracks: tracks.clone(), + subject, + time_spans: vec![PlacementTimeSpan { + start_time: element.base().start_time, + duration: element.base().duration, + exclude_element_id: None, + }], + strategy, + })?; + + let mut element_to_place = element.clone(); + if let PlacementResult::ExistingTrack { + adjusted_start_time: Some(adjusted), + .. + } = &placement_result + { + element_to_place.base_mut().start_time = *adjusted; + } + + let insert_index_override = match &self.placement { + InsertElementPlacement::Auto { insert_index, .. } => *insert_index, + InsertElementPlacement::Explicit { .. } => None, + }; + + let applied = apply_placement(&ApplyPlacementParams { + tracks: tracks.clone(), + placement_result, + elements: vec![element_to_place], + new_track_id: Uuid::new_v4().to_string(), + new_track_insert_index_override: insert_index_override, + })?; + + Some((applied.updated_tracks, applied.target_track_id)) + } +} + +impl Command for InsertElementCommand { + fn execute( + &mut self, + project: &mut Project, + ) -> Result<Option<CommandResult>, CommandError> { + let Some(scene) = active_scene_mut(project) else { + return Ok(None); + }; + + self.saved_state = Some(scene.tracks.clone()); + + if !validate_element_basics(&self.element) { + return Ok(None); + } + + let mut new_element = self.element.clone(); + new_element.base_mut().id = self.element_id.clone(); + + let Some((updated_tracks, target_track_id)) = + self.apply_placement_result(&scene.tracks, &new_element) + else { + return Ok(None); + }; + + self.target_track_id = Some(target_track_id.clone()); + scene.tracks = updated_tracks; + + Ok(Some(CommandResult::element_selection(vec![ElementRef { + track_id: target_track_id, + element_id: self.element_id.clone(), + }]))) + } + + fn undo(&mut self, project: &mut Project) { + let Some(saved) = &self.saved_state else { + return; + }; + if let Some(scene) = active_scene_mut(project) { + scene.tracks = saved.clone(); + } + } +} diff --git a/rust/crates/commands/src/timeline/element/mod.rs b/rust/crates/commands/src/timeline/element/mod.rs index 2ba806e1..a1d8de6e 100644 --- a/rust/crates/commands/src/timeline/element/mod.rs +++ b/rust/crates/commands/src/timeline/element/mod.rs @@ -1,3 +1,15 @@ pub mod delete_elements; +pub mod duplicate_elements; +pub mod insert_element; +pub mod move_elements; +pub mod shift_split_remainder; +pub mod split_elements; +pub mod update_elements; pub use delete_elements::DeleteElementsCommand; +pub use duplicate_elements::DuplicateElementsCommand; +pub use insert_element::{InsertElementCommand, InsertElementPlacement}; +pub use move_elements::{MoveElementCommand, PlannedElementMove, PlannedTrackCreation}; +pub use shift_split_remainder::ShiftSplitRemainderCommand; +pub use split_elements::{RetainSide, SplitElementsCommand}; +pub use update_elements::{ElementUpdate, UpdateElementsCommand}; diff --git a/rust/crates/commands/src/timeline/element/move_elements.rs b/rust/crates/commands/src/timeline/element/move_elements.rs index fc6425d1..b9324ab8 100644 --- a/rust/crates/commands/src/timeline/element/move_elements.rs +++ b/rust/crates/commands/src/timeline/element/move_elements.rs @@ -40,8 +40,16 @@ fn insert_track_at_display_index( } fn validate_compatibility(element: &Element, track: &Track) -> Result<(), CommandError> { - timeline::placement::validate_element_track_compatibility(element, track) - .map_err(CommandError::IncompatiblePlacement) + let compatibility = timeline::placement::validate_element_track_compatibility( + timeline::ElementType::of_element(element), + timeline::TrackType::of_track(track), + ); + if compatibility.is_valid { + return Ok(()); + } + Err(CommandError::IncompatiblePlacement( + compatibility.error_message.unwrap_or_default(), + )) } pub struct MoveElementCommand { @@ -75,7 +83,7 @@ impl Command for MoveElementCommand { let mut create_tracks = self.create_tracks.clone(); create_tracks.sort_by_key(|creation| creation.index); for creation in &create_tracks { - let track = build_empty_track(&creation.id, creation.track_type, None); + let track = build_empty_track(creation.id.clone(), creation.track_type, None); insert_track_at_display_index(&mut scene.tracks, track, creation.index); } diff --git a/rust/crates/commands/src/timeline/element/split_elements.rs b/rust/crates/commands/src/timeline/element/split_elements.rs index 0da90792..98d292ab 100644 --- a/rust/crates/commands/src/timeline/element/split_elements.rs +++ b/rust/crates/commands/src/timeline/element/split_elements.rs @@ -1,50 +1,12 @@ use std::cell::RefCell; use std::rc::Rc; -use scene::{AudioElement, Element, ElementRef, Project, RetimeConfig, SceneTracks}; +use scene::{ElementRef, Project, SceneTracks}; use time::MediaTime; -use uuid::Uuid; +pub use timeline::split::RetainSide; use crate::command::{Command, CommandError, CommandResult}; use crate::project_ext::active_scene_mut; -use crate::tracks::all_tracks_mut; - -const MIN_RETIME_RATE: f64 = 0.01; -const MAX_RETIME_RATE: f64 = 5.0; -const DEFAULT_RETIME_RATE: f64 = 1.0; - -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] -pub enum RetainSide { - #[default] - Both, - Left, - Right, -} - -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) -} - -fn source_span_at_clip_time(clip_time: MediaTime, retime: Option<RetimeConfig>) -> f64 { - let rate = clamp_retime_rate(retime.map(|config| config.rate).unwrap_or(1.0)); - (clip_time.as_ticks() as f64 * rate).max(0.0) -} - -fn round_to_ticks(time: f64) -> MediaTime { - MediaTime::from_ticks(time.round() as i64) -} - -fn retime_of(element: &Element) -> Option<RetimeConfig> { - match element { - Element::Video(video) => video.retime, - Element::Audio(AudioElement::Upload(upload)) => upload.retime, - Element::Audio(AudioElement::Library(library)) => library.retime, - _ => None, - } -} pub struct SplitElementsCommand { elements: Vec<ElementRef>, @@ -72,100 +34,6 @@ impl SplitElementsCommand { pub fn right_side_elements(&self) -> Vec<ElementRef> { self.right_side.borrow().clone() } - - fn split_element( - &self, - track_id: &str, - element: Element, - right_side: &mut Vec<ElementRef>, - ) -> Vec<Element> { - let start = element.base().start_time; - let end = start + element.base().duration; - - if self.split_time <= start || self.split_time >= end { - return vec![element]; - } - - let relative_time = self.split_time - start; - let left_visible_duration = relative_time; - let right_visible_duration = element.base().duration - relative_time; - let retime = retime_of(&element); - - let left_source_span = round_to_ticks(source_span_at_clip_time( - left_visible_duration, - retime, - )); - let total_source_span = - round_to_ticks(source_span_at_clip_time(element.base().duration, retime)); - let right_source_span = total_source_span - left_source_span; - - let (left_animations, right_animations) = animation::split_animations_at_time_with_options( - element.base().animations.as_ref(), - relative_time, - true, - ); - - let left_trim_end = element.base().trim_end + right_source_span; - let right_trim_start = element.base().trim_start + left_source_span; - - match self.retain_side { - RetainSide::Left => { - let mut left = element; - { - let base = left.base_mut(); - base.duration = left_visible_duration; - base.trim_end = left_trim_end; - base.name = format!("{} (left)", base.name); - base.animations = left_animations; - } - vec![left] - } - RetainSide::Right => { - let mut right = element; - let new_id = Uuid::new_v4().to_string(); - right_side.push(ElementRef { - track_id: track_id.to_string(), - element_id: new_id.clone(), - }); - { - let base = right.base_mut(); - base.id = new_id; - base.start_time = self.split_time; - base.duration = right_visible_duration; - base.trim_start = right_trim_start; - base.name = format!("{} (right)", base.name); - base.animations = right_animations; - } - vec![right] - } - RetainSide::Both => { - let mut left = element; - let mut right = left.clone(); - let right_id = Uuid::new_v4().to_string(); - right_side.push(ElementRef { - track_id: track_id.to_string(), - element_id: right_id.clone(), - }); - { - let base = left.base_mut(); - base.duration = left_visible_duration; - base.trim_end = left_trim_end; - base.name = format!("{} (left)", base.name); - base.animations = left_animations; - } - { - let base = right.base_mut(); - base.id = right_id; - base.start_time = self.split_time; - base.duration = right_visible_duration; - base.trim_start = right_trim_start; - base.name = format!("{} (right)", base.name); - base.animations = right_animations; - } - vec![left, right] - } - } - } } impl Command for SplitElementsCommand { @@ -178,34 +46,16 @@ impl Command for SplitElementsCommand { }; self.saved_state = Some(scene.tracks.clone()); - self.right_side.borrow_mut().clear(); - for track in all_tracks_mut(&mut scene.tracks) { - let targets: Vec<&str> = self - .elements - .iter() - .filter(|target| target.track_id == track.id()) - .map(|target| target.element_id.as_str()) - .collect(); - if targets.is_empty() { - continue; - } + let result = timeline::split::split_elements( + &scene.tracks, + &self.elements, + self.split_time, + self.retain_side, + ); + scene.tracks = result.tracks; - let track_id = track.id().to_string(); - let previous = std::mem::take(track.elements_mut()); - let mut next = Vec::with_capacity(previous.len()); - { - let mut right_side = self.right_side.borrow_mut(); - for element in previous { - if !targets.contains(&element.base().id.as_str()) { - next.push(element); - continue; - } - next.extend(self.split_element(&track_id, element, &mut right_side)); - } - } - *track.elements_mut() = next; - } + *self.right_side.borrow_mut() = result.right_side_elements; let right_side = self.right_side.borrow(); if right_side.is_empty() { diff --git a/rust/crates/commands/src/timeline/element/update_elements.rs b/rust/crates/commands/src/timeline/element/update_elements.rs index f5931bb7..0ab21174 100644 --- a/rust/crates/commands/src/timeline/element/update_elements.rs +++ b/rust/crates/commands/src/timeline/element/update_elements.rs @@ -1,11 +1,11 @@ -use scene::{ElementRef, Project, SceneTracks}; +use scene::{Project, SceneTracks}; use timeline::update_pipeline::{ElementPatch, ElementUpdateContext, apply_element_update}; use crate::command::{Command, CommandError, CommandResult}; use crate::project_ext::active_scene_mut; use crate::tracks::find_track_mut; -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug)] pub struct ElementUpdate { pub track_id: String, pub element_id: String, @@ -54,8 +54,7 @@ impl Command for UpdateElementsCommand { continue; }; - let current = element.clone(); - *element = apply_element_update(current, update.patch.clone(), &context); + *element = apply_element_update(element, &update.patch, &context); } Ok(None) diff --git a/rust/crates/commands/src/timeline/mod.rs b/rust/crates/commands/src/timeline/mod.rs index 532885ce..b83d50b4 100644 --- a/rust/crates/commands/src/timeline/mod.rs +++ b/rust/crates/commands/src/timeline/mod.rs @@ -2,4 +2,6 @@ pub mod element; pub mod track; pub mod tracks_snapshot; +pub use element::*; +pub use track::*; pub use tracks_snapshot::TracksSnapshotCommand; diff --git a/rust/crates/commands/src/timeline/track/add_track.rs b/rust/crates/commands/src/timeline/track/add_track.rs index d6e9bbae..4bf170c7 100644 --- a/rust/crates/commands/src/timeline/track/add_track.rs +++ b/rust/crates/commands/src/timeline/track/add_track.rs @@ -43,7 +43,7 @@ impl Command for AddTrackCommand { get_default_insert_index_for_track(&scene.tracks, self.track_type) }); - let new_track = build_empty_track(&self.track_id, self.track_type, None); + let new_track = build_empty_track(self.track_id.clone(), self.track_type, None); if self.track_type == TrackType::Audio { let audio_insert_index = insert_index .saturating_sub(scene.tracks.overlay.len() + 1); diff --git a/rust/crates/commands/tests/common/mod.rs b/rust/crates/commands/tests/common/mod.rs new file mode 100644 index 00000000..4cf45a9a --- /dev/null +++ b/rust/crates/commands/tests/common/mod.rs @@ -0,0 +1,118 @@ +#![allow(dead_code)] + +use scene::{ + Background, BaseTimelineElement, CanvasSize, Element, ImageElement, ParamValue, + ParamValues, Project, ProjectMetadata, ProjectSettings, Scene, VideoElement, +}; +use time::{FrameRate, MediaTime}; + +pub const SPLIT_SECONDS: f64 = 4.0; +pub const FREEZE_SECONDS: f64 = 3.0; +pub const VIDEO_SECONDS: f64 = 10.0; + +pub fn seconds(value: f64) -> MediaTime { + MediaTime::from_seconds_f64(value).expect("finite seconds") +} + +pub fn default_params() -> ParamValues { + ParamValues::from([ + ("transform.positionX".to_string(), ParamValue::Number(0.0)), + ("transform.positionY".to_string(), ParamValue::Number(0.0)), + ("transform.scaleX".to_string(), ParamValue::Number(1.0)), + ("transform.scaleY".to_string(), ParamValue::Number(1.0)), + ("transform.rotate".to_string(), ParamValue::Number(0.0)), + ("opacity".to_string(), ParamValue::Number(1.0)), + ]) +} + +pub fn base_element(id: &str, name: &str, duration: MediaTime) -> BaseTimelineElement { + BaseTimelineElement { + id: id.to_string(), + name: name.to_string(), + duration, + start_time: MediaTime::ZERO, + trim_start: MediaTime::ZERO, + trim_end: MediaTime::ZERO, + source_duration: None, + animations: None, + params: default_params(), + } +} + +pub fn build_video_element() -> Element { + Element::Video(VideoElement { + base: base_element("video-1", "video-1", seconds(VIDEO_SECONDS)), + media_id: "media-video-1".to_string(), + is_source_audio_enabled: None, + hidden: None, + retime: None, + effects: None, + masks: None, + }) +} + +pub fn build_freeze_image_element() -> Element { + let mut base = base_element("freeze-template", "Freeze frame", seconds(FREEZE_SECONDS)); + base.start_time = seconds(SPLIT_SECONDS); + Element::Image(ImageElement { + base, + media_id: "media-freeze-1".to_string(), + hidden: None, + effects: None, + masks: None, + }) +} + +pub fn active_scene(project: &Project) -> &Scene { + project + .scenes + .iter() + .find(|scene| scene.id == project.current_scene_id) + .expect("active scene") +} + +pub fn seed_project_with_video() -> (Project, String) { + let now = chrono::DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z") + .expect("valid date") + .to_utc(); + + let mut scene: Scene = timeline::scenes::build_default_scene( + "scene-1".to_string(), + "main-track-1".to_string(), + "Main scene".to_string(), + true, + now, + ); + scene.tracks.main.elements_mut().push(build_video_element()); + let track_id = scene.tracks.main.id().to_string(); + + let project = Project { + metadata: ProjectMetadata { + id: "project-1".to_string(), + name: "Test project".to_string(), + thumbnail: None, + duration: seconds(VIDEO_SECONDS), + created_at: now, + updated_at: now, + }, + scenes: vec![scene], + current_scene_id: "scene-1".to_string(), + settings: ProjectSettings { + fps: FrameRate::new(30, 1), + canvas_size: CanvasSize { + width: 1920, + height: 1080, + }, + canvas_size_mode: None, + last_custom_canvas_size: None, + original_canvas_size: None, + background: Background::Color { + color: "#000000".to_string(), + }, + }, + version: 1, + timeline_view_state: None, + }; + + (project, track_id) +} diff --git a/rust/crates/commands/tests/framework.rs b/rust/crates/commands/tests/framework.rs new file mode 100644 index 00000000..e3defbad --- /dev/null +++ b/rust/crates/commands/tests/framework.rs @@ -0,0 +1,162 @@ +mod common; + +use std::cell::RefCell; +use std::rc::Rc; + +use commands::{ + BatchCommand, Command, CommandError, CommandHistory, CommandResult, DeleteElementsCommand, + PreviewTracker, +}; +use common::{VIDEO_SECONDS, seconds, seed_project_with_video}; +use scene::{ElementRef, Project}; + +struct RecordingCommand { + label: &'static str, + log: Rc<RefCell<Vec<&'static str>>>, + selection: Option<Vec<ElementRef>>, +} + +impl RecordingCommand { + fn new(label: &'static str, log: &Rc<RefCell<Vec<&'static str>>>) -> Self { + Self { + label, + log: Rc::clone(log), + selection: None, + } + } + + fn with_selection(mut self, selection: Vec<ElementRef>) -> Self { + self.selection = Some(selection); + self + } +} + +impl Command for RecordingCommand { + fn execute(&mut self, _project: &mut Project) -> Result<Option<CommandResult>, CommandError> { + self.log.borrow_mut().push(self.label); + Ok(self + .selection + .clone() + .map(CommandResult::element_selection)) + } + + fn undo(&mut self, _project: &mut Project) { + self.log.borrow_mut().push(self.label); + } +} + +#[test] +fn batch_executes_in_order_and_undoes_in_reverse() { + let (mut project, _) = seed_project_with_video(); + let log = Rc::new(RefCell::new(Vec::new())); + + let mut batch = BatchCommand::new(vec![ + Box::new(RecordingCommand::new("first", &log)), + Box::new(RecordingCommand::new("second", &log)), + Box::new(RecordingCommand::new("third", &log)), + ]); + + batch.execute(&mut project).expect("execute"); + assert_eq!(*log.borrow(), vec!["first", "second", "third"]); + + log.borrow_mut().clear(); + batch.undo(&mut project); + assert_eq!(*log.borrow(), vec!["third", "second", "first"]); +} + +#[test] +fn batch_returns_latest_selection_result() { + let (mut project, _) = seed_project_with_video(); + let log = Rc::new(RefCell::new(Vec::new())); + + let selected = vec![ElementRef { + track_id: "main-track-1".to_string(), + element_id: "video-1".to_string(), + }]; + let mut batch = BatchCommand::new(vec![ + Box::new( + RecordingCommand::new("selecting", &log).with_selection(selected.clone()), + ), + Box::new(RecordingCommand::new("plain", &log)), + ]); + + let result = batch.execute(&mut project).expect("execute"); + let selection = result + .and_then(|result| result.selection) + .expect("selection result"); + assert_eq!(selection.selected_elements, selected); +} + +#[test] +fn history_undo_redo_roundtrip() { + let (mut project, track_id) = seed_project_with_video(); + let mut history = CommandHistory::new(); + + assert!(!history.can_undo()); + assert!(!history.can_redo()); + + history + .execute( + Box::new(DeleteElementsCommand::new(vec![ElementRef { + track_id: track_id.clone(), + element_id: "video-1".to_string(), + }])), + &mut project, + ) + .expect("execute"); + + let scene = common::active_scene; + assert!(scene(&project).tracks.main.elements().is_empty()); + assert!(history.can_undo()); + assert!(!history.can_redo()); + + history.undo(&mut project); + assert_eq!(scene(&project).tracks.main.elements().len(), 1); + assert!(!history.can_undo()); + assert!(history.can_redo()); + + history.redo(&mut project).expect("redo"); + assert!(scene(&project).tracks.main.elements().is_empty()); + assert!(history.can_undo()); + assert!(!history.can_redo()); +} + +#[test] +fn history_new_execute_clears_redo_stack() { + let (mut project, track_id) = seed_project_with_video(); + let mut history = CommandHistory::new(); + + let delete = || { + Box::new(DeleteElementsCommand::new(vec![ElementRef { + track_id: track_id.clone(), + element_id: "video-1".to_string(), + }])) as Box<dyn Command> + }; + + history.execute(delete(), &mut project).expect("execute"); + history.undo(&mut project); + assert!(history.can_redo()); + + history.execute(delete(), &mut project).expect("execute"); + assert!(!history.can_redo()); + + history.clear(); + assert!(!history.can_undo()); + assert!(!history.can_redo()); +} + +#[test] +fn preview_tracker_keeps_first_snapshot_until_end() { + let mut tracker = PreviewTracker::new(); + assert!(!tracker.is_active()); + assert_eq!(tracker.end(), None); + + tracker.begin(&seconds(VIDEO_SECONDS)); + tracker.begin(&seconds(1.0)); + assert!(tracker.is_active()); + assert_eq!(tracker.snapshot(), Some(&seconds(VIDEO_SECONDS))); + + assert_eq!(tracker.end(), Some(seconds(VIDEO_SECONDS))); + assert!(!tracker.is_active()); + assert_eq!(tracker.snapshot(), None); +} diff --git a/rust/crates/commands/tests/freeze_frame.rs b/rust/crates/commands/tests/freeze_frame.rs new file mode 100644 index 00000000..614b73f7 --- /dev/null +++ b/rust/crates/commands/tests/freeze_frame.rs @@ -0,0 +1,155 @@ +mod common; + +use commands::{ + BatchCommand, Command, InsertElementCommand, InsertElementPlacement, RetainSide, + ShiftSplitRemainderCommand, SplitElementsCommand, +}; +use common::{ + FREEZE_SECONDS, SPLIT_SECONDS, VIDEO_SECONDS, build_freeze_image_element, seconds, + seed_project_with_video, +}; +use scene::{Element, ElementRef, Project}; +use time::MediaTime; + +fn build_freeze_batch(track_id: &str) -> BatchCommand { + let split = SplitElementsCommand::new( + vec![ElementRef { + track_id: track_id.to_string(), + element_id: "video-1".to_string(), + }], + seconds(SPLIT_SECONDS), + RetainSide::Both, + ); + let right_side = split.right_side_handle(); + let shift = ShiftSplitRemainderCommand::new( + right_side, + seconds(SPLIT_SECONDS) + seconds(FREEZE_SECONDS), + ); + let insert = InsertElementCommand::new( + build_freeze_image_element(), + InsertElementPlacement::Explicit { + track_id: track_id.to_string(), + }, + ); + BatchCommand::new(vec![Box::new(split), Box::new(shift), Box::new(insert)]) +} + +fn sorted_main_elements(project: &Project) -> Vec<Element> { + let scene = project + .scenes + .iter() + .find(|scene| scene.id == project.current_scene_id) + .expect("active scene"); + let mut elements = scene.tracks.main.elements().to_vec(); + elements.sort_by_key(|element| element.base().start_time); + elements +} + +fn element_type_name(element: &Element) -> &'static str { + match element { + Element::Video(_) => "video", + Element::Image(_) => "image", + Element::Text(_) => "text", + Element::Audio(_) => "audio", + Element::Sticker(_) => "sticker", + Element::Graphic(_) => "graphic", + Element::Effect(_) => "effect", + } +} + +#[test] +fn splits_shifts_right_side_and_inserts_into_gap() { + let (mut project, track_id) = seed_project_with_video(); + + build_freeze_batch(&track_id) + .execute(&mut project) + .expect("execute"); + + let elements = sorted_main_elements(&project); + let type_names: Vec<&str> = elements.iter().map(element_type_name).collect(); + assert_eq!(type_names, ["video", "image", "video"]); + + let [left, image, right] = &elements[..] else { + panic!("expected three elements"); + }; + assert_eq!(left.base().start_time, MediaTime::ZERO); + assert_eq!(left.base().duration, seconds(SPLIT_SECONDS)); + assert_eq!(image.base().start_time, seconds(SPLIT_SECONDS)); + assert_eq!(image.base().duration, seconds(FREEZE_SECONDS)); + assert_eq!( + right.base().start_time, + seconds(SPLIT_SECONDS) + seconds(FREEZE_SECONDS) + ); + assert_eq!( + right.base().duration, + seconds(VIDEO_SECONDS - SPLIT_SECONDS) + ); +} + +#[test] +fn undo_restores_original_element_as_single_step() { + let (mut project, track_id) = seed_project_with_video(); + let mut batch = build_freeze_batch(&track_id); + + batch.execute(&mut project).expect("execute"); + batch.undo(&mut project); + + let scene = project + .scenes + .iter() + .find(|scene| scene.id == project.current_scene_id) + .expect("active scene"); + let elements = scene.tracks.main.elements(); + assert_eq!(elements.len(), 1); + assert_eq!(elements[0].base().id, "video-1"); + assert_eq!(elements[0].base().start_time, MediaTime::ZERO); + assert_eq!(elements[0].base().duration, seconds(VIDEO_SECONDS)); +} + +#[test] +fn redo_after_undo_reapplies_shift_to_resplit_elements() { + let (mut project, track_id) = seed_project_with_video(); + let mut batch = build_freeze_batch(&track_id); + + batch.execute(&mut project).expect("execute"); + batch.undo(&mut project); + batch.redo(&mut project).expect("redo"); + + let elements = sorted_main_elements(&project); + let type_names: Vec<&str> = elements.iter().map(element_type_name).collect(); + assert_eq!(type_names, ["video", "image", "video"]); + + let right = elements.last().expect("right element"); + assert!(matches!(right, Element::Video(_))); + assert_eq!( + right.base().start_time, + seconds(SPLIT_SECONDS) + seconds(FREEZE_SECONDS) + ); +} + +#[test] +fn no_op_when_split_produces_no_right_side() { + let (mut project, track_id) = seed_project_with_video(); + + let split = SplitElementsCommand::new( + vec![ElementRef { + track_id: track_id.to_string(), + element_id: "video-1".to_string(), + }], + MediaTime::ZERO, + RetainSide::Both, + ); + let right_side = split.right_side_handle(); + let shift = ShiftSplitRemainderCommand::new(right_side, seconds(FREEZE_SECONDS)); + let mut batch = BatchCommand::new(vec![Box::new(split), Box::new(shift)]); + + batch.execute(&mut project).expect("execute"); + batch.undo(&mut project); + + let scene = project + .scenes + .iter() + .find(|scene| scene.id == project.current_scene_id) + .expect("active scene"); + assert_eq!(scene.tracks.main.elements().len(), 1); +} |
