diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/loop_clip_instances.rs b/lightningbeam-ui/lightningbeam-core/src/actions/loop_clip_instances.rs index b9fa16d..fd9afbc 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/loop_clip_instances.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/loop_clip_instances.rs @@ -1,7 +1,7 @@ //! Loop clip instances action //! //! 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::document::Document; @@ -9,14 +9,17 @@ use crate::layer::AnyLayer; use std::collections::HashMap; 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, Option, Option, Option); + /// Action that changes the loop duration of clip instances pub struct LoopClipInstancesAction { - /// Map of layer IDs to vectors of (instance_id, old_timeline_duration, new_timeline_duration) - layer_loops: HashMap, Option)>>, + /// Map of layer IDs to vectors of loop entries + layer_loops: HashMap>, } impl LoopClipInstancesAction { - pub fn new(layer_loops: HashMap, Option)>>) -> Self { + pub fn new(layer_loops: HashMap>) -> Self { Self { layer_loops } } } @@ -34,9 +37,10 @@ impl Action for LoopClipInstancesAction { 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) { - 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, }; - 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) { - instance.timeline_duration = *old; + instance.timeline_duration = *old_dur; + instance.loop_before = *old_lb; } } } @@ -102,7 +107,7 @@ impl LoopClipInstancesAction { _ => 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() .find(|ci| ci.id == *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) .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 = if rollback { old } else { new }; + let (target_duration, target_loop_before) = if rollback { + (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 trim_end = instance.trim_end.unwrap_or(clip.duration); (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; - match &clip.clip_type { - AudioClipType::Midi { midi_clip_id } => { - controller.extend_clip(*track_id, *midi_clip_id, external_duration); - } - AudioClipType::Sampled { .. } => { - let backend_instance_id = backend.clip_instance_to_backend_map.get(instance_id) - .ok_or_else(|| format!("Clip instance {} not mapped to backend", instance_id))?; - - match backend_instance_id { - crate::action::BackendClipInstanceId::Audio(audio_id) => { - controller.extend_clip(*track_id, *audio_id, external_duration); + let get_backend_clip_id = |inst_id: &Uuid| -> Result { + match &clip.clip_type { + AudioClipType::Midi { midi_clip_id } => Ok(*midi_clip_id), + AudioClipType::Sampled { .. } => { + let backend_id = backend.clip_instance_to_backend_map.get(inst_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()), } - _ => return Err("Expected audio instance ID for sampled clip".to_string()), } + AudioClipType::Recording => Err("Cannot sync recording clip".to_string()), } - AudioClipType::Recording => {} + }; + + if let Ok(backend_clip_id) = get_backend_clip_id(instance_id) { + controller.move_clip(*track_id, backend_clip_id, external_start); + controller.extend_clip(*track_id, backend_clip_id, external_duration); } } } diff --git a/lightningbeam-ui/lightningbeam-core/src/clip.rs b/lightningbeam-ui/lightningbeam-core/src/clip.rs index 45c4c17..c9c1e88 100644 --- a/lightningbeam-ui/lightningbeam-core/src/clip.rs +++ b/lightningbeam-ui/lightningbeam-core/src/clip.rs @@ -573,6 +573,12 @@ pub struct ClipInstance { /// Compatible with daw-backend's Clip.gain /// Default: 1.0 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, } impl ClipInstance { @@ -590,6 +596,7 @@ impl ClipInstance { trim_end: None, playback_speed: 1.0, gain: 1.0, + loop_before: None, } } @@ -607,6 +614,7 @@ impl ClipInstance { trim_end: None, playback_speed: 1.0, gain: 1.0, + loop_before: None, } } @@ -683,6 +691,17 @@ impl ClipInstance { (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 /// /// Takes a global timeline time and returns the corresponding time within this diff --git a/lightningbeam-ui/lightningbeam-core/src/document.rs b/lightningbeam-ui/lightningbeam-core/src/document.rs index 16ae16e..47e8364 100644 --- a/lightningbeam-ui/lightningbeam-core/src/document.rs +++ b/lightningbeam-ui/lightningbeam-core/src/document.rs @@ -604,15 +604,13 @@ impl Document { continue; } - // Calculate instance end time + // Calculate instance extent (accounting for loop_before) let Some(clip_duration) = self.get_clip_duration(&instance.clip_id) else { continue; }; - let instance_start = instance.timeline_start; - let trim_start = instance.trim_start; - let trim_end = instance.trim_end.unwrap_or(clip_duration); - let instance_end = instance_start + (trim_end - trim_start); + let instance_start = instance.effective_start(); + let instance_end = instance.timeline_start + instance.effective_duration(clip_duration); // Check overlap: start_a < end_b AND start_b < end_a 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) { - let inst_start = instance.timeline_start; - let trim_start = instance.trim_start; - let trim_end = instance.trim_end.unwrap_or(clip_dur); - let inst_end = inst_start + (trim_end - trim_start); + let inst_start = instance.effective_start(); + let inst_end = instance.timeline_start + instance.effective_duration(clip_dur); occupied_ranges.push((inst_start, inst_end, instance.id)); } } @@ -762,8 +758,9 @@ impl Document { 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 start = inst.effective_start(); + let end = inst.timeline_start + inst.effective_duration(dur); + non_group.push((start, end)); } } @@ -828,10 +825,9 @@ impl Document { 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) { - let trim_end = other.trim_end.unwrap_or(clip_duration); - let other_end = other.timeline_start + (trim_end - other.trim_start); + let other_end = other.timeline_start + other.effective_duration(clip_duration); // If this clip is to the left and closer than current nearest if other_end <= current_timeline_start && other_end > nearest_end { @@ -878,9 +874,10 @@ impl Document { continue; } - // If this clip is to the right and closer than current nearest - if other.timeline_start >= current_end && other.timeline_start < nearest_start { - nearest_start = other.timeline_start; + // Use effective_start to account for loop_before on the other clip + let other_start = other.effective_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 } } + /// 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)] diff --git a/lightningbeam-ui/lightningbeam-editor/src/custom_cursor.rs b/lightningbeam-ui/lightningbeam-editor/src/custom_cursor.rs new file mode 100644 index 0000000..e9fa3dc --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/custom_cursor.rs @@ -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, + /// White cursor for the outline + outline_textures: HashMap, +} + +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 { + 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::(id); + d.remove::(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); + } + } +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index fafdc16..fa4f3dc 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -38,6 +38,7 @@ mod notifications; mod effect_thumbnails; use effect_thumbnails::EffectThumbnailGenerator; +mod custom_cursor; mod debug_overlay; mod sample_import; @@ -732,6 +733,8 @@ struct EditorApp { /// GPU-rendered effect thumbnail generator effect_thumbnail_generator: Option, + /// Custom cursor cache for SVG cursors + cursor_cache: custom_cursor::CursorCache, /// Debug overlay (F3) state debug_overlay_visible: bool, debug_stats_collector: debug_overlay::DebugStatsCollector, @@ -926,6 +929,7 @@ impl EditorApp { effect_thumbnail_generator: None, // Initialized when GPU available // Debug overlay (F3) + cursor_cache: custom_cursor::CursorCache::new(), debug_overlay_visible: false, debug_stats_collector: debug_overlay::DebugStatsCollector::new(), gpu_info, @@ -4696,6 +4700,9 @@ impl eframe::App for EditorApp { 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; if frame_ms > 50.0 { eprintln!("[TIMING] SLOW FRAME: {:.1}ms (pre-events={:.1}, events={:.1}, post-events={:.1})", diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs index f679005..58e7f60 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs @@ -6314,6 +6314,17 @@ impl PaneRenderer for StagePane { // Render vector editing overlays (vertices, control points, etc.) 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 { diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs index f10a8dd..1d392d3 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs @@ -25,7 +25,8 @@ enum ClipDragType { Move, TrimLeft, TrimRight, - LoopExtend, + LoopExtendRight, + LoopExtendLeft, } pub struct TimelinePane { @@ -194,7 +195,7 @@ impl TimelinePane { match layer_type { AudioLayerType::Midi => { // 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); shared.recording_clips.insert(active_layer_id, clip_id); println!("🎹 Started MIDI recording on track {:?} at {:.2}s, clip_id={}", @@ -204,7 +205,7 @@ impl TimelinePane { drop(controller); // 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 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.timeline_start; + let instance_start = clip_instance.effective_start(); + let instance_duration = clip_instance.total_duration(clip_duration); let instance_end = instance_start + instance_duration; 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 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 { - ClipDragType::TrimLeft + // 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 + } } else if (end_x - mouse_x).abs() <= EDGE_DETECTION_PIXELS { - // Top-right corner of audio clips = loop extend - if is_audio_layer && mouse_in_top_corner { - ClipDragType::LoopExtend + // If already looping, right edge is always loop extend + // Otherwise, top-right corner of audio clips = loop extend + if is_audio_layer && (is_looping || mouse_in_top_corner) { + ClipDragType::LoopExtendRight } else { ClipDragType::TrimRight } @@ -1026,7 +1035,7 @@ impl TimelinePane { .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))) + Some((ci.id, ci.effective_start(), ci.total_duration(dur))) }) .collect(); if !group.is_empty() { @@ -1060,13 +1069,13 @@ impl TimelinePane { if let Some(clip_duration) = clip_duration { // 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 // The layer itself has start_time, so the absolute timeline position is: // layer.start_time + instance.timeline_start 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 let is_selected = selection.contains_clip_instance(&clip_instance.id); @@ -1085,6 +1094,10 @@ impl TimelinePane { 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 let mut preview_trim_start = clip_instance.trim_start; let mut preview_clip_duration = clip_duration; @@ -1094,7 +1107,8 @@ impl TimelinePane { match drag_type { ClipDragType::Move => { 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 => { @@ -1108,7 +1122,7 @@ impl TimelinePane { let max_extend = document.find_max_trim_extend_left( &layer.id(), &clip_instance.id, - clip_instance.timeline_start, + clip_instance.effective_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 instance_start = (clip_instance.timeline_start + actual_offset) .max(0.0); + instance_duration = (clip_duration - new_trim_start).max(0.0); // Adjust for existing trim_end @@ -1166,22 +1181,62 @@ impl TimelinePane { // (the waveform system uses clip_duration to determine visible range) preview_clip_duration = new_trim_end - preview_trim_start; } - ClipDragType::LoopExtend => { - // Loop extend: extend clip beyond content window - // Use trimmed content window, NOT effective_duration (which includes loop extension) + ClipDragType::LoopExtendRight => { + // Loop extend right: extend clip beyond content window let trim_end = clip_instance.trim_end.unwrap_or(clip_duration); 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 max_extend = document.find_max_trim_extend_right( - &layer.id(), - &clip_instance.id, - clip_instance.timeline_start, - content_window, - ); - let extend_amount = (desired_total - content_window).min(max_extend).max(0.0); - instance_duration = content_window + extend_amount; + let new_right = if desired_right > current_right { + let max_extend = document.find_max_trim_extend_right( + &layer.id(), + &clip_instance.id, + clip_instance.timeline_start, + current_right, + ); + let extend_amount = (desired_right - current_right).min(max_extend); + 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; 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( clip_color.r(), clip_color.g(), clip_color.b(), (clip_color.a() as f32 * 0.55) as u8, ); - for i in 0..num_bg_iters { - let iter_time_start = instance_start + i as f64 * content_window_for_bg; - let iter_time_end = (iter_time_start + content_window_for_bg).min(instance_start + instance_duration); + for i in 0..total_iters { + let signed_i = i as i64 - pre_iters as i64; + 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 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 { @@ -1252,7 +1326,7 @@ impl TimelinePane { egui::pos2(ix0, clip_rect.min.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); } } @@ -1275,35 +1349,52 @@ impl TimelinePane { 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 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 { - let iter_offset = iteration as f64 * content_window; - let iter_start = instance_start + iter_offset; - let iter_end = (iter_start + content_window).min(instance_start + instance_duration); - let iter_duration = iter_end - iter_start; + if is_looping && content_window > 0.0 { + // Compute iterations aligned to content_origin + let lb_val = content_origin - instance_start; + 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 }; - if iter_duration <= 0.0 { - break; + 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; + if iter_duration <= 0.0 { continue; } + + Self::render_midi_piano_roll( + painter, + clip_rect, + rect.min.x, + events, + clip_instance.trim_start, + iter_duration, + iter_start, + self.viewport_start_time, + self.pixels_per_second, + theme, + ui.ctx(), + si != 0, // fade non-content iterations + ); } - + } else { Self::render_midi_piano_roll( painter, clip_rect, rect.min.x, events, clip_instance.trim_start, - iter_duration, - iter_start, + instance_duration, + instance_start, self.viewport_start_time, self.pixels_per_second, theme, ui.ctx(), - iteration > 0, // fade subsequent iterations + false, ); } } @@ -1338,21 +1429,31 @@ impl TimelinePane { 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 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 { 1 }; + let total_w = pre_w + post_w; - for iteration in 0..num_iterations { - let iter_offset = iteration as f64 * content_window; - let iter_start = instance_start + iter_offset; - let iter_end = (iter_start + content_window).min(instance_start + instance_duration); - let iter_duration = iter_end - iter_start; + for wi in 0..total_w { + let si_w = wi as i64 - pre_w as i64; + let (iter_start, iter_duration) = if is_looping { + let raw_start = content_origin + si_w as f64 * content_window; + 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 { - break; - } + if iter_duration <= 0.0 { continue; } 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; @@ -1362,7 +1463,8 @@ impl TimelinePane { ); 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 { pool_index: *audio_pool_index, segment_index: 0, @@ -1379,7 +1481,7 @@ impl TimelinePane { segment_start_frame: 0.0, display_mode: if waveform_stereo { 1.0 } else { 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] } else { tint @@ -1388,7 +1490,7 @@ impl TimelinePane { _pad: [0.0, 0.0], }, target_format, - pending_upload: if iteration == 0 { pending_upload.clone() } else { None }, + pending_upload: if wi == 0 { pending_upload.clone() } else { None }, instance_id, }; @@ -1494,10 +1596,18 @@ impl TimelinePane { }; if is_looping_bg { - let num_border_iters = ((instance_duration / content_window_for_bg).ceil() as usize).max(1); - for i in 0..num_border_iters { - let iter_time_start = instance_start + i as f64 * content_window_for_bg; - let iter_time_end = (iter_time_start + content_window_for_bg).min(instance_start + instance_duration); + // Aligned to content_origin (same as bg rendering) + let lb_border = content_origin - instance_start; + let pre_b = if lb_border > 0.001 { (lb_border / content_window_for_bg).ceil() as usize } else { 0 }; + 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 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 { @@ -1637,8 +1747,8 @@ impl TimelinePane { }; if let Some(clip_duration) = clip_duration { - let instance_duration = clip_instance.effective_duration(clip_duration); - let instance_start = clip_instance.timeline_start; + let instance_duration = clip_instance.total_duration(clip_duration); + let instance_start = clip_instance.effective_start(); let instance_end = instance_start + instance_duration; // Check if click is within this clip instance's time range @@ -1945,8 +2055,8 @@ impl TimelinePane { pending_actions.push(action); } } - ClipDragType::LoopExtend => { - let mut layer_loops: HashMap, Option)>> = HashMap::new(); + ClipDragType::LoopExtendRight => { + let mut layer_loops: HashMap> = HashMap::new(); for layer in &document.root.children { let layer_id = layer.id(); @@ -1967,25 +2077,27 @@ impl TimelinePane { }; 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 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 max_extend = document.find_max_trim_extend_right( - &layer_id, - &clip_instance.id, - clip_instance.timeline_start, - content_window, - ); - let extend_amount = (desired_total - content_window).min(max_extend).max(0.0); - let new_total = content_window + extend_amount; + let new_right = if desired_right > current_right { + let max_extend = document.find_max_trim_extend_right( + &layer_id, + &clip_instance.id, + clip_instance.timeline_start, + current_right, + ); + let extend_amount = (desired_right - current_right).min(max_extend); + current_right + extend_amount + } else { + desired_right + }; let old_timeline_duration = clip_instance.timeline_duration; - // Only set timeline_duration if extending beyond content - let new_timeline_duration = if new_total > content_window + 0.001 { - Some(new_total) + let new_timeline_duration = if new_right > content_window + 0.001 { + Some(new_right) } else { None }; @@ -1994,7 +2106,89 @@ impl TimelinePane { layer_loops .entry(layer_id) .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> = 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 => { ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal); } - ClipDragType::LoopExtend => { - ui.ctx().set_cursor_icon(egui::CursorIcon::Alias); + ClipDragType::LoopExtendRight | ClipDragType::LoopExtendLeft => { + crate::custom_cursor::set(ui.ctx(), crate::custom_cursor::CustomCursor::LoopExtend); } ClipDragType::Move => {} } @@ -2178,8 +2372,8 @@ impl TimelinePane { ClipDragType::TrimLeft | ClipDragType::TrimRight => { ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal); } - ClipDragType::LoopExtend => { - ui.ctx().set_cursor_icon(egui::CursorIcon::Alias); + ClipDragType::LoopExtendRight | ClipDragType::LoopExtendLeft => { + crate::custom_cursor::set(ui.ctx(), crate::custom_cursor::CustomCursor::LoopExtend); } ClipDragType::Move => {} }