Timeline types stage 2: fix editor seconds↔beats at every clip boundary

Thread Beats/Seconds through the editor now that lightningbeam-core is typed.
The timeline UI is seconds-domain (pixels_per_second, viewport_start_time,
playback_time, drag_offset), while ClipInstance.timeline_start/duration/
loop_before are beats — the compiler flagged every place the two were mixed.

Fixes the reported bugs and the whole class behind them:
- Recording placement (audio + MIDI + webcam): the new clip's timeline_start
  was written from playback_time (seconds) into a beats field, so a second
  recording landed at the wrong time. Now converted via the tempo map.
- Drag/move: introduce snapped_move_offset -> Beats (snap in the seconds/pixel
  domain, express the anchor's movement in beats) and moved_start(); the group
  clamp, live preview, and commit all use one uniform beats offset instead of
  adding a seconds delta to a beats position.
- Trim/loop drag preview + commit: overlap limits come from the timeline
  (beats) but trims are content seconds — converted at the boundary.
- Drop (asset drag, stage + timeline), paste, duplicate, split-at-playhead:
  all convert the seconds drop/playhead position to beats before placing.
- Stage playback gates + clip-local time remap: compared seconds playback_time
  against beats timeline_start; now both in seconds.
- Piano roll / infopanel / effect export: clip_dur is Seconds; effect and
  waveform data converted at use.

Core API refinement (motivated by the above): find_max_trim_extend_left/right
now return Seconds (the content-seconds gap) instead of raw Beats, since every
trim caller wants seconds; loop-extend callers convert to beats. Added a
beats_to_x() timeline helper.

Whole workspace compiles; 299 core tests pass.
This commit is contained in:
Skyler Lehmkuhl 2026-07-11 12:11:05 -04:00
parent 053a77cfa1
commit 0050c18623
8 changed files with 398 additions and 277 deletions

View File

@ -204,20 +204,12 @@ impl Action for TrimClipInstancesAction {
{ {
// If extending to the left (new_trim < old_trim) // If extending to the left (new_trim < old_trim)
if should_validate && new_trim < old_trim { if should_validate && new_trim < old_trim {
// Max we can extend left is the timeline gap to the // Max leftward extension as content seconds (the gap's wall-clock span).
// previous clip (beats). let max_extend_secs = document.find_max_trim_extend_left(
let max_extend_beats = document.find_max_trim_extend_left(
layer_id, layer_id,
instance_id, instance_id,
instance.timeline_start, instance.timeline_start,
); ).seconds_to_f64();
// Audio plays 1 content-second per timeline-second, so the
// revealable content equals that gap's wall-clock span.
let tempo_map = document.tempo_map();
let old_timeline_secs = tempo_map.beats_to_seconds(old_timeline);
let max_extend_secs = (old_timeline_secs
- tempo_map.beats_to_seconds(old_timeline - max_extend_beats))
.seconds_to_f64();
// Calculate how much we want to extend (content seconds) // Calculate how much we want to extend (content seconds)
let desired_extend = old_trim - new_trim; let desired_extend = old_trim - new_trim;
@ -226,8 +218,9 @@ impl Action for TrimClipInstancesAction {
let actual_extend = desired_extend.min(max_extend_secs); let actual_extend = desired_extend.min(max_extend_secs);
let clamped_trim_start = old_trim - actual_extend; let clamped_trim_start = old_trim - actual_extend;
// Move the timeline left by the same wall-clock seconds. // Move the timeline left by the same wall-clock seconds.
let tempo_map = document.tempo_map();
let clamped_timeline_start = tempo_map let clamped_timeline_start = tempo_map
.seconds_to_beats(old_timeline_secs - Seconds(actual_extend)) .seconds_to_beats(tempo_map.beats_to_seconds(old_timeline) - Seconds(actual_extend))
.max(Beats::ZERO); .max(Beats::ZERO);
clamped_new = TrimData::left(clamped_trim_start, clamped_timeline_start); clamped_new = TrimData::left(clamped_trim_start, clamped_timeline_start);
@ -248,19 +241,13 @@ impl Action for TrimClipInstancesAction {
tempo_map.beats_to_seconds(instance.timeline_start) + content_secs, tempo_map.beats_to_seconds(instance.timeline_start) + content_secs,
) - instance.timeline_start; ) - instance.timeline_start;
// Max we can extend right is the timeline gap to the next // Max rightward extension as content seconds (the gap's wall-clock span).
// clip (beats). let max_extend_secs = document.find_max_trim_extend_right(
let max_extend_beats = document.find_max_trim_extend_right(
layer_id, layer_id,
instance_id, instance_id,
instance.timeline_start, instance.timeline_start,
current_effective_duration, current_effective_duration,
); ).seconds_to_f64();
// Convert that gap to content seconds at the clip's right edge.
let right_edge = instance.timeline_start + current_effective_duration;
let max_extend_secs = (tempo_map.beats_to_seconds(right_edge + max_extend_beats)
- tempo_map.beats_to_seconds(right_edge))
.seconds_to_f64();
// Calculate how much we want to extend (content seconds) // Calculate how much we want to extend (content seconds)
let desired_extend = new_trim_end - old_trim_end; let desired_extend = new_trim_end - old_trim_end;

View File

@ -1186,19 +1186,21 @@ impl Document {
/// ///
/// Returns the distance to the nearest clip to the left, or the distance to /// Returns the distance to the nearest clip to the left, or the distance to
/// timeline start (0.0) if no clips exist to the left. /// timeline start (0.0) if no clips exist to the left.
/// Returns the max leftward trim extension as a content-seconds span (the wall-clock
/// length of the timeline gap to the previous clip); the trim domain is seconds.
pub fn find_max_trim_extend_left( pub fn find_max_trim_extend_left(
&self, &self,
layer_id: &Uuid, layer_id: &Uuid,
instance_id: &Uuid, instance_id: &Uuid,
current_timeline_start: Beats, current_timeline_start: Beats,
) -> Beats { ) -> Seconds {
let Some(layer) = self.get_layer(layer_id) else { let Some(layer) = self.get_layer(layer_id) else {
return current_timeline_start; // No limit if layer not found return self.tempo_map().beats_to_seconds(current_timeline_start); // No limit if layer not found
}; };
// Only check audio, video, and effect layers // Only check audio, video, and effect layers
if matches!(layer, AnyLayer::Vector(_) | AnyLayer::Group(_)) { if matches!(layer, AnyLayer::Vector(_) | AnyLayer::Group(_)) {
return current_timeline_start; // No limit for vector/group layers return self.tempo_map().beats_to_seconds(current_timeline_start); // No limit for vector/group layers
}; };
// Find the nearest clip to the left // Find the nearest clip to the left
@ -1231,27 +1233,27 @@ impl Document {
} }
} }
current_timeline_start - nearest_end self.tempo_map().beats_to_seconds(current_timeline_start) - self.tempo_map().beats_to_seconds(nearest_end)
} }
/// Find the maximum amount we can extend a clip to the right without overlapping /// Find the maximum amount we can extend a clip to the right without overlapping.
/// ///
/// Returns the distance to the nearest clip to the right, or f64::MAX if no /// Returns the content-seconds span of the timeline gap to the nearest clip on the
/// clips exist to the right. /// right, or Seconds(f64::MAX) if none. `current_effective_duration` is beats (timeline).
pub fn find_max_trim_extend_right( pub fn find_max_trim_extend_right(
&self, &self,
layer_id: &Uuid, layer_id: &Uuid,
instance_id: &Uuid, instance_id: &Uuid,
current_timeline_start: Beats, current_timeline_start: Beats,
current_effective_duration: Beats, current_effective_duration: Beats,
) -> Beats { ) -> Seconds {
let Some(layer) = self.get_layer(layer_id) else { let Some(layer) = self.get_layer(layer_id) else {
return Beats(f64::MAX); // No limit if layer not found return Seconds(f64::MAX); // No limit if layer not found
}; };
// Only check audio, video, and effect layers // Only check audio, video, and effect layers
if matches!(layer, AnyLayer::Vector(_) | AnyLayer::Group(_)) { if matches!(layer, AnyLayer::Vector(_) | AnyLayer::Group(_)) {
return Beats(f64::MAX); // No limit for vector/group layers return Seconds(f64::MAX); // No limit for vector/group layers
} }
let instances: &[ClipInstance] = match layer { let instances: &[ClipInstance] = match layer {
@ -1280,9 +1282,10 @@ impl Document {
} }
if nearest_start == Beats(f64::MAX) { if nearest_start == Beats(f64::MAX) {
Beats(f64::MAX) // No clip to the right, can extend freely Seconds(f64::MAX) // No clip to the right, can extend freely
} else { } else {
(nearest_start - current_end).max(Beats::ZERO) // Gap between our end and next clip's start // Gap between our end and next clip's start, as content seconds.
(self.tempo_map().beats_to_seconds(nearest_start) - self.tempo_map().beats_to_seconds(current_end)).max(Seconds::ZERO)
} }
} }
/// Find the maximum amount we can extend loop_before to the left without overlapping. /// Find the maximum amount we can extend loop_before to the left without overlapping.

View File

@ -1062,10 +1062,13 @@ fn composite_document_to_hdr(
let success = gpu_resources.effect_processor.compile_effect(device, effect_def); let success = gpu_resources.effect_processor.compile_effect(device, effect_def);
if !success { eprintln!("Failed to compile effect: {}", effect_def.name); continue; } if !success { eprintln!("Failed to compile effect: {}", effect_def.name); continue; }
} }
let tempo_map = document.tempo_map();
let effect_end_beats = effect_instance.timeline_start
+ effect_instance.effective_duration(daw_backend::Seconds(lightningbeam_core::effect::EFFECT_DURATION), tempo_map);
let effect_inst = lightningbeam_core::effect::EffectInstance::new( let effect_inst = lightningbeam_core::effect::EffectInstance::new(
effect_def, effect_def,
effect_instance.timeline_start, tempo_map.beats_to_seconds(effect_instance.timeline_start).seconds_to_f64(),
effect_instance.timeline_start + effect_instance.effective_duration(lightningbeam_core::effect::EFFECT_DURATION, document.tempo_map()), tempo_map.beats_to_seconds(effect_end_beats).seconds_to_f64(),
); );
let effect_output_handle = gpu_resources.buffer_pool.acquire(device, hdr_spec); let effect_output_handle = gpu_resources.buffer_pool.acquire(device, hdr_spec);
if let Some(effect_output_view) = gpu_resources.buffer_pool.get_view(effect_output_handle) { if let Some(effect_output_view) = gpu_resources.buffer_pool.get_view(effect_output_handle) {

View File

@ -1,6 +1,7 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use eframe::egui; use eframe::egui;
use daw_backend::{Beats, Seconds};
use lightningbeam_core::layer::{AnyLayer, AudioLayer}; use lightningbeam_core::layer::{AnyLayer, AudioLayer};
use lightningbeam_core::layout::{LayoutDefinition, LayoutNode}; use lightningbeam_core::layout::{LayoutDefinition, LayoutNode};
use lightningbeam_core::pane::PaneType; use lightningbeam_core::pane::PaneType;
@ -2352,7 +2353,8 @@ impl EditorApp {
use lightningbeam_core::instance_group::InstanceGroup; use lightningbeam_core::instance_group::InstanceGroup;
use std::collections::HashSet; use std::collections::HashSet;
let split_time = self.playback_time; // Split position as a beats timeline position (playback_time is seconds).
let split_time = self.action_executor.document().tempo_map().seconds_to_beats(Seconds(self.playback_time));
let active_layer_id = match self.active_layer_id { let active_layer_id = match self.active_layer_id {
Some(id) => id, Some(id) => id,
None => return, // No active layer, nothing to split None => return, // No active layer, nothing to split
@ -2363,7 +2365,7 @@ impl EditorApp {
// Helper to find clips that span the playhead in a specific layer // Helper to find clips that span the playhead in a specific layer
fn find_splittable_clips( fn find_splittable_clips(
clip_instances: &[lightningbeam_core::clip::ClipInstance], clip_instances: &[lightningbeam_core::clip::ClipInstance],
split_time: f64, split_time: Beats,
document: &lightningbeam_core::document::Document, document: &lightningbeam_core::document::Document,
) -> Vec<uuid::Uuid> { ) -> Vec<uuid::Uuid> {
let mut result = Vec::new(); let mut result = Vec::new();
@ -2372,9 +2374,9 @@ impl EditorApp {
let effective_duration = instance.effective_duration(clip_duration, document.tempo_map()); let effective_duration = instance.effective_duration(clip_duration, document.tempo_map());
let timeline_end = instance.timeline_start + effective_duration; let timeline_end = instance.timeline_start + effective_duration;
const EPSILON: f64 = 0.001; let epsilon = Beats(0.001);
if split_time > instance.timeline_start + EPSILON if split_time > instance.timeline_start + epsilon
&& split_time < timeline_end - EPSILON && split_time < timeline_end - epsilon
{ {
result.push(instance.id); result.push(instance.id);
} }
@ -3051,10 +3053,11 @@ impl EditorApp {
let min_start = instances let min_start = instances
.iter() .iter()
.map(|i| i.timeline_start) .map(|i| i.timeline_start)
.fold(f64::INFINITY, f64::min); .fold(Beats(f64::INFINITY), |a, b| a.min(b));
let offset = self.playback_time - min_start; let playhead_beats = self.action_executor.document().tempo_map().seconds_to_beats(Seconds(self.playback_time));
let offset = playhead_beats - min_start;
for inst in &mut instances { for inst in &mut instances {
inst.timeline_start = (inst.timeline_start + offset).max(0.0); inst.timeline_start = (inst.timeline_start + offset).max(Beats::ZERO);
} }
} }
@ -3256,7 +3259,7 @@ impl EditorApp {
let duplicates: Vec<lightningbeam_core::clip::ClipInstance> = clips_to_duplicate.iter().map(|original| { let duplicates: Vec<lightningbeam_core::clip::ClipInstance> = clips_to_duplicate.iter().map(|original| {
let mut duplicate = original.clone(); let mut duplicate = original.clone();
duplicate.id = uuid::Uuid::new_v4(); duplicate.id = uuid::Uuid::new_v4();
let clip_duration = document.get_clip_duration(&original.clip_id).unwrap_or(1.0); let clip_duration = document.get_clip_duration(&original.clip_id).unwrap_or(Seconds(1.0));
let effective_duration = original.effective_duration(clip_duration, document.tempo_map()); let effective_duration = original.effective_duration(clip_duration, document.tempo_map());
duplicate.timeline_start = original.timeline_start + effective_duration; duplicate.timeline_start = original.timeline_start + effective_duration;
if let Some((new_clip_def_id, _)) = midi_clip_replacements.get(&original.clip_id) { if let Some((new_clip_def_id, _)) = midi_clip_replacements.get(&original.clip_id) {
@ -5553,6 +5556,7 @@ impl EditorApp {
use lightningbeam_core::layer::*; use lightningbeam_core::layer::*;
let drop_time = self.playback_time; let drop_time = self.playback_time;
let drop_beats = self.action_executor.document().tempo_map().seconds_to_beats(Seconds(drop_time));
// Find or create a compatible layer // Find or create a compatible layer
let document = self.action_executor.document(); let document = self.action_executor.document();
@ -5645,7 +5649,7 @@ impl EditorApp {
} else { } else {
// For clips, create a clip instance // For clips, create a clip instance
let mut clip_instance = ClipInstance::new(asset_info.clip_id) let mut clip_instance = ClipInstance::new(asset_info.clip_id)
.with_timeline_start(drop_time); .with_timeline_start(drop_beats);
// For video clips, scale to fit and center in document // For video clips, scale to fit and center in document
if asset_info.clip_type == panes::DragClipType::Video { if asset_info.clip_type == panes::DragClipType::Video {
@ -5706,7 +5710,7 @@ impl EditorApp {
// Create audio clip instance at same timeline position // Create audio clip instance at same timeline position
let audio_instance = ClipInstance::new(linked_audio_clip_id) let audio_instance = ClipInstance::new(linked_audio_clip_id)
.with_timeline_start(drop_time); .with_timeline_start(drop_beats);
let audio_instance_id = audio_instance.id; let audio_instance_id = audio_instance.id;
// Execute audio action with backend sync // Execute audio action with backend sync
@ -5748,7 +5752,7 @@ impl EditorApp {
// Find the video clip instance in the document // Find the video clip instance in the document
let document = self.action_executor.document(); let document = self.action_executor.document();
let mut video_instance_info: Option<(uuid::Uuid, f64, bool)> = None; // (layer_id, timeline_start, already_in_group) let mut video_instance_info: Option<(uuid::Uuid, Beats, bool)> = None; // (layer_id, timeline_start [beats], already_in_group)
// Search root layers for a video clip instance with matching clip_id // Search root layers for a video clip instance with matching clip_id
for layer in &document.root.children { for layer in &document.root.children {
@ -6418,9 +6422,10 @@ impl eframe::App for EditorApp {
let clip = AudioClip::new_recording("Recording..."); let clip = AudioClip::new_recording("Recording...");
let doc_clip_id = self.action_executor.document_mut().add_audio_clip(clip); let doc_clip_id = self.action_executor.document_mut().add_audio_clip(clip);
// Create clip instance on the layer // Create clip instance on the layer (recording_start_time is seconds)
let rec_start_beats = self.action_executor.document().tempo_map().seconds_to_beats(Seconds(self.recording_start_time));
let clip_instance = ClipInstance::new(doc_clip_id) let clip_instance = ClipInstance::new(doc_clip_id)
.with_timeline_start(self.recording_start_time); .with_timeline_start(rec_start_beats);
let clip_instance_id = clip_instance.id; let clip_instance_id = clip_instance.id;
@ -6537,7 +6542,7 @@ impl eframe::App for EditorApp {
None None
} }
}) })
.unwrap_or((uuid::Uuid::nil(), uuid::Uuid::nil(), 0.0, 0.0)) .unwrap_or((uuid::Uuid::nil(), uuid::Uuid::nil(), Beats::ZERO, 0.0))
}; };
if !clip_id.is_nil() { if !clip_id.is_nil() {
@ -7494,9 +7499,13 @@ impl eframe::App for EditorApp {
let duration = clip.duration; let duration = clip.duration;
self.action_executor.document_mut().video_clips.insert(clip_id, clip); self.action_executor.document_mut().video_clips.insert(clip_id, clip);
// recording_start_time and duration are seconds; convert to beats.
let tempo_map = self.action_executor.document().tempo_map();
let rec_start_beats = tempo_map.seconds_to_beats(Seconds(self.recording_start_time));
let dur_beats = tempo_map.seconds_to_beats(tempo_map.beats_to_seconds(rec_start_beats) + Seconds(duration)) - rec_start_beats;
let mut clip_instance = ClipInstance::new(clip_id) let mut clip_instance = ClipInstance::new(clip_id)
.with_timeline_start(self.recording_start_time) .with_timeline_start(rec_start_beats)
.with_timeline_duration(duration); .with_timeline_duration(dur_beats);
// Scale to fit document and center (like drag-dropped videos) // Scale to fit document and center (like drag-dropped videos)
{ {

View File

@ -1609,15 +1609,17 @@ impl InfopanelPane {
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.label("Start:"); ui.label("Start:");
ui.label(format!("{:.2}s", ci.effective_start())); ui.label(format!("{:.2}s", document.tempo_map().beats_to_seconds(ci.effective_start()).seconds_to_f64()));
}); });
let clip_dur = document.get_clip_duration(&ci.clip_id) let clip_dur = document.get_clip_duration(&ci.clip_id)
.unwrap_or_else(|| ci.trim_end.unwrap_or(1.0) - ci.trim_start); .unwrap_or_else(|| daw_backend::Seconds(ci.trim_end.unwrap_or(1.0) - ci.trim_start));
let total_dur = ci.total_duration(clip_dur, document.tempo_map()); let total_dur = ci.total_duration(clip_dur, document.tempo_map());
let total_dur_secs = (document.tempo_map().beats_to_seconds(ci.effective_start() + total_dur)
- document.tempo_map().beats_to_seconds(ci.effective_start())).seconds_to_f64();
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.label("Duration:"); ui.label("Duration:");
ui.label(format!("{:.2}s", total_dur)); ui.label(format!("{:.2}s", total_dur_secs));
}); });
if ci.trim_start > 0.0 { if ci.trim_start > 0.0 {

View File

@ -5,6 +5,7 @@
/// When a sampled audio layer is selected, shows a GPU-rendered spectrogram. /// When a sampled audio layer is selected, shows a GPU-rendered spectrogram.
use eframe::egui; use eframe::egui;
use daw_backend::Seconds;
use egui::{pos2, vec2, Align2, Color32, FontId, Rect, Stroke, StrokeKind}; use egui::{pos2, vec2, Align2, Color32, FontId, Rect, Stroke, StrokeKind};
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use uuid::Uuid; use uuid::Uuid;
@ -465,8 +466,8 @@ impl PianoRollPane {
for instance in &audio_layer.clip_instances { for instance in &audio_layer.clip_instances {
if let Some(clip) = document.audio_clips.get(&instance.clip_id) { if let Some(clip) = document.audio_clips.get(&instance.clip_id) {
if let AudioClipType::Midi { midi_clip_id } = clip.clip_type { if let AudioClipType::Midi { midi_clip_id } = clip.clip_type {
let duration = instance.effective_duration(clip.duration, document.tempo_map()); let duration = instance.effective_duration(Seconds(clip.duration), document.tempo_map());
clip_data.push((midi_clip_id, instance.timeline_start, instance.trim_start, duration, instance.id)); clip_data.push((midi_clip_id, instance.timeline_start.beats_to_f64(), instance.trim_start, duration.beats_to_f64(), instance.id));
} }
} }
} }
@ -2454,10 +2455,15 @@ impl PianoRollPane {
for instance in &audio_layer.clip_instances { for instance in &audio_layer.clip_instances {
if let Some(clip) = document.audio_clips.get(&instance.clip_id) { if let Some(clip) = document.audio_clips.get(&instance.clip_id) {
if let AudioClipType::Sampled { audio_pool_index } = clip.clip_type { if let AudioClipType::Sampled { audio_pool_index } = clip.clip_type {
let duration = instance.timeline_duration.unwrap_or(clip.duration); // Duration in beats: explicit timeline_duration, else the clip's content
// length converted to beats at the clip's start.
let duration = instance.timeline_duration.unwrap_or_else(|| {
let tmap = document.tempo_map();
tmap.seconds_to_beats(tmap.beats_to_seconds(instance.timeline_start) + Seconds(clip.duration)) - instance.timeline_start
});
// Get sample rate from raw_audio_cache // Get sample rate from raw_audio_cache
if let Some((_samples, sr, _ch)) = shared.raw_audio_cache.get(&audio_pool_index) { if let Some((_samples, sr, _ch)) = shared.raw_audio_cache.get(&audio_pool_index) {
clip_infos.push((audio_pool_index, instance.timeline_start, instance.trim_start, duration, *sr)); clip_infos.push((audio_pool_index, instance.timeline_start.beats_to_f64(), instance.trim_start, duration.beats_to_f64(), *sr));
} }
} }
} }

View File

@ -4,6 +4,7 @@
/// Supports HDR compositing pipeline with per-layer buffers and effects. /// Supports HDR compositing pipeline with per-layer buffers and effects.
use eframe::egui; use eframe::egui;
use daw_backend::Seconds;
use lightningbeam_core::action::Action; use lightningbeam_core::action::Action;
use lightningbeam_core::clip::ClipInstance; use lightningbeam_core::clip::ClipInstance;
use lightningbeam_core::gpu::{BufferPool, BufferFormat, BufferSpec, Compositor, EffectProcessor, SrgbToLinearConverter}; use lightningbeam_core::gpu::{BufferPool, BufferFormat, BufferSpec, Compositor, EffectProcessor, SrgbToLinearConverter};
@ -1851,10 +1852,13 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
// Create EffectInstance from ClipInstance for the processor // Create EffectInstance from ClipInstance for the processor
// For now, create a simple effect instance with default parameters // For now, create a simple effect instance with default parameters
let tempo_map = self.ctx.document.tempo_map();
let effect_end_beats = effect_instance.timeline_start
+ effect_instance.effective_duration(Seconds(lightningbeam_core::effect::EFFECT_DURATION), tempo_map);
let effect_inst = lightningbeam_core::effect::EffectInstance::new( let effect_inst = lightningbeam_core::effect::EffectInstance::new(
effect_def, effect_def,
effect_instance.timeline_start, tempo_map.beats_to_seconds(effect_instance.timeline_start).seconds_to_f64(),
effect_instance.timeline_start + effect_instance.effective_duration(lightningbeam_core::effect::EFFECT_DURATION, self.ctx.document.tempo_map()), tempo_map.beats_to_seconds(effect_end_beats).seconds_to_f64(),
); );
// Acquire temp buffer for effect output (HDR format) // Acquire temp buffer for effect output (HDR format)
@ -2204,7 +2208,8 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
let combined_transform = overlay_transform * clip_transform; let combined_transform = overlay_transform * clip_transform;
// Calculate clip bounds for preview // Calculate clip bounds for preview
let clip_time = ((self.ctx.playback_time - clip_inst.timeline_start) * clip_inst.playback_speed) + clip_inst.trim_start; let start_secs = self.ctx.document.tempo_map().beats_to_seconds(clip_inst.timeline_start).seconds_to_f64();
let clip_time = ((self.ctx.playback_time - start_secs) * clip_inst.playback_speed) + clip_inst.trim_start;
let content_bounds = if let Some(vector_clip) = self.ctx.document.get_vector_clip(&clip_inst.clip_id) { let content_bounds = if let Some(vector_clip) = self.ctx.document.get_vector_clip(&clip_inst.clip_id) {
vector_clip.calculate_content_bounds(&self.ctx.document, clip_time) vector_clip.calculate_content_bounds(&self.ctx.document, clip_time)
} else if let Some(video_clip) = self.ctx.document.get_video_clip(&clip_inst.clip_id) { } else if let Some(video_clip) = self.ctx.document.get_video_clip(&clip_inst.clip_id) {
@ -2293,15 +2298,19 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
// Also draw selection outlines for clip instances // Also draw selection outlines for clip instances
for &clip_id in self.ctx.selection.clip_instances() { for &clip_id in self.ctx.selection.clip_instances() {
if let Some(clip_instance) = vector_layer.clip_instances.iter().find(|ci| ci.id == clip_id) { if let Some(clip_instance) = vector_layer.clip_instances.iter().find(|ci| ci.id == clip_id) {
// Skip clip instances not active at current time // Skip clip instances not active at current time (compare in seconds).
let clip_dur = self.ctx.document.get_clip_duration(&clip_instance.clip_id).unwrap_or(0.0); let clip_dur = self.ctx.document.get_clip_duration(&clip_instance.clip_id).unwrap_or(Seconds::ZERO);
let instance_end = clip_instance.timeline_start + clip_instance.effective_duration(clip_dur, self.ctx.document.tempo_map()); let tempo_map = self.ctx.document.tempo_map();
if self.ctx.playback_time < clip_instance.timeline_start || self.ctx.playback_time >= instance_end { let start_secs = tempo_map.beats_to_seconds(clip_instance.timeline_start).seconds_to_f64();
let instance_end = tempo_map.beats_to_seconds(
clip_instance.timeline_start + clip_instance.effective_duration(clip_dur, tempo_map)
).seconds_to_f64();
if self.ctx.playback_time < start_secs || self.ctx.playback_time >= instance_end {
continue; continue;
} }
// Calculate clip-local time // Calculate clip-local time
let clip_time = ((self.ctx.playback_time - clip_instance.timeline_start) * clip_instance.playback_speed) + clip_instance.trim_start; let clip_time = ((self.ctx.playback_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start;
// Get dynamic clip bounds from content at current time // Get dynamic clip bounds from content at current time
let bbox = if let Some(vector_clip) = self.ctx.document.get_vector_clip(&clip_instance.clip_id) { let bbox = if let Some(vector_clip) = self.ctx.document.get_vector_clip(&clip_instance.clip_id) {
@ -2671,9 +2680,11 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
// Find clip instance visible at playback time // Find clip instance visible at playback time
let visible_clip = video_layer.clip_instances.iter().find(|inst| { let visible_clip = video_layer.clip_instances.iter().find(|inst| {
let clip_duration = self.ctx.document.get_clip_duration(&inst.clip_id).unwrap_or(0.0); let clip_duration = self.ctx.document.get_clip_duration(&inst.clip_id).unwrap_or(Seconds::ZERO);
let effective_duration = inst.effective_duration(clip_duration, self.ctx.document.tempo_map()); let tempo_map = self.ctx.document.tempo_map();
playback_time >= inst.timeline_start && playback_time < inst.timeline_start + effective_duration let start_secs = tempo_map.beats_to_seconds(inst.timeline_start).seconds_to_f64();
let end_secs = tempo_map.beats_to_seconds(inst.timeline_start + inst.effective_duration(clip_duration, tempo_map)).seconds_to_f64();
playback_time >= start_secs && playback_time < end_secs
}); });
if let Some(clip_inst) = visible_clip { if let Some(clip_inst) = visible_clip {
@ -10130,7 +10141,8 @@ impl StagePane {
for &clip_id in shared.selection.clip_instances() { for &clip_id in shared.selection.clip_instances() {
if let Some(clip_instance) = vector_layer.clip_instances.iter().find(|ci| ci.id == clip_id) { if let Some(clip_instance) = vector_layer.clip_instances.iter().find(|ci| ci.id == clip_id) {
// Calculate clip-local time // Calculate clip-local time
let clip_time = ((*shared.playback_time - clip_instance.timeline_start) * clip_instance.playback_speed) + clip_instance.trim_start; let start_secs = shared.action_executor.document().tempo_map().beats_to_seconds(clip_instance.timeline_start).seconds_to_f64();
let clip_time = ((*shared.playback_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start;
// Get dynamic clip bounds from content at current time // Get dynamic clip bounds from content at current time
use vello::kurbo::Rect as KurboRect; use vello::kurbo::Rect as KurboRect;
@ -10330,7 +10342,8 @@ impl StagePane {
// Try clip instance // Try clip instance
if let Some(clip_instance) = vector_layer.clip_instances.iter().find(|ci| ci.id == object_id) { if let Some(clip_instance) = vector_layer.clip_instances.iter().find(|ci| ci.id == object_id) {
// Calculate clip-local time // Calculate clip-local time
let clip_time = ((*shared.playback_time - clip_instance.timeline_start) * clip_instance.playback_speed) + clip_instance.trim_start; let start_secs = shared.action_executor.document().tempo_map().beats_to_seconds(clip_instance.timeline_start).seconds_to_f64();
let clip_time = ((*shared.playback_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start;
// Get dynamic clip bounds from content at current time // Get dynamic clip bounds from content at current time
let local_bbox = if let Some(vector_clip) = shared.action_executor.document().get_vector_clip(&clip_instance.clip_id) { let local_bbox = if let Some(vector_clip) = shared.action_executor.document().get_vector_clip(&clip_instance.clip_id) {
@ -11046,9 +11059,11 @@ impl StagePane {
let document = shared.action_executor.document(); let document = shared.action_executor.document();
if let Some(AnyLayer::Video(video_layer)) = document.get_layer(layer_id) { if let Some(AnyLayer::Video(video_layer)) = document.get_layer(layer_id) {
video_layer.clip_instances.iter().find(|inst| { video_layer.clip_instances.iter().find(|inst| {
let clip_duration = document.get_clip_duration(&inst.clip_id).unwrap_or(0.0); let clip_duration = document.get_clip_duration(&inst.clip_id).unwrap_or(Seconds::ZERO);
let effective_duration = inst.effective_duration(clip_duration, document.tempo_map()); let tempo_map = document.tempo_map();
playback_time >= inst.timeline_start && playback_time < inst.timeline_start + effective_duration let start_secs = tempo_map.beats_to_seconds(inst.timeline_start).seconds_to_f64();
let end_secs = tempo_map.beats_to_seconds(inst.timeline_start + inst.effective_duration(clip_duration, tempo_map)).seconds_to_f64();
playback_time >= start_secs && playback_time < end_secs
}).map(|inst| inst.id) }).map(|inst| inst.id)
} else { } else {
None None
@ -12487,8 +12502,14 @@ impl PaneRenderer for StagePane {
let canvas_pos = pointer_pos - rect.min; let canvas_pos = pointer_pos - rect.min;
let world_pos = (canvas_pos - self.pan_offset) / self.zoom; let world_pos = (canvas_pos - self.pan_offset) / self.zoom;
// Use playhead time // Use playhead time (seconds); the beats placement position for clips.
let drop_time = *shared.playback_time; let drop_time = *shared.playback_time;
let drop_beats = shared.action_executor.document().tempo_map().seconds_to_beats(Seconds(drop_time));
// 5-second default effect duration as a beats span at the drop point.
let effect_dur_beats = {
let tmap = shared.action_executor.document().tempo_map();
tmap.seconds_to_beats(tmap.beats_to_seconds(drop_beats) + Seconds(5.0)) - drop_beats
};
// Find or create a compatible layer // Find or create a compatible layer
let document = shared.action_executor.document(); let document = shared.action_executor.document();
@ -12559,8 +12580,8 @@ impl PaneRenderer for StagePane {
// Create clip instance for effect with 5 second default duration // Create clip instance for effect with 5 second default duration
let clip_instance = ClipInstance::new(def.id) let clip_instance = ClipInstance::new(def.id)
.with_timeline_start(drop_time) .with_timeline_start(drop_beats)
.with_timeline_duration(5.0); .with_timeline_duration(effect_dur_beats);
// Use AddEffectAction for effect layers // Use AddEffectAction for effect layers
let action = lightningbeam_core::actions::AddEffectAction::new( let action = lightningbeam_core::actions::AddEffectAction::new(
@ -12572,7 +12593,7 @@ impl PaneRenderer for StagePane {
} else { } else {
// For clips, create a clip instance // For clips, create a clip instance
let mut clip_instance = ClipInstance::new(dragging.clip_id) let mut clip_instance = ClipInstance::new(dragging.clip_id)
.with_timeline_start(drop_time); .with_timeline_start(drop_beats);
// For video clips, scale to fit and center in document // For video clips, scale to fit and center in document
if dragging.clip_type == DragClipType::Video { if dragging.clip_type == DragClipType::Video {
@ -12642,7 +12663,7 @@ impl PaneRenderer for StagePane {
// Create audio clip instance at same timeline position // Create audio clip instance at same timeline position
let audio_instance = ClipInstance::new(linked_audio_clip_id) let audio_instance = ClipInstance::new(linked_audio_clip_id)
.with_timeline_start(drop_time); .with_timeline_start(drop_beats);
let audio_instance_id = audio_instance.id; let audio_instance_id = audio_instance.id;
eprintln!("DEBUG STAGE: Created audio instance: {} for clip: {}", audio_instance_id, linked_audio_clip_id); eprintln!("DEBUG STAGE: Created audio instance: {} for clip: {}", audio_instance_id, linked_audio_clip_id);

View File

@ -7,6 +7,7 @@
/// - Basic layer visualization /// - Basic layer visualization
use eframe::egui; use eframe::egui;
use daw_backend::{Beats, Seconds};
use lightningbeam_core::clip::{ use lightningbeam_core::clip::{
ClipInstance, audio_backend_uuid, midi_backend_uuid, ClipInstance, audio_backend_uuid, midi_backend_uuid,
}; };
@ -77,11 +78,12 @@ fn compute_clip_stacking(
} }
let tempo_map = document.tempo_map(); let tempo_map = document.tempo_map();
// Stacking only needs relative overlap, so compare in the beats domain.
let ranges: Vec<(f64, f64)> = clip_instances.iter().map(|ci| { let ranges: Vec<(f64, f64)> = clip_instances.iter().map(|ci| {
let clip_dur = effective_clip_duration(document, layer, ci).unwrap_or(0.0); let clip_dur = effective_clip_duration(document, layer, ci).unwrap_or(Seconds::ZERO);
let start = ci.effective_start(); let start = ci.effective_start();
let end = start + ci.total_duration(clip_dur, tempo_map); let end = start + ci.total_duration(clip_dur, tempo_map);
(start, end) (start.beats_to_f64(), end.beats_to_f64())
}).collect(); }).collect();
compute_clip_stacking_from_ranges(&ranges) compute_clip_stacking_from_ranges(&ranges)
@ -196,22 +198,23 @@ fn effective_clip_duration(
document: &lightningbeam_core::document::Document, document: &lightningbeam_core::document::Document,
layer: &AnyLayer, layer: &AnyLayer,
clip_instance: &ClipInstance, clip_instance: &ClipInstance,
) -> Option<f64> { ) -> Option<Seconds> {
match layer { match layer {
AnyLayer::Vector(vl) => { AnyLayer::Vector(vl) => {
let vc = document.get_vector_clip(&clip_instance.clip_id)?; let vc = document.get_vector_clip(&clip_instance.clip_id)?;
if vc.is_group { if vc.is_group {
let frame_duration = 1.0 / document.framerate; let frame_duration = 1.0 / document.framerate;
let end = vl.group_visibility_end(&clip_instance.id, clip_instance.timeline_start, frame_duration); let start_secs = document.tempo_map().beats_to_seconds(clip_instance.timeline_start).seconds_to_f64();
Some((end - clip_instance.timeline_start).max(0.0)) let end = vl.group_visibility_end(&clip_instance.id, start_secs, frame_duration);
Some(Seconds((end - start_secs).max(0.0)))
} else { } else {
// Movie clips: duration based on all internal content (keyframes + clip instances) // Movie clips: duration based on all internal content (keyframes + clip instances)
document.get_clip_duration(&clip_instance.clip_id) document.get_clip_duration(&clip_instance.clip_id)
} }
} }
AnyLayer::Audio(_) => document.get_audio_clip(&clip_instance.clip_id).map(|c| c.duration), AnyLayer::Audio(_) => document.get_audio_clip(&clip_instance.clip_id).map(|c| Seconds(c.duration)),
AnyLayer::Video(_) => document.get_video_clip(&clip_instance.clip_id).map(|c| c.duration), AnyLayer::Video(_) => document.get_video_clip(&clip_instance.clip_id).map(|c| Seconds(c.duration)),
AnyLayer::Effect(_) => Some(lightningbeam_core::effect::EFFECT_DURATION), AnyLayer::Effect(_) => Some(Seconds(lightningbeam_core::effect::EFFECT_DURATION)),
AnyLayer::Group(_) => None, AnyLayer::Group(_) => None,
AnyLayer::Raster(_) => None, AnyLayer::Raster(_) => None,
AnyLayer::Text(_) => None, AnyLayer::Text(_) => None,
@ -626,14 +629,14 @@ fn build_audio_clip_cache(
.unwrap_or_else(|| audio_backend_uuid(ac.id)); .unwrap_or_else(|| audio_backend_uuid(ac.id));
let mut ci = ClipInstance::new(clip_id); let mut ci = ClipInstance::new(clip_id);
ci.id = instance_id; ci.id = instance_id;
ci.timeline_start = ac.external_start.beats_to_f64(); ci.timeline_start = ac.external_start;
ci.trim_start = ac.internal_start.seconds_to_f64(); ci.trim_start = ac.internal_start.seconds_to_f64();
ci.trim_end = Some(ac.internal_end.seconds_to_f64()); ci.trim_end = Some(ac.internal_end.seconds_to_f64());
let internal_dur_secs = (ac.internal_end - ac.internal_start).seconds_to_f64(); let internal_dur_secs = (ac.internal_end - ac.internal_start).seconds_to_f64();
let tempo_map = document.tempo_map(); let tempo_map = document.tempo_map();
let external_dur_secs = tempo_map.transform(ac.external_duration.beats_to_f64()); let external_dur_secs = tempo_map.transform(ac.external_duration.beats_to_f64());
if (external_dur_secs - internal_dur_secs).abs() > 1e-9 { if (external_dur_secs - internal_dur_secs).abs() > 1e-9 {
ci.timeline_duration = Some(ac.external_duration.beats_to_f64()); ci.timeline_duration = Some(ac.external_duration);
} }
ci.gain = ac.gain; ci.gain = ac.gain;
instances.push(ci); instances.push(ci);
@ -650,12 +653,12 @@ fn build_audio_clip_cache(
.unwrap_or_else(|| midi_backend_uuid(mc.id)); .unwrap_or_else(|| midi_backend_uuid(mc.id));
let mut ci = ClipInstance::new(clip_id); let mut ci = ClipInstance::new(clip_id);
ci.id = instance_id; ci.id = instance_id;
ci.timeline_start = mc.external_start.beats_to_f64(); ci.timeline_start = mc.external_start;
ci.trim_start = mc.internal_start.beats_to_f64(); ci.trim_start = mc.internal_start.beats_to_f64();
ci.trim_end = Some(mc.internal_end.beats_to_f64()); ci.trim_end = Some(mc.internal_end.beats_to_f64());
// Always set timeline_duration for MIDI clips: duration is in beats, so we // Always set timeline_duration for MIDI clips: duration is in beats, so we
// must bypass the content_window_secs * bpm/60 formula (which expects seconds). // must bypass the content_window_secs * bpm/60 formula (which expects seconds).
ci.timeline_duration = Some(mc.external_duration.beats_to_f64()); ci.timeline_duration = Some(mc.external_duration);
instances.push(ci); instances.push(ci);
} }
} }
@ -734,12 +737,13 @@ fn collect_clip_instances<'a>(
fn find_sampled_audio_track_for_clip( fn find_sampled_audio_track_for_clip(
document: &lightningbeam_core::document::Document, document: &lightningbeam_core::document::Document,
clip_id: uuid::Uuid, clip_id: uuid::Uuid,
timeline_start: f64, timeline_start: Beats,
editing_clip_id: Option<&uuid::Uuid>, editing_clip_id: Option<&uuid::Uuid>,
) -> Option<uuid::Uuid> { ) -> Option<uuid::Uuid> {
// Get the clip duration // Get the clip duration (content seconds) and convert its span to beats at the drop point.
let clip_duration = document.get_clip_duration(&clip_id)?; let clip_duration = document.get_clip_duration(&clip_id)?;
let clip_end = timeline_start + clip_duration; let tempo_map = document.tempo_map();
let clip_end = tempo_map.seconds_to_beats(tempo_map.beats_to_seconds(timeline_start) + clip_duration);
// Check each sampled audio layer // Check each sampled audio layer
let context_layers = document.context_layers(editing_clip_id); let context_layers = document.context_layers(editing_clip_id);
@ -1170,8 +1174,10 @@ impl TimelinePane {
*shared.recording_clips.get(&layer_id).unwrap_or(&0), daw_backend::Beats::ZERO); *shared.recording_clips.get(&layer_id).unwrap_or(&0), daw_backend::Beats::ZERO);
let doc_clip_id = shared.action_executor.document_mut().add_audio_clip(doc_clip); let doc_clip_id = shared.action_executor.document_mut().add_audio_clip(doc_clip);
let start_beats = shared.action_executor.document().tempo_map()
.seconds_to_beats(Seconds(start_time));
let clip_instance = ClipInstance::new(doc_clip_id) let clip_instance = ClipInstance::new(doc_clip_id)
.with_timeline_start(start_time); .with_timeline_start(start_beats);
if let Some(layer) = shared.action_executor.document_mut().get_layer_mut(&layer_id) { if let Some(layer) = shared.action_executor.document_mut().get_layer_mut(&layer_id) {
if let lightningbeam_core::layer::AnyLayer::Audio(audio_layer) = layer { if let lightningbeam_core::layer::AnyLayer::Audio(audio_layer) = layer {
@ -1347,8 +1353,8 @@ impl TimelinePane {
let instance_duration = clip_instance.total_duration(clip_duration, tempo_map); let instance_duration = clip_instance.total_duration(clip_duration, tempo_map);
let instance_end = instance_start + instance_duration; let instance_end = instance_start + instance_duration;
let start_x = self.time_to_x(instance_start); let start_x = self.beats_to_x(instance_start, tempo_map);
let end_x = self.time_to_x(instance_end).max(start_x + MIN_CLIP_WIDTH_PX); let end_x = self.beats_to_x(instance_end, tempo_map).max(start_x + MIN_CLIP_WIDTH_PX);
let mouse_x = pointer_pos.x - content_rect.min.x; let mouse_x = pointer_pos.x - content_rect.min.x;
if mouse_x >= start_x && mouse_x <= end_x { if mouse_x >= start_x && mouse_x <= end_x {
@ -1422,12 +1428,12 @@ impl TimelinePane {
// Compute merged spans with the child clip IDs that contribute to each // Compute merged spans with the child clip IDs that contribute to each
let child_clips = group.all_child_clip_instances(); let child_clips = group.all_child_clip_instances();
let mut spans: Vec<(f64, f64, Vec<uuid::Uuid>)> = Vec::new(); // (start, end, clip_ids) let mut spans: Vec<(Beats, Beats, Vec<uuid::Uuid>)> = Vec::new(); // (start, end, clip_ids) in beats
let tempo_map = document.tempo_map(); let tempo_map = document.tempo_map();
for (_child_layer_id, ci) in &child_clips { for (_child_layer_id, ci) in &child_clips {
let clip_dur = document.get_clip_duration(&ci.clip_id).unwrap_or_else(|| { let clip_dur = document.get_clip_duration(&ci.clip_id).unwrap_or_else(|| {
ci.trim_end.unwrap_or(1.0) - ci.trim_start Seconds(ci.trim_end.unwrap_or(1.0) - ci.trim_start)
}); });
let start = ci.effective_start(); let start = ci.effective_start();
let end = start + ci.total_duration(clip_dur, tempo_map); let end = start + ci.total_duration(clip_dur, tempo_map);
@ -1437,7 +1443,7 @@ impl TimelinePane {
spans.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal)); spans.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
// Merge overlapping spans // Merge overlapping spans
let mut merged: Vec<(f64, f64, Vec<uuid::Uuid>)> = Vec::new(); let mut merged: Vec<(Beats, Beats, Vec<uuid::Uuid>)> = Vec::new();
for (s, e, ids) in spans { for (s, e, ids) in spans {
if let Some(last) = merged.last_mut() { if let Some(last) = merged.last_mut() {
if s <= last.1 { if s <= last.1 {
@ -1454,8 +1460,8 @@ impl TimelinePane {
// Check which merged span the pointer is over // Check which merged span the pointer is over
let mouse_x = pointer_pos.x - content_rect.min.x; let mouse_x = pointer_pos.x - content_rect.min.x;
for (s, e, ids) in merged { for (s, e, ids) in merged {
let sx = self.time_to_x(s); let sx = self.beats_to_x(s, tempo_map);
let ex = self.time_to_x(e).max(sx + MIN_CLIP_WIDTH_PX); let ex = self.beats_to_x(e, tempo_map).max(sx + MIN_CLIP_WIDTH_PX);
if mouse_x >= sx && mouse_x <= ex { if mouse_x >= sx && mouse_x <= ex {
return Some(ids); return Some(ids);
} }
@ -1517,24 +1523,30 @@ impl TimelinePane {
((time - self.viewport_start_time) * self.pixels_per_second as f64) as f32 ((time - self.viewport_start_time) * self.pixels_per_second as f64) as f32
} }
/// Convert a beats timeline position to pixel x-coordinate (via seconds).
fn beats_to_x(&self, beats: Beats, tempo_map: &daw_backend::TempoMap) -> f32 {
self.time_to_x(tempo_map.beats_to_seconds(beats).seconds_to_f64())
}
/// Effective display start for a clip instance, in seconds. /// Effective display start for a clip instance, in seconds.
/// ///
/// `timeline_start` is in beats; converts to seconds using the current (preview) BPM so /// `timeline_start` is in beats; converts to seconds using the current (preview) BPM so
/// clips stay anchored to their beat position during live BPM drag. /// clips stay anchored to their beat position during live BPM drag.
fn instance_display_start(&self, ci: &lightningbeam_core::clip::ClipInstance, tempo_map: &daw_backend::TempoMap) -> f64 { fn instance_display_start(&self, ci: &lightningbeam_core::clip::ClipInstance, tempo_map: &daw_backend::TempoMap) -> f64 {
tempo_map.transform(ci.effective_start()) tempo_map.beats_to_seconds(ci.effective_start()).seconds_to_f64()
} }
/// Effective on-timeline duration for a clip instance, in seconds. /// Effective on-timeline duration for a clip instance, in seconds.
/// ///
/// `total_duration` is in beats; converts to seconds using the current (preview) BPM. /// `total_duration` is in beats; converts to seconds using the current (preview) BPM.
fn instance_display_duration(&self, ci: &lightningbeam_core::clip::ClipInstance, clip_dur_secs: f64, tempo_map: &daw_backend::TempoMap) -> f64 { fn instance_display_duration(&self, ci: &lightningbeam_core::clip::ClipInstance, clip_dur_secs: Seconds, tempo_map: &daw_backend::TempoMap) -> f64 {
tempo_map.transform(ci.timeline_start + ci.total_duration(clip_dur_secs, tempo_map)) - tempo_map.transform(ci.effective_start()) (tempo_map.beats_to_seconds(ci.timeline_start + ci.total_duration(clip_dur_secs, tempo_map))
- tempo_map.beats_to_seconds(ci.effective_start())).seconds_to_f64()
} }
/// Returns the clip content start (trim_start) and duration in display seconds. /// Returns the clip content start (trim_start) and duration in display seconds.
fn content_display_range(&self, ci: &lightningbeam_core::clip::ClipInstance, clip_dur_secs: f64, _bpm: f64) -> (f64, f64) { fn content_display_range(&self, ci: &lightningbeam_core::clip::ClipInstance, clip_dur_secs: Seconds, _bpm: f64) -> (f64, f64) {
let trim_end = ci.trim_end.unwrap_or(clip_dur_secs); let trim_end = ci.trim_end.unwrap_or(clip_dur_secs.seconds_to_f64());
(ci.trim_start, (trim_end - ci.trim_start).max(0.0)) (ci.trim_start, (trim_end - ci.trim_start).max(0.0))
} }
@ -1593,21 +1605,33 @@ impl TimelinePane {
} }
} }
/// Effective drag offset for Move operations. /// Effective drag offset for Move operations, as a uniform beats delta.
/// Snaps the anchor clip's resulting position to the grid; all selected clips use the same offset. /// Snapping happens in the seconds/pixel domain (where the grid and `drag_anchor_start`/
/// `drag_offset` live); the result is the anchor's net snapped movement expressed in beats,
/// so every selected clip shifts by the same beats amount (preserving beat-relative spacing).
fn snapped_move_offset( fn snapped_move_offset(
&self, &self,
tempo_map: &daw_backend::TempoMap, tempo_map: &daw_backend::TempoMap,
time_sig: &lightningbeam_core::document::TimeSignature, time_sig: &lightningbeam_core::document::TimeSignature,
framerate: f64, framerate: f64,
) -> f64 { ) -> Beats {
match self.quantize_grid_size(tempo_map, time_sig, framerate) { let anchor = self.drag_anchor_start; // seconds
Some(grid) => { let target = match self.quantize_grid_size(tempo_map, time_sig, framerate) {
let snapped = ((self.drag_anchor_start + self.drag_offset) / grid).round() * grid; Some(grid) => ((anchor + self.drag_offset) / grid).round() * grid,
snapped - self.drag_anchor_start None => anchor + self.drag_offset,
} };
None => self.drag_offset, tempo_map.seconds_to_beats(Seconds(target)) - tempo_map.seconds_to_beats(Seconds(anchor))
} }
/// Apply the current snapped move-drag offset to a clip's beats start, clamped to ≥ 0.
fn moved_start(
&self,
start: Beats,
tempo_map: &daw_backend::TempoMap,
time_sig: &lightningbeam_core::document::TimeSignature,
framerate: f64,
) -> Beats {
(start + self.snapped_move_offset(tempo_map, time_sig, framerate)).max(Beats::ZERO)
} }
/// Calculate appropriate interval for time ruler based on zoom level /// Calculate appropriate interval for time ruler based on zoom level
@ -2995,23 +3019,23 @@ impl TimelinePane {
// Collect all child clip time ranges (with drag preview offset) // Collect all child clip time ranges (with drag preview offset)
let child_clips = g.all_child_clip_instances(); let child_clips = g.all_child_clip_instances();
let is_move_drag = self.clip_drag_state == Some(ClipDragType::Move); let is_move_drag = self.clip_drag_state == Some(ClipDragType::Move);
let mut ranges: Vec<(f64, f64)> = Vec::new(); let mut ranges: Vec<(Beats, Beats)> = Vec::new();
for (_child_layer_id, ci) in &child_clips { for (_child_layer_id, ci) in &child_clips {
let clip_dur = document.get_clip_duration(&ci.clip_id).unwrap_or_else(|| { let clip_dur = document.get_clip_duration(&ci.clip_id).unwrap_or_else(|| {
ci.trim_end.unwrap_or(1.0) - ci.trim_start Seconds(ci.trim_end.unwrap_or(1.0) - ci.trim_start)
}); });
let mut start = ci.effective_start(); let mut start = ci.effective_start();
let dur = ci.total_duration(clip_dur, document.tempo_map()); let dur = ci.total_duration(clip_dur, document.tempo_map());
// Apply drag offset for selected clips during move // Apply drag offset for selected clips during move
if is_move_drag && selection.contains_clip_instance(&ci.id) { if is_move_drag && selection.contains_clip_instance(&ci.id) {
start = (start + self.snapped_move_offset(document.tempo_map(), &document.time_signature, document.framerate)).max(0.0); start = self.moved_start(start, document.tempo_map(), &document.time_signature, document.framerate);
} }
ranges.push((start, start + dur)); ranges.push((start, start + dur));
} }
// Sort and merge overlapping ranges // Sort and merge overlapping ranges
ranges.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal)); ranges.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
let mut merged: Vec<(f64, f64)> = Vec::new(); let mut merged: Vec<(Beats, Beats)> = Vec::new();
for (s, e) in ranges { for (s, e) in ranges {
if let Some(last) = merged.last_mut() { if let Some(last) = merged.last_mut() {
if s <= last.1 { if s <= last.1 {
@ -3038,9 +3062,9 @@ impl TimelinePane {
theme.text_color(&["#timeline", ".group-bar"], ui.ctx(), egui::Color32::from_rgb(100, 220, 220)) theme.text_color(&["#timeline", ".group-bar"], ui.ctx(), egui::Color32::from_rgb(100, 220, 220))
}; };
for (s, e) in &merged { for (s, e) in &merged {
// `merged` ranges are in beats; convert to seconds for time_to_x. // `merged` ranges are in beats.
let sx = self.time_to_x(document.tempo_map().transform(*s)); let sx = self.beats_to_x(*s, document.tempo_map());
let ex = self.time_to_x(document.tempo_map().transform(*e)).max(sx + MIN_CLIP_WIDTH_PX); let ex = self.beats_to_x(*e, document.tempo_map()).max(sx + MIN_CLIP_WIDTH_PX);
if ex >= 0.0 && sx <= rect.width() { if ex >= 0.0 && sx <= rect.width() {
let vsx = sx.max(0.0); let vsx = sx.max(0.0);
let vex = ex.min(rect.width()); let vex = ex.min(rect.width());
@ -3072,17 +3096,17 @@ impl TimelinePane {
if let AnyLayer::Video(vl) = video_child { if let AnyLayer::Video(vl) = video_child {
for ci in &vl.clip_instances { for ci in &vl.clip_instances {
let clip_dur = document.get_clip_duration(&ci.clip_id) let clip_dur = document.get_clip_duration(&ci.clip_id)
.unwrap_or_else(|| ci.trim_end.unwrap_or(1.0) - ci.trim_start); .unwrap_or_else(|| Seconds(ci.trim_end.unwrap_or(1.0) - ci.trim_start));
let mut ci_start = ci.effective_start(); let mut ci_start = ci.effective_start();
if is_move_drag && selection.contains_clip_instance(&ci.id) { if is_move_drag && selection.contains_clip_instance(&ci.id) {
ci_start = (ci_start + self.snapped_move_offset(document.tempo_map(), &document.time_signature, document.framerate)).max(0.0); ci_start = self.moved_start(ci_start, document.tempo_map(), &document.time_signature, document.framerate);
} }
let ci_duration = ci.total_duration(clip_dur, document.tempo_map()); let ci_duration = ci.total_duration(clip_dur, document.tempo_map());
let ci_end = ci_start + ci_duration; let ci_end = ci_start + ci_duration;
// ci_start/ci_end are in beats; convert to seconds for time_to_x. // ci_start/ci_end are in beats.
let sx = self.time_to_x(document.tempo_map().transform(ci_start)); let sx = self.beats_to_x(ci_start, document.tempo_map());
let ex = self.time_to_x(document.tempo_map().transform(ci_end)); let ex = self.beats_to_x(ci_end, document.tempo_map());
if ex < 0.0 || sx > rect.width() { continue; } if ex < 0.0 || sx > rect.width() { continue; }
let ci_rect = egui::Rect::from_min_max( let ci_rect = egui::Rect::from_min_max(
@ -3161,16 +3185,16 @@ impl TimelinePane {
}; };
let audio_file_duration = total_frames as f64 / eff_sr as f64; let audio_file_duration = total_frames as f64 / eff_sr as f64;
let clip_dur = audio_clip.duration; let clip_dur = Seconds(audio_clip.duration);
let mut ci_start = ci.effective_start(); let mut ci_start = ci.effective_start();
if is_move_drag && selection.contains_clip_instance(&ci.id) { if is_move_drag && selection.contains_clip_instance(&ci.id) {
ci_start = (ci_start + self.snapped_move_offset(document.tempo_map(), &document.time_signature, document.framerate)).max(0.0); ci_start = self.moved_start(ci_start, document.tempo_map(), &document.time_signature, document.framerate);
} }
let ci_duration = ci.total_duration(clip_dur, document.tempo_map()); let ci_duration = ci.total_duration(clip_dur, document.tempo_map());
// ci_start/ci_duration are in beats; convert to seconds for time_to_x. // ci_start/ci_duration are in beats.
let ci_screen_start = rect.min.x + self.time_to_x(document.tempo_map().transform(ci_start)); let ci_screen_start = rect.min.x + self.beats_to_x(ci_start, document.tempo_map());
let ci_screen_end = rect.min.x + self.time_to_x(document.tempo_map().transform(ci_start + ci_duration)); let ci_screen_end = rect.min.x + self.beats_to_x(ci_start + ci_duration, document.tempo_map());
let waveform_rect = egui::Rect::from_min_max( let waveform_rect = egui::Rect::from_min_max(
egui::pos2(ci_screen_start.max(rect.min.x), wave_y_min), egui::pos2(ci_screen_start.max(rect.min.x), wave_y_min),
@ -3292,7 +3316,7 @@ impl TimelinePane {
// For moves, precompute the clamped offset so all selected clips move uniformly // For moves, precompute the clamped offset so all selected clips move uniformly
let group_move_offset = if self.clip_drag_state == Some(ClipDragType::Move) { let group_move_offset = if self.clip_drag_state == Some(ClipDragType::Move) {
let group: Vec<(uuid::Uuid, f64, f64)> = clip_instances.iter() let group: Vec<(uuid::Uuid, Beats, Beats)> = clip_instances.iter()
.filter(|ci| selection.contains_clip_instance(&ci.id)) .filter(|ci| selection.contains_clip_instance(&ci.id))
.filter_map(|ci| { .filter_map(|ci| {
let dur = document.get_clip_duration(&ci.clip_id)?; let dur = document.get_clip_duration(&ci.clip_id)?;
@ -3311,9 +3335,18 @@ impl TimelinePane {
// Compute stacking using preview positions (with drag offsets) for vector layers // Compute stacking using preview positions (with drag offsets) for vector layers
let clip_stacking = if matches!(layer, AnyLayer::Vector(_)) && clip_instances.len() > 1 { let clip_stacking = if matches!(layer, AnyLayer::Vector(_)) && clip_instances.len() > 1 {
let preview_ranges: Vec<(f64, f64)> = clip_instances.iter().map(|ci| { let preview_ranges: Vec<(f64, f64)> = clip_instances.iter().map(|ci| {
let clip_dur = effective_clip_duration(document, layer, ci).unwrap_or(0.0); let tmap = document.tempo_map();
// Beats span occupied by `secs` of content starting at beats position `anchor`.
let secs_to_beats_at = |anchor: Beats, secs: f64|
tmap.seconds_to_beats(tmap.beats_to_seconds(anchor) + Seconds(secs)) - anchor;
// Beats position `secs` seconds (wall-clock) after beats position `anchor`.
let shift_beats = |anchor: Beats, secs: f64|
tmap.seconds_to_beats(tmap.beats_to_seconds(anchor) + Seconds(secs));
let clip_dur = effective_clip_duration(document, layer, ci).unwrap_or(Seconds::ZERO);
let clip_dur_secs = clip_dur.seconds_to_f64();
let mut start = ci.effective_start(); let mut start = ci.effective_start();
let mut duration = ci.total_duration(clip_dur, document.tempo_map()); let mut duration = ci.total_duration(clip_dur, tmap);
let is_selected = selection.contains_clip_instance(&ci.id); let is_selected = selection.contains_clip_instance(&ci.id);
let is_linked = if self.clip_drag_state.is_some() { let is_linked = if self.clip_drag_state.is_some() {
@ -3329,47 +3362,57 @@ impl TimelinePane {
match drag_type { match drag_type {
ClipDragType::Move => { ClipDragType::Move => {
if let Some(offset) = group_move_offset { if let Some(offset) = group_move_offset {
start = (ci.effective_start() + offset).max(0.0); start = (ci.effective_start() + offset).max(Beats::ZERO);
} }
} }
ClipDragType::TrimLeft => { ClipDragType::TrimLeft => {
let new_trim = self.snap_to_grid(ci.trim_start + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate).max(0.0).min(clip_dur); let new_trim = self.snap_to_grid(ci.trim_start + self.drag_offset, tmap, &document.time_signature, document.framerate).max(0.0).min(clip_dur_secs);
let offset = new_trim - ci.trim_start; let trim_offset_secs = new_trim - ci.trim_start;
start = (ci.timeline_start + offset).max(0.0); start = shift_beats(ci.timeline_start, trim_offset_secs).max(Beats::ZERO);
duration = (clip_dur - new_trim).max(0.0); let dur_secs = if let Some(trim_end) = ci.trim_end {
if let Some(trim_end) = ci.trim_end { (trim_end - new_trim).max(0.0)
duration = (trim_end - new_trim).max(0.0); } else {
} (clip_dur_secs - new_trim).max(0.0)
};
duration = secs_to_beats_at(start, dur_secs);
} }
ClipDragType::TrimRight => { ClipDragType::TrimRight => {
let old_trim_end = ci.trim_end.unwrap_or(clip_dur); let old_trim_end = ci.trim_end.unwrap_or(clip_dur_secs);
let new_trim_end = self.snap_to_grid(old_trim_end + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate).max(ci.trim_start).min(clip_dur); let new_trim_end = self.snap_to_grid(old_trim_end + self.drag_offset, tmap, &document.time_signature, document.framerate).max(ci.trim_start).min(clip_dur_secs);
duration = (new_trim_end - ci.trim_start).max(0.0); let dur_secs = (new_trim_end - ci.trim_start).max(0.0);
duration = secs_to_beats_at(start, dur_secs);
} }
ClipDragType::LoopExtendRight => { ClipDragType::LoopExtendRight => {
let trim_end = ci.trim_end.unwrap_or(clip_dur); let trim_end = ci.trim_end.unwrap_or(clip_dur_secs);
let content_window = (trim_end - ci.trim_start).max(0.0); let content_window_secs = (trim_end - ci.trim_start).max(0.0);
let content_window = secs_to_beats_at(ci.timeline_start, content_window_secs);
let current_right = ci.timeline_duration.unwrap_or(content_window); let current_right = ci.timeline_duration.unwrap_or(content_window);
let right_edge = ci.timeline_start + current_right + self.drag_offset; let right_edge_secs = tmap.beats_to_seconds(ci.timeline_start + current_right).seconds_to_f64() + self.drag_offset;
let snapped_edge = self.snap_to_grid(right_edge, document.tempo_map(), &document.time_signature, document.framerate); let snapped_edge_secs = self.snap_to_grid(right_edge_secs, tmap, &document.time_signature, document.framerate);
let snapped_edge = tmap.seconds_to_beats(Seconds(snapped_edge_secs));
let new_right = (snapped_edge - ci.timeline_start).max(content_window); let new_right = (snapped_edge - ci.timeline_start).max(content_window);
let loop_before = ci.loop_before.unwrap_or(0.0); let loop_before = ci.loop_before.unwrap_or(Beats::ZERO);
duration = loop_before + new_right; duration = loop_before + new_right;
} }
ClipDragType::LoopExtendLeft => { ClipDragType::LoopExtendLeft => {
let trim_end = ci.trim_end.unwrap_or(clip_dur); let trim_end = ci.trim_end.unwrap_or(clip_dur_secs);
let content_window = (trim_end - ci.trim_start).max(0.001); let content_window_secs = (trim_end - ci.trim_start).max(0.001);
let current_loop_before = ci.loop_before.unwrap_or(0.0); let content_window = secs_to_beats_at(ci.timeline_start, content_window_secs);
let desired = (current_loop_before - self.drag_offset).max(0.0); let current_loop_before = ci.loop_before.unwrap_or(Beats::ZERO);
let snapped = (desired / content_window).round() * content_window; // drag_offset (seconds) as a beats delta at this clip's start.
let drag_beats = shift_beats(ci.timeline_start, self.drag_offset) - ci.timeline_start;
let desired = (current_loop_before - drag_beats).max(Beats::ZERO);
// Snap loop-before to whole content-window multiples.
let cw = content_window.beats_to_f64().max(1e-9);
let snapped = Beats((desired.beats_to_f64() / cw).round() * cw);
start = ci.timeline_start - snapped; start = ci.timeline_start - snapped;
duration = snapped + ci.effective_duration(clip_dur, document.tempo_map()); duration = snapped + ci.effective_duration(clip_dur, tmap);
} }
} }
} }
} }
(start, start + duration) (start.beats_to_f64(), (start + duration).beats_to_f64())
}).collect(); }).collect();
compute_clip_stacking_from_ranges(&preview_ranges) compute_clip_stacking_from_ranges(&preview_ranges)
} else { } else {
@ -3409,7 +3452,7 @@ impl TimelinePane {
// Content origin: where the first "real" content iteration starts // Content origin: where the first "real" content iteration starts
// Loop iterations tile outward from this point // Loop iterations tile outward from this point
let mut content_origin = instance_start + clip_instance.loop_before.unwrap_or(0.0); let mut content_origin = document.tempo_map().beats_to_seconds(clip_instance.timeline_start).seconds_to_f64();
// Track preview trim values for note/waveform rendering. // Track preview trim values for note/waveform rendering.
// In Measures mode, derive from beats so they track BPM during live drag. // In Measures mode, derive from beats so they track BPM during live drag.
@ -3422,26 +3465,27 @@ impl TimelinePane {
match drag_type { match drag_type {
ClipDragType::Move => { ClipDragType::Move => {
if let Some(offset) = group_move_offset { if let Some(offset) = group_move_offset {
instance_start = (clip_instance.effective_start() + offset).max(0.0); let tmap = document.tempo_map();
content_origin = instance_start + clip_instance.loop_before.unwrap_or(0.0); instance_start = tmap.beats_to_seconds((clip_instance.effective_start() + offset).max(Beats::ZERO)).seconds_to_f64();
content_origin = tmap.beats_to_seconds(clip_instance.timeline_start + offset).seconds_to_f64();
} }
} }
ClipDragType::TrimLeft => { ClipDragType::TrimLeft => {
// Trim left: calculate new trim_start with snap to adjacent clips // Trim left: calculate new trim_start with snap to adjacent clips
let desired_trim_start = self.snap_to_grid(clip_instance.trim_start + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate) let desired_trim_start = self.snap_to_grid(clip_instance.trim_start + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate)
.max(0.0) .max(0.0)
.min(clip_duration); .min(clip_duration.seconds_to_f64());
let new_trim_start = if desired_trim_start < clip_instance.trim_start { let new_trim_start = if desired_trim_start < clip_instance.trim_start {
// Extending left - check for adjacent clips // Extending left - limit is the content-seconds gap to the previous clip.
let max_extend = document.find_max_trim_extend_left( let max_extend_secs = document.find_max_trim_extend_left(
&layer.id(), &layer.id(),
&clip_instance.id, &clip_instance.id,
clip_instance.effective_start(), clip_instance.effective_start(),
); ).seconds_to_f64();
let desired_extend = clip_instance.trim_start - desired_trim_start; let desired_extend = clip_instance.trim_start - desired_trim_start;
let actual_extend = desired_extend.min(max_extend); let actual_extend = desired_extend.min(max_extend_secs);
clip_instance.trim_start - actual_extend clip_instance.trim_start - actual_extend
} else { } else {
// Shrinking - no snap needed // Shrinking - no snap needed
@ -3450,11 +3494,11 @@ impl TimelinePane {
let actual_offset = new_trim_start - clip_instance.trim_start; let actual_offset = new_trim_start - clip_instance.trim_start;
// Move start and reduce duration by actual clamped offset // Move start (display seconds) and reduce duration by the clamped offset.
instance_start = (clip_instance.timeline_start + actual_offset) instance_start = (document.tempo_map().beats_to_seconds(clip_instance.timeline_start).seconds_to_f64() + actual_offset)
.max(0.0); .max(0.0);
instance_duration = (clip_duration - new_trim_start).max(0.0); instance_duration = (clip_duration.seconds_to_f64() - new_trim_start).max(0.0);
// Adjust for existing trim_end // Adjust for existing trim_end
if let Some(trim_end) = clip_instance.trim_end { if let Some(trim_end) = clip_instance.trim_end {
@ -3467,23 +3511,27 @@ impl TimelinePane {
} }
ClipDragType::TrimRight => { ClipDragType::TrimRight => {
// Trim right: extend or reduce duration with snap to adjacent clips // Trim right: extend or reduce duration with snap to adjacent clips
let old_trim_end = clip_instance.trim_end.unwrap_or(clip_duration); let old_trim_end = clip_instance.trim_end.unwrap_or(clip_duration.seconds_to_f64());
let desired_trim_end = self.snap_to_grid(old_trim_end + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate) let desired_trim_end = self.snap_to_grid(old_trim_end + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate)
.max(clip_instance.trim_start) .max(clip_instance.trim_start)
.min(clip_duration); .min(clip_duration.seconds_to_f64());
let new_trim_end = if desired_trim_end > old_trim_end { let new_trim_end = if desired_trim_end > old_trim_end {
// Extending right - check for adjacent clips // Extending right - limit is the content-seconds gap to the next clip.
let current_duration = old_trim_end - clip_instance.trim_start; let current_duration_secs = old_trim_end - clip_instance.trim_start;
let max_extend = document.find_max_trim_extend_right( let tmap = document.tempo_map();
let current_duration = tmap.seconds_to_beats(
tmap.beats_to_seconds(clip_instance.timeline_start) + Seconds(current_duration_secs)
) - clip_instance.timeline_start;
let max_extend_secs = document.find_max_trim_extend_right(
&layer.id(), &layer.id(),
&clip_instance.id, &clip_instance.id,
clip_instance.timeline_start, clip_instance.timeline_start,
current_duration, current_duration,
); ).seconds_to_f64();
let desired_extend = desired_trim_end - old_trim_end; let desired_extend = desired_trim_end - old_trim_end;
let actual_extend = desired_extend.min(max_extend); let actual_extend = desired_extend.min(max_extend_secs);
old_trim_end + actual_extend old_trim_end + actual_extend
} else { } else {
// Shrinking - no snap needed // Shrinking - no snap needed
@ -3498,41 +3546,56 @@ impl TimelinePane {
} }
ClipDragType::LoopExtendRight => { ClipDragType::LoopExtendRight => {
// Loop extend right: extend clip beyond content window // Loop extend right: extend clip beyond content window
let trim_end = clip_instance.trim_end.unwrap_or(clip_duration); let trim_end = clip_instance.trim_end.unwrap_or(clip_duration.seconds_to_f64());
let content_window = (trim_end - clip_instance.trim_start).max(0.0); let content_window_secs = (trim_end - clip_instance.trim_start).max(0.0);
let tmap = document.tempo_map();
let ts = clip_instance.timeline_start;
// content window and right-duration are beats-domain timeline spans.
let content_window = tmap.seconds_to_beats(tmap.beats_to_seconds(ts) + Seconds(content_window_secs)) - ts;
let current_right = clip_instance.timeline_duration.unwrap_or(content_window); let current_right = clip_instance.timeline_duration.unwrap_or(content_window);
let right_edge = clip_instance.timeline_start + current_right + self.drag_offset; // Snap the right edge in the seconds/pixel domain (drag_offset is seconds).
let snapped_edge = self.snap_to_grid(right_edge, document.tempo_map(), &document.time_signature, document.framerate); let right_edge_secs = tmap.beats_to_seconds(ts + current_right).seconds_to_f64() + self.drag_offset;
let desired_right = (snapped_edge - clip_instance.timeline_start).max(content_window); let snapped_edge_secs = self.snap_to_grid(right_edge_secs, tmap, &document.time_signature, document.framerate);
let snapped_edge = tmap.seconds_to_beats(Seconds(snapped_edge_secs));
let desired_right = (snapped_edge - ts).max(content_window);
let new_right = if desired_right > current_right { let new_right = if desired_right > current_right {
let max_extend = document.find_max_trim_extend_right( // Gap to the next clip comes back as content seconds; convert to a
// beats span at the clip's right edge for the loop-length limit.
let max_extend_secs = document.find_max_trim_extend_right(
&layer.id(), &layer.id(),
&clip_instance.id, &clip_instance.id,
clip_instance.timeline_start, ts,
current_right, current_right,
); );
let right_edge = ts + current_right;
let max_extend = tmap.seconds_to_beats(tmap.beats_to_seconds(right_edge) + max_extend_secs) - right_edge;
let extend_amount = (desired_right - current_right).min(max_extend); let extend_amount = (desired_right - current_right).min(max_extend);
current_right + extend_amount current_right + extend_amount
} else { } else {
desired_right desired_right
}; };
// Total duration = loop_before + right duration // Right edge lands at ts + new_right (beats); duration in display seconds.
let loop_before = clip_instance.loop_before.unwrap_or(0.0); instance_duration = tmap.beats_to_seconds(ts + new_right).seconds_to_f64() - instance_start;
instance_duration = loop_before + new_right;
} }
ClipDragType::LoopExtendLeft => { ClipDragType::LoopExtendLeft => {
// Loop extend left: extend loop_before (pre-loop region) // Loop extend left: extend loop_before (pre-loop region)
// Snap to multiples of content_window so iterations align with backend // Snap to multiples of content_window so iterations align with backend
let trim_end = clip_instance.trim_end.unwrap_or(clip_duration); let trim_end = clip_instance.trim_end.unwrap_or(clip_duration.seconds_to_f64());
let content_window = (trim_end - clip_instance.trim_start).max(0.001); let content_window_secs = (trim_end - clip_instance.trim_start).max(0.001);
let current_loop_before = clip_instance.loop_before.unwrap_or(0.0); let tmap = document.tempo_map();
// Invert: dragging left (negative offset) = extend let ts = clip_instance.timeline_start;
let desired_loop_before = (current_loop_before - self.drag_offset).max(0.0); // content window is a beats-domain span; guard against zero for division.
// Snap to whole iterations let content_window = tmap.seconds_to_beats(tmap.beats_to_seconds(ts) + Seconds(content_window_secs)) - ts;
let desired_iters = (desired_loop_before / content_window).round(); let cw = content_window.beats_to_f64().max(1e-9);
let snapped_loop_before = desired_iters * content_window; let current_loop_before = clip_instance.loop_before.unwrap_or(Beats::ZERO);
// Invert: dragging left (negative seconds offset) = extend. Convert to a beats delta.
let drag_beats = tmap.seconds_to_beats(tmap.beats_to_seconds(ts) + Seconds(self.drag_offset)) - ts;
let desired_loop_before = (current_loop_before - drag_beats).max(Beats::ZERO);
// Snap to whole content-window iterations
let desired_iters = (desired_loop_before.beats_to_f64() / cw).round();
let snapped_loop_before = Beats(desired_iters * cw);
let new_loop_before = if snapped_loop_before > current_loop_before { let new_loop_before = if snapped_loop_before > current_loop_before {
// Extending left - check for adjacent clips // Extending left - check for adjacent clips
@ -3544,16 +3607,17 @@ impl TimelinePane {
let extend_amount = (snapped_loop_before - current_loop_before).min(max_extend); let extend_amount = (snapped_loop_before - current_loop_before).min(max_extend);
// Re-snap after clamping // Re-snap after clamping
let clamped = current_loop_before + extend_amount; let clamped = current_loop_before + extend_amount;
(clamped / content_window).floor() * content_window Beats((clamped.beats_to_f64() / cw).floor() * cw)
} else { } else {
snapped_loop_before snapped_loop_before
}; };
// Recompute instance_start and instance_duration // Recompute instance_start and instance_duration (display seconds).
let right_duration = clip_instance.effective_duration(clip_duration, document.tempo_map()); // Real content ends at ts + right_duration (beats), independent of loop_before.
instance_start = clip_instance.timeline_start - new_loop_before; let right_duration = clip_instance.effective_duration(clip_duration, tmap);
instance_duration = new_loop_before + right_duration; instance_start = tmap.beats_to_seconds(ts - new_loop_before).seconds_to_f64();
content_origin = clip_instance.timeline_start; instance_duration = tmap.beats_to_seconds(ts + right_duration).seconds_to_f64() - instance_start;
content_origin = tmap.beats_to_seconds(ts).seconds_to_f64();
} }
} }
} }
@ -3757,7 +3821,7 @@ impl TimelinePane {
// Calculate content window for loop detection // Calculate content window for loop detection
// Use trimmed content window (preview_trim_start accounts for TrimLeft drag) // Use trimmed content window (preview_trim_start accounts for TrimLeft drag)
let preview_trim_end = clip_instance.trim_end.unwrap_or(clip_duration); let preview_trim_end = clip_instance.trim_end.unwrap_or(clip_duration.seconds_to_f64());
let content_window = (preview_trim_end - preview_trim_start).max(0.0); let content_window = (preview_trim_end - preview_trim_start).max(0.0);
let is_looping = instance_duration > content_window + 0.001; let is_looping = instance_duration > content_window + 0.001;
@ -4211,8 +4275,8 @@ impl TimelinePane {
let instance_end = instance_start + instance_duration; let instance_end = instance_start + instance_duration;
// Check if click is within this clip instance's pixel range and vertical bounds // Check if click is within this clip instance's pixel range and vertical bounds
let ci_start_x = self.time_to_x(instance_start); let ci_start_x = self.beats_to_x(instance_start, document.tempo_map());
let ci_end_x = self.time_to_x(instance_end).max(ci_start_x + MIN_CLIP_WIDTH_PX); let ci_end_x = self.beats_to_x(instance_end, document.tempo_map()).max(ci_start_x + MIN_CLIP_WIDTH_PX);
let click_x = pos.x - content_rect.min.x; let click_x = pos.x - content_rect.min.x;
let (row, total_rows) = click_stacking[ci_idx]; let (row, total_rows) = click_stacking[ci_idx];
let (cy_min, cy_max) = clip_instance_y_bounds(row, total_rows); let (cy_min, cy_max) = clip_instance_y_bounds(row, total_rows);
@ -4503,8 +4567,9 @@ impl TimelinePane {
let mut earliest = f64::MAX; let mut earliest = f64::MAX;
for (_, clip_instances) in all_layer_clip_instances(context_layers, &audio_cache) { for (_, clip_instances) in all_layer_clip_instances(context_layers, &audio_cache) {
for ci in clip_instances { for ci in clip_instances {
if selection.contains_clip_instance(&ci.id) && ci.timeline_start < earliest { if selection.contains_clip_instance(&ci.id) {
earliest = ci.timeline_start; let start_secs = document.tempo_map().beats_to_seconds(ci.timeline_start).seconds_to_f64();
if start_secs < earliest { earliest = start_secs; }
} }
} }
} }
@ -4532,8 +4597,9 @@ impl TimelinePane {
let mut earliest = f64::MAX; let mut earliest = f64::MAX;
for (_, clip_instances) in all_layer_clip_instances(context_layers, &audio_cache) { for (_, clip_instances) in all_layer_clip_instances(context_layers, &audio_cache) {
for ci in clip_instances { for ci in clip_instances {
if selection.contains_clip_instance(&ci.id) && ci.timeline_start < earliest { if selection.contains_clip_instance(&ci.id) {
earliest = ci.timeline_start; let start_secs = document.tempo_map().beats_to_seconds(ci.timeline_start).seconds_to_f64();
if start_secs < earliest { earliest = start_secs; }
} }
} }
} }
@ -4555,10 +4621,11 @@ impl TimelinePane {
if response.drag_stopped() { if response.drag_stopped() {
// Build layer_moves map for the action // Build layer_moves map for the action
use std::collections::HashMap; use std::collections::HashMap;
let mut layer_moves: HashMap<uuid::Uuid, Vec<(uuid::Uuid, f64, f64)>> = let mut layer_moves: HashMap<uuid::Uuid, Vec<(uuid::Uuid, Beats, Beats)>> =
HashMap::new(); HashMap::new();
// Compute snapped offset once for all selected clips (preserves relative spacing) // Compute snapped offset once for all selected clips (preserves relative spacing).
// `snapped_move_offset` is a uniform beats delta.
let move_offset = self.snapped_move_offset(document.tempo_map(), &document.time_signature, document.framerate); let move_offset = self.snapped_move_offset(document.tempo_map(), &document.time_signature, document.framerate);
// Iterate through all layers (including group children) to find selected clip instances // Iterate through all layers (including group children) to find selected clip instances
@ -4622,26 +4689,28 @@ impl TimelinePane {
// New trim_start is snapped then clamped to valid range // New trim_start is snapped then clamped to valid range
let desired_trim_start = self.snap_to_grid( let desired_trim_start = self.snap_to_grid(
old_trim_start + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate, old_trim_start + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate,
).max(0.0).min(clip_duration); ).max(0.0).min(clip_duration.seconds_to_f64());
// Apply overlap prevention when extending left // Apply overlap prevention when extending left (content-seconds gap).
let new_trim_start = if desired_trim_start < old_trim_start { let new_trim_start = if desired_trim_start < old_trim_start {
let max_extend = document.find_max_trim_extend_left( let max_extend_secs = document.find_max_trim_extend_left(
&layer_id, &layer_id,
&clip_instance.id, &clip_instance.id,
old_timeline_start, old_timeline_start,
); ).seconds_to_f64();
let desired_extend = old_trim_start - desired_trim_start; let desired_extend = old_trim_start - desired_trim_start;
let actual_extend = desired_extend.min(max_extend); let actual_extend = desired_extend.min(max_extend_secs);
old_trim_start - actual_extend old_trim_start - actual_extend
} else { } else {
desired_trim_start desired_trim_start
}; };
// Calculate actual offset after clamping // Calculate actual offset after clamping (seconds), then the
// new timeline start in beats.
let actual_offset = new_trim_start - old_trim_start; let actual_offset = new_trim_start - old_trim_start;
let new_timeline_start = let new_timeline_start = document.tempo_map().seconds_to_beats(
old_timeline_start + actual_offset; document.tempo_map().beats_to_seconds(old_timeline_start) + Seconds(actual_offset)
);
layer_trims layer_trims
.entry(layer_id) .entry(layer_id)
@ -4665,21 +4734,21 @@ impl TimelinePane {
// Calculate new trim_end based on current duration // Calculate new trim_end based on current duration
let current_duration = let current_duration =
clip_instance.effective_duration(clip_duration, document.tempo_map()); clip_instance.effective_duration(clip_duration, document.tempo_map());
let old_trim_end_val = clip_instance.trim_end.unwrap_or(clip_duration); let old_trim_end_val = clip_instance.trim_end.unwrap_or(clip_duration.seconds_to_f64());
let desired_trim_end = self.snap_to_grid( let desired_trim_end = self.snap_to_grid(
old_trim_end_val + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate, old_trim_end_val + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate,
).max(clip_instance.trim_start).min(clip_duration); ).max(clip_instance.trim_start).min(clip_duration.seconds_to_f64());
// Apply overlap prevention when extending right // Apply overlap prevention when extending right (content-seconds gap).
let new_trim_end_val = if desired_trim_end > old_trim_end_val { let new_trim_end_val = if desired_trim_end > old_trim_end_val {
let max_extend = document.find_max_trim_extend_right( let max_extend_secs = document.find_max_trim_extend_right(
&layer_id, &layer_id,
&clip_instance.id, &clip_instance.id,
clip_instance.timeline_start, clip_instance.timeline_start,
current_duration, current_duration,
); ).seconds_to_f64();
let desired_extend = desired_trim_end - old_trim_end_val; let desired_extend = desired_trim_end - old_trim_end_val;
let actual_extend = desired_extend.min(max_extend); let actual_extend = desired_extend.min(max_extend_secs);
old_trim_end_val + actual_extend old_trim_end_val + actual_extend
} else { } else {
desired_trim_end desired_trim_end
@ -4688,10 +4757,10 @@ impl TimelinePane {
let new_duration = (new_trim_end_val - clip_instance.trim_start).max(0.0); let new_duration = (new_trim_end_val - clip_instance.trim_start).max(0.0);
// Convert new duration back to trim_end value // Convert new duration back to trim_end value
let new_trim_end = if new_duration >= clip_duration { let new_trim_end = if new_duration >= clip_duration.seconds_to_f64() {
None // Use full clip duration None // Use full clip duration
} else { } else {
Some((clip_instance.trim_start + new_duration).min(clip_duration)) Some((clip_instance.trim_start + new_duration).min(clip_duration.seconds_to_f64()))
}; };
layer_trims layer_trims
@ -4741,20 +4810,26 @@ impl TimelinePane {
}; };
if let Some(clip_duration) = clip_duration { if let Some(clip_duration) = clip_duration {
let tmap = document.tempo_map();
let ts = clip_instance.timeline_start;
let trim_end = clip_instance.trim_end.unwrap_or(clip_duration); let trim_end = clip_instance.trim_end.unwrap_or(clip_duration);
let content_window = (trim_end - clip_instance.trim_start).max(0.0); let content_window_secs = (trim_end - clip_instance.trim_start).max(0.0);
let content_window = tmap.seconds_to_beats(tmap.beats_to_seconds(ts) + Seconds(content_window_secs)) - ts;
let current_right = clip_instance.timeline_duration.unwrap_or(content_window); let current_right = clip_instance.timeline_duration.unwrap_or(content_window);
let right_edge = clip_instance.timeline_start + current_right + self.drag_offset; // Snap the right edge in the seconds/pixel domain.
let snapped_edge = self.snap_to_grid(right_edge, document.tempo_map(), &document.time_signature, document.framerate); let right_edge_secs = tmap.beats_to_seconds(ts + current_right).seconds_to_f64() + self.drag_offset;
let desired_right = snapped_edge - clip_instance.timeline_start; let snapped_edge_secs = self.snap_to_grid(right_edge_secs, tmap, &document.time_signature, document.framerate);
let desired_right = tmap.seconds_to_beats(Seconds(snapped_edge_secs)) - ts;
let new_right = if desired_right > current_right { let new_right = if desired_right > current_right {
let max_extend = document.find_max_trim_extend_right( let max_extend_secs = document.find_max_trim_extend_right(
&layer_id, &layer_id,
&clip_instance.id, &clip_instance.id,
clip_instance.timeline_start, ts,
current_right, current_right,
); );
let right_edge = ts + current_right;
let max_extend = tmap.seconds_to_beats(tmap.beats_to_seconds(right_edge) + max_extend_secs) - right_edge;
let extend_amount = (desired_right - current_right).min(max_extend); let extend_amount = (desired_right - current_right).min(max_extend);
current_right + extend_amount current_right + extend_amount
} else { } else {
@ -4762,7 +4837,7 @@ impl TimelinePane {
}; };
let old_timeline_duration = clip_instance.timeline_duration; let old_timeline_duration = clip_instance.timeline_duration;
let new_timeline_duration = if new_right > content_window + 0.001 { let new_timeline_duration = if new_right > content_window + Beats(0.001) {
Some(new_right) Some(new_right)
} else { } else {
None None
@ -4809,14 +4884,19 @@ impl TimelinePane {
}; };
if let Some(clip_duration) = clip_duration { if let Some(clip_duration) = clip_duration {
let tmap = document.tempo_map();
let ts = clip_instance.timeline_start;
let trim_end = clip_instance.trim_end.unwrap_or(clip_duration); let trim_end = clip_instance.trim_end.unwrap_or(clip_duration);
let content_window = (trim_end - clip_instance.trim_start).max(0.001); let content_window_secs = (trim_end - clip_instance.trim_start).max(0.001);
let current_loop_before = clip_instance.loop_before.unwrap_or(0.0); let content_window = tmap.seconds_to_beats(tmap.beats_to_seconds(ts) + Seconds(content_window_secs)) - ts;
// Invert: dragging left (negative offset) = extend let cw = content_window.beats_to_f64().max(1e-9);
let desired_loop_before = (current_loop_before - self.drag_offset).max(0.0); let current_loop_before = clip_instance.loop_before.unwrap_or(Beats::ZERO);
// Invert: dragging left (negative seconds offset) = extend.
let drag_beats = tmap.seconds_to_beats(tmap.beats_to_seconds(ts) + Seconds(self.drag_offset)) - ts;
let desired_loop_before = (current_loop_before - drag_beats).max(Beats::ZERO);
// Snap to whole iterations so backend modulo aligns // Snap to whole iterations so backend modulo aligns
let desired_iters = (desired_loop_before / content_window).round(); let desired_iters = (desired_loop_before.beats_to_f64() / cw).round();
let snapped = desired_iters * content_window; let snapped = Beats(desired_iters * cw);
let new_loop_before = if snapped > current_loop_before { let new_loop_before = if snapped > current_loop_before {
let max_extend = document.find_max_loop_extend_left( let max_extend = document.find_max_loop_extend_left(
@ -4826,13 +4906,13 @@ impl TimelinePane {
); );
let extend_amount = (snapped - current_loop_before).min(max_extend); let extend_amount = (snapped - current_loop_before).min(max_extend);
let clamped = current_loop_before + extend_amount; let clamped = current_loop_before + extend_amount;
(clamped / content_window).floor() * content_window Beats((clamped.beats_to_f64() / cw).floor() * cw)
} else { } else {
snapped snapped
}; };
let old_loop_before = clip_instance.loop_before; let old_loop_before = clip_instance.loop_before;
let new_lb = if new_loop_before > 0.001 { let new_lb = if new_loop_before > Beats(0.001) {
Some(new_loop_before) Some(new_loop_before)
} else { } else {
None None
@ -5512,7 +5592,7 @@ impl PaneRenderer for TimelinePane {
if let Some(clip_duration) = clip_duration { if let Some(clip_duration) = clip_duration {
let instance_duration = clip_instance.effective_duration(clip_duration, document.tempo_map()); let instance_duration = clip_instance.effective_duration(clip_duration, document.tempo_map());
let instance_end = clip_instance.timeline_start + instance_duration; let instance_end = clip_instance.timeline_start + instance_duration;
max_endpoint = max_endpoint.max(instance_end); max_endpoint = max_endpoint.max(document.tempo_map().beats_to_seconds(instance_end).seconds_to_f64());
} }
} }
} }
@ -5943,8 +6023,8 @@ impl PaneRenderer for TimelinePane {
if !shared.selection.contains_clip_instance(&inst.id) { continue; } if !shared.selection.contains_clip_instance(&inst.id) { continue; }
if let Some(dur) = document.get_clip_duration(&inst.clip_id) { if let Some(dur) = document.get_clip_duration(&inst.clip_id) {
let eff = inst.effective_duration(dur, document.tempo_map()); let eff = inst.effective_duration(dur, document.tempo_map());
let start = inst.timeline_start; let start = document.tempo_map().beats_to_seconds(inst.timeline_start).seconds_to_f64();
let end = start + eff; let end = document.tempo_map().beats_to_seconds(inst.timeline_start + eff).seconds_to_f64();
let min_dist = min_split_px as f64 / self.pixels_per_second as f64; let min_dist = min_split_px as f64 / self.pixels_per_second as f64;
if playback_time > start + min_dist && playback_time < end - min_dist { if playback_time > start + min_dist && playback_time < end - min_dist {
enabled = true; enabled = true;
@ -5969,10 +6049,14 @@ impl PaneRenderer for TimelinePane {
.all(|ci| { .all(|ci| {
if let Some(dur) = document.get_clip_duration(&ci.clip_id) { if let Some(dur) = document.get_clip_duration(&ci.clip_id) {
let eff = ci.effective_duration(dur, document.tempo_map()); let eff = ci.effective_duration(dur, document.tempo_map());
let max_extend = document.find_max_trim_extend_right( // Room to duplicate = seconds gap to the right ≥ this clip's own length.
let max_extend_secs = document.find_max_trim_extend_right(
&layer_id, &ci.id, ci.timeline_start, eff, &layer_id, &ci.id, ci.timeline_start, eff,
); ).seconds_to_f64();
max_extend >= eff let tmap = document.tempo_map();
let eff_secs = (tmap.beats_to_seconds(ci.timeline_start + eff)
- tmap.beats_to_seconds(ci.timeline_start)).seconds_to_f64();
max_extend_secs >= eff_secs
} else { } else {
false false
} }
@ -6001,11 +6085,13 @@ impl PaneRenderer for TimelinePane {
let min_start = instances let min_start = instances
.iter() .iter()
.map(|i| i.timeline_start) .map(|i| i.timeline_start)
.fold(f64::INFINITY, f64::min); .fold(Beats(f64::INFINITY), |a, b| a.min(b));
let offset = *shared.playback_time - min_start; // Place so the earliest clip lands at the playhead (beats).
let playhead_beats = document.tempo_map().seconds_to_beats(Seconds(*shared.playback_time));
let offset = playhead_beats - min_start;
enabled = instances.iter().all(|ci| { enabled = instances.iter().all(|ci| {
let paste_start = (ci.timeline_start + offset).max(0.0); let paste_start = (ci.timeline_start + offset).max(Beats::ZERO);
if let Some(dur) = document.get_clip_duration(&ci.clip_id) { if let Some(dur) = document.get_clip_duration(&ci.clip_id) {
let eff = ci.effective_duration(dur, document.tempo_map()); let eff = ci.effective_duration(dur, document.tempo_map());
document document
@ -6210,24 +6296,22 @@ impl PaneRenderer for TimelinePane {
// Show drop time indicator with snap preview // Show drop time indicator with snap preview
let raw_drop_time = self.x_to_time(pointer_pos.x - content_rect.min.x).max(0.0); let raw_drop_time = self.x_to_time(pointer_pos.x - content_rect.min.x).max(0.0);
// Calculate snapped drop time for preview // Calculate snapped drop time for preview (seconds, for time_to_x)
let drop_time = if is_compatible { let drop_time = if is_compatible {
// Get clip duration to calculate snapped position let doc = shared.action_executor.document();
let clip_duration = { let tmap = doc.tempo_map();
let doc = shared.action_executor.document(); let clip_duration = doc.get_clip_duration(&dragging.clip_id).unwrap_or(Seconds(1.0));
doc.get_clip_duration(&dragging.clip_id).unwrap_or(1.0) // Overlap testing is beats-domain: convert the drop point and the clip's
}; // content span to beats.
let raw_drop_beats = tmap.seconds_to_beats(Seconds(raw_drop_time));
// Find nearest valid position (auto-snap for preview) let clip_dur_beats = tmap.seconds_to_beats(tmap.beats_to_seconds(raw_drop_beats) + clip_duration) - raw_drop_beats;
let snapped = shared.action_executor.document() let snapped = doc.find_nearest_valid_position(
.find_nearest_valid_position( &layer.id(),
&layer.id(), raw_drop_beats,
raw_drop_time, clip_dur_beats,
clip_duration, &[],
&[], );
); snapped.map(|b| tmap.beats_to_seconds(b).seconds_to_f64()).unwrap_or(raw_drop_time)
snapped.unwrap_or(raw_drop_time)
} else { } else {
raw_drop_time raw_drop_time
}; };
@ -6261,9 +6345,12 @@ impl PaneRenderer for TimelinePane {
} }
// Create clip instance for effect with 5 second default duration // Create clip instance for effect with 5 second default duration
let tmap = shared.action_executor.document().tempo_map();
let drop_beats = tmap.seconds_to_beats(Seconds(drop_time));
let dur_beats = tmap.seconds_to_beats(tmap.beats_to_seconds(drop_beats) + Seconds(5.0)) - drop_beats;
let clip_instance = ClipInstance::new(def.id) let clip_instance = ClipInstance::new(def.id)
.with_timeline_start(drop_time) .with_timeline_start(drop_beats)
.with_timeline_duration(5.0); .with_timeline_duration(dur_beats);
// Use AddEffectAction for effect layers // Use AddEffectAction for effect layers
let action = lightningbeam_core::actions::AddEffectAction::new( let action = lightningbeam_core::actions::AddEffectAction::new(
@ -6283,7 +6370,7 @@ impl PaneRenderer for TimelinePane {
let center_y = doc.height / 2.0; let center_y = doc.height / 2.0;
let mut clip_instance = ClipInstance::new(dragging.clip_id) let mut clip_instance = ClipInstance::new(dragging.clip_id)
.with_timeline_start(drop_time); .with_timeline_start(doc.tempo_map().seconds_to_beats(Seconds(drop_time)));
// For video clips, fit uniformly + centered (preserve aspect). // For video clips, fit uniformly + centered (preserve aspect).
// Shared with the direct-import path via Transform::fit_centered. // Shared with the direct-import path via Transform::fit_centered.
@ -6321,7 +6408,7 @@ impl PaneRenderer for TimelinePane {
// Find or create sampled audio track where the audio won't overlap // Find or create sampled audio track where the audio won't overlap
let audio_layer_id = { let audio_layer_id = {
let doc = shared.action_executor.document(); let doc = shared.action_executor.document();
let result = find_sampled_audio_track_for_clip(doc, linked_audio_clip_id, drop_time, editing_clip_id.as_ref()); let result = find_sampled_audio_track_for_clip(doc, linked_audio_clip_id, doc.tempo_map().seconds_to_beats(Seconds(drop_time)), editing_clip_id.as_ref());
if let Some(id) = result { if let Some(id) = result {
eprintln!("DEBUG: Found existing audio track without overlap: {}", id); eprintln!("DEBUG: Found existing audio track without overlap: {}", id);
} else { } else {
@ -6343,7 +6430,7 @@ impl PaneRenderer for TimelinePane {
// Create audio clip instance at same timeline position // Create audio clip instance at same timeline position
let audio_instance = ClipInstance::new(linked_audio_clip_id) let audio_instance = ClipInstance::new(linked_audio_clip_id)
.with_timeline_start(drop_time); .with_timeline_start(shared.action_executor.document().tempo_map().seconds_to_beats(Seconds(drop_time)));
let audio_instance_id = audio_instance.id; let audio_instance_id = audio_instance.id;
eprintln!("DEBUG: Created audio instance: {} for clip: {}", audio_instance_id, linked_audio_clip_id); eprintln!("DEBUG: Created audio instance: {} for clip: {}", audio_instance_id, linked_audio_clip_id);
@ -6436,9 +6523,12 @@ impl PaneRenderer for TimelinePane {
shared.action_executor.document_mut().add_effect_definition(def.clone()); shared.action_executor.document_mut().add_effect_definition(def.clone());
} }
let tmap = shared.action_executor.document().tempo_map();
let drop_beats = tmap.seconds_to_beats(Seconds(drop_time));
let dur_beats = tmap.seconds_to_beats(tmap.beats_to_seconds(drop_beats) + Seconds(5.0)) - drop_beats;
let clip_instance = ClipInstance::new(def.id) let clip_instance = ClipInstance::new(def.id)
.with_timeline_start(drop_time) .with_timeline_start(drop_beats)
.with_timeline_duration(5.0); .with_timeline_duration(dur_beats);
let action = lightningbeam_core::actions::AddEffectAction::new( let action = lightningbeam_core::actions::AddEffectAction::new(
new_layer_id, new_layer_id,
@ -6449,7 +6539,7 @@ impl PaneRenderer for TimelinePane {
} else { } else {
// Handle other clip types // Handle other clip types
let clip_instance = ClipInstance::new(dragging.clip_id) let clip_instance = ClipInstance::new(dragging.clip_id)
.with_timeline_start(drop_time); .with_timeline_start(shared.action_executor.document().tempo_map().seconds_to_beats(Seconds(drop_time)));
let action = lightningbeam_core::actions::AddClipInstanceAction::new( let action = lightningbeam_core::actions::AddClipInstanceAction::new(
new_layer_id, new_layer_id,