Stack clips only on overlap

This commit is contained in:
Skyler Lehmkuhl 2026-02-21 01:22:48 -05:00
parent 1892f970c4
commit 70855963cb
1 changed files with 139 additions and 16 deletions

View File

@ -20,18 +20,74 @@ const EDGE_DETECTION_PIXELS: f32 = 8.0; // Distance from edge to detect trim han
const LOOP_CORNER_SIZE: f32 = 12.0; // Size of loop corner hotzone at top-right of clip const LOOP_CORNER_SIZE: f32 = 12.0; // Size of loop corner hotzone at top-right of clip
const MIN_CLIP_WIDTH_PX: f32 = 8.0; // Minimum visible width for very short clips (e.g. groups) const MIN_CLIP_WIDTH_PX: f32 = 8.0; // Minimum visible width for very short clips (e.g. groups)
/// Calculate vertical bounds for a clip instance within a layer row. /// Compute stacking row assignments for clip instances on a vector layer.
/// For vector layers with multiple clip instances, stacks them vertically. /// Only clips that overlap in time are stacked; non-overlapping clips share row 0.
/// Returns (y_min, y_max) relative to the layer top. /// Returns a Vec of (row, total_rows) for each clip instance.
fn clip_instance_y_bounds( fn compute_clip_stacking_from_ranges(
ranges: &[(f64, f64)],
) -> Vec<(usize, usize)> {
if ranges.len() <= 1 {
return vec![(0, 1); ranges.len()];
}
// Greedy row assignment: assign each clip to the first row where it doesn't overlap
let mut row_assignments = vec![0usize; ranges.len()];
let mut row_ends: Vec<f64> = Vec::new(); // track the end time of the last clip in each row
// Sort indices by start time for greedy packing
let mut sorted_indices: Vec<usize> = (0..ranges.len()).collect();
sorted_indices.sort_by(|&a, &b| ranges[a].0.partial_cmp(&ranges[b].0).unwrap_or(std::cmp::Ordering::Equal));
for &idx in &sorted_indices {
let (start, end) = ranges[idx];
// Find first row where this clip fits (no overlap)
let mut assigned_row = None;
for (row, row_end) in row_ends.iter_mut().enumerate() {
if start >= *row_end {
*row_end = end;
assigned_row = Some(row);
break;
}
}
if let Some(row) = assigned_row {
row_assignments[idx] = row;
} else {
row_assignments[idx] = row_ends.len();
row_ends.push(end);
}
}
let total_rows = row_ends.len().max(1);
row_assignments.iter().map(|&row| (row, total_rows)).collect()
}
fn compute_clip_stacking(
document: &lightningbeam_core::document::Document,
layer: &AnyLayer, layer: &AnyLayer,
clip_index: usize, clip_instances: &[lightningbeam_core::clip::ClipInstance],
clip_count: usize, ) -> Vec<(usize, usize)> {
) -> (f32, f32) { if !matches!(layer, AnyLayer::Vector(_)) || clip_instances.len() <= 1 {
if matches!(layer, AnyLayer::Vector(_)) && clip_count > 1 { return vec![(0, 1); clip_instances.len()];
}
let ranges: Vec<(f64, f64)> = clip_instances.iter().map(|ci| {
let clip_dur = effective_clip_duration(document, layer, ci).unwrap_or(0.0);
let start = ci.effective_start();
let end = start + ci.total_duration(clip_dur);
(start, end)
}).collect();
compute_clip_stacking_from_ranges(&ranges)
}
/// Calculate vertical bounds for a clip instance within a layer row.
/// `row` is the stacking row (0-based), `total_rows` is the total number of rows needed.
/// Returns (y_min, y_max) relative to the layer top.
fn clip_instance_y_bounds(row: usize, total_rows: usize) -> (f32, f32) {
if total_rows > 1 {
let usable_height = LAYER_HEIGHT - 20.0; // 10px padding top/bottom let usable_height = LAYER_HEIGHT - 20.0; // 10px padding top/bottom
let row_height = (usable_height / clip_count as f32).min(20.0); let row_height = (usable_height / total_rows as f32).min(20.0);
let top = 10.0 + clip_index as f32 * row_height; let top = 10.0 + row as f32 * row_height;
(top, top + row_height - 1.0) (top, top + row_height - 1.0)
} else { } else {
(10.0, LAYER_HEIGHT - 10.0) (10.0, LAYER_HEIGHT - 10.0)
@ -374,7 +430,7 @@ impl TimelinePane {
}; };
// Check each clip instance // Check each clip instance
let clip_count = clip_instances.len(); let stacking = compute_clip_stacking(document, layer, clip_instances);
for (ci_idx, clip_instance) in clip_instances.iter().enumerate() { for (ci_idx, clip_instance) in clip_instances.iter().enumerate() {
let clip_duration = effective_clip_duration(document, layer, clip_instance)?; let clip_duration = effective_clip_duration(document, layer, clip_instance)?;
@ -389,7 +445,8 @@ impl TimelinePane {
if mouse_x >= start_x && mouse_x <= end_x { if mouse_x >= start_x && mouse_x <= end_x {
// Check vertical bounds for stacked vector layer clips // Check vertical bounds for stacked vector layer clips
let layer_top = header_rect.min.y + (hovered_layer_index as f32 * LAYER_HEIGHT) - self.viewport_scroll_y; let layer_top = header_rect.min.y + (hovered_layer_index as f32 * LAYER_HEIGHT) - self.viewport_scroll_y;
let (cy_min, cy_max) = clip_instance_y_bounds(layer, ci_idx, clip_count); let (row, total_rows) = stacking[ci_idx];
let (cy_min, cy_max) = clip_instance_y_bounds(row, total_rows);
let mouse_rel_y = pointer_pos.y - layer_top; let mouse_rel_y = pointer_pos.y - layer_top;
if mouse_rel_y < cy_min || mouse_rel_y > cy_max { if mouse_rel_y < cy_min || mouse_rel_y > cy_max {
continue; continue;
@ -1096,7 +1153,71 @@ impl TimelinePane {
None None
}; };
let clip_instance_count = clip_instances.len(); // Compute stacking using preview positions (with drag offsets) for vector layers
let clip_stacking = if matches!(layer, AnyLayer::Vector(_)) && clip_instances.len() > 1 {
let preview_ranges: Vec<(f64, f64)> = clip_instances.iter().map(|ci| {
let clip_dur = effective_clip_duration(document, layer, ci).unwrap_or(0.0);
let mut start = ci.effective_start();
let mut duration = ci.total_duration(clip_dur);
let is_selected = selection.contains_clip_instance(&ci.id);
let is_linked = if self.clip_drag_state.is_some() {
instance_to_group.get(&ci.id).map_or(false, |group| {
group.members.iter().any(|(_, mid)| *mid != ci.id && selection.contains_clip_instance(mid))
})
} else {
false
};
if let Some(drag_type) = self.clip_drag_state {
if is_selected || is_linked {
match drag_type {
ClipDragType::Move => {
if let Some(offset) = group_move_offset {
start = (ci.effective_start() + offset).max(0.0);
}
}
ClipDragType::TrimLeft => {
let new_trim = (ci.trim_start + self.drag_offset).max(0.0).min(clip_dur);
let offset = new_trim - ci.trim_start;
start = (ci.timeline_start + offset).max(0.0);
duration = (clip_dur - new_trim).max(0.0);
if let Some(trim_end) = ci.trim_end {
duration = (trim_end - new_trim).max(0.0);
}
}
ClipDragType::TrimRight => {
let old_trim_end = ci.trim_end.unwrap_or(clip_dur);
let new_trim_end = (old_trim_end + self.drag_offset).max(ci.trim_start).min(clip_dur);
duration = (new_trim_end - ci.trim_start).max(0.0);
}
ClipDragType::LoopExtendRight => {
let trim_end = ci.trim_end.unwrap_or(clip_dur);
let content_window = (trim_end - ci.trim_start).max(0.0);
let current_right = ci.timeline_duration.unwrap_or(content_window);
let new_right = (current_right + self.drag_offset).max(content_window);
let loop_before = ci.loop_before.unwrap_or(0.0);
duration = loop_before + new_right;
}
ClipDragType::LoopExtendLeft => {
let trim_end = ci.trim_end.unwrap_or(clip_dur);
let content_window = (trim_end - ci.trim_start).max(0.001);
let current_loop_before = ci.loop_before.unwrap_or(0.0);
let desired = (current_loop_before - self.drag_offset).max(0.0);
let snapped = (desired / content_window).round() * content_window;
start = ci.timeline_start - snapped;
duration = snapped + ci.effective_duration(clip_dur);
}
}
}
}
(start, start + duration)
}).collect();
compute_clip_stacking_from_ranges(&preview_ranges)
} else {
compute_clip_stacking(document, layer, clip_instances)
};
for (clip_instance_index, clip_instance) in clip_instances.iter().enumerate() { for (clip_instance_index, clip_instance) in clip_instances.iter().enumerate() {
// Get the clip to determine duration // Get the clip to determine duration
let clip_duration = effective_clip_duration(document, layer, clip_instance); let clip_duration = effective_clip_duration(document, layer, clip_instance);
@ -1314,7 +1435,8 @@ impl TimelinePane {
), ),
}; };
let (cy_min, cy_max) = clip_instance_y_bounds(layer, clip_instance_index, clip_instance_count); let (row, total_rows) = clip_stacking[clip_instance_index];
let (cy_min, cy_max) = clip_instance_y_bounds(row, total_rows);
let clip_rect = egui::Rect::from_min_max( let clip_rect = egui::Rect::from_min_max(
egui::pos2(rect.min.x + visible_start_x, y + cy_min), egui::pos2(rect.min.x + visible_start_x, y + cy_min),
@ -1787,7 +1909,7 @@ impl TimelinePane {
}; };
// Check if click is within any clip instance // Check if click is within any clip instance
let click_clip_count = clip_instances.len(); let click_stacking = compute_clip_stacking(document, layer, clip_instances);
let click_layer_top = pos.y - (relative_y % LAYER_HEIGHT); let click_layer_top = pos.y - (relative_y % LAYER_HEIGHT);
for (ci_idx, clip_instance) in clip_instances.iter().enumerate() { for (ci_idx, clip_instance) in clip_instances.iter().enumerate() {
let clip_duration = effective_clip_duration(document, layer, clip_instance); let clip_duration = effective_clip_duration(document, layer, clip_instance);
@ -1801,7 +1923,8 @@ impl TimelinePane {
let ci_start_x = self.time_to_x(instance_start); let ci_start_x = self.time_to_x(instance_start);
let ci_end_x = self.time_to_x(instance_end).max(ci_start_x + MIN_CLIP_WIDTH_PX); let ci_end_x = self.time_to_x(instance_end).max(ci_start_x + MIN_CLIP_WIDTH_PX);
let click_x = pos.x - content_rect.min.x; let click_x = pos.x - content_rect.min.x;
let (cy_min, cy_max) = clip_instance_y_bounds(layer, ci_idx, click_clip_count); let (row, total_rows) = click_stacking[ci_idx];
let (cy_min, cy_max) = clip_instance_y_bounds(row, total_rows);
let click_rel_y = pos.y - click_layer_top; let click_rel_y = pos.y - click_layer_top;
if click_x >= ci_start_x && click_x <= ci_end_x if click_x >= ci_start_x && click_x <= ci_end_x
&& click_rel_y >= cy_min && click_rel_y <= cy_max && click_rel_y >= cy_min && click_rel_y <= cy_max