Clips in timeline

This commit is contained in:
Skyler Lehmkuhl 2025-11-28 05:53:11 -05:00
parent 1cb2aabc9c
commit bbeb85b3a3
24 changed files with 3613 additions and 424 deletions

View File

@ -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<Uuid>,
}
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<String>) -> 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<Uuid> {
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");
}
}

View File

@ -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<Uuid>,
@ -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);
}
}
}

View File

@ -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};

View File

@ -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<Uuid, Vec<(Uuid, f64, f64)>>,
}
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<Uuid, Vec<(Uuid, f64, f64)>>) -> 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);
}
}
}

View File

@ -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<Uuid, (Point, Point)>, // (old_pos, new_pos)
shape_instance_positions: HashMap<Uuid, (Point, Point)>, // (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<Uuid, (Point, Point)>) -> 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<Uuid, (Point, Point)>) -> 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);
}

View File

@ -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<Uuid>,
/// ID of the created object (set after execution)
created_object_id: Option<Uuid>,
/// ID of the created shape instance (set after execution)
created_shape_instance_id: Option<Uuid>,
}
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);
}
}

View File

@ -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<Uuid, (Transform, Transform)>,
}
impl TransformClipInstancesAction {
pub fn new(
layer_id: Uuid,
clip_instance_transforms: HashMap<Uuid, (Transform, Transform)>,
) -> 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()
)
}
}

View File

@ -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<Uuid, (Transform, Transform)>,
/// Map of shape instance ID to (old transform, new transform)
shape_instance_transforms: HashMap<Uuid, (Transform, Transform)>,
}
impl TransformObjectsAction {
impl TransformShapeInstancesAction {
/// Create a new transform action
pub fn new(
layer_id: Uuid,
object_transforms: HashMap<Uuid, (Transform, Transform)>,
shape_instance_transforms: HashMap<Uuid, (Transform, Transform)>,
) -> 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())
}
}

View File

@ -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<f64>
layer_trims: HashMap<Uuid, Vec<(Uuid, TrimType, TrimData, TrimData)>>,
}
/// 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<f64>,
/// For TrimLeft: timeline_start value (where the clip appears on timeline)
/// For TrimRight: unused (None)
pub timeline_start: Option<f64>,
}
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<f64>) -> Self {
Self {
trim_value: trim_end,
timeline_start: None,
}
}
}
impl TrimClipInstancesAction {
/// Create a new trim clip instances action
pub fn new(layer_trims: HashMap<Uuid, Vec<(Uuid, TrimType, TrimData, TrimData)>>) -> 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);
}
}
}

View File

@ -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<AnyLayer>,
}
impl VectorClip {
/// Create a new vector clip
pub fn new(name: impl Into<String>, 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<String>,
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<String>,
file_path: impl Into<String>,
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<MidiEvent>,
/// 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<String>, 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<String>,
duration: f64,
events: Vec<MidiEvent>,
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<usize> {
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<String>,
/// 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<f64>,
/// 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<f64>,
/// 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<String>) -> 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<f64>) -> 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<f64> {
// 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);
}
}

View File

@ -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<Uuid, VectorClip>,
/// Video clip library - references to video files
pub video_clips: HashMap<Uuid, VideoClip>,
/// Audio clip library - sampled audio and MIDI clips
pub audio_clips: HashMap<Uuid, AudioClip>,
/// 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<Item = &AnyLayer> {
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<VectorClip> {
self.vector_clips.remove(id)
}
/// Remove a video clip from the library
pub fn remove_video_clip(&mut self, id: &Uuid) -> Option<VideoClip> {
self.video_clips.remove(id)
}
/// Remove an audio clip from the library
pub fn remove_audio_clip(&mut self, id: &Uuid) -> Option<AudioClip> {
self.audio_clips.remove(id)
}
}
#[cfg(test)]

View File

@ -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<Uuid> {
// 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<Uuid> {
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<Uuid, VectorClip>,
video_clips: &std::collections::HashMap<Uuid, VideoClip>,
point: Point,
parent_transform: Affine,
) -> Option<Uuid> {
// 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<Uuid, VectorClip>,
video_clips: &std::collections::HashMap<Uuid, VideoClip>,
rect: Rect,
parent_transform: Affine,
) -> Vec<Uuid> {
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::*;

View File

@ -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<Shape>,
/// Shapes defined in this layer (indexed by UUID for O(1) lookup)
pub shapes: HashMap<Uuid, Shape>,
/// Object instances (references to shapes with transforms)
pub objects: Vec<Object>,
/// Shape instances (references to shapes with transforms)
pub shape_instances: Vec<ShapeInstance>,
/// Clip instances (references to vector clips with transforms)
/// VectorLayer can contain instances of VectorClips for nested compositions
pub clip_instances: Vec<ClipInstance>,
}
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<String>) -> 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<Shape> {
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<Object> {
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<ShapeInstance> {
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<F>(&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<String>,
/// Clip instances (references to audio clips)
/// AudioLayer can contain instances of AudioClips (sampled or MIDI)
pub clip_instances: Vec<ClipInstance>,
}
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<String>) -> 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<String>,
/// Clip instances (references to video clips)
/// VideoLayer can contain instances of VideoClips
pub clip_instances: Vec<ClipInstance>,
}
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<String>) -> 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]

View File

@ -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<T> {
/// The layer data
pub data: T,
/// Child layers
pub children: Vec<LayerNode<T>>,
}
impl<T> LayerNode<T> {
/// 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<T>) {
self.children.push(child);
}
/// Remove a child layer by index
pub fn remove_child(&mut self, index: usize) -> Option<LayerNode<T>> {
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<T>> {
self.children.get(index)
}
/// Get a mutable reference to a child
pub fn get_child_mut(&mut self, index: usize) -> Option<&mut LayerNode<T>> {
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<T> {
/// Root layers (no parent)
pub roots: Vec<LayerNode<T>>,
}
impl<T> LayerTree<T> {
/// 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<LayerNode<T>> {
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<T>> {
self.roots.get(index)
}
/// Get a mutable reference to a root layer
pub fn get_root_mut(&mut self, index: usize) -> Option<&mut LayerNode<T>> {
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<Item = &LayerNode<T>> {
self.roots.iter()
}
/// Iterate over all root layers mutably
pub fn iter_mut(&mut self) -> impl Iterator<Item = &mut LayerNode<T>> {
self.roots.iter_mut()
}
}
impl<T> Default for LayerTree<T> {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_layer_tree_creation() {
let tree: LayerTree<i32> = 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");
}
}

View File

@ -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;

View File

@ -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<String>,
}
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);
}
}

View File

@ -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));

View File

@ -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<Uuid>,
/// Currently selected shape instances
selected_shape_instances: Vec<Uuid>,
/// Currently selected shapes (definitions)
selected_shapes: Vec<Uuid>,
/// Currently selected clip instances
selected_clip_instances: Vec<Uuid>,
}
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());

View File

@ -5,6 +5,7 @@ edition = "2021"
[dependencies]
lightningbeam-core = { path = "../lightningbeam-core" }
daw-backend = { path = "../../daw-backend" }
# UI Framework
eframe = { workspace = true }

View File

@ -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<daw_backend::EngineController>, // Audio engine controller for playback
audio_event_rx: Option<rtrb::Consumer<daw_backend::AudioEvent>>, // 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<Uuid>,
active_layer_id: &mut Option<Uuid>,
tool_state: &mut lightningbeam_core::tool::ToolState,
pending_actions: &mut Vec<Box<dyn lightningbeam_core::action::Action>>,
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<Uuid>,
active_layer_id: &mut Option<Uuid>,
tool_state: &mut lightningbeam_core::tool::ToolState,
pending_actions: &mut Vec<Box<dyn lightningbeam_core::action::Action>>,
draw_simplify_mode: &mut lightningbeam_core::tool::SimplifyMode,

View File

@ -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),
],

View File

@ -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<uuid::Uuid>,
pub active_layer_id: &'a mut Option<uuid::Uuid>,
/// 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)

View File

@ -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<KurboRect> = 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,25 +1254,43 @@ 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
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 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_object(object_id);
shared.selection.toggle_shape_instance(object_id);
} else {
// No shift: replace selection
if !shared.selection.contains_object(&object_id) {
shared.selection.select_only_object(object_id);
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_object(&object_id) {
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.objects() {
for &obj_id in shared.selection.shape_instances() {
if let Some(obj) = vector_layer.get_object(&obj_id) {
original_positions.insert(
obj_id,
@ -1227,6 +1305,42 @@ impl StagePane {
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);
}
}
// 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
if !shift_held {
@ -1266,24 +1380,56 @@ 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);
// 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
*shared.tool_state = ToolState::Idle;
}
@ -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<vello::kurbo::Rect> = 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);
}
}
}

View File

@ -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<egui::Pos2>,
/// Clip drag state (None if not dragging)
clip_drag_state: Option<ClipDragType>,
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<egui::Pos2>,
/// 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<uuid::Uuid>,
) {
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<uuid::Uuid>,
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<uuid::Uuid>,
selection: &mut lightningbeam_core::selection::Selection,
pending_actions: &mut Vec<Box<dyn lightningbeam_core::action::Action>>,
) {
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<uuid::Uuid, Vec<(uuid::Uuid, f64, f64)>> =
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) {
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).min(self.duration);
self.current_time = self.x_to_time(x).max(0.0);
self.is_scrubbing = true;
}
} else if !response.dragged() {
self.is_scrubbing = false;
}
} 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)