summaryrefslogtreecommitdiff
path: root/rust/crates/commands/src/timeline
diff options
context:
space:
mode:
authorcashmere <cashmere@autistici.org>2026-07-28 19:41:16 +0200
committercashmere <cashmere@autistici.org>2026-07-28 19:41:16 +0200
commitf5a82152394fde4d8f740f664ab7737b82861618 (patch)
tree5a51b181abead1ce03d2dbfc12b77918d3be0e1b /rust/crates/commands/src/timeline
parente837e7504379e100ff3b56282577ec67bb88bec9 (diff)
downloadopencut-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/commands/src/timeline')
-rw-r--r--rust/crates/commands/src/timeline/element/delete_elements.rs64
-rw-r--r--rust/crates/commands/src/timeline/element/mod.rs3
-rw-r--r--rust/crates/commands/src/timeline/element/move_elements.rs145
-rw-r--r--rust/crates/commands/src/timeline/element/shift_split_remainder.rs58
-rw-r--r--rust/crates/commands/src/timeline/element/split_elements.rs225
-rw-r--r--rust/crates/commands/src/timeline/element/update_elements.rs72
-rw-r--r--rust/crates/commands/src/timeline/mod.rs5
-rw-r--r--rust/crates/commands/src/timeline/track/add_track.rs69
-rw-r--r--rust/crates/commands/src/timeline/track/mod.rs9
-rw-r--r--rust/crates/commands/src/timeline/track/remove_track.rs43
-rw-r--r--rust/crates/commands/src/timeline/track/toggle_track_mute.rs49
-rw-r--r--rust/crates/commands/src/timeline/track/toggle_track_visibility.rs54
-rw-r--r--rust/crates/commands/src/timeline/tracks_snapshot.rs33
13 files changed, 829 insertions, 0 deletions
diff --git a/rust/crates/commands/src/timeline/element/delete_elements.rs b/rust/crates/commands/src/timeline/element/delete_elements.rs
new file mode 100644
index 00000000..bdcb5b6d
--- /dev/null
+++ b/rust/crates/commands/src/timeline/element/delete_elements.rs
@@ -0,0 +1,64 @@
+use scene::{ElementRef, Project, SceneTracks, Track};
+
+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()) {
+ return;
+ }
+ track.elements_mut().retain(|element| {
+ !elements
+ .iter()
+ .any(|target| target.track_id == track.id() && target.element_id == element.base().id)
+ });
+}
+
+fn remove_elements_from_tracks(tracks: &mut SceneTracks, elements: &[ElementRef]) {
+ for track in &mut tracks.overlay {
+ remove_track_elements(track, elements);
+ }
+ remove_track_elements(&mut tracks.main, elements);
+ for track in &mut tracks.audio {
+ remove_track_elements(track, elements);
+ }
+}
+
+pub struct DeleteElementsCommand {
+ elements: Vec<ElementRef>,
+ saved_state: Option<SceneTracks>,
+}
+
+impl DeleteElementsCommand {
+ pub fn new(elements: Vec<ElementRef>) -> Self {
+ Self {
+ elements,
+ saved_state: None,
+ }
+ }
+}
+
+impl Command for DeleteElementsCommand {
+ 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());
+ remove_elements_from_tracks(&mut scene.tracks, &self.elements);
+
+ Ok(Some(CommandResult::element_selection(Vec::new())))
+ }
+
+ 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
new file mode 100644
index 00000000..2ba806e1
--- /dev/null
+++ b/rust/crates/commands/src/timeline/element/mod.rs
@@ -0,0 +1,3 @@
+pub mod delete_elements;
+
+pub use delete_elements::DeleteElementsCommand;
diff --git a/rust/crates/commands/src/timeline/element/move_elements.rs b/rust/crates/commands/src/timeline/element/move_elements.rs
new file mode 100644
index 00000000..fc6425d1
--- /dev/null
+++ b/rust/crates/commands/src/timeline/element/move_elements.rs
@@ -0,0 +1,145 @@
+use scene::{Element, Project, SceneTracks, Track};
+use time::MediaTime;
+use timeline::TrackType;
+use timeline::placement::build_empty_track;
+
+use crate::command::{Command, CommandError, CommandResult};
+use crate::project_ext::active_scene_mut;
+use crate::tracks::{all_tracks_mut, find_track};
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct PlannedTrackCreation {
+ pub id: String,
+ pub track_type: TrackType,
+ pub index: usize,
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct PlannedElementMove {
+ pub source_track_id: String,
+ pub target_track_id: String,
+ pub element_id: String,
+ pub new_start_time: MediaTime,
+}
+
+fn insert_track_at_display_index(
+ tracks: &mut SceneTracks,
+ track: Track,
+ insert_index: usize,
+) {
+ if matches!(track, Track::Audio { .. }) {
+ let audio_insert_index = insert_index
+ .saturating_sub(tracks.overlay.len() + 1)
+ .min(tracks.audio.len());
+ tracks.audio.insert(audio_insert_index, track);
+ return;
+ }
+
+ let overlay_insert_index = insert_index.min(tracks.overlay.len());
+ tracks.overlay.insert(overlay_insert_index, track);
+}
+
+fn validate_compatibility(element: &Element, track: &Track) -> Result<(), CommandError> {
+ timeline::placement::validate_element_track_compatibility(element, track)
+ .map_err(CommandError::IncompatiblePlacement)
+}
+
+pub struct MoveElementCommand {
+ moves: Vec<PlannedElementMove>,
+ create_tracks: Vec<PlannedTrackCreation>,
+ saved_state: Option<SceneTracks>,
+}
+
+impl MoveElementCommand {
+ pub fn new(moves: Vec<PlannedElementMove>, create_tracks: Vec<PlannedTrackCreation>) -> Self {
+ Self {
+ moves,
+ create_tracks,
+ saved_state: None,
+ }
+ }
+}
+
+impl Command for MoveElementCommand {
+ fn execute(
+ &mut self,
+ project: &mut Project,
+ ) -> Result<Option<CommandResult>, CommandError> {
+ let Some(scene) = active_scene_mut(project) else {
+ return Ok(None);
+ };
+
+ let saved_state = scene.tracks.clone();
+ self.saved_state = Some(saved_state.clone());
+
+ 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);
+ insert_track_at_display_index(&mut scene.tracks, track, creation.index);
+ }
+
+ let mut moved_elements = Vec::new();
+ for planned_move in &self.moves {
+ let source_track = find_track(&saved_state, &planned_move.source_track_id);
+ let source_element = source_track.and_then(|track| {
+ track
+ .elements()
+ .iter()
+ .find(|element| element.base().id == planned_move.element_id)
+ });
+ let Some(source_element) = source_element else {
+ return Err(CommandError::SourceNotFound);
+ };
+
+ let Some(target_track) = find_track(&scene.tracks, &planned_move.target_track_id)
+ else {
+ return Err(CommandError::TargetTrackNotFound);
+ };
+
+ validate_compatibility(source_element, target_track)?;
+
+ let mut moved = source_element.clone();
+ moved.base_mut().start_time = planned_move.new_start_time;
+ moved_elements.push((planned_move.target_track_id.clone(), moved));
+ }
+
+ let moved_ids: Vec<&str> = self
+ .moves
+ .iter()
+ .map(|planned_move| planned_move.element_id.as_str())
+ .collect();
+
+ for track in all_tracks_mut(&mut scene.tracks) {
+ let track_id = track.id().to_string();
+ let mut elements = std::mem::take(track.elements_mut());
+ elements.retain(|element| !moved_ids.contains(&element.base().id.as_str()));
+ elements.extend(
+ moved_elements
+ .iter()
+ .filter(|(target_track_id, _)| *target_track_id == track_id)
+ .map(|(_, element)| element.clone()),
+ );
+ *track.elements_mut() = elements;
+ }
+
+ Ok(Some(CommandResult::element_selection(
+ self.moves
+ .iter()
+ .map(|planned_move| scene::ElementRef {
+ track_id: planned_move.target_track_id.clone(),
+ element_id: planned_move.element_id.clone(),
+ })
+ .collect(),
+ )))
+ }
+
+ 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/shift_split_remainder.rs b/rust/crates/commands/src/timeline/element/shift_split_remainder.rs
new file mode 100644
index 00000000..be2f78e4
--- /dev/null
+++ b/rust/crates/commands/src/timeline/element/shift_split_remainder.rs
@@ -0,0 +1,58 @@
+use std::cell::RefCell;
+use std::rc::Rc;
+
+use scene::{ElementRef, Project};
+use time::MediaTime;
+
+use crate::command::{Command, CommandError, CommandResult};
+use crate::timeline::element::move_elements::{MoveElementCommand, PlannedElementMove};
+
+pub struct ShiftSplitRemainderCommand {
+ right_side: Rc<RefCell<Vec<ElementRef>>>,
+ new_start_time: MediaTime,
+ inner: Option<MoveElementCommand>,
+}
+
+impl ShiftSplitRemainderCommand {
+ pub fn new(right_side: Rc<RefCell<Vec<ElementRef>>>, new_start_time: MediaTime) -> Self {
+ Self {
+ right_side,
+ new_start_time,
+ inner: None,
+ }
+ }
+}
+
+impl Command for ShiftSplitRemainderCommand {
+ fn execute(
+ &mut self,
+ project: &mut Project,
+ ) -> Result<Option<CommandResult>, CommandError> {
+ let right_side = self.right_side.borrow().clone();
+ if right_side.is_empty() {
+ return Ok(None);
+ }
+
+ let mut inner = MoveElementCommand::new(
+ right_side
+ .iter()
+ .map(|element| PlannedElementMove {
+ source_track_id: element.track_id.clone(),
+ target_track_id: element.track_id.clone(),
+ element_id: element.element_id.clone(),
+ new_start_time: self.new_start_time,
+ })
+ .collect(),
+ Vec::new(),
+ );
+ let result = inner.execute(project)?;
+ self.inner = Some(inner);
+ Ok(result)
+ }
+
+ fn undo(&mut self, project: &mut Project) {
+ if let Some(inner) = &mut self.inner {
+ inner.undo(project);
+ }
+ }
+}
diff --git a/rust/crates/commands/src/timeline/element/split_elements.rs b/rust/crates/commands/src/timeline/element/split_elements.rs
new file mode 100644
index 00000000..0da90792
--- /dev/null
+++ b/rust/crates/commands/src/timeline/element/split_elements.rs
@@ -0,0 +1,225 @@
+use std::cell::RefCell;
+use std::rc::Rc;
+
+use scene::{AudioElement, Element, ElementRef, Project, RetimeConfig, SceneTracks};
+use time::MediaTime;
+use uuid::Uuid;
+
+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>,
+ split_time: MediaTime,
+ retain_side: RetainSide,
+ saved_state: Option<SceneTracks>,
+ right_side: Rc<RefCell<Vec<ElementRef>>>,
+}
+
+impl SplitElementsCommand {
+ pub fn new(elements: Vec<ElementRef>, split_time: MediaTime, retain_side: RetainSide) -> Self {
+ Self {
+ elements,
+ split_time,
+ retain_side,
+ saved_state: None,
+ right_side: Rc::new(RefCell::new(Vec::new())),
+ }
+ }
+
+ pub fn right_side_handle(&self) -> Rc<RefCell<Vec<ElementRef>>> {
+ Rc::clone(&self.right_side)
+ }
+
+ 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 {
+ 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.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 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;
+ }
+
+ let right_side = self.right_side.borrow();
+ if right_side.is_empty() {
+ return Ok(None);
+ }
+ Ok(Some(CommandResult::element_selection(right_side.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/update_elements.rs b/rust/crates/commands/src/timeline/element/update_elements.rs
new file mode 100644
index 00000000..f5931bb7
--- /dev/null
+++ b/rust/crates/commands/src/timeline/element/update_elements.rs
@@ -0,0 +1,72 @@
+use scene::{ElementRef, 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)]
+pub struct ElementUpdate {
+ pub track_id: String,
+ pub element_id: String,
+ pub patch: ElementPatch,
+}
+
+pub struct UpdateElementsCommand {
+ updates: Vec<ElementUpdate>,
+ saved_state: Option<SceneTracks>,
+}
+
+impl UpdateElementsCommand {
+ pub fn new(updates: Vec<ElementUpdate>) -> Self {
+ Self {
+ updates,
+ saved_state: None,
+ }
+ }
+}
+
+impl Command for UpdateElementsCommand {
+ 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());
+
+ for update in &self.updates {
+ let context_tracks = scene.tracks.clone();
+ let context = ElementUpdateContext {
+ tracks: &context_tracks,
+ track_id: &update.track_id,
+ };
+ let Some(track) = find_track_mut(&mut scene.tracks, &update.track_id) else {
+ continue;
+ };
+ let Some(element) = track
+ .elements_mut()
+ .iter_mut()
+ .find(|element| element.base().id == update.element_id)
+ else {
+ continue;
+ };
+
+ let current = element.clone();
+ *element = apply_element_update(current, update.patch.clone(), &context);
+ }
+
+ Ok(None)
+ }
+
+ 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/mod.rs b/rust/crates/commands/src/timeline/mod.rs
new file mode 100644
index 00000000..532885ce
--- /dev/null
+++ b/rust/crates/commands/src/timeline/mod.rs
@@ -0,0 +1,5 @@
+pub mod element;
+pub mod track;
+pub mod tracks_snapshot;
+
+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
new file mode 100644
index 00000000..d6e9bbae
--- /dev/null
+++ b/rust/crates/commands/src/timeline/track/add_track.rs
@@ -0,0 +1,69 @@
+use scene::{Project, SceneTracks};
+use timeline::placement::{build_empty_track, get_default_insert_index_for_track};
+use timeline::TrackType;
+use uuid::Uuid;
+
+use crate::command::{Command, CommandError, CommandResult};
+use crate::project_ext::active_scene_mut;
+
+pub struct AddTrackCommand {
+ track_type: TrackType,
+ index: Option<usize>,
+ track_id: String,
+ saved_state: Option<SceneTracks>,
+}
+
+impl AddTrackCommand {
+ pub fn new(track_type: TrackType, index: Option<usize>) -> Self {
+ Self {
+ track_type,
+ index,
+ track_id: Uuid::new_v4().to_string(),
+ saved_state: None,
+ }
+ }
+
+ pub fn track_id(&self) -> &str {
+ &self.track_id
+ }
+}
+
+impl Command for AddTrackCommand {
+ 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());
+
+ let insert_index = self.index.unwrap_or_else(|| {
+ get_default_insert_index_for_track(&scene.tracks, self.track_type)
+ });
+
+ let new_track = build_empty_track(&self.track_id, self.track_type, None);
+ if self.track_type == TrackType::Audio {
+ let audio_insert_index = insert_index
+ .saturating_sub(scene.tracks.overlay.len() + 1);
+ scene
+ .tracks
+ .audio
+ .insert(audio_insert_index.min(scene.tracks.audio.len()), new_track);
+ } else {
+ let overlay_insert_index = insert_index.min(scene.tracks.overlay.len());
+ scene.tracks.overlay.insert(overlay_insert_index, new_track);
+ }
+ Ok(None)
+ }
+
+ 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/track/mod.rs b/rust/crates/commands/src/timeline/track/mod.rs
new file mode 100644
index 00000000..c133a12d
--- /dev/null
+++ b/rust/crates/commands/src/timeline/track/mod.rs
@@ -0,0 +1,9 @@
+pub mod add_track;
+pub mod remove_track;
+pub mod toggle_track_mute;
+pub mod toggle_track_visibility;
+
+pub use add_track::AddTrackCommand;
+pub use remove_track::RemoveTrackCommand;
+pub use toggle_track_mute::ToggleTrackMuteCommand;
+pub use toggle_track_visibility::ToggleTrackVisibilityCommand;
diff --git a/rust/crates/commands/src/timeline/track/remove_track.rs b/rust/crates/commands/src/timeline/track/remove_track.rs
new file mode 100644
index 00000000..5a79b4ae
--- /dev/null
+++ b/rust/crates/commands/src/timeline/track/remove_track.rs
@@ -0,0 +1,43 @@
+use scene::{Project, SceneTracks};
+
+use crate::command::{Command, CommandError, CommandResult};
+use crate::project_ext::active_scene_mut;
+
+pub struct RemoveTrackCommand {
+ track_id: String,
+ saved_state: Option<SceneTracks>,
+}
+
+impl RemoveTrackCommand {
+ pub fn new(track_id: impl Into<String>) -> Self {
+ Self {
+ track_id: track_id.into(),
+ saved_state: None,
+ }
+ }
+}
+
+impl Command for RemoveTrackCommand {
+ 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());
+ scene.tracks.overlay.retain(|track| track.id() != self.track_id);
+ scene.tracks.audio.retain(|track| track.id() != self.track_id);
+ Ok(None)
+ }
+
+ 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/track/toggle_track_mute.rs b/rust/crates/commands/src/timeline/track/toggle_track_mute.rs
new file mode 100644
index 00000000..afaaf3f3
--- /dev/null
+++ b/rust/crates/commands/src/timeline/track/toggle_track_mute.rs
@@ -0,0 +1,49 @@
+use scene::{Project, SceneTracks, Track};
+
+use crate::command::{Command, CommandError, CommandResult};
+use crate::project_ext::active_scene_mut;
+use crate::tracks::find_track_mut;
+
+pub struct ToggleTrackMuteCommand {
+ track_id: String,
+ saved_state: Option<SceneTracks>,
+}
+
+impl ToggleTrackMuteCommand {
+ pub fn new(track_id: impl Into<String>) -> Self {
+ Self {
+ track_id: track_id.into(),
+ saved_state: None,
+ }
+ }
+}
+
+impl Command for ToggleTrackMuteCommand {
+ 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());
+
+ match find_track_mut(&mut scene.tracks, &self.track_id) {
+ Some(Track::Video { muted, .. }) | Some(Track::Audio { muted, .. }) => {
+ *muted = !*muted;
+ }
+ _ => {}
+ }
+ Ok(None)
+ }
+
+ 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/track/toggle_track_visibility.rs b/rust/crates/commands/src/timeline/track/toggle_track_visibility.rs
new file mode 100644
index 00000000..79000a73
--- /dev/null
+++ b/rust/crates/commands/src/timeline/track/toggle_track_visibility.rs
@@ -0,0 +1,54 @@
+use scene::{Project, SceneTracks, Track};
+
+use crate::command::{Command, CommandError, CommandResult};
+use crate::project_ext::active_scene_mut;
+use crate::tracks::find_track_mut;
+
+pub struct ToggleTrackVisibilityCommand {
+ track_id: String,
+ saved_state: Option<SceneTracks>,
+}
+
+impl ToggleTrackVisibilityCommand {
+ pub fn new(track_id: impl Into<String>) -> Self {
+ Self {
+ track_id: track_id.into(),
+ saved_state: None,
+ }
+ }
+}
+
+impl Command for ToggleTrackVisibilityCommand {
+ 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());
+
+ match find_track_mut(&mut scene.tracks, &self.track_id) {
+ Some(
+ Track::Video { hidden, .. }
+ | Track::Text { hidden, .. }
+ | Track::Graphic { hidden, .. }
+ | Track::Effect { hidden, .. },
+ ) => {
+ *hidden = !*hidden;
+ }
+ _ => {}
+ }
+ Ok(None)
+ }
+
+ 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/tracks_snapshot.rs b/rust/crates/commands/src/timeline/tracks_snapshot.rs
new file mode 100644
index 00000000..5108e123
--- /dev/null
+++ b/rust/crates/commands/src/timeline/tracks_snapshot.rs
@@ -0,0 +1,33 @@
+use scene::{Project, SceneTracks};
+
+use crate::command::{Command, CommandError, CommandResult};
+use crate::project_ext::active_scene_mut;
+
+pub struct TracksSnapshotCommand {
+ before: SceneTracks,
+ after: SceneTracks,
+}
+
+impl TracksSnapshotCommand {
+ pub fn new(before: SceneTracks, after: SceneTracks) -> Self {
+ Self { before, after }
+ }
+}
+
+impl Command for TracksSnapshotCommand {
+ fn execute(
+ &mut self,
+ project: &mut Project,
+ ) -> Result<Option<CommandResult>, CommandError> {
+ if let Some(scene) = active_scene_mut(project) {
+ scene.tracks = self.after.clone();
+ }
+ Ok(None)
+ }
+
+ fn undo(&mut self, project: &mut Project) {
+ if let Some(scene) = active_scene_mut(project) {
+ scene.tracks = self.before.clone();
+ }
+ }
+}