summaryrefslogtreecommitdiff
path: root/rust/crates/commands
diff options
context:
space:
mode:
authorcashmere <cashmere@autistici.org>2026-07-28 20:14:51 +0200
committercashmere <cashmere@autistici.org>2026-07-28 20:14:51 +0200
commit32438ce25f69de99d2523b98237f62cfa90890d7 (patch)
tree3a712c347dec0889bdea3328b4c817c2d9392b27 /rust/crates/commands
parentf5a82152394fde4d8f740f664ab7737b82861618 (diff)
downloadopencut-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.rs8
-rw-r--r--rust/crates/commands/src/timeline/element/delete_elements.rs5
-rw-r--r--rust/crates/commands/src/timeline/element/duplicate_elements.rs131
-rw-r--r--rust/crates/commands/src/timeline/element/insert_element.rs195
-rw-r--r--rust/crates/commands/src/timeline/element/mod.rs12
-rw-r--r--rust/crates/commands/src/timeline/element/move_elements.rs14
-rw-r--r--rust/crates/commands/src/timeline/element/split_elements.rs170
-rw-r--r--rust/crates/commands/src/timeline/element/update_elements.rs7
-rw-r--r--rust/crates/commands/src/timeline/mod.rs2
-rw-r--r--rust/crates/commands/src/timeline/track/add_track.rs2
-rw-r--r--rust/crates/commands/tests/common/mod.rs118
-rw-r--r--rust/crates/commands/tests/framework.rs162
-rw-r--r--rust/crates/commands/tests/freeze_frame.rs155
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);
+}