diff --git a/lightningbeam-ui/lightningbeam-core/src/action.rs b/lightningbeam-ui/lightningbeam-core/src/action.rs index 72c2896..54c6773 100644 --- a/lightningbeam-ui/lightningbeam-core/src/action.rs +++ b/lightningbeam-ui/lightningbeam-core/src/action.rs @@ -133,7 +133,9 @@ pub struct ActionExecutor { impl ActionExecutor { /// Create a new action executor with the given document - pub fn new(document: Document) -> Self { + pub fn new(mut document: Document) -> Self { + // Rebuild transient lookup maps (not serialized) + document.rebuild_layer_to_clip_map(); Self { document: Arc::new(document), undo_stack: Vec::new(), diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/convert_to_movie_clip.rs b/lightningbeam-ui/lightningbeam-core/src/actions/convert_to_movie_clip.rs new file mode 100644 index 0000000..b47ac7e --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/actions/convert_to_movie_clip.rs @@ -0,0 +1,244 @@ +//! Convert to Movie Clip action +//! +//! Wraps selected shapes and/or clip instances into a new VectorClip +//! with is_group = false, giving it a real internal timeline. +//! Works with 1+ selected items (unlike Group which requires 2+). + +use crate::action::Action; +use crate::animation::{AnimationCurve, AnimationTarget, Keyframe, TransformProperty}; +use crate::clip::{ClipInstance, VectorClip}; +use crate::document::Document; +use crate::layer::{AnyLayer, VectorLayer}; +use crate::shape::Shape; +use uuid::Uuid; +use vello::kurbo::{Rect, Shape as KurboShape}; + +pub struct ConvertToMovieClipAction { + layer_id: Uuid, + time: f64, + shape_ids: Vec, + clip_instance_ids: Vec, + instance_id: Uuid, + created_clip_id: Option, + removed_shapes: Vec, + removed_clip_instances: Vec, +} + +impl ConvertToMovieClipAction { + pub fn new( + layer_id: Uuid, + time: f64, + shape_ids: Vec, + clip_instance_ids: Vec, + instance_id: Uuid, + ) -> Self { + Self { + layer_id, + time, + shape_ids, + clip_instance_ids, + instance_id, + created_clip_id: None, + removed_shapes: Vec::new(), + removed_clip_instances: Vec::new(), + } + } +} + +impl Action for ConvertToMovieClipAction { + fn execute(&mut self, document: &mut Document) -> Result<(), String> { + let layer = document + .get_layer(&self.layer_id) + .ok_or_else(|| format!("Layer {} not found", self.layer_id))?; + + let vl = match layer { + AnyLayer::Vector(vl) => vl, + _ => return Err("Convert to Movie Clip is only supported on vector layers".to_string()), + }; + + // Collect shapes + let shapes_at_time = vl.shapes_at_time(self.time); + let mut collected_shapes: Vec = Vec::new(); + for id in &self.shape_ids { + if let Some(shape) = shapes_at_time.iter().find(|s| &s.id == id) { + collected_shapes.push(shape.clone()); + } + } + + // Collect clip instances + let mut collected_clip_instances: Vec = Vec::new(); + for id in &self.clip_instance_ids { + if let Some(ci) = vl.clip_instances.iter().find(|ci| &ci.id == id) { + collected_clip_instances.push(ci.clone()); + } + } + + let total_items = collected_shapes.len() + collected_clip_instances.len(); + if total_items < 1 { + return Err("Need at least 1 item to convert to movie clip".to_string()); + } + + // Compute combined bounding box + let mut combined_bbox: Option = None; + + for shape in &collected_shapes { + let local_bbox = shape.path().bounding_box(); + let transform = shape.transform.to_affine(); + let transformed_bbox = transform.transform_rect_bbox(local_bbox); + combined_bbox = Some(match combined_bbox { + Some(existing) => existing.union(transformed_bbox), + None => transformed_bbox, + }); + } + + for ci in &collected_clip_instances { + let content_bounds = if let Some(vector_clip) = document.get_vector_clip(&ci.clip_id) { + let clip_time = ((self.time - ci.timeline_start) * ci.playback_speed) + ci.trim_start; + vector_clip.calculate_content_bounds(document, clip_time) + } else if let Some(video_clip) = document.get_video_clip(&ci.clip_id) { + Rect::new(0.0, 0.0, video_clip.width, video_clip.height) + } else { + continue; + }; + let ci_transform = ci.transform.to_affine(); + let transformed_bbox = ci_transform.transform_rect_bbox(content_bounds); + combined_bbox = Some(match combined_bbox { + Some(existing) => existing.union(transformed_bbox), + None => transformed_bbox, + }); + } + + let bbox = combined_bbox.ok_or("Could not compute bounding box")?; + let center_x = (bbox.x0 + bbox.x1) / 2.0; + let center_y = (bbox.y0 + bbox.y1) / 2.0; + + // Offset shapes relative to center + let mut clip_shapes: Vec = collected_shapes.clone(); + for shape in &mut clip_shapes { + shape.transform.x -= center_x; + shape.transform.y -= center_y; + } + + let mut clip_instances_inside: Vec = collected_clip_instances.clone(); + for ci in &mut clip_instances_inside { + ci.transform.x -= center_x; + ci.transform.y -= center_y; + } + + // Create VectorClip with real timeline duration + let mut clip = VectorClip::new("Movie Clip", bbox.width(), bbox.height(), document.duration); + // is_group defaults to false — movie clips have real timelines + let clip_id = clip.id; + + let mut inner_layer = VectorLayer::new("Layer 1"); + for shape in clip_shapes { + inner_layer.add_shape_to_keyframe(shape, 0.0); + } + for ci in clip_instances_inside { + inner_layer.clip_instances.push(ci); + } + clip.layers.add_root(AnyLayer::Vector(inner_layer)); + + document.add_vector_clip(clip); + self.created_clip_id = Some(clip_id); + + // Remove originals from the layer + let layer = document.get_layer_mut(&self.layer_id).unwrap(); + let vl = match layer { + AnyLayer::Vector(vl) => vl, + _ => unreachable!(), + }; + + self.removed_shapes.clear(); + for id in &self.shape_ids { + if let Some(shape) = vl.remove_shape_from_keyframe(id, self.time) { + self.removed_shapes.push(shape); + } + } + + self.removed_clip_instances.clear(); + for id in &self.clip_instance_ids { + if let Some(pos) = vl.clip_instances.iter().position(|ci| &ci.id == id) { + self.removed_clip_instances.push(vl.clip_instances.remove(pos)); + } + } + + // Place the new ClipInstance + let instance = ClipInstance::with_id(self.instance_id, clip_id) + .with_position(center_x, center_y) + .with_name("Movie Clip"); + vl.clip_instances.push(instance); + + // Create default animation curves + let props_and_values = [ + (TransformProperty::X, center_x), + (TransformProperty::Y, center_y), + (TransformProperty::Rotation, 0.0), + (TransformProperty::ScaleX, 1.0), + (TransformProperty::ScaleY, 1.0), + (TransformProperty::SkewX, 0.0), + (TransformProperty::SkewY, 0.0), + (TransformProperty::Opacity, 1.0), + ]; + + for (prop, value) in props_and_values { + let target = AnimationTarget::Object { + id: self.instance_id, + property: prop, + }; + let mut curve = AnimationCurve::new(target.clone(), value); + curve.set_keyframe(Keyframe::linear(0.0, value)); + vl.layer.animation_data.set_curve(curve); + } + + Ok(()) + } + + fn rollback(&mut self, document: &mut Document) -> Result<(), String> { + let layer = document + .get_layer_mut(&self.layer_id) + .ok_or_else(|| format!("Layer {} not found", self.layer_id))?; + + if let AnyLayer::Vector(vl) = layer { + // Remove animation curves + for prop in &[ + TransformProperty::X, TransformProperty::Y, + TransformProperty::Rotation, + TransformProperty::ScaleX, TransformProperty::ScaleY, + TransformProperty::SkewX, TransformProperty::SkewY, + TransformProperty::Opacity, + ] { + let target = AnimationTarget::Object { + id: self.instance_id, + property: *prop, + }; + vl.layer.animation_data.remove_curve(&target); + } + + // Remove the clip instance + vl.clip_instances.retain(|ci| ci.id != self.instance_id); + + // Re-insert removed shapes + for shape in self.removed_shapes.drain(..) { + vl.add_shape_to_keyframe(shape, self.time); + } + + // Re-insert removed clip instances + for ci in self.removed_clip_instances.drain(..) { + vl.clip_instances.push(ci); + } + } + + // Remove the VectorClip from the document + if let Some(clip_id) = self.created_clip_id.take() { + document.remove_vector_clip(&clip_id); + } + + Ok(()) + } + + fn description(&self) -> String { + let count = self.shape_ids.len() + self.clip_instance_ids.len(); + format!("Convert {} object(s) to Movie Clip", count) + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs b/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs index e3dfbb5..80e3312 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs @@ -30,6 +30,7 @@ pub mod remove_clip_instances; pub mod remove_shapes; pub mod set_keyframe; pub mod group_shapes; +pub mod convert_to_movie_clip; pub use add_clip_instance::AddClipInstanceAction; pub use add_effect::AddEffectAction; @@ -58,3 +59,4 @@ pub use remove_clip_instances::RemoveClipInstancesAction; pub use remove_shapes::RemoveShapesAction; pub use set_keyframe::SetKeyframeAction; pub use group_shapes::GroupAction; +pub use convert_to_movie_clip::ConvertToMovieClipAction; diff --git a/lightningbeam-ui/lightningbeam-core/src/clip.rs b/lightningbeam-ui/lightningbeam-core/src/clip.rs index 78b82ac..8a4f58b 100644 --- a/lightningbeam-ui/lightningbeam-core/src/clip.rs +++ b/lightningbeam-ui/lightningbeam-core/src/clip.rs @@ -90,6 +90,30 @@ impl VectorClip { } } + /// Calculate the duration of this clip based on its internal keyframe content. + /// Returns the time of the last keyframe across all layers, plus one frame. + /// Falls back to the stored `duration` field if no keyframes exist. + pub fn content_duration(&self, framerate: f64) -> f64 { + let frame_duration = 1.0 / framerate; + let mut last_time: Option = None; + + for layer_node in self.layers.iter() { + if let AnyLayer::Vector(vector_layer) = &layer_node.data { + if let Some(last_kf) = vector_layer.keyframes.last() { + last_time = Some(match last_time { + Some(t) => t.max(last_kf.time), + None => last_kf.time, + }); + } + } + } + + match last_time { + Some(t) => t + frame_duration, + None => self.duration, + } + } + /// Calculate the bounding box of all content in this clip at a specific time /// /// This recursively calculates the union of all shape and nested clip bounding boxes diff --git a/lightningbeam-ui/lightningbeam-core/src/document.rs b/lightningbeam-ui/lightningbeam-core/src/document.rs index 47e8364..228bbf2 100644 --- a/lightningbeam-ui/lightningbeam-core/src/document.rs +++ b/lightningbeam-ui/lightningbeam-core/src/document.rs @@ -166,6 +166,11 @@ pub struct Document { /// Current playback time in seconds #[serde(skip)] pub current_time: f64, + + /// Reverse lookup: layer_id → clip_id for layers inside vector clips. + /// Enables O(1) lookup in get_layer/get_layer_mut instead of scanning all clips. + #[serde(skip)] + pub layer_to_clip_map: HashMap, } impl Default for Document { @@ -195,6 +200,7 @@ impl Default for Document { ui_layout: None, ui_layout_base: None, current_time: 0.0, + layer_to_clip_map: HashMap::new(), } } } @@ -218,6 +224,27 @@ impl Document { } } + /// Rebuild the layer→clip reverse lookup map from all vector clips. + /// Call after deserialization or bulk clip modifications. + pub fn rebuild_layer_to_clip_map(&mut self) { + self.layer_to_clip_map.clear(); + for (clip_id, clip) in &self.vector_clips { + for node in &clip.layers.roots { + self.layer_to_clip_map.insert(node.data.id(), *clip_id); + } + } + } + + /// Register a layer as belonging to a clip (for O(1) lookup). + pub fn register_layer_in_clip(&mut self, layer_id: Uuid, clip_id: Uuid) { + self.layer_to_clip_map.insert(layer_id, clip_id); + } + + /// Unregister a layer from the clip lookup map. + pub fn unregister_layer_from_clip(&mut self, layer_id: &Uuid) { + self.layer_to_clip_map.remove(layer_id); + } + /// Set the background color pub fn with_background(mut self, color: ShapeColor) -> Self { self.background_color = color; @@ -343,9 +370,31 @@ impl Document { .filter(|layer| layer.layer().visible) } - /// Get a layer by ID + /// Get visible layers for the current editing context + pub fn context_visible_layers(&self, clip_id: Option<&Uuid>) -> Vec<&AnyLayer> { + self.context_layers(clip_id) + .into_iter() + .filter(|layer| layer.layer().visible) + .collect() + } + + /// Get a layer by ID (searches root layers, then clip layers via O(1) map lookup) pub fn get_layer(&self, id: &Uuid) -> Option<&AnyLayer> { - self.root.get_child(id) + // First check root layers + if let Some(layer) = self.root.get_child(id) { + return Some(layer); + } + // O(1) lookup: check if this layer belongs to a clip + if let Some(clip_id) = self.layer_to_clip_map.get(id) { + if let Some(clip) = self.vector_clips.get(clip_id) { + for node in &clip.layers.roots { + if &node.data.id() == id { + return Some(&node.data); + } + } + } + } + None } // === MUTATION METHODS (pub(crate) - only accessible to action module) === @@ -358,12 +407,59 @@ impl Document { &mut self.root } - /// Get mutable access to a layer by ID + /// Get mutable access to a layer by ID (searches root layers, then clip layers via O(1) map lookup) /// /// This method is intentionally `pub(crate)` to ensure mutations /// only happen through the action system. pub fn get_layer_mut(&mut self, id: &Uuid) -> Option<&mut AnyLayer> { - self.root.get_child_mut(id) + // First check root layers + if self.root.get_child(id).is_some() { + return self.root.get_child_mut(id); + } + // O(1) lookup: check if this layer belongs to a clip + if let Some(clip_id) = self.layer_to_clip_map.get(id).copied() { + if let Some(clip) = self.vector_clips.get_mut(&clip_id) { + for node in &mut clip.layers.roots { + if &node.data.id() == id { + return Some(&mut node.data); + } + } + } + } + None + } + + // === EDITING CONTEXT METHODS === + + /// Get the layers for the current editing context. + /// When `clip_id` is None, returns root layers. When Some, returns the clip's layers. + pub fn context_layers(&self, clip_id: Option<&Uuid>) -> Vec<&AnyLayer> { + match clip_id { + None => self.root.children.iter().collect(), + Some(id) => self.vector_clips.get(id) + .map(|clip| clip.layers.root_data()) + .unwrap_or_default(), + } + } + + /// Get mutable layers for the current editing context. + pub fn context_layers_mut(&mut self, clip_id: Option<&Uuid>) -> Vec<&mut AnyLayer> { + match clip_id { + None => self.root.children.iter_mut().collect(), + Some(id) => self.vector_clips.get_mut(id) + .map(|clip| clip.layers.root_data_mut()) + .unwrap_or_default(), + } + } + + /// Look up a layer by ID within an editing context. + pub fn get_layer_in_context(&self, clip_id: Option<&Uuid>, layer_id: &Uuid) -> Option<&AnyLayer> { + self.context_layers(clip_id).into_iter().find(|l| &l.id() == layer_id) + } + + /// Look up a mutable layer by ID within an editing context. + pub fn get_layer_in_context_mut(&mut self, clip_id: Option<&Uuid>, layer_id: &Uuid) -> Option<&mut AnyLayer> { + self.context_layers_mut(clip_id).into_iter().find(|l| &l.id() == layer_id) } // === CLIP LIBRARY METHODS === @@ -371,6 +467,10 @@ impl Document { /// Add a vector clip to the library pub fn add_vector_clip(&mut self, clip: VectorClip) -> Uuid { let id = clip.id; + // Register all layers in the clip for O(1) reverse lookup + for node in &clip.layers.roots { + self.layer_to_clip_map.insert(node.data.id(), id); + } self.vector_clips.insert(id, clip); id } @@ -439,7 +539,15 @@ impl Document { /// Remove a vector clip from the library pub fn remove_vector_clip(&mut self, id: &Uuid) -> Option { - self.vector_clips.remove(id) + if let Some(clip) = self.vector_clips.remove(id) { + // Unregister all layers from the reverse lookup map + for node in &clip.layers.roots { + self.layer_to_clip_map.remove(&node.data.id()); + } + Some(clip) + } else { + None + } } /// Remove a video clip from the library @@ -534,7 +642,11 @@ impl Document { /// have infinite internal duration. pub fn get_clip_duration(&self, clip_id: &Uuid) -> Option { if let Some(clip) = self.vector_clips.get(clip_id) { - Some(clip.duration) + if clip.is_group { + Some(clip.duration) + } else { + Some(clip.content_duration(self.framerate)) + } } else if let Some(clip) = self.video_clips.get(clip_id) { Some(clip.duration) } else if let Some(clip) = self.audio_clips.get(clip_id) { diff --git a/lightningbeam-ui/lightningbeam-core/src/hit_test.rs b/lightningbeam-ui/lightningbeam-core/src/hit_test.rs index a344d7a..3d1a457 100644 --- a/lightningbeam-ui/lightningbeam-core/src/hit_test.rs +++ b/lightningbeam-ui/lightningbeam-core/src/hit_test.rs @@ -164,6 +164,13 @@ pub fn hit_test_clip_instances( timeline_time: f64, ) -> Option { for clip_instance in clip_instances.iter().rev() { + // Check time bounds: skip clip instances not active at this time + let clip_duration = document.get_clip_duration(&clip_instance.clip_id).unwrap_or(0.0); + let instance_end = clip_instance.timeline_start + clip_instance.effective_duration(clip_duration); + if timeline_time < clip_instance.timeline_start || timeline_time >= instance_end { + continue; + } + let clip_time = ((timeline_time - clip_instance.timeline_start) * clip_instance.playback_speed) + clip_instance.trim_start; let content_bounds = if let Some(vector_clip) = document.get_vector_clip(&clip_instance.clip_id) { @@ -196,6 +203,13 @@ pub fn hit_test_clip_instances_in_rect( let mut hits = Vec::new(); for clip_instance in clip_instances { + // Check time bounds: skip clip instances not active at this time + let clip_duration = document.get_clip_duration(&clip_instance.clip_id).unwrap_or(0.0); + let instance_end = clip_instance.timeline_start + clip_instance.effective_duration(clip_duration); + if timeline_time < clip_instance.timeline_start || timeline_time >= instance_end { + continue; + } + let clip_time = ((timeline_time - clip_instance.timeline_start) * clip_instance.playback_speed) + clip_instance.trim_start; let content_bounds = if let Some(vector_clip) = document.get_vector_clip(&clip_instance.clip_id) { diff --git a/lightningbeam-ui/lightningbeam-core/src/layer.rs b/lightningbeam-ui/lightningbeam-core/src/layer.rs index 502f1f7..cc6de8f 100644 --- a/lightningbeam-ui/lightningbeam-core/src/layer.rs +++ b/lightningbeam-ui/lightningbeam-core/src/layer.rs @@ -328,48 +328,6 @@ impl VectorLayer { // === MUTATION METHODS (pub(crate) - only accessible to action module) === - /// Add a shape to this layer (internal, for actions only) - /// - /// This method is intentionally `pub(crate)` to ensure mutations - /// only happen through the action system. - pub(crate) fn add_shape_internal(&mut self, shape: Shape) -> Uuid { - let id = shape.id; - self.shapes.insert(id, shape); - id - } - - /// Add an object to this layer (internal, for actions only) - /// - /// This method is intentionally `pub(crate)` to ensure mutations - /// only happen through the action system. - pub(crate) fn add_object_internal(&mut self, object: ShapeInstance) -> Uuid { - let id = object.id; - self.shape_instances.push(object); - id - } - - /// Remove a shape from this layer (internal, for actions only) - /// - /// Returns the removed shape if found. - /// 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 { - self.shapes.remove(id) - } - - /// Remove an object from this layer (internal, for actions only) - /// - /// Returns the removed object if found. - /// This method is intentionally `pub(crate)` to ensure mutations - /// only happen through the action system. - pub(crate) fn remove_object_internal(&mut self, id: &Uuid) -> Option { - if let Some(index) = self.shape_instances.iter().position(|o| &o.id == id) { - Some(self.shape_instances.remove(index)) - } else { - None - } - } - /// Modify an object in place (internal, for actions only) /// /// Applies the given function to the object if found. diff --git a/lightningbeam-ui/lightningbeam-core/src/layer_tree.rs b/lightningbeam-ui/lightningbeam-core/src/layer_tree.rs index 701171e..2e2b58d 100644 --- a/lightningbeam-ui/lightningbeam-core/src/layer_tree.rs +++ b/lightningbeam-ui/lightningbeam-core/src/layer_tree.rs @@ -110,6 +110,18 @@ impl LayerTree { } } +impl LayerTree { + /// Get flat list of references to all root layer data + pub fn root_data(&self) -> Vec<&T> { + self.roots.iter().map(|n| &n.data).collect() + } + + /// Get flat list of mutable references to all root layer data + pub fn root_data_mut(&mut self) -> Vec<&mut T> { + self.roots.iter_mut().map(|n| &mut n.data).collect() + } +} + impl Default for LayerTree { fn default() -> Self { Self::new() diff --git a/lightningbeam-ui/lightningbeam-core/src/renderer.rs b/lightningbeam-ui/lightningbeam-core/src/renderer.rs index 03257fe..436cd63 100644 --- a/lightningbeam-ui/lightningbeam-core/src/renderer.rs +++ b/lightningbeam-ui/lightningbeam-core/src/renderer.rs @@ -373,7 +373,19 @@ pub fn render_document_with_transform( // 2. Recursively render the root graphics object at current time let time = document.current_time; - render_graphics_object(document, time, scene, base_transform, image_cache, video_manager, skip_instance_id); + + // Check if any layers are soloed + let any_soloed = document.visible_layers().any(|layer| layer.soloed()); + + for layer in document.visible_layers() { + if any_soloed { + if layer.soloed() { + render_layer(document, time, layer, scene, base_transform, 1.0, image_cache, video_manager, skip_instance_id); + } + } else { + render_layer(document, time, layer, scene, base_transform, 1.0, image_cache, video_manager, skip_instance_id); + } + } } /// Draw the document background @@ -392,35 +404,6 @@ 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, - time: f64, - scene: &mut Scene, - base_transform: Affine, - image_cache: &mut ImageCache, - video_manager: &std::sync::Arc>, - skip_instance_id: Option, -) { - // Check if any layers are soloed - let any_soloed = document.visible_layers().any(|layer| layer.soloed()); - - // Render layers based on solo state - // If any layer is soloed, only render soloed layers - // Otherwise, render all visible layers - // Start with full opacity (1.0) - for layer in document.visible_layers() { - if any_soloed { - // Only render soloed layers when solo is active - if layer.soloed() { - render_layer(document, time, layer, scene, base_transform, 1.0, image_cache, video_manager, skip_instance_id); - } - } else { - // Render all visible layers when no solo is active - render_layer(document, time, layer, scene, base_transform, 1.0, image_cache, video_manager, skip_instance_id); - } - } -} /// Render a single layer fn render_layer( @@ -451,6 +434,42 @@ fn render_layer( } } +/// Render a single clip instance by ID to a scene. +/// Used for re-rendering the "focused" clip on top of a dimmed scene when editing inside a clip. +pub fn render_single_clip_instance( + document: &Document, + scene: &mut Scene, + base_transform: Affine, + layer_id: &uuid::Uuid, + instance_id: &uuid::Uuid, + image_cache: &mut ImageCache, + video_manager: &std::sync::Arc>, +) { + let time = document.current_time; + + // Find the layer containing this instance + let Some(layer) = document.get_layer(layer_id) else { return }; + let AnyLayer::Vector(vector_layer) = layer else { return }; + + let layer_opacity = vector_layer.layer.opacity; + + // Find the specific clip instance + let Some(clip_instance) = vector_layer.clip_instances.iter().find(|ci| &ci.id == instance_id) else { return }; + + // Compute group_end_time if needed + let group_end_time = document.vector_clips.get(&clip_instance.clip_id) + .filter(|vc| vc.is_group) + .map(|_| { + let frame_duration = 1.0 / document.framerate; + vector_layer.group_visibility_end(&clip_instance.id, clip_instance.timeline_start, frame_duration) + }); + + render_clip_instance( + document, time, clip_instance, layer_opacity, scene, base_transform, + &vector_layer.layer.animation_data, image_cache, video_manager, group_end_time, + ); +} + /// Render a clip instance (recursive rendering for nested compositions) fn render_clip_instance( document: &Document, @@ -479,7 +498,8 @@ fn render_clip_instance( } 0.0 } else { - let Some(t) = clip_instance.remap_time(time, vector_clip.duration) else { + let clip_dur = vector_clip.content_duration(document.framerate); + let Some(t) = clip_instance.remap_time(time, clip_dur) else { return; // Clip instance not active at this time }; t diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index bc45976..d368e34 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -616,6 +616,51 @@ enum RecordingArmMode { Manual, } +/// Entry in the editing context stack — tracks which clip is being edited +#[derive(Clone)] +struct EditingContextEntry { + /// The VectorClip ID being edited + clip_id: Uuid, + /// The ClipInstance ID through which we entered + instance_id: Uuid, + /// The layer ID that contains the instance in the parent context + parent_layer_id: Uuid, + /// Saved playback time from the parent context (restored on exit) + saved_playback_time: f64, + /// Saved active layer ID from the parent context + saved_active_layer_id: Option, +} + +/// Editing context stack — tracks which clip (or root) is being edited. +/// Empty stack = editing the document root. +#[derive(Clone, Default)] +struct EditingContext { + stack: Vec, +} + +impl EditingContext { + fn current_clip_id(&self) -> Option { + self.stack.last().map(|e| e.clip_id) + } + + fn current_instance_id(&self) -> Option { + self.stack.last().map(|e| e.instance_id) + } + + fn current_parent_layer_id(&self) -> Option { + self.stack.last().map(|e| e.parent_layer_id) + } + + fn push(&mut self, entry: EditingContextEntry) { + self.stack.push(entry); + } + + fn pop(&mut self) -> Option { + self.stack.pop() + } + +} + struct EditorApp { layouts: Vec, current_layout_index: usize, @@ -638,6 +683,7 @@ struct EditorApp { action_executor: lightningbeam_core::action::ActionExecutor, // Action system for undo/redo active_layer_id: Option, // Currently active layer for editing selection: lightningbeam_core::selection::Selection, // Current selection state + editing_context: EditingContext, // Which clip (or root) we're editing tool_state: lightningbeam_core::tool::ToolState, // Current tool interaction state // Draw tool configuration draw_simplify_mode: lightningbeam_core::tool::SimplifyMode, // Current simplification mode for draw tool @@ -874,6 +920,7 @@ impl EditorApp { action_executor, active_layer_id: Some(layer_id), selection: lightningbeam_core::selection::Selection::new(), + editing_context: EditingContext::default(), tool_state: lightningbeam_core::tool::ToolState::default(), draw_simplify_mode: lightningbeam_core::tool::SimplifyMode::Smooth, // Default to smooth curves rdp_tolerance: 10.0, // Default RDP tolerance @@ -1585,7 +1632,6 @@ impl EditorApp { /// Delete the current selection (for cut and delete operations) fn clipboard_delete_selection(&mut self) { - use lightningbeam_core::layer::AnyLayer; if !self.selection.clip_instances().is_empty() { let active_layer_id = match self.active_layer_id { @@ -2241,6 +2287,28 @@ impl EditorApp { } } } + MenuAction::ConvertToMovieClip => { + if let Some(layer_id) = self.active_layer_id { + let shape_ids: Vec = self.selection.shape_instances().to_vec(); + let clip_ids: Vec = self.selection.clip_instances().to_vec(); + if shape_ids.len() + clip_ids.len() >= 1 { + let instance_id = uuid::Uuid::new_v4(); + let action = lightningbeam_core::actions::ConvertToMovieClipAction::new( + layer_id, + self.playback_time, + shape_ids, + clip_ids, + instance_id, + ); + if let Err(e) = self.action_executor.execute(Box::new(action)) { + eprintln!("Failed to convert to movie clip: {}", e); + } else { + self.selection.clear(); + self.selection.add_clip_instance(instance_id); + } + } + } + } MenuAction::SendToBack => { println!("Menu: Send to Back"); // TODO: Implement send to back @@ -4429,6 +4497,10 @@ impl eframe::App for EditorApp { // Menu actions queued by pane context menus let mut pending_menu_actions: Vec = Vec::new(); + // Editing context navigation requests from stage pane + let mut pending_enter_clip: Option<(Uuid, Uuid, Uuid)> = None; + let mut pending_exit_clip = false; + // Queue for effect thumbnail requests (collected during rendering) let mut effect_thumbnail_requests: Vec = Vec::new(); // Empty cache fallback if generator not initialized @@ -4468,6 +4540,11 @@ impl eframe::App for EditorApp { theme: &self.theme, action_executor: &mut self.action_executor, selection: &mut self.selection, + editing_clip_id: self.editing_context.current_clip_id(), + editing_instance_id: self.editing_context.current_instance_id(), + editing_parent_layer_id: self.editing_context.current_parent_layer_id(), + pending_enter_clip: &mut pending_enter_clip, + pending_exit_clip: &mut pending_exit_clip, active_layer_id: &mut self.active_layer_id, tool_state: &mut self.tool_state, pending_actions: &mut pending_actions, @@ -4576,6 +4653,34 @@ impl eframe::App for EditorApp { self.handle_menu_action(action); } + // Process editing context navigation (enter/exit movie clips) + if let Some((clip_id, instance_id, parent_layer_id)) = pending_enter_clip { + let entry = EditingContextEntry { + clip_id, + instance_id, + parent_layer_id, + saved_playback_time: self.playback_time, + saved_active_layer_id: self.active_layer_id, + }; + self.editing_context.push(entry); + self.selection.clear(); + // Set active layer to the clip's first layer + let first_layer_id = self.action_executor.document() + .get_vector_clip(&clip_id) + .and_then(|clip| clip.layers.roots.first()) + .map(|node| node.data.id()); + self.active_layer_id = first_layer_id; + // Reset playback time to 0 when entering a clip + self.playback_time = 0.0; + } + if pending_exit_clip { + if let Some(entry) = self.editing_context.pop() { + self.selection.clear(); + self.active_layer_id = entry.saved_active_layer_id; + self.playback_time = entry.saved_playback_time; + } + } + // Set cursor based on hover state if let Some((_, is_horizontal)) = self.hovered_divider { if is_horizontal { @@ -4735,6 +4840,11 @@ struct RenderContext<'a> { theme: &'a Theme, action_executor: &'a mut lightningbeam_core::action::ActionExecutor, selection: &'a mut lightningbeam_core::selection::Selection, + editing_clip_id: Option, + editing_instance_id: Option, + editing_parent_layer_id: Option, + pending_enter_clip: &'a mut Option<(Uuid, Uuid, Uuid)>, + pending_exit_clip: &'a mut bool, active_layer_id: &'a mut Option, tool_state: &'a mut lightningbeam_core::tool::ToolState, pending_actions: &'a mut Vec>, @@ -5272,6 +5382,11 @@ fn render_pane( project_generation: ctx.project_generation, script_to_edit: ctx.script_to_edit, script_saved: ctx.script_saved, + editing_clip_id: ctx.editing_clip_id, + editing_instance_id: ctx.editing_instance_id, + editing_parent_layer_id: ctx.editing_parent_layer_id, + pending_enter_clip: ctx.pending_enter_clip, + pending_exit_clip: ctx.pending_exit_clip, }; pane_instance.render_header(&mut header_ui, &mut shared); } @@ -5345,6 +5460,11 @@ fn render_pane( project_generation: ctx.project_generation, script_to_edit: ctx.script_to_edit, script_saved: ctx.script_saved, + editing_clip_id: ctx.editing_clip_id, + editing_instance_id: ctx.editing_instance_id, + editing_parent_layer_id: ctx.editing_parent_layer_id, + pending_enter_clip: ctx.pending_enter_clip, + pending_exit_clip: ctx.pending_exit_clip, }; // Render pane content (header was already rendered above) diff --git a/lightningbeam-ui/lightningbeam-editor/src/menu.rs b/lightningbeam-ui/lightningbeam-editor/src/menu.rs index baa47c4..e2dd80d 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/menu.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/menu.rs @@ -163,6 +163,7 @@ pub enum MenuAction { // Modify menu Group, + ConvertToMovieClip, SendToBack, BringToFront, SplitClip, @@ -259,6 +260,7 @@ impl MenuItemDef { // Modify menu items const GROUP: Self = Self { label: "Group", action: MenuAction::Group, shortcut: Some(Shortcut::new(ShortcutKey::G, CTRL, NO_SHIFT, NO_ALT)) }; + const CONVERT_TO_MOVIE_CLIP: Self = Self { label: "Convert to Movie Clip", action: MenuAction::ConvertToMovieClip, shortcut: None }; const SEND_TO_BACK: Self = Self { label: "Send to back", action: MenuAction::SendToBack, shortcut: None }; const BRING_TO_FRONT: Self = Self { label: "Bring to front", action: MenuAction::BringToFront, shortcut: None }; const SPLIT_CLIP: Self = Self { label: "Split Clip", action: MenuAction::SplitClip, shortcut: Some(Shortcut::new(ShortcutKey::K, CTRL, NO_SHIFT, NO_ALT)) }; @@ -369,6 +371,7 @@ impl MenuItemDef { label: "Modify", children: &[ MenuDef::Item(&Self::GROUP), + MenuDef::Item(&Self::CONVERT_TO_MOVIE_CLIP), MenuDef::Separator, MenuDef::Item(&Self::SEND_TO_BACK), MenuDef::Item(&Self::BRING_TO_FRONT), diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs index 67afd34..bade1b2 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs @@ -154,6 +154,16 @@ pub struct SharedPaneState<'a> { pub action_executor: &'a mut lightningbeam_core::action::ActionExecutor, /// Current selection state (mutable for tools to modify) pub selection: &'a mut lightningbeam_core::selection::Selection, + /// Which VectorClip is being edited (None = document root) + pub editing_clip_id: Option, + /// The clip instance ID being edited + pub editing_instance_id: Option, + /// The parent layer ID containing the clip instance being edited + pub editing_parent_layer_id: Option, + /// Request to enter a movie clip for editing: (clip_id, instance_id, parent_layer_id) + pub pending_enter_clip: &'a mut Option<(uuid::Uuid, uuid::Uuid, uuid::Uuid)>, + /// Request to exit the current movie clip + pub pending_exit_clip: &'a mut bool, /// Currently active layer ID pub active_layer_id: &'a mut Option, /// Current tool interaction state (mutable for tools to modify) diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mod.rs index a2c390f..1b2a30f 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mod.rs @@ -797,7 +797,7 @@ impl NodeGraphPane { if let Some(path) = rfd::FileDialog::new().pick_folder() { match crate::sample_import::scan_folder(&path) { Ok(samples) => { - let scan_result = crate::sample_import::build_import_layers(samples, &path); + let scan_result = crate::sample_import::build_import_layers(samples); let track_id = backend_track_id; let dialog = crate::sample_import_dialog::SampleImportDialog::new( path, scan_result, track_id, backend_node_id, node_id, diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs index ac4b7c9..708e551 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs @@ -380,6 +380,12 @@ struct VelloRenderContext { shape_editing_cache: Option, /// Surface format for blit pipelines target_format: wgpu::TextureFormat, + /// Which VectorClip is being edited (None = document root) + editing_clip_id: Option, + /// The clip instance ID being edited (for skip + re-render) + editing_instance_id: Option, + /// The parent layer ID containing the clip instance being edited + editing_parent_layer_id: Option, } /// Callback for Vello rendering within egui @@ -436,6 +442,23 @@ impl egui_wgpu::CallbackTrait for VelloCallback { let camera_transform = Affine::translate((self.ctx.pan_offset.x as f64, self.ctx.pan_offset.y as f64)) * Affine::scale(self.ctx.zoom as f64); + // Overlay transform: camera + clip instance transform (for rendering overlays in clip-local space) + let overlay_transform = if let (Some(parent_layer_id), Some(instance_id)) = (self.ctx.editing_parent_layer_id, self.ctx.editing_instance_id) { + let clip_affine = self.ctx.document.get_layer(&parent_layer_id) + .and_then(|layer| { + if let lightningbeam_core::layer::AnyLayer::Vector(vl) = layer { + vl.clip_instances.iter().find(|ci| ci.id == instance_id) + } else { + None + } + }) + .map(|ci| ci.transform.to_affine()) + .unwrap_or(Affine::IDENTITY); + camera_transform * clip_affine + } else { + camera_transform + }; + // Choose rendering path based on HDR compositing flag let mut scene = if USE_HDR_COMPOSITING { // HDR Compositing Pipeline: render each layer separately for proper opacity @@ -448,12 +471,19 @@ impl egui_wgpu::CallbackTrait for VelloCallback { // Skip rendering the shape instance being edited (for vector editing preview) let skip_instance_id = self.ctx.shape_editing_cache.as_ref().map(|cache| cache.instance_id); + // When editing inside a clip, skip the clip instance in the main pass + // (it will be re-rendered on top after the dim overlay) + let editing_skip_id = self.ctx.editing_clip_id.as_ref().and_then(|_| { + self.ctx.editing_instance_id + }); + let effective_skip = skip_instance_id.or(editing_skip_id); + let composite_result = lightningbeam_core::renderer::render_document_for_compositing( &self.ctx.document, camera_transform, &mut image_cache, &shared.video_manager, - skip_instance_id, + effective_skip, ); drop(image_cache); @@ -677,6 +707,89 @@ impl egui_wgpu::CallbackTrait for VelloCallback { drop(effect_processor); + // When editing inside a clip: dim overlay + re-render the clip at full opacity + if let (Some(parent_layer_id), Some(instance_id)) = (self.ctx.editing_parent_layer_id, self.ctx.editing_instance_id) { + // 1. Render dim overlay scene + let mut dim_scene = vello::Scene::new(); + let doc_rect = vello::kurbo::Rect::new(0.0, 0.0, self.ctx.document.width, self.ctx.document.height); + dim_scene.fill( + vello::peniko::Fill::NonZero, + camera_transform, + vello::peniko::Color::new([0.0, 0.0, 0.0, 0.5]), + None, + &doc_rect, + ); + + // Composite dim overlay onto HDR texture + let dim_srgb_handle = buffer_pool.acquire(device, lightningbeam_core::gpu::BufferSpec::new(width, height, lightningbeam_core::gpu::BufferFormat::Rgba8Srgb)); + let dim_hdr_handle = buffer_pool.acquire(device, lightningbeam_core::gpu::BufferSpec::new(width, height, BufferFormat::Rgba16Float)); + if let (Some(dim_srgb_view), Some(dim_hdr_view), Some(hdr_view)) = ( + buffer_pool.get_view(dim_srgb_handle), + buffer_pool.get_view(dim_hdr_handle), + &instance_resources.hdr_texture_view, + ) { + let dim_params = vello::RenderParams { + base_color: vello::peniko::Color::TRANSPARENT, + width, height, + antialiasing_method: vello::AaConfig::Msaa16, + }; + if let Ok(mut renderer) = shared.renderer.lock() { + renderer.render_to_texture(device, queue, &dim_scene, dim_srgb_view, &dim_params).ok(); + } + let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("dim_srgb_to_linear") }); + shared.srgb_to_linear.convert(device, &mut enc, dim_srgb_view, dim_hdr_view); + queue.submit(Some(enc.finish())); + + let dim_layer = lightningbeam_core::gpu::CompositorLayer::normal(dim_hdr_handle, 1.0); + let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("dim_composite") }); + shared.compositor.composite(device, queue, &mut enc, &[dim_layer], &buffer_pool, hdr_view, None); + queue.submit(Some(enc.finish())); + } + buffer_pool.release(dim_srgb_handle); + buffer_pool.release(dim_hdr_handle); + + // 2. Re-render the clip instance at full opacity + let mut clip_scene = vello::Scene::new(); + let mut image_cache = shared.image_cache.lock().unwrap(); + lightningbeam_core::renderer::render_single_clip_instance( + &self.ctx.document, + &mut clip_scene, + camera_transform, + &parent_layer_id, + &instance_id, + &mut image_cache, + &shared.video_manager, + ); + drop(image_cache); + + let clip_srgb_handle = buffer_pool.acquire(device, lightningbeam_core::gpu::BufferSpec::new(width, height, lightningbeam_core::gpu::BufferFormat::Rgba8Srgb)); + let clip_hdr_handle = buffer_pool.acquire(device, lightningbeam_core::gpu::BufferSpec::new(width, height, BufferFormat::Rgba16Float)); + if let (Some(clip_srgb_view), Some(clip_hdr_view), Some(hdr_view)) = ( + buffer_pool.get_view(clip_srgb_handle), + buffer_pool.get_view(clip_hdr_handle), + &instance_resources.hdr_texture_view, + ) { + let clip_params = vello::RenderParams { + base_color: vello::peniko::Color::TRANSPARENT, + width, height, + antialiasing_method: vello::AaConfig::Msaa16, + }; + if let Ok(mut renderer) = shared.renderer.lock() { + renderer.render_to_texture(device, queue, &clip_scene, clip_srgb_view, &clip_params).ok(); + } + let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("clip_srgb_to_linear") }); + shared.srgb_to_linear.convert(device, &mut enc, clip_srgb_view, clip_hdr_view); + queue.submit(Some(enc.finish())); + + let clip_layer = lightningbeam_core::gpu::CompositorLayer::normal(clip_hdr_handle, 1.0); + let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("clip_composite") }); + shared.compositor.composite(device, queue, &mut enc, &[clip_layer], &buffer_pool, hdr_view, None); + queue.submit(Some(enc.finish())); + } + buffer_pool.release(clip_srgb_handle); + buffer_pool.release(clip_hdr_handle); + } + // Advance frame counter for buffer cleanup buffer_pool.next_frame(); drop(buffer_pool); @@ -692,14 +805,43 @@ impl egui_wgpu::CallbackTrait for VelloCallback { // Skip rendering the shape instance being edited (for vector editing preview) let skip_instance_id = self.ctx.shape_editing_cache.as_ref().map(|cache| cache.instance_id); + let editing_skip_id = self.ctx.editing_clip_id.as_ref().and_then(|_| { + self.ctx.editing_instance_id + }); + let effective_skip = skip_instance_id.or(editing_skip_id); + lightningbeam_core::renderer::render_document_with_transform( &self.ctx.document, &mut scene, camera_transform, &mut image_cache, &shared.video_manager, - skip_instance_id, + effective_skip, ); + + // When editing inside a clip: dim overlay + re-render the clip at full opacity + if let (Some(parent_layer_id), Some(instance_id)) = (self.ctx.editing_parent_layer_id, self.ctx.editing_instance_id) { + // Semi-transparent dim overlay + let doc_rect = vello::kurbo::Rect::new(0.0, 0.0, self.ctx.document.width, self.ctx.document.height); + scene.fill( + vello::peniko::Fill::NonZero, + camera_transform, + vello::peniko::Color::new([0.0, 0.0, 0.0, 0.5]), + None, + &doc_rect, + ); + // Re-render the clip instance on top + lightningbeam_core::renderer::render_single_clip_instance( + &self.ctx.document, + &mut scene, + camera_transform, + &parent_layer_id, + &instance_id, + &mut image_cache, + &shared.video_manager, + ); + } + drop(image_cache); scene }; @@ -751,7 +893,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback { * Affine::rotate(shape.transform.rotation.to_radians()) * Affine::scale_non_uniform(shape.transform.scale_x, shape.transform.scale_y) * skew_transform; - let combined_transform = camera_transform * object_transform; + let combined_transform = overlay_transform * object_transform; // Render shape with semi-transparent fill (light blue, 40% opacity) let alpha_color = Color::from_rgba8(100, 150, 255, 100); @@ -772,7 +914,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback { use vello::kurbo::Stroke; let clip_transform = Affine::translate((new_x, new_y)); - let combined_transform = camera_transform * clip_transform; + let combined_transform = overlay_transform * clip_transform; // Calculate clip bounds for preview let clip_time = ((self.ctx.playback_time - clip_inst.timeline_start) * clip_inst.playback_speed) + clip_inst.trim_start; @@ -822,7 +964,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback { // Apply object transform and camera transform let object_transform = Affine::translate((shape.transform.x, shape.transform.y)); - let combined_transform = camera_transform * object_transform; + let combined_transform = overlay_transform * object_transform; // Create selection rectangle let selection_rect = KurboRect::new(bbox.x0, bbox.y0, bbox.x1, bbox.y1); @@ -868,9 +1010,15 @@ impl egui_wgpu::CallbackTrait for VelloCallback { } // Also draw selection outlines for clip instances - let _clip_instance_count = self.ctx.selection.clip_instances().len(); for &clip_id in self.ctx.selection.clip_instances() { if let Some(clip_instance) = vector_layer.clip_instances.iter().find(|ci| ci.id == clip_id) { + // Skip clip instances not active at current time + let clip_dur = self.ctx.document.get_clip_duration(&clip_instance.clip_id).unwrap_or(0.0); + let instance_end = clip_instance.timeline_start + clip_instance.effective_duration(clip_dur); + if self.ctx.playback_time < clip_instance.timeline_start || self.ctx.playback_time >= instance_end { + continue; + } + // Calculate clip-local time let clip_time = ((self.ctx.playback_time - clip_instance.timeline_start) * clip_instance.playback_speed) + clip_instance.trim_start; @@ -886,7 +1034,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback { // Apply clip instance transform and camera transform let clip_transform = clip_instance.transform.to_affine(); - let combined_transform = camera_transform * clip_transform; + let combined_transform = overlay_transform * clip_transform; // Draw selection outline with different color for clip instances let clip_selection_color = Color::from_rgb8(255, 120, 0); // Orange @@ -943,7 +1091,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback { let marquee_fill = Color::from_rgba8(0, 120, 255, 100); scene.fill( Fill::NonZero, - camera_transform, + overlay_transform, marquee_fill, None, &marquee_rect, @@ -952,7 +1100,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback { // Border stroke scene.stroke( &Stroke::new(1.0), - camera_transform, + overlay_transform, selection_color, None, &marquee_rect, @@ -1006,7 +1154,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback { if width > 0.0 && height > 0.0 { let rect = KurboRect::new(0.0, 0.0, width, height); - let preview_transform = camera_transform * Affine::translate((position.x, position.y)); + let preview_transform = overlay_transform * Affine::translate((position.x, position.y)); if self.ctx.fill_enabled { let fill_color = Color::from_rgba8( @@ -1079,7 +1227,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback { }; if rx > 0.0 && ry > 0.0 { - let preview_transform = camera_transform * Affine::translate((position.x, position.y)); + let preview_transform = overlay_transform * Affine::translate((position.x, position.y)); let fill_color = Color::from_rgba8( self.ctx.fill_color.r(), @@ -1132,7 +1280,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback { let line = Line::new(*start_point, *current_point); scene.stroke( &Stroke::new(2.0), - camera_transform, + overlay_transform, stroke_color, None, &line, @@ -1151,7 +1299,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback { let radius = (dx * dx + dy * dy).sqrt(); if radius > 5.0 && num_sides >= 3 { - let preview_transform = camera_transform * Affine::translate((center.x, center.y)); + let preview_transform = overlay_transform * Affine::translate((center.x, center.y)); // Use actual fill color (same as final shape) let fill_color = Color::from_rgba8( @@ -1229,7 +1377,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback { ); scene.fill( Fill::NonZero, - camera_transform, + overlay_transform, fill_color, None, &preview_path, @@ -1245,7 +1393,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback { scene.stroke( &Stroke::new(self.ctx.stroke_width), - camera_transform, + overlay_transform, stroke_color, None, &preview_path, @@ -1261,10 +1409,10 @@ impl egui_wgpu::CallbackTrait for VelloCallback { let preview_path = rebuild_bezpath(&cache.editable_data); // Get the layer first, then the shape from the layer - if let Some(layer) = (*self.ctx.document).root.get_child(&cache.layer_id) { + if let Some(layer) = (*self.ctx.document).get_layer(&cache.layer_id) { if let lightningbeam_core::layer::AnyLayer::Vector(vector_layer) = layer { if let Some(shape) = vector_layer.get_shape_in_keyframe(&cache.shape_id, self.ctx.playback_time) { - let transform = camera_transform * cache.local_to_world; + let transform = overlay_transform * cache.local_to_world; // Render fill with FULL OPACITY (same as original) if let Some(fill_color) = &shape.fill_color { @@ -1389,7 +1537,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback { scene.stroke( &Stroke::new(stroke_width), - camera_transform, + overlay_transform, handle_color, None, &bbox_path, @@ -1407,7 +1555,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback { // Fill scene.fill( Fill::NonZero, - camera_transform, + overlay_transform, handle_color, None, &handle_rect, @@ -1416,7 +1564,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback { // White outline scene.stroke( &Stroke::new(1.0), - camera_transform, + overlay_transform, Color::from_rgb8(255, 255, 255), None, &handle_rect, @@ -1437,7 +1585,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback { // Fill scene.fill( Fill::NonZero, - camera_transform, + overlay_transform, handle_color, None, &edge_circle, @@ -1446,7 +1594,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback { // White outline scene.stroke( &Stroke::new(1.0), - camera_transform, + overlay_transform, Color::from_rgb8(255, 255, 255), None, &edge_circle, @@ -1471,7 +1619,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback { // Fill with different color (green) scene.fill( Fill::NonZero, - camera_transform, + overlay_transform, Color::from_rgb8(50, 200, 50), None, &rotation_circle, @@ -1480,7 +1628,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback { // White outline scene.stroke( &Stroke::new(1.0), - camera_transform, + overlay_transform, Color::from_rgb8(255, 255, 255), None, &rotation_circle, @@ -1496,7 +1644,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback { scene.stroke( &Stroke::new(1.0), - camera_transform, + overlay_transform, Color::from_rgb8(50, 200, 50), None, &line_path, @@ -1526,7 +1674,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback { let handle_color = Color::from_rgb8(0, 120, 255); let rotation_handle_offset = 20.0 / self.ctx.zoom.max(0.5) as f64; - scene.stroke(&Stroke::new(stroke_width), camera_transform, handle_color, None, &bbox); + scene.stroke(&Stroke::new(stroke_width), overlay_transform, handle_color, None, &bbox); let corners = [ vello::kurbo::Point::new(bbox.x0, bbox.y0), @@ -1540,8 +1688,8 @@ impl egui_wgpu::CallbackTrait for VelloCallback { corner.x - handle_size / 2.0, corner.y - handle_size / 2.0, corner.x + handle_size / 2.0, corner.y + handle_size / 2.0, ); - scene.fill(Fill::NonZero, camera_transform, handle_color, None, &handle_rect); - scene.stroke(&Stroke::new(1.0), camera_transform, Color::from_rgb8(255, 255, 255), None, &handle_rect); + scene.fill(Fill::NonZero, overlay_transform, handle_color, None, &handle_rect); + scene.stroke(&Stroke::new(1.0), overlay_transform, Color::from_rgb8(255, 255, 255), None, &handle_rect); } let edges = [ @@ -1553,14 +1701,14 @@ impl egui_wgpu::CallbackTrait for VelloCallback { for edge in &edges { let edge_circle = Circle::new(*edge, handle_size / 2.0); - scene.fill(Fill::NonZero, camera_transform, handle_color, None, &edge_circle); - scene.stroke(&Stroke::new(1.0), camera_transform, Color::from_rgb8(255, 255, 255), None, &edge_circle); + scene.fill(Fill::NonZero, overlay_transform, handle_color, None, &edge_circle); + scene.stroke(&Stroke::new(1.0), overlay_transform, Color::from_rgb8(255, 255, 255), None, &edge_circle); } let rotation_handle_pos = vello::kurbo::Point::new(bbox.center().x, bbox.y0 - rotation_handle_offset); let rotation_circle = Circle::new(rotation_handle_pos, handle_size / 2.0); - scene.fill(Fill::NonZero, camera_transform, Color::from_rgb8(50, 200, 50), None, &rotation_circle); - scene.stroke(&Stroke::new(1.0), camera_transform, Color::from_rgb8(255, 255, 255), None, &rotation_circle); + scene.fill(Fill::NonZero, overlay_transform, Color::from_rgb8(50, 200, 50), None, &rotation_circle); + scene.stroke(&Stroke::new(1.0), overlay_transform, Color::from_rgb8(255, 255, 255), None, &rotation_circle); let line_path = { let mut path = vello::kurbo::BezPath::new(); @@ -1568,7 +1716,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback { path.line_to(vello::kurbo::Point::new(bbox.center().x, bbox.y0)); path }; - scene.stroke(&Stroke::new(1.0), camera_transform, Color::from_rgb8(50, 200, 50), None, &line_path); + scene.stroke(&Stroke::new(1.0), overlay_transform, Color::from_rgb8(50, 200, 50), None, &line_path); } } } @@ -1660,7 +1808,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback { scene.stroke( &Stroke::new(stroke_width), - camera_transform, + overlay_transform, handle_color, None, &bbox_path, @@ -1678,7 +1826,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback { // Fill scene.fill( Fill::NonZero, - camera_transform, + overlay_transform, handle_color, None, &handle_rect, @@ -1687,7 +1835,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback { // White outline scene.stroke( &Stroke::new(1.0), - camera_transform, + overlay_transform, Color::from_rgb8(255, 255, 255), None, &handle_rect, @@ -1708,7 +1856,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback { // Fill scene.fill( Fill::NonZero, - camera_transform, + overlay_transform, handle_color, None, &edge_circle, @@ -1717,7 +1865,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback { // White outline scene.stroke( &Stroke::new(1.0), - camera_transform, + overlay_transform, Color::from_rgb8(255, 255, 255), None, &edge_circle, @@ -1740,7 +1888,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback { // Fill with different color (green) scene.fill( Fill::NonZero, - camera_transform, + overlay_transform, Color::from_rgb8(50, 200, 50), None, &rotation_circle, @@ -1749,7 +1897,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback { // White outline scene.stroke( &Stroke::new(1.0), - camera_transform, + overlay_transform, Color::from_rgb8(255, 255, 255), None, &rotation_circle, @@ -1765,7 +1913,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback { scene.stroke( &Stroke::new(1.0), - camera_transform, + overlay_transform, Color::from_rgb8(50, 200, 50), None, &line_path, @@ -2075,6 +2223,50 @@ impl StagePane { } } + /// Convert a document-space position to clip-local coordinates when editing inside a clip. + /// Returns the position unchanged when at root level. + fn doc_to_clip_local(&self, doc_pos: egui::Vec2, shared: &SharedPaneState) -> egui::Vec2 { + if let (Some(parent_layer_id), Some(instance_id)) = (shared.editing_parent_layer_id, shared.editing_instance_id) { + let document = shared.action_executor.document(); + let clip_affine = document.get_layer(&parent_layer_id) + .and_then(|layer| { + if let lightningbeam_core::layer::AnyLayer::Vector(vl) = layer { + vl.clip_instances.iter().find(|ci| ci.id == instance_id) + } else { + None + } + }) + .map(|ci| ci.transform.to_affine()) + .unwrap_or(vello::kurbo::Affine::IDENTITY); + let inv = clip_affine.inverse(); + let p = inv * vello::kurbo::Point::new(doc_pos.x as f64, doc_pos.y as f64); + egui::vec2(p.x as f32, p.y as f32) + } else { + doc_pos + } + } + + /// Convert a clip-local position back to document-space coordinates. + /// Returns the position unchanged when at root level. + fn clip_local_to_doc(&self, local_pos: vello::kurbo::Point, shared: &SharedPaneState) -> vello::kurbo::Point { + if let (Some(parent_layer_id), Some(instance_id)) = (shared.editing_parent_layer_id, shared.editing_instance_id) { + let document = shared.action_executor.document(); + let clip_affine = document.get_layer(&parent_layer_id) + .and_then(|layer| { + if let lightningbeam_core::layer::AnyLayer::Vector(vl) = layer { + vl.clip_instances.iter().find(|ci| ci.id == instance_id) + } else { + None + } + }) + .map(|ci| ci.transform.to_affine()) + .unwrap_or(vello::kurbo::Affine::IDENTITY); + clip_affine * local_pos + } else { + local_pos + } + } + /// Execute a view action with the given parameters /// Called from main.rs after determining this is the best handler pub fn execute_view_action(&mut self, action: &crate::menu::MenuAction, zoom_center: egui::Vec2) { @@ -2185,6 +2377,41 @@ impl StagePane { let point = Point::new(world_pos.x as f64, world_pos.y as f64); + // Double-click: enter/exit movie clip editing + if response.double_clicked() { + // Hit test clip instances at the click position + let document = shared.action_executor.document(); + let clip_hit = hit_test::hit_test_clip_instances( + &vector_layer.clip_instances, + document, + point, + Affine::IDENTITY, + *shared.playback_time, + ); + + if let Some(instance_id) = clip_hit { + // Find the clip instance to get its clip_id + if let Some(clip_instance) = vector_layer.clip_instances.iter().find(|ci| ci.id == instance_id) { + // Check if this is a movie clip (not a group) + if let Some(vector_clip) = document.get_vector_clip(&clip_instance.clip_id) { + if !vector_clip.is_group { + // Enter the movie clip + *shared.pending_enter_clip = Some(( + clip_instance.clip_id, + instance_id, + active_layer_id, + )); + return; + } + } + } + } else if shared.editing_clip_id.is_some() { + // Double-click on empty space while inside a clip: exit + *shared.pending_exit_clip = true; + return; + } + } + // Mouse down: start interaction (check on initial press, not after drag starts) // Scope this section to drop vector_layer borrow before drag handling let mouse_pressed = ui.input(|i| i.pointer.primary_pressed()); @@ -5419,7 +5646,8 @@ impl StagePane { // Get last known mouse position (will be at edge if offscreen) if let Some(mouse_pos) = ui.input(|i| i.pointer.latest_pos()) { let mouse_canvas_pos = mouse_pos - rect.min; - let world_pos = (mouse_canvas_pos - self.pan_offset) / self.zoom; + let world_pos_doc = (mouse_canvas_pos - self.pan_offset) / self.zoom; + let world_pos = self.doc_to_clip_local(world_pos_doc, shared); let point = Point::new(world_pos.x as f64, world_pos.y as f64); let delta = point - start_mouse; @@ -5548,7 +5776,9 @@ impl StagePane { let mouse_canvas_pos = mouse_pos - rect.min; // Convert screen position to world position (accounting for pan and zoom) - let world_pos = (mouse_canvas_pos - self.pan_offset) / self.zoom; + // When inside a clip, further transform to clip-local coordinates + let world_pos_doc = (mouse_canvas_pos - self.pan_offset) / self.zoom; + let world_pos = self.doc_to_clip_local(world_pos_doc, shared); // Handle tool input (only if not using Alt modifier for panning) if !alt_held { @@ -5678,18 +5908,22 @@ impl StagePane { _ => return, }; - // Get mouse position in world coordinates + // Get mouse position in world coordinates (clip-local when inside a clip) let mouse_screen_pos = ui.input(|i| i.pointer.hover_pos()).unwrap_or(rect.center()); let mouse_canvas_pos = mouse_screen_pos - rect.min; - let mouse_world_pos = Point::new( - ((mouse_canvas_pos.x - self.pan_offset.x) / self.zoom) as f64, - ((mouse_canvas_pos.y - self.pan_offset.y) / self.zoom) as f64, + let mouse_doc_pos = egui::vec2( + (mouse_canvas_pos.x - self.pan_offset.x) / self.zoom, + (mouse_canvas_pos.y - self.pan_offset.y) / self.zoom, ); + let mouse_local = self.doc_to_clip_local(mouse_doc_pos, shared); + let mouse_world_pos = Point::new(mouse_local.x as f64, mouse_local.y as f64); - // Helper to convert world coordinates to screen coordinates + // Helper to convert world coordinates (clip-local) to screen coordinates let world_to_screen = |world_pos: Point| -> egui::Pos2 { - let screen_x = (world_pos.x as f32 * self.zoom) + self.pan_offset.x + rect.min.x; - let screen_y = (world_pos.y as f32 * self.zoom) + self.pan_offset.y + rect.min.y; + // When inside a clip, first transform from clip-local to document space + let doc_pos = self.clip_local_to_doc(world_pos, shared); + let screen_x = (doc_pos.x as f32 * self.zoom) + self.pan_offset.x + rect.min.x; + let screen_y = (doc_pos.y as f32 * self.zoom) + self.pan_offset.y + rect.min.y; egui::pos2(screen_x, screen_y) }; @@ -6254,12 +6488,13 @@ impl PaneRenderer for StagePane { } } - // Calculate drag delta for preview rendering (world space) + // Calculate drag delta for preview rendering (clip-local space) let drag_delta = if let lightningbeam_core::tool::ToolState::DraggingSelection { ref start_mouse, .. } = shared.tool_state { - // Get current mouse position in world coordinates + // Get current mouse position in clip-local coordinates (matching start_mouse) if let Some(mouse_pos) = ui.input(|i| i.pointer.hover_pos()) { let mouse_canvas_pos = mouse_pos - rect.min; - let world_mouse = (mouse_canvas_pos - self.pan_offset) / self.zoom; + let world_mouse_doc = (mouse_canvas_pos - self.pan_offset) / self.zoom; + let world_mouse = self.doc_to_clip_local(world_mouse_doc, shared); let delta_x = world_mouse.x as f64 - start_mouse.x; let delta_y = world_mouse.y as f64 - start_mouse.y; @@ -6294,6 +6529,9 @@ impl PaneRenderer for StagePane { video_manager: shared.video_manager.clone(), shape_editing_cache: self.shape_editing_cache.clone(), target_format: shared.target_format, + editing_clip_id: shared.editing_clip_id, + editing_instance_id: shared.editing_instance_id, + editing_parent_layer_id: shared.editing_parent_layer_id, }}; let cb = egui_wgpu::Callback::new_paint_callback( @@ -6313,6 +6551,63 @@ impl PaneRenderer for StagePane { egui::Color32::from_gray(200), ); + // Render breadcrumb navigation when inside a movie clip + if shared.editing_clip_id.is_some() { + let document = shared.action_executor.document(); + // Build breadcrumb names from the editing context + // We only have the current clip_id, so show "Scene 1 > ClipName" + let clip_name = shared.editing_clip_id + .and_then(|id| document.get_vector_clip(&id)) + .map(|c| c.name.clone()) + .unwrap_or_else(|| "Unknown".to_string()); + + let breadcrumb_y = rect.min.y + 30.0; + let breadcrumb_x = rect.min.x + 10.0; + + // Background pill + let scene_text = "Scene 1"; + let separator = " > "; + let full_text = format!("{}{}{}", scene_text, separator, clip_name); + let font = egui::FontId::proportional(13.0); + let galley = ui.painter().layout_no_wrap(full_text.clone(), font.clone(), egui::Color32::WHITE); + let text_rect = egui::Rect::from_min_size( + egui::pos2(breadcrumb_x, breadcrumb_y), + galley.size() + egui::vec2(16.0, 8.0), + ); + ui.painter().rect_filled( + text_rect, + 4.0, + egui::Color32::from_rgba_unmultiplied(0, 0, 0, 180), + ); + + // "Scene 1" as clickable (exit clip) + let scene_galley = ui.painter().layout_no_wrap( + scene_text.to_string(), font.clone(), egui::Color32::from_rgb(120, 180, 255), + ); + let scene_rect = egui::Rect::from_min_size( + egui::pos2(breadcrumb_x + 8.0, breadcrumb_y + 4.0), + scene_galley.size(), + ); + let scene_response = ui.allocate_rect(scene_rect, egui::Sense::click()); + ui.painter().galley(scene_rect.min, scene_galley, egui::Color32::WHITE); + if scene_response.clicked() { + *shared.pending_exit_clip = true; + } + if scene_response.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + + // Separator + clip name (not clickable, it's the current level) + let rest_text = format!("{}{}", separator, clip_name); + ui.painter().text( + egui::pos2(scene_rect.max.x, breadcrumb_y + 4.0), + egui::Align2::LEFT_TOP, + rest_text, + font, + egui::Color32::WHITE, + ); + } + // Render vector editing overlays (vertices, control points, etc.) self.render_vector_editing_overlays(ui, rect, shared); diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs index 88efbb4..80a32ca 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs @@ -54,7 +54,8 @@ fn effective_clip_duration( let end = vl.group_visibility_end(&clip_instance.id, clip_instance.timeline_start, frame_duration); Some((end - clip_instance.timeline_start).max(0.0)) } else { - Some(vc.duration) + // Movie clips: duration based on internal keyframe content + Some(vc.content_duration(document.framerate)) } } AnyLayer::Audio(_) => document.get_audio_clip(&clip_instance.clip_id).map(|c| c.duration), @@ -130,13 +131,15 @@ fn find_sampled_audio_track_for_clip( document: &lightningbeam_core::document::Document, clip_id: uuid::Uuid, timeline_start: f64, + editing_clip_id: Option<&uuid::Uuid>, ) -> Option { // Get the clip duration let clip_duration = document.get_clip_duration(&clip_id)?; let clip_end = timeline_start + clip_duration; // Check each sampled audio layer - for layer in &document.root.children { + let context_layers = document.context_layers(editing_clip_id); + for &layer in &context_layers { if let AnyLayer::Audio(audio_layer) = layer { if audio_layer.audio_layer_type == AudioLayerType::Sampled { // Check if there's any overlap with existing clips on this layer @@ -213,7 +216,8 @@ impl TimelinePane { // Get layer type (copy it so we can drop the document borrow before mutating) let layer_type = { let document = shared.action_executor.document(); - let Some(layer) = document.root.children.iter().find(|l| l.id() == active_layer_id) else { + let context_layers = document.context_layers(shared.editing_clip_id.as_ref()); + let Some(layer) = context_layers.iter().copied().find(|l| l.id() == active_layer_id) else { println!("⚠️ Active layer not found in document"); return; }; @@ -295,7 +299,8 @@ impl TimelinePane { fn stop_recording(&mut self, shared: &mut SharedPaneState) { // Determine if this is MIDI or audio recording by checking the layer type let is_midi_recording = if let Some(layer_id) = *shared.recording_layer_id { - shared.action_executor.document().root.children.iter() + let context_layers = shared.action_executor.document().context_layers(shared.editing_clip_id.as_ref()); + context_layers.iter().copied() .find(|l| l.id() == layer_id) .map(|layer| { if let lightningbeam_core::layer::AnyLayer::Audio(audio_layer) = layer { @@ -337,8 +342,10 @@ impl TimelinePane { document: &lightningbeam_core::document::Document, content_rect: egui::Rect, header_rect: egui::Rect, + editing_clip_id: Option<&uuid::Uuid>, ) -> Option<(ClipDragType, uuid::Uuid)> { - let layer_count = document.root.children.len(); + let context_layers = document.context_layers(editing_clip_id); + let layer_count = context_layers.len(); // Check if pointer is in valid area if pointer_pos.y < header_rect.min.y { @@ -355,8 +362,8 @@ impl TimelinePane { return None; } - let layers: Vec<_> = document.root.children.iter().rev().collect(); - let layer = layers.get(hovered_layer_index)?; + let rev_layers: Vec<&lightningbeam_core::layer::AnyLayer> = context_layers.iter().rev().copied().collect(); + let layer = rev_layers.get(hovered_layer_index)?; let _layer_data = layer.layer(); let clip_instances = match layer { @@ -711,7 +718,8 @@ impl TimelinePane { theme: &crate::theme::Theme, active_layer_id: &Option, pending_actions: &mut Vec>, - document: &lightningbeam_core::document::Document, + _document: &lightningbeam_core::document::Document, + context_layers: &[&lightningbeam_core::layer::AnyLayer], ) { // Background for header column let header_style = theme.style(".timeline-header", ui.ctx()); @@ -734,7 +742,8 @@ impl TimelinePane { let secondary_text_color = egui::Color32::from_gray(150); // Draw layer headers from document (reversed so newest layers appear on top) - for (i, layer) in document.root.children.iter().rev().enumerate() { + for (i, layer) in context_layers.iter().rev().enumerate() { + let layer = *layer; let y = rect.min.y + i as f32 * LAYER_HEIGHT - self.viewport_scroll_y; // Skip if layer is outside visible area @@ -993,6 +1002,7 @@ impl TimelinePane { waveform_gpu_dirty: &mut std::collections::HashSet, target_format: wgpu::TextureFormat, waveform_stereo: bool, + context_layers: &[&lightningbeam_core::layer::AnyLayer], ) -> Vec<(egui::Rect, uuid::Uuid, f64, f64)> { let painter = ui.painter(); @@ -1014,7 +1024,8 @@ impl TimelinePane { } // Draw layer rows from document (reversed so newest layers appear on top) - for (i, layer) in document.root.children.iter().rev().enumerate() { + for (i, layer) in context_layers.iter().rev().enumerate() { + let layer = *layer; let y = rect.min.y + i as f32 * LAYER_HEIGHT - self.viewport_scroll_y; // Skip if layer is outside visible area @@ -1719,6 +1730,8 @@ impl TimelinePane { playback_time: &mut f64, _is_playing: &mut bool, audio_controller: Option<&std::sync::Arc>>, + context_layers: &[&lightningbeam_core::layer::AnyLayer], + editing_clip_id: Option<&uuid::Uuid>, ) { // Don't allocate the header area for input - let widgets handle it directly // Only allocate content area (ruler + layers) with click and drag @@ -1761,7 +1774,7 @@ impl TimelinePane { 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(); + let layers: Vec<_> = context_layers.iter().rev().copied().collect(); if let Some(layer) = layers.get(clicked_layer_index) { let _layer_data = layer.layer(); @@ -1828,7 +1841,7 @@ impl TimelinePane { // 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(); + let layers: Vec<_> = context_layers.iter().rev().copied().collect(); if let Some(layer) = layers.get(clicked_layer_index) { *active_layer_id = Some(layer.id()); } @@ -1853,6 +1866,7 @@ impl TimelinePane { document, content_rect, header_rect, + editing_clip_id, ) { // If this clip is not selected, select it (respecting shift key) if !selection.contains_clip_instance(&clip_id) { @@ -1886,7 +1900,7 @@ impl TimelinePane { HashMap::new(); // Iterate through all layers to find selected clip instances - for layer in &document.root.children { + for &layer in context_layers { let layer_id = layer.id(); // Get clip instances for this layer @@ -1937,7 +1951,7 @@ impl TimelinePane { > = HashMap::new(); // Iterate through all layers to find selected clip instances - for layer in &document.root.children { + for &layer in context_layers { let layer_id = layer.id(); let _layer_data = layer.layer(); @@ -2078,7 +2092,7 @@ impl TimelinePane { ClipDragType::LoopExtendRight => { let mut layer_loops: HashMap> = HashMap::new(); - for layer in &document.root.children { + for &layer in context_layers { let layer_id = layer.id(); let clip_instances = match layer { lightningbeam_core::layer::AnyLayer::Vector(vl) => &vl.clip_instances, @@ -2150,7 +2164,7 @@ impl TimelinePane { // Extend loop_before (pre-loop region) let mut layer_loops: HashMap> = HashMap::new(); - for layer in &document.root.children { + for &layer in context_layers { let layer_id = layer.id(); let clip_instances = match layer { lightningbeam_core::layer::AnyLayer::Vector(vl) => &vl.clip_instances, @@ -2242,7 +2256,7 @@ impl TimelinePane { // 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(); + let layers: Vec<_> = context_layers.iter().rev().copied().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 @@ -2387,6 +2401,7 @@ impl TimelinePane { document, content_rect, header_rect, + editing_clip_id, ) { match drag_type { ClipDragType::TrimLeft | ClipDragType::TrimRight => { @@ -2535,11 +2550,13 @@ impl PaneRenderer for TimelinePane { // Get document from action executor let document = shared.action_executor.document(); - let layer_count = document.root.children.len(); + let editing_clip_id = shared.editing_clip_id; + let context_layers = document.context_layers(editing_clip_id.as_ref()); + let layer_count = context_layers.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 { + for &layer in &context_layers { let clip_instances = match layer { lightningbeam_core::layer::AnyLayer::Vector(vl) => &vl.clip_instances, lightningbeam_core::layer::AnyLayer::Audio(al) => &al.clip_instances, @@ -2606,7 +2623,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, shared.active_layer_id, &mut shared.pending_actions, document); + self.render_layer_headers(ui, layer_headers_rect, shared.theme, shared.active_layer_id, &mut shared.pending_actions, document, &context_layers); // Render time ruler (clip to ruler rect) ui.set_clip_rect(ruler_rect.intersect(original_clip_rect)); @@ -2614,7 +2631,7 @@ impl PaneRenderer for TimelinePane { // Render layer rows with clipping ui.set_clip_rect(content_rect.intersect(original_clip_rect)); - let video_clip_hovers = self.render_layers(ui, content_rect, shared.theme, document, shared.active_layer_id, shared.selection, shared.midi_event_cache, shared.raw_audio_cache, shared.waveform_gpu_dirty, shared.target_format, shared.waveform_stereo); + let video_clip_hovers = self.render_layers(ui, content_rect, shared.theme, document, shared.active_layer_id, shared.selection, shared.midi_event_cache, shared.raw_audio_cache, shared.waveform_gpu_dirty, shared.target_format, shared.waveform_stereo, &context_layers); // Render playhead on top (clip to timeline area) ui.set_clip_rect(timeline_rect.intersect(original_clip_rect)); @@ -2638,6 +2655,8 @@ impl PaneRenderer for TimelinePane { shared.playback_time, shared.is_playing, shared.audio_controller, + &context_layers, + editing_clip_id.as_ref(), ); // Context menu: detect right-click on clips or empty timeline space @@ -2646,7 +2665,7 @@ impl PaneRenderer for TimelinePane { if secondary_clicked { if let Some(pos) = ui.input(|i| i.pointer.interact_pos()) { if content_rect.contains(pos) { - if let Some((_drag_type, clip_id)) = self.detect_clip_at_pointer(pos, document, content_rect, layer_headers_rect) { + if let Some((_drag_type, clip_id)) = self.detect_clip_at_pointer(pos, document, content_rect, layer_headers_rect, editing_clip_id.as_ref()) { // Right-clicked on a clip if !shared.selection.contains_clip_instance(&clip_id) { shared.selection.select_only_clip_instance(clip_id); @@ -2934,7 +2953,7 @@ impl PaneRenderer for TimelinePane { let hovered_layer_index = (relative_y / LAYER_HEIGHT) as usize; // Get the layer at this index (accounting for reversed display order) - let layers: Vec<_> = document.root.children.iter().rev().collect(); + let layers: Vec<_> = context_layers.iter().rev().copied().collect(); if let Some(layer) = layers.get(hovered_layer_index) { let is_compatible = can_drop_on_layer(layer, dragging.clip_type); @@ -3077,7 +3096,7 @@ impl PaneRenderer for TimelinePane { // Find or create sampled audio track where the audio won't overlap let audio_layer_id = { let doc = shared.action_executor.document(); - let result = find_sampled_audio_track_for_clip(doc, linked_audio_clip_id, drop_time); + let result = find_sampled_audio_track_for_clip(doc, linked_audio_clip_id, drop_time, editing_clip_id.as_ref()); if let Some(id) = result { eprintln!("DEBUG: Found existing audio track without overlap: {}", id); } else { diff --git a/lightningbeam-ui/lightningbeam-editor/src/sample_import.rs b/lightningbeam-ui/lightningbeam-editor/src/sample_import.rs index 4b9f553..21dd89c 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/sample_import.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/sample_import.rs @@ -51,17 +51,6 @@ fn parse_note_letter(s: &str) -> Option<(u8, usize)> { } } -/// Convert a note name like "C4", "A#3", "Bb2" to a MIDI note number. -pub fn note_name_to_midi(note: &str, octave: i8) -> Option { - let (semitone, _) = parse_note_letter(note)?; - let midi = (octave as i32 + 1) * 12 + semitone as i32; - if (0..=127).contains(&midi) { - Some(midi as u8) - } else { - None - } -} - /// Format a MIDI note number as a note name (e.g., 60 → "C4"). pub fn midi_to_note_name(midi: u8) -> String { const NAMES: [&str; 12] = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]; @@ -214,8 +203,7 @@ fn tokenize(stem: &str) -> Vec<&str> { } /// Parse a sample filename to extract note, velocity, round-robin, and loop hint info. -/// `folder_path` is used for loop/articulation context from parent directory names. -pub fn parse_sample_filename(path: &Path, folder_path: &Path) -> ParsedSample { +pub fn parse_sample_filename(path: &Path) -> ParsedSample { let filename = path.file_name() .map(|n| n.to_string_lossy().to_string()) .unwrap_or_default(); @@ -390,7 +378,7 @@ pub fn scan_folder(folder_path: &Path) -> std::io::Result> { collect_audio_files(folder_path, &mut files)?; let mut samples: Vec = files.iter() - .map(|path| parse_sample_filename(path, folder_path)) + .map(|path| parse_sample_filename(path)) .collect(); // Percussion pass: for samples with no detected note, try GM drum mapping @@ -467,7 +455,6 @@ pub struct FolderScanResult { pub loop_mode: LoopMode, pub velocity_markers: Vec, pub velocity_ranges: Vec<(String, u8, u8)>, - pub detected_articulation: Option, } /// Compute auto key ranges for a sorted list of unique MIDI notes. @@ -533,28 +520,9 @@ fn detect_global_loop_mode(samples: &[ParsedSample]) -> LoopMode { } } -/// Detect articulation from folder path. -fn detect_articulation(folder_path: &Path) -> Option { - for component in folder_path.components().rev() { - if let std::path::Component::Normal(name) = component { - let lower = name.to_string_lossy().to_lowercase(); - match lower.as_str() { - "sustain" | "vibrato" | "tremolo" | "pizzicato" | "staccato" | - "legato" | "marcato" | "spiccato" | "arco" => { - return Some(name.to_string_lossy().to_string()); - } - _ => {} - } - } - } - None -} - /// Build import layers from parsed samples with auto key ranges and velocity mapping. -pub fn build_import_layers(samples: Vec, folder_path: &Path) -> FolderScanResult { +pub fn build_import_layers(samples: Vec) -> FolderScanResult { let loop_mode = detect_global_loop_mode(&samples); - let detected_articulation = detect_articulation(folder_path); - // Separate mapped vs unmapped let mut mapped: Vec = Vec::new(); let mut unmapped: Vec = Vec::new(); @@ -623,7 +591,6 @@ pub fn build_import_layers(samples: Vec, folder_path: &Path) -> Fo loop_mode, velocity_markers, velocity_ranges, - detected_articulation, } } @@ -662,13 +629,13 @@ mod tests { use super::*; #[test] - fn test_note_name_to_midi() { - assert_eq!(note_name_to_midi("C", 4), Some(60)); - assert_eq!(note_name_to_midi("A", 4), Some(69)); - assert_eq!(note_name_to_midi("A#", 3), Some(58)); - assert_eq!(note_name_to_midi("Bb", 2), Some(46)); - assert_eq!(note_name_to_midi("C", -1), Some(0)); - assert_eq!(note_name_to_midi("G", 9), Some(127)); + fn test_try_note_octave() { + assert_eq!(try_note_octave("C4"), Some(60)); + assert_eq!(try_note_octave("A4"), Some(69)); + assert_eq!(try_note_octave("A#3"), Some(58)); + assert_eq!(try_note_octave("Bb2"), Some(46)); + assert_eq!(try_note_octave("C-1"), Some(0)); + assert_eq!(try_note_octave("G9"), Some(127)); } #[test] @@ -676,7 +643,6 @@ mod tests { // Horns: horns-sus-ff-a#2-PB-loop.wav let p = parse_sample_filename( Path::new("/samples/horns-sus-ff-a#2-PB-loop.wav"), - Path::new("/samples"), ); assert_eq!(p.detected_note, Some(46)); // A#2 assert_eq!(p.velocity_marker, Some("ff".to_string())); @@ -685,7 +651,6 @@ mod tests { // Philharmonia: viola_A#3-staccato-rr1-PB.wav let p = parse_sample_filename( Path::new("/samples/viola_A#3-staccato-rr1-PB.wav"), - Path::new("/samples"), ); assert_eq!(p.detected_note, Some(58)); // A#3 assert_eq!(p.rr_index, Some(1)); @@ -694,7 +659,6 @@ mod tests { // Bare note: A1.mp3 let p = parse_sample_filename( Path::new("/samples/A1.mp3"), - Path::new("/samples"), ); assert_eq!(p.detected_note, Some(33)); // A1 } @@ -704,21 +668,18 @@ mod tests { // NoBudgetOrch: 2_A-PB.wav let p = parse_sample_filename( Path::new("/samples/2_A-PB.wav"), - Path::new("/samples"), ); assert_eq!(p.detected_note, Some(45)); // A2 // 3_Gb-PB.wav let p = parse_sample_filename( Path::new("/samples/3_Gb-PB.wav"), - Path::new("/samples"), ); assert_eq!(p.detected_note, Some(54)); // Gb3 // 1_Bb.wav let p = parse_sample_filename( Path::new("/samples/1_Bb.wav"), - Path::new("/samples"), ); assert_eq!(p.detected_note, Some(34)); // Bb1 } @@ -728,7 +689,6 @@ mod tests { // NoBudgetOrch TubularBells: 3_A_f.wav let p = parse_sample_filename( Path::new("/samples/3_A_f.wav"), - Path::new("/samples"), ); assert_eq!(p.detected_note, Some(57)); // A3 assert_eq!(p.velocity_marker, Some("f".to_string())); @@ -736,7 +696,6 @@ mod tests { // 3_C_p.wav let p = parse_sample_filename( Path::new("/samples/3_C_p.wav"), - Path::new("/samples"), ); assert_eq!(p.detected_note, Some(48)); // C3 assert_eq!(p.velocity_marker, Some("p".to_string())); @@ -747,7 +706,6 @@ mod tests { // NoBudgetOrch: 5_C_2-PB.wav → C5, rr2 let p = parse_sample_filename( Path::new("/samples/5_C_2-PB.wav"), - Path::new("/samples"), ); assert_eq!(p.detected_note, Some(72)); // C5 assert_eq!(p.rr_index, Some(2)); @@ -755,7 +713,6 @@ mod tests { // rr marker: viola_A#3-staccato-rr1-PB.wav let p = parse_sample_filename( Path::new("/samples/viola_A#3-staccato-rr1-PB.wav"), - Path::new("/samples"), ); assert_eq!(p.rr_index, Some(1)); } @@ -764,13 +721,11 @@ mod tests { fn test_loop_hints_from_folder() { let p = parse_sample_filename( Path::new("/libs/Cello/Sustain/2_A.wav"), - Path::new("/libs/Cello/Sustain"), ); assert_eq!(p.loop_hint, LoopHint::Loop); let p = parse_sample_filename( Path::new("/libs/Cello/Pizzicato/2_A-PB.wav"), - Path::new("/libs/Cello/Pizzicato"), ); assert_eq!(p.loop_hint, LoopHint::OneShot); } diff --git a/lightningbeam-ui/lightningbeam-editor/src/sample_import_dialog.rs b/lightningbeam-ui/lightningbeam-editor/src/sample_import_dialog.rs index dcf258b..42b7150 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/sample_import_dialog.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/sample_import_dialog.rs @@ -8,7 +8,7 @@ use egui_node_graph2::NodeId; use std::path::PathBuf; use crate::sample_import::{ - FolderScanResult, ImportLayer, midi_to_note_name, recalc_key_ranges, + FolderScanResult, midi_to_note_name, recalc_key_ranges, }; use daw_backend::audio::node_graph::nodes::LoopMode; @@ -234,9 +234,4 @@ impl SampleImportDialog { !self.should_close } - - /// Get the enabled layers for import. - pub fn enabled_layers(&self) -> Vec<&ImportLayer> { - self.scan_result.layers.iter().filter(|l| l.enabled).collect() - } }