Fix looping bugs

This commit is contained in:
Skyler Lehmkuhl 2026-02-20 04:27:20 -05:00
parent 042dd50db3
commit ce40147efa
7 changed files with 640 additions and 131 deletions

View File

@ -1,7 +1,7 @@
//! Loop clip instances action //! Loop clip instances action
//! //!
//! Handles extending clip instances beyond their content duration to enable looping, //! Handles extending clip instances beyond their content duration to enable looping,
//! by setting timeline_duration on the ClipInstance. //! by setting timeline_duration and/or loop_before on the ClipInstance.
use crate::action::Action; use crate::action::Action;
use crate::document::Document; use crate::document::Document;
@ -9,14 +9,17 @@ use crate::layer::AnyLayer;
use std::collections::HashMap; use std::collections::HashMap;
use uuid::Uuid; use uuid::Uuid;
/// Per-instance loop change: (instance_id, old_timeline_duration, new_timeline_duration, old_loop_before, new_loop_before)
pub type LoopEntry = (Uuid, Option<f64>, Option<f64>, Option<f64>, Option<f64>);
/// Action that changes the loop duration of clip instances /// Action that changes the loop duration of clip instances
pub struct LoopClipInstancesAction { pub struct LoopClipInstancesAction {
/// Map of layer IDs to vectors of (instance_id, old_timeline_duration, new_timeline_duration) /// Map of layer IDs to vectors of loop entries
layer_loops: HashMap<Uuid, Vec<(Uuid, Option<f64>, Option<f64>)>>, layer_loops: HashMap<Uuid, Vec<LoopEntry>>,
} }
impl LoopClipInstancesAction { impl LoopClipInstancesAction {
pub fn new(layer_loops: HashMap<Uuid, Vec<(Uuid, Option<f64>, Option<f64>)>>) -> Self { pub fn new(layer_loops: HashMap<Uuid, Vec<LoopEntry>>) -> Self {
Self { layer_loops } Self { layer_loops }
} }
} }
@ -34,9 +37,10 @@ impl Action for LoopClipInstancesAction {
AnyLayer::Effect(el) => &mut el.clip_instances, AnyLayer::Effect(el) => &mut el.clip_instances,
}; };
for (instance_id, _old, new) in loops { for (instance_id, _old_dur, new_dur, _old_lb, new_lb) in loops {
if let Some(instance) = clip_instances.iter_mut().find(|ci| ci.id == *instance_id) { if let Some(instance) = clip_instances.iter_mut().find(|ci| ci.id == *instance_id) {
instance.timeline_duration = *new; instance.timeline_duration = *new_dur;
instance.loop_before = *new_lb;
} }
} }
} }
@ -55,9 +59,10 @@ impl Action for LoopClipInstancesAction {
AnyLayer::Effect(el) => &mut el.clip_instances, AnyLayer::Effect(el) => &mut el.clip_instances,
}; };
for (instance_id, old, _new) in loops { for (instance_id, old_dur, _new_dur, old_lb, _new_lb) in loops {
if let Some(instance) = clip_instances.iter_mut().find(|ci| ci.id == *instance_id) { if let Some(instance) = clip_instances.iter_mut().find(|ci| ci.id == *instance_id) {
instance.timeline_duration = *old; instance.timeline_duration = *old_dur;
instance.loop_before = *old_lb;
} }
} }
} }
@ -102,7 +107,7 @@ impl LoopClipInstancesAction {
_ => continue, _ => continue,
}; };
for (instance_id, old, new) in loops { for (instance_id, old_dur, new_dur, old_lb, new_lb) in loops {
let instance = clip_instances.iter() let instance = clip_instances.iter()
.find(|ci| ci.id == *instance_id) .find(|ci| ci.id == *instance_id)
.ok_or_else(|| format!("Clip instance {} not found", instance_id))?; .ok_or_else(|| format!("Clip instance {} not found", instance_id))?;
@ -110,32 +115,39 @@ impl LoopClipInstancesAction {
let clip = document.get_audio_clip(&instance.clip_id) let clip = document.get_audio_clip(&instance.clip_id)
.ok_or_else(|| format!("Audio clip {} not found", instance.clip_id))?; .ok_or_else(|| format!("Audio clip {} not found", instance.clip_id))?;
// Determine which duration to send: on rollback use old, otherwise use new (current) let (target_duration, target_loop_before) = if rollback {
let target_duration = if rollback { old } else { new }; (old_dur, old_lb)
} else {
(new_dur, new_lb)
};
// If timeline_duration is None, the external duration equals the content window
let content_window = { let content_window = {
let trim_end = instance.trim_end.unwrap_or(clip.duration); let trim_end = instance.trim_end.unwrap_or(clip.duration);
(trim_end - instance.trim_start).max(0.0) (trim_end - instance.trim_start).max(0.0)
}; };
let external_duration = target_duration.unwrap_or(content_window); let right_duration = target_duration.unwrap_or(content_window);
let left_duration = target_loop_before.unwrap_or(0.0);
let external_duration = left_duration + right_duration;
let external_start = instance.timeline_start - left_duration;
let get_backend_clip_id = |inst_id: &Uuid| -> Result<u32, String> {
match &clip.clip_type { match &clip.clip_type {
AudioClipType::Midi { midi_clip_id } => { AudioClipType::Midi { midi_clip_id } => Ok(*midi_clip_id),
controller.extend_clip(*track_id, *midi_clip_id, external_duration);
}
AudioClipType::Sampled { .. } => { AudioClipType::Sampled { .. } => {
let backend_instance_id = backend.clip_instance_to_backend_map.get(instance_id) let backend_id = backend.clip_instance_to_backend_map.get(inst_id)
.ok_or_else(|| format!("Clip instance {} not mapped to backend", instance_id))?; .ok_or_else(|| format!("Clip instance {} not mapped to backend", inst_id))?;
match backend_id {
crate::action::BackendClipInstanceId::Audio(audio_id) => Ok(*audio_id),
_ => Err("Expected audio instance ID for sampled clip".to_string()),
}
}
AudioClipType::Recording => Err("Cannot sync recording clip".to_string()),
}
};
match backend_instance_id { if let Ok(backend_clip_id) = get_backend_clip_id(instance_id) {
crate::action::BackendClipInstanceId::Audio(audio_id) => { controller.move_clip(*track_id, backend_clip_id, external_start);
controller.extend_clip(*track_id, *audio_id, external_duration); controller.extend_clip(*track_id, backend_clip_id, external_duration);
}
_ => return Err("Expected audio instance ID for sampled clip".to_string()),
}
}
AudioClipType::Recording => {}
} }
} }
} }

View File

@ -573,6 +573,12 @@ pub struct ClipInstance {
/// Compatible with daw-backend's Clip.gain /// Compatible with daw-backend's Clip.gain
/// Default: 1.0 /// Default: 1.0
pub gain: f32, pub gain: f32,
/// How far (in seconds) the looped content extends before timeline_start.
/// When set, loop iterations are drawn/played before the content start.
/// Default: None (no pre-loop)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub loop_before: Option<f64>,
} }
impl ClipInstance { impl ClipInstance {
@ -590,6 +596,7 @@ impl ClipInstance {
trim_end: None, trim_end: None,
playback_speed: 1.0, playback_speed: 1.0,
gain: 1.0, gain: 1.0,
loop_before: None,
} }
} }
@ -607,6 +614,7 @@ impl ClipInstance {
trim_end: None, trim_end: None,
playback_speed: 1.0, playback_speed: 1.0,
gain: 1.0, gain: 1.0,
loop_before: None,
} }
} }
@ -683,6 +691,17 @@ impl ClipInstance {
(end - self.trim_start).max(0.0) (end - self.trim_start).max(0.0)
} }
/// Get the effective start position on the timeline, accounting for loop_before.
/// This is the left edge of the clip's visual extent.
pub fn effective_start(&self) -> f64 {
self.timeline_start - self.loop_before.unwrap_or(0.0)
}
/// Get the total visual duration including both loop_before and effective_duration.
pub fn total_duration(&self, clip_duration: f64) -> f64 {
self.loop_before.unwrap_or(0.0) + self.effective_duration(clip_duration)
}
/// Remap timeline time to clip content time /// Remap timeline time to clip content time
/// ///
/// Takes a global timeline time and returns the corresponding time within this /// Takes a global timeline time and returns the corresponding time within this

View File

@ -604,15 +604,13 @@ impl Document {
continue; continue;
} }
// Calculate instance end time // Calculate instance extent (accounting for loop_before)
let Some(clip_duration) = self.get_clip_duration(&instance.clip_id) else { let Some(clip_duration) = self.get_clip_duration(&instance.clip_id) else {
continue; continue;
}; };
let instance_start = instance.timeline_start; let instance_start = instance.effective_start();
let trim_start = instance.trim_start; let instance_end = instance.timeline_start + instance.effective_duration(clip_duration);
let trim_end = instance.trim_end.unwrap_or(clip_duration);
let instance_end = instance_start + (trim_end - trim_start);
// Check overlap: start_a < end_b AND start_b < end_a // Check overlap: start_a < end_b AND start_b < end_a
if start_time < instance_end && instance_start < end_time { if start_time < instance_end && instance_start < end_time {
@ -667,10 +665,8 @@ impl Document {
} }
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.effective_start();
let trim_start = instance.trim_start; let inst_end = instance.timeline_start + instance.effective_duration(clip_dur);
let trim_end = instance.trim_end.unwrap_or(clip_dur);
let inst_end = inst_start + (trim_end - trim_start);
occupied_ranges.push((inst_start, inst_end, instance.id)); occupied_ranges.push((inst_start, inst_end, instance.id));
} }
} }
@ -762,8 +758,9 @@ impl Document {
continue; continue;
} }
if let Some(dur) = self.get_clip_duration(&inst.clip_id) { 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); let start = inst.effective_start();
non_group.push((inst.timeline_start, end)); let end = inst.timeline_start + inst.effective_duration(dur);
non_group.push((start, end));
} }
} }
@ -828,10 +825,9 @@ impl Document {
continue; continue;
} }
// Calculate other clip's end time // Calculate other clip's extent (accounting for loop_before)
if let Some(clip_duration) = self.get_clip_duration(&other.clip_id) { if let Some(clip_duration) = self.get_clip_duration(&other.clip_id) {
let trim_end = other.trim_end.unwrap_or(clip_duration); let other_end = other.timeline_start + other.effective_duration(clip_duration);
let other_end = other.timeline_start + (trim_end - other.trim_start);
// If this clip is to the left and closer than current nearest // If this clip is to the left and closer than current nearest
if other_end <= current_timeline_start && other_end > nearest_end { if other_end <= current_timeline_start && other_end > nearest_end {
@ -878,9 +874,10 @@ impl Document {
continue; continue;
} }
// If this clip is to the right and closer than current nearest // Use effective_start to account for loop_before on the other clip
if other.timeline_start >= current_end && other.timeline_start < nearest_start { let other_start = other.effective_start();
nearest_start = other.timeline_start; if other_start >= current_end && other_start < nearest_start {
nearest_start = other_start;
} }
} }
@ -890,6 +887,48 @@ impl Document {
(nearest_start - current_end).max(0.0) // Gap between our end and next clip's start (nearest_start - current_end).max(0.0) // Gap between our end and next clip's start
} }
} }
/// Find the maximum amount we can extend loop_before to the left without overlapping.
///
/// Returns the max additional loop_before distance (from the current effective start).
pub fn find_max_loop_extend_left(
&self,
layer_id: &Uuid,
instance_id: &Uuid,
current_effective_start: f64,
) -> f64 {
let Some(layer) = self.get_layer(layer_id) else {
return current_effective_start;
};
if matches!(layer, AnyLayer::Vector(_)) {
return current_effective_start;
}
let instances: &[ClipInstance] = match layer {
AnyLayer::Audio(audio) => &audio.clip_instances,
AnyLayer::Video(video) => &video.clip_instances,
AnyLayer::Effect(effect) => &effect.clip_instances,
AnyLayer::Vector(vector) => &vector.clip_instances,
};
let mut nearest_end = 0.0;
for other in instances {
if &other.id == instance_id {
continue;
}
if let Some(clip_duration) = self.get_clip_duration(&other.clip_id) {
let other_end = other.timeline_start + other.effective_duration(clip_duration);
if other_end <= current_effective_start && other_end > nearest_end {
nearest_end = other_end;
}
}
}
current_effective_start - nearest_end
}
} }
#[cfg(test)] #[cfg(test)]

View File

@ -0,0 +1,227 @@
//! Custom cursor system
//!
//! Provides SVG-based custom cursors beyond egui's built-in system cursors.
//! When a custom cursor is active, the system cursor is hidden and the SVG
//! cursor image is drawn at the pointer position.
use eframe::egui;
use egui::TextureHandle;
use lightningbeam_core::tool::Tool;
use std::collections::HashMap;
/// Custom cursor identifiers
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum CustomCursor {
// Stage tool cursors
Select,
Draw,
Transform,
Rectangle,
Ellipse,
PaintBucket,
Eyedropper,
Line,
Polygon,
BezierEdit,
Text,
// Timeline cursors
LoopExtend,
}
impl CustomCursor {
/// Convert a Tool enum to the corresponding custom cursor
pub fn from_tool(tool: Tool) -> Self {
match tool {
Tool::Select => CustomCursor::Select,
Tool::Draw => CustomCursor::Draw,
Tool::Transform => CustomCursor::Transform,
Tool::Rectangle => CustomCursor::Rectangle,
Tool::Ellipse => CustomCursor::Ellipse,
Tool::PaintBucket => CustomCursor::PaintBucket,
Tool::Eyedropper => CustomCursor::Eyedropper,
Tool::Line => CustomCursor::Line,
Tool::Polygon => CustomCursor::Polygon,
Tool::BezierEdit => CustomCursor::BezierEdit,
Tool::Text => CustomCursor::Text,
}
}
/// Hotspot offset — the "click point" relative to the image top-left
pub fn hotspot(&self) -> egui::Vec2 {
match self {
// Select cursor: pointer tip at top-left
CustomCursor::Select => egui::vec2(3.0, 1.0),
// Drawing tools: tip at bottom-left
CustomCursor::Draw => egui::vec2(1.0, 23.0),
// Transform: center
CustomCursor::Transform => egui::vec2(12.0, 12.0),
// Shape tools: crosshair at center
CustomCursor::Rectangle
| CustomCursor::Ellipse
| CustomCursor::Line
| CustomCursor::Polygon => egui::vec2(12.0, 12.0),
// Paint bucket: tip at bottom-left
CustomCursor::PaintBucket => egui::vec2(2.0, 21.0),
// Eyedropper: tip at bottom
CustomCursor::Eyedropper => egui::vec2(4.0, 22.0),
// Bezier edit: tip at top-left
CustomCursor::BezierEdit => egui::vec2(3.0, 1.0),
// Text: I-beam center
CustomCursor::Text => egui::vec2(12.0, 12.0),
// Loop extend: center of circular arrow
CustomCursor::LoopExtend => egui::vec2(12.0, 12.0),
}
}
/// Get the embedded SVG data for this cursor
fn svg_data(&self) -> &'static [u8] {
match self {
CustomCursor::Select => include_bytes!("../../../src/assets/select.svg"),
CustomCursor::Draw => include_bytes!("../../../src/assets/draw.svg"),
CustomCursor::Transform => include_bytes!("../../../src/assets/transform.svg"),
CustomCursor::Rectangle => include_bytes!("../../../src/assets/rectangle.svg"),
CustomCursor::Ellipse => include_bytes!("../../../src/assets/ellipse.svg"),
CustomCursor::PaintBucket => include_bytes!("../../../src/assets/paint_bucket.svg"),
CustomCursor::Eyedropper => include_bytes!("../../../src/assets/eyedropper.svg"),
CustomCursor::Line => include_bytes!("../../../src/assets/line.svg"),
CustomCursor::Polygon => include_bytes!("../../../src/assets/polygon.svg"),
CustomCursor::BezierEdit => include_bytes!("../../../src/assets/bezier_edit.svg"),
CustomCursor::Text => include_bytes!("../../../src/assets/text.svg"),
CustomCursor::LoopExtend => include_bytes!("../../../src/assets/arrow-counterclockwise.svg"),
}
}
}
/// Cache of rasterized cursor textures (black fill + white outline version)
pub struct CursorCache {
/// Black cursor for the main image
textures: HashMap<CustomCursor, TextureHandle>,
/// White cursor for the outline
outline_textures: HashMap<CustomCursor, TextureHandle>,
}
impl CursorCache {
pub fn new() -> Self {
Self {
textures: HashMap::new(),
outline_textures: HashMap::new(),
}
}
/// Get or lazily load the black (fill) cursor texture
pub fn get_or_load(&mut self, cursor: CustomCursor, ctx: &egui::Context) -> &TextureHandle {
self.textures.entry(cursor).or_insert_with(|| {
let svg_data = cursor.svg_data();
let svg_string = String::from_utf8_lossy(svg_data);
let svg_with_color = svg_string.replace("currentColor", "#000000");
rasterize_cursor_svg(svg_with_color.as_bytes(), &format!("cursor_{:?}", cursor), CURSOR_SIZE, ctx)
.expect("Failed to rasterize cursor SVG")
})
}
/// Get or lazily load the white (outline) cursor texture
pub fn get_or_load_outline(&mut self, cursor: CustomCursor, ctx: &egui::Context) -> &TextureHandle {
self.outline_textures.entry(cursor).or_insert_with(|| {
let svg_data = cursor.svg_data();
let svg_string = String::from_utf8_lossy(svg_data);
// Replace all colors with white for the outline
let svg_white = svg_string
.replace("currentColor", "#ffffff")
.replace("#000000", "#ffffff")
.replace("#000", "#ffffff");
rasterize_cursor_svg(svg_white.as_bytes(), &format!("cursor_{:?}_outline", cursor), CURSOR_SIZE, ctx)
.expect("Failed to rasterize cursor SVG outline")
})
}
}
const CURSOR_SIZE: u32 = 24;
const OUTLINE_OFFSET: f32 = 1.0;
/// Rasterize an SVG into an egui texture (same approach as main.rs rasterize_svg)
fn rasterize_cursor_svg(
svg_data: &[u8],
name: &str,
render_size: u32,
ctx: &egui::Context,
) -> Option<TextureHandle> {
let tree = resvg::usvg::Tree::from_data(svg_data, &resvg::usvg::Options::default()).ok()?;
let pixmap_size = tree.size().to_int_size();
let scale_x = render_size as f32 / pixmap_size.width() as f32;
let scale_y = render_size as f32 / pixmap_size.height() as f32;
let mut pixmap = resvg::tiny_skia::Pixmap::new(render_size, render_size)?;
resvg::render(
&tree,
resvg::tiny_skia::Transform::from_scale(scale_x, scale_y),
&mut pixmap.as_mut(),
);
let rgba_data = pixmap.data().to_vec();
let color_image = egui::ColorImage::from_rgba_unmultiplied(
[render_size as usize, render_size as usize],
&rgba_data,
);
Some(ctx.load_texture(name, color_image, egui::TextureOptions::LINEAR))
}
// --- Per-frame cursor slot using egui context data ---
/// Key for storing the active custom cursor in egui's per-frame data
#[derive(Clone, Copy)]
struct ActiveCustomCursor(CustomCursor);
/// Set the custom cursor for this frame. Call from any pane during rendering.
/// This hides the system cursor and draws the SVG cursor at pointer position.
pub fn set(ctx: &egui::Context, cursor: CustomCursor) {
ctx.data_mut(|d| d.insert_temp(egui::Id::new("active_custom_cursor"), ActiveCustomCursor(cursor)));
}
/// Render the custom cursor overlay. Call at the end of the main update loop.
pub fn render_overlay(ctx: &egui::Context, cache: &mut CursorCache) {
// Take and remove the cursor so it doesn't persist to the next frame
let id = egui::Id::new("active_custom_cursor");
let cursor = ctx.data_mut(|d| {
let val = d.get_temp::<ActiveCustomCursor>(id);
d.remove::<ActiveCustomCursor>(id);
val
});
if let Some(ActiveCustomCursor(cursor)) = cursor {
// If a system cursor was explicitly set (resize handles, text inputs, etc.),
// let it take priority over the custom cursor
let system_cursor = ctx.output(|o| o.cursor_icon);
if system_cursor != egui::CursorIcon::Default {
return;
}
// Hide the system cursor
ctx.set_cursor_icon(egui::CursorIcon::None);
if let Some(pos) = ctx.input(|i| i.pointer.latest_pos()) {
let hotspot = cursor.hotspot();
let size = egui::vec2(CURSOR_SIZE as f32, CURSOR_SIZE as f32);
let uv = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0));
let painter = ctx.debug_painter();
// Draw white outline: render white version offset in 8 directions
let outline_tex = cache.get_or_load_outline(cursor, ctx);
let outline_id = outline_tex.id();
for &(dx, dy) in &[
(-OUTLINE_OFFSET, 0.0), (OUTLINE_OFFSET, 0.0),
(0.0, -OUTLINE_OFFSET), (0.0, OUTLINE_OFFSET),
(-OUTLINE_OFFSET, -OUTLINE_OFFSET), (OUTLINE_OFFSET, -OUTLINE_OFFSET),
(-OUTLINE_OFFSET, OUTLINE_OFFSET), (OUTLINE_OFFSET, OUTLINE_OFFSET),
] {
let offset_rect = egui::Rect::from_min_size(
pos - hotspot + egui::vec2(dx, dy),
size,
);
painter.image(outline_id, offset_rect, uv, egui::Color32::WHITE);
}
// Draw black fill on top
let fill_tex = cache.get_or_load(cursor, ctx);
let cursor_rect = egui::Rect::from_min_size(pos - hotspot, size);
painter.image(fill_tex.id(), cursor_rect, uv, egui::Color32::WHITE);
}
}
}

View File

@ -38,6 +38,7 @@ mod notifications;
mod effect_thumbnails; mod effect_thumbnails;
use effect_thumbnails::EffectThumbnailGenerator; use effect_thumbnails::EffectThumbnailGenerator;
mod custom_cursor;
mod debug_overlay; mod debug_overlay;
mod sample_import; mod sample_import;
@ -732,6 +733,8 @@ struct EditorApp {
/// GPU-rendered effect thumbnail generator /// GPU-rendered effect thumbnail generator
effect_thumbnail_generator: Option<EffectThumbnailGenerator>, effect_thumbnail_generator: Option<EffectThumbnailGenerator>,
/// Custom cursor cache for SVG cursors
cursor_cache: custom_cursor::CursorCache,
/// Debug overlay (F3) state /// Debug overlay (F3) state
debug_overlay_visible: bool, debug_overlay_visible: bool,
debug_stats_collector: debug_overlay::DebugStatsCollector, debug_stats_collector: debug_overlay::DebugStatsCollector,
@ -926,6 +929,7 @@ impl EditorApp {
effect_thumbnail_generator: None, // Initialized when GPU available effect_thumbnail_generator: None, // Initialized when GPU available
// Debug overlay (F3) // Debug overlay (F3)
cursor_cache: custom_cursor::CursorCache::new(),
debug_overlay_visible: false, debug_overlay_visible: false,
debug_stats_collector: debug_overlay::DebugStatsCollector::new(), debug_stats_collector: debug_overlay::DebugStatsCollector::new(),
gpu_info, gpu_info,
@ -4696,6 +4700,9 @@ impl eframe::App for EditorApp {
debug_overlay::render_debug_overlay(ctx, &stats); debug_overlay::render_debug_overlay(ctx, &stats);
} }
// Render custom cursor overlay (on top of everything including debug overlay)
custom_cursor::render_overlay(ctx, &mut self.cursor_cache);
let frame_ms = _frame_start.elapsed().as_secs_f64() * 1000.0; let frame_ms = _frame_start.elapsed().as_secs_f64() * 1000.0;
if frame_ms > 50.0 { if frame_ms > 50.0 {
eprintln!("[TIMING] SLOW FRAME: {:.1}ms (pre-events={:.1}, events={:.1}, post-events={:.1})", eprintln!("[TIMING] SLOW FRAME: {:.1}ms (pre-events={:.1}, events={:.1}, post-events={:.1})",

View File

@ -6314,6 +6314,17 @@ impl PaneRenderer for StagePane {
// Render vector editing overlays (vertices, control points, etc.) // Render vector editing overlays (vertices, control points, etc.)
self.render_vector_editing_overlays(ui, rect, shared); self.render_vector_editing_overlays(ui, rect, shared);
// Set custom tool cursor when pointer is over the stage canvas
// (system cursors from transform handles take priority via render_overlay check)
if let Some(pos) = ui.input(|i| i.pointer.hover_pos()) {
if rect.contains(pos) {
crate::custom_cursor::set(
ui.ctx(),
crate::custom_cursor::CustomCursor::from_tool(*shared.selected_tool),
);
}
}
} }
fn name(&self) -> &str { fn name(&self) -> &str {

View File

@ -25,7 +25,8 @@ enum ClipDragType {
Move, Move,
TrimLeft, TrimLeft,
TrimRight, TrimRight,
LoopExtend, LoopExtendRight,
LoopExtendLeft,
} }
pub struct TimelinePane { pub struct TimelinePane {
@ -194,7 +195,7 @@ impl TimelinePane {
match layer_type { match layer_type {
AudioLayerType::Midi => { AudioLayerType::Midi => {
// Create backend MIDI clip and start recording // Create backend MIDI clip and start recording
let clip_id = controller.create_midi_clip(track_id, start_time, 4.0); let clip_id = controller.create_midi_clip(track_id, start_time, 0.0);
controller.start_midi_recording(track_id, clip_id, start_time); controller.start_midi_recording(track_id, clip_id, start_time);
shared.recording_clips.insert(active_layer_id, clip_id); shared.recording_clips.insert(active_layer_id, clip_id);
println!("🎹 Started MIDI recording on track {:?} at {:.2}s, clip_id={}", println!("🎹 Started MIDI recording on track {:?} at {:.2}s, clip_id={}",
@ -204,7 +205,7 @@ impl TimelinePane {
drop(controller); drop(controller);
// Create document clip + clip instance immediately (clip_id is known synchronously) // Create document clip + clip instance immediately (clip_id is known synchronously)
let doc_clip = AudioClip::new_midi("Recording...", clip_id, 4.0); let doc_clip = AudioClip::new_midi("Recording...", clip_id, 0.0);
let doc_clip_id = shared.action_executor.document_mut().add_audio_clip(doc_clip); let doc_clip_id = shared.action_executor.document_mut().add_audio_clip(doc_clip);
let clip_instance = ClipInstance::new(doc_clip_id) let clip_instance = ClipInstance::new(doc_clip_id)
@ -339,8 +340,8 @@ impl TimelinePane {
} }
}?; }?;
let instance_duration = clip_instance.effective_duration(clip_duration); let instance_start = clip_instance.effective_start();
let instance_start = clip_instance.timeline_start; let instance_duration = clip_instance.total_duration(clip_duration);
let instance_end = instance_start + instance_duration; let instance_end = instance_start + instance_duration;
if hover_time >= instance_start && hover_time <= instance_end { if hover_time >= instance_start && hover_time <= instance_end {
@ -353,12 +354,20 @@ impl TimelinePane {
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 mouse_in_top_corner = pointer_pos.y < layer_top + LOOP_CORNER_SIZE; let mouse_in_top_corner = pointer_pos.y < layer_top + LOOP_CORNER_SIZE;
let is_looping = clip_instance.timeline_duration.is_some() || clip_instance.loop_before.is_some();
let drag_type = if (mouse_x - start_x).abs() <= EDGE_DETECTION_PIXELS { let drag_type = if (mouse_x - start_x).abs() <= EDGE_DETECTION_PIXELS {
// Left edge: loop extend left for audio clips that are looping or top-left corner
let mouse_in_top_left_corner = pointer_pos.y < layer_top + LOOP_CORNER_SIZE;
if is_audio_layer && (is_looping || mouse_in_top_left_corner) {
ClipDragType::LoopExtendLeft
} else {
ClipDragType::TrimLeft ClipDragType::TrimLeft
}
} else if (end_x - mouse_x).abs() <= EDGE_DETECTION_PIXELS { } else if (end_x - mouse_x).abs() <= EDGE_DETECTION_PIXELS {
// Top-right corner of audio clips = loop extend // If already looping, right edge is always loop extend
if is_audio_layer && mouse_in_top_corner { // Otherwise, top-right corner of audio clips = loop extend
ClipDragType::LoopExtend if is_audio_layer && (is_looping || mouse_in_top_corner) {
ClipDragType::LoopExtendRight
} else { } else {
ClipDragType::TrimRight ClipDragType::TrimRight
} }
@ -1026,7 +1035,7 @@ impl TimelinePane {
.filter(|ci| selection.contains_clip_instance(&ci.id)) .filter(|ci| selection.contains_clip_instance(&ci.id))
.filter_map(|ci| { .filter_map(|ci| {
let dur = document.get_clip_duration(&ci.clip_id)?; let dur = document.get_clip_duration(&ci.clip_id)?;
Some((ci.id, ci.timeline_start, ci.effective_duration(dur))) Some((ci.id, ci.effective_start(), ci.total_duration(dur)))
}) })
.collect(); .collect();
if !group.is_empty() { if !group.is_empty() {
@ -1060,13 +1069,13 @@ impl TimelinePane {
if let Some(clip_duration) = clip_duration { if let Some(clip_duration) = clip_duration {
// Calculate effective duration accounting for trimming // Calculate effective duration accounting for trimming
let mut instance_duration = clip_instance.effective_duration(clip_duration); let mut instance_duration = clip_instance.total_duration(clip_duration);
// Instance positioned on the layer's timeline using timeline_start // Instance positioned on the layer's timeline using timeline_start
// The layer itself has start_time, so the absolute timeline position is: // The layer itself has start_time, so the absolute timeline position is:
// layer.start_time + instance.timeline_start // layer.start_time + instance.timeline_start
let _layer_data = layer.layer(); let _layer_data = layer.layer();
let mut instance_start = clip_instance.timeline_start; let mut instance_start = clip_instance.effective_start();
// Apply drag offset preview for selected clips with snapping // Apply drag offset preview for selected clips with snapping
let is_selected = selection.contains_clip_instance(&clip_instance.id); let is_selected = selection.contains_clip_instance(&clip_instance.id);
@ -1085,6 +1094,10 @@ impl TimelinePane {
false false
}; };
// Content origin: where the first "real" content iteration starts
// Loop iterations tile outward from this point
let mut content_origin = clip_instance.timeline_start;
// Track preview trim values for waveform rendering // Track preview trim values for waveform rendering
let mut preview_trim_start = clip_instance.trim_start; let mut preview_trim_start = clip_instance.trim_start;
let mut preview_clip_duration = clip_duration; let mut preview_clip_duration = clip_duration;
@ -1094,7 +1107,8 @@ impl TimelinePane {
match drag_type { match drag_type {
ClipDragType::Move => { ClipDragType::Move => {
if let Some(offset) = group_move_offset { if let Some(offset) = group_move_offset {
instance_start = (clip_instance.timeline_start + offset).max(0.0); instance_start = (clip_instance.effective_start() + offset).max(0.0);
content_origin = instance_start + clip_instance.loop_before.unwrap_or(0.0);
} }
} }
ClipDragType::TrimLeft => { ClipDragType::TrimLeft => {
@ -1108,7 +1122,7 @@ impl TimelinePane {
let max_extend = document.find_max_trim_extend_left( let max_extend = document.find_max_trim_extend_left(
&layer.id(), &layer.id(),
&clip_instance.id, &clip_instance.id,
clip_instance.timeline_start, clip_instance.effective_start(),
); );
let desired_extend = clip_instance.trim_start - desired_trim_start; let desired_extend = clip_instance.trim_start - desired_trim_start;
@ -1124,6 +1138,7 @@ impl TimelinePane {
// Move start and reduce duration by actual clamped offset // Move start and reduce duration by actual clamped offset
instance_start = (clip_instance.timeline_start + actual_offset) instance_start = (clip_instance.timeline_start + actual_offset)
.max(0.0); .max(0.0);
instance_duration = (clip_duration - new_trim_start).max(0.0); instance_duration = (clip_duration - new_trim_start).max(0.0);
// Adjust for existing trim_end // Adjust for existing trim_end
@ -1166,22 +1181,62 @@ impl TimelinePane {
// (the waveform system uses clip_duration to determine visible range) // (the waveform system uses clip_duration to determine visible range)
preview_clip_duration = new_trim_end - preview_trim_start; preview_clip_duration = new_trim_end - preview_trim_start;
} }
ClipDragType::LoopExtend => { ClipDragType::LoopExtendRight => {
// Loop extend: extend clip beyond content window // Loop extend right: extend clip beyond content window
// Use trimmed content window, NOT effective_duration (which includes loop extension)
let trim_end = clip_instance.trim_end.unwrap_or(clip_duration); let trim_end = clip_instance.trim_end.unwrap_or(clip_duration);
let content_window = (trim_end - clip_instance.trim_start).max(0.0); let content_window = (trim_end - clip_instance.trim_start).max(0.0);
let desired_total = (content_window + self.drag_offset).max(content_window * 0.25); let current_right = clip_instance.timeline_duration.unwrap_or(content_window);
let desired_right = (current_right + self.drag_offset).max(content_window);
// Check for adjacent clips let new_right = if desired_right > current_right {
let max_extend = document.find_max_trim_extend_right( let max_extend = document.find_max_trim_extend_right(
&layer.id(), &layer.id(),
&clip_instance.id, &clip_instance.id,
clip_instance.timeline_start, clip_instance.timeline_start,
content_window, current_right,
); );
let extend_amount = (desired_total - content_window).min(max_extend).max(0.0); let extend_amount = (desired_right - current_right).min(max_extend);
instance_duration = content_window + extend_amount; current_right + extend_amount
} else {
desired_right
};
// Total duration = loop_before + right duration
let loop_before = clip_instance.loop_before.unwrap_or(0.0);
instance_duration = loop_before + new_right;
}
ClipDragType::LoopExtendLeft => {
// Loop extend left: extend loop_before (pre-loop region)
// Snap to multiples of content_window so iterations align with backend
let trim_end = clip_instance.trim_end.unwrap_or(clip_duration);
let content_window = (trim_end - clip_instance.trim_start).max(0.001);
let current_loop_before = clip_instance.loop_before.unwrap_or(0.0);
// Invert: dragging left (negative offset) = extend
let desired_loop_before = (current_loop_before - self.drag_offset).max(0.0);
// Snap to whole iterations
let desired_iters = (desired_loop_before / content_window).round();
let snapped_loop_before = desired_iters * content_window;
let new_loop_before = if snapped_loop_before > current_loop_before {
// Extending left - check for adjacent clips
let max_extend = document.find_max_loop_extend_left(
&layer.id(),
&clip_instance.id,
clip_instance.effective_start(),
);
let extend_amount = (snapped_loop_before - current_loop_before).min(max_extend);
// Re-snap after clamping
let clamped = current_loop_before + extend_amount;
(clamped / content_window).floor() * content_window
} else {
snapped_loop_before
};
// Recompute instance_start and instance_duration
let right_duration = clip_instance.effective_duration(clip_duration);
instance_start = clip_instance.timeline_start - new_loop_before;
instance_duration = new_loop_before + right_duration;
content_origin = clip_instance.timeline_start;
} }
} }
} }
@ -1237,14 +1292,33 @@ impl TimelinePane {
let is_looping_bg = instance_duration > content_window_for_bg + 0.001 && content_window_for_bg > 0.0; let is_looping_bg = instance_duration > content_window_for_bg + 0.001 && content_window_for_bg > 0.0;
if is_looping_bg { if is_looping_bg {
let num_bg_iters = ((instance_duration / content_window_for_bg).ceil() as usize).max(1); // Compute iterations aligned to content_origin
let loop_before_val = content_origin - instance_start;
let pre_iters = if loop_before_val > 0.001 {
(loop_before_val / content_window_for_bg).ceil() as usize
} else {
0
};
let right_duration = instance_duration - loop_before_val;
let post_iters = if right_duration > 0.001 {
(right_duration / content_window_for_bg).ceil() as usize
} else {
1
};
let total_iters = pre_iters + post_iters;
let faded_color = egui::Color32::from_rgba_unmultiplied( let faded_color = egui::Color32::from_rgba_unmultiplied(
clip_color.r(), clip_color.g(), clip_color.b(), clip_color.r(), clip_color.g(), clip_color.b(),
(clip_color.a() as f32 * 0.55) as u8, (clip_color.a() as f32 * 0.55) as u8,
); );
for i in 0..num_bg_iters { for i in 0..total_iters {
let iter_time_start = instance_start + i as f64 * content_window_for_bg; let signed_i = i as i64 - pre_iters as i64;
let iter_time_end = (iter_time_start + content_window_for_bg).min(instance_start + instance_duration); let iter_time_start_raw = content_origin + signed_i as f64 * content_window_for_bg;
let iter_time_end_raw = iter_time_start_raw + content_window_for_bg;
let iter_time_start = iter_time_start_raw.max(instance_start);
let iter_time_end = iter_time_end_raw.min(instance_start + instance_duration);
if iter_time_end <= iter_time_start { continue; }
let ix0 = (rect.min.x + ((iter_time_start - self.viewport_start_time) * self.pixels_per_second as f64) as f32).max(clip_rect.min.x); let ix0 = (rect.min.x + ((iter_time_start - self.viewport_start_time) * self.pixels_per_second as f64) as f32).max(clip_rect.min.x);
let ix1 = (rect.min.x + ((iter_time_end - self.viewport_start_time) * self.pixels_per_second as f64) as f32).min(clip_rect.max.x); let ix1 = (rect.min.x + ((iter_time_end - self.viewport_start_time) * self.pixels_per_second as f64) as f32).min(clip_rect.max.x);
if ix1 > ix0 { if ix1 > ix0 {
@ -1252,7 +1326,7 @@ impl TimelinePane {
egui::pos2(ix0, clip_rect.min.y), egui::pos2(ix0, clip_rect.min.y),
egui::pos2(ix1, clip_rect.max.y), egui::pos2(ix1, clip_rect.max.y),
); );
let color = if i == 0 { clip_color } else { faded_color }; let color = if signed_i == 0 { clip_color } else { faded_color };
painter.rect_filled(iter_rect, 3.0, color); painter.rect_filled(iter_rect, 3.0, color);
} }
} }
@ -1275,21 +1349,22 @@ impl TimelinePane {
let preview_trim_end = clip_instance.trim_end.unwrap_or(clip_duration); let preview_trim_end = clip_instance.trim_end.unwrap_or(clip_duration);
let content_window = (preview_trim_end - preview_trim_start).max(0.0); let content_window = (preview_trim_end - preview_trim_start).max(0.0);
let is_looping = instance_duration > content_window + 0.001; let is_looping = instance_duration > content_window + 0.001;
let num_iterations = if is_looping && content_window > 0.0 {
((instance_duration / content_window).ceil() as usize).max(1)
} else {
1
};
for iteration in 0..num_iterations { if is_looping && content_window > 0.0 {
let iter_offset = iteration as f64 * content_window; // Compute iterations aligned to content_origin
let iter_start = instance_start + iter_offset; let lb_val = content_origin - instance_start;
let iter_end = (iter_start + content_window).min(instance_start + instance_duration); let pre = if lb_val > 0.001 { (lb_val / content_window).ceil() as usize } else { 0 };
let right_dur = instance_duration - lb_val;
let post = if right_dur > 0.001 { (right_dur / content_window).ceil() as usize } else { 1 };
for i in 0..(pre + post) {
let si = i as i64 - pre as i64;
let iter_start_raw = content_origin + si as f64 * content_window;
let iter_end_raw = iter_start_raw + content_window;
let iter_start = iter_start_raw.max(instance_start);
let iter_end = iter_end_raw.min(instance_start + instance_duration);
let iter_duration = iter_end - iter_start; let iter_duration = iter_end - iter_start;
if iter_duration <= 0.0 { continue; }
if iter_duration <= 0.0 {
break;
}
Self::render_midi_piano_roll( Self::render_midi_piano_roll(
painter, painter,
@ -1303,7 +1378,23 @@ impl TimelinePane {
self.pixels_per_second, self.pixels_per_second,
theme, theme,
ui.ctx(), ui.ctx(),
iteration > 0, // fade subsequent iterations si != 0, // fade non-content iterations
);
}
} else {
Self::render_midi_piano_roll(
painter,
clip_rect,
rect.min.x,
events,
clip_instance.trim_start,
instance_duration,
instance_start,
self.viewport_start_time,
self.pixels_per_second,
theme,
ui.ctx(),
false,
); );
} }
} }
@ -1338,21 +1429,31 @@ impl TimelinePane {
let preview_trim_end = clip_instance.trim_end.unwrap_or(clip_duration); let preview_trim_end = clip_instance.trim_end.unwrap_or(clip_duration);
let content_window = (preview_trim_end - preview_trim_start).max(0.0); let content_window = (preview_trim_end - preview_trim_start).max(0.0);
let is_looping = instance_duration > content_window + 0.001; let is_looping = instance_duration > content_window + 0.001;
let num_iterations = if is_looping && content_window > 0.0 {
((instance_duration / content_window).ceil() as usize).max(1) // Compute iterations aligned to content_origin
let lb_val = content_origin - instance_start;
let pre_w = if is_looping && lb_val > 0.001 { (lb_val / content_window).ceil() as usize } else { 0 };
let right_dur_w = instance_duration - lb_val;
let post_w = if is_looping && content_window > 0.0 {
(right_dur_w / content_window).ceil() as usize
} else { } else {
1 1
}; };
let total_w = pre_w + post_w;
for iteration in 0..num_iterations { for wi in 0..total_w {
let iter_offset = iteration as f64 * content_window; let si_w = wi as i64 - pre_w as i64;
let iter_start = instance_start + iter_offset; let (iter_start, iter_duration) = if is_looping {
let iter_end = (iter_start + content_window).min(instance_start + instance_duration); let raw_start = content_origin + si_w as f64 * content_window;
let iter_duration = iter_end - iter_start; let raw_end = raw_start + content_window;
let s = raw_start.max(instance_start);
let e = raw_end.min(instance_start + instance_duration);
(s, (e - s).max(0.0))
} else {
(instance_start, instance_duration)
};
if iter_duration <= 0.0 { if iter_duration <= 0.0 { continue; }
break;
}
let iter_screen_start = rect.min.x + ((iter_start - self.viewport_start_time) * self.pixels_per_second as f64) as f32; let iter_screen_start = rect.min.x + ((iter_start - self.viewport_start_time) * self.pixels_per_second as f64) as f32;
let iter_screen_end = iter_screen_start + (iter_duration * self.pixels_per_second as f64) as f32; let iter_screen_end = iter_screen_start + (iter_duration * self.pixels_per_second as f64) as f32;
@ -1362,7 +1463,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 {
let instance_id = clip_instance.id.as_u128() as u64 + iteration as u64; let instance_id = clip_instance.id.as_u128() as u64 + wi as u64;
let is_loop_iter = si_w != 0;
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,
@ -1379,7 +1481,7 @@ impl TimelinePane {
segment_start_frame: 0.0, segment_start_frame: 0.0,
display_mode: if waveform_stereo { 1.0 } else { 0.0 }, display_mode: if waveform_stereo { 1.0 } else { 0.0 },
_pad1: [0.0, 0.0], _pad1: [0.0, 0.0],
tint_color: if iteration > 0 { tint_color: if is_loop_iter {
[tint[0], tint[1], tint[2], tint[3] * 0.5] [tint[0], tint[1], tint[2], tint[3] * 0.5]
} else { } else {
tint tint
@ -1388,7 +1490,7 @@ impl TimelinePane {
_pad: [0.0, 0.0], _pad: [0.0, 0.0],
}, },
target_format, target_format,
pending_upload: if iteration == 0 { pending_upload.clone() } else { None }, pending_upload: if wi == 0 { pending_upload.clone() } else { None },
instance_id, instance_id,
}; };
@ -1494,10 +1596,18 @@ impl TimelinePane {
}; };
if is_looping_bg { if is_looping_bg {
let num_border_iters = ((instance_duration / content_window_for_bg).ceil() as usize).max(1); // Aligned to content_origin (same as bg rendering)
for i in 0..num_border_iters { let lb_border = content_origin - instance_start;
let iter_time_start = instance_start + i as f64 * content_window_for_bg; let pre_b = if lb_border > 0.001 { (lb_border / content_window_for_bg).ceil() as usize } else { 0 };
let iter_time_end = (iter_time_start + content_window_for_bg).min(instance_start + instance_duration); let right_b = instance_duration - lb_border;
let post_b = if right_b > 0.001 { (right_b / content_window_for_bg).ceil() as usize } else { 1 };
for i in 0..(pre_b + post_b) {
let si_b = i as i64 - pre_b as i64;
let iter_time_start_raw = content_origin + si_b as f64 * content_window_for_bg;
let iter_time_end_raw = iter_time_start_raw + content_window_for_bg;
let iter_time_start = iter_time_start_raw.max(instance_start);
let iter_time_end = iter_time_end_raw.min(instance_start + instance_duration);
if iter_time_end <= iter_time_start { continue; }
let ix0 = (rect.min.x + ((iter_time_start - self.viewport_start_time) * self.pixels_per_second as f64) as f32).max(clip_rect.min.x); let ix0 = (rect.min.x + ((iter_time_start - self.viewport_start_time) * self.pixels_per_second as f64) as f32).max(clip_rect.min.x);
let ix1 = (rect.min.x + ((iter_time_end - self.viewport_start_time) * self.pixels_per_second as f64) as f32).min(clip_rect.max.x); let ix1 = (rect.min.x + ((iter_time_end - self.viewport_start_time) * self.pixels_per_second as f64) as f32).min(clip_rect.max.x);
if ix1 > ix0 { if ix1 > ix0 {
@ -1637,8 +1747,8 @@ impl TimelinePane {
}; };
if let Some(clip_duration) = clip_duration { if let Some(clip_duration) = clip_duration {
let instance_duration = clip_instance.effective_duration(clip_duration); let instance_duration = clip_instance.total_duration(clip_duration);
let instance_start = clip_instance.timeline_start; let instance_start = clip_instance.effective_start();
let instance_end = instance_start + instance_duration; let instance_end = instance_start + instance_duration;
// Check if click is within this clip instance's time range // Check if click is within this clip instance's time range
@ -1945,8 +2055,8 @@ impl TimelinePane {
pending_actions.push(action); pending_actions.push(action);
} }
} }
ClipDragType::LoopExtend => { ClipDragType::LoopExtendRight => {
let mut layer_loops: HashMap<uuid::Uuid, Vec<(uuid::Uuid, Option<f64>, Option<f64>)>> = HashMap::new(); let mut layer_loops: HashMap<uuid::Uuid, Vec<lightningbeam_core::actions::loop_clip_instances::LoopEntry>> = HashMap::new();
for layer in &document.root.children { for layer in &document.root.children {
let layer_id = layer.id(); let layer_id = layer.id();
@ -1967,25 +2077,27 @@ impl TimelinePane {
}; };
if let Some(clip_duration) = clip_duration { if let Some(clip_duration) = clip_duration {
// Use trimmed content window, NOT effective_duration (which includes loop extension)
let trim_end = clip_instance.trim_end.unwrap_or(clip_duration); let trim_end = clip_instance.trim_end.unwrap_or(clip_duration);
let content_window = (trim_end - clip_instance.trim_start).max(0.0); let content_window = (trim_end - clip_instance.trim_start).max(0.0);
let desired_total = content_window + self.drag_offset; let current_right = clip_instance.timeline_duration.unwrap_or(content_window);
let desired_right = current_right + self.drag_offset;
// Check for adjacent clips let new_right = if desired_right > current_right {
let max_extend = document.find_max_trim_extend_right( let max_extend = document.find_max_trim_extend_right(
&layer_id, &layer_id,
&clip_instance.id, &clip_instance.id,
clip_instance.timeline_start, clip_instance.timeline_start,
content_window, current_right,
); );
let extend_amount = (desired_total - content_window).min(max_extend).max(0.0); let extend_amount = (desired_right - current_right).min(max_extend);
let new_total = content_window + extend_amount; current_right + extend_amount
} else {
desired_right
};
let old_timeline_duration = clip_instance.timeline_duration; let old_timeline_duration = clip_instance.timeline_duration;
// Only set timeline_duration if extending beyond content let new_timeline_duration = if new_right > content_window + 0.001 {
let new_timeline_duration = if new_total > content_window + 0.001 { Some(new_right)
Some(new_total)
} else { } else {
None None
}; };
@ -1994,7 +2106,89 @@ impl TimelinePane {
layer_loops layer_loops
.entry(layer_id) .entry(layer_id)
.or_insert_with(Vec::new) .or_insert_with(Vec::new)
.push((clip_instance.id, old_timeline_duration, new_timeline_duration)); .push((
clip_instance.id,
old_timeline_duration,
new_timeline_duration,
clip_instance.loop_before,
clip_instance.loop_before, // loop_before unchanged
));
}
}
}
}
}
if !layer_loops.is_empty() {
let action = Box::new(
lightningbeam_core::actions::LoopClipInstancesAction::new(layer_loops),
);
pending_actions.push(action);
}
}
ClipDragType::LoopExtendLeft => {
// Extend loop_before (pre-loop region)
let mut layer_loops: HashMap<uuid::Uuid, Vec<lightningbeam_core::actions::loop_clip_instances::LoopEntry>> = HashMap::new();
for layer in &document.root.children {
let layer_id = layer.id();
let clip_instances = match layer {
lightningbeam_core::layer::AnyLayer::Vector(vl) => &vl.clip_instances,
lightningbeam_core::layer::AnyLayer::Audio(al) => &al.clip_instances,
lightningbeam_core::layer::AnyLayer::Video(vl) => &vl.clip_instances,
lightningbeam_core::layer::AnyLayer::Effect(el) => &el.clip_instances,
};
for clip_instance in clip_instances {
if selection.contains_clip_instance(&clip_instance.id) {
let clip_duration = match layer {
lightningbeam_core::layer::AnyLayer::Audio(_) => {
document.get_audio_clip(&clip_instance.clip_id).map(|c| c.duration)
}
_ => continue,
};
if let Some(clip_duration) = clip_duration {
let trim_end = clip_instance.trim_end.unwrap_or(clip_duration);
let content_window = (trim_end - clip_instance.trim_start).max(0.001);
let current_loop_before = clip_instance.loop_before.unwrap_or(0.0);
// Invert: dragging left (negative offset) = extend
let desired_loop_before = (current_loop_before - self.drag_offset).max(0.0);
// Snap to whole iterations so backend modulo aligns
let desired_iters = (desired_loop_before / content_window).round();
let snapped = desired_iters * content_window;
let new_loop_before = if snapped > current_loop_before {
let max_extend = document.find_max_loop_extend_left(
&layer_id,
&clip_instance.id,
clip_instance.effective_start(),
);
let extend_amount = (snapped - current_loop_before).min(max_extend);
let clamped = current_loop_before + extend_amount;
(clamped / content_window).floor() * content_window
} else {
snapped
};
let old_loop_before = clip_instance.loop_before;
let new_lb = if new_loop_before > 0.001 {
Some(new_loop_before)
} else {
None
};
if old_loop_before != new_lb {
layer_loops
.entry(layer_id)
.or_insert_with(Vec::new)
.push((
clip_instance.id,
clip_instance.timeline_duration,
clip_instance.timeline_duration, // timeline_duration unchanged
old_loop_before,
new_lb,
));
} }
} }
} }
@ -2161,8 +2355,8 @@ impl TimelinePane {
ClipDragType::TrimLeft | ClipDragType::TrimRight => { ClipDragType::TrimLeft | ClipDragType::TrimRight => {
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal); ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
} }
ClipDragType::LoopExtend => { ClipDragType::LoopExtendRight | ClipDragType::LoopExtendLeft => {
ui.ctx().set_cursor_icon(egui::CursorIcon::Alias); crate::custom_cursor::set(ui.ctx(), crate::custom_cursor::CustomCursor::LoopExtend);
} }
ClipDragType::Move => {} ClipDragType::Move => {}
} }
@ -2178,8 +2372,8 @@ impl TimelinePane {
ClipDragType::TrimLeft | ClipDragType::TrimRight => { ClipDragType::TrimLeft | ClipDragType::TrimRight => {
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal); ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
} }
ClipDragType::LoopExtend => { ClipDragType::LoopExtendRight | ClipDragType::LoopExtendLeft => {
ui.ctx().set_cursor_icon(egui::CursorIcon::Alias); crate::custom_cursor::set(ui.ctx(), crate::custom_cursor::CustomCursor::LoopExtend);
} }
ClipDragType::Move => {} ClipDragType::Move => {}
} }