Add clip split and duplicate commands
This commit is contained in:
parent
12d927ed3d
commit
394e369122
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -88,8 +88,6 @@ 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,
|
||||
|
|
@ -97,32 +95,18 @@ impl Action for MoveClipInstancesAction {
|
|||
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);
|
||||
|
|
|
|||
|
|
@ -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<Uuid>) {
|
||||
let Some(layer) = self.get_layer(layer_id) else {
|
||||
return (false, None);
|
||||
|
|
@ -568,12 +568,10 @@ 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 {
|
||||
// Skip excluded instances
|
||||
if exclude_instance_ids.contains(&instance.id) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate instance end time
|
||||
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
|
||||
///
|
||||
/// 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<f64> {
|
||||
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,11 +631,9 @@ 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 {
|
||||
if exclude_instance_ids.contains(&instance.id) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(clip_dur) = self.get_clip_duration(&instance.clip_id) {
|
||||
let inst_start = instance.timeline_start;
|
||||
|
|
@ -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<f64> = 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<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
|
||||
///
|
||||
/// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.label(egui::RichText::new(&shortcut_text).weak().size(12.0));
|
||||
});
|
||||
|
||||
button.clicked()
|
||||
}).inner
|
||||
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);
|
||||
}
|
||||
|
||||
// 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")
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
pub struct PendingUpload {
|
||||
pub samples: Vec<f32>,
|
||||
|
|
@ -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::<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,
|
||||
};
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue