diff --git a/daw-backend/src/audio/engine.rs b/daw-backend/src/audio/engine.rs index 35c2782..ee4ecaa 100644 --- a/daw-backend/src/audio/engine.rs +++ b/daw-backend/src/audio/engine.rs @@ -658,8 +658,8 @@ impl Engine { _ => {} } } - Command::CreateMetatrack(name) => { - let track_id = self.project.add_group_track(name.clone(), None); + Command::CreateMetatrack(name, parent_id) => { + let track_id = self.project.add_group_track(name.clone(), parent_id); // Notify UI about the new metatrack let _ = self.event_tx.push(AudioEvent::TrackCreated(track_id, true, name)); } @@ -686,8 +686,18 @@ impl Engine { metatrack.pitch_shift = semitones; } } - Command::CreateAudioTrack(name) => { - let track_id = self.project.add_audio_track(name.clone(), None); + Command::SetTrimStart(track_id, trim_start) => { + if let Some(crate::audio::track::TrackNode::Group(metatrack)) = self.project.get_track_mut(track_id) { + metatrack.trim_start = trim_start.max(0.0); + } + } + Command::SetTrimEnd(track_id, trim_end) => { + if let Some(crate::audio::track::TrackNode::Group(metatrack)) = self.project.get_track_mut(track_id) { + metatrack.trim_end = trim_end.map(|t| t.max(0.0)); + } + } + Command::CreateAudioTrack(name, parent_id) => { + let track_id = self.project.add_audio_track(name.clone(), parent_id); // Notify UI about the new audio track let _ = self.event_tx.push(AudioEvent::TrackCreated(track_id, false, name)); } @@ -793,8 +803,8 @@ impl Engine { eprintln!("[Engine] ERROR: Track {} not found or is not an audio track", track_id); } } - Command::CreateMidiTrack(name) => { - let track_id = self.project.add_midi_track(name.clone(), None); + Command::CreateMidiTrack(name, parent_id) => { + let track_id = self.project.add_midi_track(name.clone(), parent_id); // Notify UI about the new MIDI track let _ = self.event_tx.push(AudioEvent::TrackCreated(track_id, false, name)); } @@ -2348,20 +2358,24 @@ impl Engine { QueryResponse::TrackGraphLoaded(result) } - Query::CreateAudioTrackSync(name) => { - let track_id = self.project.add_audio_track(name.clone(), None); - eprintln!("[Engine] Created audio track '{}' with ID {}", name, track_id); - // Notify UI about the new audio track + Query::CreateAudioTrackSync(name, parent_id) => { + let track_id = self.project.add_audio_track(name.clone(), parent_id); + eprintln!("[Engine] Created audio track '{}' with ID {} (parent: {:?})", name, track_id, parent_id); let _ = self.event_tx.push(AudioEvent::TrackCreated(track_id, false, name)); QueryResponse::TrackCreated(Ok(track_id)) } - Query::CreateMidiTrackSync(name) => { - let track_id = self.project.add_midi_track(name.clone(), None); - eprintln!("[Engine] Created MIDI track '{}' with ID {}", name, track_id); - // Notify UI about the new MIDI track + Query::CreateMidiTrackSync(name, parent_id) => { + let track_id = self.project.add_midi_track(name.clone(), parent_id); + eprintln!("[Engine] Created MIDI track '{}' with ID {} (parent: {:?})", name, track_id, parent_id); let _ = self.event_tx.push(AudioEvent::TrackCreated(track_id, false, name)); QueryResponse::TrackCreated(Ok(track_id)) } + Query::CreateMetatrackSync(name, parent_id) => { + let track_id = self.project.add_group_track(name.clone(), parent_id); + eprintln!("[Engine] Created metatrack '{}' with ID {} (parent: {:?})", name, track_id, parent_id); + let _ = self.event_tx.push(AudioEvent::TrackCreated(track_id, true, name)); + QueryResponse::TrackCreated(Ok(track_id)) + } Query::GetPoolWaveform(pool_index, target_peaks) => { match self.audio_pool.generate_waveform(pool_index, target_peaks) { Some(waveform) => QueryResponse::PoolWaveform(Ok(waveform)), @@ -2930,7 +2944,7 @@ impl EngineController { /// Create a new metatrack pub fn create_metatrack(&mut self, name: String) { - let _ = self.command_tx.push(Command::CreateMetatrack(name)); + let _ = self.command_tx.push(Command::CreateMetatrack(name, None)); } /// Add a track to a metatrack @@ -2960,9 +2974,19 @@ impl EngineController { let _ = self.command_tx.push(Command::SetPitchShift(track_id, semitones)); } + /// Set metatrack trim start in seconds + pub fn set_trim_start(&mut self, track_id: TrackId, trim_start: f64) { + let _ = self.command_tx.push(Command::SetTrimStart(track_id, trim_start)); + } + + /// Set metatrack trim end in seconds (None = no end trim) + pub fn set_trim_end(&mut self, track_id: TrackId, trim_end: Option) { + let _ = self.command_tx.push(Command::SetTrimEnd(track_id, trim_end)); + } + /// Create a new audio track pub fn create_audio_track(&mut self, name: String) { - let _ = self.command_tx.push(Command::CreateAudioTrack(name)); + let _ = self.command_tx.push(Command::CreateAudioTrack(name, None)); } /// Add an audio file to the pool (must be called from non-audio thread with pre-loaded data) @@ -3012,7 +3036,7 @@ impl EngineController { /// Create a new MIDI track pub fn create_midi_track(&mut self, name: String) { - let _ = self.command_tx.push(Command::CreateMidiTrack(name)); + let _ = self.command_tx.push(Command::CreateMidiTrack(name, None)); } /// Add a MIDI clip to the pool without placing it on any track @@ -3022,8 +3046,8 @@ impl EngineController { } /// Create a new audio track synchronously (waits for creation to complete) - pub fn create_audio_track_sync(&mut self, name: String) -> Result { - if let Err(_) = self.query_tx.push(Query::CreateAudioTrackSync(name)) { + pub fn create_audio_track_sync(&mut self, name: String, parent: Option) -> Result { + if let Err(_) = self.query_tx.push(Query::CreateAudioTrackSync(name, parent)) { return Err("Failed to send track creation query".to_string()); } @@ -3042,8 +3066,8 @@ impl EngineController { } /// Create a new MIDI track synchronously (waits for creation to complete) - pub fn create_midi_track_sync(&mut self, name: String) -> Result { - if let Err(_) = self.query_tx.push(Query::CreateMidiTrackSync(name)) { + pub fn create_midi_track_sync(&mut self, name: String, parent: Option) -> Result { + if let Err(_) = self.query_tx.push(Query::CreateMidiTrackSync(name, parent)) { return Err("Failed to send track creation query".to_string()); } @@ -3061,6 +3085,25 @@ impl EngineController { Err("Track creation timeout".to_string()) } + /// Create a new metatrack/group synchronously (waits for creation to complete) + pub fn create_group_track_sync(&mut self, name: String, parent: Option) -> Result { + if let Err(_) = self.query_tx.push(Query::CreateMetatrackSync(name, parent)) { + return Err("Failed to send metatrack creation query".to_string()); + } + + let start = std::time::Instant::now(); + let timeout = std::time::Duration::from_secs(2); + + while start.elapsed() < timeout { + if let Ok(QueryResponse::TrackCreated(result)) = self.query_response_rx.pop() { + return result; + } + std::thread::sleep(std::time::Duration::from_millis(1)); + } + + Err("Metatrack creation timeout".to_string()) + } + /// Create a new MIDI clip on a track pub fn create_midi_clip(&mut self, track_id: TrackId, start_time: f64, duration: f64) -> MidiClipId { // Peek at the next clip ID that will be used diff --git a/daw-backend/src/audio/project.rs b/daw-backend/src/audio/project.rs index 8e31bfa..ce497e1 100644 --- a/daw-backend/src/audio/project.rs +++ b/daw-backend/src/audio/project.rs @@ -82,7 +82,7 @@ impl Project { /// The new group's ID pub fn add_group_track(&mut self, name: String, parent_id: Option) -> TrackId { let id = self.next_id(); - let group = Metatrack::new(id, name); + let group = Metatrack::new(id, name, self.sample_rate); self.tracks.insert(id, TrackNode::Group(group)); if let Some(parent) = parent_id { @@ -450,6 +450,11 @@ impl Project { track.render(output, &self.midi_clip_pool, ctx.playhead_seconds, ctx.sample_rate, ctx.channels); } Some(TrackNode::Group(group)) => { + // Skip rendering if playhead is outside the metatrack's trim window + if !group.is_active_at_time(ctx.playhead_seconds) { + return; + } + // Read group properties and transform context (index-based child iteration to avoid clone) let num_children = group.children.len(); let this_group_is_soloed = group.solo; @@ -479,14 +484,37 @@ impl Project { ); } - // Apply group volume and mix into output + // Route children's mix through metatrack's audio graph if let Some(TrackNode::Group(group)) = self.tracks.get_mut(&track_id) { - for (out_sample, group_sample) in output.iter_mut().zip(group_buffer.iter()) { - *out_sample += group_sample * group.volume; + // Inject children's mix into audio graph's input node + let node_indices: Vec<_> = group.audio_graph.node_indices().collect(); + for node_idx in node_indices { + if let Some(graph_node) = group.audio_graph.get_graph_node_mut(node_idx) { + if graph_node.node.node_type() == "AudioInput" { + if let Some(input_node) = graph_node.node.as_any_mut() + .downcast_mut::() + { + input_node.inject_audio(&group_buffer); + break; + } + } + } } + + // Process through the audio graph into a fresh buffer + let mut graph_output = buffer_pool.acquire(); + graph_output.resize(output.len(), 0.0); + graph_output.fill(0.0); + group.audio_graph.process(&mut graph_output, &[], ctx.playhead_seconds); + + // Apply group volume and mix into output + for (out_sample, graph_sample) in output.iter_mut().zip(graph_output.iter()) { + *out_sample += graph_sample * group.volume; + } + buffer_pool.release(graph_output); } - // Release buffer back to pool + // Release children mix buffer back to pool buffer_pool.release(group_buffer); } None => {} @@ -581,8 +609,8 @@ impl Project { TrackNode::Midi(midi_track) => { midi_track.prepare_for_save(); } - TrackNode::Group(_) => { - // Groups don't have audio graphs + TrackNode::Group(group) => { + group.prepare_for_save(); } } } @@ -604,8 +632,8 @@ impl Project { TrackNode::Midi(midi_track) => { midi_track.rebuild_audio_graph(self.sample_rate, buffer_size)?; } - TrackNode::Group(_) => { - // Groups don't have audio graphs + TrackNode::Group(group) => { + group.rebuild_audio_graph(self.sample_rate, buffer_size)?; } } } diff --git a/daw-backend/src/audio/track.rs b/daw-backend/src/audio/track.rs index 6d69f45..bedaa13 100644 --- a/daw-backend/src/audio/track.rs +++ b/daw-backend/src/audio/track.rs @@ -153,7 +153,7 @@ impl TrackNode { } /// Metatrack that contains other tracks with time transformation capabilities -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize)] pub struct Metatrack { pub id: TrackId, pub name: String, @@ -167,14 +167,50 @@ pub struct Metatrack { pub pitch_shift: f32, /// Time offset in seconds (shift content forward/backward in time) pub offset: f64, + /// Trim start: offset into the metatrack's internal content (seconds) + /// Children will see time starting from this point + pub trim_start: f64, + /// Trim end: offset into the metatrack's internal content (seconds) + /// None means no end trim (play until content ends) + pub trim_end: Option, /// Automation lanes for this metatrack pub automation_lanes: HashMap, next_automation_id: AutomationLaneId, + /// Audio node graph for effects processing (input → output) + #[serde(skip, default = "default_audio_graph")] + pub audio_graph: AudioGraph, + /// Saved graph preset for serialization + audio_graph_preset: Option, +} + +impl Clone for Metatrack { + fn clone(&self) -> Self { + Self { + id: self.id, + name: self.name.clone(), + children: self.children.clone(), + volume: self.volume, + muted: self.muted, + solo: self.solo, + time_stretch: self.time_stretch, + pitch_shift: self.pitch_shift, + offset: self.offset, + trim_start: self.trim_start, + trim_end: self.trim_end, + automation_lanes: self.automation_lanes.clone(), + next_automation_id: self.next_automation_id, + audio_graph: default_audio_graph(), // Create fresh graph, not cloned + audio_graph_preset: self.audio_graph_preset.clone(), + } + } } impl Metatrack { - /// Create a new metatrack - pub fn new(id: TrackId, name: String) -> Self { + /// Create a new metatrack with an audio graph (input → output) + pub fn new(id: TrackId, name: String, sample_rate: u32) -> Self { + let default_buffer_size = 8192; + let audio_graph = Self::create_default_graph(sample_rate, default_buffer_size); + Self { id, name, @@ -185,11 +221,52 @@ impl Metatrack { time_stretch: 1.0, pitch_shift: 0.0, offset: 0.0, + trim_start: 0.0, + trim_end: None, automation_lanes: HashMap::new(), next_automation_id: 0, + audio_graph, + audio_graph_preset: None, } } + /// Create a default audio graph with AudioInput -> AudioOutput + fn create_default_graph(sample_rate: u32, buffer_size: usize) -> AudioGraph { + let mut graph = AudioGraph::new(sample_rate, buffer_size); + + let input_node = Box::new(AudioInputNode::new("Audio Input")); + let input_id = graph.add_node(input_node); + graph.set_node_position(input_id, 100.0, 150.0); + + let output_node = Box::new(AudioOutputNode::new("Audio Output")); + let output_id = graph.add_node(output_node); + graph.set_node_position(output_id, 500.0, 150.0); + + let _ = graph.connect(input_id, 0, output_id, 0); + graph.set_output_node(Some(output_id)); + + graph + } + + /// Prepare for serialization by saving the audio graph as a preset + pub fn prepare_for_save(&mut self) { + self.audio_graph_preset = Some(self.audio_graph.to_preset("Metatrack Graph")); + } + + /// Rebuild the audio graph from preset after deserialization + pub fn rebuild_audio_graph(&mut self, sample_rate: u32, buffer_size: usize) -> Result<(), String> { + if let Some(preset) = &self.audio_graph_preset { + if !preset.nodes.is_empty() && preset.output_node.is_some() { + self.audio_graph = AudioGraph::from_preset(preset, sample_rate, buffer_size, None)?; + } else { + self.audio_graph = Self::create_default_graph(sample_rate, buffer_size); + } + } else { + self.audio_graph = Self::create_default_graph(sample_rate, buffer_size); + } + Ok(()) + } + /// Add an automation lane to this metatrack pub fn add_automation_lane(&mut self, parameter_id: ParameterId) -> AutomationLaneId { let lane_id = self.next_automation_id; @@ -282,11 +359,27 @@ impl Metatrack { !self.muted && (!any_solo || self.solo) } + /// Check whether this metatrack should produce audio at the given parent time. + /// Returns false if the playhead is outside the trim window. + pub fn is_active_at_time(&self, parent_playhead: f64) -> bool { + let local_time = (parent_playhead - self.offset) * self.time_stretch as f64; + if local_time < self.trim_start { + return false; + } + if let Some(end) = self.trim_end { + if local_time >= end { + return false; + } + } + true + } + /// Transform a render context for this metatrack's children /// - /// Applies time stretching and offset transformations. + /// Applies time stretching, offset, and trim transformations. /// Time stretch affects how fast content plays: 0.5 = half speed, 2.0 = double speed /// Offset shifts content forward/backward in time + /// Trim start offsets into the internal content pub fn transform_context(&self, ctx: RenderContext) -> RenderContext { let mut transformed = ctx; @@ -300,7 +393,11 @@ impl Metatrack { // With stretch=0.5, when parent time is 2.0s, child reads from 1.0s (plays slower, pitches down) // With stretch=2.0, when parent time is 2.0s, child reads from 4.0s (plays faster, pitches up) // Note: This creates pitch shift as well - true time stretching would require resampling - transformed.playhead_seconds = adjusted_playhead * self.time_stretch as f64; + let stretched = adjusted_playhead * self.time_stretch as f64; + + // 3. Add trim_start so children see time starting from the trim point + // If trim_start=2.0, children start seeing time 2.0 when parent reaches offset + transformed.playhead_seconds = stretched + self.trim_start; // Accumulate time stretch for nested metatracks transformed.time_stretch *= self.time_stretch; diff --git a/daw-backend/src/command/types.rs b/daw-backend/src/command/types.rs index ccb7adc..84ea9b2 100644 --- a/daw-backend/src/command/types.rs +++ b/daw-backend/src/command/types.rs @@ -38,8 +38,8 @@ pub enum Command { ExtendClip(TrackId, ClipId, f64), // Metatrack management commands - /// Create a new metatrack with a name - CreateMetatrack(String), + /// Create a new metatrack with a name and optional parent group + CreateMetatrack(String, Option), /// Add a track to a metatrack (track_id, metatrack_id) AddToMetatrack(TrackId, TrackId), /// Remove a track from its parent metatrack @@ -54,10 +54,16 @@ pub enum Command { SetOffset(TrackId, f64), /// Set metatrack pitch shift in semitones (track_id, semitones) - for future use SetPitchShift(TrackId, f32), + /// Set metatrack trim start in seconds (track_id, trim_start) + /// Children won't hear content before this point + SetTrimStart(TrackId, f64), + /// Set metatrack trim end in seconds (track_id, trim_end) + /// None means no end trim + SetTrimEnd(TrackId, Option), // Audio track commands - /// Create a new audio track with a name - CreateAudioTrack(String), + /// Create a new audio track with a name and optional parent group + CreateAudioTrack(String, Option), /// Add an audio file to the pool (path, data, channels, sample_rate) /// Returns the pool index via an AudioEvent AddAudioFile(String, Vec, u32, u32), @@ -65,8 +71,8 @@ pub enum Command { AddAudioClip(TrackId, usize, f64, f64, f64), // MIDI commands - /// Create a new MIDI track with a name - CreateMidiTrack(String), + /// Create a new MIDI track with a name and optional parent group + CreateMidiTrack(String, Option), /// Add a MIDI clip to the pool without placing it on a track AddMidiClipToPool(MidiClip), /// Create a new MIDI clip on a track (track_id, start_time, duration) @@ -361,10 +367,12 @@ pub enum Query { SerializeTrackGraph(TrackId, std::path::PathBuf), /// Load a track's effects/instrument graph (track_id, preset_json, project_path) LoadTrackGraph(TrackId, String, std::path::PathBuf), - /// Create a new audio track (name) - returns track ID synchronously - CreateAudioTrackSync(String), - /// Create a new MIDI track (name) - returns track ID synchronously - CreateMidiTrackSync(String), + /// Create a new audio track (name, parent) - returns track ID synchronously + CreateAudioTrackSync(String, Option), + /// Create a new MIDI track (name, parent) - returns track ID synchronously + CreateMidiTrackSync(String, Option), + /// Create a new metatrack/group (name, parent) - returns track ID synchronously + CreateMetatrackSync(String, Option), /// Get waveform data from audio pool (pool_index, target_peaks) GetPoolWaveform(usize, usize), /// Get file info from audio pool (pool_index) - returns (duration, sample_rate, channels) diff --git a/lightningbeam-ui/lightningbeam-core/src/action.rs b/lightningbeam-ui/lightningbeam-core/src/action.rs index 54c6773..ce9ae4e 100644 --- a/lightningbeam-ui/lightningbeam-core/src/action.rs +++ b/lightningbeam-ui/lightningbeam-core/src/action.rs @@ -43,6 +43,9 @@ pub struct BackendContext<'a> { /// Mapping from document clip instance UUIDs to backend clip instance IDs pub clip_instance_to_backend_map: &'a mut HashMap, + /// Mapping from movie clip UUIDs to backend metatrack (group track) TrackIds + pub clip_to_metatrack_map: &'a HashMap, + // Future: pub video_controller: Option<&'a mut VideoController>, } diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/add_layer.rs b/lightningbeam-ui/lightningbeam-core/src/actions/add_layer.rs index e018c23..c76a323 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/add_layer.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/add_layer.rs @@ -12,6 +12,9 @@ pub struct AddLayerAction { /// The layer to add layer: AnyLayer, + /// If Some, add to this VectorClip's layers instead of root + target_clip_id: Option, + /// ID of the created layer (set after execution) created_layer_id: Option, } @@ -26,6 +29,7 @@ impl AddLayerAction { let layer = VectorLayer::new(name); Self { layer: AnyLayer::Vector(layer), + target_clip_id: None, created_layer_id: None, } } @@ -38,10 +42,17 @@ impl AddLayerAction { pub fn new(layer: AnyLayer) -> Self { Self { layer, + target_clip_id: None, created_layer_id: None, } } + /// Set the target clip for this action (add layer inside a movie clip) + pub fn with_target_clip(mut self, clip_id: Option) -> Self { + self.target_clip_id = clip_id; + self + } + /// Get the ID of the created layer (after execution) pub fn created_layer_id(&self) -> Option { self.created_layer_id @@ -50,8 +61,19 @@ impl AddLayerAction { impl Action for AddLayerAction { fn execute(&mut self, document: &mut Document) -> Result<(), String> { - // Add layer to the document's root - let layer_id = document.root_mut().add_child(self.layer.clone()); + let layer_id = if let Some(clip_id) = self.target_clip_id { + // Add layer inside a vector clip (movie clip) + let clip = document.vector_clips.get_mut(&clip_id) + .ok_or_else(|| format!("Target clip {} not found", clip_id))?; + let id = self.layer.id(); + clip.layers.add_root(self.layer.clone()); + // Register in layer_to_clip_map for O(1) lookup + document.layer_to_clip_map.insert(id, clip_id); + id + } else { + // Add layer to the document's root + document.root_mut().add_child(self.layer.clone()) + }; // Store the ID for rollback self.created_layer_id = Some(layer_id); @@ -62,7 +84,15 @@ impl Action for AddLayerAction { fn rollback(&mut self, document: &mut Document) -> Result<(), String> { // Remove the created layer if it exists if let Some(layer_id) = self.created_layer_id { - document.root_mut().remove_child(&layer_id); + if let Some(clip_id) = self.target_clip_id { + // Remove from vector clip + if let Some(clip) = document.vector_clips.get_mut(&clip_id) { + clip.layers.roots.retain(|node| node.data.id() != layer_id); + } + document.layer_to_clip_map.remove(&layer_id); + } else { + document.root_mut().remove_child(&layer_id); + } // Clear the stored ID self.created_layer_id = None; diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/move_clip_instances.rs b/lightningbeam-ui/lightningbeam-core/src/actions/move_clip_instances.rs index daecbd4..460e3e4 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/move_clip_instances.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/move_clip_instances.rs @@ -190,6 +190,21 @@ impl Action for MoveClipInstancesAction { let layer = document.get_layer(layer_id) .ok_or_else(|| format!("Layer {} not found", layer_id))?; + // Handle vector layers: update metatrack offset for movie clips with audio + if let AnyLayer::Vector(vl) = layer { + for (instance_id, _old_start, new_start) in moves { + if let Some(instance) = vl.clip_instances.iter().find(|ci| ci.id == *instance_id) { + // Check if this clip has a metatrack + if let Some(&metatrack_id) = backend.clip_to_metatrack_map.get(&instance.clip_id) { + controller.set_offset(metatrack_id, *new_start); + controller.set_trim_start(metatrack_id, instance.trim_start); + controller.set_trim_end(metatrack_id, instance.trim_end); + } + } + } + continue; + } + // Only process audio layers if !matches!(layer, AnyLayer::Audio(_)) { continue; @@ -260,6 +275,20 @@ impl Action for MoveClipInstancesAction { let layer = document.get_layer(layer_id) .ok_or_else(|| format!("Layer {} not found", layer_id))?; + // Handle vector layers: restore metatrack offset for movie clips with audio + if let AnyLayer::Vector(vl) = layer { + for (instance_id, old_start, _new_start) in moves { + if let Some(instance) = vl.clip_instances.iter().find(|ci| ci.id == *instance_id) { + if let Some(&metatrack_id) = backend.clip_to_metatrack_map.get(&instance.clip_id) { + controller.set_offset(metatrack_id, *old_start); + controller.set_trim_start(metatrack_id, instance.trim_start); + controller.set_trim_end(metatrack_id, instance.trim_end); + } + } + } + continue; + } + // Only process audio layers if !matches!(layer, AnyLayer::Audio(_)) { continue; diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/set_layer_properties.rs b/lightningbeam-ui/lightningbeam-core/src/actions/set_layer_properties.rs index 5cca3e6..ae6fd20 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/set_layer_properties.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/set_layer_properties.rs @@ -76,8 +76,8 @@ impl SetLayerPropertiesAction { impl Action for SetLayerPropertiesAction { fn execute(&mut self, document: &mut Document) -> Result<(), String> { for (i, &layer_id) in self.layer_ids.iter().enumerate() { - // Find the layer in the document - if let Some(layer) = document.root_mut().get_child_mut(&layer_id) { + // Find the layer in the document (searches root + inside movie clips) + if let Some(layer) = document.get_layer_mut(&layer_id) { // Store old value if not already stored if self.old_values[i].is_none() { self.old_values[i] = Some(match &self.property { @@ -106,8 +106,8 @@ impl Action for SetLayerPropertiesAction { fn rollback(&mut self, document: &mut Document) -> Result<(), String> { for (i, &layer_id) in self.layer_ids.iter().enumerate() { - // Find the layer in the document - if let Some(layer) = document.root_mut().get_child_mut(&layer_id) { + // Find the layer in the document (searches root + inside movie clips) + if let Some(layer) = document.get_layer_mut(&layer_id) { // Restore old value if we have one if let Some(old_value) = &self.old_values[i] { match old_value { diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/trim_clip_instances.rs b/lightningbeam-ui/lightningbeam-core/src/actions/trim_clip_instances.rs index 95cc545..7060447 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/trim_clip_instances.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/trim_clip_instances.rs @@ -355,6 +355,21 @@ impl Action for TrimClipInstancesAction { let layer = document.get_layer(layer_id) .ok_or_else(|| format!("Layer {} not found", layer_id))?; + // Handle vector layers: update metatrack trim for movie clips with audio + if let AnyLayer::Vector(vl) = layer { + for (instance_id, _trim_type, _old, _new) in trims { + if let Some(instance) = vl.clip_instances.iter().find(|ci| ci.id == *instance_id) { + if let Some(&metatrack_id) = backend.clip_to_metatrack_map.get(&instance.clip_id) { + // Instance already has new values after execute() + controller.set_offset(metatrack_id, instance.timeline_start); + controller.set_trim_start(metatrack_id, instance.trim_start); + controller.set_trim_end(metatrack_id, instance.trim_end); + } + } + } + continue; + } + // Only process audio layers if !matches!(layer, AnyLayer::Audio(_)) { continue; @@ -430,6 +445,21 @@ impl Action for TrimClipInstancesAction { let layer = document.get_layer(layer_id) .ok_or_else(|| format!("Layer {} not found", layer_id))?; + // Handle vector layers: restore metatrack trim for movie clips with audio + if let AnyLayer::Vector(vl) = layer { + for (instance_id, _trim_type, _old, _new) in trims { + if let Some(instance) = vl.clip_instances.iter().find(|ci| ci.id == *instance_id) { + if let Some(&metatrack_id) = backend.clip_to_metatrack_map.get(&instance.clip_id) { + // Instance already has old values after rollback() + controller.set_offset(metatrack_id, instance.timeline_start); + controller.set_trim_start(metatrack_id, instance.trim_start); + controller.set_trim_end(metatrack_id, instance.trim_end); + } + } + } + continue; + } + // Only process audio layers if !matches!(layer, AnyLayer::Audio(_)) { continue; diff --git a/lightningbeam-ui/lightningbeam-core/src/clip.rs b/lightningbeam-ui/lightningbeam-core/src/clip.rs index 8a4f58b..0d53d88 100644 --- a/lightningbeam-ui/lightningbeam-core/src/clip.rs +++ b/lightningbeam-ui/lightningbeam-core/src/clip.rs @@ -90,14 +90,49 @@ 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. + /// Calculate the duration of this clip based on its internal content. + /// + /// Considers: + /// - Vector layer keyframes (last keyframe time + one frame) + /// - Audio/video/effect layer clip instances (timeline_start + effective duration) + /// + /// The `clip_duration_fn` resolves referenced clip durations for non-vector layers. + /// Falls back to the stored `duration` field if no content exists. pub fn content_duration(&self, framerate: f64) -> f64 { + self.content_duration_with(framerate, |_| None) + } + + /// Like `content_duration`, but with a closure that resolves clip durations + /// for audio/video/effect clip instances inside this movie clip. + pub fn content_duration_with(&self, framerate: f64, clip_duration_fn: impl Fn(&Uuid) -> Option) -> f64 { let frame_duration = 1.0 / framerate; let mut last_time: Option = None; for layer_node in self.layers.iter() { + // Check clip instances on ALL layer types (vector, audio, video, effect) + let clip_instances: &[ClipInstance] = match &layer_node.data { + AnyLayer::Vector(vl) => &vl.clip_instances, + AnyLayer::Audio(al) => &al.clip_instances, + AnyLayer::Video(vl) => &vl.clip_instances, + AnyLayer::Effect(el) => &el.clip_instances, + }; + for ci in clip_instances { + let end = if let Some(td) = ci.timeline_duration { + ci.timeline_start + td + } else if let Some(te) = ci.trim_end { + ci.timeline_start + (te - ci.trim_start).max(0.0) + } else if let Some(clip_dur) = clip_duration_fn(&ci.clip_id) { + ci.timeline_start + (clip_dur - ci.trim_start).max(0.0) + } else { + continue; + }; + last_time = Some(match last_time { + Some(t) => t.max(end), + None => end, + }); + } + + // Also check vector layer keyframes if let AnyLayer::Vector(vector_layer) = &layer_node.data { if let Some(last_kf) = vector_layer.keyframes.last() { last_time = Some(match last_time { diff --git a/lightningbeam-ui/lightningbeam-core/src/document.rs b/lightningbeam-ui/lightningbeam-core/src/document.rs index 228bbf2..16c15f6 100644 --- a/lightningbeam-ui/lightningbeam-core/src/document.rs +++ b/lightningbeam-ui/lightningbeam-core/src/document.rs @@ -462,6 +462,15 @@ impl Document { self.context_layers_mut(clip_id).into_iter().find(|l| &l.id() == layer_id) } + /// Get all layers across the entire document (root + inside all vector clips). + pub fn all_layers(&self) -> Vec<&AnyLayer> { + let mut layers: Vec<&AnyLayer> = self.root.children.iter().collect(); + for clip in self.vector_clips.values() { + layers.extend(clip.layers.root_data()); + } + layers + } + // === CLIP LIBRARY METHODS === /// Add a vector clip to the library @@ -645,7 +654,21 @@ impl Document { if clip.is_group { Some(clip.duration) } else { - Some(clip.content_duration(self.framerate)) + Some(clip.content_duration_with(self.framerate, |id| { + // Resolve nested clip durations (audio, video, other vector clips) + if let Some(vc) = self.vector_clips.get(id) { + // Avoid deep recursion — use stored duration for nested vector clips + Some(vc.content_duration(self.framerate)) + } else if let Some(ac) = self.audio_clips.get(id) { + Some(ac.duration) + } else if let Some(vc) = self.video_clips.get(id) { + Some(vc.duration) + } else if self.effect_definitions.contains_key(id) { + Some(crate::effect::EFFECT_DURATION) + } else { + None + } + })) } } else if let Some(clip) = self.video_clips.get(clip_id) { Some(clip.duration) diff --git a/lightningbeam-ui/lightningbeam-core/src/file_io.rs b/lightningbeam-ui/lightningbeam-core/src/file_io.rs index db3e2ec..13c72b0 100644 --- a/lightningbeam-ui/lightningbeam-core/src/file_io.rs +++ b/lightningbeam-ui/lightningbeam-core/src/file_io.rs @@ -60,6 +60,10 @@ pub struct SerializedAudioBackend { /// Preserves the connection between UI layers and audio engine tracks across save/load #[serde(default)] pub layer_to_track_map: std::collections::HashMap, + + /// Mapping from movie clip UUIDs to backend metatrack (group track) TrackIds + #[serde(default)] + pub clip_to_metatrack_map: std::collections::HashMap, } /// Settings for saving a project @@ -96,6 +100,9 @@ pub struct LoadedProject { /// Mapping from UI layer UUIDs to backend TrackIds (empty for old files) pub layer_to_track_map: std::collections::HashMap, + /// Mapping from movie clip UUIDs to backend metatrack TrackIds (empty for old files) + pub clip_to_metatrack_map: std::collections::HashMap, + /// Loaded audio pool entries pub audio_pool_entries: Vec, @@ -147,6 +154,7 @@ pub fn save_beam( audio_project: &mut AudioProject, audio_pool_entries: Vec, layer_to_track_map: &std::collections::HashMap, + clip_to_metatrack_map: &std::collections::HashMap, _settings: &SaveSettings, ) -> Result<(), String> { let fn_start = std::time::Instant::now(); @@ -375,6 +383,7 @@ pub fn save_beam( project: audio_project.clone(), audio_pool_entries: modified_entries, layer_to_track_map: layer_to_track_map.clone(), + clip_to_metatrack_map: clip_to_metatrack_map.clone(), }, }; eprintln!("📊 [SAVE_BEAM] Step 5: Build BeamProject structure took {:.2}ms", step5_start.elapsed().as_secs_f64() * 1000.0); @@ -462,6 +471,7 @@ pub fn load_beam(path: &Path) -> Result { let mut audio_project = beam_project.audio_backend.project; let audio_pool_entries = beam_project.audio_backend.audio_pool_entries; let layer_to_track_map = beam_project.audio_backend.layer_to_track_map; + let clip_to_metatrack_map = beam_project.audio_backend.clip_to_metatrack_map; eprintln!("📊 [LOAD_BEAM] Step 5: Extract document and audio state took {:.2}ms", step5_start.elapsed().as_secs_f64() * 1000.0); // 6. Rebuild AudioGraphs from presets @@ -607,6 +617,7 @@ pub fn load_beam(path: &Path) -> Result { document, audio_project, layer_to_track_map, + clip_to_metatrack_map, audio_pool_entries: restored_entries, missing_files, }) diff --git a/lightningbeam-ui/lightningbeam-core/src/renderer.rs b/lightningbeam-ui/lightningbeam-core/src/renderer.rs index 436cd63..97dbcf0 100644 --- a/lightningbeam-ui/lightningbeam-core/src/renderer.rs +++ b/lightningbeam-ui/lightningbeam-core/src/renderer.rs @@ -498,7 +498,7 @@ fn render_clip_instance( } 0.0 } else { - let clip_dur = vector_clip.content_duration(document.framerate); + let clip_dur = document.get_clip_duration(&vector_clip.id).unwrap_or(vector_clip.duration); let Some(t) = clip_instance.remap_time(time, clip_dur) else { return; // Clip instance not active at this time }; diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index d368e34..6cc938c 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -384,6 +384,7 @@ enum FileCommand { path: std::path::PathBuf, document: lightningbeam_core::document::Document, layer_to_track_map: std::collections::HashMap, + clip_to_metatrack_map: std::collections::HashMap, progress_tx: std::sync::mpsc::Sender, }, Load { @@ -458,8 +459,8 @@ impl FileOperationsWorker { fn run(self) { while let Ok(command) = self.command_rx.recv() { match command { - FileCommand::Save { path, document, layer_to_track_map, progress_tx } => { - self.handle_save(path, document, &layer_to_track_map, progress_tx); + FileCommand::Save { path, document, layer_to_track_map, clip_to_metatrack_map, progress_tx } => { + self.handle_save(path, document, &layer_to_track_map, &clip_to_metatrack_map, progress_tx); } FileCommand::Load { path, progress_tx } => { self.handle_load(path, progress_tx); @@ -474,6 +475,7 @@ impl FileOperationsWorker { path: std::path::PathBuf, document: lightningbeam_core::document::Document, layer_to_track_map: &std::collections::HashMap, + clip_to_metatrack_map: &std::collections::HashMap, progress_tx: std::sync::mpsc::Sender, ) { use lightningbeam_core::file_io::{save_beam, SaveSettings}; @@ -516,7 +518,7 @@ impl FileOperationsWorker { let step3_start = std::time::Instant::now(); let settings = SaveSettings::default(); - match save_beam(&path, &document, &mut audio_project, audio_pool_entries, layer_to_track_map, &settings) { + match save_beam(&path, &document, &mut audio_project, audio_pool_entries, layer_to_track_map, clip_to_metatrack_map, &settings) { Ok(()) => { eprintln!("📊 [SAVE] Step 3: save_beam() took {:.2}ms", step3_start.elapsed().as_secs_f64() * 1000.0); eprintln!("📊 [SAVE] ✅ Total save time: {:.2}ms", save_start.elapsed().as_secs_f64() * 1000.0); @@ -704,6 +706,8 @@ struct EditorApp { // Track ID mapping (Document layer UUIDs <-> daw-backend TrackIds) layer_to_track_map: HashMap, track_to_layer_map: HashMap, + // Movie clip ID -> backend metatrack (group track) mapping + clip_to_metatrack_map: HashMap, /// Generation counter - incremented on project load to force UI components to reload project_generation: u64, // Clip instance ID mapping (Document clip instance UUIDs <-> backend clip instance IDs) @@ -936,6 +940,7 @@ impl EditorApp { )), layer_to_track_map: HashMap::new(), track_to_layer_map: HashMap::new(), + clip_to_metatrack_map: HashMap::new(), project_generation: 0, clip_instance_to_backend_map: HashMap::new(), playback_time: 0.0, // Start at beginning @@ -1309,63 +1314,79 @@ impl EditorApp { fn sync_audio_layers_to_backend(&mut self) { use lightningbeam_core::layer::{AnyLayer, AudioLayerType}; - // Iterate through all layers in the document + // Collect audio layers from root and inside vector clips + // Each entry: (layer_id, layer_name, audio_type, parent_clip_id) + let mut audio_layers_to_sync: Vec<(uuid::Uuid, String, AudioLayerType, Option)> = Vec::new(); + + // Root layers for layer in &self.action_executor.document().root.children { - // Only process Audio layers if let AnyLayer::Audio(audio_layer) = layer { let layer_id = audio_layer.layer.id; - let layer_name = &audio_layer.layer.name; - - // Skip if already mapped (shouldn't happen, but be defensive) - if self.layer_to_track_map.contains_key(&layer_id) { - continue; + if !self.layer_to_track_map.contains_key(&layer_id) { + audio_layers_to_sync.push(( + layer_id, + audio_layer.layer.name.clone(), + audio_layer.audio_layer_type, + None, + )); } + } + } - // Handle both MIDI and Sampled audio tracks - match audio_layer.audio_layer_type { - AudioLayerType::Midi => { - // Create daw-backend MIDI track - if let Some(ref controller_arc) = self.audio_controller { - let mut controller = controller_arc.lock().unwrap(); - match controller.create_midi_track_sync(layer_name.clone()) { - Ok(track_id) => { - // Store bidirectional mapping - self.layer_to_track_map.insert(layer_id, track_id); - self.track_to_layer_map.insert(track_id, layer_id); + // Layers inside vector clips + for (&clip_id, clip) in &self.action_executor.document().vector_clips { + for layer in clip.layers.root_data() { + if let AnyLayer::Audio(audio_layer) = layer { + let layer_id = audio_layer.layer.id; + if !self.layer_to_track_map.contains_key(&layer_id) { + audio_layers_to_sync.push(( + layer_id, + audio_layer.layer.name.clone(), + audio_layer.audio_layer_type, + Some(clip_id), + )); + } + } + } + } - // Load default instrument - if let Err(e) = default_instrument::load_default_instrument(&mut *controller, track_id) { - eprintln!("⚠️ Failed to load default instrument for {}: {}", layer_name, e); - } else { - println!("✅ Synced MIDI layer '{}' to backend (TrackId: {})", layer_name, track_id); - } + // Now create backend tracks for each + for (layer_id, layer_name, audio_type, parent_clip_id) in audio_layers_to_sync { + // If inside a clip, ensure a metatrack exists + let parent_track = parent_clip_id.and_then(|cid| self.ensure_metatrack_for_clip(cid)); - // TODO: Sync any existing clips on this layer to the backend - // This will be implemented when we add clip synchronization - } - Err(e) => { - eprintln!("⚠️ Failed to create daw-backend track for MIDI layer '{}': {}", layer_name, e); + match audio_type { + AudioLayerType::Midi => { + if let Some(ref controller_arc) = self.audio_controller { + let mut controller = controller_arc.lock().unwrap(); + match controller.create_midi_track_sync(layer_name.clone(), parent_track) { + Ok(track_id) => { + self.layer_to_track_map.insert(layer_id, track_id); + self.track_to_layer_map.insert(track_id, layer_id); + + if let Err(e) = default_instrument::load_default_instrument(&mut *controller, track_id) { + eprintln!("⚠️ Failed to load default instrument for {}: {}", layer_name, e); + } else { + println!("✅ Synced MIDI layer '{}' to backend (TrackId: {}, parent: {:?})", layer_name, track_id, parent_track); } } + Err(e) => { + eprintln!("⚠️ Failed to create daw-backend track for MIDI layer '{}': {}", layer_name, e); + } } } - AudioLayerType::Sampled => { - // Create daw-backend Audio track - if let Some(ref controller_arc) = self.audio_controller { - let mut controller = controller_arc.lock().unwrap(); - match controller.create_audio_track_sync(layer_name.clone()) { - Ok(track_id) => { - // Store bidirectional mapping - self.layer_to_track_map.insert(layer_id, track_id); - self.track_to_layer_map.insert(track_id, layer_id); - println!("✅ Synced Audio layer '{}' to backend (TrackId: {})", layer_name, track_id); - - // TODO: Sync any existing clips on this layer to the backend - // This will be implemented when we add clip synchronization - } - Err(e) => { - eprintln!("⚠️ Failed to create daw-backend audio track for '{}': {}", layer_name, e); - } + } + AudioLayerType::Sampled => { + if let Some(ref controller_arc) = self.audio_controller { + let mut controller = controller_arc.lock().unwrap(); + match controller.create_audio_track_sync(layer_name.clone(), parent_track) { + Ok(track_id) => { + self.layer_to_track_map.insert(layer_id, track_id); + self.track_to_layer_map.insert(track_id, layer_id); + println!("✅ Synced Audio layer '{}' to backend (TrackId: {}, parent: {:?})", layer_name, track_id, parent_track); + } + Err(e) => { + eprintln!("⚠️ Failed to create daw-backend audio track for '{}': {}", layer_name, e); } } } @@ -1374,6 +1395,36 @@ impl EditorApp { } } + /// Ensure a backend metatrack (group track) exists for a movie clip. + /// Returns the metatrack's TrackId, creating one if needed. + fn ensure_metatrack_for_clip(&mut self, clip_id: Uuid) -> Option { + // Return existing metatrack if already mapped + if let Some(&track_id) = self.clip_to_metatrack_map.get(&clip_id) { + return Some(track_id); + } + + // Create a new metatrack in the backend + let clip_name = self.action_executor.document().vector_clips + .get(&clip_id) + .map(|c| c.name.clone()) + .unwrap_or_else(|| format!("Clip {}", clip_id)); + + if let Some(ref controller_arc) = self.audio_controller { + let mut controller = controller_arc.lock().unwrap(); + match controller.create_group_track_sync(format!("[{}]", clip_name), None) { + Ok(track_id) => { + self.clip_to_metatrack_map.insert(clip_id, track_id); + println!("✅ Created metatrack for clip '{}' (TrackId: {})", clip_name, track_id); + return Some(track_id); + } + Err(e) => { + eprintln!("⚠️ Failed to create metatrack for clip '{}': {}", clip_name, e); + } + } + } + None + } + /// Split clip instances at the current playhead position /// /// Only splits clips on the active layer, plus any clips linked to them @@ -1487,6 +1538,7 @@ impl EditorApp { audio_controller: Some(&mut *controller), layer_to_track_map: &self.layer_to_track_map, clip_instance_to_backend_map: &mut self.clip_instance_to_backend_map, + clip_to_metatrack_map: &self.clip_to_metatrack_map, }; if let Err(e) = self.action_executor.execute_with_backend(Box::new(action), &mut backend_context) { @@ -1659,6 +1711,7 @@ impl EditorApp { audio_controller: Some(&mut *controller), layer_to_track_map: &self.layer_to_track_map, clip_instance_to_backend_map: &mut self.clip_instance_to_backend_map, + clip_to_metatrack_map: &self.clip_to_metatrack_map, }; if let Err(e) = self .action_executor @@ -1780,6 +1833,7 @@ impl EditorApp { audio_controller: Some(&mut *controller), layer_to_track_map: &self.layer_to_track_map, clip_instance_to_backend_map: &mut self.clip_instance_to_backend_map, + clip_to_metatrack_map: &self.clip_to_metatrack_map, }; if let Err(e) = self .action_executor @@ -1894,6 +1948,7 @@ impl EditorApp { audio_controller: Some(&mut *controller), layer_to_track_map: &self.layer_to_track_map, clip_instance_to_backend_map: &mut self.clip_instance_to_backend_map, + clip_to_metatrack_map: &self.clip_to_metatrack_map, }; if let Err(e) = self.action_executor.execute_with_backend(Box::new(action), &mut backend_context) { eprintln!("Duplicate clip failed: {}", e); @@ -1973,6 +2028,7 @@ impl EditorApp { // TODO: Add ResetProject command to EngineController self.layer_to_track_map.clear(); self.track_to_layer_map.clear(); + self.clip_to_metatrack_map.clear(); // Clear file path self.current_file_path = None; @@ -2176,6 +2232,7 @@ impl EditorApp { audio_controller: Some(&mut *controller), layer_to_track_map: &self.layer_to_track_map, clip_instance_to_backend_map: &mut self.clip_instance_to_backend_map, + clip_to_metatrack_map: &self.clip_to_metatrack_map, }; match self.action_executor.undo_with_backend(&mut backend_context) { Ok(true) => { @@ -2211,6 +2268,7 @@ impl EditorApp { audio_controller: Some(&mut *controller), layer_to_track_map: &self.layer_to_track_map, clip_instance_to_backend_map: &mut self.clip_instance_to_backend_map, + clip_to_metatrack_map: &self.clip_to_metatrack_map, }; match self.action_executor.redo_with_backend(&mut backend_context) { Ok(true) => { @@ -2327,58 +2385,71 @@ impl EditorApp { // Layer menu MenuAction::AddLayer => { // Create a new vector layer with a default name - let layer_count = self.action_executor.document().root.children.len(); + let editing_clip_id = self.editing_context.current_clip_id(); + let context_layers = self.action_executor.document().context_layers(editing_clip_id.as_ref()); + let layer_count = context_layers.len(); let layer_name = format!("Layer {}", layer_count + 1); - let action = lightningbeam_core::actions::AddLayerAction::new_vector(layer_name); + let action = lightningbeam_core::actions::AddLayerAction::new_vector(layer_name) + .with_target_clip(editing_clip_id); let _ = 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() { + // Select the newly created layer (last in context) + let context_layers = self.action_executor.document().context_layers(editing_clip_id.as_ref()); + if let Some(last_layer) = context_layers.last() { self.active_layer_id = Some(last_layer.id()); } } MenuAction::AddVideoLayer => { - println!("Menu: Add Video Layer"); - // Create a new video layer with a default name - let layer_number = self.action_executor.document().root.children.len() + 1; + let editing_clip_id = self.editing_context.current_clip_id(); + let context_layers = self.action_executor.document().context_layers(editing_clip_id.as_ref()); + let layer_number = context_layers.len() + 1; let layer_name = format!("Video {}", layer_number); let new_layer = lightningbeam_core::layer::AnyLayer::Video( lightningbeam_core::layer::VideoLayer::new(&layer_name) ); - // Add the layer to the document - self.action_executor.document_mut().root.add_child(new_layer.clone()); + let action = lightningbeam_core::actions::AddLayerAction::new(new_layer) + .with_target_clip(editing_clip_id); + let _ = self.action_executor.execute(Box::new(action)); // Set it as the active layer - if let Some(last_layer) = self.action_executor.document().root.children.last() { + let context_layers = self.action_executor.document().context_layers(editing_clip_id.as_ref()); + if let Some(last_layer) = context_layers.last() { self.active_layer_id = Some(last_layer.id()); } } MenuAction::AddAudioTrack => { // Create a new sampled audio layer with a default name - let layer_count = self.action_executor.document().root.children.len(); + let editing_clip_id = self.editing_context.current_clip_id(); + let context_layers = self.action_executor.document().context_layers(editing_clip_id.as_ref()); + let layer_count = context_layers.len(); let layer_name = format!("Audio Track {}", layer_count + 1); // Create audio layer in document let audio_layer = AudioLayer::new_sampled(layer_name.clone()); - let action = lightningbeam_core::actions::AddLayerAction::new(AnyLayer::Audio(audio_layer)); + let action = lightningbeam_core::actions::AddLayerAction::new(AnyLayer::Audio(audio_layer)) + .with_target_clip(editing_clip_id); let _ = self.action_executor.execute(Box::new(action)); // Get the newly created layer ID - if let Some(last_layer) = self.action_executor.document().root.children.last() { + let context_layers = self.action_executor.document().context_layers(editing_clip_id.as_ref()); + if let Some(last_layer) = context_layers.last() { let layer_id = last_layer.id(); self.active_layer_id = Some(layer_id); + // If inside a clip, ensure a metatrack exists for it + let parent_track = editing_clip_id.and_then(|cid| self.ensure_metatrack_for_clip(cid)); + // Create corresponding daw-backend audio track if let Some(ref controller_arc) = self.audio_controller { let mut controller = controller_arc.lock().unwrap(); - match controller.create_audio_track_sync(layer_name.clone()) { + match controller.create_audio_track_sync(layer_name.clone(), parent_track) { Ok(track_id) => { // Store bidirectional mapping self.layer_to_track_map.insert(layer_id, track_id); self.track_to_layer_map.insert(track_id, layer_id); - println!("✅ Created {} (backend TrackId: {})", layer_name, track_id); + println!("✅ Created {} (backend TrackId: {}, parent: {:?})", layer_name, track_id, parent_track); } Err(e) => { eprintln!("⚠️ Failed to create daw-backend audio track for {}: {}", layer_name, e); @@ -2390,23 +2461,30 @@ impl EditorApp { } MenuAction::AddMidiTrack => { // Create a new MIDI audio layer with a default name - let layer_count = self.action_executor.document().root.children.len(); + let editing_clip_id = self.editing_context.current_clip_id(); + let context_layers = self.action_executor.document().context_layers(editing_clip_id.as_ref()); + let layer_count = context_layers.len(); let layer_name = format!("MIDI Track {}", layer_count + 1); // Create MIDI layer in document let midi_layer = AudioLayer::new_midi(layer_name.clone()); - let action = lightningbeam_core::actions::AddLayerAction::new(AnyLayer::Audio(midi_layer)); + let action = lightningbeam_core::actions::AddLayerAction::new(AnyLayer::Audio(midi_layer)) + .with_target_clip(editing_clip_id); let _ = self.action_executor.execute(Box::new(action)); // Get the newly created layer ID - if let Some(last_layer) = self.action_executor.document().root.children.last() { + let context_layers = self.action_executor.document().context_layers(editing_clip_id.as_ref()); + if let Some(last_layer) = context_layers.last() { let layer_id = last_layer.id(); self.active_layer_id = Some(layer_id); + // If inside a clip, ensure a metatrack exists for it + let parent_track = editing_clip_id.and_then(|cid| self.ensure_metatrack_for_clip(cid)); + // Create corresponding daw-backend MIDI track if let Some(ref controller_arc) = self.audio_controller { let mut controller = controller_arc.lock().unwrap(); - match controller.create_midi_track_sync(layer_name.clone()) { + match controller.create_midi_track_sync(layer_name.clone(), parent_track) { Ok(track_id) => { // Store bidirectional mapping self.layer_to_track_map.insert(layer_id, track_id); @@ -2627,6 +2705,7 @@ impl EditorApp { path: path.clone(), document, layer_to_track_map: self.layer_to_track_map.clone(), + clip_to_metatrack_map: self.clip_to_metatrack_map.clone(), progress_tx, }; @@ -2776,6 +2855,15 @@ impl EditorApp { eprintln!("📊 [APPLY] Step 5: No saved track mappings (old file format)"); } + // Restore clip-to-metatrack mappings + if !loaded_project.clip_to_metatrack_map.is_empty() { + for (&clip_id, &track_id) in &loaded_project.clip_to_metatrack_map { + self.clip_to_metatrack_map.insert(clip_id, track_id); + } + eprintln!("📊 [APPLY] Step 5b: Restored {} clip-to-metatrack mappings", + loaded_project.clip_to_metatrack_map.len()); + } + // Sync any audio layers that don't have a mapping yet (new layers, or old file format) let step6_start = std::time::Instant::now(); self.sync_audio_layers_to_backend(); @@ -3317,12 +3405,16 @@ impl EditorApp { // Update active layer to the new layer self.active_layer_id = target_layer_id; + // If inside a clip, ensure a metatrack exists for it + let editing_clip_id = self.editing_context.current_clip_id(); + let parent_track = editing_clip_id.and_then(|cid| self.ensure_metatrack_for_clip(cid)); + // Create a backend audio/MIDI track and add the mapping if let Some(ref controller_arc) = self.audio_controller { let mut controller = controller_arc.lock().unwrap(); match asset_info.clip_type { panes::DragClipType::AudioSampled => { - match controller.create_audio_track_sync(layer_name.clone()) { + match controller.create_audio_track_sync(layer_name.clone(), parent_track) { Ok(track_id) => { self.layer_to_track_map.insert(layer_id, track_id); self.track_to_layer_map.insert(track_id, layer_id); @@ -3331,7 +3423,7 @@ impl EditorApp { } } panes::DragClipType::AudioMidi => { - match controller.create_midi_track_sync(layer_name.clone()) { + match controller.create_midi_track_sync(layer_name.clone(), parent_track) { Ok(track_id) => { self.layer_to_track_map.insert(layer_id, track_id); self.track_to_layer_map.insert(track_id, layer_id); @@ -3432,6 +3524,7 @@ impl EditorApp { audio_controller: Some(&mut *controller), layer_to_track_map: &self.layer_to_track_map, clip_instance_to_backend_map: &mut self.clip_instance_to_backend_map, + clip_to_metatrack_map: &self.clip_to_metatrack_map, }; if let Err(e) = self.action_executor.execute_with_backend(Box::new(action), &mut backend_context) { @@ -3477,6 +3570,7 @@ impl EditorApp { audio_controller: Some(&mut *controller), layer_to_track_map: &self.layer_to_track_map, clip_instance_to_backend_map: &mut self.clip_instance_to_backend_map, + clip_to_metatrack_map: &self.clip_to_metatrack_map, }; if let Err(e) = self.action_executor.execute_with_backend(Box::new(audio_action), &mut backend_context) { @@ -3505,8 +3599,9 @@ impl EditorApp { let document = self.action_executor.document(); let mut video_instance_info: Option<(uuid::Uuid, uuid::Uuid, f64)> = None; // (layer_id, instance_id, timeline_start) - // Search all layers for a video clip instance with matching clip_id - for layer in &document.root.children { + // Search all layers (root + inside movie clips) for a video clip instance with matching clip_id + let all_layers = document.all_layers(); + for layer in &all_layers { if let AnyLayer::Video(video_layer) = layer { for instance in &video_layer.clip_instances { if instance.clip_id == video_clip_id { @@ -3559,6 +3654,7 @@ impl EditorApp { audio_controller: Some(&mut *controller), layer_to_track_map: &self.layer_to_track_map, clip_instance_to_backend_map: &mut self.clip_instance_to_backend_map, + clip_to_metatrack_map: &self.clip_to_metatrack_map, }; if let Err(e) = self.action_executor.execute_with_backend(Box::new(audio_action), &mut backend_context) { @@ -3923,10 +4019,8 @@ impl eframe::App for EditorApp { let clip_instance = ClipInstance::new(doc_clip_id) .with_timeline_start(self.recording_start_time); - // Add instance to layer - if let Some(layer) = self.action_executor.document_mut().root.children.iter_mut() - .find(|l| l.id() == layer_id) - { + // Add instance to layer (works for root and inside movie clips) + if let Some(layer) = self.action_executor.document_mut().get_layer_mut(&layer_id) { if let lightningbeam_core::layer::AnyLayer::Audio(audio_layer) = layer { audio_layer.clip_instances.push(clip_instance); println!("✅ Created recording clip instance on layer {}", layer_id); @@ -3948,8 +4042,7 @@ impl eframe::App for EditorApp { // First, find the clip_id from the layer (read-only borrow) let clip_id = { let document = self.action_executor.document(); - document.root.children.iter() - .find(|l| l.id() == layer_id) + document.get_layer(&layer_id) .and_then(|layer| { if let lightningbeam_core::layer::AnyLayer::Audio(audio_layer) = layer { audio_layer.clip_instances.last().map(|i| i.clip_id) @@ -4014,8 +4107,7 @@ impl eframe::App for EditorApp { // First, find the clip instance and clip id let (clip_id, instance_id, timeline_start, trim_start) = { let document = self.action_executor.document(); - document.root.children.iter() - .find(|l| l.id() == layer_id) + document.get_layer(&layer_id) .and_then(|layer| { if let lightningbeam_core::layer::AnyLayer::Audio(audio_layer) = layer { audio_layer.clip_instances.last().map(|instance| { @@ -4093,8 +4185,7 @@ impl eframe::App for EditorApp { if let Some(layer_id) = self.recording_layer_id { let doc_clip_id = { let document = self.action_executor.document(); - document.root.children.iter() - .find(|l| l.id() == layer_id) + document.get_layer(&layer_id) .and_then(|layer| { if let lightningbeam_core::layer::AnyLayer::Audio(audio_layer) = layer { audio_layer.clip_instances.last().map(|i| i.clip_id) @@ -4153,8 +4244,7 @@ impl eframe::App for EditorApp { if let Some(layer_id) = self.recording_layer_id { let doc_clip_id = { let document = self.action_executor.document(); - document.root.children.iter() - .find(|l| l.id() == layer_id) + document.get_layer(&layer_id) .and_then(|layer| { if let lightningbeam_core::layer::AnyLayer::Audio(audio_layer) = layer { audio_layer.clip_instances.last().map(|i| i.clip_id) @@ -4512,6 +4602,7 @@ impl eframe::App for EditorApp { { let time = self.playback_time; let document = self.action_executor.document_mut(); + // Bake animation transforms for root layers for layer in document.root.children.iter_mut() { if let lightningbeam_core::layer::AnyLayer::Vector(vl) = layer { for ci in &mut vl.clip_instances { @@ -4523,6 +4614,20 @@ impl eframe::App for EditorApp { } } } + // Bake animation transforms for layers inside movie clips + for clip in document.vector_clips.values_mut() { + for layer_node in clip.layers.roots.iter_mut() { + if let lightningbeam_core::layer::AnyLayer::Vector(vl) = &mut layer_node.data { + for ci in &mut vl.clip_instances { + let (t, opacity) = vl.layer.animation_data.eval_clip_instance_transform( + ci.id, time, &ci.transform, ci.opacity, + ); + ci.transform = t; + ci.opacity = opacity; + } + } + } + } } // Create render context @@ -4636,6 +4741,7 @@ impl eframe::App for EditorApp { audio_controller: Some(&mut *controller), layer_to_track_map: &self.layer_to_track_map, clip_instance_to_backend_map: &mut self.clip_instance_to_backend_map, + clip_to_metatrack_map: &self.clip_to_metatrack_map, }; // Execute action with backend synchronization diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/asset_library.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/asset_library.rs index ed7c505..7c38e4c 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/asset_library.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/asset_library.rs @@ -1310,8 +1310,8 @@ impl AssetLibraryPane { /// Check if an asset is currently in use (has clip instances on layers) fn is_asset_in_use(document: &Document, asset_id: Uuid, category: AssetCategory) -> bool { - // Check all layers for clip instances referencing this asset - for layer in &document.root.children { + // Check all layers (root + inside movie clips) for clip instances referencing this asset + for layer in document.all_layers() { match layer { lightningbeam_core::layer::AnyLayer::Vector(vl) => { if category == AssetCategory::Vector { 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 1b2a30f..edb8310 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mod.rs @@ -636,10 +636,12 @@ impl NodeGraphPane { let mut controller = audio_controller.lock().unwrap(); // Node graph actions don't use clip instances, so we use an empty map let mut empty_clip_map = std::collections::HashMap::new(); + let empty_metatrack_map = std::collections::HashMap::new(); let mut backend_context = lightningbeam_core::action::BackendContext { audio_controller: Some(&mut *controller), layer_to_track_map: shared.layer_to_track_map, clip_instance_to_backend_map: &mut empty_clip_map, + clip_to_metatrack_map: &empty_metatrack_map, }; if let Err(e) = shared.action_executor.execute_with_backend(action, &mut backend_context) { diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs index 04d4fd9..2addb83 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs @@ -110,8 +110,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 { - // Movie clips: duration based on internal keyframe content - Some(vc.content_duration(document.framerate)) + // Movie clips: duration based on all internal content (keyframes + clip instances) + document.get_clip_duration(&clip_instance.clip_id) } } AnyLayer::Audio(_) => document.get_audio_clip(&clip_instance.clip_id).map(|c| c.duration), @@ -315,9 +315,7 @@ impl TimelinePane { let clip_instance = ClipInstance::new(doc_clip_id) .with_timeline_start(start_time); - if let Some(layer) = shared.action_executor.document_mut().root.children.iter_mut() - .find(|l| l.id() == active_layer_id) - { + if let Some(layer) = shared.action_executor.document_mut().get_layer_mut(&active_layer_id) { if let lightningbeam_core::layer::AnyLayer::Audio(audio_layer) = layer { audio_layer.clip_instances.push(clip_instance); } @@ -3313,8 +3311,15 @@ impl PaneRenderer for TimelinePane { let new_layer = super::create_layer_for_clip_type(dragging.clip_type, &layer_name); let new_layer_id = new_layer.id(); - // Add the layer - shared.action_executor.document_mut().root.add_child(new_layer); + // Add the layer to the current editing context + if let Some(clip_id) = shared.editing_clip_id { + if let Some(clip) = shared.action_executor.document_mut().vector_clips.get_mut(&clip_id) { + clip.layers.add_root(new_layer); + } + shared.action_executor.document_mut().layer_to_clip_map.insert(new_layer_id, clip_id); + } else { + shared.action_executor.document_mut().root.add_child(new_layer); + } // Now add the clip to the new layer if dragging.clip_type == DragClipType::Effect {