Fix looping bugs
This commit is contained in:
parent
042dd50db3
commit
ce40147efa
|
|
@ -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<f64>, Option<f64>, Option<f64>, Option<f64>);
|
||||
|
||||
/// 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<Uuid, Vec<(Uuid, Option<f64>, Option<f64>)>>,
|
||||
/// Map of layer IDs to vectors of loop entries
|
||||
layer_loops: HashMap<Uuid, Vec<LoopEntry>>,
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
}
|
||||
|
|
@ -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<u32, String> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<f64>,
|
||||
}
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<EffectThumbnailGenerator>,
|
||||
|
||||
/// 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})",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<uuid::Uuid, Vec<(uuid::Uuid, Option<f64>, Option<f64>)>> = HashMap::new();
|
||||
ClipDragType::LoopExtendRight => {
|
||||
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();
|
||||
|
|
@ -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<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 => {
|
||||
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 => {}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue