diff --git a/daw-backend/src/audio/engine.rs b/daw-backend/src/audio/engine.rs index ac6a6c0..a7dcab5 100644 --- a/daw-backend/src/audio/engine.rs +++ b/daw-backend/src/audio/engine.rs @@ -1001,20 +1001,20 @@ impl Engine { let _ = self.event_tx.push(AudioEvent::AudioFileAdded(pool_index, path)); } Command::AddAudioClip(track_id, clip_id, pool_index, start_time, duration, offset) => { - // Create a new clip instance with the pre-assigned clip_id - // start_time and duration are in beats; offset (internal_start) is seconds - let start_beats = Beats(start_time); - let end_beats = Beats(start_time + duration); + // Create a new clip instance with the pre-assigned clip_id. + // start_time/duration are beats; offset (internal_start) is seconds. + let start_beats = start_time; + let end_beats = start_time + duration; let start_secs = self.tempo_map.beats_to_seconds(start_beats); let end_secs = self.tempo_map.beats_to_seconds(end_beats); let content_dur_secs = (end_secs - start_secs).seconds_to_f64(); let mut clip = AudioClipInstance::new( clip_id, pool_index, - Seconds(offset), - Seconds(offset + content_dur_secs), + offset, + offset + Seconds(content_dur_secs), start_beats, - Beats(duration), + duration, ); // If the source is streamed (a compressed audio file, or a video's @@ -3380,8 +3380,8 @@ impl EngineController { } /// Move a clip to a new timeline position (changes external_start) - pub fn move_clip(&mut self, track_id: TrackId, clip_id: ClipId, new_start_time: f64) { - let _ = self.command_tx.push(Command::MoveClip(track_id, clip_id, new_start_time)); + pub fn move_clip(&mut self, track_id: TrackId, clip_id: ClipId, new_start_time: Beats) { + let _ = self.command_tx.push(Command::MoveClip(track_id, clip_id, new_start_time.beats_to_f64())); } /// Trim a clip's internal boundaries (changes which portion of source content is used) @@ -3391,8 +3391,8 @@ impl EngineController { } /// Extend or shrink a clip's external duration (enables looping if > internal duration) - pub fn extend_clip(&mut self, track_id: TrackId, clip_id: ClipId, new_external_duration: f64) { - let _ = self.command_tx.push(Command::ExtendClip(track_id, clip_id, new_external_duration)); + pub fn extend_clip(&mut self, track_id: TrackId, clip_id: ClipId, new_external_duration: Beats) { + let _ = self.command_tx.push(Command::ExtendClip(track_id, clip_id, new_external_duration.beats_to_f64())); } /// Send a generic command to the audio thread @@ -3440,8 +3440,8 @@ impl EngineController { /// Set metatrack time offset in seconds /// Positive = shift content later, negative = shift earlier - pub fn set_offset(&mut self, track_id: TrackId, offset: f64) { - let _ = self.command_tx.push(Command::SetOffset(track_id, offset)); + pub fn set_offset(&mut self, track_id: TrackId, offset: Seconds) { + let _ = self.command_tx.push(Command::SetOffset(track_id, offset.seconds_to_f64())); } /// Set metatrack pitch shift in semitones (for future use) @@ -3525,14 +3525,14 @@ impl EngineController { /// Add a clip to an audio track (async, fire-and-forget) /// Returns the pre-assigned clip instance ID so callers can track the clip without a sync round-trip - pub fn add_audio_clip(&mut self, track_id: TrackId, pool_index: usize, start_time: f64, duration: f64, offset: f64) -> AudioClipInstanceId { + pub fn add_audio_clip(&mut self, track_id: TrackId, pool_index: usize, start_time: Beats, duration: Beats, offset: Seconds) -> AudioClipInstanceId { let clip_id = self.next_audio_clip_id.fetch_add(1, Ordering::Relaxed); let _ = self.command_tx.push(Command::AddAudioClip(track_id, clip_id, pool_index, start_time, duration, offset)); clip_id } /// Add a clip to an audio track with a pre-assigned ID (for undo/redo, restoring deleted clips) - pub fn add_audio_clip_with_id(&mut self, track_id: TrackId, clip_id: AudioClipInstanceId, pool_index: usize, start_time: f64, duration: f64, offset: f64) { + pub fn add_audio_clip_with_id(&mut self, track_id: TrackId, clip_id: AudioClipInstanceId, pool_index: usize, start_time: Beats, duration: Beats, offset: Seconds) { let _ = self.command_tx.push(Command::AddAudioClip(track_id, clip_id, pool_index, start_time, duration, offset)); } diff --git a/daw-backend/src/command/types.rs b/daw-backend/src/command/types.rs index cf29ccc..98cce70 100644 --- a/daw-backend/src/command/types.rs +++ b/daw-backend/src/command/types.rs @@ -71,7 +71,8 @@ pub enum Command { AddAudioFile(String, Vec, u32, u32), /// Add a clip to an audio track (track_id, clip_id, pool_index, start_time, duration, offset) /// The clip_id is pre-assigned by the caller (via EngineController::next_audio_clip_id()) - AddAudioClip(TrackId, AudioClipInstanceId, usize, f64, f64, f64), + /// (track, clip_id, pool_index, start_time [beats], duration [beats], offset [seconds]) + AddAudioClip(TrackId, AudioClipInstanceId, usize, Beats, Beats, Seconds), // MIDI commands /// Create a new MIDI track with a name and optional parent group diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/add_clip_instance.rs b/lightningbeam-ui/lightningbeam-core/src/actions/add_clip_instance.rs index 326550a..705c9d5 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/add_clip_instance.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/add_clip_instance.rs @@ -60,13 +60,14 @@ impl AddClipInstanceAction { impl Action for AddClipInstanceAction { fn execute(&mut self, document: &mut Document) -> Result<(), String> { - // Calculate the clip's effective duration + // Calculate the clip's effective duration in BEATS for overlap testing. + // `get_clip_duration` is the content length in seconds; the placement span + // must be beats (the timeline is beats-domain), so convert via the clip's + // typed helper rather than treating the seconds span as beats. let clip_duration = document.get_clip_duration(&self.clip_instance.clip_id) .ok_or_else(|| format!("Clip {} not found", self.clip_instance.clip_id))?; - - let trim_start = self.clip_instance.trim_start; - let trim_end = self.clip_instance.trim_end.unwrap_or(clip_duration); - let effective_duration = trim_end - trim_start; + let effective_duration = self.clip_instance + .effective_duration_beats(clip_duration, document.tempo_map()); // Auto-adjust position for audio/video layers to avoid overlaps let adjusted_start = document.find_nearest_valid_position( @@ -200,9 +201,10 @@ impl Action for AddClipInstanceAction { let internal_end = self.clip_instance.trim_end.unwrap_or(clip.duration); let external_start = self.clip_instance.timeline_start; - // Calculate external duration (for looping if timeline_duration is set) + // Calculate external duration (for looping if timeline_duration is set). + // MIDI trims are beats-domain, so the fallback span is beats too. let external_duration = self.clip_instance.timeline_duration - .unwrap_or(internal_end - internal_start); + .unwrap_or(daw_backend::Beats(internal_end - internal_start)); // Create MidiClipInstance let instance = daw_backend::MidiClipInstance::new( @@ -210,8 +212,8 @@ impl Action for AddClipInstanceAction { *midi_clip_id, daw_backend::Beats(internal_start), daw_backend::Beats(internal_end), - daw_backend::Beats(external_start), - daw_backend::Beats(external_duration), + external_start, + external_duration, ); // Send query to add instance and get instance ID @@ -247,8 +249,8 @@ impl Action for AddClipInstanceAction { // the seconds-as-beats bug that made clips stop early off 60 BPM). let effective_duration = self.clip_instance.timeline_duration.unwrap_or_else(|| { let tempo_map = document.tempo_map(); - let content_secs = internal_end - internal_start; - tempo_map.inverse_transform(tempo_map.transform(start_time) + content_secs) + let content_secs = daw_backend::Seconds(internal_end - internal_start); + tempo_map.seconds_to_beats(tempo_map.beats_to_seconds(start_time) + content_secs) - start_time }); @@ -257,7 +259,7 @@ impl Action for AddClipInstanceAction { *audio_pool_index, start_time, effective_duration, - internal_start, + daw_backend::Seconds(internal_start), ); self.backend_track_id = Some(*backend_track_id); diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/add_effect.rs b/lightningbeam-ui/lightningbeam-core/src/actions/add_effect.rs index 9df7fca..cf89b9c 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/add_effect.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/add_effect.rs @@ -159,7 +159,7 @@ mod tests { fn test_add_effect() { let (mut document, layer_id, def) = create_test_setup(); - let instance = def.create_instance(0.0, 10.0); + let instance = def.create_instance(daw_backend::Beats(0.0), daw_backend::Beats(10.0)); let instance_id = instance.id; let mut action = AddEffectAction::new(layer_id, instance); @@ -181,7 +181,7 @@ mod tests { fn test_add_effect_rollback() { let (mut document, layer_id, def) = create_test_setup(); - let instance = def.create_instance(0.0, 10.0); + let instance = def.create_instance(daw_backend::Beats(0.0), daw_backend::Beats(10.0)); let mut action = AddEffectAction::new(layer_id, instance); action.execute(&mut document).unwrap(); @@ -201,19 +201,19 @@ mod tests { let (mut document, layer_id, def) = create_test_setup(); // Add first effect - let instance1 = def.create_instance(0.0, 10.0); + let instance1 = def.create_instance(daw_backend::Beats(0.0), daw_backend::Beats(10.0)); let id1 = instance1.id; let mut action1 = AddEffectAction::new(layer_id, instance1); action1.execute(&mut document).unwrap(); // Add second effect - let instance2 = def.create_instance(0.0, 10.0); + let instance2 = def.create_instance(daw_backend::Beats(0.0), daw_backend::Beats(10.0)); let id2 = instance2.id; let mut action2 = AddEffectAction::new(layer_id, instance2); action2.execute(&mut document).unwrap(); // Insert third effect at index 1 (between first and second) - let instance3 = def.create_instance(0.0, 10.0); + let instance3 = def.create_instance(daw_backend::Beats(0.0), daw_backend::Beats(10.0)); let id3 = instance3.id; let mut action3 = AddEffectAction::at_index(layer_id, instance3, 1); action3.execute(&mut document).unwrap(); diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/loop_clip_instances.rs b/lightningbeam-ui/lightningbeam-core/src/actions/loop_clip_instances.rs index 01fd71e..b275b22 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/loop_clip_instances.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/loop_clip_instances.rs @@ -9,8 +9,9 @@ use crate::layer::AnyLayer; use std::collections::HashMap; use uuid::Uuid; -/// Per-instance loop change: (instance_id, old_timeline_duration, new_timeline_duration, old_loop_before, new_loop_before) -pub type LoopEntry = (Uuid, Option, Option, Option, Option); +/// Per-instance loop change: (instance_id, old_timeline_duration, new_timeline_duration, old_loop_before, new_loop_before). +/// All durations/offsets are in beats. +pub type LoopEntry = (Uuid, Option, Option, Option, Option); /// Action that changes the loop duration of clip instances pub struct LoopClipInstancesAction { @@ -129,10 +130,17 @@ impl LoopClipInstancesAction { let content_window = { let trim_end = instance.trim_end.unwrap_or(clip.duration); - (trim_end - instance.trim_start).max(0.0) + (trim_end - instance.trim_start).max(0.0) // seconds }; - let right_duration = target_duration.unwrap_or(content_window); - let left_duration = target_loop_before.unwrap_or(0.0); + // Natural content length as a beats span at the clip's start (the + // fallback when no explicit timeline_duration is set). + let tempo_map = document.tempo_map(); + let content_window_beats = tempo_map.seconds_to_beats( + tempo_map.beats_to_seconds(instance.timeline_start) + + daw_backend::Seconds(content_window), + ) - instance.timeline_start; + let right_duration = target_duration.unwrap_or(content_window_beats); + let left_duration = target_loop_before.unwrap_or(daw_backend::Beats::ZERO); let external_duration = left_duration + right_duration; let external_start = instance.timeline_start - left_duration; diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/move_clip_instances.rs b/lightningbeam-ui/lightningbeam-core/src/actions/move_clip_instances.rs index dd70075..6664d3d 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/move_clip_instances.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/move_clip_instances.rs @@ -6,13 +6,15 @@ use crate::action::Action; use crate::clip::ClipInstance; use crate::document::Document; use crate::layer::AnyLayer; +use daw_backend::Beats; use std::collections::HashMap; use uuid::Uuid; /// Action that moves clip instances to new timeline positions pub struct MoveClipInstancesAction { - /// Map of layer IDs to vectors of (clip_instance_id, old_timeline_start, new_timeline_start) - layer_moves: HashMap>, + /// Map of layer IDs to vectors of (clip_instance_id, old_timeline_start, new_timeline_start). + /// Timeline positions are in beats. + layer_moves: HashMap>, } impl MoveClipInstancesAction { @@ -20,8 +22,8 @@ impl MoveClipInstancesAction { /// /// # Arguments /// - /// * `layer_moves` - Map of layer IDs to vectors of (clip_instance_id, old_timeline_start, new_timeline_start) - pub fn new(layer_moves: HashMap>) -> Self { + /// * `layer_moves` - Map of layer IDs to vectors of (clip_instance_id, old_timeline_start, new_timeline_start) in beats + pub fn new(layer_moves: HashMap>) -> Self { Self { layer_moves } } } @@ -42,7 +44,7 @@ impl Action for MoveClipInstancesAction { // Check if this instance is in a group if let Some(group) = document.find_group_for_instance(instance_id) { - let offset = new_start - old_start; + let offset = *new_start - *old_start; // Add all group members to the move list for (member_layer_id, member_instance_id) in group.get_members() { @@ -77,7 +79,7 @@ impl Action for MoveClipInstancesAction { } // Auto-adjust moves to avoid overlaps - let mut adjusted_moves: HashMap> = HashMap::new(); + let mut adjusted_moves: HashMap> = HashMap::new(); for (layer_id, moves) in &expanded_moves { let layer = document.get_layer(layer_id) @@ -101,10 +103,10 @@ impl Action for MoveClipInstancesAction { AnyLayer::Text(_) => &[], }; - let group: Vec<(Uuid, f64, f64)> = moves.iter().filter_map(|(id, old_start, _)| { + let group: Vec<(Uuid, Beats, Beats)> = moves.iter().filter_map(|(id, old_start, _)| { let inst = clip_instances.iter().find(|ci| &ci.id == id)?; let dur = document.get_clip_duration(&inst.clip_id)?; - let eff = inst.trim_end.unwrap_or(dur) - inst.trim_start; + let eff = inst.effective_duration_beats(dur, document.tempo_map()); Some((*id, *old_start, eff)) }).collect(); @@ -112,7 +114,7 @@ impl Action for MoveClipInstancesAction { let clamped = document.clamp_group_move_offset(layer_id, &group, desired_offset); for (instance_id, old_start, _) in moves { - adjusted_layer_moves.push((*instance_id, *old_start, (*old_start + clamped).max(0.0))); + adjusted_layer_moves.push((*instance_id, *old_start, (*old_start + clamped).max(Beats::ZERO))); } adjusted_moves.insert(*layer_id, adjusted_layer_moves); @@ -208,7 +210,7 @@ impl Action for MoveClipInstancesAction { if let Some(instance) = vl.clip_instances.iter().find(|ci| ci.id == *instance_id) { // Check if this clip has a metatrack if let Some(&metatrack_id) = backend.layer_to_track_map.get(&instance.clip_id) { - controller.set_offset(metatrack_id, *new_start); + controller.set_offset(metatrack_id, document.tempo_map().beats_to_seconds(*new_start)); controller.set_trim_start(metatrack_id, instance.trim_start); controller.set_trim_end(metatrack_id, instance.trim_end); } @@ -292,7 +294,7 @@ impl Action for MoveClipInstancesAction { for (instance_id, old_start, _new_start) in moves { if let Some(instance) = vl.clip_instances.iter().find(|ci| ci.id == *instance_id) { if let Some(&metatrack_id) = backend.layer_to_track_map.get(&instance.clip_id) { - controller.set_offset(metatrack_id, *old_start); + controller.set_offset(metatrack_id, document.tempo_map().beats_to_seconds(*old_start)); controller.set_trim_start(metatrack_id, instance.trim_start); controller.set_trim_end(metatrack_id, instance.trim_end); } @@ -373,15 +375,15 @@ mod tests { let mut vector_layer = VectorLayer::new("Layer 1"); let mut clip_instance = ClipInstance::new(clip_id); - clip_instance.timeline_start = 1.0; // Start at 1 second + clip_instance.timeline_start = Beats(1.0); // Start at beat 1 let instance_id = clip_instance.id; vector_layer.clip_instances.push(clip_instance); let layer_id = document.root.add_child(AnyLayer::Vector(vector_layer)); - // Create move action: move from 1.0 to 5.0 seconds + // Create move action: move from beat 1 to beat 5 let mut layer_moves = HashMap::new(); - layer_moves.insert(layer_id, vec![(instance_id, 1.0, 5.0)]); + layer_moves.insert(layer_id, vec![(instance_id, Beats(1.0), Beats(5.0))]); let mut action = MoveClipInstancesAction::new(layer_moves); @@ -395,7 +397,7 @@ mod tests { .iter() .find(|ci| ci.id == instance_id) .unwrap(); - assert_eq!(instance.timeline_start, 5.0); + assert_eq!(instance.timeline_start, Beats(5.0)); } // Rollback @@ -408,7 +410,7 @@ mod tests { .iter() .find(|ci| ci.id == instance_id) .unwrap(); - assert_eq!(instance.timeline_start, 1.0); + assert_eq!(instance.timeline_start, Beats(1.0)); } } } diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/remove_clip_instances.rs b/lightningbeam-ui/lightningbeam-core/src/actions/remove_clip_instances.rs index a043c7b..76af639 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/remove_clip_instances.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/remove_clip_instances.rs @@ -172,17 +172,18 @@ impl Action for RemoveClipInstancesAction { let internal_start = instance.trim_start; let internal_end = instance.trim_end.unwrap_or(clip.duration); let external_start = instance.timeline_start; + // MIDI trims are beats-domain, so the fallback span is beats too. let external_duration = instance .timeline_duration - .unwrap_or(internal_end - internal_start); + .unwrap_or(daw_backend::Beats(internal_end - internal_start)); let midi_instance = daw_backend::MidiClipInstance::new( 0, *midi_clip_id, daw_backend::Beats(internal_start), daw_backend::Beats(internal_end), - daw_backend::Beats(external_start), - daw_backend::Beats(external_duration), + external_start, + external_duration, ); let query = Query::AddMidiClipInstanceSync(track_id, midi_instance); @@ -198,16 +199,22 @@ impl Action for RemoveClipInstancesAction { AudioClipType::Sampled { audio_pool_index } => { let internal_start = instance.trim_start; let internal_end = instance.trim_end.unwrap_or(clip.duration); - let effective_duration = instance.timeline_duration - .unwrap_or(internal_end - internal_start); let start_time = instance.timeline_start; + // Fallback span is the content seconds converted to beats at the + // clip's start (not the seconds span treated as beats). + let effective_duration = instance.timeline_duration.unwrap_or_else(|| { + let tempo_map = document.tempo_map(); + let content_secs = daw_backend::Seconds(internal_end - internal_start); + tempo_map.seconds_to_beats(tempo_map.beats_to_seconds(start_time) + content_secs) + - start_time + }); let new_id = controller.add_audio_clip( track_id, *audio_pool_index, start_time, effective_duration, - internal_start, + daw_backend::Seconds(internal_start), ); backend.clip_instance_to_backend_map.insert( instance.id, @@ -238,11 +245,11 @@ mod tests { let mut vector_layer = VectorLayer::new("Layer 1"); let mut ci1 = ClipInstance::new(clip_id); - ci1.timeline_start = 0.0; + ci1.timeline_start = daw_backend::Beats::ZERO; let id1 = ci1.id; let mut ci2 = ClipInstance::new(clip_id); - ci2.timeline_start = 5.0; + ci2.timeline_start = daw_backend::Beats(5.0); let id2 = ci2.id; vector_layer.clip_instances.push(ci1); diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/remove_effect.rs b/lightningbeam-ui/lightningbeam-core/src/actions/remove_effect.rs index 4aeb5fb..7b83df5 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/remove_effect.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/remove_effect.rs @@ -134,7 +134,7 @@ mod tests { let (mut document, layer_id, def) = create_test_setup(); // Add an effect first - let instance = def.create_instance(0.0, 10.0); + let instance = def.create_instance(daw_backend::Beats(0.0), daw_backend::Beats(10.0)); let instance_id = instance.id; if let Some(AnyLayer::Effect(el)) = document.get_layer_mut(&layer_id) { @@ -161,7 +161,7 @@ mod tests { let (mut document, layer_id, def) = create_test_setup(); // Add an effect first - let instance = def.create_instance(0.0, 10.0); + let instance = def.create_instance(daw_backend::Beats(0.0), daw_backend::Beats(10.0)); let instance_id = instance.id; if let Some(AnyLayer::Effect(el)) = document.get_layer_mut(&layer_id) { @@ -185,11 +185,11 @@ mod tests { let (mut document, layer_id, def) = create_test_setup(); // Add three effects - let instance1 = def.create_instance(0.0, 10.0); + let instance1 = def.create_instance(daw_backend::Beats(0.0), daw_backend::Beats(10.0)); let id1 = instance1.id; - let instance2 = def.create_instance(0.0, 10.0); + let instance2 = def.create_instance(daw_backend::Beats(0.0), daw_backend::Beats(10.0)); let id2 = instance2.id; - let instance3 = def.create_instance(0.0, 10.0); + let instance3 = def.create_instance(daw_backend::Beats(0.0), daw_backend::Beats(10.0)); let id3 = instance3.id; if let Some(AnyLayer::Effect(el)) = document.get_layer_mut(&layer_id) { diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/set_keyframe.rs b/lightningbeam-ui/lightningbeam-core/src/actions/set_keyframe.rs index 8f10b6f..961e304 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/set_keyframe.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/set_keyframe.rs @@ -81,9 +81,11 @@ impl Action for SetKeyframeAction { if let Some(AnyLayer::Vector(vl)) = document.get_layer(&self.layer_id) { for clip_id in &self.clip_instance_ids { if let Some(ci) = vl.clip_instances.iter().find(|c| c.id == *clip_id) { + // `start` is a keyframe time in seconds; group_visibility_start returns seconds, + // so the fallback must convert the clip's beats start to seconds too. let start = vl .group_visibility_start(clip_id, self.time) - .unwrap_or(ci.timeline_start); + .unwrap_or_else(|| document.tempo_map().beats_to_seconds(ci.timeline_start).seconds_to_f64()); clip_info.insert(*clip_id, (ci.transform.clone(), ci.opacity, start)); } } diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/split_clip_instance.rs b/lightningbeam-ui/lightningbeam-core/src/actions/split_clip_instance.rs index 5e2101d..fb3095e 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/split_clip_instance.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/split_clip_instance.rs @@ -17,8 +17,8 @@ pub struct SplitClipInstanceAction { /// The clip instance to split instance_id: Uuid, - /// Timeline time where to split (in seconds) - split_time: f64, + /// Timeline position where to split (in beats) + split_time: daw_backend::Beats, /// Whether the action has been executed (for rollback) executed: bool, @@ -26,8 +26,8 @@ pub struct SplitClipInstanceAction { // Stored during execute for rollback /// Original trim_end value of the left (original) instance original_trim_end: Option, - /// Original timeline_duration value of the left (original) instance - original_timeline_duration: Option, + /// Original timeline_duration value of the left (original) instance (beats) + original_timeline_duration: Option, /// ID of the new (right) instance created by the split new_instance_id: Option, @@ -47,8 +47,8 @@ impl SplitClipInstanceAction { /// /// * `layer_id` - The ID of the layer containing the clip instance /// * `instance_id` - The ID of the clip instance to split - /// * `split_time` - The timeline time (in seconds) where to split - pub fn new(layer_id: Uuid, instance_id: Uuid, split_time: f64) -> Self { + /// * `split_time` - The timeline position (in beats) where to split + pub fn new(layer_id: Uuid, instance_id: Uuid, split_time: daw_backend::Beats) -> Self { Self { layer_id, instance_id, @@ -72,9 +72,9 @@ impl SplitClipInstanceAction { /// /// * `layer_id` - The ID of the layer containing the clip instance /// * `instance_id` - The ID of the clip instance to split - /// * `split_time` - The timeline time (in seconds) where to split + /// * `split_time` - The timeline position (in beats) where to split /// * `new_instance_id` - The UUID to use for the new (right) clip instance - pub fn with_new_instance_id(layer_id: Uuid, instance_id: Uuid, split_time: f64, new_instance_id: Uuid) -> Self { + pub fn with_new_instance_id(layer_id: Uuid, instance_id: Uuid, split_time: daw_backend::Beats, new_instance_id: Uuid) -> Self { Self { layer_id, instance_id, @@ -132,9 +132,9 @@ impl Action for SplitClipInstanceAction { let timeline_end = instance.timeline_start + effective_duration; // Validate: split_time must be strictly within the clip's timeline span - const EPSILON: f64 = 0.001; // 1ms tolerance - if self.split_time <= instance.timeline_start + EPSILON - || self.split_time >= timeline_end - EPSILON + let epsilon = daw_backend::Beats(0.001); // ~1ms tolerance + if self.split_time <= instance.timeline_start + epsilon + || self.split_time >= timeline_end - epsilon { return Err(format!( "Split time {} must be within clip bounds ({} to {})", @@ -146,21 +146,27 @@ impl Action for SplitClipInstanceAction { self.original_trim_end = instance.trim_end; self.original_timeline_duration = instance.timeline_duration; - // Check if this is a looping clip + // Check if this is a looping clip. `content_duration` is a trim-domain + // span (seconds), so `clip_duration` must be unwrapped as seconds. let is_looping = instance.timeline_duration.is_some(); - let content_duration = instance.trim_end.unwrap_or(clip_duration) - instance.trim_start; + let content_duration = instance.trim_end.unwrap_or(clip_duration.seconds_to_f64()) - instance.trim_start; - // Calculate the split point + // Timeline split point (beats). let time_into_clip = self.split_time - instance.timeline_start; let left_duration = time_into_clip; let right_duration = effective_duration - left_duration; - // Calculate content split time + // How far the split lands into the clip's *content* (seconds, trim domain). + let tempo_map = document.tempo_map(); + let time_into_clip_secs = (tempo_map.beats_to_seconds(self.split_time) + - tempo_map.beats_to_seconds(instance.timeline_start)).seconds_to_f64(); + + // Calculate content split time (seconds) let content_split_time = if is_looping { // For looping clips, wrap around content - instance.trim_start + (time_into_clip % content_duration) + instance.trim_start + (time_into_clip_secs % content_duration) } else { - instance.trim_start + time_into_clip + instance.trim_start + time_into_clip_secs }; // Clone the instance for the right side @@ -384,17 +390,18 @@ impl Action for SplitClipInstanceAction { let internal_start = new_instance.trim_start; let internal_end = new_instance.trim_end.unwrap_or(clip.duration); let external_start = new_instance.timeline_start; + // MIDI trims are beats-domain, so the fallback span is beats too. let external_duration = new_instance .timeline_duration - .unwrap_or(internal_end - internal_start); + .unwrap_or(daw_backend::Beats(internal_end - internal_start)); let instance = daw_backend::MidiClipInstance::new( 0, *midi_clip_id, daw_backend::Beats(internal_start), daw_backend::Beats(internal_end), - daw_backend::Beats(external_start), - daw_backend::Beats(external_duration), + external_start, + external_duration, ); let query = Query::AddMidiClipInstanceSync(*backend_track_id, instance); @@ -430,16 +437,22 @@ impl Action for SplitClipInstanceAction { // 2. Add the new (right) instance let internal_start = new_instance.trim_start; let internal_end = new_instance.trim_end.unwrap_or(clip.duration); - let effective_duration = new_instance.timeline_duration - .unwrap_or(internal_end - internal_start); let start_time = new_instance.timeline_start; + // Fallback span is the content seconds converted to beats at the + // clip's start (not the seconds span treated as beats). + let effective_duration = new_instance.timeline_duration.unwrap_or_else(|| { + let tempo_map = document.tempo_map(); + let content_secs = daw_backend::Seconds(internal_end - internal_start); + tempo_map.seconds_to_beats(tempo_map.beats_to_seconds(start_time) + content_secs) + - start_time + }); let instance_id = controller.add_audio_clip( *backend_track_id, *audio_pool_index, start_time, effective_duration, - internal_start, + daw_backend::Seconds(internal_start), ); self.backend_track_id = Some(*backend_track_id); @@ -540,7 +553,7 @@ mod tests { // Create a clip instance at timeline 0, with trim 0-10 (10 seconds) let mut clip_instance = ClipInstance::new(clip_id); - clip_instance.timeline_start = 0.0; + clip_instance.timeline_start = daw_backend::Beats::ZERO; clip_instance.trim_start = 0.0; clip_instance.trim_end = Some(10.0); let instance_id = clip_instance.id; @@ -549,7 +562,7 @@ mod tests { let layer_id = document.root.add_child(AnyLayer::Vector(vector_layer)); // Split at timeline 5.0 - let mut action = SplitClipInstanceAction::new(layer_id, instance_id, 5.0); + let mut action = SplitClipInstanceAction::new(layer_id, instance_id, daw_backend::Beats(5.0)); // Execute - this will fail because we don't have a real clip in the document // In a real test, we'd need to add a VectorClip first @@ -559,7 +572,7 @@ mod tests { #[test] fn test_split_action_description() { - let action = SplitClipInstanceAction::new(Uuid::new_v4(), Uuid::new_v4(), 5.0); + let action = SplitClipInstanceAction::new(Uuid::new_v4(), Uuid::new_v4(), daw_backend::Beats(5.0)); assert_eq!(action.description(), "Split clip instance"); } } diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/trim_clip_instances.rs b/lightningbeam-ui/lightningbeam-core/src/actions/trim_clip_instances.rs index 60c1bd7..6d46a66 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/trim_clip_instances.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/trim_clip_instances.rs @@ -6,6 +6,7 @@ use crate::action::Action; use crate::clip::ClipInstance; use crate::document::Document; use crate::layer::AnyLayer; +use daw_backend::{Beats, Seconds}; use std::collections::HashMap; use uuid::Uuid; @@ -32,14 +33,14 @@ pub struct TrimData { /// For TrimLeft: trim_start value /// For TrimRight: trim_end value (Option because it can be None) pub trim_value: Option, - /// For TrimLeft: timeline_start value (where the clip appears on timeline) + /// For TrimLeft: timeline_start value (where the clip appears on timeline, beats) /// For TrimRight: unused (None) - pub timeline_start: Option, + pub timeline_start: Option, } impl TrimData { /// Create TrimData for left trim - pub fn left(trim_start: f64, timeline_start: f64) -> Self { + pub fn left(trim_start: f64, timeline_start: Beats) -> Self { Self { trim_value: Some(trim_start), timeline_start: Some(timeline_start), @@ -203,51 +204,73 @@ impl Action for TrimClipInstancesAction { { // If extending to the left (new_trim < old_trim) if should_validate && new_trim < old_trim { - // Find the maximum we can extend left - let max_extend = document.find_max_trim_extend_left( + // Max we can extend left is the timeline gap to the + // previous clip (beats). + let max_extend_beats = document.find_max_trim_extend_left( layer_id, instance_id, instance.timeline_start, ); + // 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 + // Calculate how much we want to extend (content seconds) let desired_extend = old_trim - new_trim; // Clamp to max allowed - let actual_extend = desired_extend.min(max_extend); + let actual_extend = desired_extend.min(max_extend_secs); let clamped_trim_start = old_trim - actual_extend; - let clamped_timeline_start = (old_timeline - actual_extend).max(0.0); + // Move the timeline left by the same wall-clock seconds. + let clamped_timeline_start = tempo_map + .seconds_to_beats(old_timeline_secs - Seconds(actual_extend)) + .max(Beats::ZERO); clamped_new = TrimData::left(clamped_trim_start, clamped_timeline_start); } } } TrimType::TrimRight => { - let old_trim_end = old.trim_value.unwrap_or(clip_duration); - let new_trim_end = new.trim_value.unwrap_or(clip_duration); + let old_trim_end = old.trim_value.unwrap_or(clip_duration.seconds_to_f64()); + let new_trim_end = new.trim_value.unwrap_or(clip_duration.seconds_to_f64()); // If extending to the right (new_trim_end > old_trim_end) if should_validate && new_trim_end > old_trim_end { - // Calculate current effective duration - let current_effective_duration = old_trim_end - instance.trim_start; + let tempo_map = document.tempo_map(); + // Current effective duration in beats (content seconds + // converted to beats at the clip's start). + let content_secs = Seconds(old_trim_end - instance.trim_start); + let current_effective_duration = tempo_map.seconds_to_beats( + tempo_map.beats_to_seconds(instance.timeline_start) + content_secs, + ) - instance.timeline_start; - // Find the maximum we can extend right - let max_extend = document.find_max_trim_extend_right( + // Max we can extend right is the timeline gap to the next + // clip (beats). + let max_extend_beats = document.find_max_trim_extend_right( layer_id, instance_id, instance.timeline_start, current_effective_duration, ); + // 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 + // Calculate how much we want to extend (content seconds) let desired_extend = new_trim_end - old_trim_end; // Clamp to max allowed - let actual_extend = desired_extend.min(max_extend); + let actual_extend = desired_extend.min(max_extend_secs); let clamped_trim_end = old_trim_end + actual_extend; // Don't exceed clip duration - let final_trim_end = clamped_trim_end.min(clip_duration); + let final_trim_end = clamped_trim_end.min(clip_duration.seconds_to_f64()); clamped_new = TrimData::right(Some(final_trim_end)); } @@ -376,7 +399,7 @@ impl Action for TrimClipInstancesAction { if let Some(instance) = vl.clip_instances.iter().find(|ci| ci.id == *instance_id) { if let Some(&metatrack_id) = backend.layer_to_track_map.get(&instance.clip_id) { // Instance already has new values after execute() - controller.set_offset(metatrack_id, instance.timeline_start); + controller.set_offset(metatrack_id, document.tempo_map().beats_to_seconds(instance.timeline_start)); controller.set_trim_start(metatrack_id, instance.trim_start); controller.set_trim_end(metatrack_id, instance.trim_end); } @@ -466,7 +489,7 @@ impl Action for TrimClipInstancesAction { if let Some(instance) = vl.clip_instances.iter().find(|ci| ci.id == *instance_id) { if let Some(&metatrack_id) = backend.layer_to_track_map.get(&instance.clip_id) { // Instance already has old values after rollback() - controller.set_offset(metatrack_id, instance.timeline_start); + controller.set_offset(metatrack_id, document.tempo_map().beats_to_seconds(instance.timeline_start)); controller.set_trim_start(metatrack_id, instance.trim_start); controller.set_trim_end(metatrack_id, instance.trim_end); } @@ -558,7 +581,7 @@ mod tests { let mut vector_layer = VectorLayer::new("Layer 1"); let mut clip_instance = ClipInstance::new(clip_id); - clip_instance.timeline_start = 0.0; + clip_instance.timeline_start = Beats::ZERO; clip_instance.trim_start = 0.0; let instance_id = clip_instance.id; vector_layer.clip_instances.push(clip_instance); @@ -572,8 +595,8 @@ mod tests { vec![( instance_id, TrimType::TrimLeft, - TrimData::left(0.0, 0.0), - TrimData::left(2.0, 2.0), + TrimData::left(0.0, Beats::ZERO), + TrimData::left(2.0, Beats(2.0)), )], ); @@ -590,7 +613,7 @@ mod tests { .find(|ci| ci.id == instance_id) .unwrap(); assert_eq!(instance.trim_start, 2.0); - assert_eq!(instance.timeline_start, 2.0); + assert_eq!(instance.timeline_start, Beats(2.0)); } // Rollback @@ -604,7 +627,7 @@ mod tests { .find(|ci| ci.id == instance_id) .unwrap(); assert_eq!(instance.trim_start, 0.0); - assert_eq!(instance.timeline_start, 0.0); + assert_eq!(instance.timeline_start, Beats::ZERO); } } diff --git a/lightningbeam-ui/lightningbeam-core/src/clip.rs b/lightningbeam-ui/lightningbeam-core/src/clip.rs index 17a306a..2c216df 100644 --- a/lightningbeam-ui/lightningbeam-core/src/clip.rs +++ b/lightningbeam-ui/lightningbeam-core/src/clip.rs @@ -14,6 +14,7 @@ use crate::layer::AnyLayer; use crate::layer_tree::LayerTree; use crate::object::Transform; +use daw_backend::{Beats, Seconds}; use serde::{Deserialize, Serialize}; use std::path::PathBuf; use uuid::Uuid; @@ -110,7 +111,7 @@ impl VectorClip { pub fn content_duration_with(&self, framerate: f64, tempo_map: &crate::tempo_map::TempoMap, clip_duration_fn: impl Fn(&Uuid) -> Option) -> f64 { let frame_duration = 1.0 / framerate; // Work in beats, convert to seconds at the end. - let mut last_beats: Option = None; + let mut last_beats: Option = None; let mut last_secs: Option = None; for layer_node in self.layers.iter() { @@ -126,18 +127,18 @@ impl VectorClip { }; for ci in clip_instances { // Compute end position of this clip instance in beats - let end_beats = if let Some(td_beats) = ci.timeline_duration { + let end_beats: Beats = if let Some(td_beats) = ci.timeline_duration { ci.timeline_start + td_beats } else if let Some(te) = ci.trim_end { let secs = (te - ci.trim_start).max(0.0); - ci.timeline_start + tempo_map.inverse_transform(tempo_map.transform(ci.timeline_start) + secs) - ci.timeline_start + tempo_map.seconds_to_beats(tempo_map.beats_to_seconds(ci.timeline_start) + Seconds(secs)) } else if let Some(clip_dur_secs) = clip_duration_fn(&ci.clip_id) { let secs = (clip_dur_secs - ci.trim_start).max(0.0); - ci.timeline_start + tempo_map.inverse_transform(tempo_map.transform(ci.timeline_start) + secs) - ci.timeline_start + tempo_map.seconds_to_beats(tempo_map.beats_to_seconds(ci.timeline_start) + Seconds(secs)) } else { continue; }; - last_beats = Some(last_beats.map_or(end_beats, |t: f64| t.max(end_beats))); + last_beats = Some(last_beats.map_or(end_beats, |t: Beats| t.max(end_beats))); } // Vector layer keyframes are in seconds @@ -148,7 +149,7 @@ impl VectorClip { } } - let from_clips = last_beats.map(|b| tempo_map.transform(b)); + let from_clips = last_beats.map(|b| tempo_map.beats_to_seconds(b).seconds_to_f64()); let combined = match (from_clips, last_secs) { (Some(a), Some(b)) => Some(a.max(b)), (Some(a), None) => Some(a), @@ -199,7 +200,7 @@ impl VectorClip { for clip_instance in &vector_layer.clip_instances { // Convert parent clip time (seconds) to nested clip local time (seconds). // timeline_start is in beats; convert to seconds using document BPM. - let start_secs = document.tempo_map().transform(clip_instance.timeline_start); + let start_secs = document.tempo_map().beats_to_seconds(clip_instance.timeline_start).seconds_to_f64(); let nested_clip_time = ((clip_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start; // Look up the nested clip definition @@ -647,12 +648,12 @@ pub struct ClipInstance { /// When this instance starts on the timeline, in **beats**. /// Default: 0.0 - pub timeline_start: f64, + pub timeline_start: Beats, /// How long this instance appears on the timeline, in **beats**. /// If set and longer than the trimmed content, the content will loop. /// Default: None (use trimmed clip duration, no looping) - pub timeline_duration: Option, + pub timeline_duration: Option, /// Trim start: offset into the clip's internal content, in **seconds**. /// - For audio: byte-offset into the audio file @@ -679,7 +680,7 @@ pub struct ClipInstance { /// When set, loop iterations are drawn/played before the content start. /// Default: None (no pre-loop) #[serde(default, skip_serializing_if = "Option::is_none")] - pub loop_before: Option, + pub loop_before: Option, } /// High 64-bit sentinel used to identify UUIDs that encode a backend audio clip instance ID. @@ -731,7 +732,7 @@ impl ClipInstance { transform: Transform::default(), opacity: 1.0, name: None, - timeline_start: 0.0, + timeline_start: Beats::ZERO, timeline_duration: None, trim_start: 0.0, trim_end: None, @@ -749,7 +750,7 @@ impl ClipInstance { transform: Transform::default(), opacity: 1.0, name: None, - timeline_start: 0.0, + timeline_start: Beats::ZERO, timeline_duration: None, trim_start: 0.0, trim_end: None, @@ -784,8 +785,8 @@ impl ClipInstance { self } - /// Set timeline position - pub fn with_timeline_start(mut self, timeline_start: f64) -> Self { + /// Set timeline position (beats). + pub fn with_timeline_start(mut self, timeline_start: Beats) -> Self { self.timeline_start = timeline_start; self } @@ -810,79 +811,78 @@ impl ClipInstance { } /// Set explicit timeline duration (in beats) by directly setting `timeline_duration`. - pub fn with_timeline_duration(mut self, duration_beats: f64) -> Self { + pub fn with_timeline_duration(mut self, duration_beats: Beats) -> Self { self.timeline_duration = Some(duration_beats); self } /// Content window size in seconds: `trim_end - trim_start`. /// Used for internal looping calculations. - pub fn content_window_secs(&self, clip_duration_secs: f64) -> f64 { - let end = self.trim_end.unwrap_or(clip_duration_secs); - (end - self.trim_start).max(0.0) + pub fn content_window_secs(&self, clip_duration_secs: Seconds) -> Seconds { + let end = self.trim_end.unwrap_or(clip_duration_secs.seconds_to_f64()); + Seconds((end - self.trim_start).max(0.0)) } /// How long this instance appears on the timeline, in **beats**. /// /// If `timeline_duration` is set, returns that (enabling content looping). /// Otherwise converts the content window from seconds to beats using the tempo map. - pub fn effective_duration_beats(&self, clip_duration_secs: f64, tempo_map: &crate::tempo_map::TempoMap) -> f64 { + pub fn effective_duration_beats(&self, clip_duration_secs: Seconds, tempo_map: &crate::tempo_map::TempoMap) -> Beats { if let Some(td) = self.timeline_duration { return td; } - let window_secs = self.content_window_secs(clip_duration_secs); - let start_secs = tempo_map.transform(self.timeline_start); - tempo_map.inverse_transform(start_secs + window_secs) - self.timeline_start + let window = self.content_window_secs(clip_duration_secs); + let start_secs = tempo_map.beats_to_seconds(self.timeline_start); + tempo_map.seconds_to_beats(start_secs + window) - self.timeline_start } /// Left edge of the clip's visual extent on the timeline, in **beats**. - pub fn effective_start(&self) -> f64 { - self.timeline_start - self.loop_before.unwrap_or(0.0) + pub fn effective_start(&self) -> Beats { + self.timeline_start - self.loop_before.unwrap_or(Beats::ZERO) } /// Total visual duration (loop_before + effective_duration), in **beats**. - pub fn total_duration(&self, clip_duration_secs: f64, tempo_map: &crate::tempo_map::TempoMap) -> f64 { - self.loop_before.unwrap_or(0.0) + self.effective_duration_beats(clip_duration_secs, tempo_map) + pub fn total_duration(&self, clip_duration_secs: Seconds, tempo_map: &crate::tempo_map::TempoMap) -> Beats { + self.loop_before.unwrap_or(Beats::ZERO) + self.effective_duration_beats(clip_duration_secs, tempo_map) } /// Map a playback time (in **seconds**) to clip-local content time (in **seconds**). /// /// Returns `None` if the clip instance is not active at `time_secs`. - pub fn remap_time_secs(&self, time_secs: f64, clip_duration_secs: f64, tempo_map: &crate::tempo_map::TempoMap) -> Option { - let start_secs = tempo_map.transform(self.timeline_start); + pub fn remap_time_secs(&self, time: Seconds, clip_duration_secs: Seconds, tempo_map: &crate::tempo_map::TempoMap) -> Option { + let start_secs = tempo_map.beats_to_seconds(self.timeline_start); let dur_beats = self.effective_duration_beats(clip_duration_secs, tempo_map); - let end_secs = tempo_map.transform(self.timeline_start + dur_beats); + let end_secs = tempo_map.beats_to_seconds(self.timeline_start + dur_beats); - if time_secs < start_secs || time_secs >= end_secs { + if time < start_secs || time >= end_secs { return None; } - let relative_secs = time_secs - start_secs; - let content_time = relative_secs * self.playback_speed; + let content_time = (time - start_secs) * self.playback_speed; let content_window = self.content_window_secs(clip_duration_secs); - if content_window == 0.0 { - return Some(self.trim_start); + if content_window == Seconds::ZERO { + return Some(Seconds(self.trim_start)); } - let looped_time = if content_time > content_window { + let looped = if content_time > content_window { content_time % content_window } else { content_time }; - Some(self.trim_start + looped_time) + Some(Seconds(self.trim_start) + looped) } /// Alias for `remap_time_secs`. #[inline] - pub fn remap_time(&self, time_secs: f64, clip_duration_secs: f64, tempo_map: &crate::tempo_map::TempoMap) -> Option { - self.remap_time_secs(time_secs, clip_duration_secs, tempo_map) + pub fn remap_time(&self, time: Seconds, clip_duration_secs: Seconds, tempo_map: &crate::tempo_map::TempoMap) -> Option { + self.remap_time_secs(time, clip_duration_secs, tempo_map) } /// Alias for `effective_duration_beats`. #[inline] - pub fn effective_duration(&self, clip_duration_secs: f64, tempo_map: &crate::tempo_map::TempoMap) -> f64 { + pub fn effective_duration(&self, clip_duration_secs: Seconds, tempo_map: &crate::tempo_map::TempoMap) -> Beats { self.effective_duration_beats(clip_duration_secs, tempo_map) } @@ -959,7 +959,7 @@ mod tests { assert_eq!(instance.clip_id, clip_id); assert_eq!(instance.opacity, 1.0); - assert_eq!(instance.timeline_start, 0.0); + assert_eq!(instance.timeline_start, Beats::ZERO); assert_eq!(instance.trim_start, 0.0); assert_eq!(instance.trim_end, None); assert_eq!(instance.playback_speed, 1.0); @@ -977,7 +977,7 @@ mod tests { // At 60 BPM the tempo map is identity (1 beat == 1 second), so the // beats-domain effective duration equals the seconds content window. let tempo_map = crate::tempo_map::TempoMap::constant(60.0); - assert_eq!(instance.effective_duration(10.0, &tempo_map), 6.0); + assert_eq!(instance.effective_duration(Seconds(10.0), &tempo_map), Beats(6.0)); } #[test] @@ -990,7 +990,7 @@ mod tests { assert_eq!(instance.trim_end, None); // At 60 BPM the tempo map is identity (1 beat == 1 second). let tempo_map = crate::tempo_map::TempoMap::constant(60.0); - assert_eq!(instance.effective_duration(10.0, &tempo_map), 8.0); + assert_eq!(instance.effective_duration(Seconds(10.0), &tempo_map), Beats(8.0)); } #[test] diff --git a/lightningbeam-ui/lightningbeam-core/src/document.rs b/lightningbeam-ui/lightningbeam-core/src/document.rs index a27b405..f60fe33 100644 --- a/lightningbeam-ui/lightningbeam-core/src/document.rs +++ b/lightningbeam-ui/lightningbeam-core/src/document.rs @@ -5,6 +5,7 @@ use crate::asset_folder::AssetFolderTree; use crate::clip::{AudioClip, ClipInstance, ImageAsset, VideoClip, VectorClip}; +use daw_backend::{Beats, Seconds}; use crate::effect::EffectDefinition; use crate::layer::{AnyLayer, GroupLayer}; use crate::script::ScriptDefinition; @@ -438,20 +439,24 @@ impl Document { /// Returns the end time of the last clip instance across all layers, /// or the document's duration if no clips are found. pub fn calculate_timeline_endpoint(&self) -> f64 { + let tempo_map = self.tempo_map(); + // Accumulated in **beats** (as f64, to keep the recursive helper's `Fn(_, f64) -> f64` + // signature); converted to seconds once at the return. let mut max_end_time: f64 = 0.0; - // Helper function to calculate the end time of a clip instance + // End position of a clip instance, in **beats**. Its trimmed content window is in seconds + // (scaled by playback speed), so convert that seconds span to beats via the tempo map — the + // old code added a seconds duration straight onto the beats start. let calculate_instance_end = |instance: &ClipInstance, clip_duration: f64| -> f64 { - let effective_duration = if let Some(timeline_duration) = instance.timeline_duration { - // Explicit timeline duration set (may include looping) - timeline_duration + let end_beats: Beats = if let Some(timeline_duration) = instance.timeline_duration { + instance.timeline_start + timeline_duration } else { - // Calculate from trim points let trim_end = instance.trim_end.unwrap_or(clip_duration); - let trimmed_duration = trim_end - instance.trim_start; - trimmed_duration / instance.playback_speed // Adjust for playback speed + let trimmed_secs = ((trim_end - instance.trim_start) / instance.playback_speed).max(0.0); + let start_secs = tempo_map.beats_to_seconds(instance.timeline_start); + tempo_map.seconds_to_beats(start_secs + Seconds(trimmed_secs)) }; - instance.timeline_start + effective_duration + end_beats.beats_to_f64() }; // Iterate through all layers to find the maximum end time @@ -484,7 +489,7 @@ impl Document { crate::layer::AnyLayer::Effect(effect_layer) => { for instance in &effect_layer.clip_instances { if let Some(clip_duration) = self.get_clip_duration(&instance.clip_id) { - let end_time = calculate_instance_end(instance, clip_duration); + let end_time = calculate_instance_end(instance, clip_duration.seconds_to_f64()); max_end_time = max_end_time.max(end_time); } } @@ -526,7 +531,7 @@ impl Document { crate::layer::AnyLayer::Effect(el) => { for inst in &el.clip_instances { if let Some(dur) = doc.get_clip_duration(&inst.clip_id) { - *max_end = max_end.max(calc_end(inst, dur)); + *max_end = max_end.max(calc_end(inst, dur.seconds_to_f64())); } } } @@ -544,9 +549,10 @@ impl Document { } } - // Return the maximum end time, or document duration if no clips found + // Return the max end (converting the beats accumulator to seconds), or the document + // duration (already seconds) if no clips were found. if max_end_time > 0.0 { - max_end_time + tempo_map.beats_to_seconds(Beats(max_end_time)).seconds_to_f64() } else { self.duration } @@ -890,13 +896,13 @@ impl Document { /// Searches through all clip libraries to find the clip and return its duration. /// For effect definitions, returns `EFFECT_DURATION` (f64::MAX) since effects /// have infinite internal duration. - pub fn get_clip_duration(&self, clip_id: &Uuid) -> Option { + pub fn get_clip_duration(&self, clip_id: &Uuid) -> Option { if let Some(clip) = self.vector_clips.get(clip_id) { if clip.is_group { - Some(clip.duration) + Some(Seconds(clip.duration)) } else { let tempo_map = self.tempo_map(); - Some(clip.content_duration_with(self.framerate, tempo_map, |id| { + Some(Seconds(clip.content_duration_with(self.framerate, tempo_map, |id| { // Resolve nested clip durations (audio, video, other vector clips) if let Some(vc) = self.vector_clips.get(id) { // Avoid deep recursion — use stored duration for nested vector clips @@ -910,23 +916,23 @@ impl Document { } else { None } - })) + }))) } } else if let Some(clip) = self.video_clips.get(clip_id) { - Some(clip.duration) + Some(Seconds(clip.duration)) } else if let Some(clip) = self.audio_clips.get(clip_id) { - Some(clip.duration) + Some(Seconds(clip.duration)) } else if self.effect_definitions.contains_key(clip_id) { // Effects have infinite internal duration - their timeline length // is controlled by ClipInstance.trim_end - Some(crate::effect::EFFECT_DURATION) + Some(Seconds(crate::effect::EFFECT_DURATION)) } else { None } } - /// Calculate the end time of a clip instance on the timeline - pub fn get_clip_instance_end_time(&self, layer_id: &Uuid, instance_id: &Uuid) -> Option { + /// Calculate the end position of a clip instance on the timeline, in **beats**. + pub fn get_clip_instance_end_time(&self, layer_id: &Uuid, instance_id: &Uuid) -> Option { let layer = self.get_layer(layer_id)?; // Find the clip instance @@ -942,12 +948,8 @@ impl Document { let instance = instances.iter().find(|inst| &inst.id == instance_id)?; let clip_duration = self.get_clip_duration(&instance.clip_id)?; - - let trim_start = instance.trim_start; - let trim_end = instance.trim_end.unwrap_or(clip_duration); - let effective_duration = trim_end - trim_start; - - Some(instance.timeline_start + effective_duration) + // End position on the timeline, in beats (convert the seconds content window via tempo map). + Some(instance.timeline_start + instance.effective_duration_beats(clip_duration, self.tempo_map())) } /// Check if a time range overlaps with any existing clip on the layer @@ -958,8 +960,8 @@ impl Document { pub fn check_overlap_on_layer( &self, layer_id: &Uuid, - start_time: f64, - end_time: f64, + start_time: Beats, + end_time: Beats, exclude_instance_ids: &[Uuid], ) -> (bool, Option) { let Some(layer) = self.get_layer(layer_id) else { @@ -1012,14 +1014,14 @@ impl Document { pub fn find_nearest_valid_position( &self, layer_id: &Uuid, - desired_start: f64, - clip_duration: f64, + desired_start: Beats, + clip_duration: Beats, exclude_instance_ids: &[Uuid], - ) -> Option { + ) -> Option { let layer = self.get_layer(layer_id)?; // Clamp to timeline start (can't go before 0) - let desired_start = desired_start.max(0.0); + let desired_start = desired_start.max(Beats::ZERO); // Vector layers don't need overlap adjustment, but still respect timeline start if matches!(layer, AnyLayer::Vector(_) | AnyLayer::Group(_)) { @@ -1044,7 +1046,7 @@ impl Document { AnyLayer::Text(_) => return Some(desired_start), // Text layers don't have own clips }; - let mut occupied_ranges: Vec<(f64, f64, Uuid)> = Vec::new(); + let mut occupied_ranges: Vec<(Beats, Beats, Uuid)> = Vec::new(); for instance in instances { if exclude_instance_ids.contains(&instance.id) { continue; @@ -1063,7 +1065,7 @@ impl Document { // Find the clip we're overlapping with and try both sides, pick nearest for (occupied_start, occupied_end, _) in &occupied_ranges { if desired_start < *occupied_end && *occupied_start < desired_end { - let mut candidates: Vec = Vec::new(); + let mut candidates: Vec = Vec::new(); // Try snapping to the right (after this clip) let snap_right = *occupied_end; @@ -1079,8 +1081,8 @@ impl Document { } // Try snapping to the left (before this clip) - let snap_left = occupied_start - clip_duration; - if snap_left >= 0.0 { + let snap_left = *occupied_start - clip_duration; + if snap_left >= Beats::ZERO { let (overlaps_left, _) = self.check_overlap_on_layer( layer_id, snap_left, @@ -1095,8 +1097,8 @@ impl Document { // Pick the candidate closest to desired_start if !candidates.is_empty() { candidates.sort_by(|a, b| { - let dist_a = (a - desired_start).abs(); - let dist_b = (b - desired_start).abs(); + let dist_a = (*a - desired_start).abs(); + let dist_b = (*b - desired_start).abs(); dist_a.partial_cmp(&dist_b).unwrap_or(std::cmp::Ordering::Equal) }); return Some(candidates[0]); @@ -1106,7 +1108,7 @@ impl Document { // If no gap found, try placing at timeline start if occupied_ranges.is_empty() || occupied_ranges[0].0 >= clip_duration { - return Some(0.0); + return Some(Beats::ZERO); } // No valid position found @@ -1118,9 +1120,9 @@ impl Document { pub fn clamp_group_move_offset( &self, layer_id: &Uuid, - group: &[(Uuid, f64, f64)], // (instance_id, timeline_start, effective_duration) - desired_offset: f64, - ) -> f64 { + group: &[(Uuid, Beats, Beats)], // (instance_id, timeline_start, effective_duration) in beats + desired_offset: Beats, + ) -> Beats { let Some(layer) = self.get_layer(layer_id) else { return desired_offset; }; @@ -1140,8 +1142,8 @@ impl Document { AnyLayer::Text(_) => &[], }; - // Collect non-group clip ranges - let mut non_group: Vec<(f64, f64)> = Vec::new(); + // Collect non-group clip ranges (beats) + let mut non_group: Vec<(Beats, Beats)> = Vec::new(); for inst in instances { if group_ids.contains(&inst.id) { continue; @@ -1163,12 +1165,12 @@ impl Document { // Check against non-group clips for &(ns, ne) in &non_group { - if clamped < 0.0 { + if clamped < Beats::ZERO { // Moving left: if non-group clip end is between our destination and current start if ne <= start && ne > start + clamped { clamped = clamped.max(ne - start); } - } else if clamped > 0.0 { + } else if clamped > Beats::ZERO { // Moving right: if non-group clip start is between our current end and destination if ns >= end && ns < end + clamped { clamped = clamped.min(ns - end); @@ -1188,8 +1190,8 @@ impl Document { &self, layer_id: &Uuid, instance_id: &Uuid, - current_timeline_start: f64, - ) -> f64 { + current_timeline_start: Beats, + ) -> Beats { let Some(layer) = self.get_layer(layer_id) else { return current_timeline_start; // No limit if layer not found }; @@ -1200,7 +1202,7 @@ impl Document { }; // Find the nearest clip to the left - let mut nearest_end = 0.0; // Can extend to timeline start by default + let mut nearest_end = Beats::ZERO; // Can extend to timeline start by default let instances: &[ClipInstance] = match layer { AnyLayer::Audio(audio) => &audio.clip_instances, @@ -1220,6 +1222,7 @@ impl Document { // Calculate other clip's extent (accounting for loop_before) if let Some(clip_duration) = self.get_clip_duration(&other.clip_id) { let other_end = other.timeline_start + other.effective_duration(clip_duration, self.tempo_map()); + // (clip_duration is Seconds via get_clip_duration; effective_duration converts.) // If this clip is to the left and closer than current nearest if other_end <= current_timeline_start && other_end > nearest_end { @@ -1239,16 +1242,16 @@ impl Document { &self, layer_id: &Uuid, instance_id: &Uuid, - current_timeline_start: f64, - current_effective_duration: f64, - ) -> f64 { + current_timeline_start: Beats, + current_effective_duration: Beats, + ) -> Beats { let Some(layer) = self.get_layer(layer_id) else { - return f64::MAX; // No limit if layer not found + return Beats(f64::MAX); // No limit if layer not found }; // Only check audio, video, and effect layers if matches!(layer, AnyLayer::Vector(_) | AnyLayer::Group(_)) { - return f64::MAX; // No limit for vector/group layers + return Beats(f64::MAX); // No limit for vector/group layers } let instances: &[ClipInstance] = match layer { @@ -1261,7 +1264,7 @@ impl Document { AnyLayer::Text(_) => &[], }; - let mut nearest_start = f64::MAX; + let mut nearest_start = Beats(f64::MAX); let current_end = current_timeline_start + current_effective_duration; for other in instances { @@ -1276,10 +1279,10 @@ impl Document { } } - if nearest_start == f64::MAX { - f64::MAX // No clip to the right, can extend freely + if nearest_start == Beats(f64::MAX) { + Beats(f64::MAX) // No clip to the right, can extend freely } else { - (nearest_start - current_end).max(0.0) // Gap between our end and next clip's start + (nearest_start - current_end).max(Beats::ZERO) // Gap between our end and next clip's start } } /// Find the maximum amount we can extend loop_before to the left without overlapping. @@ -1289,8 +1292,8 @@ impl Document { &self, layer_id: &Uuid, instance_id: &Uuid, - current_effective_start: f64, - ) -> f64 { + current_effective_start: Beats, + ) -> Beats { let Some(layer) = self.get_layer(layer_id) else { return current_effective_start; }; @@ -1309,7 +1312,7 @@ impl Document { AnyLayer::Text(_) => &[], }; - let mut nearest_end = 0.0; + let mut nearest_end = Beats::ZERO; for other in instances { if &other.id == instance_id { diff --git a/lightningbeam-ui/lightningbeam-core/src/effect.rs b/lightningbeam-ui/lightningbeam-core/src/effect.rs index ab5186c..ccabbcd 100644 --- a/lightningbeam-ui/lightningbeam-core/src/effect.rs +++ b/lightningbeam-ui/lightningbeam-core/src/effect.rs @@ -351,7 +351,7 @@ impl EffectDefinition { /// /// * `timeline_start` - When the effect starts on the timeline (seconds) /// * `duration` - How long the effect appears on the timeline (seconds) - pub fn create_instance(&self, timeline_start: f64, duration: f64) -> ClipInstance { + pub fn create_instance(&self, timeline_start: daw_backend::Beats, duration: daw_backend::Beats) -> ClipInstance { ClipInstance::new(self.id) .with_timeline_start(timeline_start) .with_timeline_duration(duration) diff --git a/lightningbeam-ui/lightningbeam-core/src/effect_layer.rs b/lightningbeam-ui/lightningbeam-core/src/effect_layer.rs index 3a830ba..3d52675 100644 --- a/lightningbeam-ui/lightningbeam-core/src/effect_layer.rs +++ b/lightningbeam-ui/lightningbeam-core/src/effect_layer.rs @@ -148,11 +148,11 @@ impl EffectLayer { /// `timeline_start` (beats) to seconds for comparison. pub fn active_clip_instances_at(&self, time_secs: f64, tempo_map: &crate::tempo_map::TempoMap) -> Vec<&ClipInstance> { use crate::effect::EFFECT_DURATION; - let time_beats = tempo_map.inverse_transform(time_secs); + let time_beats = tempo_map.seconds_to_beats(daw_backend::Seconds(time_secs)); self.clip_instances .iter() .filter(|e| { - let end = e.timeline_start + e.effective_duration(EFFECT_DURATION, tempo_map); + let end = e.timeline_start + e.effective_duration(daw_backend::Seconds(EFFECT_DURATION), tempo_map); time_beats >= e.timeline_start && time_beats < end }) .collect() @@ -218,7 +218,7 @@ mod tests { fn test_add_effect() { let mut layer = EffectLayer::new("Effects"); let def = create_test_effect_def(); - let effect = def.create_instance(0.0, 10.0); + let effect = def.create_instance(daw_backend::Beats(0.0), daw_backend::Beats(10.0)); let effect_id = effect.id; let id = layer.add_clip_instance(effect); @@ -233,11 +233,11 @@ mod tests { let def = create_test_effect_def(); // Effect 1: active from 0 to 5 - let effect1 = def.create_instance(0.0, 5.0); + let effect1 = def.create_instance(daw_backend::Beats(0.0), daw_backend::Beats(5.0)); layer.add_clip_instance(effect1); // Effect 2: active from 3 to 10 - let effect2 = def.create_instance(3.0, 7.0); // 3.0 + 7.0 = 10.0 end + let effect2 = def.create_instance(daw_backend::Beats(3.0), daw_backend::Beats(7.0)); // 3.0 + 7.0 = 10.0 end layer.add_clip_instance(effect2); // At 60 BPM the tempo map is identity (1 beat == 1 second), so the @@ -259,11 +259,11 @@ mod tests { let mut layer = EffectLayer::new("Effects"); let def = create_test_effect_def(); - let effect1 = def.create_instance(0.0, 10.0); + let effect1 = def.create_instance(daw_backend::Beats(0.0), daw_backend::Beats(10.0)); let id1 = effect1.id; layer.add_clip_instance(effect1); - let effect2 = def.create_instance(0.0, 10.0); + let effect2 = def.create_instance(daw_backend::Beats(0.0), daw_backend::Beats(10.0)); let id2 = effect2.id; layer.add_clip_instance(effect2); diff --git a/lightningbeam-ui/lightningbeam-core/src/hit_test.rs b/lightningbeam-ui/lightningbeam-core/src/hit_test.rs index 8e647c6..9cc7a92 100644 --- a/lightningbeam-ui/lightningbeam-core/src/hit_test.rs +++ b/lightningbeam-ui/lightningbeam-core/src/hit_test.rs @@ -260,15 +260,15 @@ pub fn hit_test_clip_instances( for clip_instance in clip_instances.iter().rev() { // Check time bounds: skip clip instances not active at this time // timeline_start/instance_end are in beats; convert timeline_time (seconds) to beats. - let clip_duration = document.get_clip_duration(&clip_instance.clip_id).unwrap_or(0.0); + let clip_duration = document.get_clip_duration(&clip_instance.clip_id).unwrap_or(daw_backend::Seconds::ZERO); let instance_end = clip_instance.timeline_start + clip_instance.effective_duration(clip_duration, tempo_map); - let timeline_beats = tempo_map.inverse_transform(timeline_time); + let timeline_beats = tempo_map.seconds_to_beats(daw_backend::Seconds(timeline_time)); if timeline_beats < clip_instance.timeline_start || timeline_beats >= instance_end { continue; } // clip_time is in seconds; offset from clip start (in seconds) + trim_start (seconds) - let start_secs = tempo_map.transform(clip_instance.timeline_start); + let start_secs = tempo_map.beats_to_seconds(clip_instance.timeline_start).seconds_to_f64(); let clip_time = ((timeline_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start; let content_bounds = if let Some(vector_clip) = document.get_vector_clip(&clip_instance.clip_id) { @@ -304,14 +304,14 @@ pub fn hit_test_clip_instances_in_rect( for clip_instance in clip_instances { // Check time bounds: skip clip instances not active at this time // timeline_start/instance_end are in beats; convert timeline_time (seconds) to beats. - let clip_duration = document.get_clip_duration(&clip_instance.clip_id).unwrap_or(0.0); + let clip_duration = document.get_clip_duration(&clip_instance.clip_id).unwrap_or(daw_backend::Seconds::ZERO); let instance_end = clip_instance.timeline_start + clip_instance.effective_duration(clip_duration, tempo_map); - let timeline_beats = tempo_map.inverse_transform(timeline_time); + let timeline_beats = tempo_map.seconds_to_beats(daw_backend::Seconds(timeline_time)); if timeline_beats < clip_instance.timeline_start || timeline_beats >= instance_end { continue; } - let start_secs = tempo_map.transform(clip_instance.timeline_start); + let start_secs = tempo_map.beats_to_seconds(clip_instance.timeline_start).seconds_to_f64(); let clip_time = ((timeline_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start; let content_bounds = if let Some(vector_clip) = document.get_vector_clip(&clip_instance.clip_id) { diff --git a/lightningbeam-ui/lightningbeam-core/src/renderer.rs b/lightningbeam-ui/lightningbeam-core/src/renderer.rs index 01b098d..9c2cbd8 100644 --- a/lightningbeam-ui/lightningbeam-core/src/renderer.rs +++ b/lightningbeam-ui/lightningbeam-core/src/renderer.rs @@ -11,6 +11,7 @@ use crate::animation::TransformProperty; use crate::clip::{ClipInstance, ImageAsset}; use crate::document::Document; +use daw_backend::Seconds; use crate::gpu::BlendMode; use crate::layer::{AnyLayer, LayerTrait, VectorLayer}; use kurbo::Affine; @@ -567,7 +568,8 @@ pub fn render_layer_isolated( let tempo_map = document.tempo_map(); for clip_instance in &video_layer.clip_instances { let Some(video_clip) = document.video_clips.get(&clip_instance.clip_id) else { continue }; - let Some(clip_time) = clip_instance.remap_time(time, video_clip.duration, tempo_map) else { continue }; + let Some(clip_time) = clip_instance.remap_time(Seconds(time), Seconds(video_clip.duration), tempo_map) else { continue }; + let clip_time = clip_time.seconds_to_f64(); let Some(frame) = video_mgr.get_frame(&clip_instance.clip_id, clip_time, target_w, target_h) else { continue }; // Evaluate animated transform properties. @@ -975,7 +977,7 @@ pub fn render_single_clip_instance( .filter(|vc| vc.is_group) .map(|_| { let frame_duration = 1.0 / document.framerate; - vector_layer.group_visibility_end(&clip_instance.id, clip_instance.timeline_start, frame_duration) + vector_layer.group_visibility_end(&clip_instance.id, document.tempo_map().beats_to_seconds(clip_instance.timeline_start).seconds_to_f64(), frame_duration) }); render_clip_instance( @@ -1010,18 +1012,18 @@ fn render_clip_instance( let clip_time = if vector_clip.is_group { // Groups are static — visible from timeline_start to the next keyframe boundary. // timeline_start is in beats; group_end_time is in seconds (render time). - let start_secs = tempo_map.transform(clip_instance.timeline_start); + let start_secs = tempo_map.beats_to_seconds(clip_instance.timeline_start).seconds_to_f64(); let end = group_end_time.unwrap_or(start_secs); if time < start_secs || time >= end { return; } 0.0 } else { - let clip_dur = document.get_clip_duration(&vector_clip.id).unwrap_or(vector_clip.duration); - let Some(t) = clip_instance.remap_time(time, clip_dur, tempo_map) else { + let clip_dur = document.get_clip_duration(&vector_clip.id).unwrap_or(Seconds(vector_clip.duration)); + let Some(t) = clip_instance.remap_time(Seconds(time), clip_dur, tempo_map) else { return; // Clip instance not active at this time }; - t + t.seconds_to_f64() }; // Evaluate animated transform properties @@ -1172,9 +1174,10 @@ fn render_video_layer( // Remap timeline time to clip's internal time let tempo_map = document.tempo_map(); - let Some(clip_time) = clip_instance.remap_time(time, video_clip.duration, tempo_map) else { + let Some(clip_time) = clip_instance.remap_time(Seconds(time), Seconds(video_clip.duration), tempo_map) else { continue; // Clip instance not active at this time }; + let clip_time = clip_time.seconds_to_f64(); // Get video frame from VideoManager at the output (export/preview) resolution. let (target_w, target_h) = video_decode_target(document, base_transform); @@ -1568,7 +1571,7 @@ fn render_vector_layer( .filter(|vc| vc.is_group) .map(|_| { let frame_duration = 1.0 / document.framerate; - layer.group_visibility_end(&clip_instance.id, clip_instance.timeline_start, frame_duration) + layer.group_visibility_end(&clip_instance.id, document.tempo_map().beats_to_seconds(clip_instance.timeline_start).seconds_to_f64(), frame_duration) }); render_clip_instance(document, time, clip_instance, layer_opacity, scene, base_transform, &layer.layer.animation_data, image_cache, video_manager, group_end_time, extract.as_deref_mut()); } @@ -1877,7 +1880,7 @@ fn render_vector_layer_cpu( .filter(|vc| vc.is_group) .map(|_| { let frame_duration = 1.0 / document.framerate; - layer.group_visibility_end(&clip_instance.id, clip_instance.timeline_start, frame_duration) + layer.group_visibility_end(&clip_instance.id, document.tempo_map().beats_to_seconds(clip_instance.timeline_start).seconds_to_f64(), frame_duration) }); render_clip_instance_cpu( document, time, clip_instance, layer_opacity, pixmap, base_transform, @@ -1902,14 +1905,14 @@ fn render_clip_instance_cpu( let tempo_map = document.tempo_map(); let clip_time = if vector_clip.is_group { - let start_secs = tempo_map.transform(clip_instance.timeline_start); + let start_secs = tempo_map.beats_to_seconds(clip_instance.timeline_start).seconds_to_f64(); let end = group_end_time.unwrap_or(start_secs); if time < start_secs || time >= end { return; } 0.0 } else { - let clip_dur = document.get_clip_duration(&vector_clip.id).unwrap_or(vector_clip.duration); - let Some(t) = clip_instance.remap_time(time, clip_dur, tempo_map) else { return }; - t + let clip_dur = document.get_clip_duration(&vector_clip.id).unwrap_or(Seconds(vector_clip.duration)); + let Some(t) = clip_instance.remap_time(Seconds(time), clip_dur, tempo_map) else { return }; + t.seconds_to_f64() }; let transform = &clip_instance.transform;