From bbeb85b3a3e6c95412b5f0f551e3c6f3c269501e Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Fri, 28 Nov 2025 05:53:11 -0500 Subject: [PATCH] Clips in timeline --- .../src/actions/add_layer.rs | 126 +++ .../src/actions/add_shape.rs | 24 +- .../lightningbeam-core/src/actions/mod.rs | 12 +- .../src/actions/move_clip_instances.rs | 143 ++++ .../src/actions/move_objects.rs | 56 +- .../src/actions/paint_bucket.rs | 54 +- .../src/actions/transform_clip_instances.rs | 80 ++ .../src/actions/transform_objects.rs | 30 +- .../src/actions/trim_clip_instances.rs | 271 +++++++ .../lightningbeam-core/src/clip.rs | 621 ++++++++++++++ .../lightningbeam-core/src/document.rs | 90 +- .../lightningbeam-core/src/hit_test.rs | 167 +++- .../lightningbeam-core/src/layer.rs | 506 ++++++++++-- .../lightningbeam-core/src/layer_tree.rs | 187 +++++ .../lightningbeam-core/src/lib.rs | 2 + .../lightningbeam-core/src/object.rs | 39 +- .../lightningbeam-core/src/renderer.rs | 93 ++- .../lightningbeam-core/src/selection.rs | 174 ++-- .../lightningbeam-editor/Cargo.toml | 1 + .../lightningbeam-editor/src/main.rs | 85 +- .../lightningbeam-editor/src/menu.rs | 4 + .../lightningbeam-editor/src/panes/mod.rs | 2 +- .../lightningbeam-editor/src/panes/stage.rs | 504 +++++++++--- .../src/panes/timeline.rs | 766 +++++++++++++++++- 24 files changed, 3613 insertions(+), 424 deletions(-) create mode 100644 lightningbeam-ui/lightningbeam-core/src/actions/add_layer.rs create mode 100644 lightningbeam-ui/lightningbeam-core/src/actions/move_clip_instances.rs create mode 100644 lightningbeam-ui/lightningbeam-core/src/actions/transform_clip_instances.rs create mode 100644 lightningbeam-ui/lightningbeam-core/src/actions/trim_clip_instances.rs create mode 100644 lightningbeam-ui/lightningbeam-core/src/clip.rs create mode 100644 lightningbeam-ui/lightningbeam-core/src/layer_tree.rs diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/add_layer.rs b/lightningbeam-ui/lightningbeam-core/src/actions/add_layer.rs new file mode 100644 index 0000000..510b088 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/actions/add_layer.rs @@ -0,0 +1,126 @@ +//! Add layer action +//! +//! Handles adding a new layer to the document. + +use crate::action::Action; +use crate::document::Document; +use crate::layer::{AnyLayer, VectorLayer}; +use uuid::Uuid; + +/// Action that adds a new layer to the document +pub struct AddLayerAction { + /// The layer to add + layer: AnyLayer, + + /// ID of the created layer (set after execution) + created_layer_id: Option, +} + +impl AddLayerAction { + /// Create a new add layer action with a vector layer + /// + /// # Arguments + /// + /// * `name` - The name for the new layer + pub fn new_vector(name: impl Into) -> Self { + let layer = VectorLayer::new(name); + Self { + layer: AnyLayer::Vector(layer), + created_layer_id: None, + } + } + + /// Create a new add layer action with any layer type + /// + /// # Arguments + /// + /// * `layer` - The layer to add + pub fn new(layer: AnyLayer) -> Self { + Self { + layer, + created_layer_id: None, + } + } + + /// Get the ID of the created layer (after execution) + pub fn created_layer_id(&self) -> Option { + self.created_layer_id + } +} + +impl Action for AddLayerAction { + fn execute(&mut self, document: &mut Document) { + // Add layer to the document's root + let layer_id = document.root_mut().add_child(self.layer.clone()); + + // Store the ID for rollback + self.created_layer_id = Some(layer_id); + } + + fn rollback(&mut self, document: &mut Document) { + // Remove the created layer if it exists + if let Some(layer_id) = self.created_layer_id { + document.root_mut().remove_child(&layer_id); + + // Clear the stored ID + self.created_layer_id = None; + } + } + + fn description(&self) -> String { + match &self.layer { + AnyLayer::Vector(_) => "Add vector layer", + AnyLayer::Audio(_) => "Add audio layer", + AnyLayer::Video(_) => "Add video layer", + } + .to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_add_vector_layer() { + let mut document = Document::new("Test"); + assert_eq!(document.root.children.len(), 0); + + // Create and execute action + let mut action = AddLayerAction::new_vector("New Layer"); + action.execute(&mut document); + + // Verify layer was added + assert_eq!(document.root.children.len(), 1); + let layer = &document.root.children[0]; + assert_eq!(layer.layer().name, "New Layer"); + assert!(matches!(layer, AnyLayer::Vector(_))); + + // Rollback + action.rollback(&mut document); + + // Verify layer was removed + assert_eq!(document.root.children.len(), 0); + } + + #[test] + fn test_add_layer_description() { + let action = AddLayerAction::new_vector("Test"); + assert_eq!(action.description(), "Add vector layer"); + } + + #[test] + fn test_add_multiple_layers() { + let mut document = Document::new("Test"); + + let mut action1 = AddLayerAction::new_vector("Layer 1"); + let mut action2 = AddLayerAction::new_vector("Layer 2"); + + action1.execute(&mut document); + action2.execute(&mut document); + + assert_eq!(document.root.children.len(), 2); + assert_eq!(document.root.children[0].layer().name, "Layer 1"); + assert_eq!(document.root.children[1].layer().name, "Layer 2"); + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/add_shape.rs b/lightningbeam-ui/lightningbeam-core/src/actions/add_shape.rs index 0898c6a..d87c14a 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/add_shape.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/add_shape.rs @@ -5,13 +5,13 @@ use crate::action::Action; use crate::document::Document; use crate::layer::AnyLayer; -use crate::object::Object; +use crate::object::ShapeInstance; use crate::shape::Shape; use uuid::Uuid; /// Action that adds a shape and object to a vector layer /// -/// This action creates both a Shape (the path/geometry) and an Object +/// This action creates both a Shape (the path/geometry) and an ShapeInstance /// (the instance with transform). Both are added to the layer. pub struct AddShapeAction { /// Layer ID to add the shape to @@ -21,7 +21,7 @@ pub struct AddShapeAction { shape: Shape, /// The object to add (references the shape with transform) - object: Object, + object: ShapeInstance, /// ID of the created shape (set after execution) created_shape_id: Option, @@ -38,7 +38,7 @@ impl AddShapeAction { /// * `layer_id` - The layer to add the shape to /// * `shape` - The shape to add /// * `object` - The object instance referencing the shape - pub fn new(layer_id: Uuid, shape: Shape, object: Object) -> Self { + pub fn new(layer_id: Uuid, shape: Shape, object: ShapeInstance) -> Self { Self { layer_id, shape, @@ -110,7 +110,7 @@ mod tests { let rect = Rect::new(0.0, 0.0, 100.0, 50.0); let path = rect.to_path(0.1); let shape = Shape::new(path).with_fill(ShapeColor::rgb(255, 0, 0)); - let object = Object::new(shape.id).with_position(50.0, 50.0); + let object = ShapeInstance::new(shape.id).with_position(50.0, 50.0); // Create and execute action let mut action = AddShapeAction::new(layer_id, shape, object); @@ -119,9 +119,9 @@ mod tests { // Verify shape and object were added if let Some(AnyLayer::Vector(layer)) = document.get_layer(&layer_id) { assert_eq!(layer.shapes.len(), 1); - assert_eq!(layer.objects.len(), 1); + assert_eq!(layer.shape_instances.len(), 1); - let added_object = &layer.objects[0]; + let added_object = &layer.shape_instances[0]; assert_eq!(added_object.transform.x, 50.0); assert_eq!(added_object.transform.y, 50.0); } else { @@ -134,7 +134,7 @@ mod tests { // Verify shape and object were removed if let Some(AnyLayer::Vector(layer)) = document.get_layer(&layer_id) { assert_eq!(layer.shapes.len(), 0); - assert_eq!(layer.objects.len(), 0); + assert_eq!(layer.shape_instances.len(), 0); } } @@ -149,7 +149,7 @@ mod tests { let path = circle.to_path(0.1); let shape = Shape::new(path) .with_fill(ShapeColor::rgb(0, 255, 0)); - let object = Object::new(shape.id); + let object = ShapeInstance::new(shape.id); let mut action = AddShapeAction::new(layer_id, shape, object); @@ -161,7 +161,7 @@ mod tests { if let Some(AnyLayer::Vector(layer)) = document.get_layer(&layer_id) { assert_eq!(layer.shapes.len(), 1); - assert_eq!(layer.objects.len(), 1); + assert_eq!(layer.shape_instances.len(), 1); } } @@ -174,7 +174,7 @@ mod tests { let rect = Rect::new(0.0, 0.0, 50.0, 50.0); let path = rect.to_path(0.1); let shape = Shape::new(path); - let object = Object::new(shape.id); + let object = ShapeInstance::new(shape.id); let mut action = AddShapeAction::new(layer_id, shape, object); @@ -185,7 +185,7 @@ mod tests { if let Some(AnyLayer::Vector(layer)) = document.get_layer(&layer_id) { // Should have 2 shapes and 2 objects assert_eq!(layer.shapes.len(), 2); - assert_eq!(layer.objects.len(), 2); + assert_eq!(layer.shape_instances.len(), 2); } } } diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs b/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs index 9287f2e..27dd78e 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs @@ -3,12 +3,20 @@ //! This module contains all the concrete action types that can be executed //! through the action system. +pub mod add_layer; pub mod add_shape; +pub mod move_clip_instances; pub mod move_objects; pub mod paint_bucket; +pub mod transform_clip_instances; pub mod transform_objects; +pub mod trim_clip_instances; +pub use add_layer::AddLayerAction; pub use add_shape::AddShapeAction; -pub use move_objects::MoveObjectsAction; +pub use move_clip_instances::MoveClipInstancesAction; +pub use move_objects::MoveShapeInstancesAction; pub use paint_bucket::PaintBucketAction; -pub use transform_objects::TransformObjectsAction; +pub use transform_clip_instances::TransformClipInstancesAction; +pub use transform_objects::TransformShapeInstancesAction; +pub use trim_clip_instances::{TrimClipInstancesAction, TrimData, TrimType}; diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/move_clip_instances.rs b/lightningbeam-ui/lightningbeam-core/src/actions/move_clip_instances.rs new file mode 100644 index 0000000..f865bbd --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/actions/move_clip_instances.rs @@ -0,0 +1,143 @@ +//! Move clip instances action +//! +//! Handles moving one or more clip instances along the timeline. + +use crate::action::Action; +use crate::document::Document; +use crate::layer::AnyLayer; +use std::collections::HashMap; +use uuid::Uuid; + +/// Action that moves clip instances to new timeline positions +pub struct MoveClipInstancesAction { + /// Map of layer IDs to vectors of (clip_instance_id, old_timeline_start, new_timeline_start) + layer_moves: HashMap>, +} + +impl MoveClipInstancesAction { + /// Create a new move clip instances action + /// + /// # Arguments + /// + /// * `layer_moves` - Map of layer IDs to vectors of (clip_instance_id, old_timeline_start, new_timeline_start) + pub fn new(layer_moves: HashMap>) -> Self { + Self { layer_moves } + } +} + +impl Action for MoveClipInstancesAction { + fn execute(&mut self, document: &mut Document) { + for (layer_id, moves) in &self.layer_moves { + let layer = match document.get_layer_mut(layer_id) { + Some(l) => l, + None => continue, + }; + + // Get mutable reference to clip_instances for this layer type + let clip_instances = match layer { + AnyLayer::Vector(vl) => &mut vl.clip_instances, + AnyLayer::Audio(al) => &mut al.clip_instances, + AnyLayer::Video(vl) => &mut vl.clip_instances, + }; + + // Update timeline_start for each clip instance + for (clip_id, _old, new) in moves { + if let Some(clip_instance) = clip_instances.iter_mut().find(|ci| ci.id == *clip_id) + { + clip_instance.timeline_start = *new; + } + } + } + } + + fn rollback(&mut self, document: &mut Document) { + for (layer_id, moves) in &self.layer_moves { + let layer = match document.get_layer_mut(layer_id) { + Some(l) => l, + None => continue, + }; + + // Get mutable reference to clip_instances for this layer type + let clip_instances = match layer { + AnyLayer::Vector(vl) => &mut vl.clip_instances, + AnyLayer::Audio(al) => &mut al.clip_instances, + AnyLayer::Video(vl) => &mut vl.clip_instances, + }; + + // Restore original timeline_start for each clip instance + for (clip_id, old, _new) in moves { + if let Some(clip_instance) = clip_instances.iter_mut().find(|ci| ci.id == *clip_id) + { + clip_instance.timeline_start = *old; + } + } + } + } + + fn description(&self) -> String { + let total_count: usize = self.layer_moves.values().map(|v| v.len()).sum(); + if total_count == 1 { + "Move clip instance".to_string() + } else { + format!("Move {} clip instances", total_count) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::clip::{Clip, ClipInstance, ClipType}; + use crate::layer::VectorLayer; + + #[test] + fn test_move_clip_instances_action() { + // Create a document with a test clip instance + let mut document = Document::new("Test"); + + let clip = Clip::new(ClipType::Vector, "Test Clip", None); + let clip_id = clip.id; + + let mut vector_layer = VectorLayer::new("Layer 1"); + vector_layer.clips.push(clip); + + let mut clip_instance = ClipInstance::new(clip_id); + clip_instance.timeline_start = 1.0; // Start at 1 second + let instance_id = clip_instance.id; + vector_layer.clip_instances.push(clip_instance); + + let layer_id = document.root.add_child(AnyLayer::Vector(vector_layer)); + + // Create move action: move from 1.0 to 5.0 seconds + let mut layer_moves = HashMap::new(); + layer_moves.insert(layer_id, vec![(instance_id, 1.0, 5.0)]); + + let mut action = MoveClipInstancesAction::new(layer_moves); + + // Execute + action.execute(&mut document); + + // Verify position changed + if let Some(AnyLayer::Vector(layer)) = document.get_layer(&layer_id) { + let instance = layer + .clip_instances + .iter() + .find(|ci| ci.id == instance_id) + .unwrap(); + assert_eq!(instance.timeline_start, 5.0); + } + + // Rollback + action.rollback(&mut document); + + // Verify position restored + if let Some(AnyLayer::Vector(layer)) = document.get_layer(&layer_id) { + let instance = layer + .clip_instances + .iter() + .find(|ci| ci.id == instance_id) + .unwrap(); + assert_eq!(instance.timeline_start, 1.0); + } + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/move_objects.rs b/lightningbeam-ui/lightningbeam-core/src/actions/move_objects.rs index ca68335..b87af29 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/move_objects.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/move_objects.rs @@ -1,6 +1,6 @@ -//! Move objects action +//! Move shape instances action //! -//! Handles moving one or more objects to new positions. +//! Handles moving one or more shape instances to new positions. use crate::action::Action; use crate::document::Document; @@ -9,31 +9,31 @@ use std::collections::HashMap; use uuid::Uuid; use vello::kurbo::Point; -/// Action that moves objects to new positions -pub struct MoveObjectsAction { - /// Layer ID containing the objects +/// Action that moves shape instances to new positions +pub struct MoveShapeInstancesAction { + /// Layer ID containing the shape instances layer_id: Uuid, /// Map of object IDs to their old and new positions - object_positions: HashMap, // (old_pos, new_pos) + shape_instance_positions: HashMap, // (old_pos, new_pos) } -impl MoveObjectsAction { - /// Create a new move objects action +impl MoveShapeInstancesAction { + /// Create a new move shape instances action /// /// # Arguments /// - /// * `layer_id` - The layer containing the objects - /// * `object_positions` - Map of object IDs to (old_position, new_position) - pub fn new(layer_id: Uuid, object_positions: HashMap) -> Self { + /// * `layer_id` - The layer containing the shape instances + /// * `shape_instance_positions` - Map of object IDs to (old_position, new_position) + pub fn new(layer_id: Uuid, shape_instance_positions: HashMap) -> Self { Self { layer_id, - object_positions, + shape_instance_positions, } } } -impl Action for MoveObjectsAction { +impl Action for MoveShapeInstancesAction { fn execute(&mut self, document: &mut Document) { let layer = match document.get_layer_mut(&self.layer_id) { Some(l) => l, @@ -41,8 +41,8 @@ impl Action for MoveObjectsAction { }; if let AnyLayer::Vector(vector_layer) = layer { - for (object_id, (_old, new)) in &self.object_positions { - vector_layer.modify_object_internal(object_id, |obj| { + for (shape_instance_id, (_old, new)) in &self.shape_instance_positions { + vector_layer.modify_object_internal(shape_instance_id, |obj| { obj.transform.x = new.x; obj.transform.y = new.y; }); @@ -57,8 +57,8 @@ impl Action for MoveObjectsAction { }; if let AnyLayer::Vector(vector_layer) = layer { - for (object_id, (old, _new)) in &self.object_positions { - vector_layer.modify_object_internal(object_id, |obj| { + for (shape_instance_id, (old, _new)) in &self.shape_instance_positions { + vector_layer.modify_object_internal(shape_instance_id, |obj| { obj.transform.x = old.x; obj.transform.y = old.y; }); @@ -67,11 +67,11 @@ impl Action for MoveObjectsAction { } fn description(&self) -> String { - let count = self.object_positions.len(); + let count = self.shape_instance_positions.len(); if count == 1 { - "Move object".to_string() + "Move shape instance".to_string() } else { - format!("Move {} objects", count) + format!("Move {} shape instances", count) } } } @@ -80,40 +80,40 @@ impl Action for MoveObjectsAction { mod tests { use super::*; use crate::layer::VectorLayer; - use crate::object::Object; + use crate::object::ShapeInstance; use crate::shape::Shape; use vello::kurbo::{Circle, Shape as KurboShape}; #[test] - fn test_move_objects_action() { + fn test_move_shape_instances_action() { // Create a document with a test object let mut document = Document::new("Test"); let circle = Circle::new((100.0, 100.0), 50.0); let path = circle.to_path(0.1); let shape = Shape::new(path); - let object = Object::new(shape.id).with_position(50.0, 50.0); + let object = ShapeInstance::new(shape.id).with_position(50.0, 50.0); let mut vector_layer = VectorLayer::new("Layer 1"); vector_layer.add_shape(shape); - let object_id = vector_layer.add_object(object); + let shape_instance_id = vector_layer.add_object(object); let layer_id = document.root.add_child(AnyLayer::Vector(vector_layer)); // Create move action let mut positions = HashMap::new(); positions.insert( - object_id, + shape_instance_id, (Point::new(50.0, 50.0), Point::new(150.0, 200.0)) ); - let mut action = MoveObjectsAction::new(layer_id, positions); + let mut action = MoveShapeInstancesAction::new(layer_id, positions); // Execute action.execute(&mut document); // Verify position changed if let Some(AnyLayer::Vector(layer)) = document.get_layer(&layer_id) { - let obj = layer.get_object(&object_id).unwrap(); + let obj = layer.get_object(&shape_instance_id).unwrap(); assert_eq!(obj.transform.x, 150.0); assert_eq!(obj.transform.y, 200.0); } @@ -123,7 +123,7 @@ mod tests { // Verify position restored if let Some(AnyLayer::Vector(layer)) = document.get_layer(&layer_id) { - let obj = layer.get_object(&object_id).unwrap(); + let obj = layer.get_object(&shape_instance_id).unwrap(); assert_eq!(obj.transform.x, 50.0); assert_eq!(obj.transform.y, 50.0); } diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/paint_bucket.rs b/lightningbeam-ui/lightningbeam-core/src/actions/paint_bucket.rs index d71ae55..39015e3 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/paint_bucket.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/paint_bucket.rs @@ -8,7 +8,7 @@ use crate::curve_segment::CurveSegment; use crate::document::Document; use crate::gap_handling::GapHandlingMode; use crate::layer::AnyLayer; -use crate::object::Object; +use crate::object::ShapeInstance; use crate::planar_graph::PlanarGraph; use crate::shape::ShapeColor; use uuid::Uuid; @@ -34,8 +34,8 @@ pub struct PaintBucketAction { /// ID of the created shape (set after execution) created_shape_id: Option, - /// ID of the created object (set after execution) - created_object_id: Option, + /// ID of the created shape instance (set after execution) + created_shape_instance_id: Option, } impl PaintBucketAction { @@ -62,7 +62,7 @@ impl PaintBucketAction { tolerance, gap_mode, created_shape_id: None, - created_object_id: None, + created_shape_instance_id: None, } } } @@ -74,10 +74,10 @@ impl Action for PaintBucketAction { // Optimization: Check if we're clicking on an existing shape first // This is much faster than building a planar graph if let Some(AnyLayer::Vector(vector_layer)) = document.get_layer_mut(&self.layer_id) { - // Iterate through objects in reverse order (topmost first) - for object in vector_layer.objects.iter().rev() { - // Find the corresponding shape - if let Some(shape) = vector_layer.shapes.iter().find(|s| s.id == object.shape_id) { + // Iterate through shape instances in reverse order (topmost first) + for shape_instance in vector_layer.shape_instances.iter().rev() { + // Find the corresponding shape (O(1) HashMap lookup) + if let Some(shape) = vector_layer.shapes.get(&shape_instance.shape_id) { // Skip shapes without fill color (e.g., lines with only stroke) if shape.fill_color.is_none() { continue; @@ -92,8 +92,8 @@ impl Action for PaintBucketAction { continue; } - // Apply the object's transform to get the transformed path - let transform_affine = object.transform.to_affine(); + // Apply the shape instance's transform to get the transformed path + let transform_affine = shape_instance.transform.to_affine(); // Transform the click point to shape's local coordinates (inverse transform) let inverse_transform = transform_affine.inverse(); @@ -110,8 +110,8 @@ impl Action for PaintBucketAction { // Store the shape ID before the immutable borrow ends let shape_id = shape.id; - // Find mutable reference to the shape and update its fill - if let Some(shape_mut) = vector_layer.shapes.iter_mut().find(|s| s.id == shape_id) { + // Find mutable reference to the shape and update its fill (O(1) HashMap lookup) + if let Some(shape_mut) = vector_layer.shapes.get_mut(&shape_id) { shape_mut.fill_color = Some(self.fill_color); println!("Updated shape fill color"); } @@ -154,20 +154,20 @@ impl Action for PaintBucketAction { println!("DEBUG: Face shape created with fill_color: {:?}", face_shape.fill_color); - let face_object = Object::new(face_shape.id); + let face_shape_instance = ShapeInstance::new(face_shape.id); // Store the created IDs for rollback self.created_shape_id = Some(face_shape.id); - self.created_object_id = Some(face_object.id); + self.created_shape_instance_id = Some(face_shape_instance.id); if let Some(AnyLayer::Vector(vector_layer)) = document.get_layer_mut(&self.layer_id) { let shape_id_for_debug = face_shape.id; vector_layer.add_shape_internal(face_shape); - vector_layer.add_object_internal(face_object); + vector_layer.add_object_internal(face_shape_instance); println!("DEBUG: Added filled shape"); - // Verify the shape still has the fill color after being added - if let Some(added_shape) = vector_layer.shapes.iter().find(|s| s.id == shape_id_for_debug) { + // Verify the shape still has the fill color after being added (O(1) HashMap lookup) + if let Some(added_shape) = vector_layer.shapes.get(&shape_id_for_debug) { println!("DEBUG: After adding to layer, shape fill_color = {:?}", added_shape.fill_color); } } @@ -180,7 +180,7 @@ impl Action for PaintBucketAction { fn rollback(&mut self, document: &mut Document) { // Remove the created shape and object if they exist - if let (Some(shape_id), Some(object_id)) = (self.created_shape_id, self.created_object_id) { + if let (Some(shape_id), Some(object_id)) = (self.created_shape_id, self.created_shape_instance_id) { let layer = match document.get_layer_mut(&self.layer_id) { Some(l) => l, None => return, @@ -192,7 +192,7 @@ impl Action for PaintBucketAction { } self.created_shape_id = None; - self.created_object_id = None; + self.created_shape_instance_id = None; } } @@ -219,11 +219,11 @@ fn extract_curves_from_all_shapes( // Extract curves only from this vector layer if let AnyLayer::Vector(vector_layer) = layer { - println!("Extracting curves from {} objects in layer", vector_layer.objects.len()); + println!("Extracting curves from {} objects in layer", vector_layer.shape_instances.len()); // Extract curves from each object (which applies transforms to shapes) - for (obj_idx, object) in vector_layer.objects.iter().enumerate() { - // Find the shape for this object - let shape = match vector_layer.shapes.iter().find(|s| s.id == object.shape_id) { + for (obj_idx, object) in vector_layer.shape_instances.iter().enumerate() { + // Find the shape for this object (O(1) HashMap lookup) + let shape = match vector_layer.shapes.get(&object.shape_id) { Some(s) => s, None => continue, }; @@ -313,12 +313,12 @@ mod tests { let rect = Rect::new(0.0, 0.0, 100.0, 100.0); let path = rect.to_path(0.1); let shape = Shape::new(path); - let object = Object::new(shape.id); + let shape_instance = ShapeInstance::new(shape.id); // Add the boundary shape if let Some(AnyLayer::Vector(layer)) = document.get_layer_mut(&layer_id) { layer.add_shape_internal(shape); - layer.add_object_internal(object); + layer.add_object_internal(shape_instance); } // Create and execute paint bucket action @@ -336,7 +336,7 @@ mod tests { if let Some(AnyLayer::Vector(layer)) = document.get_layer(&layer_id) { // Should have original shape + filled shape assert!(layer.shapes.len() >= 1); - assert!(layer.objects.len() >= 1); + assert!(layer.shape_instances.len() >= 1); } else { panic!("Layer not found or not a vector layer"); } @@ -347,7 +347,7 @@ mod tests { if let Some(AnyLayer::Vector(layer)) = document.get_layer(&layer_id) { // Should only have original shape assert_eq!(layer.shapes.len(), 1); - assert_eq!(layer.objects.len(), 1); + assert_eq!(layer.shape_instances.len(), 1); } } diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/transform_clip_instances.rs b/lightningbeam-ui/lightningbeam-core/src/actions/transform_clip_instances.rs new file mode 100644 index 0000000..95b11c6 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/actions/transform_clip_instances.rs @@ -0,0 +1,80 @@ +//! Transform clip instances action +//! +//! Handles spatial transformation (move, scale, rotate) of clip instances on the stage. + +use crate::action::Action; +use crate::document::Document; +use crate::layer::AnyLayer; +use crate::object::Transform; +use std::collections::HashMap; +use uuid::Uuid; + +/// Action that transforms clip instances spatially on the stage +pub struct TransformClipInstancesAction { + layer_id: Uuid, + /// Map of clip instance ID to (old transform, new transform) + clip_instance_transforms: HashMap, +} + +impl TransformClipInstancesAction { + pub fn new( + layer_id: Uuid, + clip_instance_transforms: HashMap, + ) -> Self { + Self { + layer_id, + clip_instance_transforms, + } + } +} + +impl Action for TransformClipInstancesAction { + fn execute(&mut self, document: &mut Document) { + let layer = match document.get_layer_mut(&self.layer_id) { + Some(l) => l, + None => return, + }; + + // Get mutable reference to clip_instances for this layer type + let clip_instances = match layer { + AnyLayer::Vector(vl) => &mut vl.clip_instances, + AnyLayer::Audio(al) => &mut al.clip_instances, + AnyLayer::Video(vl) => &mut vl.clip_instances, + }; + + // Apply new transforms + for (clip_id, (_old, new)) in &self.clip_instance_transforms { + if let Some(clip_instance) = clip_instances.iter_mut().find(|ci| ci.id == *clip_id) { + clip_instance.transform = new.clone(); + } + } + } + + fn rollback(&mut self, document: &mut Document) { + let layer = match document.get_layer_mut(&self.layer_id) { + Some(l) => l, + None => return, + }; + + // Get mutable reference to clip_instances for this layer type + let clip_instances = match layer { + AnyLayer::Vector(vl) => &mut vl.clip_instances, + AnyLayer::Audio(al) => &mut al.clip_instances, + AnyLayer::Video(vl) => &mut vl.clip_instances, + }; + + // Restore old transforms + for (clip_id, (old, _new)) in &self.clip_instance_transforms { + if let Some(clip_instance) = clip_instances.iter_mut().find(|ci| ci.id == *clip_id) { + clip_instance.transform = old.clone(); + } + } + } + + fn description(&self) -> String { + format!( + "Transform {} clip instance(s)", + self.clip_instance_transforms.len() + ) + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/transform_objects.rs b/lightningbeam-ui/lightningbeam-core/src/actions/transform_objects.rs index f774345..59d22e8 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/transform_objects.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/transform_objects.rs @@ -1,6 +1,6 @@ -//! Transform objects action +//! Transform shape instances action //! -//! Applies scale, rotation, and other transformations to objects with undo/redo support. +//! Applies scale, rotation, and other transformations to shape instances with undo/redo support. use crate::action::Action; use crate::document::Document; @@ -9,32 +9,32 @@ use crate::object::Transform; use std::collections::HashMap; use uuid::Uuid; -/// Action to transform multiple objects -pub struct TransformObjectsAction { +/// Action to transform multiple shape instances +pub struct TransformShapeInstancesAction { layer_id: Uuid, - /// Map of object ID to (old transform, new transform) - object_transforms: HashMap, + /// Map of shape instance ID to (old transform, new transform) + shape_instance_transforms: HashMap, } -impl TransformObjectsAction { +impl TransformShapeInstancesAction { /// Create a new transform action pub fn new( layer_id: Uuid, - object_transforms: HashMap, + shape_instance_transforms: HashMap, ) -> Self { Self { layer_id, - object_transforms, + shape_instance_transforms, } } } -impl Action for TransformObjectsAction { +impl Action for TransformShapeInstancesAction { fn execute(&mut self, document: &mut Document) { if let Some(layer) = document.get_layer_mut(&self.layer_id) { if let AnyLayer::Vector(vector_layer) = layer { - for (object_id, (_old, new)) in &self.object_transforms { - vector_layer.modify_object_internal(object_id, |obj| { + for (shape_instance_id, (_old, new)) in &self.shape_instance_transforms { + vector_layer.modify_object_internal(shape_instance_id, |obj| { obj.transform = new.clone(); }); } @@ -45,8 +45,8 @@ impl Action for TransformObjectsAction { fn rollback(&mut self, document: &mut Document) { if let Some(layer) = document.get_layer_mut(&self.layer_id) { if let AnyLayer::Vector(vector_layer) = layer { - for (object_id, (old, _new)) in &self.object_transforms { - vector_layer.modify_object_internal(object_id, |obj| { + for (shape_instance_id, (old, _new)) in &self.shape_instance_transforms { + vector_layer.modify_object_internal(shape_instance_id, |obj| { obj.transform = old.clone(); }); } @@ -55,6 +55,6 @@ impl Action for TransformObjectsAction { } fn description(&self) -> String { - format!("Transform {} object(s)", self.object_transforms.len()) + format!("Transform {} shape instance(s)", self.shape_instance_transforms.len()) } } diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/trim_clip_instances.rs b/lightningbeam-ui/lightningbeam-core/src/actions/trim_clip_instances.rs new file mode 100644 index 0000000..121723e --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/actions/trim_clip_instances.rs @@ -0,0 +1,271 @@ +//! Trim clip instances action +//! +//! Handles trimming one or more clip instances by adjusting trim_start and/or trim_end. + +use crate::action::Action; +use crate::document::Document; +use crate::layer::AnyLayer; +use std::collections::HashMap; +use uuid::Uuid; + +/// Type of trim operation +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum TrimType { + /// Trim from the start (adjust trim_start and timeline_start) + TrimLeft, + /// Trim from the end (adjust trim_end) + TrimRight, +} + +/// Action that trims clip instances +pub struct TrimClipInstancesAction { + /// Map of layer IDs to vectors of (clip_instance_id, trim_type, old_values, new_values) + /// For TrimLeft: (old_trim_start, old_timeline_start, new_trim_start, new_timeline_start) + /// For TrimRight: (old_trim_end, new_trim_end) - stored as Option + layer_trims: HashMap>, +} + +/// Trim data that can represent either left or right trim values +#[derive(Debug, Clone)] +pub struct TrimData { + /// For TrimLeft: trim_start value + /// For TrimRight: trim_end value (Option because it can be None) + pub trim_value: Option, + /// For TrimLeft: timeline_start value (where the clip appears on timeline) + /// For TrimRight: unused (None) + pub timeline_start: Option, +} + +impl TrimData { + /// Create TrimData for left trim + pub fn left(trim_start: f64, timeline_start: f64) -> Self { + Self { + trim_value: Some(trim_start), + timeline_start: Some(timeline_start), + } + } + + /// Create TrimData for right trim + pub fn right(trim_end: Option) -> Self { + Self { + trim_value: trim_end, + timeline_start: None, + } + } +} + +impl TrimClipInstancesAction { + /// Create a new trim clip instances action + pub fn new(layer_trims: HashMap>) -> Self { + Self { layer_trims } + } +} + +impl Action for TrimClipInstancesAction { + fn execute(&mut self, document: &mut Document) { + for (layer_id, trims) in &self.layer_trims { + let layer = match document.get_layer_mut(layer_id) { + Some(l) => l, + None => continue, + }; + + // Get mutable reference to clip_instances for this layer type + let clip_instances = match layer { + AnyLayer::Vector(vl) => &mut vl.clip_instances, + AnyLayer::Audio(al) => &mut al.clip_instances, + AnyLayer::Video(vl) => &mut vl.clip_instances, + }; + + // Apply trims + for (clip_id, trim_type, _old, new) in trims { + if let Some(clip_instance) = clip_instances.iter_mut().find(|ci| ci.id == *clip_id) + { + match trim_type { + TrimType::TrimLeft => { + if let (Some(new_trim), Some(new_timeline)) = + (new.trim_value, new.timeline_start) + { + clip_instance.trim_start = new_trim; + clip_instance.timeline_start = new_timeline; + } + } + TrimType::TrimRight => { + clip_instance.trim_end = new.trim_value; + } + } + } + } + } + } + + fn rollback(&mut self, document: &mut Document) { + for (layer_id, trims) in &self.layer_trims { + let layer = match document.get_layer_mut(layer_id) { + Some(l) => l, + None => continue, + }; + + // Get mutable reference to clip_instances for this layer type + let clip_instances = match layer { + AnyLayer::Vector(vl) => &mut vl.clip_instances, + AnyLayer::Audio(al) => &mut al.clip_instances, + AnyLayer::Video(vl) => &mut vl.clip_instances, + }; + + // Restore original trim values + for (clip_id, trim_type, old, _new) in trims { + if let Some(clip_instance) = clip_instances.iter_mut().find(|ci| ci.id == *clip_id) + { + match trim_type { + TrimType::TrimLeft => { + if let (Some(old_trim), Some(old_timeline)) = + (old.trim_value, old.timeline_start) + { + clip_instance.trim_start = old_trim; + clip_instance.timeline_start = old_timeline; + } + } + TrimType::TrimRight => { + clip_instance.trim_end = old.trim_value; + } + } + } + } + } + } + + fn description(&self) -> String { + let total_count: usize = self.layer_trims.values().map(|v| v.len()).sum(); + if total_count == 1 { + "Trim clip instance".to_string() + } else { + format!("Trim {} clip instances", total_count) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::clip::{Clip, ClipInstance, ClipType}; + use crate::layer::VectorLayer; + + #[test] + fn test_trim_left_action() { + let mut document = Document::new("Test"); + + let clip = Clip::new(ClipType::Vector, "Test Clip", Some(10.0)); + let clip_id = clip.id; + + let mut vector_layer = VectorLayer::new("Layer 1"); + vector_layer.clips.push(clip); + + let mut clip_instance = ClipInstance::new(clip_id); + clip_instance.timeline_start = 0.0; + clip_instance.trim_start = 0.0; + let instance_id = clip_instance.id; + vector_layer.clip_instances.push(clip_instance); + + let layer_id = document.root.add_child(AnyLayer::Vector(vector_layer)); + + // Create trim action: trim 2 seconds from left + let mut layer_trims = HashMap::new(); + layer_trims.insert( + layer_id, + vec![( + instance_id, + TrimType::TrimLeft, + TrimData::left(0.0, 0.0), + TrimData::left(2.0, 2.0), + )], + ); + + let mut action = TrimClipInstancesAction::new(layer_trims); + + // Execute + action.execute(&mut document); + + // Verify trim applied + if let Some(AnyLayer::Vector(layer)) = document.get_layer(&layer_id) { + let instance = layer + .clip_instances + .iter() + .find(|ci| ci.id == instance_id) + .unwrap(); + assert_eq!(instance.trim_start, 2.0); + assert_eq!(instance.timeline_start, 2.0); + } + + // Rollback + action.rollback(&mut document); + + // Verify restored + if let Some(AnyLayer::Vector(layer)) = document.get_layer(&layer_id) { + let instance = layer + .clip_instances + .iter() + .find(|ci| ci.id == instance_id) + .unwrap(); + assert_eq!(instance.trim_start, 0.0); + assert_eq!(instance.timeline_start, 0.0); + } + } + + #[test] + fn test_trim_right_action() { + let mut document = Document::new("Test"); + + let clip = Clip::new(ClipType::Vector, "Test Clip", Some(10.0)); + let clip_id = clip.id; + + let mut vector_layer = VectorLayer::new("Layer 1"); + vector_layer.clips.push(clip); + + let mut clip_instance = ClipInstance::new(clip_id); + clip_instance.trim_end = None; // Full duration + let instance_id = clip_instance.id; + vector_layer.clip_instances.push(clip_instance); + + let layer_id = document.root.add_child(AnyLayer::Vector(vector_layer)); + + // Create trim action: trim to 8 seconds from right + let mut layer_trims = HashMap::new(); + layer_trims.insert( + layer_id, + vec![( + instance_id, + TrimType::TrimRight, + TrimData::right(None), + TrimData::right(Some(8.0)), + )], + ); + + let mut action = TrimClipInstancesAction::new(layer_trims); + + // Execute + action.execute(&mut document); + + // Verify trim applied + if let Some(AnyLayer::Vector(layer)) = document.get_layer(&layer_id) { + let instance = layer + .clip_instances + .iter() + .find(|ci| ci.id == instance_id) + .unwrap(); + assert_eq!(instance.trim_end, Some(8.0)); + } + + // Rollback + action.rollback(&mut document); + + // Verify restored + if let Some(AnyLayer::Vector(layer)) = document.get_layer(&layer_id) { + let instance = layer + .clip_instances + .iter() + .find(|ci| ci.id == instance_id) + .unwrap(); + assert_eq!(instance.trim_end, None); + } + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/clip.rs b/lightningbeam-ui/lightningbeam-core/src/clip.rs new file mode 100644 index 0000000..02a843b --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/clip.rs @@ -0,0 +1,621 @@ +//! Clip system for Lightningbeam +//! +//! Clips are reusable compositions that can contain layers and be instantiated multiple times. +//! Similar to MovieClips in Flash or Compositions in After Effects. +//! +//! ## Architecture +//! +//! - **Clip**: The reusable definition (VectorClip, VideoClip, AudioClip) +//! - **ClipInstance**: An instance of a clip with its own transform, timing, and playback properties +//! +//! Multiple ClipInstances can reference the same Clip, each with different positions, +//! timing windows, and playback speeds. + +use crate::layer::AnyLayer; +use crate::layer_tree::LayerTree; +use crate::object::Transform; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// Vector clip containing nested layers +/// +/// A VectorClip is a composition that contains its own layer hierarchy. +/// Multiple ClipInstances can reference the same VectorClip, each with +/// their own transform and timing properties. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct VectorClip { + /// Unique identifier + pub id: Uuid, + + /// Clip name + pub name: String, + + /// Canvas width in pixels + pub width: f64, + + /// Canvas height in pixels + pub height: f64, + + /// Duration in seconds + pub duration: f64, + + /// Nested layer hierarchy + pub layers: LayerTree, +} + +impl VectorClip { + /// Create a new vector clip + pub fn new(name: impl Into, width: f64, height: f64, duration: f64) -> Self { + Self { + id: Uuid::new_v4(), + name: name.into(), + width, + height, + duration, + layers: LayerTree::new(), + } + } + + /// Create with a specific ID + pub fn with_id( + id: Uuid, + name: impl Into, + width: f64, + height: f64, + duration: f64, + ) -> Self { + Self { + id, + name: name.into(), + width, + height, + duration, + layers: LayerTree::new(), + } + } +} + +/// Video clip referencing an external video file +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct VideoClip { + /// Unique identifier + pub id: Uuid, + + /// Clip name + pub name: String, + + /// Path to video file + pub file_path: String, + + /// Video width in pixels + pub width: f64, + + /// Video height in pixels + pub height: f64, + + /// Duration in seconds (from video metadata) + pub duration: f64, + + /// Frame rate (from video metadata) + pub frame_rate: f64, +} + +impl VideoClip { + /// Create a new video clip + pub fn new( + name: impl Into, + file_path: impl Into, + width: f64, + height: f64, + duration: f64, + frame_rate: f64, + ) -> Self { + Self { + id: Uuid::new_v4(), + name: name.into(), + file_path: file_path.into(), + width, + height, + duration, + frame_rate, + } + } +} + +/// MIDI event representing a single MIDI message +/// +/// Compatible with daw-backend's MidiEvent structure +#[derive(Clone, Copy, Debug, Serialize, Deserialize)] +pub struct MidiEvent { + /// Time position within the clip in seconds + pub timestamp: f64, + /// MIDI status byte (includes channel) + pub status: u8, + /// First data byte (note number, CC number, etc.) + pub data1: u8, + /// Second data byte (velocity, CC value, etc.) + pub data2: u8, +} + +impl MidiEvent { + /// Create a new MIDI event + pub fn new(timestamp: f64, status: u8, data1: u8, data2: u8) -> Self { + Self { + timestamp, + status, + data1, + data2, + } + } + + /// Create a note on event + pub fn note_on(timestamp: f64, channel: u8, note: u8, velocity: u8) -> Self { + Self { + timestamp, + status: 0x90 | (channel & 0x0F), + data1: note, + data2: velocity, + } + } + + /// Create a note off event + pub fn note_off(timestamp: f64, channel: u8, note: u8, velocity: u8) -> Self { + Self { + timestamp, + status: 0x80 | (channel & 0x0F), + data1: note, + data2: velocity, + } + } +} + +/// Audio clip type +#[derive(Clone, Debug, Serialize, Deserialize)] +pub enum AudioClipType { + /// Sampled audio from a file + /// + /// References audio data in a shared AudioPool (managed by daw-backend). + /// Compatible with daw-backend's Clip structure. + Sampled { + /// Index into the audio pool (references AudioFile) + /// This allows sharing audio data between multiple clip instances + audio_pool_index: usize, + }, + /// MIDI sequence + /// + /// Compatible with daw-backend's MidiClip structure. + Midi { + /// MIDI events with timestamps + events: Vec, + /// Whether the clip loops + loop_enabled: bool, + }, +} + +/// Audio clip +/// +/// This is compatible with daw-backend's audio system: +/// - Sampled audio references data in AudioPool (managed externally) +/// - MIDI audio stores events directly in the clip +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct AudioClip { + /// Unique identifier + pub id: Uuid, + + /// Clip name + pub name: String, + + /// Duration in seconds + /// For sampled audio, this can be set to trim the audio shorter than the source file + pub duration: f64, + + /// Audio clip type (sampled or MIDI) + pub clip_type: AudioClipType, +} + +impl AudioClip { + /// Create a new sampled audio clip + /// + /// # Arguments + /// * `name` - Clip name + /// * `audio_pool_index` - Index into the AudioPool (from daw-backend) + /// * `duration` - Clip duration (can be shorter than source file for trimming) + pub fn new_sampled(name: impl Into, audio_pool_index: usize, duration: f64) -> Self { + Self { + id: Uuid::new_v4(), + name: name.into(), + duration, + clip_type: AudioClipType::Sampled { audio_pool_index }, + } + } + + /// Create a new MIDI clip + pub fn new_midi( + name: impl Into, + duration: f64, + events: Vec, + loop_enabled: bool, + ) -> Self { + Self { + id: Uuid::new_v4(), + name: name.into(), + duration, + clip_type: AudioClipType::Midi { + events, + loop_enabled, + }, + } + } + + /// Get the audio pool index if this is a sampled audio clip + pub fn audio_pool_index(&self) -> Option { + match &self.clip_type { + AudioClipType::Sampled { audio_pool_index } => Some(*audio_pool_index), + _ => None, + } + } + + /// Get MIDI events if this is a MIDI clip + pub fn midi_events(&self) -> Option<&[MidiEvent]> { + match &self.clip_type { + AudioClipType::Midi { events, .. } => Some(events), + _ => None, + } + } +} + +/// Unified clip enum for polymorphic handling +#[derive(Clone, Debug, Serialize, Deserialize)] +pub enum AnyClip { + Vector(VectorClip), + Video(VideoClip), + Audio(AudioClip), +} + +impl AnyClip { + /// Get the clip ID + pub fn id(&self) -> Uuid { + match self { + AnyClip::Vector(c) => c.id, + AnyClip::Audio(c) => c.id, + AnyClip::Video(c) => c.id, + } + } + + /// Get the clip name + pub fn name(&self) -> &str { + match self { + AnyClip::Vector(c) => &c.name, + AnyClip::Audio(c) => &c.name, + AnyClip::Video(c) => &c.name, + } + } + + /// Get the clip duration + pub fn duration(&self) -> f64 { + match self { + AnyClip::Vector(c) => c.duration, + AnyClip::Audio(c) => c.duration, + AnyClip::Video(c) => c.duration, + } + } +} + +/// Clip instance with transform, timing, and playback properties +/// +/// References a clip and provides instance-specific properties: +/// - Transform (position, rotation, scale) +/// - Timeline placement (when this instance appears on the parent layer's timeline) +/// - Trimming (trim_start, trim_end within the clip's internal content) +/// - Playback speed (time remapping) +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ClipInstance { + /// Unique identifier for this instance + pub id: Uuid, + + /// The clip this instance references + pub clip_id: Uuid, + + /// Transform (position, rotation, scale, skew) + pub transform: Transform, + + /// Opacity (0.0 to 1.0) + pub opacity: f64, + + /// Optional name for this instance + pub name: Option, + + /// When this instance starts on the timeline (in seconds, relative to parent layer) + /// This is the external positioning - where the instance appears on the timeline + /// Default: 0.0 (start at beginning of layer) + pub timeline_start: f64, + + /// How long this instance appears on the timeline (in seconds) + /// If timeline_duration > (trim_end - trim_start), the trimmed content will loop + /// Default: None (use trimmed clip duration, no looping) + pub timeline_duration: Option, + + /// Trim start: offset into the clip's internal content (in seconds) + /// Allows trimming the beginning of the clip + /// - For audio: offset into the audio file + /// - For video: offset into the video file + /// - For vector: offset into the animation timeline + /// Default: 0.0 (start at beginning of clip) + pub trim_start: f64, + + /// Trim end: offset into the clip's internal content (in seconds) + /// Allows trimming the end of the clip + /// Default: None (use full clip duration) + pub trim_end: Option, + + /// Playback speed multiplier + /// 1.0 = normal speed, 0.5 = half speed, 2.0 = double speed + /// Default: 1.0 + pub playback_speed: f64, + + /// Clip-level gain/volume (for audio clips) + /// Compatible with daw-backend's Clip.gain + /// Default: 1.0 + pub gain: f32, +} + +impl ClipInstance { + /// Create a new clip instance + pub fn new(clip_id: Uuid) -> Self { + Self { + id: Uuid::new_v4(), + clip_id, + transform: Transform::default(), + opacity: 1.0, + name: None, + timeline_start: 0.0, + timeline_duration: None, + trim_start: 0.0, + trim_end: None, + playback_speed: 1.0, + gain: 1.0, + } + } + + /// Create with a specific ID + pub fn with_id(id: Uuid, clip_id: Uuid) -> Self { + Self { + id, + clip_id, + transform: Transform::default(), + opacity: 1.0, + name: None, + timeline_start: 0.0, + timeline_duration: None, + trim_start: 0.0, + trim_end: None, + playback_speed: 1.0, + gain: 1.0, + } + } + + /// Set the transform + pub fn with_transform(mut self, transform: Transform) -> Self { + self.transform = transform; + self + } + + /// Set the position + pub fn with_position(mut self, x: f64, y: f64) -> Self { + self.transform.x = x; + self.transform.y = y; + self + } + + /// Set the opacity + pub fn with_opacity(mut self, opacity: f64) -> Self { + self.opacity = opacity; + self + } + + /// Set the name + pub fn with_name(mut self, name: impl Into) -> Self { + self.name = Some(name.into()); + self + } + + /// Set timeline position + pub fn with_timeline_start(mut self, timeline_start: f64) -> Self { + self.timeline_start = timeline_start; + self + } + + /// Set trimming (start and end time within the clip's internal content) + pub fn with_trimming(mut self, trim_start: f64, trim_end: Option) -> Self { + self.trim_start = trim_start; + self.trim_end = trim_end; + self + } + + /// Set playback speed + pub fn with_playback_speed(mut self, speed: f64) -> Self { + self.playback_speed = speed; + self + } + + /// Set gain/volume (for audio) + pub fn with_gain(mut self, gain: f32) -> Self { + self.gain = gain; + self + } + + /// Get the effective duration of this instance (accounting for trimming and looping) + /// If timeline_duration is set, returns that (enabling content looping) + /// Otherwise returns the trimmed content duration + pub fn effective_duration(&self, clip_duration: f64) -> f64 { + // If timeline_duration is explicitly set, use that (for looping) + if let Some(timeline_dur) = self.timeline_duration { + return timeline_dur; + } + + // Otherwise, return the trimmed content duration + let end = self.trim_end.unwrap_or(clip_duration); + (end - self.trim_start).max(0.0) + } + + /// Remap timeline time to clip content time + /// + /// Takes a global timeline time and returns the corresponding time within this + /// clip's content, accounting for: + /// - Instance position (timeline_start) + /// - Playback speed + /// - Trimming (trim_start, trim_end) + /// - Looping (if timeline_duration > content window) + /// + /// Returns None if the clip instance is not active at the given timeline time. + pub fn remap_time(&self, timeline_time: f64, clip_duration: f64) -> Option { + // Check if clip instance is active at this time + let instance_end = self.timeline_start + self.effective_duration(clip_duration); + if timeline_time < self.timeline_start || timeline_time >= instance_end { + return None; + } + + // Calculate relative time within the instance (0.0 = start of instance) + let relative_time = timeline_time - self.timeline_start; + + // Account for playback speed + let content_time = relative_time * self.playback_speed; + + // Get the content window size (the portion of clip we're sampling) + let trim_end = self.trim_end.unwrap_or(clip_duration); + let content_window = (trim_end - self.trim_start).max(0.0); + + // If content_window is zero, can't sample anything + if content_window == 0.0 { + return Some(self.trim_start); + } + + // Apply looping if content exceeds the window + let looped_time = if content_time > content_window { + content_time % content_window + } else { + content_time + }; + + // Add trim_start offset to get final clip time + Some(self.trim_start + looped_time) + } + + /// Convert to affine transform + pub fn to_affine(&self) -> vello::kurbo::Affine { + self.transform.to_affine() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_vector_clip_creation() { + let clip = VectorClip::new("My Composition", 1920.0, 1080.0, 10.0); + assert_eq!(clip.name, "My Composition"); + assert_eq!(clip.width, 1920.0); + assert_eq!(clip.height, 1080.0); + assert_eq!(clip.duration, 10.0); + } + + #[test] + fn test_video_clip_creation() { + let clip = VideoClip::new("My Video", "/path/to/video.mp4", 1920.0, 1080.0, 30.0, 24.0); + assert_eq!(clip.name, "My Video"); + assert_eq!(clip.file_path, "/path/to/video.mp4"); + assert_eq!(clip.duration, 30.0); + assert_eq!(clip.frame_rate, 24.0); + } + + #[test] + fn test_audio_clip_sampled() { + let clip = AudioClip::new_sampled("Background Music", 0, 180.0); + assert_eq!(clip.name, "Background Music"); + assert_eq!(clip.duration, 180.0); + assert_eq!(clip.audio_pool_index(), Some(0)); + } + + #[test] + fn test_audio_clip_midi() { + let events = vec![MidiEvent::note_on(0.0, 0, 60, 100)]; + let clip = AudioClip::new_midi("Piano Melody", 60.0, events.clone(), false); + assert_eq!(clip.name, "Piano Melody"); + assert_eq!(clip.duration, 60.0); + assert_eq!(clip.midi_events().map(|e| e.len()), Some(1)); + } + + #[test] + fn test_midi_event_creation() { + let event = MidiEvent::note_on(1.5, 0, 60, 100); + assert_eq!(event.timestamp, 1.5); + assert_eq!(event.status, 0x90); // Note on, channel 0 + assert_eq!(event.data1, 60); // Middle C + assert_eq!(event.data2, 100); // Velocity + } + + #[test] + fn test_any_clip_enum() { + let vector_clip = VectorClip::new("Comp", 1920.0, 1080.0, 10.0); + let any_clip = AnyClip::Vector(vector_clip.clone()); + + assert_eq!(any_clip.id(), vector_clip.id); + assert_eq!(any_clip.name(), "Comp"); + assert_eq!(any_clip.duration(), 10.0); + } + + #[test] + fn test_clip_instance_creation() { + let clip_id = Uuid::new_v4(); + let instance = ClipInstance::new(clip_id); + + assert_eq!(instance.clip_id, clip_id); + assert_eq!(instance.opacity, 1.0); + assert_eq!(instance.timeline_start, 0.0); + assert_eq!(instance.trim_start, 0.0); + assert_eq!(instance.trim_end, None); + assert_eq!(instance.playback_speed, 1.0); + assert_eq!(instance.gain, 1.0); + } + + #[test] + fn test_clip_instance_trimming() { + let clip_id = Uuid::new_v4(); + let instance = ClipInstance::new(clip_id) + .with_trimming(2.0, Some(8.0)); + + assert_eq!(instance.trim_start, 2.0); + assert_eq!(instance.trim_end, Some(8.0)); + assert_eq!(instance.effective_duration(10.0), 6.0); + } + + #[test] + fn test_clip_instance_no_end_trim() { + let clip_id = Uuid::new_v4(); + let instance = ClipInstance::new(clip_id) + .with_trimming(2.0, None); + + assert_eq!(instance.trim_start, 2.0); + assert_eq!(instance.trim_end, None); + assert_eq!(instance.effective_duration(10.0), 8.0); + } + + #[test] + fn test_clip_instance_builder() { + let clip_id = Uuid::new_v4(); + let instance = ClipInstance::new(clip_id) + .with_position(100.0, 200.0) + .with_opacity(0.5) + .with_name("My Instance") + .with_playback_speed(2.0) + .with_gain(0.8); + + assert_eq!(instance.transform.x, 100.0); + assert_eq!(instance.transform.y, 200.0); + assert_eq!(instance.opacity, 0.5); + assert_eq!(instance.name, Some("My Instance".to_string())); + assert_eq!(instance.playback_speed, 2.0); + assert_eq!(instance.gain, 0.8); + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/document.rs b/lightningbeam-ui/lightningbeam-core/src/document.rs index e161ffc..37e4a60 100644 --- a/lightningbeam-ui/lightningbeam-core/src/document.rs +++ b/lightningbeam-ui/lightningbeam-core/src/document.rs @@ -3,9 +3,11 @@ //! The Document represents a complete animation project with settings //! and a root graphics object containing the scene graph. +use crate::clip::{AudioClip, VideoClip, VectorClip}; use crate::layer::AnyLayer; use crate::shape::ShapeColor; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; use uuid::Uuid; /// Root graphics object containing all layers in the scene @@ -91,6 +93,16 @@ pub struct Document { /// Root graphics object containing all layers pub root: GraphicsObject, + /// Clip libraries - reusable clip definitions + /// VectorClips can be instantiated multiple times with different transforms/timing + pub vector_clips: HashMap, + + /// Video clip library - references to video files + pub video_clips: HashMap, + + /// Audio clip library - sampled audio and MIDI clips + pub audio_clips: HashMap, + /// Current playback time in seconds #[serde(skip)] pub current_time: f64, @@ -107,6 +119,9 @@ impl Default for Document { framerate: 60.0, duration: 10.0, root: GraphicsObject::default(), + vector_clips: HashMap::new(), + video_clips: HashMap::new(), + audio_clips: HashMap::new(), current_time: 0.0, } } @@ -159,15 +174,12 @@ impl Document { self.current_time = time.max(0.0).min(self.duration); } - /// Get visible layers at the current time from the root graphics object + /// Get visible layers from the root graphics object pub fn visible_layers(&self) -> impl Iterator { self.root .children .iter() - .filter(|layer| { - let layer = layer.layer(); - layer.visible && layer.contains_time(self.current_time) - }) + .filter(|layer| layer.layer().visible) } /// Get a layer by ID @@ -192,6 +204,74 @@ impl Document { pub fn get_layer_mut(&mut self, id: &Uuid) -> Option<&mut AnyLayer> { self.root.get_child_mut(id) } + + // === CLIP LIBRARY METHODS === + + /// Add a vector clip to the library + pub fn add_vector_clip(&mut self, clip: VectorClip) -> Uuid { + let id = clip.id; + self.vector_clips.insert(id, clip); + id + } + + /// Add a video clip to the library + pub fn add_video_clip(&mut self, clip: VideoClip) -> Uuid { + let id = clip.id; + self.video_clips.insert(id, clip); + id + } + + /// Add an audio clip to the library + pub fn add_audio_clip(&mut self, clip: AudioClip) -> Uuid { + let id = clip.id; + self.audio_clips.insert(id, clip); + id + } + + /// Get a vector clip by ID + pub fn get_vector_clip(&self, id: &Uuid) -> Option<&VectorClip> { + self.vector_clips.get(id) + } + + /// Get a video clip by ID + pub fn get_video_clip(&self, id: &Uuid) -> Option<&VideoClip> { + self.video_clips.get(id) + } + + /// Get an audio clip by ID + pub fn get_audio_clip(&self, id: &Uuid) -> Option<&AudioClip> { + self.audio_clips.get(id) + } + + /// Get a mutable vector clip by ID + pub fn get_vector_clip_mut(&mut self, id: &Uuid) -> Option<&mut VectorClip> { + self.vector_clips.get_mut(id) + } + + /// Get a mutable video clip by ID + pub fn get_video_clip_mut(&mut self, id: &Uuid) -> Option<&mut VideoClip> { + self.video_clips.get_mut(id) + } + + /// Get a mutable audio clip by ID + pub fn get_audio_clip_mut(&mut self, id: &Uuid) -> Option<&mut AudioClip> { + self.audio_clips.get_mut(id) + } + + /// Remove a vector clip from the library + pub fn remove_vector_clip(&mut self, id: &Uuid) -> Option { + self.vector_clips.remove(id) + } + + /// Remove a video clip from the library + pub fn remove_video_clip(&mut self, id: &Uuid) -> Option { + self.video_clips.remove(id) + } + + /// Remove an audio clip from the library + pub fn remove_audio_clip(&mut self, id: &Uuid) -> Option { + self.audio_clips.remove(id) + } } #[cfg(test)] diff --git a/lightningbeam-ui/lightningbeam-core/src/hit_test.rs b/lightningbeam-ui/lightningbeam-core/src/hit_test.rs index fb53b0c..f2ef592 100644 --- a/lightningbeam-ui/lightningbeam-core/src/hit_test.rs +++ b/lightningbeam-ui/lightningbeam-core/src/hit_test.rs @@ -3,12 +3,23 @@ //! Provides functions for testing if points or rectangles intersect with //! shapes and objects, taking into account transform hierarchies. +use crate::clip::{ClipInstance, VectorClip, VideoClip}; use crate::layer::VectorLayer; -use crate::object::Object; +use crate::object::ShapeInstance; use crate::shape::Shape; +use serde::{Deserialize, Serialize}; use uuid::Uuid; use vello::kurbo::{Affine, Point, Rect, Shape as KurboShape}; +/// Result of a hit test operation +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum HitResult { + /// Hit a shape instance + ShapeInstance(Uuid), + /// Hit a clip instance + ClipInstance(Uuid), +} + /// Hit test a layer at a specific point /// /// Tests objects in reverse order (front to back) and returns the first hit. @@ -31,7 +42,7 @@ pub fn hit_test_layer( parent_transform: Affine, ) -> Option { // Test objects in reverse order (back to front in Vec = front to back for hit testing) - for object in layer.objects.iter().rev() { + for object in layer.shape_instances.iter().rev() { // Get the shape for this object let shape = layer.get_shape(&object.shape_id)?; @@ -127,7 +138,7 @@ pub fn hit_test_objects_in_rect( ) -> Vec { let mut hits = Vec::new(); - for object in &layer.objects { + for object in &layer.shape_instances { // Get the shape for this object if let Some(shape) = layer.get_shape(&object.shape_id) { // Combine parent transform with object transform @@ -161,7 +172,7 @@ pub fn hit_test_objects_in_rect( /// /// The bounding box in screen/canvas space pub fn get_object_bounds( - object: &Object, + object: &ShapeInstance, shape: &Shape, parent_transform: Affine, ) -> Rect { @@ -170,6 +181,154 @@ pub fn get_object_bounds( combined_transform.transform_rect_bbox(local_bbox) } +/// Hit test a single clip instance with a given clip bounds +/// +/// Tests if a point hits the clip instance's bounding box. +/// +/// # Arguments +/// +/// * `clip_instance` - The clip instance to test +/// * `clip_width` - The clip's width in pixels +/// * `clip_height` - The clip's height in pixels +/// * `point` - The point to test in screen/canvas space +/// * `parent_transform` - Transform from parent layer/clip +/// +/// # Returns +/// +/// true if the point hits the clip instance, false otherwise +pub fn hit_test_clip_instance( + clip_instance: &ClipInstance, + clip_width: f64, + clip_height: f64, + point: Point, + parent_transform: Affine, +) -> bool { + // Create bounding rectangle for the clip + let clip_rect = Rect::new(0.0, 0.0, clip_width, clip_height); + + // Combine parent transform with clip instance transform + let combined_transform = parent_transform * clip_instance.transform.to_affine(); + + // Transform the bounding rectangle to screen space + let transformed_rect = combined_transform.transform_rect_bbox(clip_rect); + + // Test if point is inside the transformed rectangle + transformed_rect.contains(point) +} + +/// Get the bounding box of a clip instance in screen space +/// +/// # Arguments +/// +/// * `clip_instance` - The clip instance to get bounds for +/// * `clip_width` - The clip's width in pixels +/// * `clip_height` - The clip's height in pixels +/// * `parent_transform` - Transform from parent layer/clip +/// +/// # Returns +/// +/// The bounding box in screen/canvas space +pub fn get_clip_instance_bounds( + clip_instance: &ClipInstance, + clip_width: f64, + clip_height: f64, + parent_transform: Affine, +) -> Rect { + let clip_rect = Rect::new(0.0, 0.0, clip_width, clip_height); + let combined_transform = parent_transform * clip_instance.transform.to_affine(); + combined_transform.transform_rect_bbox(clip_rect) +} + +/// Hit test clip instances at a specific point +/// +/// Tests clip instances in reverse order (front to back) and returns the first hit. +/// This function requires the clip libraries to look up clip dimensions. +/// +/// # Arguments +/// +/// * `clip_instances` - The clip instances to test +/// * `vector_clips` - HashMap of vector clips for looking up dimensions +/// * `video_clips` - HashMap of video clips for looking up dimensions +/// * `point` - The point to test in screen/canvas space +/// * `parent_transform` - Transform from parent layer/clip +/// +/// # Returns +/// +/// The UUID of the first clip instance hit, or None if no hit +pub fn hit_test_clip_instances( + clip_instances: &[ClipInstance], + vector_clips: &std::collections::HashMap, + video_clips: &std::collections::HashMap, + point: Point, + parent_transform: Affine, +) -> Option { + // Test in reverse order (front to back) + for clip_instance in clip_instances.iter().rev() { + // Try to get clip dimensions from either vector or video clips + let (width, height) = if let Some(vector_clip) = vector_clips.get(&clip_instance.clip_id) { + (vector_clip.width, vector_clip.height) + } else if let Some(video_clip) = video_clips.get(&clip_instance.clip_id) { + (video_clip.width, video_clip.height) + } else { + // Clip not found or is audio (no spatial representation) + continue; + }; + + if hit_test_clip_instance(clip_instance, width, height, point, parent_transform) { + return Some(clip_instance.id); + } + } + + None +} + +/// Hit test clip instances within a rectangle (for marquee selection) +/// +/// Returns all clip instances whose bounding boxes intersect with the given rectangle. +/// +/// # Arguments +/// +/// * `clip_instances` - The clip instances to test +/// * `vector_clips` - HashMap of vector clips for looking up dimensions +/// * `video_clips` - HashMap of video clips for looking up dimensions +/// * `rect` - The selection rectangle in screen/canvas space +/// * `parent_transform` - Transform from parent layer/clip +/// +/// # Returns +/// +/// Vector of UUIDs for all clip instances that intersect the rectangle +pub fn hit_test_clip_instances_in_rect( + clip_instances: &[ClipInstance], + vector_clips: &std::collections::HashMap, + video_clips: &std::collections::HashMap, + rect: Rect, + parent_transform: Affine, +) -> Vec { + let mut hits = Vec::new(); + + for clip_instance in clip_instances { + // Try to get clip dimensions from either vector or video clips + let (width, height) = if let Some(vector_clip) = vector_clips.get(&clip_instance.clip_id) { + (vector_clip.width, vector_clip.height) + } else if let Some(video_clip) = video_clips.get(&clip_instance.clip_id) { + (video_clip.width, video_clip.height) + } else { + // Clip not found or is audio (no spatial representation) + continue; + }; + + // Get clip instance bounding box in screen space + let clip_bbox = get_clip_instance_bounds(clip_instance, width, height, parent_transform); + + // Check if rectangles intersect + if rect.intersect(clip_bbox).area() > 0.0 { + hits.push(clip_instance.id); + } + } + + hits +} + #[cfg(test)] mod tests { use super::*; diff --git a/lightningbeam-ui/lightningbeam-core/src/layer.rs b/lightningbeam-ui/lightningbeam-core/src/layer.rs index 034e969..a0e8d95 100644 --- a/lightningbeam-ui/lightningbeam-core/src/layer.rs +++ b/lightningbeam-ui/lightningbeam-core/src/layer.rs @@ -3,9 +3,11 @@ //! Layers organize objects and shapes, and contain animation data. use crate::animation::AnimationData; -use crate::object::Object; +use crate::clip::ClipInstance; +use crate::object::ShapeInstance; use crate::shape::Shape; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; use uuid::Uuid; /// Layer type @@ -21,6 +23,37 @@ pub enum LayerType { Automation, } +/// Common trait for all layer types +/// +/// Provides uniform access to common layer properties across VectorLayer, +/// AudioLayer, VideoLayer, and their wrapper AnyLayer enum. +pub trait LayerTrait { + // Identity + fn id(&self) -> Uuid; + fn name(&self) -> &str; + fn set_name(&mut self, name: String); + fn has_custom_name(&self) -> bool; + fn set_has_custom_name(&mut self, custom: bool); + + // Visual properties + fn visible(&self) -> bool; + fn set_visible(&mut self, visible: bool); + fn opacity(&self) -> f64; + fn set_opacity(&mut self, opacity: f64); + + // Audio properties (all layers can affect audio through nesting) + fn volume(&self) -> f64; + fn set_volume(&mut self, volume: f64); + fn muted(&self) -> bool; + fn set_muted(&mut self, muted: bool); + + // Editor state + fn soloed(&self) -> bool; + fn set_soloed(&mut self, soloed: bool); + fn locked(&self) -> bool; + fn set_locked(&mut self, locked: bool); +} + /// Base layer structure #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Layer { @@ -33,17 +66,26 @@ pub struct Layer { /// Layer name pub name: String, + /// Whether the name was set by user (vs auto-generated) + pub has_custom_name: bool, + /// Whether the layer is visible pub visible: bool, /// Layer opacity (0.0 to 1.0) pub opacity: f64, - /// Start time in seconds - pub start_time: f64, + /// Audio volume (1.0 = 100%, affects nested audio layers/clips) + pub volume: f64, - /// End time in seconds - pub end_time: f64, + /// Audio mute state + pub muted: bool, + + /// Solo state (for isolating layers) + pub soloed: bool, + + /// Lock state (prevents editing) + pub locked: bool, /// Animation data for this layer pub animation_data: AnimationData, @@ -56,10 +98,13 @@ impl Layer { id: Uuid::new_v4(), layer_type, name: name.into(), + has_custom_name: false, // Auto-generated by default visible: true, opacity: 1.0, - start_time: 0.0, - end_time: 10.0, // Default 10 second duration + volume: 1.0, // 100% volume + muted: false, + soloed: false, + locked: false, animation_data: AnimationData::new(), } } @@ -70,36 +115,22 @@ impl Layer { id, layer_type, name: name.into(), + has_custom_name: false, visible: true, opacity: 1.0, - start_time: 0.0, - end_time: 10.0, + volume: 1.0, + muted: false, + soloed: false, + locked: false, animation_data: AnimationData::new(), } } - /// Set the time range - pub fn with_time_range(mut self, start: f64, end: f64) -> Self { - self.start_time = start; - self.end_time = end; - self - } - /// Set visibility pub fn with_visibility(mut self, visible: bool) -> Self { self.visible = visible; self } - - /// Get duration - pub fn duration(&self) -> f64 { - self.end_time - self.start_time - } - - /// Check if a time is within this layer's range - pub fn contains_time(&self, time: f64) -> bool { - time >= self.start_time && time <= self.end_time - } } /// Vector layer containing shapes and objects @@ -108,11 +139,85 @@ pub struct VectorLayer { /// Base layer properties pub layer: Layer, - /// Shapes defined in this layer - pub shapes: Vec, + /// Shapes defined in this layer (indexed by UUID for O(1) lookup) + pub shapes: HashMap, - /// Object instances (references to shapes with transforms) - pub objects: Vec, + /// Shape instances (references to shapes with transforms) + pub shape_instances: Vec, + + /// Clip instances (references to vector clips with transforms) + /// VectorLayer can contain instances of VectorClips for nested compositions + pub clip_instances: Vec, +} + +impl LayerTrait for VectorLayer { + fn id(&self) -> Uuid { + self.layer.id + } + + fn name(&self) -> &str { + &self.layer.name + } + + fn set_name(&mut self, name: String) { + self.layer.name = name; + } + + fn has_custom_name(&self) -> bool { + self.layer.has_custom_name + } + + fn set_has_custom_name(&mut self, custom: bool) { + self.layer.has_custom_name = custom; + } + + fn visible(&self) -> bool { + self.layer.visible + } + + fn set_visible(&mut self, visible: bool) { + self.layer.visible = visible; + } + + fn opacity(&self) -> f64 { + self.layer.opacity + } + + fn set_opacity(&mut self, opacity: f64) { + self.layer.opacity = opacity; + } + + fn volume(&self) -> f64 { + self.layer.volume + } + + fn set_volume(&mut self, volume: f64) { + self.layer.volume = volume; + } + + fn muted(&self) -> bool { + self.layer.muted + } + + fn set_muted(&mut self, muted: bool) { + self.layer.muted = muted; + } + + fn soloed(&self) -> bool { + self.layer.soloed + } + + fn set_soloed(&mut self, soloed: bool) { + self.layer.soloed = soloed; + } + + fn locked(&self) -> bool { + self.layer.locked + } + + fn set_locked(&mut self, locked: bool) { + self.layer.locked = locked; + } } impl VectorLayer { @@ -120,43 +225,44 @@ impl VectorLayer { pub fn new(name: impl Into) -> Self { Self { layer: Layer::new(LayerType::Vector, name), - shapes: Vec::new(), - objects: Vec::new(), + shapes: HashMap::new(), + shape_instances: Vec::new(), + clip_instances: Vec::new(), } } /// Add a shape to this layer pub fn add_shape(&mut self, shape: Shape) -> Uuid { let id = shape.id; - self.shapes.push(shape); + self.shapes.insert(id, shape); id } /// Add an object to this layer - pub fn add_object(&mut self, object: Object) -> Uuid { + pub fn add_object(&mut self, object: ShapeInstance) -> Uuid { let id = object.id; - self.objects.push(object); + self.shape_instances.push(object); id } /// Find a shape by ID pub fn get_shape(&self, id: &Uuid) -> Option<&Shape> { - self.shapes.iter().find(|s| &s.id == id) + self.shapes.get(id) } /// Find a mutable shape by ID pub fn get_shape_mut(&mut self, id: &Uuid) -> Option<&mut Shape> { - self.shapes.iter_mut().find(|s| &s.id == id) + self.shapes.get_mut(id) } /// Find an object by ID - pub fn get_object(&self, id: &Uuid) -> Option<&Object> { - self.objects.iter().find(|o| &o.id == id) + pub fn get_object(&self, id: &Uuid) -> Option<&ShapeInstance> { + self.shape_instances.iter().find(|o| &o.id == id) } /// Find a mutable object by ID - pub fn get_object_mut(&mut self, id: &Uuid) -> Option<&mut Object> { - self.objects.iter_mut().find(|o| &o.id == id) + pub fn get_object_mut(&mut self, id: &Uuid) -> Option<&mut ShapeInstance> { + self.shape_instances.iter_mut().find(|o| &o.id == id) } // === MUTATION METHODS (pub(crate) - only accessible to action module) === @@ -167,7 +273,7 @@ impl VectorLayer { /// only happen through the action system. pub(crate) fn add_shape_internal(&mut self, shape: Shape) -> Uuid { let id = shape.id; - self.shapes.push(shape); + self.shapes.insert(id, shape); id } @@ -175,9 +281,9 @@ impl VectorLayer { /// /// This method is intentionally `pub(crate)` to ensure mutations /// only happen through the action system. - pub(crate) fn add_object_internal(&mut self, object: Object) -> Uuid { + pub(crate) fn add_object_internal(&mut self, object: ShapeInstance) -> Uuid { let id = object.id; - self.objects.push(object); + self.shape_instances.push(object); id } @@ -187,11 +293,7 @@ impl VectorLayer { /// This method is intentionally `pub(crate)` to ensure mutations /// only happen through the action system. pub(crate) fn remove_shape_internal(&mut self, id: &Uuid) -> Option { - if let Some(index) = self.shapes.iter().position(|s| &s.id == id) { - Some(self.shapes.remove(index)) - } else { - None - } + self.shapes.remove(id) } /// Remove an object from this layer (internal, for actions only) @@ -199,9 +301,9 @@ impl VectorLayer { /// Returns the removed object if found. /// This method is intentionally `pub(crate)` to ensure mutations /// only happen through the action system. - pub(crate) fn remove_object_internal(&mut self, id: &Uuid) -> Option { - if let Some(index) = self.objects.iter().position(|o| &o.id == id) { - Some(self.objects.remove(index)) + pub(crate) fn remove_object_internal(&mut self, id: &Uuid) -> Option { + if let Some(index) = self.shape_instances.iter().position(|o| &o.id == id) { + Some(self.shape_instances.remove(index)) } else { None } @@ -214,7 +316,7 @@ impl VectorLayer { /// only happen through the action system. pub fn modify_object_internal(&mut self, id: &Uuid, f: F) where - F: FnOnce(&mut Object), + F: FnOnce(&mut ShapeInstance), { if let Some(object) = self.get_object_mut(id) { f(object); @@ -222,14 +324,85 @@ impl VectorLayer { } } -/// Audio layer (placeholder for future implementation) +/// Audio layer containing audio clips #[derive(Clone, Debug, Serialize, Deserialize)] pub struct AudioLayer { /// Base layer properties pub layer: Layer, - /// Audio file path or data reference - pub audio_source: Option, + /// Clip instances (references to audio clips) + /// AudioLayer can contain instances of AudioClips (sampled or MIDI) + pub clip_instances: Vec, +} + +impl LayerTrait for AudioLayer { + fn id(&self) -> Uuid { + self.layer.id + } + + fn name(&self) -> &str { + &self.layer.name + } + + fn set_name(&mut self, name: String) { + self.layer.name = name; + } + + fn has_custom_name(&self) -> bool { + self.layer.has_custom_name + } + + fn set_has_custom_name(&mut self, custom: bool) { + self.layer.has_custom_name = custom; + } + + fn visible(&self) -> bool { + self.layer.visible + } + + fn set_visible(&mut self, visible: bool) { + self.layer.visible = visible; + } + + fn opacity(&self) -> f64 { + self.layer.opacity + } + + fn set_opacity(&mut self, opacity: f64) { + self.layer.opacity = opacity; + } + + fn volume(&self) -> f64 { + self.layer.volume + } + + fn set_volume(&mut self, volume: f64) { + self.layer.volume = volume; + } + + fn muted(&self) -> bool { + self.layer.muted + } + + fn set_muted(&mut self, muted: bool) { + self.layer.muted = muted; + } + + fn soloed(&self) -> bool { + self.layer.soloed + } + + fn set_soloed(&mut self, soloed: bool) { + self.layer.soloed = soloed; + } + + fn locked(&self) -> bool { + self.layer.locked + } + + fn set_locked(&mut self, locked: bool) { + self.layer.locked = locked; + } } impl AudioLayer { @@ -237,19 +410,90 @@ impl AudioLayer { pub fn new(name: impl Into) -> Self { Self { layer: Layer::new(LayerType::Audio, name), - audio_source: None, + clip_instances: Vec::new(), } } } -/// Video layer (placeholder for future implementation) +/// Video layer containing video clips #[derive(Clone, Debug, Serialize, Deserialize)] pub struct VideoLayer { /// Base layer properties pub layer: Layer, - /// Video file path or data reference - pub video_source: Option, + /// Clip instances (references to video clips) + /// VideoLayer can contain instances of VideoClips + pub clip_instances: Vec, +} + +impl LayerTrait for VideoLayer { + fn id(&self) -> Uuid { + self.layer.id + } + + fn name(&self) -> &str { + &self.layer.name + } + + fn set_name(&mut self, name: String) { + self.layer.name = name; + } + + fn has_custom_name(&self) -> bool { + self.layer.has_custom_name + } + + fn set_has_custom_name(&mut self, custom: bool) { + self.layer.has_custom_name = custom; + } + + fn visible(&self) -> bool { + self.layer.visible + } + + fn set_visible(&mut self, visible: bool) { + self.layer.visible = visible; + } + + fn opacity(&self) -> f64 { + self.layer.opacity + } + + fn set_opacity(&mut self, opacity: f64) { + self.layer.opacity = opacity; + } + + fn volume(&self) -> f64 { + self.layer.volume + } + + fn set_volume(&mut self, volume: f64) { + self.layer.volume = volume; + } + + fn muted(&self) -> bool { + self.layer.muted + } + + fn set_muted(&mut self, muted: bool) { + self.layer.muted = muted; + } + + fn soloed(&self) -> bool { + self.layer.soloed + } + + fn set_soloed(&mut self, soloed: bool) { + self.layer.soloed = soloed; + } + + fn locked(&self) -> bool { + self.layer.locked + } + + fn set_locked(&mut self, locked: bool) { + self.layer.locked = locked; + } } impl VideoLayer { @@ -257,7 +501,7 @@ impl VideoLayer { pub fn new(name: impl Into) -> Self { Self { layer: Layer::new(LayerType::Video, name), - video_source: None, + clip_instances: Vec::new(), } } } @@ -270,6 +514,144 @@ pub enum AnyLayer { Video(VideoLayer), } +impl LayerTrait for AnyLayer { + fn id(&self) -> Uuid { + match self { + AnyLayer::Vector(l) => l.id(), + AnyLayer::Audio(l) => l.id(), + AnyLayer::Video(l) => l.id(), + } + } + + fn name(&self) -> &str { + match self { + AnyLayer::Vector(l) => l.name(), + AnyLayer::Audio(l) => l.name(), + AnyLayer::Video(l) => l.name(), + } + } + + fn set_name(&mut self, name: String) { + match self { + AnyLayer::Vector(l) => l.set_name(name), + AnyLayer::Audio(l) => l.set_name(name), + AnyLayer::Video(l) => l.set_name(name), + } + } + + fn has_custom_name(&self) -> bool { + match self { + AnyLayer::Vector(l) => l.has_custom_name(), + AnyLayer::Audio(l) => l.has_custom_name(), + AnyLayer::Video(l) => l.has_custom_name(), + } + } + + fn set_has_custom_name(&mut self, custom: bool) { + match self { + AnyLayer::Vector(l) => l.set_has_custom_name(custom), + AnyLayer::Audio(l) => l.set_has_custom_name(custom), + AnyLayer::Video(l) => l.set_has_custom_name(custom), + } + } + + fn visible(&self) -> bool { + match self { + AnyLayer::Vector(l) => l.visible(), + AnyLayer::Audio(l) => l.visible(), + AnyLayer::Video(l) => l.visible(), + } + } + + fn set_visible(&mut self, visible: bool) { + match self { + AnyLayer::Vector(l) => l.set_visible(visible), + AnyLayer::Audio(l) => l.set_visible(visible), + AnyLayer::Video(l) => l.set_visible(visible), + } + } + + fn opacity(&self) -> f64 { + match self { + AnyLayer::Vector(l) => l.opacity(), + AnyLayer::Audio(l) => l.opacity(), + AnyLayer::Video(l) => l.opacity(), + } + } + + fn set_opacity(&mut self, opacity: f64) { + match self { + AnyLayer::Vector(l) => l.set_opacity(opacity), + AnyLayer::Audio(l) => l.set_opacity(opacity), + AnyLayer::Video(l) => l.set_opacity(opacity), + } + } + + fn volume(&self) -> f64 { + match self { + AnyLayer::Vector(l) => l.volume(), + AnyLayer::Audio(l) => l.volume(), + AnyLayer::Video(l) => l.volume(), + } + } + + fn set_volume(&mut self, volume: f64) { + match self { + AnyLayer::Vector(l) => l.set_volume(volume), + AnyLayer::Audio(l) => l.set_volume(volume), + AnyLayer::Video(l) => l.set_volume(volume), + } + } + + fn muted(&self) -> bool { + match self { + AnyLayer::Vector(l) => l.muted(), + AnyLayer::Audio(l) => l.muted(), + AnyLayer::Video(l) => l.muted(), + } + } + + fn set_muted(&mut self, muted: bool) { + match self { + AnyLayer::Vector(l) => l.set_muted(muted), + AnyLayer::Audio(l) => l.set_muted(muted), + AnyLayer::Video(l) => l.set_muted(muted), + } + } + + fn soloed(&self) -> bool { + match self { + AnyLayer::Vector(l) => l.soloed(), + AnyLayer::Audio(l) => l.soloed(), + AnyLayer::Video(l) => l.soloed(), + } + } + + fn set_soloed(&mut self, soloed: bool) { + match self { + AnyLayer::Vector(l) => l.set_soloed(soloed), + AnyLayer::Audio(l) => l.set_soloed(soloed), + AnyLayer::Video(l) => l.set_soloed(soloed), + } + } + + fn locked(&self) -> bool { + match self { + AnyLayer::Vector(l) => l.locked(), + AnyLayer::Audio(l) => l.locked(), + AnyLayer::Video(l) => l.locked(), + } + } + + fn set_locked(&mut self, locked: bool) { + match self { + AnyLayer::Vector(l) => l.set_locked(locked), + AnyLayer::Audio(l) => l.set_locked(locked), + AnyLayer::Video(l) => l.set_locked(locked), + } + } +} + impl AnyLayer { /// Get a reference to the base layer pub fn layer(&self) -> &Layer { @@ -316,7 +698,7 @@ mod tests { fn test_vector_layer() { let vector_layer = VectorLayer::new("My Layer"); assert_eq!(vector_layer.shapes.len(), 0); - assert_eq!(vector_layer.objects.len(), 0); + assert_eq!(vector_layer.shape_instances.len(), 0); } #[test] diff --git a/lightningbeam-ui/lightningbeam-core/src/layer_tree.rs b/lightningbeam-ui/lightningbeam-core/src/layer_tree.rs new file mode 100644 index 0000000..456bd10 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/layer_tree.rs @@ -0,0 +1,187 @@ +//! Hierarchical layer tree +//! +//! Provides a tree structure for organizing layers in a hierarchical manner. +//! Layers can be nested within other layers for organizational purposes. + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// Node in the layer tree +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct LayerNode { + /// The layer data + pub data: T, + + /// Child layers + pub children: Vec>, +} + +impl LayerNode { + /// Create a new layer node + pub fn new(data: T) -> Self { + Self { + data, + children: Vec::new(), + } + } + + /// Add a child layer + pub fn add_child(&mut self, child: LayerNode) { + self.children.push(child); + } + + /// Remove a child layer by index + pub fn remove_child(&mut self, index: usize) -> Option> { + if index < self.children.len() { + Some(self.children.remove(index)) + } else { + None + } + } + + /// Get a reference to a child + pub fn get_child(&self, index: usize) -> Option<&LayerNode> { + self.children.get(index) + } + + /// Get a mutable reference to a child + pub fn get_child_mut(&mut self, index: usize) -> Option<&mut LayerNode> { + self.children.get_mut(index) + } + + /// Get number of children + pub fn child_count(&self) -> usize { + self.children.len() + } +} + +/// Layer tree root +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct LayerTree { + /// Root layers (no parent) + pub roots: Vec>, +} + +impl LayerTree { + /// Create a new empty layer tree + pub fn new() -> Self { + Self { roots: Vec::new() } + } + + /// Add a root layer and return its index + pub fn add_root(&mut self, data: T) -> usize { + let node = LayerNode::new(data); + let index = self.roots.len(); + self.roots.push(node); + index + } + + /// Remove a root layer by index + pub fn remove_root(&mut self, index: usize) -> Option> { + if index < self.roots.len() { + Some(self.roots.remove(index)) + } else { + None + } + } + + /// Get a reference to a root layer + pub fn get_root(&self, index: usize) -> Option<&LayerNode> { + self.roots.get(index) + } + + /// Get a mutable reference to a root layer + pub fn get_root_mut(&mut self, index: usize) -> Option<&mut LayerNode> { + self.roots.get_mut(index) + } + + /// Get number of root layers + pub fn root_count(&self) -> usize { + self.roots.len() + } + + /// Iterate over all root layers + pub fn iter(&self) -> impl Iterator> { + self.roots.iter() + } + + /// Iterate over all root layers mutably + pub fn iter_mut(&mut self) -> impl Iterator> { + self.roots.iter_mut() + } +} + +impl Default for LayerTree { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_layer_tree_creation() { + let tree: LayerTree = LayerTree::new(); + assert_eq!(tree.root_count(), 0); + } + + #[test] + fn test_add_root_layers() { + let mut tree = LayerTree::new(); + tree.add_root(1); + tree.add_root(2); + tree.add_root(3); + + assert_eq!(tree.root_count(), 3); + assert_eq!(tree.get_root(0).unwrap().data, 1); + assert_eq!(tree.get_root(1).unwrap().data, 2); + assert_eq!(tree.get_root(2).unwrap().data, 3); + } + + #[test] + fn test_nested_layers() { + let mut tree = LayerTree::new(); + let root_idx = tree.add_root("Root"); + + let root = tree.get_root_mut(root_idx).unwrap(); + root.add_child(LayerNode::new("Child 1")); + root.add_child(LayerNode::new("Child 2")); + + assert_eq!(root.child_count(), 2); + assert_eq!(root.get_child(0).unwrap().data, "Child 1"); + assert_eq!(root.get_child(1).unwrap().data, "Child 2"); + } + + #[test] + fn test_remove_root() { + let mut tree = LayerTree::new(); + tree.add_root(1); + tree.add_root(2); + tree.add_root(3); + + let removed = tree.remove_root(1); + assert_eq!(removed.unwrap().data, 2); + assert_eq!(tree.root_count(), 2); + assert_eq!(tree.get_root(0).unwrap().data, 1); + assert_eq!(tree.get_root(1).unwrap().data, 3); + } + + #[test] + fn test_remove_child() { + let mut tree = LayerTree::new(); + let root_idx = tree.add_root("Root"); + + let root = tree.get_root_mut(root_idx).unwrap(); + root.add_child(LayerNode::new("Child 1")); + root.add_child(LayerNode::new("Child 2")); + root.add_child(LayerNode::new("Child 3")); + + let removed = root.remove_child(1); + assert_eq!(removed.unwrap().data, "Child 2"); + assert_eq!(root.child_count(), 2); + assert_eq!(root.get_child(0).unwrap().data, "Child 1"); + assert_eq!(root.get_child(1).unwrap().data, "Child 3"); + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/lib.rs b/lightningbeam-ui/lightningbeam-core/src/lib.rs index f55a73c..091d803 100644 --- a/lightningbeam-ui/lightningbeam-core/src/lib.rs +++ b/lightningbeam-ui/lightningbeam-core/src/lib.rs @@ -10,6 +10,8 @@ pub mod path_fitting; pub mod shape; pub mod object; pub mod layer; +pub mod layer_tree; +pub mod clip; pub mod document; pub mod renderer; pub mod action; diff --git a/lightningbeam-ui/lightningbeam-core/src/object.rs b/lightningbeam-ui/lightningbeam-core/src/object.rs index c140a6f..c5b4037 100644 --- a/lightningbeam-ui/lightningbeam-core/src/object.rs +++ b/lightningbeam-ui/lightningbeam-core/src/object.rs @@ -8,6 +8,7 @@ use uuid::Uuid; use vello::kurbo::Shape as KurboShape; /// 2D transform for an object +/// Contains only geometric transformations (position, rotation, scale, skew) #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Transform { /// X position @@ -24,8 +25,6 @@ pub struct Transform { pub skew_x: f64, /// Y skew in degrees pub skew_y: f64, - /// Opacity (0.0 to 1.0) - pub opacity: f64, } impl Default for Transform { @@ -38,7 +37,6 @@ impl Default for Transform { scale_y: 1.0, skew_x: 0.0, skew_y: 0.0, - opacity: 1.0, } } } @@ -119,39 +117,46 @@ impl Transform { } } -/// An object instance (shape with transform) +/// A shape instance (shape with transform) +/// Represents an instance of a Shape with its own transform properties. +/// Multiple instances can reference the same shape. #[derive(Clone, Debug, Serialize, Deserialize)] -pub struct Object { +pub struct ShapeInstance { /// Unique identifier pub id: Uuid, /// Reference to the shape this object uses pub shape_id: Uuid, - /// Transform properties + /// Transform properties (position, rotation, scale, skew) pub transform: Transform, + /// Opacity (0.0 to 1.0, separate from geometric transform) + pub opacity: f64, + /// Name for display in UI pub name: Option, } -impl Object { - /// Create a new object for a shape +impl ShapeInstance { + /// Create a new shape instance for a shape pub fn new(shape_id: Uuid) -> Self { Self { id: Uuid::new_v4(), shape_id, transform: Transform::default(), + opacity: 1.0, name: None, } } - /// Create a new object with a specific ID + /// Create a new shape instance with a specific ID pub fn with_id(id: Uuid, shape_id: Uuid) -> Self { Self { id, shape_id, transform: Transform::default(), + opacity: 1.0, name: None, } } @@ -174,15 +179,15 @@ impl Object { self } - /// Convert object transform to affine matrix + /// Convert shape instance transform to affine matrix pub fn to_affine(&self) -> kurbo::Affine { self.transform.to_affine() } - /// Get the bounding box of this object given its shape + /// Get the bounding box of this shape instance given its shape /// - /// Returns the bounding box in the object's parent coordinate space - /// (i.e., with the object's transform applied). + /// Returns the bounding box in the instance's parent coordinate space + /// (i.e., with the instance's transform applied). pub fn bounding_box(&self, shape: &crate::shape::Shape) -> kurbo::Rect { let path_bbox = shape.path().bounding_box(); self.to_affine().transform_rect_bbox(path_bbox) @@ -214,11 +219,11 @@ mod tests { } #[test] - fn test_object_creation() { + fn test_shape_instance_creation() { let shape_id = Uuid::new_v4(); - let object = Object::new(shape_id); + let shape_instance = ShapeInstance::new(shape_id); - assert_eq!(object.shape_id, shape_id); - assert_eq!(object.transform.x, 0.0); + assert_eq!(shape_instance.shape_id, shape_id); + assert_eq!(shape_instance.transform.x, 0.0); } } diff --git a/lightningbeam-ui/lightningbeam-core/src/renderer.rs b/lightningbeam-ui/lightningbeam-core/src/renderer.rs index ee1ae0c..f626f43 100644 --- a/lightningbeam-ui/lightningbeam-core/src/renderer.rs +++ b/lightningbeam-ui/lightningbeam-core/src/renderer.rs @@ -5,6 +5,7 @@ use crate::animation::TransformProperty; use crate::document::Document; use crate::layer::{AnyLayer, VectorLayer}; +use crate::object::ShapeInstance; use kurbo::{Affine, Shape}; use vello::kurbo::Rect; use vello::peniko::Fill; @@ -21,8 +22,9 @@ pub fn render_document_with_transform(document: &Document, scene: &mut Scene, ba // 1. Draw background render_background(document, scene, base_transform); - // 2. Recursively render the root graphics object - render_graphics_object(document, scene, base_transform); + // 2. Recursively render the root graphics object at current time + let time = document.current_time; + render_graphics_object(document, time, scene, base_transform); } /// Draw the document background @@ -42,17 +44,17 @@ fn render_background(document: &Document, scene: &mut Scene, base_transform: Aff } /// Recursively render the root graphics object and its children -fn render_graphics_object(document: &Document, scene: &mut Scene, base_transform: Affine) { +fn render_graphics_object(document: &Document, time: f64, scene: &mut Scene, base_transform: Affine) { // Render all visible layers in the root graphics object for layer in document.visible_layers() { - render_layer(document, layer, scene, base_transform); + render_layer(document, time, layer, scene, base_transform); } } /// Render a single layer -fn render_layer(document: &Document, layer: &AnyLayer, scene: &mut Scene, base_transform: Affine) { +fn render_layer(document: &Document, time: f64, layer: &AnyLayer, scene: &mut Scene, base_transform: Affine) { match layer { - AnyLayer::Vector(vector_layer) => render_vector_layer(document, vector_layer, scene, base_transform), + AnyLayer::Vector(vector_layer) => render_vector_layer(document, time, vector_layer, scene, base_transform), AnyLayer::Audio(_) => { // Audio layers don't render visually } @@ -62,28 +64,65 @@ fn render_layer(document: &Document, layer: &AnyLayer, scene: &mut Scene, base_t } } -/// Render a vector layer with all its objects -fn render_vector_layer(document: &Document, layer: &VectorLayer, scene: &mut Scene, base_transform: Affine) { - let time = document.current_time; +/// Render a clip instance (recursive rendering for nested compositions) +fn render_clip_instance( + document: &Document, + time: f64, + clip_instance: &crate::clip::ClipInstance, + _parent_opacity: f64, + scene: &mut Scene, + base_transform: Affine, +) { + // Try to find the clip in the document's clip libraries + // For now, only handle VectorClips (VideoClip and AudioClip rendering not yet implemented) + let Some(vector_clip) = document.vector_clips.get(&clip_instance.clip_id) else { + return; // Clip not found or not a vector clip + }; + + // Remap timeline time to clip's internal time + let Some(clip_time) = clip_instance.remap_time(time, vector_clip.duration) else { + return; // Clip instance not active at this time + }; + + // Build transform for this clip instance + let instance_transform = base_transform * clip_instance.to_affine(); + + // TODO: Properly handle clip instance opacity by threading opacity through rendering pipeline + // Currently clip_instance.opacity is not being applied to nested layers + + // Recursively render all root layers in the clip at the remapped time + for layer_node in vector_clip.layers.iter() { + // TODO: Filter by visibility and time range once LayerNode exposes that data + render_layer(document, clip_time, &layer_node.data, scene, instance_transform); + } +} + +/// Render a vector layer with all its clip instances and shape instances +fn render_vector_layer(document: &Document, time: f64, layer: &VectorLayer, scene: &mut Scene, base_transform: Affine) { // Get layer-level opacity let layer_opacity = layer.layer.opacity; - // Render each object in the layer - for object in &layer.objects { - // Get the shape for this object - let Some(shape) = layer.get_shape(&object.shape_id) else { + // Render clip instances first (they appear under shape instances) + for clip_instance in &layer.clip_instances { + render_clip_instance(document, time, clip_instance, layer_opacity, scene, base_transform); + } + + // Render each shape instance in the layer + for shape_instance in &layer.shape_instances { + // Get the shape for this instance + let Some(shape) = layer.get_shape(&shape_instance.shape_id) else { continue; }; // Evaluate animated properties - let transform = &object.transform; + let transform = &shape_instance.transform; let x = layer .layer .animation_data .eval( &crate::animation::AnimationTarget::Object { - id: object.id, + id: shape_instance.id, property: TransformProperty::X, }, time, @@ -94,7 +133,7 @@ fn render_vector_layer(document: &Document, layer: &VectorLayer, scene: &mut Sce .animation_data .eval( &crate::animation::AnimationTarget::Object { - id: object.id, + id: shape_instance.id, property: TransformProperty::Y, }, time, @@ -105,7 +144,7 @@ fn render_vector_layer(document: &Document, layer: &VectorLayer, scene: &mut Sce .animation_data .eval( &crate::animation::AnimationTarget::Object { - id: object.id, + id: shape_instance.id, property: TransformProperty::Rotation, }, time, @@ -116,7 +155,7 @@ fn render_vector_layer(document: &Document, layer: &VectorLayer, scene: &mut Sce .animation_data .eval( &crate::animation::AnimationTarget::Object { - id: object.id, + id: shape_instance.id, property: TransformProperty::ScaleX, }, time, @@ -127,7 +166,7 @@ fn render_vector_layer(document: &Document, layer: &VectorLayer, scene: &mut Sce .animation_data .eval( &crate::animation::AnimationTarget::Object { - id: object.id, + id: shape_instance.id, property: TransformProperty::ScaleY, }, time, @@ -138,7 +177,7 @@ fn render_vector_layer(document: &Document, layer: &VectorLayer, scene: &mut Sce .animation_data .eval( &crate::animation::AnimationTarget::Object { - id: object.id, + id: shape_instance.id, property: TransformProperty::SkewX, }, time, @@ -149,7 +188,7 @@ fn render_vector_layer(document: &Document, layer: &VectorLayer, scene: &mut Sce .animation_data .eval( &crate::animation::AnimationTarget::Object { - id: object.id, + id: shape_instance.id, property: TransformProperty::SkewY, }, time, @@ -160,11 +199,11 @@ fn render_vector_layer(document: &Document, layer: &VectorLayer, scene: &mut Sce .animation_data .eval( &crate::animation::AnimationTarget::Object { - id: object.id, + id: shape_instance.id, property: TransformProperty::Opacity, }, time, - transform.opacity, + shape_instance.opacity, ); // Check if shape has morphing animation @@ -276,7 +315,7 @@ mod tests { use super::*; use crate::document::Document; use crate::layer::{AnyLayer, VectorLayer}; - use crate::object::Object; + use crate::object::ShapeInstance; use crate::shape::{Shape, ShapeColor}; use kurbo::{Circle, Shape as KurboShape}; @@ -298,13 +337,13 @@ mod tests { let path = circle.to_path(0.1); let shape = Shape::new(path).with_fill(ShapeColor::rgb(255, 0, 0)); - // Create an object for the shape - let object = Object::new(shape.id); + // Create a shape instance for the shape + let shape_instance = ShapeInstance::new(shape.id); // Create a vector layer let mut vector_layer = VectorLayer::new("Layer 1"); vector_layer.add_shape(shape); - vector_layer.add_object(object); + vector_layer.add_object(shape_instance); // Add to document doc.root.add_child(AnyLayer::Vector(vector_layer)); diff --git a/lightningbeam-ui/lightningbeam-core/src/selection.rs b/lightningbeam-ui/lightningbeam-core/src/selection.rs index 85d2068..cd6f673 100644 --- a/lightningbeam-ui/lightningbeam-core/src/selection.rs +++ b/lightningbeam-ui/lightningbeam-core/src/selection.rs @@ -1,63 +1,67 @@ //! Selection state management //! -//! Tracks selected objects and shapes for editing operations. +//! Tracks selected shape instances, clip instances, and shapes for editing operations. use serde::{Deserialize, Serialize}; use uuid::Uuid; /// Selection state for the editor /// -/// Maintains sets of selected objects and shapes. +/// Maintains sets of selected shape instances, clip instances, and shapes. /// This is separate from the document to make it easy to /// pass around for UI rendering without needing mutable access. #[derive(Clone, Debug, Default, Serialize, Deserialize)] pub struct Selection { - /// Currently selected objects (instances) - selected_objects: Vec, + /// Currently selected shape instances + selected_shape_instances: Vec, /// Currently selected shapes (definitions) selected_shapes: Vec, + + /// Currently selected clip instances + selected_clip_instances: Vec, } impl Selection { /// Create a new empty selection pub fn new() -> Self { Self { - selected_objects: Vec::new(), + selected_shape_instances: Vec::new(), selected_shapes: Vec::new(), + selected_clip_instances: Vec::new(), } } - /// Add an object to the selection - pub fn add_object(&mut self, id: Uuid) { - if !self.selected_objects.contains(&id) { - self.selected_objects.push(id); + /// Add a shape instance to the selection + pub fn add_shape_instance(&mut self, id: Uuid) { + if !self.selected_shape_instances.contains(&id) { + self.selected_shape_instances.push(id); } } - /// Add a shape to the selection + /// Add a shape definition to the selection pub fn add_shape(&mut self, id: Uuid) { if !self.selected_shapes.contains(&id) { self.selected_shapes.push(id); } } - /// Remove an object from the selection - pub fn remove_object(&mut self, id: &Uuid) { - self.selected_objects.retain(|&x| x != *id); + /// Remove a shape instance from the selection + pub fn remove_shape_instance(&mut self, id: &Uuid) { + self.selected_shape_instances.retain(|&x| x != *id); } - /// Remove a shape from the selection + /// Remove a shape definition from the selection pub fn remove_shape(&mut self, id: &Uuid) { self.selected_shapes.retain(|&x| x != *id); } - /// Toggle an object's selection state - pub fn toggle_object(&mut self, id: Uuid) { - if self.contains_object(&id) { - self.remove_object(&id); + /// Toggle a shape instance's selection state + pub fn toggle_shape_instance(&mut self, id: Uuid) { + if self.contains_shape_instance(&id) { + self.remove_shape_instance(&id); } else { - self.add_object(id); + self.add_shape_instance(id); } } @@ -70,15 +74,37 @@ impl Selection { } } + /// Add a clip instance to the selection + pub fn add_clip_instance(&mut self, id: Uuid) { + if !self.selected_clip_instances.contains(&id) { + self.selected_clip_instances.push(id); + } + } + + /// Remove a clip instance from the selection + pub fn remove_clip_instance(&mut self, id: &Uuid) { + self.selected_clip_instances.retain(|&x| x != *id); + } + + /// Toggle a clip instance's selection state + pub fn toggle_clip_instance(&mut self, id: Uuid) { + if self.contains_clip_instance(&id) { + self.remove_clip_instance(&id); + } else { + self.add_clip_instance(id); + } + } + /// Clear all selections pub fn clear(&mut self) { - self.selected_objects.clear(); + self.selected_shape_instances.clear(); self.selected_shapes.clear(); + self.selected_clip_instances.clear(); } /// Clear only object selections - pub fn clear_objects(&mut self) { - self.selected_objects.clear(); + pub fn clear_shape_instances(&mut self) { + self.selected_shape_instances.clear(); } /// Clear only shape selections @@ -86,9 +112,14 @@ impl Selection { self.selected_shapes.clear(); } + /// Clear only clip instance selections + pub fn clear_clip_instances(&mut self) { + self.selected_clip_instances.clear(); + } + /// Check if an object is selected - pub fn contains_object(&self, id: &Uuid) -> bool { - self.selected_objects.contains(id) + pub fn contains_shape_instance(&self, id: &Uuid) -> bool { + self.selected_shape_instances.contains(id) } /// Check if a shape is selected @@ -96,14 +127,21 @@ impl Selection { self.selected_shapes.contains(id) } + /// Check if a clip instance is selected + pub fn contains_clip_instance(&self, id: &Uuid) -> bool { + self.selected_clip_instances.contains(id) + } + /// Check if selection is empty pub fn is_empty(&self) -> bool { - self.selected_objects.is_empty() && self.selected_shapes.is_empty() + self.selected_shape_instances.is_empty() + && self.selected_shapes.is_empty() + && self.selected_clip_instances.is_empty() } /// Get the selected objects - pub fn objects(&self) -> &[Uuid] { - &self.selected_objects + pub fn shape_instances(&self) -> &[Uuid] { + &self.selected_shape_instances } /// Get the selected shapes @@ -112,8 +150,8 @@ impl Selection { } /// Get the number of selected objects - pub fn object_count(&self) -> usize { - self.selected_objects.len() + pub fn shape_instance_count(&self) -> usize { + self.selected_shape_instances.len() } /// Get the number of selected shapes @@ -121,10 +159,20 @@ impl Selection { self.selected_shapes.len() } + /// Get the selected clip instances + pub fn clip_instances(&self) -> &[Uuid] { + &self.selected_clip_instances + } + + /// Get the number of selected clip instances + pub fn clip_instance_count(&self) -> usize { + self.selected_clip_instances.len() + } + /// Set selection to a single object (clears previous selection) - pub fn select_only_object(&mut self, id: Uuid) { + pub fn select_only_shape_instance(&mut self, id: Uuid) { self.clear(); - self.add_object(id); + self.add_shape_instance(id); } /// Set selection to a single shape (clears previous selection) @@ -133,11 +181,17 @@ impl Selection { self.add_shape(id); } + /// Set selection to a single clip instance (clears previous selection) + pub fn select_only_clip_instance(&mut self, id: Uuid) { + self.clear(); + self.add_clip_instance(id); + } + /// Set selection to multiple objects (clears previous selection) - pub fn select_objects(&mut self, ids: &[Uuid]) { - self.clear_objects(); + pub fn select_shape_instances(&mut self, ids: &[Uuid]) { + self.clear_shape_instances(); for &id in ids { - self.add_object(id); + self.add_shape_instance(id); } } @@ -148,6 +202,14 @@ impl Selection { self.add_shape(id); } } + + /// Set selection to multiple clip instances (clears previous selection) + pub fn select_clip_instances(&mut self, ids: &[Uuid]) { + self.clear_clip_instances(); + for &id in ids { + self.add_clip_instance(id); + } + } } #[cfg(test)] @@ -158,7 +220,7 @@ mod tests { fn test_selection_creation() { let selection = Selection::new(); assert!(selection.is_empty()); - assert_eq!(selection.object_count(), 0); + assert_eq!(selection.shape_instance_count(), 0); assert_eq!(selection.shape_count(), 0); } @@ -168,17 +230,17 @@ mod tests { let id1 = Uuid::new_v4(); let id2 = Uuid::new_v4(); - selection.add_object(id1); - assert_eq!(selection.object_count(), 1); - assert!(selection.contains_object(&id1)); + selection.add_shape_instance(id1); + assert_eq!(selection.shape_instance_count(), 1); + assert!(selection.contains_shape_instance(&id1)); - selection.add_object(id2); - assert_eq!(selection.object_count(), 2); + selection.add_shape_instance(id2); + assert_eq!(selection.shape_instance_count(), 2); - selection.remove_object(&id1); - assert_eq!(selection.object_count(), 1); - assert!(!selection.contains_object(&id1)); - assert!(selection.contains_object(&id2)); + selection.remove_shape_instance(&id1); + assert_eq!(selection.shape_instance_count(), 1); + assert!(!selection.contains_shape_instance(&id1)); + assert!(selection.contains_shape_instance(&id2)); } #[test] @@ -186,11 +248,11 @@ mod tests { let mut selection = Selection::new(); let id = Uuid::new_v4(); - selection.toggle_object(id); - assert!(selection.contains_object(&id)); + selection.toggle_shape_instance(id); + assert!(selection.contains_shape_instance(&id)); - selection.toggle_object(id); - assert!(!selection.contains_object(&id)); + selection.toggle_shape_instance(id); + assert!(!selection.contains_shape_instance(&id)); } #[test] @@ -199,20 +261,20 @@ mod tests { let id1 = Uuid::new_v4(); let id2 = Uuid::new_v4(); - selection.add_object(id1); - selection.add_object(id2); - assert_eq!(selection.object_count(), 2); + selection.add_shape_instance(id1); + selection.add_shape_instance(id2); + assert_eq!(selection.shape_instance_count(), 2); - selection.select_only_object(id1); - assert_eq!(selection.object_count(), 1); - assert!(selection.contains_object(&id1)); - assert!(!selection.contains_object(&id2)); + selection.select_only_shape_instance(id1); + assert_eq!(selection.shape_instance_count(), 1); + assert!(selection.contains_shape_instance(&id1)); + assert!(!selection.contains_shape_instance(&id2)); } #[test] fn test_clear() { let mut selection = Selection::new(); - selection.add_object(Uuid::new_v4()); + selection.add_shape_instance(Uuid::new_v4()); selection.add_shape(Uuid::new_v4()); assert!(!selection.is_empty()); diff --git a/lightningbeam-ui/lightningbeam-editor/Cargo.toml b/lightningbeam-ui/lightningbeam-editor/Cargo.toml index e7e35d1..e6a245f 100644 --- a/lightningbeam-ui/lightningbeam-editor/Cargo.toml +++ b/lightningbeam-ui/lightningbeam-editor/Cargo.toml @@ -5,6 +5,7 @@ edition = "2021" [dependencies] lightningbeam-core = { path = "../lightningbeam-core" } +daw-backend = { path = "../../daw-backend" } # UI Framework eframe = { workspace = true } diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index 331e643..b8dac2e 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -266,6 +266,9 @@ struct EditorApp { draw_simplify_mode: lightningbeam_core::tool::SimplifyMode, // Current simplification mode for draw tool rdp_tolerance: f64, // RDP simplification tolerance (default: 10.0) schneider_max_error: f64, // Schneider curve fitting max error (default: 30.0) + // Audio engine integration + audio_controller: Option, // Audio engine controller for playback + audio_event_rx: Option>, // Audio event receiver } impl EditorApp { @@ -282,14 +285,14 @@ impl EditorApp { // Add a test layer with a simple shape to visualize use lightningbeam_core::layer::{AnyLayer, VectorLayer}; - use lightningbeam_core::object::Object; + use lightningbeam_core::object::ShapeInstance; use lightningbeam_core::shape::{Shape, ShapeColor}; use vello::kurbo::{Circle, Shape as KurboShape}; let circle = Circle::new((200.0, 150.0), 50.0); let path = circle.to_path(0.1); let shape = Shape::new(path).with_fill(ShapeColor::rgb(100, 150, 250)); - let object = Object::new(shape.id); + let object = ShapeInstance::new(shape.id); let mut vector_layer = VectorLayer::new("Layer 1"); vector_layer.add_shape(shape); @@ -464,8 +467,17 @@ impl EditorApp { // Layer menu MenuAction::AddLayer => { - println!("Menu: Add Layer"); - // TODO: Implement add layer + // Create a new vector layer with a default name + let layer_count = self.action_executor.document().root.children.len(); + let layer_name = format!("Layer {}", layer_count + 1); + + let action = lightningbeam_core::actions::AddLayerAction::new_vector(layer_name); + self.action_executor.execute(Box::new(action)); + + // Select the newly created layer (last child in the document) + if let Some(last_layer) = self.action_executor.document().root.children.last() { + self.active_layer_id = Some(last_layer.id()); + } } MenuAction::AddVideoLayer => { println!("Menu: Add Video Layer"); @@ -479,6 +491,65 @@ impl EditorApp { println!("Menu: Add MIDI Track"); // TODO: Implement add MIDI track } + MenuAction::AddTestClip => { + // Require an active layer + if let Some(layer_id) = self.active_layer_id { + // Create a test vector clip (5 second duration) + use lightningbeam_core::clip::{VectorClip, ClipInstance}; + use lightningbeam_core::layer::{VectorLayer, AnyLayer}; + use lightningbeam_core::shape::{Shape, ShapeColor}; + use lightningbeam_core::object::ShapeInstance; + use kurbo::{Circle, Rect, Shape as KurboShape}; + + let mut test_clip = VectorClip::new("Test Clip", 400.0, 400.0, 5.0); + + // Create a layer with some shapes + let mut layer = VectorLayer::new("Test Layer"); + + // Create a red circle shape + let circle_path = Circle::new((100.0, 100.0), 50.0).to_path(0.1); + let mut circle_shape = Shape::new(circle_path); + circle_shape.fill_color = Some(ShapeColor::rgb(255, 0, 0)); + let circle_id = circle_shape.id; + layer.add_shape(circle_shape); + + // Create a blue rectangle shape + let rect_path = Rect::new(200.0, 50.0, 350.0, 150.0).to_path(0.1); + let mut rect_shape = Shape::new(rect_path); + rect_shape.fill_color = Some(ShapeColor::rgb(0, 0, 255)); + let rect_id = rect_shape.id; + layer.add_shape(rect_shape); + + // Add shape instances + layer.shape_instances.push(ShapeInstance::new(circle_id)); + layer.shape_instances.push(ShapeInstance::new(rect_id)); + + // Add the layer to the clip + test_clip.layers.add_root(AnyLayer::Vector(layer)); + + // Add to document's clip library + let clip_id = self.action_executor.document_mut().add_vector_clip(test_clip); + + // Create clip instance at current time + let current_time = self.action_executor.document().current_time; + let instance = ClipInstance::new(clip_id) + .with_timeline_start(current_time) + .with_name("Test Instance"); + + // Add to layer (only vector layers can have clip instances) + if let Some(layer) = self.action_executor.document_mut().get_layer_mut(&layer_id) { + use lightningbeam_core::layer::AnyLayer; + if let AnyLayer::Vector(vector_layer) = layer { + vector_layer.clip_instances.push(instance); + println!("Added test clip instance with red circle and blue rectangle at time {}", current_time); + } else { + println!("Can only add clip instances to vector layers"); + } + } + } else { + println!("No active layer selected"); + } + } MenuAction::DeleteLayer => { println!("Menu: Delete Layer"); // TODO: Implement delete layer @@ -678,7 +749,7 @@ impl eframe::App for EditorApp { &self.theme, &mut self.action_executor, &mut self.selection, - &self.active_layer_id, + &mut self.active_layer_id, &mut self.tool_state, &mut pending_actions, &mut self.draw_simplify_mode, @@ -769,7 +840,7 @@ fn render_layout_node( theme: &Theme, action_executor: &mut lightningbeam_core::action::ActionExecutor, selection: &mut lightningbeam_core::selection::Selection, - active_layer_id: &Option, + active_layer_id: &mut Option, tool_state: &mut lightningbeam_core::tool::ToolState, pending_actions: &mut Vec>, draw_simplify_mode: &mut lightningbeam_core::tool::SimplifyMode, @@ -1121,7 +1192,7 @@ fn render_pane( theme: &Theme, action_executor: &mut lightningbeam_core::action::ActionExecutor, selection: &mut lightningbeam_core::selection::Selection, - active_layer_id: &Option, + active_layer_id: &mut Option, tool_state: &mut lightningbeam_core::tool::ToolState, pending_actions: &mut Vec>, draw_simplify_mode: &mut lightningbeam_core::tool::SimplifyMode, diff --git a/lightningbeam-ui/lightningbeam-editor/src/menu.rs b/lightningbeam-ui/lightningbeam-editor/src/menu.rs index 1fb93c5..dbcee99 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/menu.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/menu.rs @@ -164,6 +164,7 @@ pub enum MenuAction { AddVideoLayer, AddAudioTrack, AddMidiTrack, + AddTestClip, // For testing: adds a test clip instance to the current layer DeleteLayer, ToggleLayerVisibility, @@ -254,6 +255,7 @@ impl MenuItemDef { const ADD_VIDEO_LAYER: Self = Self { label: "Add Video Layer", action: MenuAction::AddVideoLayer, shortcut: None }; const ADD_AUDIO_TRACK: Self = Self { label: "Add Audio Track", action: MenuAction::AddAudioTrack, shortcut: None }; const ADD_MIDI_TRACK: Self = Self { label: "Add MIDI Track", action: MenuAction::AddMidiTrack, shortcut: None }; + const ADD_TEST_CLIP: Self = Self { label: "Add Test Clip", action: MenuAction::AddTestClip, shortcut: None }; const DELETE_LAYER: Self = Self { label: "Delete Layer", action: MenuAction::DeleteLayer, shortcut: None }; const TOGGLE_LAYER_VISIBILITY: Self = Self { label: "Hide/Show Layer", action: MenuAction::ToggleLayerVisibility, shortcut: None }; @@ -364,6 +366,8 @@ impl MenuItemDef { MenuDef::Item(&Self::ADD_AUDIO_TRACK), MenuDef::Item(&Self::ADD_MIDI_TRACK), MenuDef::Separator, + MenuDef::Item(&Self::ADD_TEST_CLIP), + MenuDef::Separator, MenuDef::Item(&Self::DELETE_LAYER), MenuDef::Item(&Self::TOGGLE_LAYER_VISIBILITY), ], diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs index e8f7cd2..7f0d0fc 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs @@ -64,7 +64,7 @@ pub struct SharedPaneState<'a> { /// Current selection state (mutable for tools to modify) pub selection: &'a mut lightningbeam_core::selection::Selection, /// Currently active layer ID - pub active_layer_id: &'a Option, + pub active_layer_id: &'a mut Option, /// Current tool interaction state (mutable for tools to modify) pub tool_state: &'a mut lightningbeam_core::tool::ToolState, /// Actions to execute after rendering completes (two-phase dispatch) diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs index c8a64be..5d592ae 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs @@ -332,7 +332,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback { // 1. Draw selection outlines around selected objects // NOTE: Skip this if Transform tool is active (it has its own handles) if !self.selection.is_empty() && !matches!(self.selected_tool, Tool::Transform) { - for &object_id in self.selection.objects() { + for &object_id in self.selection.shape_instances() { if let Some(object) = vector_layer.get_object(&object_id) { if let Some(shape) = vector_layer.get_shape(&object.shape_id) { // Get shape bounding box @@ -385,6 +385,66 @@ impl egui_wgpu::CallbackTrait for VelloCallback { } } } + + // Also draw selection outlines for clip instances + for &clip_id in self.selection.clip_instances() { + if let Some(clip_instance) = vector_layer.clip_instances.iter().find(|ci| ci.id == clip_id) { + // Get clip dimensions from document + let (width, height) = if let Some(vector_clip) = self.document.get_vector_clip(&clip_instance.clip_id) { + (vector_clip.width, vector_clip.height) + } else if let Some(video_clip) = self.document.get_video_clip(&clip_instance.clip_id) { + (video_clip.width, video_clip.height) + } else { + continue; // Clip not found or is audio + }; + + // Create bounding box from clip dimensions + let bbox = KurboRect::new(0.0, 0.0, width, height); + + // Apply clip instance transform and camera transform + let clip_transform = clip_instance.transform.to_affine(); + let combined_transform = camera_transform * clip_transform; + + // Draw selection outline with different color for clip instances + let clip_selection_color = Color::rgb8(255, 120, 0); // Orange + scene.stroke( + &Stroke::new(stroke_width), + combined_transform, + clip_selection_color, + None, + &bbox, + ); + + // Draw corner handles (4 circles at corners) + let handle_radius = (6.0 / self.zoom.max(0.5) as f64).max(4.0); + let corners = [ + (bbox.x0, bbox.y0), + (bbox.x1, bbox.y0), + (bbox.x1, bbox.y1), + (bbox.x0, bbox.y1), + ]; + + for (x, y) in corners { + let corner_circle = Circle::new((x, y), handle_radius); + // Fill with orange + scene.fill( + Fill::NonZero, + combined_transform, + clip_selection_color, + None, + &corner_circle, + ); + // White outline + scene.stroke( + &Stroke::new(1.0), + combined_transform, + Color::rgb8(255, 255, 255), + None, + &corner_circle, + ); + } + } + } } // 2. Draw marquee selection rectangle @@ -673,9 +733,9 @@ impl egui_wgpu::CallbackTrait for VelloCallback { // For single object: use object-aligned (rotated) bounding box // For multiple objects: use axis-aligned bounding box (simpler for now) - if self.selection.objects().len() == 1 { + if self.selection.shape_instances().len() == 1 { // Single object - draw rotated bounding box - let object_id = *self.selection.objects().iter().next().unwrap(); + let object_id = *self.selection.shape_instances().iter().next().unwrap(); if let Some(object) = vector_layer.get_object(&object_id) { if let Some(shape) = vector_layer.get_shape(&object.shape_id) { @@ -863,7 +923,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback { // Multiple objects - use axis-aligned bbox (existing code) let mut combined_bbox: Option = None; - for &object_id in self.selection.objects() { + for &object_id in self.selection.shape_instances() { if let Some(object) = vector_layer.get_object(&object_id) { if let Some(shape) = vector_layer.get_shape(&object.shape_id) { let shape_bbox = shape.path().bounding_box(); @@ -1178,7 +1238,7 @@ impl StagePane { None => return, // No active layer }; - let active_layer = match shared.action_executor.document().get_layer(active_layer_id) { + let active_layer = match shared.action_executor.document().get_layer(&active_layer_id) { Some(layer) => layer, None => return, }; @@ -1194,38 +1254,92 @@ impl StagePane { // Mouse down: start interaction (use drag_started for immediate feedback) if response.drag_started() || response.clicked() { // Hit test at click position - let hit = hit_test::hit_test_layer(vector_layer, point, 5.0, Affine::IDENTITY); + // Test clip instances first (they're on top of shapes) + let document = shared.action_executor.document(); + let clip_hit = hit_test::hit_test_clip_instances( + &vector_layer.clip_instances, + &document.vector_clips, + &document.video_clips, + point, + Affine::IDENTITY, + ); - if let Some(object_id) = hit { - // Object was hit - if shift_held { - // Shift: toggle selection - shared.selection.toggle_object(object_id); - } else { - // No shift: replace selection - if !shared.selection.contains_object(&object_id) { - shared.selection.select_only_object(object_id); - } - } + let hit_result = if let Some(clip_id) = clip_hit { + Some(hit_test::HitResult::ClipInstance(clip_id)) + } else { + // No clip hit, test shape instances + hit_test::hit_test_layer(vector_layer, point, 5.0, Affine::IDENTITY) + .map(|id| hit_test::HitResult::ShapeInstance(id)) + }; - // If object is now selected, prepare for dragging - if shared.selection.contains_object(&object_id) { - // Store original positions of all selected objects - let mut original_positions = std::collections::HashMap::new(); - for &obj_id in shared.selection.objects() { - if let Some(obj) = vector_layer.get_object(&obj_id) { - original_positions.insert( - obj_id, - Point::new(obj.transform.x, obj.transform.y), - ); + if let Some(hit) = hit_result { + match hit { + hit_test::HitResult::ShapeInstance(object_id) => { + // Shape instance was hit + if shift_held { + // Shift: toggle selection + shared.selection.toggle_shape_instance(object_id); + } else { + // No shift: replace selection + if !shared.selection.contains_shape_instance(&object_id) { + shared.selection.select_only_shape_instance(object_id); + } + } + + // If object is now selected, prepare for dragging + if shared.selection.contains_shape_instance(&object_id) { + // Store original positions of all selected objects + let mut original_positions = std::collections::HashMap::new(); + for &obj_id in shared.selection.shape_instances() { + if let Some(obj) = vector_layer.get_object(&obj_id) { + original_positions.insert( + obj_id, + Point::new(obj.transform.x, obj.transform.y), + ); + } + } + + *shared.tool_state = ToolState::DraggingSelection { + start_pos: point, + start_mouse: point, + original_positions, + }; } } + hit_test::HitResult::ClipInstance(clip_id) => { + // Clip instance was hit + if shift_held { + // Shift: toggle selection + shared.selection.toggle_clip_instance(clip_id); + } else { + // No shift: replace selection + if !shared.selection.contains_clip_instance(&clip_id) { + shared.selection.select_only_clip_instance(clip_id); + } + } - *shared.tool_state = ToolState::DraggingSelection { - start_pos: point, - start_mouse: point, - original_positions, - }; + // If clip instance is now selected, prepare for dragging + if shared.selection.contains_clip_instance(&clip_id) { + // Store original positions of all selected clip instances + let mut original_positions = std::collections::HashMap::new(); + for &clip_inst_id in shared.selection.clip_instances() { + // Find the clip instance in the layer + if let Some(clip_inst) = vector_layer.clip_instances.iter() + .find(|ci| ci.id == clip_inst_id) { + original_positions.insert( + clip_inst_id, + Point::new(clip_inst.transform.x, clip_inst.transform.y), + ); + } + } + + *shared.tool_state = ToolState::DraggingSelection { + start_pos: point, + start_mouse: point, + original_positions, + }; + } + } } } else { // Nothing hit - start marquee selection @@ -1266,22 +1380,54 @@ impl StagePane { let delta = point - start_mouse; if delta.x.abs() > 0.01 || delta.y.abs() > 0.01 { - // Create move action with new positions + // Create move actions with new positions use std::collections::HashMap; - let mut object_positions = HashMap::new(); - for (object_id, original_pos) in original_positions { + // Separate shape instances from clip instances + use lightningbeam_core::object::Transform; + let mut shape_instance_positions = HashMap::new(); + let mut clip_instance_transforms = HashMap::new(); + + for (id, original_pos) in original_positions { let new_pos = Point::new( original_pos.x + delta.x, original_pos.y + delta.y, ); - object_positions.insert(object_id, (original_pos, new_pos)); + + // Check if this is a shape instance or clip instance + if shared.selection.contains_shape_instance(&id) { + shape_instance_positions.insert(id, (original_pos, new_pos)); + } else if shared.selection.contains_clip_instance(&id) { + // For clip instances, we need to get the full Transform + // Find the clip instance in the layer + if let Some(clip_inst) = vector_layer.clip_instances.iter() + .find(|ci| ci.id == id) { + let mut old_transform = clip_inst.transform.clone(); + old_transform.x = original_pos.x; + old_transform.y = original_pos.y; + + let mut new_transform = clip_inst.transform.clone(); + new_transform.x = new_pos.x; + new_transform.y = new_pos.y; + + clip_instance_transforms.insert(id, (old_transform, new_transform)); + } + } } - // Create and submit the action - use lightningbeam_core::actions::MoveObjectsAction; - let action = MoveObjectsAction::new(*active_layer_id, object_positions); - shared.pending_actions.push(Box::new(action)); + // Create and submit move action for shape instances + if !shape_instance_positions.is_empty() { + use lightningbeam_core::actions::MoveShapeInstancesAction; + let action = MoveShapeInstancesAction::new(*active_layer_id, shape_instance_positions); + shared.pending_actions.push(Box::new(action)); + } + + // Create and submit transform action for clip instances + if !clip_instance_transforms.is_empty() { + use lightningbeam_core::actions::TransformClipInstancesAction; + let action = TransformClipInstancesAction::new(*active_layer_id, clip_instance_transforms); + shared.pending_actions.push(Box::new(action)); + } } // Reset tool state @@ -1296,24 +1442,49 @@ impl StagePane { let selection_rect = KurboRect::new(min_x, min_y, max_x, max_y); - // Hit test all objects in rectangle - let hits = hit_test::hit_test_objects_in_rect( + // Hit test clip instances in rectangle + let document = shared.action_executor.document(); + let clip_hits = hit_test::hit_test_clip_instances_in_rect( + &vector_layer.clip_instances, + &document.vector_clips, + &document.video_clips, + selection_rect, + Affine::IDENTITY, + ); + + // Hit test shape instances in rectangle + let shape_hits = hit_test::hit_test_objects_in_rect( vector_layer, selection_rect, Affine::IDENTITY, ); - // Add to selection - for obj_id in hits { + // Add clip instances to selection + for clip_id in clip_hits { if shift_held { - shared.selection.add_object(obj_id); + shared.selection.add_clip_instance(clip_id); } else { // First hit replaces selection if shared.selection.is_empty() { - shared.selection.add_object(obj_id); + shared.selection.add_clip_instance(clip_id); } else { // Subsequent hits add to selection - shared.selection.add_object(obj_id); + shared.selection.add_clip_instance(clip_id); + } + } + } + + // Add shape instances to selection + for obj_id in shape_hits { + if shift_held { + shared.selection.add_shape_instance(obj_id); + } else { + // First hit replaces selection + if shared.selection.is_empty() { + shared.selection.add_shape_instance(obj_id); + } else { + // Subsequent hits add to selection + shared.selection.add_shape_instance(obj_id); } } } @@ -1340,12 +1511,12 @@ impl StagePane { use vello::kurbo::Point; // Check if we have an active vector layer - let active_layer_id = match shared.active_layer_id { + let active_layer_id = match *shared.active_layer_id { Some(id) => id, None => return, }; - let active_layer = match shared.action_executor.document().get_layer(active_layer_id) { + let active_layer = match shared.action_executor.document().get_layer(&active_layer_id) { Some(layer) => layer, None => return, }; @@ -1428,7 +1599,7 @@ impl StagePane { // Only create shape if rectangle has non-zero size if width > 1.0 && height > 1.0 { use lightningbeam_core::shape::{Shape, ShapeColor}; - use lightningbeam_core::object::Object; + use lightningbeam_core::object::ShapeInstance; use lightningbeam_core::actions::AddShapeAction; // Create shape with rectangle path (built from lines) @@ -1436,10 +1607,10 @@ impl StagePane { let shape = Shape::new(path).with_fill(ShapeColor::from_egui(*shared.fill_color)); // Create object at the calculated position - let object = Object::new(shape.id).with_position(position.x, position.y); + let object = ShapeInstance::new(shape.id).with_position(position.x, position.y); // Create and execute action immediately - let action = AddShapeAction::new(*active_layer_id, shape, object); + let action = AddShapeAction::new(active_layer_id, shape, object); shared.action_executor.execute(Box::new(action)); // Clear tool state to stop preview rendering @@ -1463,12 +1634,12 @@ impl StagePane { use vello::kurbo::Point; // Check if we have an active vector layer - let active_layer_id = match shared.active_layer_id { + let active_layer_id = match *shared.active_layer_id { Some(id) => id, None => return, }; - let active_layer = match shared.action_executor.document().get_layer(active_layer_id) { + let active_layer = match shared.action_executor.document().get_layer(&active_layer_id) { Some(layer) => layer, None => return, }; @@ -1545,7 +1716,7 @@ impl StagePane { // Only create shape if ellipse has non-zero size if rx > 1.0 && ry > 1.0 { use lightningbeam_core::shape::{Shape, ShapeColor}; - use lightningbeam_core::object::Object; + use lightningbeam_core::object::ShapeInstance; use lightningbeam_core::actions::AddShapeAction; // Create shape with ellipse path (built from bezier curves) @@ -1553,10 +1724,10 @@ impl StagePane { let shape = Shape::new(path).with_fill(ShapeColor::from_egui(*shared.fill_color)); // Create object at the calculated position - let object = Object::new(shape.id).with_position(position.x, position.y); + let object = ShapeInstance::new(shape.id).with_position(position.x, position.y); // Create and execute action immediately - let action = AddShapeAction::new(*active_layer_id, shape, object); + let action = AddShapeAction::new(active_layer_id, shape, object); shared.action_executor.execute(Box::new(action)); // Clear tool state to stop preview rendering @@ -1580,12 +1751,12 @@ impl StagePane { use vello::kurbo::Point; // Check if we have an active vector layer - let active_layer_id = match shared.active_layer_id { + let active_layer_id = match *shared.active_layer_id { Some(id) => id, None => return, }; - let active_layer = match shared.action_executor.document().get_layer(active_layer_id) { + let active_layer = match shared.action_executor.document().get_layer(&active_layer_id) { Some(layer) => layer, None => return, }; @@ -1626,7 +1797,7 @@ impl StagePane { // Only create shape if line has reasonable length if length > 1.0 { use lightningbeam_core::shape::{Shape, ShapeColor, StrokeStyle}; - use lightningbeam_core::object::Object; + use lightningbeam_core::object::ShapeInstance; use lightningbeam_core::actions::AddShapeAction; // Create shape with line path @@ -1643,10 +1814,10 @@ impl StagePane { ); // Create object at the start point - let object = Object::new(shape.id).with_position(start_point.x, start_point.y); + let object = ShapeInstance::new(shape.id).with_position(start_point.x, start_point.y); // Create and execute action immediately - let action = AddShapeAction::new(*active_layer_id, shape, object); + let action = AddShapeAction::new(active_layer_id, shape, object); shared.action_executor.execute(Box::new(action)); // Clear tool state to stop preview rendering @@ -1670,12 +1841,12 @@ impl StagePane { use vello::kurbo::Point; // Check if we have an active vector layer - let active_layer_id = match shared.active_layer_id { + let active_layer_id = match *shared.active_layer_id { Some(id) => id, None => return, }; - let active_layer = match shared.action_executor.document().get_layer(active_layer_id) { + let active_layer = match shared.action_executor.document().get_layer(&active_layer_id) { Some(layer) => layer, None => return, }; @@ -1718,7 +1889,7 @@ impl StagePane { // Only create shape if polygon has reasonable size if radius > 5.0 { use lightningbeam_core::shape::{Shape, ShapeColor}; - use lightningbeam_core::object::Object; + use lightningbeam_core::object::ShapeInstance; use lightningbeam_core::actions::AddShapeAction; // Create shape with polygon path @@ -1726,10 +1897,10 @@ impl StagePane { let shape = Shape::new(path).with_fill(ShapeColor::from_egui(*shared.fill_color)); // Create object at the center point - let object = Object::new(shape.id).with_position(center.x, center.y); + let object = ShapeInstance::new(shape.id).with_position(center.x, center.y); // Create and execute action immediately - let action = AddShapeAction::new(*active_layer_id, shape, object); + let action = AddShapeAction::new(active_layer_id, shape, object); shared.action_executor.execute(Box::new(action)); // Clear tool state to stop preview rendering @@ -1893,12 +2064,12 @@ impl StagePane { use vello::kurbo::Point; // Check if we have an active vector layer - let active_layer_id = match shared.active_layer_id { + let active_layer_id = match *shared.active_layer_id { Some(id) => id, None => return, }; - let active_layer = match shared.action_executor.document().get_layer(active_layer_id) { + let active_layer = match shared.action_executor.document().get_layer(&active_layer_id) { Some(layer) => layer, None => return, }; @@ -1944,7 +2115,7 @@ impl StagePane { simplify_rdp, fit_bezier_curves, RdpConfig, SchneiderConfig, }; use lightningbeam_core::shape::{Shape, ShapeColor}; - use lightningbeam_core::object::Object; + use lightningbeam_core::object::ShapeInstance; use lightningbeam_core::actions::AddShapeAction; // Convert points to the appropriate path based on simplify mode @@ -2003,10 +2174,10 @@ impl StagePane { ); // Create object at the calculated position - let object = Object::new(shape.id).with_position(position.x, position.y); + let object = ShapeInstance::new(shape.id).with_position(position.x, position.y); // Create and execute action immediately - let action = AddShapeAction::new(*active_layer_id, shape, object); + let action = AddShapeAction::new(active_layer_id, shape, object); shared.action_executor.execute(Box::new(action)); } } @@ -2037,7 +2208,7 @@ impl StagePane { } }; - let active_layer = match shared.action_executor.document().get_layer(active_layer_id) { + let active_layer = match shared.action_executor.document().get_layer(&active_layer_id) { Some(layer) => layer, None => { println!("Paint bucket: Layer not found"); @@ -2071,6 +2242,57 @@ impl StagePane { } } + /// Decompose an affine matrix into transform components + /// Returns (translation_x, translation_y, rotation_deg, scale_x, scale_y, skew_x_deg, skew_y_deg) + fn decompose_affine(affine: kurbo::Affine) -> (f64, f64, f64, f64, f64, f64, f64) { + let coeffs = affine.as_coeffs(); + let a = coeffs[0]; + let b = coeffs[1]; + let c = coeffs[2]; + let d = coeffs[3]; + let e = coeffs[4]; // translation_x + let f = coeffs[5]; // translation_y + + // Extract translation + let tx = e; + let ty = f; + + // Decompose linear part [[a, c], [b, d]] into rotate * scale * skew + // Using QR-like decomposition + + // Extract rotation + let rotation_rad = b.atan2(a); + let cos_r = rotation_rad.cos(); + let sin_r = rotation_rad.sin(); + + // Remove rotation to get scale * skew + // R^(-1) * M where M = [[a, c], [b, d]] + let m11 = a * cos_r + b * sin_r; + let m12 = c * cos_r + d * sin_r; + let m21 = -a * sin_r + b * cos_r; + let m22 = -c * sin_r + d * cos_r; + + // Now [[m11, m12], [m21, m22]] = scale * skew + // scale * skew = [[sx, 0], [0, sy]] * [[1, tan(skew_y)], [tan(skew_x), 1]] + // = [[sx, sx*tan(skew_y)], [sy*tan(skew_x), sy]] + + let scale_x = m11; + let scale_y = m22; + + let skew_x_rad = if scale_y.abs() > 0.001 { (m21 / scale_y).atan() } else { 0.0 }; + let skew_y_rad = if scale_x.abs() > 0.001 { (m12 / scale_x).atan() } else { 0.0 }; + + ( + tx, + ty, + rotation_rad.to_degrees(), + scale_x, + scale_y, + skew_x_rad.to_degrees(), + skew_y_rad.to_degrees(), + ) + } + /// Apply transform preview to objects based on current mouse position fn apply_transform_preview( vector_layer: &mut lightningbeam_core::layer::VectorLayer, @@ -2169,12 +2391,15 @@ impl StagePane { } TransformMode::ScaleEdge { axis, origin } => { - // Calculate scale along one axis + // UNIFIED MATRIX APPROACH: Calculate bounding box transform, then apply to each object + + // Step 1: Calculate the bounding box transform (world-space scale from origin) + // Preserve sign to allow flipping when dragging past the origin let (scale_x_world, scale_y_world) = match axis { Axis::Horizontal => { - let start_dist = (start_mouse.x - origin.x).abs(); - let current_dist = (current_mouse.x - origin.x).abs(); - let scale = if start_dist > 0.001 { + let start_dist = start_mouse.x - origin.x; + let current_dist = current_mouse.x - origin.x; + let scale = if start_dist.abs() > 0.001 { current_dist / start_dist } else { 1.0 @@ -2182,9 +2407,9 @@ impl StagePane { (scale, 1.0) } Axis::Vertical => { - let start_dist = (start_mouse.y - origin.y).abs(); - let current_dist = (current_mouse.y - origin.y).abs(); - let scale = if start_dist > 0.001 { + let start_dist = start_mouse.y - origin.y; + let current_dist = current_mouse.y - origin.y; + let scale = if start_dist.abs() > 0.001 { current_dist / start_dist } else { 1.0 @@ -2193,36 +2418,59 @@ impl StagePane { } }; - // Apply scale to all selected objects + // Build the bounding box transform: translate to origin, scale, translate back + use kurbo::Affine; + let bbox_transform = Affine::translate((origin.x, origin.y)) + * Affine::scale_non_uniform(scale_x_world, scale_y_world) + * Affine::translate((-origin.x, -origin.y)); + + // Step 2: Apply to each object using matrix composition for (object_id, original_transform) in original_transforms { + // Get original opacity (now separate from transform) + let original_opacity = if let Some(obj) = vector_layer.get_object(object_id) { + obj.opacity + } else { + 1.0 + }; + + // New position: transform the object's position through bbox_transform + let new_pos = bbox_transform * kurbo::Point::new(original_transform.x, original_transform.y); + + // Transform bbox operation to object's local space + // local_transform = R^(-1) * bbox_transform * R + let rotation = Affine::rotate(original_transform.rotation.to_radians()); + let rotation_inv = Affine::rotate(-original_transform.rotation.to_radians()); + + // Extract just the linear part of bbox_transform (no translation) + let bbox_linear = Affine::scale_non_uniform(scale_x_world, scale_y_world); + + // Transform to local space + let local_transform = rotation_inv * bbox_linear * rotation; + + // Extract scale and skew directly from the 2x2 matrix + // Matrix form: [[a, c], [b, d]] = [[sx, sx*tan(ky)], [sy*tan(kx), sy]] + let coeffs = local_transform.as_coeffs(); + let a = coeffs[0]; + let b = coeffs[1]; + let c = coeffs[2]; + let d = coeffs[3]; + + // Direct extraction (no rotation assumed in local space) + let local_sx = a; + let local_sy = d; + let local_skew_x = if d.abs() > 0.001 { (b / d).atan().to_degrees() } else { 0.0 }; + let local_skew_y = if a.abs() > 0.001 { (c / a).atan().to_degrees() } else { 0.0 }; + + // Apply to object vector_layer.modify_object_internal(object_id, |obj| { - // Get object's rotation in radians - let rotation_rad = original_transform.rotation.to_radians(); - let cos_r = rotation_rad.cos(); - let sin_r = rotation_rad.sin(); - - // Transform scale from world space to local space (same as corner mode) - let cos_r_sq = cos_r * cos_r; - let sin_r_sq = sin_r * sin_r; - let sx_abs = scale_x_world.abs(); - let sy_abs = scale_y_world.abs(); - - let local_scale_x = (cos_r_sq * sx_abs * sx_abs + sin_r_sq * sy_abs * sy_abs).sqrt(); - let local_scale_y = (sin_r_sq * sx_abs * sx_abs + cos_r_sq * sy_abs * sy_abs).sqrt(); - - // Scale position relative to origin in world space - let rel_x = original_transform.x - origin.x; - let rel_y = original_transform.y - origin.y; - - obj.transform.x = origin.x + rel_x * scale_x_world; - obj.transform.y = origin.y + rel_y * scale_y_world; - - // Apply local-space scale - obj.transform.scale_x = original_transform.scale_x * local_scale_x; - obj.transform.scale_y = original_transform.scale_y * local_scale_y; - - // Keep rotation unchanged - obj.transform.rotation = original_transform.rotation; + obj.transform.x = new_pos.x; + obj.transform.y = new_pos.y; + obj.transform.rotation = original_transform.rotation; // Preserve rotation + obj.transform.scale_x = original_transform.scale_x * local_sx; + obj.transform.scale_y = original_transform.scale_y * local_sy; + obj.transform.skew_x = original_transform.skew_x + local_skew_x; + obj.transform.skew_y = original_transform.skew_y + local_skew_y; + obj.opacity = original_opacity; // Preserve opacity (now separate from transform) }); } } @@ -2492,14 +2740,14 @@ impl StagePane { use vello::kurbo::Point; // Check if we have an active vector layer - let active_layer_id = match shared.active_layer_id { + let active_layer_id = match *shared.active_layer_id { Some(id) => id, None => return, }; // Only work on VectorLayer - just check type, don't hold reference { - let active_layer = match shared.action_executor.document().get_layer(active_layer_id) { + let active_layer = match shared.action_executor.document().get_layer(&active_layer_id) { Some(layer) => layer, None => return, }; @@ -2518,17 +2766,17 @@ impl StagePane { // For single object: use rotated bounding box // For multiple objects: use axis-aligned bounding box - if shared.selection.objects().len() == 1 { + if shared.selection.shape_instances().len() == 1 { // Single object - rotated bounding box - self.handle_transform_single_object(ui, response, point, active_layer_id, shared); + self.handle_transform_single_object(ui, response, point, &active_layer_id, shared); } else { // Multiple objects - axis-aligned bounding box // Calculate combined bounding box for handle hit testing let mut combined_bbox: Option = None; // Get immutable reference just for bbox calculation - if let Some(AnyLayer::Vector(vector_layer)) = shared.action_executor.document().get_layer(active_layer_id) { - for &object_id in shared.selection.objects() { + if let Some(AnyLayer::Vector(vector_layer)) = shared.action_executor.document().get_layer(&active_layer_id) { + for &object_id in shared.selection.shape_instances() { if let Some(object) = vector_layer.get_object(&object_id) { if let Some(shape) = vector_layer.get_shape(&object.shape_id) { // Get shape's local bounding box @@ -2603,8 +2851,8 @@ impl StagePane { use std::collections::HashMap; let mut original_transforms = HashMap::new(); - if let Some(AnyLayer::Vector(vector_layer)) = shared.action_executor.document().get_layer(active_layer_id) { - for &object_id in shared.selection.objects() { + if let Some(AnyLayer::Vector(vector_layer)) = shared.action_executor.document().get_layer(&active_layer_id) { + for &object_id in shared.selection.shape_instances() { if let Some(object) = vector_layer.get_object(&object_id) { original_transforms.insert(object_id, object.transform.clone()); } @@ -2661,12 +2909,12 @@ impl StagePane { if response.drag_stopped() || (ui.input(|i| i.pointer.any_released()) && matches!(shared.tool_state, ToolState::Transforming { .. })) { if let ToolState::Transforming { original_transforms, .. } = shared.tool_state.clone() { use std::collections::HashMap; - use lightningbeam_core::actions::TransformObjectsAction; + use lightningbeam_core::actions::TransformShapeInstancesAction; let mut object_transforms = HashMap::new(); // Get current transforms and pair with originals - if let Some(AnyLayer::Vector(vector_layer)) = shared.action_executor.document().get_layer(active_layer_id) { + if let Some(AnyLayer::Vector(vector_layer)) = shared.action_executor.document().get_layer(&active_layer_id) { for (object_id, original) in original_transforms { if let Some(object) = vector_layer.get_object(&object_id) { let new_transform = object.transform.clone(); @@ -2676,7 +2924,7 @@ impl StagePane { } if !object_transforms.is_empty() { - let action = TransformObjectsAction::new(*active_layer_id, object_transforms); + let action = TransformShapeInstancesAction::new(active_layer_id, object_transforms); shared.pending_actions.push(Box::new(action)); } @@ -2699,11 +2947,11 @@ impl StagePane { use lightningbeam_core::layer::AnyLayer; use vello::kurbo::Affine; - let object_id = *shared.selection.objects().iter().next().unwrap(); + let object_id = *shared.selection.shape_instances().iter().next().unwrap(); // Calculate rotated bounding box corners let (local_bbox, world_corners, obj_transform, object) = { - if let Some(AnyLayer::Vector(vector_layer)) = shared.action_executor.document().get_layer(active_layer_id) { + if let Some(AnyLayer::Vector(vector_layer)) = shared.action_executor.document().get_layer(&active_layer_id) { if let Some(object) = vector_layer.get_object(&object_id) { if let Some(shape) = vector_layer.get_shape(&object.shape_id) { let local_bbox = shape.path().bounding_box(); @@ -3303,11 +3551,11 @@ impl StagePane { if response.drag_stopped() || (ui.input(|i| i.pointer.any_released()) && matches!(shared.tool_state, ToolState::Transforming { .. })) { if let ToolState::Transforming { original_transforms, .. } = shared.tool_state.clone() { use std::collections::HashMap; - use lightningbeam_core::actions::TransformObjectsAction; + use lightningbeam_core::actions::TransformShapeInstancesAction; let mut object_transforms = HashMap::new(); - if let Some(AnyLayer::Vector(vector_layer)) = shared.action_executor.document().get_layer(active_layer_id) { + if let Some(AnyLayer::Vector(vector_layer)) = shared.action_executor.document().get_layer(&active_layer_id) { for (obj_id, original) in original_transforms { if let Some(object) = vector_layer.get_object(&obj_id) { object_transforms.insert(obj_id, (original, object.transform.clone())); @@ -3316,7 +3564,7 @@ impl StagePane { } if !object_transforms.is_empty() { - let action = TransformObjectsAction::new(*active_layer_id, object_transforms); + let action = TransformShapeInstancesAction::new(*active_layer_id, object_transforms); shared.pending_actions.push(Box::new(action)); } @@ -3356,8 +3604,8 @@ impl StagePane { object_positions.insert(object_id, (original_pos, new_pos)); } - use lightningbeam_core::actions::MoveObjectsAction; - let action = MoveObjectsAction::new(*active_layer_id, object_positions); + use lightningbeam_core::actions::MoveShapeInstancesAction; + let action = MoveShapeInstancesAction::new(*active_layer_id, object_positions); shared.pending_actions.push(Box::new(action)); } } @@ -3372,7 +3620,7 @@ impl StagePane { use lightningbeam_core::hit_test; use vello::kurbo::{Rect as KurboRect, Affine}; - if let Some(AnyLayer::Vector(vector_layer)) = shared.action_executor.document().get_layer(active_layer_id) { + if let Some(AnyLayer::Vector(vector_layer)) = shared.action_executor.document().get_layer(&active_layer_id) { // Create selection rectangle let min_x = start.x.min(current.x); let min_y = start.y.min(current.y); @@ -3390,7 +3638,7 @@ impl StagePane { // Add to selection for obj_id in hits { - shared.selection.add_object(obj_id); + shared.selection.add_shape_instance(obj_id); } } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs index ac5f613..866bba0 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs @@ -14,6 +14,15 @@ const LAYER_HEIGHT: f32 = 60.0; const LAYER_HEADER_WIDTH: f32 = 200.0; const MIN_PIXELS_PER_SECOND: f32 = 20.0; const MAX_PIXELS_PER_SECOND: f32 = 500.0; +const EDGE_DETECTION_PIXELS: f32 = 8.0; // Distance from edge to detect trim handles + +/// Type of clip drag operation +#[derive(Debug, Clone, Copy, PartialEq)] +enum ClipDragType { + Move, + TrimLeft, + TrimRight, +} pub struct TimelinePane { /// Current playback time in seconds @@ -38,11 +47,15 @@ pub struct TimelinePane { is_panning: bool, last_pan_pos: Option, + /// Clip drag state (None if not dragging) + clip_drag_state: Option, + drag_offset: f64, // Time offset being applied during drag (for preview) + + /// Cached mouse position from mousedown (used for edge detection when drag starts) + mousedown_pos: Option, + /// Is playback currently active? is_playing: bool, - - /// Currently selected/active layer index - active_layer: usize, } impl TimelinePane { @@ -51,12 +64,14 @@ impl TimelinePane { current_time: 0.0, pixels_per_second: 100.0, viewport_start_time: 0.0, - active_layer: 0, viewport_scroll_y: 0.0, duration: 10.0, // Default 10 seconds is_scrubbing: false, is_panning: false, last_pan_pos: None, + clip_drag_state: None, + drag_offset: 0.0, + mousedown_pos: None, is_playing: false, } } @@ -74,6 +89,83 @@ impl TimelinePane { } } + /// Detect which clip is under the pointer and what type of drag would occur + /// + /// Returns (drag_type, clip_id) if pointer is over a clip, None otherwise + fn detect_clip_at_pointer( + &self, + pointer_pos: egui::Pos2, + document: &lightningbeam_core::document::Document, + content_rect: egui::Rect, + header_rect: egui::Rect, + ) -> Option<(ClipDragType, uuid::Uuid)> { + let layer_count = document.root.children.len(); + + // Check if pointer is in valid area + if pointer_pos.y < header_rect.min.y { + return None; + } + if pointer_pos.x < content_rect.min.x { + return None; + } + + let hover_time = self.x_to_time(pointer_pos.x - content_rect.min.x); + let relative_y = pointer_pos.y - header_rect.min.y + self.viewport_scroll_y; + let hovered_layer_index = (relative_y / LAYER_HEIGHT) as usize; + + if hovered_layer_index >= layer_count { + return None; + } + + let layers: Vec<_> = document.root.children.iter().rev().collect(); + let layer = layers.get(hovered_layer_index)?; + let layer_data = layer.layer(); + + let clip_instances = match layer { + lightningbeam_core::layer::AnyLayer::Vector(vl) => &vl.clip_instances, + lightningbeam_core::layer::AnyLayer::Audio(al) => &al.clip_instances, + lightningbeam_core::layer::AnyLayer::Video(vl) => &vl.clip_instances, + }; + + // Check each clip instance + for clip_instance in clip_instances { + let clip_duration = match layer { + lightningbeam_core::layer::AnyLayer::Vector(_) => { + document.get_vector_clip(&clip_instance.clip_id).map(|c| c.duration) + } + lightningbeam_core::layer::AnyLayer::Audio(_) => { + document.get_audio_clip(&clip_instance.clip_id).map(|c| c.duration) + } + lightningbeam_core::layer::AnyLayer::Video(_) => { + document.get_video_clip(&clip_instance.clip_id).map(|c| c.duration) + } + }?; + + let instance_duration = clip_instance.effective_duration(clip_duration); + let instance_start = clip_instance.timeline_start; + let instance_end = instance_start + instance_duration; + + if hover_time >= instance_start && hover_time <= instance_end { + let start_x = self.time_to_x(instance_start); + let end_x = self.time_to_x(instance_end); + let mouse_x = pointer_pos.x - content_rect.min.x; + + // Determine drag type based on edge proximity (check both sides of edge) + let drag_type = if (mouse_x - start_x).abs() <= EDGE_DETECTION_PIXELS { + ClipDragType::TrimLeft + } else if (end_x - mouse_x).abs() <= EDGE_DETECTION_PIXELS { + ClipDragType::TrimRight + } else { + ClipDragType::Move + }; + + return Some((drag_type, clip_instance.id)); + } + } + + None + } + /// Zoom in by a fixed increment pub fn zoom_in(&mut self, center_x: f32) { self.apply_zoom_at_point(0.2, center_x); @@ -243,7 +335,14 @@ impl TimelinePane { } /// Render layer header column (left side with track names and controls) - fn render_layer_headers(&self, ui: &mut egui::Ui, rect: egui::Rect, theme: &crate::theme::Theme) { + fn render_layer_headers( + &self, + ui: &mut egui::Ui, + rect: egui::Rect, + theme: &crate::theme::Theme, + document: &lightningbeam_core::document::Document, + active_layer_id: &Option, + ) { let painter = ui.painter(); // Background for header column @@ -264,9 +363,10 @@ impl TimelinePane { // Get text color from theme let text_style = theme.style(".text-primary", ui.ctx()); let text_color = text_style.text_color.unwrap_or(egui::Color32::from_gray(200)); + let secondary_text_color = egui::Color32::from_gray(150); - // Test: Draw 3 layer headers - for i in 0..3 { + // Draw layer headers from document (reversed so newest layers appear on top) + for (i, layer) in document.root.children.iter().rev().enumerate() { let y = rect.min.y + i as f32 * LAYER_HEIGHT - self.viewport_scroll_y; // Skip if layer is outside visible area @@ -280,7 +380,8 @@ impl TimelinePane { ); // Active vs inactive background colors - let bg_color = if i == self.active_layer { + let is_active = active_layer_id.map_or(false, |id| id == layer.id()); + let bg_color = if is_active { active_color } else { inactive_color @@ -288,15 +389,58 @@ impl TimelinePane { painter.rect_filled(header_rect, 0.0, bg_color); + // Get layer info + let layer_data = layer.layer(); + let layer_name = &layer_data.name; + let (layer_type, type_color) = match layer { + lightningbeam_core::layer::AnyLayer::Vector(_) => ("Vector", egui::Color32::from_rgb(100, 150, 255)), // Blue + lightningbeam_core::layer::AnyLayer::Audio(_) => ("Audio", egui::Color32::from_rgb(100, 255, 150)), // Green + lightningbeam_core::layer::AnyLayer::Video(_) => ("Video", egui::Color32::from_rgb(255, 150, 100)), // Orange + }; + + // Color indicator bar on the left edge + let indicator_rect = egui::Rect::from_min_size( + header_rect.min, + egui::vec2(4.0, LAYER_HEIGHT), + ); + painter.rect_filled(indicator_rect, 0.0, type_color); + // Layer name painter.text( header_rect.min + egui::vec2(10.0, 10.0), egui::Align2::LEFT_TOP, - format!("Layer {}", i + 1), + layer_name, egui::FontId::proportional(14.0), text_color, ); + // Layer type (smaller text below name with colored background) + let type_text_pos = header_rect.min + egui::vec2(10.0, 28.0); + let type_text_galley = painter.layout_no_wrap( + layer_type.to_string(), + egui::FontId::proportional(11.0), + secondary_text_color, + ); + + // Draw colored background for type label + let type_bg_rect = egui::Rect::from_min_size( + type_text_pos + egui::vec2(-2.0, -1.0), + egui::vec2(type_text_galley.size().x + 4.0, type_text_galley.size().y + 2.0), + ); + painter.rect_filled( + type_bg_rect, + 2.0, + egui::Color32::from_rgba_unmultiplied(type_color.r(), type_color.g(), type_color.b(), 60), + ); + + painter.text( + type_text_pos, + egui::Align2::LEFT_TOP, + layer_type, + egui::FontId::proportional(11.0), + secondary_text_color, + ); + // Separator line at bottom painter.line_segment( [ @@ -318,7 +462,15 @@ impl TimelinePane { } /// Render layer rows (timeline content area) - fn render_layers(&self, ui: &mut egui::Ui, rect: egui::Rect, theme: &crate::theme::Theme) { + fn render_layers( + &self, + ui: &mut egui::Ui, + rect: egui::Rect, + theme: &crate::theme::Theme, + document: &lightningbeam_core::document::Document, + active_layer_id: &Option, + selection: &lightningbeam_core::selection::Selection, + ) { let painter = ui.painter(); // Theme colors for active/inactive layers @@ -327,8 +479,8 @@ impl TimelinePane { let active_color = active_style.background_color.unwrap_or(egui::Color32::from_rgb(85, 85, 85)); let inactive_color = inactive_style.background_color.unwrap_or(egui::Color32::from_rgb(136, 136, 136)); - // Test: Draw 3 layer rows - for i in 0..3 { + // Draw layer rows from document (reversed so newest layers appear on top) + for (i, layer) in document.root.children.iter().rev().enumerate() { let y = rect.min.y + i as f32 * LAYER_HEIGHT - self.viewport_scroll_y; // Skip if layer is outside visible area @@ -342,7 +494,8 @@ impl TimelinePane { ); // Active vs inactive background colors - let bg_color = if i == self.active_layer { + let is_active = active_layer_id.map_or(false, |id| id == layer.id()); + let bg_color = if is_active { active_color } else { inactive_color @@ -372,6 +525,141 @@ impl TimelinePane { time += interval; } + // Draw clip instances for this layer + let clip_instances = match layer { + lightningbeam_core::layer::AnyLayer::Vector(vl) => &vl.clip_instances, + lightningbeam_core::layer::AnyLayer::Audio(al) => &al.clip_instances, + lightningbeam_core::layer::AnyLayer::Video(vl) => &vl.clip_instances, + }; + + for clip_instance in clip_instances { + // Get the clip to determine duration + let clip_duration = match layer { + lightningbeam_core::layer::AnyLayer::Vector(_) => { + document.get_vector_clip(&clip_instance.clip_id) + .map(|c| c.duration) + } + lightningbeam_core::layer::AnyLayer::Audio(_) => { + document.get_audio_clip(&clip_instance.clip_id) + .map(|c| c.duration) + } + lightningbeam_core::layer::AnyLayer::Video(_) => { + document.get_video_clip(&clip_instance.clip_id) + .map(|c| c.duration) + } + }; + + if let Some(clip_duration) = clip_duration { + // Calculate effective duration accounting for trimming + let mut instance_duration = clip_instance.effective_duration(clip_duration); + + // Instance positioned on the layer's timeline using timeline_start + // The layer itself has start_time, so the absolute timeline position is: + // layer.start_time + instance.timeline_start + let layer_data = layer.layer(); + let mut instance_start = clip_instance.timeline_start; + + // Apply drag offset preview for selected clips + let is_selected = selection.contains_clip_instance(&clip_instance.id); + + if let Some(drag_type) = self.clip_drag_state { + if is_selected { + match drag_type { + ClipDragType::Move => { + // Move: shift the entire clip along the timeline + instance_start += self.drag_offset; + } + ClipDragType::TrimLeft => { + // Trim left: calculate new trim_start and clamp to valid range + let new_trim_start = (clip_instance.trim_start + self.drag_offset) + .max(0.0) + .min(clip_duration); + let actual_offset = new_trim_start - clip_instance.trim_start; + + // Move start and reduce duration by actual clamped offset + instance_start = (clip_instance.timeline_start + actual_offset) + .max(0.0); + instance_duration = (clip_duration - new_trim_start).max(0.0); + + // Adjust for existing trim_end + if let Some(trim_end) = clip_instance.trim_end { + instance_duration = (trim_end - new_trim_start).max(0.0); + } + } + ClipDragType::TrimRight => { + // Trim right: extend or reduce duration, clamped to available content + let max_duration = clip_duration - clip_instance.trim_start; + instance_duration = (instance_duration + self.drag_offset) + .max(0.0) + .min(max_duration); + } + } + } + } + + let instance_end = instance_start + instance_duration; + + let start_x = self.time_to_x(instance_start); + let end_x = self.time_to_x(instance_end); + + // Only draw if any part is visible in viewport + if end_x >= 0.0 && start_x <= rect.width() { + let visible_start_x = start_x.max(0.0); + let visible_end_x = end_x.min(rect.width()); + + // Choose color based on layer type + let (clip_color, bright_color) = match layer { + lightningbeam_core::layer::AnyLayer::Vector(_) => ( + egui::Color32::from_rgb(100, 150, 255), // Blue + egui::Color32::from_rgb(150, 200, 255), // Bright blue + ), + lightningbeam_core::layer::AnyLayer::Audio(_) => ( + egui::Color32::from_rgb(100, 255, 150), // Green + egui::Color32::from_rgb(150, 255, 200), // Bright green + ), + lightningbeam_core::layer::AnyLayer::Video(_) => ( + egui::Color32::from_rgb(255, 150, 100), // Orange + egui::Color32::from_rgb(255, 200, 150), // Bright orange + ), + }; + + let clip_rect = egui::Rect::from_min_max( + egui::pos2(rect.min.x + visible_start_x, y + 10.0), + egui::pos2(rect.min.x + visible_end_x, y + LAYER_HEIGHT - 10.0), + ); + + // Draw the clip instance + painter.rect_filled( + clip_rect, + 3.0, // Rounded corners + clip_color, + ); + + // Draw border only if selected (brighter version of clip color) + if selection.contains_clip_instance(&clip_instance.id) { + painter.rect_stroke( + clip_rect, + 3.0, + egui::Stroke::new(3.0, bright_color), + ); + } + + // Draw clip name if there's space + if let Some(name) = &clip_instance.name { + if clip_rect.width() > 50.0 { + painter.text( + clip_rect.min + egui::vec2(5.0, 5.0), + egui::Align2::LEFT_TOP, + name, + egui::FontId::proportional(11.0), + egui::Color32::WHITE, + ); + } + } + } + } + } + // Separator line at bottom painter.line_segment( [ @@ -383,8 +671,20 @@ impl TimelinePane { } } - /// Handle mouse input for scrubbing, panning, and zooming - fn handle_input(&mut self, ui: &mut egui::Ui, full_timeline_rect: egui::Rect, ruler_rect: egui::Rect, content_rect: egui::Rect) { + /// Handle mouse input for scrubbing, panning, zooming, layer selection, and clip instance selection + fn handle_input( + &mut self, + ui: &mut egui::Ui, + full_timeline_rect: egui::Rect, + ruler_rect: egui::Rect, + content_rect: egui::Rect, + header_rect: egui::Rect, + layer_count: usize, + document: &lightningbeam_core::document::Document, + active_layer_id: &mut Option, + selection: &mut lightningbeam_core::selection::Selection, + pending_actions: &mut Vec>, + ) { let response = ui.allocate_rect(full_timeline_rect, egui::Sense::click_and_drag()); // Only process input if mouse is over the timeline pane @@ -397,34 +697,358 @@ impl TimelinePane { let alt_held = ui.input(|i| i.modifiers.alt); let ctrl_held = ui.input(|i| i.modifiers.ctrl || i.modifiers.command); + let shift_held = ui.input(|i| i.modifiers.shift); + + // Handle clip instance selection by clicking on clip rectangles + let mut clicked_clip_instance = false; + if response.clicked() && !alt_held { + if let Some(pos) = response.interact_pointer_pos() { + // Check if click is in content area (not ruler or header column) + if pos.y >= header_rect.min.y && pos.x >= content_rect.min.x { + let relative_y = pos.y - header_rect.min.y + self.viewport_scroll_y; + let clicked_layer_index = (relative_y / LAYER_HEIGHT) as usize; + let click_time = self.x_to_time(pos.x - content_rect.min.x); + + // Get the layer at this index (accounting for reversed display order) + if clicked_layer_index < layer_count { + let layers: Vec<_> = document.root.children.iter().rev().collect(); + if let Some(layer) = layers.get(clicked_layer_index) { + let layer_data = layer.layer(); + + // Get clip instances for this layer + let clip_instances = match layer { + lightningbeam_core::layer::AnyLayer::Vector(vl) => &vl.clip_instances, + lightningbeam_core::layer::AnyLayer::Audio(al) => &al.clip_instances, + lightningbeam_core::layer::AnyLayer::Video(vl) => &vl.clip_instances, + }; + + // Check if click is within any clip instance + for clip_instance in clip_instances { + // Get the clip to determine duration + let clip_duration = match layer { + lightningbeam_core::layer::AnyLayer::Vector(_) => { + document.get_vector_clip(&clip_instance.clip_id) + .map(|c| c.duration) + } + lightningbeam_core::layer::AnyLayer::Audio(_) => { + document.get_audio_clip(&clip_instance.clip_id) + .map(|c| c.duration) + } + lightningbeam_core::layer::AnyLayer::Video(_) => { + document.get_video_clip(&clip_instance.clip_id) + .map(|c| c.duration) + } + }; + + if let Some(clip_duration) = clip_duration { + let instance_duration = clip_instance.effective_duration(clip_duration); + let instance_start = clip_instance.timeline_start; + let instance_end = instance_start + instance_duration; + + // Check if click is within this clip instance's time range + if click_time >= instance_start && click_time <= instance_end { + // Found a clicked clip instance! + if shift_held { + // Shift+click: add to selection + selection.add_clip_instance(clip_instance.id); + } else { + // Regular click: select only this clip + selection.select_only_clip_instance(clip_instance.id); + } + clicked_clip_instance = true; + break; + } + } + } + } + } + } + } + } + + // Cache mouse position on mousedown (before any dragging) + if response.hovered() && ui.input(|i| i.pointer.button_pressed(egui::PointerButton::Primary)) { + if let Some(pos) = response.hover_pos() { + self.mousedown_pos = Some(pos); + } + } + + // Handle clip dragging (only if not panning or scrubbing) + if !alt_held && !self.is_scrubbing && !self.is_panning { + if response.drag_started() { + // Use cached mousedown position for edge detection + if let Some(mousedown_pos) = self.mousedown_pos { + if let Some((drag_type, clip_id)) = self.detect_clip_at_pointer( + mousedown_pos, + document, + content_rect, + header_rect, + ) { + // If this clip is not selected, select it (respecting shift key) + if !selection.contains_clip_instance(&clip_id) { + if shift_held { + selection.add_clip_instance(clip_id); + } else { + selection.select_only_clip_instance(clip_id); + } + } + + // Start dragging with the detected drag type + self.clip_drag_state = Some(drag_type); + self.drag_offset = 0.0; + } + } + } + + // Update drag offset during drag + if self.clip_drag_state.is_some() && response.dragged() { + let drag_delta = response.drag_delta(); + let time_delta = drag_delta.x / self.pixels_per_second; + self.drag_offset += time_delta as f64; + } + + // End drag - create action based on drag type + if let Some(drag_type) = self.clip_drag_state { + if response.drag_stopped() { + // Build layer_moves map for the action + use std::collections::HashMap; + let mut layer_moves: HashMap> = + HashMap::new(); + + // Iterate through all layers to find selected clip instances + for layer in &document.root.children { + let layer_id = layer.id(); + + // Get clip instances for this layer + let clip_instances = match layer { + lightningbeam_core::layer::AnyLayer::Vector(vl) => &vl.clip_instances, + lightningbeam_core::layer::AnyLayer::Audio(al) => &al.clip_instances, + lightningbeam_core::layer::AnyLayer::Video(vl) => &vl.clip_instances, + }; + + // Find selected clip instances in this layer + for clip_instance in clip_instances { + if selection.contains_clip_instance(&clip_instance.id) { + let old_timeline_start = clip_instance.timeline_start; + let new_timeline_start = old_timeline_start + self.drag_offset; + + // Add to layer_moves + layer_moves + .entry(layer_id) + .or_insert_with(Vec::new) + .push((clip_instance.id, old_timeline_start, new_timeline_start)); + } + } + } + + // Create and add the action based on drag type + match drag_type { + ClipDragType::Move => { + if !layer_moves.is_empty() { + let action = Box::new( + lightningbeam_core::actions::MoveClipInstancesAction::new( + layer_moves, + ), + ); + pending_actions.push(action); + } + } + ClipDragType::TrimLeft | ClipDragType::TrimRight => { + // Build layer_trims map for trim action + let mut layer_trims: HashMap< + uuid::Uuid, + Vec<( + uuid::Uuid, + lightningbeam_core::actions::TrimType, + lightningbeam_core::actions::TrimData, + lightningbeam_core::actions::TrimData, + )>, + > = HashMap::new(); + + // Iterate through all layers to find selected clip instances + for layer in &document.root.children { + let layer_id = layer.id(); + let layer_data = layer.layer(); + + let clip_instances = match layer { + lightningbeam_core::layer::AnyLayer::Vector(vl) => { + &vl.clip_instances + } + lightningbeam_core::layer::AnyLayer::Audio(al) => { + &al.clip_instances + } + lightningbeam_core::layer::AnyLayer::Video(vl) => { + &vl.clip_instances + } + }; + + // Find selected clip instances in this layer + for clip_instance in clip_instances { + if selection.contains_clip_instance(&clip_instance.id) { + // Get clip duration to validate trim bounds + let clip_duration = match layer { + lightningbeam_core::layer::AnyLayer::Vector(_) => { + document + .get_vector_clip(&clip_instance.clip_id) + .map(|c| c.duration) + } + lightningbeam_core::layer::AnyLayer::Audio(_) => { + document + .get_audio_clip(&clip_instance.clip_id) + .map(|c| c.duration) + } + lightningbeam_core::layer::AnyLayer::Video(_) => { + document + .get_video_clip(&clip_instance.clip_id) + .map(|c| c.duration) + } + }; + + if let Some(clip_duration) = clip_duration { + match drag_type { + ClipDragType::TrimLeft => { + let old_trim_start = clip_instance.trim_start; + let old_timeline_start = + clip_instance.timeline_start; + + // New trim_start is clamped to valid range + let new_trim_start = (old_trim_start + + self.drag_offset) + .max(0.0) + .min(clip_duration); + + // Calculate actual offset after clamping + let actual_offset = new_trim_start - old_trim_start; + let new_timeline_start = + old_timeline_start + actual_offset; + + layer_trims + .entry(layer_id) + .or_insert_with(Vec::new) + .push(( + clip_instance.id, + lightningbeam_core::actions::TrimType::TrimLeft, + lightningbeam_core::actions::TrimData::left( + old_trim_start, + old_timeline_start, + ), + lightningbeam_core::actions::TrimData::left( + new_trim_start, + new_timeline_start, + ), + )); + } + ClipDragType::TrimRight => { + let old_trim_end = clip_instance.trim_end; + + // Calculate new trim_end based on current duration + let current_duration = + clip_instance.effective_duration(clip_duration); + let new_duration = + (current_duration + self.drag_offset).max(0.0); + + // Convert new duration back to trim_end value + let new_trim_end = if new_duration >= clip_duration { + None // Use full clip duration + } else { + Some((clip_instance.trim_start + new_duration).min(clip_duration)) + }; + + layer_trims + .entry(layer_id) + .or_insert_with(Vec::new) + .push(( + clip_instance.id, + lightningbeam_core::actions::TrimType::TrimRight, + lightningbeam_core::actions::TrimData::right( + old_trim_end, + ), + lightningbeam_core::actions::TrimData::right( + new_trim_end, + ), + )); + } + _ => {} + } + } + } + } + } + + // Create and add the trim action if there are any trims + if !layer_trims.is_empty() { + let action = Box::new( + lightningbeam_core::actions::TrimClipInstancesAction::new( + layer_trims, + ), + ); + pending_actions.push(action); + } + } + } + + // Reset drag state + self.clip_drag_state = None; + self.drag_offset = 0.0; + self.mousedown_pos = None; + } + } + } + + // Handle layer selection by clicking on layer header or content (only if no clip was clicked) + if response.clicked() && !alt_held && !clicked_clip_instance { + if let Some(pos) = response.interact_pointer_pos() { + // Check if click is in header or content area (not ruler) + if pos.y >= header_rect.min.y { + let relative_y = pos.y - header_rect.min.y + self.viewport_scroll_y; + let clicked_layer_index = (relative_y / LAYER_HEIGHT) as usize; + + // Get the layer at this index (accounting for reversed display order) + if clicked_layer_index < layer_count { + let layers: Vec<_> = document.root.children.iter().rev().collect(); + if let Some(layer) = layers.get(clicked_layer_index) { + *active_layer_id = Some(layer.id()); + // Clear clip instance selection when clicking on empty layer area + if !shift_held { + selection.clear_clip_instances(); + } + } + } + } + } + } // Get mouse position relative to content area let mouse_pos = response.hover_pos().unwrap_or(content_rect.center()); let mouse_x = (mouse_pos.x - content_rect.min.x).max(0.0); // Calculate max vertical scroll based on number of layers - // TODO: Get actual layer count from document - for now using test count of 3 - const TEST_LAYER_COUNT: usize = 3; - let total_content_height = TEST_LAYER_COUNT as f32 * LAYER_HEIGHT; + let total_content_height = layer_count as f32 * LAYER_HEIGHT; let visible_height = content_rect.height(); let max_scroll_y = (total_content_height - visible_height).max(0.0); // Scrubbing (clicking/dragging on ruler, but only when not panning) - if ruler_rect.contains(ui.input(|i| i.pointer.hover_pos().unwrap_or_default())) && !alt_held { - if response.clicked() || (response.dragged() && !self.is_panning) { - if let Some(pos) = response.interact_pointer_pos() { - let x = (pos.x - content_rect.min.x).max(0.0); - self.current_time = self.x_to_time(x).max(0.0).min(self.duration); - self.is_scrubbing = true; - } - } else if !response.dragged() { - self.is_scrubbing = false; + let cursor_over_ruler = ruler_rect.contains(ui.input(|i| i.pointer.hover_pos().unwrap_or_default())); + + // Start scrubbing if cursor is over ruler and we click/drag + if cursor_over_ruler && !alt_held && (response.clicked() || (response.dragged() && !self.is_panning)) { + if let Some(pos) = response.interact_pointer_pos() { + let x = (pos.x - content_rect.min.x).max(0.0); + self.current_time = self.x_to_time(x).max(0.0); + self.is_scrubbing = true; } - } else { - if !response.dragged() { - self.is_scrubbing = false; + } + // Continue scrubbing while dragging, even if cursor leaves ruler + else if self.is_scrubbing && response.dragged() && !self.is_panning { + if let Some(pos) = response.interact_pointer_pos() { + let x = (pos.x - content_rect.min.x).max(0.0); + self.current_time = self.x_to_time(x).max(0.0); } } + // Stop scrubbing when drag ends + else if !response.dragged() { + self.is_scrubbing = false; + } // Distinguish between mouse wheel (discrete) and trackpad (smooth) let mut handled = false; @@ -492,6 +1116,29 @@ impl TimelinePane { self.last_pan_pos = None; } } + + // Update cursor based on hover position (only if not scrubbing or panning) + if !self.is_scrubbing && !self.is_panning { + // If dragging a clip with trim, keep the resize cursor + if let Some(drag_type) = self.clip_drag_state { + if drag_type != ClipDragType::Move { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal); + } + } else if let Some(hover_pos) = response.hover_pos() { + // Not dragging - detect hover for cursor feedback + if let Some((drag_type, _clip_id)) = self.detect_clip_at_pointer( + hover_pos, + document, + content_rect, + header_rect, + ) { + // Set cursor for trim operations + if drag_type != ClipDragType::Move { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal); + } + } + } + } } } @@ -558,6 +1205,48 @@ impl PaneRenderer for TimelinePane { _path: &NodePath, shared: &mut SharedPaneState, ) { + // Sync timeline's current_time to document + shared.action_executor.document_mut().current_time = self.current_time; + + // Get document from action executor + let document = shared.action_executor.document(); + let layer_count = document.root.children.len(); + + // Calculate project duration from last clip endpoint across all layers + let mut max_endpoint: f64 = 10.0; // Default minimum duration + for layer in &document.root.children { + let clip_instances = match layer { + lightningbeam_core::layer::AnyLayer::Vector(vl) => &vl.clip_instances, + lightningbeam_core::layer::AnyLayer::Audio(al) => &al.clip_instances, + lightningbeam_core::layer::AnyLayer::Video(vl) => &vl.clip_instances, + }; + + for clip_instance in clip_instances { + // Get clip duration + let clip_duration = match layer { + lightningbeam_core::layer::AnyLayer::Vector(_) => { + document.get_vector_clip(&clip_instance.clip_id) + .map(|c| c.duration) + } + lightningbeam_core::layer::AnyLayer::Audio(_) => { + document.get_audio_clip(&clip_instance.clip_id) + .map(|c| c.duration) + } + lightningbeam_core::layer::AnyLayer::Video(_) => { + document.get_video_clip(&clip_instance.clip_id) + .map(|c| c.duration) + } + }; + + if let Some(clip_duration) = clip_duration { + let instance_duration = clip_instance.effective_duration(clip_duration); + let instance_end = clip_instance.timeline_start + instance_duration; + max_endpoint = max_endpoint.max(instance_end); + } + } + } + self.duration = max_endpoint; + // Split into layer header column (left) and timeline content (right) let header_column_rect = egui::Rect::from_min_size( rect.min, @@ -605,7 +1294,7 @@ impl PaneRenderer for TimelinePane { // Render layer header column with clipping ui.set_clip_rect(layer_headers_rect.intersect(original_clip_rect)); - self.render_layer_headers(ui, layer_headers_rect, shared.theme); + self.render_layer_headers(ui, layer_headers_rect, shared.theme, document, shared.active_layer_id); // Render time ruler (clip to ruler rect) ui.set_clip_rect(ruler_rect.intersect(original_clip_rect)); @@ -613,7 +1302,7 @@ impl PaneRenderer for TimelinePane { // Render layer rows with clipping ui.set_clip_rect(content_rect.intersect(original_clip_rect)); - self.render_layers(ui, content_rect, shared.theme); + self.render_layers(ui, content_rect, shared.theme, document, shared.active_layer_id, shared.selection); // Render playhead on top (clip to timeline area) ui.set_clip_rect(timeline_rect.intersect(original_clip_rect)); @@ -623,7 +1312,18 @@ impl PaneRenderer for TimelinePane { ui.set_clip_rect(original_clip_rect); // Handle input (use full rect including header column) - self.handle_input(ui, rect, ruler_rect, content_rect); + self.handle_input( + ui, + rect, + ruler_rect, + content_rect, + layer_headers_rect, + layer_count, + document, + shared.active_layer_id, + shared.selection, + shared.pending_actions, + ); // Register handler for pending view actions (two-phase dispatch) // Priority: Mouse-over (0-99) > Fallback Timeline(1001)