Add clip split and duplicate commands

This commit is contained in:
Skyler Lehmkuhl 2026-02-15 02:11:57 -05:00
parent 12d927ed3d
commit 394e369122
8 changed files with 360 additions and 117 deletions

View File

@ -73,7 +73,7 @@ impl Action for AddClipInstanceAction {
&self.layer_id, &self.layer_id,
self.clip_instance.timeline_start, self.clip_instance.timeline_start,
effective_duration, effective_duration,
None, // Not excluding any instance &[], // Not excluding any instance
); );
if let Some(valid_start) = adjusted_start { if let Some(valid_start) = adjusted_start {

View File

@ -88,8 +88,6 @@ impl Action for MoveClipInstancesAction {
let mut adjusted_layer_moves = Vec::new(); 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 { let clip_instances: &[ClipInstance] = match layer {
AnyLayer::Audio(al) => &al.clip_instances, AnyLayer::Audio(al) => &al.clip_instances,
AnyLayer::Video(vl) => &vl.clip_instances, AnyLayer::Video(vl) => &vl.clip_instances,
@ -97,32 +95,18 @@ impl Action for MoveClipInstancesAction {
AnyLayer::Effect(el) => &el.clip_instances, AnyLayer::Effect(el) => &el.clip_instances,
}; };
let instance = clip_instances.iter() let group: Vec<(Uuid, f64, f64)> = moves.iter().filter_map(|(id, old_start, _)| {
.find(|ci| &ci.id == instance_id) let inst = clip_instances.iter().find(|ci| &ci.id == id)?;
.ok_or_else(|| format!("Instance {} not found", instance_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) let desired_offset = moves[0].2 - moves[0].1;
.ok_or_else(|| format!("Clip {} not found", instance.clip_id))?; let clamped = document.clamp_group_move_offset(layer_id, &group, desired_offset);
let trim_start = instance.trim_start; for (instance_id, old_start, _) in moves {
let trim_end = instance.trim_end.unwrap_or(clip_duration); adjusted_layer_moves.push((*instance_id, *old_start, (*old_start + clamped).max(0.0)));
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"
));
}
} }
adjusted_moves.insert(*layer_id, adjusted_layer_moves); adjusted_moves.insert(*layer_id, adjusted_layer_moves);

View File

@ -549,7 +549,7 @@ impl Document {
layer_id: &Uuid, layer_id: &Uuid,
start_time: f64, start_time: f64,
end_time: f64, end_time: f64,
exclude_instance_id: Option<&Uuid>, exclude_instance_ids: &[Uuid],
) -> (bool, Option<Uuid>) { ) -> (bool, Option<Uuid>) {
let Some(layer) = self.get_layer(layer_id) else { let Some(layer) = self.get_layer(layer_id) else {
return (false, None); return (false, None);
@ -568,12 +568,10 @@ impl Document {
}; };
for instance in instances { for instance in instances {
// Skip the instance we're checking against itself // Skip excluded instances
if let Some(exclude_id) = exclude_instance_id { if exclude_instance_ids.contains(&instance.id) {
if &instance.id == exclude_id {
continue; continue;
} }
}
// Calculate instance end time // Calculate instance end time
let Some(clip_duration) = self.get_clip_duration(&instance.clip_id) else { let Some(clip_duration) = self.get_clip_duration(&instance.clip_id) else {
@ -598,13 +596,13 @@ impl Document {
/// ///
/// Returns adjusted timeline_start, or None if no valid position exists /// 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( pub fn find_nearest_valid_position(
&self, &self,
layer_id: &Uuid, layer_id: &Uuid,
desired_start: f64, desired_start: f64,
clip_duration: f64, clip_duration: f64,
exclude_instance_id: Option<&Uuid>, exclude_instance_ids: &[Uuid],
) -> Option<f64> { ) -> Option<f64> {
let layer = self.get_layer(layer_id)?; let layer = self.get_layer(layer_id)?;
@ -618,7 +616,7 @@ impl Document {
// Check if desired position is already valid // Check if desired position is already valid
let desired_end = desired_start + clip_duration; 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 { if !overlaps {
return Some(desired_start); return Some(desired_start);
} }
@ -633,11 +631,9 @@ impl Document {
let mut occupied_ranges: Vec<(f64, f64, Uuid)> = Vec::new(); let mut occupied_ranges: Vec<(f64, f64, Uuid)> = Vec::new();
for instance in instances { for instance in instances {
if let Some(exclude_id) = exclude_instance_id { if exclude_instance_ids.contains(&instance.id) {
if &instance.id == exclude_id {
continue; continue;
} }
}
if let Some(clip_dur) = self.get_clip_duration(&instance.clip_id) { if let Some(clip_dur) = self.get_clip_duration(&instance.clip_id) {
let inst_start = instance.timeline_start; let inst_start = instance.timeline_start;
@ -651,22 +647,22 @@ impl Document {
// Sort by start time // Sort by start time
occupied_ranges.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal)); 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 { for (occupied_start, occupied_end, _) in &occupied_ranges {
if desired_start < *occupied_end && *occupied_start < desired_end { if desired_start < *occupied_end && *occupied_start < desired_end {
let mut candidates: Vec<f64> = Vec::new();
// Try snapping to the right (after this clip) // Try snapping to the right (after this clip)
let snap_right = *occupied_end; let snap_right = *occupied_end;
let snap_right_end = snap_right + clip_duration; let snap_right_end = snap_right + clip_duration;
let (overlaps_right, _) = self.check_overlap_on_layer( let (overlaps_right, _) = self.check_overlap_on_layer(
layer_id, layer_id,
snap_right, snap_right,
snap_right_end, snap_right_end,
exclude_instance_id, exclude_instance_ids,
); );
if !overlaps_right { if !overlaps_right {
return Some(snap_right); candidates.push(snap_right);
} }
// Try snapping to the left (before this clip) // Try snapping to the left (before this clip)
@ -676,13 +672,22 @@ impl Document {
layer_id, layer_id,
snap_left, snap_left,
*occupied_start, *occupied_start,
exclude_instance_id, exclude_instance_ids,
); );
if !overlaps_left { 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 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<Uuid> = 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 /// 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 /// 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 { if nearest_start == f64::MAX {
f64::MAX // No clip to the right, can extend freely f64::MAX // No clip to the right, can extend freely
} else { } 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
} }
} }
} }

View File

@ -349,9 +349,20 @@ impl CqtGpuResources {
if let Some(entry) = self.entries.get_mut(&pool_index) { if let Some(entry) = self.entries.get_mut(&pool_index) {
if entry.waveform_total_frames != total_frames { if entry.waveform_total_frames != total_frames {
// Waveform texture updated in-place with more data. // Waveform texture updated in-place with more data.
// The texture view is still valid (no destroy/recreate), // Invalidate cached CQT columns near the old boundary — they were
// so just update total_frames to allow computing new columns. // 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; 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; return;
} }
@ -621,7 +632,8 @@ impl egui_wgpu::CallbackTrait for CqtCallback {
&& vis_start < cache_valid_end && vis_start < cache_valid_end
&& vis_end > 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 = let actual_end =
cache_valid_end + (vis_end - cache_valid_end).min(max_cols_global); cache_valid_end + (vis_end - cache_valid_end).min(max_cols_global);
cmds = dispatch_cqt_compute( cmds = dispatch_cqt_compute(
@ -633,22 +645,26 @@ impl egui_wgpu::CallbackTrait for CqtCallback {
let cache_cap_global = entry.cache_capacity as i64 * stride; let cache_cap_global = entry.cache_capacity as i64 * stride;
if entry.cache_valid_end - entry.cache_valid_start > cache_cap_global { 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_valid_start = entry.cache_valid_end - cache_cap_global;
entry.cache_start_column = entry.cache_valid_start;
} }
} else if vis_end <= cache_valid_end } else if vis_end <= cache_valid_end
&& vis_end > cache_valid_start && vis_end > cache_valid_start
&& vis_start < 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 = let actual_start =
cache_valid_start - (cache_valid_start - vis_start).min(max_cols_global); 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( cmds = dispatch_cqt_compute(
device, queue, &cqt_gpu.compute_pipeline, entry, device, queue, &cqt_gpu.compute_pipeline, entry,
actual_start, cache_valid_start, self.stride, actual_start, cache_valid_start, self.stride,
); );
let entry = cqt_gpu.entries.get_mut(&self.pool_index).unwrap(); let entry = cqt_gpu.entries.get_mut(&self.pool_index).unwrap();
entry.cache_valid_start = actual_start; entry.cache_valid_start = actual_start;
entry.cache_start_column = actual_start;
let cache_cap_global = entry.cache_capacity as i64 * stride; let cache_cap_global = entry.cache_capacity as i64 * stride;
if entry.cache_valid_end - entry.cache_valid_start > cache_cap_global { if entry.cache_valid_end - entry.cache_valid_start > cache_cap_global {
entry.cache_valid_end = entry.cache_valid_start + cache_cap_global; entry.cache_valid_end = entry.cache_valid_start + cache_cap_global;

View File

@ -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<lightningbeam_core::clip::ClipInstance> = {
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<lightningbeam_core::clip::ClipInstance> = 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) { fn switch_layout(&mut self, index: usize) {
self.current_layout_index = index; self.current_layout_index = index;
self.current_layout = self.layouts[index].layout.clone(); self.current_layout = self.layouts[index].layout.clone();
@ -1835,6 +1902,12 @@ impl EditorApp {
println!("Menu: Bring to Front"); println!("Menu: Bring to Front");
// TODO: Implement bring to front // TODO: Implement bring to front
} }
MenuAction::SplitClip => {
self.split_clips_at_playhead();
}
MenuAction::DuplicateClip => {
self.duplicate_selected_clips();
}
// Layer menu // Layer menu
MenuAction::AddLayer => { MenuAction::AddLayer => {
@ -4049,16 +4122,6 @@ impl eframe::App for EditorApp {
// This ensures text fields have had a chance to claim focus first // This ensures text fields have had a chance to claim focus first
let wants_keyboard = ctx.wants_keyboard_input(); 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) // Space bar toggles play/pause (only when no text input is focused)
if !wants_keyboard && ctx.input(|i| i.key_pressed(egui::Key::Space)) { if !wants_keyboard && ctx.input(|i| i.key_pressed(egui::Key::Space)) {
self.is_playing = !self.is_playing; self.is_playing = !self.is_playing;

View File

@ -25,7 +25,7 @@ pub struct Shortcut {
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ShortcutKey { pub enum ShortcutKey {
// Letters // 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 // Numbers
Num0, Num0,
// Symbols // Symbols
@ -61,6 +61,7 @@ impl Shortcut {
let code = match self.key { let code = match self.key {
ShortcutKey::A => Code::KeyA, ShortcutKey::A => Code::KeyA,
ShortcutKey::C => Code::KeyC, ShortcutKey::C => Code::KeyC,
ShortcutKey::D => Code::KeyD,
ShortcutKey::E => Code::KeyE, ShortcutKey::E => Code::KeyE,
ShortcutKey::G => Code::KeyG, ShortcutKey::G => Code::KeyG,
ShortcutKey::I => Code::KeyI, ShortcutKey::I => Code::KeyI,
@ -104,6 +105,7 @@ impl Shortcut {
let key = match self.key { let key = match self.key {
ShortcutKey::A => egui::Key::A, ShortcutKey::A => egui::Key::A,
ShortcutKey::C => egui::Key::C, ShortcutKey::C => egui::Key::C,
ShortcutKey::D => egui::Key::D,
ShortcutKey::E => egui::Key::E, ShortcutKey::E => egui::Key::E,
ShortcutKey::G => egui::Key::G, ShortcutKey::G => egui::Key::G,
ShortcutKey::I => egui::Key::I, ShortcutKey::I => egui::Key::I,
@ -163,6 +165,8 @@ pub enum MenuAction {
Group, Group,
SendToBack, SendToBack,
BringToFront, BringToFront,
SplitClip,
DuplicateClip,
// Layer menu // Layer menu
AddLayer, 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 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 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 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 // Layer menu items
const ADD_LAYER: Self = Self { label: "Add Layer", action: MenuAction::AddLayer, shortcut: Some(Shortcut::new(ShortcutKey::L, CTRL, SHIFT, NO_ALT)) }; 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::Separator,
MenuDef::Item(&Self::SEND_TO_BACK), MenuDef::Item(&Self::SEND_TO_BACK),
MenuDef::Item(&Self::BRING_TO_FRONT), MenuDef::Item(&Self::BRING_TO_FRONT),
MenuDef::Separator,
MenuDef::Item(&Self::SPLIT_CLIP),
MenuDef::Item(&Self::DUPLICATE_CLIP),
], ],
}, },
// Layer menu // Layer menu
@ -677,21 +686,47 @@ impl MenuSystem {
// Set minimum width for menu items to prevent cramping // Set minimum width for menu items to prevent cramping
ui.set_min_width(180.0); ui.set_min_width(180.0);
if shortcut_text.is_empty() { let desired_width = ui.available_width();
ui.add(egui::Button::new(def.label).min_size(egui::vec2(0.0, 0.0))).clicked() let (rect, response) = ui.allocate_exact_size(
} else { egui::vec2(desired_width, ui.spacing().interact_size.y),
ui.horizontal(|ui| { egui::Sense::click(),
ui.spacing_mut().item_spacing.x = 20.0; // More space between label and shortcut );
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
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if response.hovered() {
ui.label(egui::RichText::new(&shortcut_text).weak().size(12.0)); ui.painter().rect_filled(rect, 2.0, ui.visuals().widgets.hovered.bg_fill);
});
button.clicked()
}).inner
} }
// 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,
);
// 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") /// Format shortcut for display (e.g., "Ctrl+S")
@ -711,6 +746,7 @@ impl MenuSystem {
let key_name = match shortcut.key { let key_name = match shortcut.key {
ShortcutKey::A => "A", ShortcutKey::A => "A",
ShortcutKey::C => "C", ShortcutKey::C => "C",
ShortcutKey::D => "D",
ShortcutKey::E => "E", ShortcutKey::E => "E",
ShortcutKey::G => "G", ShortcutKey::G => "G",
ShortcutKey::I => "I", ShortcutKey::I => "I",

View File

@ -93,7 +93,7 @@ fn find_sampled_audio_track_for_clip(
&audio_layer.layer.id, &audio_layer.layer.id,
timeline_start, timeline_start,
clip_end, clip_end,
None, // Don't exclude any instances &[], // Don't exclude any instances
); );
if !overlaps { if !overlaps {
@ -997,6 +997,24 @@ impl TimelinePane {
lightningbeam_core::layer::AnyLayer::Effect(el) => &el.clip_instances, 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 { for clip_instance in clip_instances {
// Get the clip to determine duration // Get the clip to determine duration
let clip_duration = match layer { let clip_duration = match layer {
@ -1052,21 +1070,9 @@ impl TimelinePane {
if is_selected || is_linked_to_dragged { if is_selected || is_linked_to_dragged {
match drag_type { match drag_type {
ClipDragType::Move => { ClipDragType::Move => {
// Move: shift the entire clip along the timeline with auto-snap preview if let Some(offset) = group_move_offset {
let desired_start = clip_instance.timeline_start + self.drag_offset; instance_start = (clip_instance.timeline_start + offset).max(0.0);
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;
} }
ClipDragType::TrimLeft => { ClipDragType::TrimLeft => {
// Trim left: calculate new trim_start with snap to adjacent clips // 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 { 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 { let callback = crate::waveform_gpu::WaveformCallback {
pool_index: *audio_pool_index, pool_index: *audio_pool_index,
segment_index: 0, segment_index: 0,
@ -1268,6 +1276,7 @@ impl TimelinePane {
}, },
target_format, target_format,
pending_upload, pending_upload,
instance_id,
}; };
ui.painter().add(egui_wgpu::Callback::new_paint_callback( ui.painter().add(egui_wgpu::Callback::new_paint_callback(
@ -1640,11 +1649,25 @@ impl TimelinePane {
clip_instance.timeline_start; clip_instance.timeline_start;
// New trim_start is clamped to valid range // 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) + self.drag_offset)
.max(0.0) .max(0.0)
.min(clip_duration); .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 // Calculate actual offset after clamping
let actual_offset = new_trim_start - old_trim_start; let actual_offset = new_trim_start - old_trim_start;
let new_timeline_start = let new_timeline_start =
@ -1672,8 +1695,27 @@ impl TimelinePane {
// Calculate new trim_end based on current duration // Calculate new trim_end based on current duration
let current_duration = let current_duration =
clip_instance.effective_duration(clip_duration); clip_instance.effective_duration(clip_duration);
let new_duration = let old_trim_end_val = clip_instance.trim_end.unwrap_or(clip_duration);
(current_duration + self.drag_offset).max(0.0); 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 // Convert new duration back to trim_end value
let new_trim_end = if new_duration >= clip_duration { let new_trim_end = if new_duration >= clip_duration {
@ -2244,7 +2286,7 @@ impl PaneRenderer for TimelinePane {
&layer.id(), &layer.id(),
raw_drop_time, raw_drop_time,
clip_duration, clip_duration,
None, &[],
); );
snapped.unwrap_or(raw_drop_time) snapped.unwrap_or(raw_drop_time)

View File

@ -28,6 +28,10 @@ pub struct WaveformGpuResources {
mipgen_bind_group_layout: wgpu::BindGroupLayout, mipgen_bind_group_layout: wgpu::BindGroupLayout,
/// Sampler for waveform texture (nearest, since we do manual LOD selection) /// Sampler for waveform texture (nearest, since we do manual LOD selection)
sampler: wgpu::Sampler, 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 /// GPU data for a single audio file
@ -92,8 +96,12 @@ pub struct WaveformCallback {
pub target_format: wgpu::TextureFormat, pub target_format: wgpu::TextureFormat,
/// Raw audio data for upload if this is the first time we see this pool_index /// Raw audio data for upload if this is the first time we see this pool_index
pub pending_upload: Option<PendingUpload>, pub pending_upload: Option<PendingUpload>,
/// 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 /// Raw audio data waiting to be uploaded to GPU
pub struct PendingUpload { pub struct PendingUpload {
pub samples: Vec<f32>, pub samples: Vec<f32>,
@ -259,6 +267,7 @@ impl WaveformGpuResources {
render_bind_group_layout, render_bind_group_layout,
mipgen_bind_group_layout, mipgen_bind_group_layout,
sampler, sampler,
per_instance: HashMap::new(),
} }
} }
@ -364,6 +373,8 @@ impl WaveformGpuResources {
// Full create (first upload or texture needs to grow) // Full create (first upload or texture needs to grow)
self.entries.remove(&pool_index); 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; let total_frames = new_total_frames;
@ -652,14 +663,40 @@ impl egui_wgpu::CallbackTrait for WaveformCallback {
cmds.extend(new_cmds); 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 let Some(entry) = gpu_resources.entries.get(&self.pool_index) {
if self.segment_index < entry.uniform_buffers.len() { if self.segment_index < entry.texture_views.len() {
queue.write_buffer( let (buf, _bg) = gpu_resources.per_instance.entry(key).or_insert_with(|| {
&entry.uniform_buffers[self.segment_index], let buf = device.create_buffer(&wgpu::BufferDescriptor {
0, label: Some(&format!("waveform_{}_inst_{}", self.pool_index, self.instance_id)),
bytemuck::cast_slice(&[self.params]), size: std::mem::size_of::<WaveformParams>() 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, None => return,
}; };
let entry = match gpu_resources.entries.get(&self.pool_index) { let key = (self.pool_index, self.instance_id);
Some(e) => e, let (_buf, bind_group) = match gpu_resources.per_instance.get(&key) {
Some(entry) => entry,
None => return, None => return,
}; };
if self.segment_index >= entry.render_bind_groups.len() {
return;
}
render_pass.set_pipeline(&gpu_resources.render_pipeline); 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 render_pass.draw(0..3, 0..1); // Fullscreen triangle
} }
} }