From 394e369122f031f39a0da4da6286f11ac9e23f5c Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Sun, 15 Feb 2026 02:11:57 -0500 Subject: [PATCH] Add clip split and duplicate commands --- .../src/actions/add_clip_instance.rs | 2 +- .../src/actions/move_clip_instances.rs | 48 +++----- .../lightningbeam-core/src/document.rs | 112 ++++++++++++++---- .../lightningbeam-editor/src/cqt_gpu.rs | 28 ++++- .../lightningbeam-editor/src/main.rs | 83 +++++++++++-- .../lightningbeam-editor/src/menu.rs | 60 ++++++++-- .../src/panes/timeline.rs | 82 +++++++++---- .../lightningbeam-editor/src/waveform_gpu.rs | 62 +++++++--- 8 files changed, 360 insertions(+), 117 deletions(-) diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/add_clip_instance.rs b/lightningbeam-ui/lightningbeam-core/src/actions/add_clip_instance.rs index ec7fb35..1474004 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/add_clip_instance.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/add_clip_instance.rs @@ -73,7 +73,7 @@ impl Action for AddClipInstanceAction { &self.layer_id, self.clip_instance.timeline_start, effective_duration, - None, // Not excluding any instance + &[], // Not excluding any instance ); if let Some(valid_start) = adjusted_start { 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 752a235..daecbd4 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/move_clip_instances.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/move_clip_instances.rs @@ -88,41 +88,25 @@ impl Action for MoveClipInstancesAction { let mut adjusted_layer_moves = Vec::new(); - for (instance_id, old_start, new_start) in moves { - // Get the instance to calculate its duration - let clip_instances: &[ClipInstance] = match layer { - AnyLayer::Audio(al) => &al.clip_instances, - AnyLayer::Video(vl) => &vl.clip_instances, - AnyLayer::Vector(vl) => &vl.clip_instances, - AnyLayer::Effect(el) => &el.clip_instances, - }; + let clip_instances: &[ClipInstance] = match layer { + AnyLayer::Audio(al) => &al.clip_instances, + AnyLayer::Video(vl) => &vl.clip_instances, + AnyLayer::Vector(vl) => &vl.clip_instances, + AnyLayer::Effect(el) => &el.clip_instances, + }; - let instance = clip_instances.iter() - .find(|ci| &ci.id == instance_id) - .ok_or_else(|| format!("Instance {} not found", instance_id))?; + let group: Vec<(Uuid, f64, f64)> = moves.iter().filter_map(|(id, old_start, _)| { + let inst = clip_instances.iter().find(|ci| &ci.id == id)?; + let dur = document.get_clip_duration(&inst.clip_id)?; + let eff = inst.trim_end.unwrap_or(dur) - inst.trim_start; + Some((*id, *old_start, eff)) + }).collect(); - let clip_duration = document.get_clip_duration(&instance.clip_id) - .ok_or_else(|| format!("Clip {} not found", instance.clip_id))?; + let desired_offset = moves[0].2 - moves[0].1; + let clamped = document.clamp_group_move_offset(layer_id, &group, desired_offset); - let trim_start = instance.trim_start; - let trim_end = instance.trim_end.unwrap_or(clip_duration); - let effective_duration = trim_end - trim_start; - - // Find nearest valid position, excluding this instance from overlap checks - let adjusted_start = document.find_nearest_valid_position( - layer_id, - *new_start, - effective_duration, - Some(instance_id), - ); - - if let Some(valid_start) = adjusted_start { - adjusted_layer_moves.push((*instance_id, *old_start, valid_start)); - } else { - return Err(format!( - "Cannot move clip: no valid position found on layer" - )); - } + for (instance_id, old_start, _) in moves { + adjusted_layer_moves.push((*instance_id, *old_start, (*old_start + clamped).max(0.0))); } adjusted_moves.insert(*layer_id, adjusted_layer_moves); diff --git a/lightningbeam-ui/lightningbeam-core/src/document.rs b/lightningbeam-ui/lightningbeam-core/src/document.rs index 6f22f1f..85cf1b6 100644 --- a/lightningbeam-ui/lightningbeam-core/src/document.rs +++ b/lightningbeam-ui/lightningbeam-core/src/document.rs @@ -549,7 +549,7 @@ impl Document { layer_id: &Uuid, start_time: f64, end_time: f64, - exclude_instance_id: Option<&Uuid>, + exclude_instance_ids: &[Uuid], ) -> (bool, Option) { let Some(layer) = self.get_layer(layer_id) else { return (false, None); @@ -568,11 +568,9 @@ impl Document { }; for instance in instances { - // Skip the instance we're checking against itself - if let Some(exclude_id) = exclude_instance_id { - if &instance.id == exclude_id { - continue; - } + // Skip excluded instances + if exclude_instance_ids.contains(&instance.id) { + continue; } // Calculate instance end time @@ -598,13 +596,13 @@ impl Document { /// /// Returns adjusted timeline_start, or None if no valid position exists /// - /// Strategy: Prefers snapping to the right (later in timeline) over left + /// Strategy: Snaps to whichever side (left or right) is closest to the desired position pub fn find_nearest_valid_position( &self, layer_id: &Uuid, desired_start: f64, clip_duration: f64, - exclude_instance_id: Option<&Uuid>, + exclude_instance_ids: &[Uuid], ) -> Option { let layer = self.get_layer(layer_id)?; @@ -618,7 +616,7 @@ impl Document { // Check if desired position is already valid let desired_end = desired_start + clip_duration; - let (overlaps, _) = self.check_overlap_on_layer(layer_id, desired_start, desired_end, exclude_instance_id); + let (overlaps, _) = self.check_overlap_on_layer(layer_id, desired_start, desired_end, exclude_instance_ids); if !overlaps { return Some(desired_start); } @@ -633,10 +631,8 @@ impl Document { let mut occupied_ranges: Vec<(f64, f64, Uuid)> = Vec::new(); for instance in instances { - if let Some(exclude_id) = exclude_instance_id { - if &instance.id == exclude_id { - continue; - } + if exclude_instance_ids.contains(&instance.id) { + continue; } if let Some(clip_dur) = self.get_clip_duration(&instance.clip_id) { @@ -651,22 +647,22 @@ impl Document { // Sort by start time occupied_ranges.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal)); - // Find the clip we're overlapping with + // Find the clip we're overlapping with and try both sides, pick nearest for (occupied_start, occupied_end, _) in &occupied_ranges { if desired_start < *occupied_end && *occupied_start < desired_end { + let mut candidates: Vec = Vec::new(); + // Try snapping to the right (after this clip) let snap_right = *occupied_end; let snap_right_end = snap_right + clip_duration; - let (overlaps_right, _) = self.check_overlap_on_layer( layer_id, snap_right, snap_right_end, - exclude_instance_id, + exclude_instance_ids, ); - if !overlaps_right { - return Some(snap_right); + candidates.push(snap_right); } // Try snapping to the left (before this clip) @@ -676,13 +672,22 @@ impl Document { layer_id, snap_left, *occupied_start, - exclude_instance_id, + exclude_instance_ids, ); - if !overlaps_left { - return Some(snap_left); + candidates.push(snap_left); } } + + // Pick the candidate closest to desired_start + if !candidates.is_empty() { + candidates.sort_by(|a, b| { + let dist_a = (a - desired_start).abs(); + let dist_b = (b - desired_start).abs(); + dist_a.partial_cmp(&dist_b).unwrap_or(std::cmp::Ordering::Equal) + }); + return Some(candidates[0]); + } } } @@ -695,6 +700,69 @@ impl Document { None } + /// Clamp a group move offset so no clip in the group overlaps a non-group clip or + /// goes before timeline start. All clips move by the same returned offset. + pub fn clamp_group_move_offset( + &self, + layer_id: &Uuid, + group: &[(Uuid, f64, f64)], // (instance_id, timeline_start, effective_duration) + desired_offset: f64, + ) -> f64 { + let Some(layer) = self.get_layer(layer_id) else { + return desired_offset; + }; + if matches!(layer, AnyLayer::Vector(_)) { + return desired_offset; + } + + let group_ids: Vec = group.iter().map(|(id, _, _)| *id).collect(); + + let instances: &[ClipInstance] = match layer { + AnyLayer::Audio(a) => &a.clip_instances, + AnyLayer::Video(v) => &v.clip_instances, + AnyLayer::Effect(e) => &e.clip_instances, + AnyLayer::Vector(v) => &v.clip_instances, + }; + + // Collect non-group clip ranges + let mut non_group: Vec<(f64, f64)> = Vec::new(); + for inst in instances { + if group_ids.contains(&inst.id) { + continue; + } + if let Some(dur) = self.get_clip_duration(&inst.clip_id) { + let end = inst.timeline_start + (inst.trim_end.unwrap_or(dur) - inst.trim_start); + non_group.push((inst.timeline_start, end)); + } + } + + let mut clamped = desired_offset; + + for &(_, start, duration) in group { + let end = start + duration; + + // Can't go before timeline start + clamped = clamped.max(-start); + + // Check against non-group clips + for &(ns, ne) in &non_group { + if clamped < 0.0 { + // Moving left: if non-group clip end is between our destination and current start + if ne <= start && ne > start + clamped { + clamped = clamped.max(ne - start); + } + } else if clamped > 0.0 { + // Moving right: if non-group clip start is between our current end and destination + if ns >= end && ns < end + clamped { + clamped = clamped.min(ns - end); + } + } + } + } + + clamped + } + /// Find the maximum amount we can extend a clip to the left without overlapping /// /// Returns the distance to the nearest clip to the left, or the distance to @@ -788,7 +856,7 @@ impl Document { if nearest_start == f64::MAX { f64::MAX // No clip to the right, can extend freely } else { - nearest_start - current_timeline_start // Distance to next clip + (nearest_start - current_end).max(0.0) // Gap between our end and next clip's start } } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/cqt_gpu.rs b/lightningbeam-ui/lightningbeam-editor/src/cqt_gpu.rs index 1409290..ac84e57 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/cqt_gpu.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/cqt_gpu.rs @@ -349,9 +349,20 @@ impl CqtGpuResources { if let Some(entry) = self.entries.get_mut(&pool_index) { if entry.waveform_total_frames != total_frames { // Waveform texture updated in-place with more data. - // The texture view is still valid (no destroy/recreate), - // so just update total_frames to allow computing new columns. + // Invalidate cached CQT columns near the old boundary — they were + // computed with truncated windows and show artifacts. Rewind + // cache_valid_end so those columns get recomputed with full data. + // The lowest bin (A0=27.5Hz) has the longest window: + // Q = 1/(2^(1/B)-1), N_k = ceil(Q * sr / f_min), margin = N_k/2/hop + let q = 1.0 / (2.0_f64.powf(1.0 / BINS_PER_OCTAVE as f64) - 1.0); + let max_window = (q * entry.sample_rate as f64 / F_MIN).ceil() as i64; + let margin_cols = max_window / 2 / HOP_SIZE as i64 + 1; + let old_max_col = entry.waveform_total_frames as i64 / HOP_SIZE as i64; entry.waveform_total_frames = total_frames; + let invalidate_from = old_max_col - margin_cols; + if entry.cache_valid_end > invalidate_from { + entry.cache_valid_end = invalidate_from.max(entry.cache_valid_start); + } } return; } @@ -621,7 +632,8 @@ impl egui_wgpu::CallbackTrait for CqtCallback { && vis_start < cache_valid_end && vis_end > cache_valid_end { - // Scrolling right — align to stride boundary + // Scrolling right — compute new columns at the right edge. + // cache_start_column stays fixed; the ring buffer wraps naturally. let actual_end = cache_valid_end + (vis_end - cache_valid_end).min(max_cols_global); cmds = dispatch_cqt_compute( @@ -633,22 +645,26 @@ impl egui_wgpu::CallbackTrait for CqtCallback { let cache_cap_global = entry.cache_capacity as i64 * stride; if entry.cache_valid_end - entry.cache_valid_start > cache_cap_global { entry.cache_valid_start = entry.cache_valid_end - cache_cap_global; - entry.cache_start_column = entry.cache_valid_start; } } else if vis_end <= cache_valid_end && vis_end > cache_valid_start && vis_start < cache_valid_start { - // Scrolling left + // Scrolling left — move anchor BEFORE dispatch so compute and + // render use the same cache_start_column mapping. let actual_start = cache_valid_start - (cache_valid_start - vis_start).min(max_cols_global); + { + let entry = cqt_gpu.entries.get_mut(&self.pool_index).unwrap(); + entry.cache_start_column = actual_start; + } + let entry = cqt_gpu.entries.get(&self.pool_index).unwrap(); cmds = dispatch_cqt_compute( device, queue, &cqt_gpu.compute_pipeline, entry, actual_start, cache_valid_start, self.stride, ); let entry = cqt_gpu.entries.get_mut(&self.pool_index).unwrap(); entry.cache_valid_start = actual_start; - entry.cache_start_column = actual_start; let cache_cap_global = entry.cache_capacity as i64 * stride; if entry.cache_valid_end - entry.cache_valid_start > cache_cap_global { entry.cache_valid_end = entry.cache_valid_start + cache_cap_global; diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index eebad36..a80576c 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -1472,6 +1472,73 @@ impl EditorApp { } } + /// Duplicate the selected clip instances on the active layer. + /// Each duplicate is placed immediately after the original clip. + fn duplicate_selected_clips(&mut self) { + use lightningbeam_core::layer::AnyLayer; + use lightningbeam_core::actions::AddClipInstanceAction; + + let active_layer_id = match self.active_layer_id { + Some(id) => id, + None => return, + }; + + let document = self.action_executor.document(); + let selection = &self.selection; + + // Find selected clip instances on the active layer + let clips_to_duplicate: Vec = { + let layer = match document.get_layer(&active_layer_id) { + Some(l) => l, + None => return, + }; + let instances = match layer { + AnyLayer::Vector(vl) => &vl.clip_instances, + AnyLayer::Audio(al) => &al.clip_instances, + AnyLayer::Video(vl) => &vl.clip_instances, + AnyLayer::Effect(el) => &el.clip_instances, + }; + instances.iter() + .filter(|ci| selection.contains_clip_instance(&ci.id)) + .cloned() + .collect() + }; + + if clips_to_duplicate.is_empty() { + return; + } + + // Collect all duplicate instances upfront to release the document borrow + let duplicates: Vec = clips_to_duplicate.iter().map(|original| { + let mut duplicate = original.clone(); + duplicate.id = uuid::Uuid::new_v4(); + let clip_duration = document.get_clip_duration(&original.clip_id).unwrap_or(1.0); + let effective_duration = original.effective_duration(clip_duration); + duplicate.timeline_start = original.timeline_start + effective_duration; + duplicate + }).collect(); + + for duplicate in duplicates { + let action = AddClipInstanceAction::new(active_layer_id, duplicate); + + if let Some(ref controller_arc) = self.audio_controller { + let mut controller = controller_arc.lock().unwrap(); + let mut backend_context = lightningbeam_core::action::BackendContext { + 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, + }; + if let Err(e) = self.action_executor.execute_with_backend(Box::new(action), &mut backend_context) { + eprintln!("Duplicate clip failed: {}", e); + } + } else { + if let Err(e) = self.action_executor.execute(Box::new(action)) { + eprintln!("Duplicate clip failed: {}", e); + } + } + } + } + fn switch_layout(&mut self, index: usize) { self.current_layout_index = index; self.current_layout = self.layouts[index].layout.clone(); @@ -1835,6 +1902,12 @@ impl EditorApp { println!("Menu: Bring to Front"); // TODO: Implement bring to front } + MenuAction::SplitClip => { + self.split_clips_at_playhead(); + } + MenuAction::DuplicateClip => { + self.duplicate_selected_clips(); + } // Layer menu MenuAction::AddLayer => { @@ -4049,16 +4122,6 @@ impl eframe::App for EditorApp { // This ensures text fields have had a chance to claim focus first let wants_keyboard = ctx.wants_keyboard_input(); - // Check for Ctrl+K (split clip at playhead) - needs to be outside the input closure - // so we can mutate self - let split_clips_requested = ctx.input(|i| { - (i.modifiers.ctrl || i.modifiers.command) && i.key_pressed(egui::Key::K) - }); - - if split_clips_requested { - self.split_clips_at_playhead(); - } - // Space bar toggles play/pause (only when no text input is focused) if !wants_keyboard && ctx.input(|i| i.key_pressed(egui::Key::Space)) { self.is_playing = !self.is_playing; diff --git a/lightningbeam-ui/lightningbeam-editor/src/menu.rs b/lightningbeam-ui/lightningbeam-editor/src/menu.rs index 535ec0c..baa47c4 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/menu.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/menu.rs @@ -25,7 +25,7 @@ pub struct Shortcut { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ShortcutKey { // Letters - A, C, E, G, I, K, L, N, O, Q, S, V, W, X, Z, + A, C, D, E, G, I, K, L, N, O, Q, S, V, W, X, Z, // Numbers Num0, // Symbols @@ -61,6 +61,7 @@ impl Shortcut { let code = match self.key { ShortcutKey::A => Code::KeyA, ShortcutKey::C => Code::KeyC, + ShortcutKey::D => Code::KeyD, ShortcutKey::E => Code::KeyE, ShortcutKey::G => Code::KeyG, ShortcutKey::I => Code::KeyI, @@ -104,6 +105,7 @@ impl Shortcut { let key = match self.key { ShortcutKey::A => egui::Key::A, ShortcutKey::C => egui::Key::C, + ShortcutKey::D => egui::Key::D, ShortcutKey::E => egui::Key::E, ShortcutKey::G => egui::Key::G, ShortcutKey::I => egui::Key::I, @@ -163,6 +165,8 @@ pub enum MenuAction { Group, SendToBack, BringToFront, + SplitClip, + DuplicateClip, // Layer menu AddLayer, @@ -257,6 +261,8 @@ impl MenuItemDef { const GROUP: Self = Self { label: "Group", action: MenuAction::Group, shortcut: Some(Shortcut::new(ShortcutKey::G, CTRL, NO_SHIFT, NO_ALT)) }; 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)) }; + const DUPLICATE_CLIP: Self = Self { label: "Duplicate Clip", action: MenuAction::DuplicateClip, shortcut: Some(Shortcut::new(ShortcutKey::D, CTRL, NO_SHIFT, NO_ALT)) }; // Layer menu items const ADD_LAYER: Self = Self { label: "Add Layer", action: MenuAction::AddLayer, shortcut: Some(Shortcut::new(ShortcutKey::L, CTRL, SHIFT, NO_ALT)) }; @@ -366,6 +372,9 @@ impl MenuItemDef { MenuDef::Separator, MenuDef::Item(&Self::SEND_TO_BACK), MenuDef::Item(&Self::BRING_TO_FRONT), + MenuDef::Separator, + MenuDef::Item(&Self::SPLIT_CLIP), + MenuDef::Item(&Self::DUPLICATE_CLIP), ], }, // Layer menu @@ -677,21 +686,47 @@ impl MenuSystem { // Set minimum width for menu items to prevent cramping ui.set_min_width(180.0); - if shortcut_text.is_empty() { - ui.add(egui::Button::new(def.label).min_size(egui::vec2(0.0, 0.0))).clicked() - } else { - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 20.0; // More space between label and shortcut + let desired_width = ui.available_width(); + let (rect, response) = ui.allocate_exact_size( + egui::vec2(desired_width, ui.spacing().interact_size.y), + egui::Sense::click(), + ); - let button = ui.add(egui::Button::new(def.label).min_size(egui::vec2(0.0, 0.0))); + if ui.is_rect_visible(rect) { + // Highlight on hover + if response.hovered() { + ui.painter().rect_filled(rect, 2.0, ui.visuals().widgets.hovered.bg_fill); + } - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.label(egui::RichText::new(&shortcut_text).weak().size(12.0)); - }); + // Draw label text left-aligned + let text_color = if response.hovered() { + ui.visuals().widgets.hovered.text_color() + } else { + ui.visuals().widgets.inactive.text_color() + }; + let label_pos = rect.min + egui::vec2(4.0, (rect.height() - 14.0) / 2.0); + ui.painter().text( + label_pos, + egui::Align2::LEFT_TOP, + def.label, + egui::FontId::proportional(14.0), + text_color, + ); - button.clicked() - }).inner + // Draw shortcut text right-aligned + if !shortcut_text.is_empty() { + let shortcut_pos = rect.max - egui::vec2(4.0, (rect.height() - 12.0) / 2.0); + ui.painter().text( + shortcut_pos, + egui::Align2::RIGHT_BOTTOM, + &shortcut_text, + egui::FontId::proportional(12.0), + ui.visuals().weak_text_color(), + ); + } } + + response.clicked() } /// Format shortcut for display (e.g., "Ctrl+S") @@ -711,6 +746,7 @@ impl MenuSystem { let key_name = match shortcut.key { ShortcutKey::A => "A", ShortcutKey::C => "C", + ShortcutKey::D => "D", ShortcutKey::E => "E", ShortcutKey::G => "G", ShortcutKey::I => "I", diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs index c081c64..b77abec 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs @@ -93,7 +93,7 @@ fn find_sampled_audio_track_for_clip( &audio_layer.layer.id, timeline_start, clip_end, - None, // Don't exclude any instances + &[], // Don't exclude any instances ); if !overlaps { @@ -997,6 +997,24 @@ impl TimelinePane { lightningbeam_core::layer::AnyLayer::Effect(el) => &el.clip_instances, }; + // For moves, precompute the clamped offset so all selected clips move uniformly + let group_move_offset = if self.clip_drag_state == Some(ClipDragType::Move) { + let group: Vec<(uuid::Uuid, f64, f64)> = clip_instances.iter() + .filter(|ci| selection.contains_clip_instance(&ci.id)) + .filter_map(|ci| { + let dur = document.get_clip_duration(&ci.clip_id)?; + Some((ci.id, ci.timeline_start, ci.effective_duration(dur))) + }) + .collect(); + if !group.is_empty() { + Some(document.clamp_group_move_offset(&layer.id(), &group, self.drag_offset)) + } else { + None + } + } else { + None + }; + for clip_instance in clip_instances { // Get the clip to determine duration let clip_duration = match layer { @@ -1052,21 +1070,9 @@ impl TimelinePane { if is_selected || is_linked_to_dragged { match drag_type { ClipDragType::Move => { - // Move: shift the entire clip along the timeline with auto-snap preview - let desired_start = clip_instance.timeline_start + self.drag_offset; - let current_duration = instance_duration; - - // Find snapped position for preview - let snapped_start = document - .find_nearest_valid_position( - &layer.id(), - desired_start, - current_duration, - Some(&clip_instance.id), - ) - .unwrap_or(desired_start); - - instance_start = snapped_start; + if let Some(offset) = group_move_offset { + instance_start = (clip_instance.timeline_start + offset).max(0.0); + } } ClipDragType::TrimLeft => { // Trim left: calculate new trim_start with snap to adjacent clips @@ -1246,6 +1252,8 @@ impl TimelinePane { ); if waveform_rect.width() > 0.0 && waveform_rect.height() > 0.0 { + // Use clip instance UUID's lower 64 bits as stable instance ID + let instance_id = clip_instance.id.as_u128() as u64; let callback = crate::waveform_gpu::WaveformCallback { pool_index: *audio_pool_index, segment_index: 0, @@ -1268,6 +1276,7 @@ impl TimelinePane { }, target_format, pending_upload, + instance_id, }; ui.painter().add(egui_wgpu::Callback::new_paint_callback( @@ -1640,11 +1649,25 @@ impl TimelinePane { clip_instance.timeline_start; // New trim_start is clamped to valid range - let new_trim_start = (old_trim_start + let desired_trim_start = (old_trim_start + self.drag_offset) .max(0.0) .min(clip_duration); + // Apply overlap prevention when extending left + let new_trim_start = if desired_trim_start < old_trim_start { + let max_extend = document.find_max_trim_extend_left( + &layer_id, + &clip_instance.id, + old_timeline_start, + ); + let desired_extend = old_trim_start - desired_trim_start; + let actual_extend = desired_extend.min(max_extend); + old_trim_start - actual_extend + } else { + desired_trim_start + }; + // Calculate actual offset after clamping let actual_offset = new_trim_start - old_trim_start; let new_timeline_start = @@ -1672,8 +1695,27 @@ impl TimelinePane { // Calculate new trim_end based on current duration let current_duration = clip_instance.effective_duration(clip_duration); - let new_duration = - (current_duration + self.drag_offset).max(0.0); + let old_trim_end_val = clip_instance.trim_end.unwrap_or(clip_duration); + let desired_trim_end = (old_trim_end_val + self.drag_offset) + .max(clip_instance.trim_start) + .min(clip_duration); + + // Apply overlap prevention when extending right + let new_trim_end_val = if desired_trim_end > old_trim_end_val { + let max_extend = document.find_max_trim_extend_right( + &layer_id, + &clip_instance.id, + clip_instance.timeline_start, + current_duration, + ); + let desired_extend = desired_trim_end - old_trim_end_val; + let actual_extend = desired_extend.min(max_extend); + old_trim_end_val + actual_extend + } else { + desired_trim_end + }; + + let new_duration = (new_trim_end_val - clip_instance.trim_start).max(0.0); // Convert new duration back to trim_end value let new_trim_end = if new_duration >= clip_duration { @@ -2244,7 +2286,7 @@ impl PaneRenderer for TimelinePane { &layer.id(), raw_drop_time, clip_duration, - None, + &[], ); snapped.unwrap_or(raw_drop_time) diff --git a/lightningbeam-ui/lightningbeam-editor/src/waveform_gpu.rs b/lightningbeam-ui/lightningbeam-editor/src/waveform_gpu.rs index 82d492b..50b6f6c 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/waveform_gpu.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/waveform_gpu.rs @@ -28,6 +28,10 @@ pub struct WaveformGpuResources { mipgen_bind_group_layout: wgpu::BindGroupLayout, /// Sampler for waveform texture (nearest, since we do manual LOD selection) sampler: wgpu::Sampler, + /// Per-callback-instance uniform buffers and bind groups. + /// Keyed by (pool_index, instance_id). Each clip instance referencing the same + /// pool_index gets its own uniform buffer so multiple clips don't clobber each other. + per_instance: HashMap<(usize, u64), (wgpu::Buffer, wgpu::BindGroup)>, } /// GPU data for a single audio file @@ -92,8 +96,12 @@ pub struct WaveformCallback { pub target_format: wgpu::TextureFormat, /// Raw audio data for upload if this is the first time we see this pool_index pub pending_upload: Option, + /// Unique ID for this callback instance (allows multiple clips sharing the same + /// pool_index to have independent uniform buffers) + pub instance_id: u64, } + /// Raw audio data waiting to be uploaded to GPU pub struct PendingUpload { pub samples: Vec, @@ -259,6 +267,7 @@ impl WaveformGpuResources { render_bind_group_layout, mipgen_bind_group_layout, sampler, + per_instance: HashMap::new(), } } @@ -364,6 +373,8 @@ impl WaveformGpuResources { // Full create (first upload or texture needs to grow) self.entries.remove(&pool_index); + // Invalidate per-instance bind groups for this pool (texture changed) + self.per_instance.retain(|&(pi, _), _| pi != pool_index); let total_frames = new_total_frames; @@ -652,14 +663,40 @@ impl egui_wgpu::CallbackTrait for WaveformCallback { cmds.extend(new_cmds); } - // Update uniform buffer for this draw + // Get or create a per-instance uniform buffer + bind group. + // This ensures multiple clip instances sharing the same pool_index + // don't clobber each other's shader params. + let key = (self.pool_index, self.instance_id); if let Some(entry) = gpu_resources.entries.get(&self.pool_index) { - if self.segment_index < entry.uniform_buffers.len() { - queue.write_buffer( - &entry.uniform_buffers[self.segment_index], - 0, - bytemuck::cast_slice(&[self.params]), - ); + if self.segment_index < entry.texture_views.len() { + let (buf, _bg) = gpu_resources.per_instance.entry(key).or_insert_with(|| { + let buf = device.create_buffer(&wgpu::BufferDescriptor { + label: Some(&format!("waveform_{}_inst_{}", self.pool_index, self.instance_id)), + size: std::mem::size_of::() as u64, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let bg = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some(&format!("waveform_{}_inst_{}_bg", self.pool_index, self.instance_id)), + layout: &gpu_resources.render_bind_group_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(&entry.texture_views[self.segment_index]), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&gpu_resources.sampler), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: buf.as_entire_binding(), + }, + ], + }); + (buf, bg) + }); + queue.write_buffer(buf, 0, bytemuck::cast_slice(&[self.params])); } } @@ -677,17 +714,14 @@ impl egui_wgpu::CallbackTrait for WaveformCallback { None => return, }; - let entry = match gpu_resources.entries.get(&self.pool_index) { - Some(e) => e, + let key = (self.pool_index, self.instance_id); + let (_buf, bind_group) = match gpu_resources.per_instance.get(&key) { + Some(entry) => entry, None => return, }; - if self.segment_index >= entry.render_bind_groups.len() { - return; - } - render_pass.set_pipeline(&gpu_resources.render_pipeline); - render_pass.set_bind_group(0, &entry.render_bind_groups[self.segment_index], &[]); + render_pass.set_bind_group(0, bind_group, &[]); render_pass.draw(0..3, 0..1); // Fullscreen triangle } }