summaryrefslogtreecommitdiff
path: root/rust/crates/commands/src
diff options
context:
space:
mode:
Diffstat (limited to 'rust/crates/commands/src')
-rw-r--r--rust/crates/commands/src/batch_command.rs61
-rw-r--r--rust/crates/commands/src/bookmarks.rs94
-rw-r--r--rust/crates/commands/src/command.rs46
-rw-r--r--rust/crates/commands/src/history.rs68
-rw-r--r--rust/crates/commands/src/lib.rs18
-rw-r--r--rust/crates/commands/src/preview_tracker.rs28
-rw-r--r--rust/crates/commands/src/project/mod.rs3
-rw-r--r--rust/crates/commands/src/project/update_project_settings.rs77
-rw-r--r--rust/crates/commands/src/project_ext.rs20
-rw-r--r--rust/crates/commands/src/scene/create_scene.rs46
-rw-r--r--rust/crates/commands/src/scene/delete_scene.rs61
-rw-r--r--rust/crates/commands/src/scene/mod.rs15
-rw-r--r--rust/crates/commands/src/scene/move_bookmark.rs50
-rw-r--r--rust/crates/commands/src/scene/remove_bookmark.rs54
-rw-r--r--rust/crates/commands/src/scene/rename_scene.rs58
-rw-r--r--rust/crates/commands/src/scene/toggle_bookmark.rs48
-rw-r--r--rust/crates/commands/src/scene/update_bookmark.rs49
-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
-rw-r--r--rust/crates/commands/src/tracks.rs42
31 files changed, 1667 insertions, 0 deletions
diff --git a/rust/crates/commands/src/batch_command.rs b/rust/crates/commands/src/batch_command.rs
new file mode 100644
index 00000000..bd928d33
--- /dev/null
+++ b/rust/crates/commands/src/batch_command.rs
@@ -0,0 +1,61 @@
+use scene::Project;
+
+use crate::command::{Command, CommandError, CommandResult};
+
+pub struct BatchCommand {
+ commands: Vec<Box<dyn Command>>,
+}
+
+impl BatchCommand {
+ pub fn new(commands: Vec<Box<dyn Command>>) -> Self {
+ Self { commands }
+ }
+
+ pub fn len(&self) -> usize {
+ self.commands.len()
+ }
+
+ pub fn is_empty(&self) -> bool {
+ self.commands.is_empty()
+ }
+}
+
+impl Command for BatchCommand {
+ fn execute(
+ &mut self,
+ project: &mut Project,
+ ) -> Result<Option<CommandResult>, CommandError> {
+ let mut latest_selection_result = None;
+
+ for command in &mut self.commands {
+ let result = command.execute(project)?;
+ if result.as_ref().and_then(|r| r.selection.as_ref()).is_some() {
+ latest_selection_result = result;
+ }
+ }
+
+ Ok(latest_selection_result)
+ }
+
+ fn undo(&mut self, project: &mut Project) {
+ for command in self.commands.iter_mut().rev() {
+ command.undo(project);
+ }
+ }
+
+ fn redo(
+ &mut self,
+ project: &mut Project,
+ ) -> Result<Option<CommandResult>, CommandError> {
+ let mut latest_selection_result = None;
+
+ for command in &mut self.commands {
+ let result = command.redo(project)?;
+ if result.as_ref().and_then(|r| r.selection.as_ref()).is_some() {
+ latest_selection_result = result;
+ }
+ }
+
+ Ok(latest_selection_result)
+ }
+}
diff --git a/rust/crates/commands/src/bookmarks.rs b/rust/crates/commands/src/bookmarks.rs
new file mode 100644
index 00000000..79a73015
--- /dev/null
+++ b/rust/crates/commands/src/bookmarks.rs
@@ -0,0 +1,94 @@
+use scene::Bookmark;
+use time::{FrameRate, MediaTime};
+
+#[derive(Clone, Debug, Default, PartialEq)]
+pub struct BookmarkPatch {
+ pub note: Option<Option<String>>,
+ pub color: Option<Option<String>>,
+ pub duration: Option<Option<MediaTime>>,
+}
+
+pub fn find_bookmark_index(bookmarks: &[Bookmark], frame_time: MediaTime) -> Option<usize> {
+ bookmarks.iter().position(|bookmark| bookmark.time == frame_time)
+}
+
+pub fn is_bookmark_at_time(bookmarks: &[Bookmark], frame_time: MediaTime) -> bool {
+ find_bookmark_index(bookmarks, frame_time).is_some()
+}
+
+pub fn toggle_bookmark_in_array(
+ bookmarks: &[Bookmark],
+ frame_time: MediaTime,
+) -> Vec<Bookmark> {
+ if let Some(index) = find_bookmark_index(bookmarks, frame_time) {
+ return bookmarks
+ .iter()
+ .enumerate()
+ .filter(|(bookmark_index, _)| *bookmark_index != index)
+ .map(|(_, bookmark)| bookmark.clone())
+ .collect();
+ }
+
+ let mut next = bookmarks.to_vec();
+ next.push(Bookmark {
+ time: frame_time,
+ note: None,
+ color: None,
+ duration: None,
+ });
+ next.sort_by_key(|bookmark| bookmark.time);
+ next
+}
+
+pub fn remove_bookmark_from_array(
+ bookmarks: &[Bookmark],
+ frame_time: MediaTime,
+) -> Vec<Bookmark> {
+ bookmarks
+ .iter()
+ .filter(|bookmark| bookmark.time != frame_time)
+ .cloned()
+ .collect()
+}
+
+pub fn update_bookmark_in_array(
+ bookmarks: &[Bookmark],
+ frame_time: MediaTime,
+ updates: &BookmarkPatch,
+) -> Vec<Bookmark> {
+ let Some(index) = find_bookmark_index(bookmarks, frame_time) else {
+ return bookmarks.to_vec();
+ };
+
+ let mut next = bookmarks.to_vec();
+ let bookmark = &mut next[index];
+ if let Some(note) = &updates.note {
+ bookmark.note = note.clone();
+ }
+ if let Some(color) = &updates.color {
+ bookmark.color = color.clone();
+ }
+ if let Some(duration) = &updates.duration {
+ bookmark.duration = *duration;
+ }
+ next
+}
+
+pub fn move_bookmark_in_array(
+ bookmarks: &[Bookmark],
+ from_time: MediaTime,
+ to_time: MediaTime,
+) -> Vec<Bookmark> {
+ let Some(index) = find_bookmark_index(bookmarks, from_time) else {
+ return bookmarks.to_vec();
+ };
+
+ let mut next = bookmarks.to_vec();
+ next[index].time = to_time;
+ next.sort_by_key(|bookmark| bookmark.time);
+ next
+}
+
+pub fn get_frame_time(time: MediaTime, fps: FrameRate) -> MediaTime {
+ time.round_to_frame(fps).unwrap_or(time)
+}
diff --git a/rust/crates/commands/src/command.rs b/rust/crates/commands/src/command.rs
new file mode 100644
index 00000000..c13468e7
--- /dev/null
+++ b/rust/crates/commands/src/command.rs
@@ -0,0 +1,46 @@
+use scene::{ElementRef, Project};
+use thiserror::Error;
+
+#[derive(Debug, Error)]
+pub enum CommandError {
+ #[error("source track or element not found")]
+ SourceNotFound,
+ #[error("target track not found")]
+ TargetTrackNotFound,
+ #[error("{0}")]
+ IncompatiblePlacement(String),
+}
+
+#[derive(Clone, Debug, Default, PartialEq)]
+pub struct SelectionPatch {
+ pub selected_elements: Vec<ElementRef>,
+}
+
+#[derive(Clone, Debug, Default, PartialEq)]
+pub struct CommandResult {
+ pub selection: Option<SelectionPatch>,
+}
+
+impl CommandResult {
+ pub fn element_selection(selected_elements: Vec<ElementRef>) -> Self {
+ Self {
+ selection: Some(SelectionPatch { selected_elements }),
+ }
+ }
+}
+
+pub trait Command {
+ fn execute(
+ &mut self,
+ project: &mut Project,
+ ) -> Result<Option<CommandResult>, CommandError>;
+
+ fn undo(&mut self, project: &mut Project);
+
+ fn redo(
+ &mut self,
+ project: &mut Project,
+ ) -> Result<Option<CommandResult>, CommandError> {
+ self.execute(project)
+ }
+}
diff --git a/rust/crates/commands/src/history.rs b/rust/crates/commands/src/history.rs
new file mode 100644
index 00000000..f8792373
--- /dev/null
+++ b/rust/crates/commands/src/history.rs
@@ -0,0 +1,68 @@
+use scene::Project;
+
+use crate::command::{Command, CommandError, CommandResult};
+
+#[derive(Default)]
+pub struct CommandHistory {
+ history: Vec<Box<dyn Command>>,
+ redo_stack: Vec<Box<dyn Command>>,
+}
+
+impl CommandHistory {
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ pub fn execute(
+ &mut self,
+ mut command: Box<dyn Command>,
+ project: &mut Project,
+ ) -> Result<Option<CommandResult>, CommandError> {
+ let result = command.execute(project)?;
+ self.history.push(command);
+ self.redo_stack.clear();
+ Ok(result)
+ }
+
+ pub fn push(&mut self, command: Box<dyn Command>) {
+ self.history.push(command);
+ self.redo_stack.clear();
+ }
+
+ pub fn undo(&mut self, project: &mut Project) {
+ let Some(mut command) = self.history.pop() else {
+ return;
+ };
+ command.undo(project);
+ self.redo_stack.push(command);
+ }
+
+ pub fn redo(
+ &mut self,
+ project: &mut Project,
+ ) -> Result<Option<CommandResult>, CommandError> {
+ let Some(mut command) = self.redo_stack.pop() else {
+ return Ok(None);
+ };
+ let result = command.redo(project)?;
+ self.history.push(command);
+ Ok(result)
+ }
+
+ pub fn can_undo(&self) -> bool {
+ !self.history.is_empty()
+ }
+
+ pub fn can_redo(&self) -> bool {
+ !self.redo_stack.is_empty()
+ }
+
+ pub fn len(&self) -> usize {
+ self.history.len()
+ }
+
+ pub fn clear(&mut self) {
+ self.history.clear();
+ self.redo_stack.clear();
+ }
+}
diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs
new file mode 100644
index 00000000..3fede6bb
--- /dev/null
+++ b/rust/crates/commands/src/lib.rs
@@ -0,0 +1,18 @@
+pub mod batch_command;
+pub mod bookmarks;
+pub mod command;
+pub mod history;
+pub mod preview_tracker;
+pub mod project;
+pub mod project_ext;
+pub mod scene;
+pub mod timeline;
+pub mod tracks;
+
+pub use batch_command::BatchCommand;
+pub use command::{Command, CommandError, CommandResult, SelectionPatch};
+pub use history::CommandHistory;
+pub use preview_tracker::PreviewTracker;
+pub use project::*;
+pub use scene::*;
+pub use timeline::*;
diff --git a/rust/crates/commands/src/preview_tracker.rs b/rust/crates/commands/src/preview_tracker.rs
new file mode 100644
index 00000000..4eb7484d
--- /dev/null
+++ b/rust/crates/commands/src/preview_tracker.rs
@@ -0,0 +1,28 @@
+#[derive(Clone, Debug, Default)]
+pub struct PreviewTracker<T> {
+ snapshot: Option<T>,
+}
+
+impl<T: Clone> PreviewTracker<T> {
+ pub fn new() -> Self {
+ Self { snapshot: None }
+ }
+
+ pub fn begin(&mut self, state: &T) {
+ if self.snapshot.is_none() {
+ self.snapshot = Some(state.clone());
+ }
+ }
+
+ pub fn is_active(&self) -> bool {
+ self.snapshot.is_some()
+ }
+
+ pub fn snapshot(&self) -> Option<&T> {
+ self.snapshot.as_ref()
+ }
+
+ pub fn end(&mut self) -> Option<T> {
+ self.snapshot.take()
+ }
+}
diff --git a/rust/crates/commands/src/project/mod.rs b/rust/crates/commands/src/project/mod.rs
new file mode 100644
index 00000000..28e9c92c
--- /dev/null
+++ b/rust/crates/commands/src/project/mod.rs
@@ -0,0 +1,3 @@
+pub mod update_project_settings;
+
+pub use update_project_settings::{ProjectSettingsPatch, UpdateProjectSettingsCommand};
diff --git a/rust/crates/commands/src/project/update_project_settings.rs b/rust/crates/commands/src/project/update_project_settings.rs
new file mode 100644
index 00000000..a0f6e99c
--- /dev/null
+++ b/rust/crates/commands/src/project/update_project_settings.rs
@@ -0,0 +1,77 @@
+use scene::{Background, CanvasSize, CanvasSizeMode, Project, ProjectSettings};
+use time::FrameRate;
+
+use crate::command::{Command, CommandError, CommandResult};
+
+#[derive(Clone, Debug, Default, PartialEq)]
+pub struct ProjectSettingsPatch {
+ pub fps: Option<FrameRate>,
+ pub canvas_size: Option<CanvasSize>,
+ pub canvas_size_mode: Option<Option<CanvasSizeMode>>,
+ pub last_custom_canvas_size: Option<Option<CanvasSize>>,
+ pub original_canvas_size: Option<Option<CanvasSize>>,
+ pub background: Option<Background>,
+}
+
+impl ProjectSettingsPatch {
+ pub fn apply_to(&self, settings: &mut ProjectSettings) {
+ if let Some(fps) = self.fps {
+ settings.fps = fps;
+ }
+ if let Some(canvas_size) = self.canvas_size {
+ settings.canvas_size = canvas_size;
+ }
+ if let Some(canvas_size_mode) = &self.canvas_size_mode {
+ settings.canvas_size_mode = *canvas_size_mode;
+ }
+ if let Some(last_custom_canvas_size) = &self.last_custom_canvas_size {
+ settings.last_custom_canvas_size = *last_custom_canvas_size;
+ }
+ if let Some(original_canvas_size) = &self.original_canvas_size {
+ settings.original_canvas_size = *original_canvas_size;
+ }
+ if let Some(background) = &self.background {
+ settings.background = background.clone();
+ }
+ }
+}
+
+pub struct UpdateProjectSettingsCommand {
+ updates: ProjectSettingsPatch,
+ saved_settings: Option<ProjectSettings>,
+ saved_updated_at: Option<chrono::DateTime<chrono::Utc>>,
+}
+
+impl UpdateProjectSettingsCommand {
+ pub fn new(updates: ProjectSettingsPatch) -> Self {
+ Self {
+ updates,
+ saved_settings: None,
+ saved_updated_at: None,
+ }
+ }
+}
+
+impl Command for UpdateProjectSettingsCommand {
+ fn execute(
+ &mut self,
+ project: &mut Project,
+ ) -> Result<Option<CommandResult>, CommandError> {
+ self.saved_settings = Some(project.settings.clone());
+ self.saved_updated_at = Some(project.metadata.updated_at);
+
+ self.updates.apply_to(&mut project.settings);
+ project.metadata.updated_at = chrono::Utc::now();
+ Ok(None)
+ }
+
+ fn undo(&mut self, project: &mut Project) {
+ let (Some(saved_settings), Some(saved_updated_at)) =
+ (&self.saved_settings, self.saved_updated_at)
+ else {
+ return;
+ };
+ project.settings = saved_settings.clone();
+ project.metadata.updated_at = saved_updated_at;
+ }
+}
diff --git a/rust/crates/commands/src/project_ext.rs b/rust/crates/commands/src/project_ext.rs
new file mode 100644
index 00000000..18262fbd
--- /dev/null
+++ b/rust/crates/commands/src/project_ext.rs
@@ -0,0 +1,20 @@
+use scene::{Project, Scene};
+
+pub fn active_scene(project: &Project) -> Option<&Scene> {
+ project
+ .scenes
+ .iter()
+ .find(|scene| scene.id == project.current_scene_id)
+ .or_else(|| project.scenes.iter().find(|scene| scene.is_main))
+ .or_else(|| project.scenes.first())
+}
+
+pub fn active_scene_mut(project: &mut Project) -> Option<&mut Scene> {
+ let index = project
+ .scenes
+ .iter()
+ .position(|scene| scene.id == project.current_scene_id)
+ .or_else(|| project.scenes.iter().position(|scene| scene.is_main))
+ .or(if project.scenes.is_empty() { None } else { Some(0) })?;
+ project.scenes.get_mut(index)
+}
diff --git a/rust/crates/commands/src/scene/create_scene.rs b/rust/crates/commands/src/scene/create_scene.rs
new file mode 100644
index 00000000..1f2de02b
--- /dev/null
+++ b/rust/crates/commands/src/scene/create_scene.rs
@@ -0,0 +1,46 @@
+use scene::{Project, Scene};
+
+use crate::command::{Command, CommandError, CommandResult};
+
+pub struct CreateSceneCommand {
+ name: String,
+ is_main: bool,
+ saved_scenes: Option<Vec<Scene>>,
+ created_scene_id: Option<String>,
+}
+
+impl CreateSceneCommand {
+ pub fn new(name: impl Into<String>, is_main: bool) -> Self {
+ Self {
+ name: name.into(),
+ is_main,
+ saved_scenes: None,
+ created_scene_id: None,
+ }
+ }
+
+ pub fn scene_id(&self) -> Option<&str> {
+ self.created_scene_id.as_deref()
+ }
+}
+
+impl Command for CreateSceneCommand {
+ fn execute(
+ &mut self,
+ project: &mut Project,
+ ) -> Result<Option<CommandResult>, CommandError> {
+ self.saved_scenes = Some(project.scenes.clone());
+
+ let created = timeline::scenes::build_default_scene(&self.name, self.is_main);
+ self.created_scene_id = Some(created.id.clone());
+ project.scenes.push(created);
+ Ok(None)
+ }
+
+ fn undo(&mut self, project: &mut Project) {
+ let Some(saved) = &self.saved_scenes else {
+ return;
+ };
+ project.scenes = saved.clone();
+ }
+}
diff --git a/rust/crates/commands/src/scene/delete_scene.rs b/rust/crates/commands/src/scene/delete_scene.rs
new file mode 100644
index 00000000..61763dfd
--- /dev/null
+++ b/rust/crates/commands/src/scene/delete_scene.rs
@@ -0,0 +1,61 @@
+use scene::{Project, Scene};
+
+use crate::command::{Command, CommandError, CommandResult};
+
+pub struct DeleteSceneCommand {
+ scene_id: String,
+ saved_scenes: Option<Vec<Scene>>,
+ saved_current_scene_id: Option<String>,
+}
+
+impl DeleteSceneCommand {
+ pub fn new(scene_id: impl Into<String>) -> Self {
+ Self {
+ scene_id: scene_id.into(),
+ saved_scenes: None,
+ saved_current_scene_id: None,
+ }
+ }
+}
+
+impl Command for DeleteSceneCommand {
+ fn execute(
+ &mut self,
+ project: &mut Project,
+ ) -> Result<Option<CommandResult>, CommandError> {
+ let Some(deleted) = project.scenes.iter().find(|scene| scene.id == self.scene_id)
+ else {
+ return Ok(None);
+ };
+ if deleted.is_main {
+ return Ok(None);
+ }
+
+ self.saved_scenes = Some(project.scenes.clone());
+ self.saved_current_scene_id = Some(project.current_scene_id.clone());
+
+ project.scenes.retain(|scene| scene.id != self.scene_id);
+
+ if project.current_scene_id == self.scene_id {
+ let fallback_id = project
+ .scenes
+ .iter()
+ .find(|scene| scene.is_main)
+ .map(|scene| scene.id.clone());
+ if let Some(fallback_id) = fallback_id {
+ project.current_scene_id = fallback_id;
+ }
+ }
+ Ok(None)
+ }
+
+ fn undo(&mut self, project: &mut Project) {
+ let (Some(saved_scenes), Some(saved_current_scene_id)) =
+ (&self.saved_scenes, &self.saved_current_scene_id)
+ else {
+ return;
+ };
+ project.scenes = saved_scenes.clone();
+ project.current_scene_id = saved_current_scene_id.clone();
+ }
+}
diff --git a/rust/crates/commands/src/scene/mod.rs b/rust/crates/commands/src/scene/mod.rs
new file mode 100644
index 00000000..10d687c1
--- /dev/null
+++ b/rust/crates/commands/src/scene/mod.rs
@@ -0,0 +1,15 @@
+pub mod create_scene;
+pub mod delete_scene;
+pub mod move_bookmark;
+pub mod remove_bookmark;
+pub mod rename_scene;
+pub mod toggle_bookmark;
+pub mod update_bookmark;
+
+pub use create_scene::CreateSceneCommand;
+pub use delete_scene::DeleteSceneCommand;
+pub use move_bookmark::MoveBookmarkCommand;
+pub use remove_bookmark::RemoveBookmarkCommand;
+pub use rename_scene::RenameSceneCommand;
+pub use toggle_bookmark::ToggleBookmarkCommand;
+pub use update_bookmark::UpdateBookmarkCommand;
diff --git a/rust/crates/commands/src/scene/move_bookmark.rs b/rust/crates/commands/src/scene/move_bookmark.rs
new file mode 100644
index 00000000..dddef11f
--- /dev/null
+++ b/rust/crates/commands/src/scene/move_bookmark.rs
@@ -0,0 +1,50 @@
+use scene::Project;
+use time::MediaTime;
+
+use crate::bookmarks::{get_frame_time, move_bookmark_in_array};
+use crate::command::{Command, CommandError, CommandResult};
+use crate::project_ext::active_scene_mut;
+
+pub struct MoveBookmarkCommand {
+ from_time: MediaTime,
+ to_time: MediaTime,
+ saved_bookmarks: Option<Vec<scene::Bookmark>>,
+}
+
+impl MoveBookmarkCommand {
+ pub fn new(from_time: MediaTime, to_time: MediaTime) -> Self {
+ Self {
+ from_time,
+ to_time,
+ saved_bookmarks: None,
+ }
+ }
+}
+
+impl Command for MoveBookmarkCommand {
+ fn execute(
+ &mut self,
+ project: &mut Project,
+ ) -> Result<Option<CommandResult>, CommandError> {
+ let fps = project.settings.fps;
+ let Some(scene) = active_scene_mut(project) else {
+ return Ok(None);
+ };
+
+ self.saved_bookmarks = Some(scene.bookmarks.clone());
+ let from_frame_time = get_frame_time(self.from_time, fps);
+ let to_frame_time = get_frame_time(self.to_time, fps);
+ scene.bookmarks =
+ move_bookmark_in_array(&scene.bookmarks, from_frame_time, to_frame_time);
+ Ok(None)
+ }
+
+ fn undo(&mut self, project: &mut Project) {
+ let Some(saved) = &self.saved_bookmarks else {
+ return;
+ };
+ if let Some(scene) = active_scene_mut(project) {
+ scene.bookmarks = saved.clone();
+ }
+ }
+}
diff --git a/rust/crates/commands/src/scene/remove_bookmark.rs b/rust/crates/commands/src/scene/remove_bookmark.rs
new file mode 100644
index 00000000..f8fe0c5a
--- /dev/null
+++ b/rust/crates/commands/src/scene/remove_bookmark.rs
@@ -0,0 +1,54 @@
+use scene::Project;
+use time::MediaTime;
+
+use crate::bookmarks::{get_frame_time, remove_bookmark_from_array};
+use crate::command::{Command, CommandError, CommandResult};
+use crate::project_ext::active_scene_mut;
+
+pub struct RemoveBookmarkCommand {
+ time: MediaTime,
+ frame_time: MediaTime,
+ saved_bookmarks: Option<Vec<scene::Bookmark>>,
+}
+
+impl RemoveBookmarkCommand {
+ pub fn new(time: MediaTime) -> Self {
+ Self {
+ time,
+ frame_time: MediaTime::ZERO,
+ saved_bookmarks: None,
+ }
+ }
+}
+
+impl Command for RemoveBookmarkCommand {
+ fn execute(
+ &mut self,
+ project: &mut Project,
+ ) -> Result<Option<CommandResult>, CommandError> {
+ let fps = project.settings.fps;
+ let Some(scene) = active_scene_mut(project) else {
+ return Ok(None);
+ };
+
+ self.saved_bookmarks = Some(scene.bookmarks.clone());
+ self.frame_time = get_frame_time(self.time, fps);
+
+ let updated = remove_bookmark_from_array(&scene.bookmarks, self.frame_time);
+ if updated.len() == scene.bookmarks.len() {
+ return Ok(None);
+ }
+
+ scene.bookmarks = updated;
+ Ok(None)
+ }
+
+ fn undo(&mut self, project: &mut Project) {
+ let Some(saved) = &self.saved_bookmarks else {
+ return;
+ };
+ if let Some(scene) = active_scene_mut(project) {
+ scene.bookmarks = saved.clone();
+ }
+ }
+}
diff --git a/rust/crates/commands/src/scene/rename_scene.rs b/rust/crates/commands/src/scene/rename_scene.rs
new file mode 100644
index 00000000..4876fb9b
--- /dev/null
+++ b/rust/crates/commands/src/scene/rename_scene.rs
@@ -0,0 +1,58 @@
+use scene::Project;
+
+use crate::command::{Command, CommandError, CommandResult};
+
+pub struct RenameSceneCommand {
+ scene_id: String,
+ new_name: String,
+ saved_name: Option<String>,
+ saved_updated_at: Option<chrono::DateTime<chrono::Utc>>,
+}
+
+impl RenameSceneCommand {
+ pub fn new(scene_id: impl Into<String>, new_name: impl Into<String>) -> Self {
+ Self {
+ scene_id: scene_id.into(),
+ new_name: new_name.into(),
+ saved_name: None,
+ saved_updated_at: None,
+ }
+ }
+}
+
+impl Command for RenameSceneCommand {
+ fn execute(
+ &mut self,
+ project: &mut Project,
+ ) -> Result<Option<CommandResult>, CommandError> {
+ let Some(scene) = project
+ .scenes
+ .iter_mut()
+ .find(|scene| scene.id == self.scene_id)
+ else {
+ return Ok(None);
+ };
+
+ self.saved_name = Some(scene.name.clone());
+ self.saved_updated_at = Some(scene.updated_at);
+ scene.name = self.new_name.clone();
+ scene.updated_at = chrono::Utc::now();
+ Ok(None)
+ }
+
+ fn undo(&mut self, project: &mut Project) {
+ let (Some(saved_name), Some(saved_updated_at)) =
+ (&self.saved_name, self.saved_updated_at)
+ else {
+ return;
+ };
+ if let Some(scene) = project
+ .scenes
+ .iter_mut()
+ .find(|scene| scene.id == self.scene_id)
+ {
+ scene.name = saved_name.clone();
+ scene.updated_at = saved_updated_at;
+ }
+ }
+}
diff --git a/rust/crates/commands/src/scene/toggle_bookmark.rs b/rust/crates/commands/src/scene/toggle_bookmark.rs
new file mode 100644
index 00000000..054e1c78
--- /dev/null
+++ b/rust/crates/commands/src/scene/toggle_bookmark.rs
@@ -0,0 +1,48 @@
+use scene::Project;
+
+use crate::bookmarks::{get_frame_time, toggle_bookmark_in_array};
+use crate::command::{Command, CommandError, CommandResult};
+use crate::project_ext::active_scene_mut;
+use time::MediaTime;
+
+pub struct ToggleBookmarkCommand {
+ time: MediaTime,
+ frame_time: MediaTime,
+ saved_bookmarks: Option<Vec<scene::Bookmark>>,
+}
+
+impl ToggleBookmarkCommand {
+ pub fn new(time: MediaTime) -> Self {
+ Self {
+ time,
+ frame_time: MediaTime::ZERO,
+ saved_bookmarks: None,
+ }
+ }
+}
+
+impl Command for ToggleBookmarkCommand {
+ fn execute(
+ &mut self,
+ project: &mut Project,
+ ) -> Result<Option<CommandResult>, CommandError> {
+ let fps = project.settings.fps;
+ let Some(scene) = active_scene_mut(project) else {
+ return Ok(None);
+ };
+
+ self.saved_bookmarks = Some(scene.bookmarks.clone());
+ self.frame_time = get_frame_time(self.time, fps);
+ scene.bookmarks = toggle_bookmark_in_array(&scene.bookmarks, self.frame_time);
+ Ok(None)
+ }
+
+ fn undo(&mut self, project: &mut Project) {
+ let Some(saved) = &self.saved_bookmarks else {
+ return;
+ };
+ if let Some(scene) = active_scene_mut(project) {
+ scene.bookmarks = saved.clone();
+ }
+ }
+}
diff --git a/rust/crates/commands/src/scene/update_bookmark.rs b/rust/crates/commands/src/scene/update_bookmark.rs
new file mode 100644
index 00000000..f2c9b94e
--- /dev/null
+++ b/rust/crates/commands/src/scene/update_bookmark.rs
@@ -0,0 +1,49 @@
+use scene::Project;
+use time::MediaTime;
+
+use crate::bookmarks::{BookmarkPatch, get_frame_time, update_bookmark_in_array};
+use crate::command::{Command, CommandError, CommandResult};
+use crate::project_ext::active_scene_mut;
+
+pub struct UpdateBookmarkCommand {
+ time: MediaTime,
+ updates: BookmarkPatch,
+ saved_bookmarks: Option<Vec<scene::Bookmark>>,
+}
+
+impl UpdateBookmarkCommand {
+ pub fn new(time: MediaTime, updates: BookmarkPatch) -> Self {
+ Self {
+ time,
+ updates,
+ saved_bookmarks: None,
+ }
+ }
+}
+
+impl Command for UpdateBookmarkCommand {
+ fn execute(
+ &mut self,
+ project: &mut Project,
+ ) -> Result<Option<CommandResult>, CommandError> {
+ let fps = project.settings.fps;
+ let Some(scene) = active_scene_mut(project) else {
+ return Ok(None);
+ };
+
+ self.saved_bookmarks = Some(scene.bookmarks.clone());
+ let frame_time = get_frame_time(self.time, fps);
+ scene.bookmarks =
+ update_bookmark_in_array(&scene.bookmarks, frame_time, &self.updates);
+ Ok(None)
+ }
+
+ fn undo(&mut self, project: &mut Project) {
+ let Some(saved) = &self.saved_bookmarks else {
+ return;
+ };
+ if let Some(scene) = active_scene_mut(project) {
+ scene.bookmarks = saved.clone();
+ }
+ }
+}
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();
+ }
+ }
+}
diff --git a/rust/crates/commands/src/tracks.rs b/rust/crates/commands/src/tracks.rs
new file mode 100644
index 00000000..332a5e6b
--- /dev/null
+++ b/rust/crates/commands/src/tracks.rs
@@ -0,0 +1,42 @@
+use scene::{SceneTracks, Track};
+
+pub fn find_track<'a>(tracks: &'a SceneTracks, track_id: &str) -> Option<&'a Track> {
+ if tracks.main.id() == track_id {
+ return Some(&tracks.main);
+ }
+ tracks
+ .overlay
+ .iter()
+ .find(|track| track.id() == track_id)
+ .or_else(|| tracks.audio.iter().find(|track| track.id() == track_id))
+}
+
+pub fn find_track_mut<'a>(
+ tracks: &'a mut SceneTracks,
+ track_id: &str,
+) -> Option<&'a mut Track> {
+ if tracks.main.id() == track_id {
+ return Some(&mut tracks.main);
+ }
+ tracks
+ .overlay
+ .iter_mut()
+ .find(|track| track.id() == track_id)
+ .or_else(|| tracks.audio.iter_mut().find(|track| track.id() == track_id))
+}
+
+pub fn all_tracks(tracks: &SceneTracks) -> impl Iterator<Item = &Track> {
+ tracks
+ .overlay
+ .iter()
+ .chain(std::iter::once(&tracks.main))
+ .chain(tracks.audio.iter())
+}
+
+pub fn all_tracks_mut(tracks: &mut SceneTracks) -> impl Iterator<Item = &mut Track> {
+ tracks
+ .overlay
+ .iter_mut()
+ .chain(std::iter::once(&mut tracks.main))
+ .chain(tracks.audio.iter_mut())
+}