Timeline types stage 1: make lightningbeam-core Beats/Seconds-typed

Type ClipInstance.timeline_start/timeline_duration/loop_before as Beats and
thread Beats/Seconds through core so the compiler catches the seconds-vs-beats
mismatches behind the audio-clip placement/drag/trim bugs.

Fixes latent mixups surfaced by the types:
- add/remove/split clip instance: the audio "effective duration" fallback was
  the content-seconds span treated as beats (clips stopped early off 60 BPM);
  now converted via the tempo map at the clip's start.
- trim validation: extend-left/right clamped a content-seconds delta against a
  timeline-beats gap (and moved trim_start + timeline_start by the same raw
  amount, assuming 1:1). Now the gap is converted to content seconds and the
  timeline moves by the beats-equivalent.
- split content-split point mixed beats into a seconds trim value.
- set_keyframe: visibility-start fallback returned beats where seconds expected.
- hit_test: timeline_time (s) compared against beats without conversion.

Typed backend boundaries that were passing beats/seconds as bare f64:
add_audio_clip (start/dur Beats, offset Seconds), move_clip/extend_clip (Beats),
set_offset (Seconds). Command enum transport stays f64.

Serialization is unchanged (Beats/Seconds are #[serde(transparent)]).
lightningbeam-core builds; 299 core tests pass. The editor call sites
(recording/drop/paste/drag placement) are updated in stage 2.
This commit is contained in:
Skyler Lehmkuhl 2026-07-10 09:35:48 -04:00
parent b6f43d2e72
commit 053a77cfa1
17 changed files with 313 additions and 249 deletions

View File

@ -1001,20 +1001,20 @@ impl Engine {
let _ = self.event_tx.push(AudioEvent::AudioFileAdded(pool_index, path)); let _ = self.event_tx.push(AudioEvent::AudioFileAdded(pool_index, path));
} }
Command::AddAudioClip(track_id, clip_id, pool_index, start_time, duration, offset) => { Command::AddAudioClip(track_id, clip_id, pool_index, start_time, duration, offset) => {
// Create a new clip instance with the pre-assigned clip_id // Create a new clip instance with the pre-assigned clip_id.
// start_time and duration are in beats; offset (internal_start) is seconds // start_time/duration are beats; offset (internal_start) is seconds.
let start_beats = Beats(start_time); let start_beats = start_time;
let end_beats = Beats(start_time + duration); let end_beats = start_time + duration;
let start_secs = self.tempo_map.beats_to_seconds(start_beats); let start_secs = self.tempo_map.beats_to_seconds(start_beats);
let end_secs = self.tempo_map.beats_to_seconds(end_beats); let end_secs = self.tempo_map.beats_to_seconds(end_beats);
let content_dur_secs = (end_secs - start_secs).seconds_to_f64(); let content_dur_secs = (end_secs - start_secs).seconds_to_f64();
let mut clip = AudioClipInstance::new( let mut clip = AudioClipInstance::new(
clip_id, clip_id,
pool_index, pool_index,
Seconds(offset), offset,
Seconds(offset + content_dur_secs), offset + Seconds(content_dur_secs),
start_beats, start_beats,
Beats(duration), duration,
); );
// If the source is streamed (a compressed audio file, or a video's // 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) /// 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) { 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)); 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) /// 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) /// 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) { 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)); 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 /// Send a generic command to the audio thread
@ -3440,8 +3440,8 @@ impl EngineController {
/// Set metatrack time offset in seconds /// Set metatrack time offset in seconds
/// Positive = shift content later, negative = shift earlier /// Positive = shift content later, negative = shift earlier
pub fn set_offset(&mut self, track_id: TrackId, offset: f64) { pub fn set_offset(&mut self, track_id: TrackId, offset: Seconds) {
let _ = self.command_tx.push(Command::SetOffset(track_id, offset)); let _ = self.command_tx.push(Command::SetOffset(track_id, offset.seconds_to_f64()));
} }
/// Set metatrack pitch shift in semitones (for future use) /// 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) /// 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 /// 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 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)); let _ = self.command_tx.push(Command::AddAudioClip(track_id, clip_id, pool_index, start_time, duration, offset));
clip_id clip_id
} }
/// Add a clip to an audio track with a pre-assigned ID (for undo/redo, restoring deleted clips) /// 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)); let _ = self.command_tx.push(Command::AddAudioClip(track_id, clip_id, pool_index, start_time, duration, offset));
} }

View File

@ -71,7 +71,8 @@ pub enum Command {
AddAudioFile(String, Vec<f32>, u32, u32), AddAudioFile(String, Vec<f32>, u32, u32),
/// Add a clip to an audio track (track_id, clip_id, pool_index, start_time, duration, offset) /// 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()) /// 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 // MIDI commands
/// Create a new MIDI track with a name and optional parent group /// Create a new MIDI track with a name and optional parent group

View File

@ -60,13 +60,14 @@ impl AddClipInstanceAction {
impl Action for AddClipInstanceAction { impl Action for AddClipInstanceAction {
fn execute(&mut self, document: &mut Document) -> Result<(), String> { 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) let clip_duration = document.get_clip_duration(&self.clip_instance.clip_id)
.ok_or_else(|| format!("Clip {} not found", self.clip_instance.clip_id))?; .ok_or_else(|| format!("Clip {} not found", self.clip_instance.clip_id))?;
let effective_duration = self.clip_instance
let trim_start = self.clip_instance.trim_start; .effective_duration_beats(clip_duration, document.tempo_map());
let trim_end = self.clip_instance.trim_end.unwrap_or(clip_duration);
let effective_duration = trim_end - trim_start;
// Auto-adjust position for audio/video layers to avoid overlaps // Auto-adjust position for audio/video layers to avoid overlaps
let adjusted_start = document.find_nearest_valid_position( 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 internal_end = self.clip_instance.trim_end.unwrap_or(clip.duration);
let external_start = self.clip_instance.timeline_start; 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 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 // Create MidiClipInstance
let instance = daw_backend::MidiClipInstance::new( let instance = daw_backend::MidiClipInstance::new(
@ -210,8 +212,8 @@ impl Action for AddClipInstanceAction {
*midi_clip_id, *midi_clip_id,
daw_backend::Beats(internal_start), daw_backend::Beats(internal_start),
daw_backend::Beats(internal_end), daw_backend::Beats(internal_end),
daw_backend::Beats(external_start), external_start,
daw_backend::Beats(external_duration), external_duration,
); );
// Send query to add instance and get instance ID // 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). // 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 effective_duration = self.clip_instance.timeline_duration.unwrap_or_else(|| {
let tempo_map = document.tempo_map(); let tempo_map = document.tempo_map();
let content_secs = internal_end - internal_start; let content_secs = daw_backend::Seconds(internal_end - internal_start);
tempo_map.inverse_transform(tempo_map.transform(start_time) + content_secs) tempo_map.seconds_to_beats(tempo_map.beats_to_seconds(start_time) + content_secs)
- start_time - start_time
}); });
@ -257,7 +259,7 @@ impl Action for AddClipInstanceAction {
*audio_pool_index, *audio_pool_index,
start_time, start_time,
effective_duration, effective_duration,
internal_start, daw_backend::Seconds(internal_start),
); );
self.backend_track_id = Some(*backend_track_id); self.backend_track_id = Some(*backend_track_id);

View File

@ -159,7 +159,7 @@ mod tests {
fn test_add_effect() { fn test_add_effect() {
let (mut document, layer_id, def) = create_test_setup(); 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 instance_id = instance.id;
let mut action = AddEffectAction::new(layer_id, instance); let mut action = AddEffectAction::new(layer_id, instance);
@ -181,7 +181,7 @@ mod tests {
fn test_add_effect_rollback() { fn test_add_effect_rollback() {
let (mut document, layer_id, def) = create_test_setup(); 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); let mut action = AddEffectAction::new(layer_id, instance);
action.execute(&mut document).unwrap(); action.execute(&mut document).unwrap();
@ -201,19 +201,19 @@ mod tests {
let (mut document, layer_id, def) = create_test_setup(); let (mut document, layer_id, def) = create_test_setup();
// Add first effect // 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 id1 = instance1.id;
let mut action1 = AddEffectAction::new(layer_id, instance1); let mut action1 = AddEffectAction::new(layer_id, instance1);
action1.execute(&mut document).unwrap(); action1.execute(&mut document).unwrap();
// Add second effect // 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 id2 = instance2.id;
let mut action2 = AddEffectAction::new(layer_id, instance2); let mut action2 = AddEffectAction::new(layer_id, instance2);
action2.execute(&mut document).unwrap(); action2.execute(&mut document).unwrap();
// Insert third effect at index 1 (between first and second) // 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 id3 = instance3.id;
let mut action3 = AddEffectAction::at_index(layer_id, instance3, 1); let mut action3 = AddEffectAction::at_index(layer_id, instance3, 1);
action3.execute(&mut document).unwrap(); action3.execute(&mut document).unwrap();

View File

@ -9,8 +9,9 @@ use crate::layer::AnyLayer;
use std::collections::HashMap; use std::collections::HashMap;
use uuid::Uuid; use uuid::Uuid;
/// Per-instance loop change: (instance_id, old_timeline_duration, new_timeline_duration, old_loop_before, new_loop_before) /// Per-instance loop change: (instance_id, old_timeline_duration, new_timeline_duration, old_loop_before, new_loop_before).
pub type LoopEntry = (Uuid, Option<f64>, Option<f64>, Option<f64>, Option<f64>); /// All durations/offsets are in beats.
pub type LoopEntry = (Uuid, Option<daw_backend::Beats>, Option<daw_backend::Beats>, Option<daw_backend::Beats>, Option<daw_backend::Beats>);
/// Action that changes the loop duration of clip instances /// Action that changes the loop duration of clip instances
pub struct LoopClipInstancesAction { pub struct LoopClipInstancesAction {
@ -129,10 +130,17 @@ impl LoopClipInstancesAction {
let content_window = { let content_window = {
let trim_end = instance.trim_end.unwrap_or(clip.duration); let trim_end = instance.trim_end.unwrap_or(clip.duration);
(trim_end - instance.trim_start).max(0.0) (trim_end - instance.trim_start).max(0.0) // seconds
}; };
let right_duration = target_duration.unwrap_or(content_window); // Natural content length as a beats span at the clip's start (the
let left_duration = target_loop_before.unwrap_or(0.0); // 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_duration = left_duration + right_duration;
let external_start = instance.timeline_start - left_duration; let external_start = instance.timeline_start - left_duration;

View File

@ -6,13 +6,15 @@ use crate::action::Action;
use crate::clip::ClipInstance; use crate::clip::ClipInstance;
use crate::document::Document; use crate::document::Document;
use crate::layer::AnyLayer; use crate::layer::AnyLayer;
use daw_backend::Beats;
use std::collections::HashMap; use std::collections::HashMap;
use uuid::Uuid; use uuid::Uuid;
/// Action that moves clip instances to new timeline positions /// Action that moves clip instances to new timeline positions
pub struct MoveClipInstancesAction { pub struct MoveClipInstancesAction {
/// Map of layer IDs to vectors of (clip_instance_id, old_timeline_start, new_timeline_start) /// Map of layer IDs to vectors of (clip_instance_id, old_timeline_start, new_timeline_start).
layer_moves: HashMap<Uuid, Vec<(Uuid, f64, f64)>>, /// Timeline positions are in beats.
layer_moves: HashMap<Uuid, Vec<(Uuid, Beats, Beats)>>,
} }
impl MoveClipInstancesAction { impl MoveClipInstancesAction {
@ -20,8 +22,8 @@ impl MoveClipInstancesAction {
/// ///
/// # Arguments /// # Arguments
/// ///
/// * `layer_moves` - Map of layer IDs to vectors of (clip_instance_id, old_timeline_start, new_timeline_start) /// * `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<Uuid, Vec<(Uuid, f64, f64)>>) -> Self { pub fn new(layer_moves: HashMap<Uuid, Vec<(Uuid, Beats, Beats)>>) -> Self {
Self { layer_moves } Self { layer_moves }
} }
} }
@ -42,7 +44,7 @@ impl Action for MoveClipInstancesAction {
// Check if this instance is in a group // Check if this instance is in a group
if let Some(group) = document.find_group_for_instance(instance_id) { 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 // Add all group members to the move list
for (member_layer_id, member_instance_id) in group.get_members() { 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 // Auto-adjust moves to avoid overlaps
let mut adjusted_moves: HashMap<Uuid, Vec<(Uuid, f64, f64)>> = HashMap::new(); let mut adjusted_moves: HashMap<Uuid, Vec<(Uuid, Beats, Beats)>> = HashMap::new();
for (layer_id, moves) in &expanded_moves { for (layer_id, moves) in &expanded_moves {
let layer = document.get_layer(layer_id) let layer = document.get_layer(layer_id)
@ -101,10 +103,10 @@ impl Action for MoveClipInstancesAction {
AnyLayer::Text(_) => &[], 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 inst = clip_instances.iter().find(|ci| &ci.id == id)?;
let dur = document.get_clip_duration(&inst.clip_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)) Some((*id, *old_start, eff))
}).collect(); }).collect();
@ -112,7 +114,7 @@ impl Action for MoveClipInstancesAction {
let clamped = document.clamp_group_move_offset(layer_id, &group, desired_offset); let clamped = document.clamp_group_move_offset(layer_id, &group, desired_offset);
for (instance_id, old_start, _) in moves { 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); 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) { if let Some(instance) = vl.clip_instances.iter().find(|ci| ci.id == *instance_id) {
// Check if this clip has a metatrack // Check if this clip has a metatrack
if let Some(&metatrack_id) = backend.layer_to_track_map.get(&instance.clip_id) { 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_start(metatrack_id, instance.trim_start);
controller.set_trim_end(metatrack_id, instance.trim_end); 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 { 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(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) { 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_start(metatrack_id, instance.trim_start);
controller.set_trim_end(metatrack_id, instance.trim_end); 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 vector_layer = VectorLayer::new("Layer 1");
let mut clip_instance = ClipInstance::new(clip_id); 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; let instance_id = clip_instance.id;
vector_layer.clip_instances.push(clip_instance); vector_layer.clip_instances.push(clip_instance);
let layer_id = document.root.add_child(AnyLayer::Vector(vector_layer)); 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(); 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); let mut action = MoveClipInstancesAction::new(layer_moves);
@ -395,7 +397,7 @@ mod tests {
.iter() .iter()
.find(|ci| ci.id == instance_id) .find(|ci| ci.id == instance_id)
.unwrap(); .unwrap();
assert_eq!(instance.timeline_start, 5.0); assert_eq!(instance.timeline_start, Beats(5.0));
} }
// Rollback // Rollback
@ -408,7 +410,7 @@ mod tests {
.iter() .iter()
.find(|ci| ci.id == instance_id) .find(|ci| ci.id == instance_id)
.unwrap(); .unwrap();
assert_eq!(instance.timeline_start, 1.0); assert_eq!(instance.timeline_start, Beats(1.0));
} }
} }
} }

View File

@ -172,17 +172,18 @@ impl Action for RemoveClipInstancesAction {
let internal_start = instance.trim_start; let internal_start = instance.trim_start;
let internal_end = instance.trim_end.unwrap_or(clip.duration); let internal_end = instance.trim_end.unwrap_or(clip.duration);
let external_start = instance.timeline_start; let external_start = instance.timeline_start;
// MIDI trims are beats-domain, so the fallback span is beats too.
let external_duration = instance let external_duration = instance
.timeline_duration .timeline_duration
.unwrap_or(internal_end - internal_start); .unwrap_or(daw_backend::Beats(internal_end - internal_start));
let midi_instance = daw_backend::MidiClipInstance::new( let midi_instance = daw_backend::MidiClipInstance::new(
0, 0,
*midi_clip_id, *midi_clip_id,
daw_backend::Beats(internal_start), daw_backend::Beats(internal_start),
daw_backend::Beats(internal_end), daw_backend::Beats(internal_end),
daw_backend::Beats(external_start), external_start,
daw_backend::Beats(external_duration), external_duration,
); );
let query = Query::AddMidiClipInstanceSync(track_id, midi_instance); let query = Query::AddMidiClipInstanceSync(track_id, midi_instance);
@ -198,16 +199,22 @@ impl Action for RemoveClipInstancesAction {
AudioClipType::Sampled { audio_pool_index } => { AudioClipType::Sampled { audio_pool_index } => {
let internal_start = instance.trim_start; let internal_start = instance.trim_start;
let internal_end = instance.trim_end.unwrap_or(clip.duration); 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; 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( let new_id = controller.add_audio_clip(
track_id, track_id,
*audio_pool_index, *audio_pool_index,
start_time, start_time,
effective_duration, effective_duration,
internal_start, daw_backend::Seconds(internal_start),
); );
backend.clip_instance_to_backend_map.insert( backend.clip_instance_to_backend_map.insert(
instance.id, instance.id,
@ -238,11 +245,11 @@ mod tests {
let mut vector_layer = VectorLayer::new("Layer 1"); let mut vector_layer = VectorLayer::new("Layer 1");
let mut ci1 = ClipInstance::new(clip_id); let mut ci1 = ClipInstance::new(clip_id);
ci1.timeline_start = 0.0; ci1.timeline_start = daw_backend::Beats::ZERO;
let id1 = ci1.id; let id1 = ci1.id;
let mut ci2 = ClipInstance::new(clip_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; let id2 = ci2.id;
vector_layer.clip_instances.push(ci1); vector_layer.clip_instances.push(ci1);

View File

@ -134,7 +134,7 @@ mod tests {
let (mut document, layer_id, def) = create_test_setup(); let (mut document, layer_id, def) = create_test_setup();
// Add an effect first // 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; let instance_id = instance.id;
if let Some(AnyLayer::Effect(el)) = document.get_layer_mut(&layer_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(); let (mut document, layer_id, def) = create_test_setup();
// Add an effect first // 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; let instance_id = instance.id;
if let Some(AnyLayer::Effect(el)) = document.get_layer_mut(&layer_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(); let (mut document, layer_id, def) = create_test_setup();
// Add three effects // 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 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 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; let id3 = instance3.id;
if let Some(AnyLayer::Effect(el)) = document.get_layer_mut(&layer_id) { if let Some(AnyLayer::Effect(el)) = document.get_layer_mut(&layer_id) {

View File

@ -81,9 +81,11 @@ impl Action for SetKeyframeAction {
if let Some(AnyLayer::Vector(vl)) = document.get_layer(&self.layer_id) { if let Some(AnyLayer::Vector(vl)) = document.get_layer(&self.layer_id) {
for clip_id in &self.clip_instance_ids { for clip_id in &self.clip_instance_ids {
if let Some(ci) = vl.clip_instances.iter().find(|c| c.id == *clip_id) { 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 let start = vl
.group_visibility_start(clip_id, self.time) .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)); clip_info.insert(*clip_id, (ci.transform.clone(), ci.opacity, start));
} }
} }

View File

@ -17,8 +17,8 @@ pub struct SplitClipInstanceAction {
/// The clip instance to split /// The clip instance to split
instance_id: Uuid, instance_id: Uuid,
/// Timeline time where to split (in seconds) /// Timeline position where to split (in beats)
split_time: f64, split_time: daw_backend::Beats,
/// Whether the action has been executed (for rollback) /// Whether the action has been executed (for rollback)
executed: bool, executed: bool,
@ -26,8 +26,8 @@ pub struct SplitClipInstanceAction {
// Stored during execute for rollback // Stored during execute for rollback
/// Original trim_end value of the left (original) instance /// Original trim_end value of the left (original) instance
original_trim_end: Option<f64>, original_trim_end: Option<f64>,
/// Original timeline_duration value of the left (original) instance /// Original timeline_duration value of the left (original) instance (beats)
original_timeline_duration: Option<f64>, original_timeline_duration: Option<daw_backend::Beats>,
/// ID of the new (right) instance created by the split /// ID of the new (right) instance created by the split
new_instance_id: Option<Uuid>, new_instance_id: Option<Uuid>,
@ -47,8 +47,8 @@ impl SplitClipInstanceAction {
/// ///
/// * `layer_id` - The ID of the layer containing the clip instance /// * `layer_id` - The ID of the layer containing the clip instance
/// * `instance_id` - The ID of the clip instance to split /// * `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
pub fn new(layer_id: Uuid, instance_id: Uuid, split_time: f64) -> Self { pub fn new(layer_id: Uuid, instance_id: Uuid, split_time: daw_backend::Beats) -> Self {
Self { Self {
layer_id, layer_id,
instance_id, instance_id,
@ -72,9 +72,9 @@ impl SplitClipInstanceAction {
/// ///
/// * `layer_id` - The ID of the layer containing the clip instance /// * `layer_id` - The ID of the layer containing the clip instance
/// * `instance_id` - The ID of the clip instance to split /// * `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 /// * `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 { Self {
layer_id, layer_id,
instance_id, instance_id,
@ -132,9 +132,9 @@ impl Action for SplitClipInstanceAction {
let timeline_end = instance.timeline_start + effective_duration; let timeline_end = instance.timeline_start + effective_duration;
// Validate: split_time must be strictly within the clip's timeline span // Validate: split_time must be strictly within the clip's timeline span
const EPSILON: f64 = 0.001; // 1ms tolerance let epsilon = daw_backend::Beats(0.001); // ~1ms tolerance
if self.split_time <= instance.timeline_start + EPSILON if self.split_time <= instance.timeline_start + epsilon
|| self.split_time >= timeline_end - EPSILON || self.split_time >= timeline_end - epsilon
{ {
return Err(format!( return Err(format!(
"Split time {} must be within clip bounds ({} to {})", "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_trim_end = instance.trim_end;
self.original_timeline_duration = instance.timeline_duration; 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 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 time_into_clip = self.split_time - instance.timeline_start;
let left_duration = time_into_clip; let left_duration = time_into_clip;
let right_duration = effective_duration - left_duration; 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 { let content_split_time = if is_looping {
// For looping clips, wrap around content // For looping clips, wrap around content
instance.trim_start + (time_into_clip % content_duration) instance.trim_start + (time_into_clip_secs % content_duration)
} else { } else {
instance.trim_start + time_into_clip instance.trim_start + time_into_clip_secs
}; };
// Clone the instance for the right side // Clone the instance for the right side
@ -384,17 +390,18 @@ impl Action for SplitClipInstanceAction {
let internal_start = new_instance.trim_start; let internal_start = new_instance.trim_start;
let internal_end = new_instance.trim_end.unwrap_or(clip.duration); let internal_end = new_instance.trim_end.unwrap_or(clip.duration);
let external_start = new_instance.timeline_start; let external_start = new_instance.timeline_start;
// MIDI trims are beats-domain, so the fallback span is beats too.
let external_duration = new_instance let external_duration = new_instance
.timeline_duration .timeline_duration
.unwrap_or(internal_end - internal_start); .unwrap_or(daw_backend::Beats(internal_end - internal_start));
let instance = daw_backend::MidiClipInstance::new( let instance = daw_backend::MidiClipInstance::new(
0, 0,
*midi_clip_id, *midi_clip_id,
daw_backend::Beats(internal_start), daw_backend::Beats(internal_start),
daw_backend::Beats(internal_end), daw_backend::Beats(internal_end),
daw_backend::Beats(external_start), external_start,
daw_backend::Beats(external_duration), external_duration,
); );
let query = Query::AddMidiClipInstanceSync(*backend_track_id, instance); let query = Query::AddMidiClipInstanceSync(*backend_track_id, instance);
@ -430,16 +437,22 @@ impl Action for SplitClipInstanceAction {
// 2. Add the new (right) instance // 2. Add the new (right) instance
let internal_start = new_instance.trim_start; let internal_start = new_instance.trim_start;
let internal_end = new_instance.trim_end.unwrap_or(clip.duration); 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; 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( let instance_id = controller.add_audio_clip(
*backend_track_id, *backend_track_id,
*audio_pool_index, *audio_pool_index,
start_time, start_time,
effective_duration, effective_duration,
internal_start, daw_backend::Seconds(internal_start),
); );
self.backend_track_id = Some(*backend_track_id); 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) // Create a clip instance at timeline 0, with trim 0-10 (10 seconds)
let mut clip_instance = ClipInstance::new(clip_id); 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_start = 0.0;
clip_instance.trim_end = Some(10.0); clip_instance.trim_end = Some(10.0);
let instance_id = clip_instance.id; let instance_id = clip_instance.id;
@ -549,7 +562,7 @@ mod tests {
let layer_id = document.root.add_child(AnyLayer::Vector(vector_layer)); let layer_id = document.root.add_child(AnyLayer::Vector(vector_layer));
// Split at timeline 5.0 // 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 // 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 // In a real test, we'd need to add a VectorClip first
@ -559,7 +572,7 @@ mod tests {
#[test] #[test]
fn test_split_action_description() { 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"); assert_eq!(action.description(), "Split clip instance");
} }
} }

View File

@ -6,6 +6,7 @@ use crate::action::Action;
use crate::clip::ClipInstance; use crate::clip::ClipInstance;
use crate::document::Document; use crate::document::Document;
use crate::layer::AnyLayer; use crate::layer::AnyLayer;
use daw_backend::{Beats, Seconds};
use std::collections::HashMap; use std::collections::HashMap;
use uuid::Uuid; use uuid::Uuid;
@ -32,14 +33,14 @@ pub struct TrimData {
/// For TrimLeft: trim_start value /// For TrimLeft: trim_start value
/// For TrimRight: trim_end value (Option because it can be None) /// For TrimRight: trim_end value (Option because it can be None)
pub trim_value: Option<f64>, pub trim_value: Option<f64>,
/// 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) /// For TrimRight: unused (None)
pub timeline_start: Option<f64>, pub timeline_start: Option<Beats>,
} }
impl TrimData { impl TrimData {
/// Create TrimData for left trim /// 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 { Self {
trim_value: Some(trim_start), trim_value: Some(trim_start),
timeline_start: Some(timeline_start), timeline_start: Some(timeline_start),
@ -203,51 +204,73 @@ 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 {
// Find the maximum we can extend left // Max we can extend left is the timeline gap to the
let max_extend = document.find_max_trim_extend_left( // previous clip (beats).
let max_extend_beats = document.find_max_trim_extend_left(
layer_id, layer_id,
instance_id, instance_id,
instance.timeline_start, 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; let desired_extend = old_trim - new_trim;
// Clamp to max allowed // 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_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); clamped_new = TrimData::left(clamped_trim_start, clamped_timeline_start);
} }
} }
} }
TrimType::TrimRight => { TrimType::TrimRight => {
let old_trim_end = old.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); 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 extending to the right (new_trim_end > old_trim_end)
if should_validate && new_trim_end > old_trim_end { if should_validate && new_trim_end > old_trim_end {
// Calculate current effective duration let tempo_map = document.tempo_map();
let current_effective_duration = old_trim_end - instance.trim_start; // 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 // Max we can extend right is the timeline gap to the next
let max_extend = document.find_max_trim_extend_right( // clip (beats).
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,
); );
// 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; let desired_extend = new_trim_end - old_trim_end;
// Clamp to max allowed // 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; let clamped_trim_end = old_trim_end + actual_extend;
// Don't exceed clip duration // 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)); 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(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) { if let Some(&metatrack_id) = backend.layer_to_track_map.get(&instance.clip_id) {
// Instance already has new values after execute() // 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_start(metatrack_id, instance.trim_start);
controller.set_trim_end(metatrack_id, instance.trim_end); 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(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) { if let Some(&metatrack_id) = backend.layer_to_track_map.get(&instance.clip_id) {
// Instance already has old values after rollback() // 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_start(metatrack_id, instance.trim_start);
controller.set_trim_end(metatrack_id, instance.trim_end); 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 vector_layer = VectorLayer::new("Layer 1");
let mut clip_instance = ClipInstance::new(clip_id); 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; clip_instance.trim_start = 0.0;
let instance_id = clip_instance.id; let instance_id = clip_instance.id;
vector_layer.clip_instances.push(clip_instance); vector_layer.clip_instances.push(clip_instance);
@ -572,8 +595,8 @@ mod tests {
vec![( vec![(
instance_id, instance_id,
TrimType::TrimLeft, TrimType::TrimLeft,
TrimData::left(0.0, 0.0), TrimData::left(0.0, Beats::ZERO),
TrimData::left(2.0, 2.0), TrimData::left(2.0, Beats(2.0)),
)], )],
); );
@ -590,7 +613,7 @@ mod tests {
.find(|ci| ci.id == instance_id) .find(|ci| ci.id == instance_id)
.unwrap(); .unwrap();
assert_eq!(instance.trim_start, 2.0); assert_eq!(instance.trim_start, 2.0);
assert_eq!(instance.timeline_start, 2.0); assert_eq!(instance.timeline_start, Beats(2.0));
} }
// Rollback // Rollback
@ -604,7 +627,7 @@ mod tests {
.find(|ci| ci.id == instance_id) .find(|ci| ci.id == instance_id)
.unwrap(); .unwrap();
assert_eq!(instance.trim_start, 0.0); assert_eq!(instance.trim_start, 0.0);
assert_eq!(instance.timeline_start, 0.0); assert_eq!(instance.timeline_start, Beats::ZERO);
} }
} }

View File

@ -14,6 +14,7 @@
use crate::layer::AnyLayer; use crate::layer::AnyLayer;
use crate::layer_tree::LayerTree; use crate::layer_tree::LayerTree;
use crate::object::Transform; use crate::object::Transform;
use daw_backend::{Beats, Seconds};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::path::PathBuf; use std::path::PathBuf;
use uuid::Uuid; 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>) -> f64 { pub fn content_duration_with(&self, framerate: f64, tempo_map: &crate::tempo_map::TempoMap, clip_duration_fn: impl Fn(&Uuid) -> Option<f64>) -> f64 {
let frame_duration = 1.0 / framerate; let frame_duration = 1.0 / framerate;
// Work in beats, convert to seconds at the end. // Work in beats, convert to seconds at the end.
let mut last_beats: Option<f64> = None; let mut last_beats: Option<Beats> = None;
let mut last_secs: Option<f64> = None; let mut last_secs: Option<f64> = None;
for layer_node in self.layers.iter() { for layer_node in self.layers.iter() {
@ -126,18 +127,18 @@ impl VectorClip {
}; };
for ci in clip_instances { for ci in clip_instances {
// Compute end position of this clip instance in beats // 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 ci.timeline_start + td_beats
} else if let Some(te) = ci.trim_end { } else if let Some(te) = ci.trim_end {
let secs = (te - ci.trim_start).max(0.0); 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) { } else if let Some(clip_dur_secs) = clip_duration_fn(&ci.clip_id) {
let secs = (clip_dur_secs - ci.trim_start).max(0.0); 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 { } else {
continue; 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 // 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) { let combined = match (from_clips, last_secs) {
(Some(a), Some(b)) => Some(a.max(b)), (Some(a), Some(b)) => Some(a.max(b)),
(Some(a), None) => Some(a), (Some(a), None) => Some(a),
@ -199,7 +200,7 @@ impl VectorClip {
for clip_instance in &vector_layer.clip_instances { for clip_instance in &vector_layer.clip_instances {
// Convert parent clip time (seconds) to nested clip local time (seconds). // Convert parent clip time (seconds) to nested clip local time (seconds).
// timeline_start is in beats; convert to seconds using document BPM. // 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; let nested_clip_time = ((clip_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start;
// Look up the nested clip definition // Look up the nested clip definition
@ -647,12 +648,12 @@ pub struct ClipInstance {
/// When this instance starts on the timeline, in **beats**. /// When this instance starts on the timeline, in **beats**.
/// Default: 0.0 /// Default: 0.0
pub timeline_start: f64, pub timeline_start: Beats,
/// How long this instance appears on the timeline, in **beats**. /// How long this instance appears on the timeline, in **beats**.
/// If set and longer than the trimmed content, the content will loop. /// If set and longer than the trimmed content, the content will loop.
/// Default: None (use trimmed clip duration, no looping) /// Default: None (use trimmed clip duration, no looping)
pub timeline_duration: Option<f64>, pub timeline_duration: Option<Beats>,
/// Trim start: offset into the clip's internal content, in **seconds**. /// Trim start: offset into the clip's internal content, in **seconds**.
/// - For audio: byte-offset into the audio file /// - 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. /// When set, loop iterations are drawn/played before the content start.
/// Default: None (no pre-loop) /// Default: None (no pre-loop)
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub loop_before: Option<f64>, pub loop_before: Option<Beats>,
} }
/// High 64-bit sentinel used to identify UUIDs that encode a backend audio clip instance ID. /// 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(), transform: Transform::default(),
opacity: 1.0, opacity: 1.0,
name: None, name: None,
timeline_start: 0.0, timeline_start: Beats::ZERO,
timeline_duration: None, timeline_duration: None,
trim_start: 0.0, trim_start: 0.0,
trim_end: None, trim_end: None,
@ -749,7 +750,7 @@ impl ClipInstance {
transform: Transform::default(), transform: Transform::default(),
opacity: 1.0, opacity: 1.0,
name: None, name: None,
timeline_start: 0.0, timeline_start: Beats::ZERO,
timeline_duration: None, timeline_duration: None,
trim_start: 0.0, trim_start: 0.0,
trim_end: None, trim_end: None,
@ -784,8 +785,8 @@ impl ClipInstance {
self self
} }
/// Set timeline position /// Set timeline position (beats).
pub fn with_timeline_start(mut self, timeline_start: f64) -> Self { pub fn with_timeline_start(mut self, timeline_start: Beats) -> Self {
self.timeline_start = timeline_start; self.timeline_start = timeline_start;
self self
} }
@ -810,79 +811,78 @@ impl ClipInstance {
} }
/// Set explicit timeline duration (in beats) by directly setting `timeline_duration`. /// 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.timeline_duration = Some(duration_beats);
self self
} }
/// Content window size in seconds: `trim_end - trim_start`. /// Content window size in seconds: `trim_end - trim_start`.
/// Used for internal looping calculations. /// Used for internal looping calculations.
pub fn content_window_secs(&self, clip_duration_secs: f64) -> f64 { pub fn content_window_secs(&self, clip_duration_secs: Seconds) -> Seconds {
let end = self.trim_end.unwrap_or(clip_duration_secs); let end = self.trim_end.unwrap_or(clip_duration_secs.seconds_to_f64());
(end - self.trim_start).max(0.0) Seconds((end - self.trim_start).max(0.0))
} }
/// How long this instance appears on the timeline, in **beats**. /// How long this instance appears on the timeline, in **beats**.
/// ///
/// If `timeline_duration` is set, returns that (enabling content looping). /// If `timeline_duration` is set, returns that (enabling content looping).
/// Otherwise converts the content window from seconds to beats using the tempo map. /// 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 { if let Some(td) = self.timeline_duration {
return td; return td;
} }
let window_secs = self.content_window_secs(clip_duration_secs); let window = self.content_window_secs(clip_duration_secs);
let start_secs = tempo_map.transform(self.timeline_start); let start_secs = tempo_map.beats_to_seconds(self.timeline_start);
tempo_map.inverse_transform(start_secs + window_secs) - 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**. /// Left edge of the clip's visual extent on the timeline, in **beats**.
pub fn effective_start(&self) -> f64 { pub fn effective_start(&self) -> Beats {
self.timeline_start - self.loop_before.unwrap_or(0.0) self.timeline_start - self.loop_before.unwrap_or(Beats::ZERO)
} }
/// Total visual duration (loop_before + effective_duration), in **beats**. /// 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 { pub fn total_duration(&self, clip_duration_secs: Seconds, tempo_map: &crate::tempo_map::TempoMap) -> Beats {
self.loop_before.unwrap_or(0.0) + self.effective_duration_beats(clip_duration_secs, tempo_map) 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**). /// 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`. /// 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<f64> { pub fn remap_time_secs(&self, time: Seconds, clip_duration_secs: Seconds, tempo_map: &crate::tempo_map::TempoMap) -> Option<Seconds> {
let start_secs = tempo_map.transform(self.timeline_start); let start_secs = tempo_map.beats_to_seconds(self.timeline_start);
let dur_beats = self.effective_duration_beats(clip_duration_secs, tempo_map); 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; return None;
} }
let relative_secs = time_secs - start_secs; let content_time = (time - start_secs) * self.playback_speed;
let content_time = relative_secs * self.playback_speed;
let content_window = self.content_window_secs(clip_duration_secs); let content_window = self.content_window_secs(clip_duration_secs);
if content_window == 0.0 { if content_window == Seconds::ZERO {
return Some(self.trim_start); 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 content_time % content_window
} else { } else {
content_time content_time
}; };
Some(self.trim_start + looped_time) Some(Seconds(self.trim_start) + looped)
} }
/// Alias for `remap_time_secs`. /// Alias for `remap_time_secs`.
#[inline] #[inline]
pub fn remap_time(&self, time_secs: f64, clip_duration_secs: f64, tempo_map: &crate::tempo_map::TempoMap) -> Option<f64> { pub fn remap_time(&self, time: Seconds, clip_duration_secs: Seconds, tempo_map: &crate::tempo_map::TempoMap) -> Option<Seconds> {
self.remap_time_secs(time_secs, clip_duration_secs, tempo_map) self.remap_time_secs(time, clip_duration_secs, tempo_map)
} }
/// Alias for `effective_duration_beats`. /// Alias for `effective_duration_beats`.
#[inline] #[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) 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.clip_id, clip_id);
assert_eq!(instance.opacity, 1.0); 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_start, 0.0);
assert_eq!(instance.trim_end, None); assert_eq!(instance.trim_end, None);
assert_eq!(instance.playback_speed, 1.0); 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 // At 60 BPM the tempo map is identity (1 beat == 1 second), so the
// beats-domain effective duration equals the seconds content window. // beats-domain effective duration equals the seconds content window.
let tempo_map = crate::tempo_map::TempoMap::constant(60.0); 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] #[test]
@ -990,7 +990,7 @@ mod tests {
assert_eq!(instance.trim_end, None); assert_eq!(instance.trim_end, None);
// At 60 BPM the tempo map is identity (1 beat == 1 second). // At 60 BPM the tempo map is identity (1 beat == 1 second).
let tempo_map = crate::tempo_map::TempoMap::constant(60.0); 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] #[test]

View File

@ -5,6 +5,7 @@
use crate::asset_folder::AssetFolderTree; use crate::asset_folder::AssetFolderTree;
use crate::clip::{AudioClip, ClipInstance, ImageAsset, VideoClip, VectorClip}; use crate::clip::{AudioClip, ClipInstance, ImageAsset, VideoClip, VectorClip};
use daw_backend::{Beats, Seconds};
use crate::effect::EffectDefinition; use crate::effect::EffectDefinition;
use crate::layer::{AnyLayer, GroupLayer}; use crate::layer::{AnyLayer, GroupLayer};
use crate::script::ScriptDefinition; use crate::script::ScriptDefinition;
@ -438,20 +439,24 @@ impl Document {
/// Returns the end time of the last clip instance across all layers, /// Returns the end time of the last clip instance across all layers,
/// or the document's duration if no clips are found. /// or the document's duration if no clips are found.
pub fn calculate_timeline_endpoint(&self) -> f64 { 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; 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 calculate_instance_end = |instance: &ClipInstance, clip_duration: f64| -> f64 {
let effective_duration = if let Some(timeline_duration) = instance.timeline_duration { let end_beats: Beats = if let Some(timeline_duration) = instance.timeline_duration {
// Explicit timeline duration set (may include looping) instance.timeline_start + timeline_duration
timeline_duration
} else { } else {
// Calculate from trim points
let trim_end = instance.trim_end.unwrap_or(clip_duration); let trim_end = instance.trim_end.unwrap_or(clip_duration);
let trimmed_duration = trim_end - instance.trim_start; let trimmed_secs = ((trim_end - instance.trim_start) / instance.playback_speed).max(0.0);
trimmed_duration / instance.playback_speed // Adjust for playback speed 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 // Iterate through all layers to find the maximum end time
@ -484,7 +489,7 @@ impl Document {
crate::layer::AnyLayer::Effect(effect_layer) => { crate::layer::AnyLayer::Effect(effect_layer) => {
for instance in &effect_layer.clip_instances { for instance in &effect_layer.clip_instances {
if let Some(clip_duration) = self.get_clip_duration(&instance.clip_id) { 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); max_end_time = max_end_time.max(end_time);
} }
} }
@ -526,7 +531,7 @@ impl Document {
crate::layer::AnyLayer::Effect(el) => { crate::layer::AnyLayer::Effect(el) => {
for inst in &el.clip_instances { for inst in &el.clip_instances {
if let Some(dur) = doc.get_clip_duration(&inst.clip_id) { 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 { if max_end_time > 0.0 {
max_end_time tempo_map.beats_to_seconds(Beats(max_end_time)).seconds_to_f64()
} else { } else {
self.duration self.duration
} }
@ -890,13 +896,13 @@ impl Document {
/// Searches through all clip libraries to find the clip and return its duration. /// Searches through all clip libraries to find the clip and return its duration.
/// For effect definitions, returns `EFFECT_DURATION` (f64::MAX) since effects /// For effect definitions, returns `EFFECT_DURATION` (f64::MAX) since effects
/// have infinite internal duration. /// have infinite internal duration.
pub fn get_clip_duration(&self, clip_id: &Uuid) -> Option<f64> { pub fn get_clip_duration(&self, clip_id: &Uuid) -> Option<Seconds> {
if let Some(clip) = self.vector_clips.get(clip_id) { if let Some(clip) = self.vector_clips.get(clip_id) {
if clip.is_group { if clip.is_group {
Some(clip.duration) Some(Seconds(clip.duration))
} else { } else {
let tempo_map = self.tempo_map(); 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) // Resolve nested clip durations (audio, video, other vector clips)
if let Some(vc) = self.vector_clips.get(id) { if let Some(vc) = self.vector_clips.get(id) {
// Avoid deep recursion — use stored duration for nested vector clips // Avoid deep recursion — use stored duration for nested vector clips
@ -910,23 +916,23 @@ impl Document {
} else { } else {
None None
} }
})) })))
} }
} else if let Some(clip) = self.video_clips.get(clip_id) { } 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) { } 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) { } else if self.effect_definitions.contains_key(clip_id) {
// Effects have infinite internal duration - their timeline length // Effects have infinite internal duration - their timeline length
// is controlled by ClipInstance.trim_end // is controlled by ClipInstance.trim_end
Some(crate::effect::EFFECT_DURATION) Some(Seconds(crate::effect::EFFECT_DURATION))
} else { } else {
None None
} }
} }
/// Calculate the end time of a clip instance on the timeline /// 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<f64> { pub fn get_clip_instance_end_time(&self, layer_id: &Uuid, instance_id: &Uuid) -> Option<Beats> {
let layer = self.get_layer(layer_id)?; let layer = self.get_layer(layer_id)?;
// Find the clip instance // Find the clip instance
@ -942,12 +948,8 @@ impl Document {
let instance = instances.iter().find(|inst| &inst.id == instance_id)?; let instance = instances.iter().find(|inst| &inst.id == instance_id)?;
let clip_duration = self.get_clip_duration(&instance.clip_id)?; let clip_duration = self.get_clip_duration(&instance.clip_id)?;
// End position on the timeline, in beats (convert the seconds content window via tempo map).
let trim_start = instance.trim_start; Some(instance.timeline_start + instance.effective_duration_beats(clip_duration, self.tempo_map()))
let trim_end = instance.trim_end.unwrap_or(clip_duration);
let effective_duration = trim_end - trim_start;
Some(instance.timeline_start + effective_duration)
} }
/// Check if a time range overlaps with any existing clip on the layer /// 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( pub fn check_overlap_on_layer(
&self, &self,
layer_id: &Uuid, layer_id: &Uuid,
start_time: f64, start_time: Beats,
end_time: f64, end_time: Beats,
exclude_instance_ids: &[Uuid], exclude_instance_ids: &[Uuid],
) -> (bool, Option<Uuid>) { ) -> (bool, Option<Uuid>) {
let Some(layer) = self.get_layer(layer_id) else { let Some(layer) = self.get_layer(layer_id) else {
@ -1012,14 +1014,14 @@ impl Document {
pub fn find_nearest_valid_position( pub fn find_nearest_valid_position(
&self, &self,
layer_id: &Uuid, layer_id: &Uuid,
desired_start: f64, desired_start: Beats,
clip_duration: f64, clip_duration: Beats,
exclude_instance_ids: &[Uuid], exclude_instance_ids: &[Uuid],
) -> Option<f64> { ) -> Option<Beats> {
let layer = self.get_layer(layer_id)?; let layer = self.get_layer(layer_id)?;
// Clamp to timeline start (can't go before 0) // 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 // Vector layers don't need overlap adjustment, but still respect timeline start
if matches!(layer, AnyLayer::Vector(_) | AnyLayer::Group(_)) { 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 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 { for instance in instances {
if exclude_instance_ids.contains(&instance.id) { if exclude_instance_ids.contains(&instance.id) {
continue; continue;
@ -1063,7 +1065,7 @@ impl Document {
// Find the clip we're overlapping with and try both sides, pick nearest // Find the clip we're overlapping with and try both sides, pick nearest
for (occupied_start, occupied_end, _) in &occupied_ranges { for (occupied_start, occupied_end, _) in &occupied_ranges {
if desired_start < *occupied_end && *occupied_start < desired_end { if desired_start < *occupied_end && *occupied_start < desired_end {
let mut candidates: Vec<f64> = Vec::new(); let mut candidates: Vec<Beats> = Vec::new();
// Try snapping to the right (after this clip) // Try snapping to the right (after this clip)
let snap_right = *occupied_end; let snap_right = *occupied_end;
@ -1079,8 +1081,8 @@ impl Document {
} }
// Try snapping to the left (before this clip) // Try snapping to the left (before this clip)
let snap_left = occupied_start - clip_duration; let snap_left = *occupied_start - clip_duration;
if snap_left >= 0.0 { if snap_left >= Beats::ZERO {
let (overlaps_left, _) = self.check_overlap_on_layer( let (overlaps_left, _) = self.check_overlap_on_layer(
layer_id, layer_id,
snap_left, snap_left,
@ -1095,8 +1097,8 @@ impl Document {
// Pick the candidate closest to desired_start // Pick the candidate closest to desired_start
if !candidates.is_empty() { if !candidates.is_empty() {
candidates.sort_by(|a, b| { candidates.sort_by(|a, b| {
let dist_a = (a - desired_start).abs(); let dist_a = (*a - desired_start).abs();
let dist_b = (b - desired_start).abs(); let dist_b = (*b - desired_start).abs();
dist_a.partial_cmp(&dist_b).unwrap_or(std::cmp::Ordering::Equal) dist_a.partial_cmp(&dist_b).unwrap_or(std::cmp::Ordering::Equal)
}); });
return Some(candidates[0]); return Some(candidates[0]);
@ -1106,7 +1108,7 @@ impl Document {
// If no gap found, try placing at timeline start // If no gap found, try placing at timeline start
if occupied_ranges.is_empty() || occupied_ranges[0].0 >= clip_duration { if occupied_ranges.is_empty() || occupied_ranges[0].0 >= clip_duration {
return Some(0.0); return Some(Beats::ZERO);
} }
// No valid position found // No valid position found
@ -1118,9 +1120,9 @@ impl Document {
pub fn clamp_group_move_offset( pub fn clamp_group_move_offset(
&self, &self,
layer_id: &Uuid, layer_id: &Uuid,
group: &[(Uuid, f64, f64)], // (instance_id, timeline_start, effective_duration) group: &[(Uuid, Beats, Beats)], // (instance_id, timeline_start, effective_duration) in beats
desired_offset: f64, desired_offset: Beats,
) -> f64 { ) -> Beats {
let Some(layer) = self.get_layer(layer_id) else { let Some(layer) = self.get_layer(layer_id) else {
return desired_offset; return desired_offset;
}; };
@ -1140,8 +1142,8 @@ impl Document {
AnyLayer::Text(_) => &[], AnyLayer::Text(_) => &[],
}; };
// Collect non-group clip ranges // Collect non-group clip ranges (beats)
let mut non_group: Vec<(f64, f64)> = Vec::new(); let mut non_group: Vec<(Beats, Beats)> = Vec::new();
for inst in instances { for inst in instances {
if group_ids.contains(&inst.id) { if group_ids.contains(&inst.id) {
continue; continue;
@ -1163,12 +1165,12 @@ impl Document {
// Check against non-group clips // Check against non-group clips
for &(ns, ne) in &non_group { 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 // Moving left: if non-group clip end is between our destination and current start
if ne <= start && ne > start + clamped { if ne <= start && ne > start + clamped {
clamped = clamped.max(ne - start); 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 // Moving right: if non-group clip start is between our current end and destination
if ns >= end && ns < end + clamped { if ns >= end && ns < end + clamped {
clamped = clamped.min(ns - end); clamped = clamped.min(ns - end);
@ -1188,8 +1190,8 @@ impl Document {
&self, &self,
layer_id: &Uuid, layer_id: &Uuid,
instance_id: &Uuid, instance_id: &Uuid,
current_timeline_start: f64, current_timeline_start: Beats,
) -> f64 { ) -> Beats {
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 current_timeline_start; // No limit if layer not found
}; };
@ -1200,7 +1202,7 @@ impl Document {
}; };
// Find the nearest clip to the left // 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 { let instances: &[ClipInstance] = match layer {
AnyLayer::Audio(audio) => &audio.clip_instances, AnyLayer::Audio(audio) => &audio.clip_instances,
@ -1220,6 +1222,7 @@ impl Document {
// Calculate other clip's extent (accounting for loop_before) // Calculate other clip's extent (accounting for loop_before)
if let Some(clip_duration) = self.get_clip_duration(&other.clip_id) { if let Some(clip_duration) = self.get_clip_duration(&other.clip_id) {
let other_end = other.timeline_start + other.effective_duration(clip_duration, self.tempo_map()); 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 this clip is to the left and closer than current nearest
if other_end <= current_timeline_start && other_end > nearest_end { if other_end <= current_timeline_start && other_end > nearest_end {
@ -1239,16 +1242,16 @@ impl Document {
&self, &self,
layer_id: &Uuid, layer_id: &Uuid,
instance_id: &Uuid, instance_id: &Uuid,
current_timeline_start: f64, current_timeline_start: Beats,
current_effective_duration: f64, current_effective_duration: Beats,
) -> f64 { ) -> Beats {
let Some(layer) = self.get_layer(layer_id) else { 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 // Only check audio, video, and effect layers
if matches!(layer, AnyLayer::Vector(_) | AnyLayer::Group(_)) { 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 { let instances: &[ClipInstance] = match layer {
@ -1261,7 +1264,7 @@ impl Document {
AnyLayer::Text(_) => &[], AnyLayer::Text(_) => &[],
}; };
let mut nearest_start = f64::MAX; let mut nearest_start = Beats(f64::MAX);
let current_end = current_timeline_start + current_effective_duration; let current_end = current_timeline_start + current_effective_duration;
for other in instances { for other in instances {
@ -1276,10 +1279,10 @@ impl Document {
} }
} }
if nearest_start == f64::MAX { if nearest_start == Beats(f64::MAX) {
f64::MAX // No clip to the right, can extend freely Beats(f64::MAX) // No clip to the right, can extend freely
} else { } 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. /// Find the maximum amount we can extend loop_before to the left without overlapping.
@ -1289,8 +1292,8 @@ impl Document {
&self, &self,
layer_id: &Uuid, layer_id: &Uuid,
instance_id: &Uuid, instance_id: &Uuid,
current_effective_start: f64, current_effective_start: Beats,
) -> f64 { ) -> Beats {
let Some(layer) = self.get_layer(layer_id) else { let Some(layer) = self.get_layer(layer_id) else {
return current_effective_start; return current_effective_start;
}; };
@ -1309,7 +1312,7 @@ impl Document {
AnyLayer::Text(_) => &[], AnyLayer::Text(_) => &[],
}; };
let mut nearest_end = 0.0; let mut nearest_end = Beats::ZERO;
for other in instances { for other in instances {
if &other.id == instance_id { if &other.id == instance_id {

View File

@ -351,7 +351,7 @@ impl EffectDefinition {
/// ///
/// * `timeline_start` - When the effect starts on the timeline (seconds) /// * `timeline_start` - When the effect starts on the timeline (seconds)
/// * `duration` - How long the effect appears 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) ClipInstance::new(self.id)
.with_timeline_start(timeline_start) .with_timeline_start(timeline_start)
.with_timeline_duration(duration) .with_timeline_duration(duration)

View File

@ -148,11 +148,11 @@ impl EffectLayer {
/// `timeline_start` (beats) to seconds for comparison. /// `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> { pub fn active_clip_instances_at(&self, time_secs: f64, tempo_map: &crate::tempo_map::TempoMap) -> Vec<&ClipInstance> {
use crate::effect::EFFECT_DURATION; 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 self.clip_instances
.iter() .iter()
.filter(|e| { .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 time_beats >= e.timeline_start && time_beats < end
}) })
.collect() .collect()
@ -218,7 +218,7 @@ mod tests {
fn test_add_effect() { fn test_add_effect() {
let mut layer = EffectLayer::new("Effects"); let mut layer = EffectLayer::new("Effects");
let def = create_test_effect_def(); 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 effect_id = effect.id;
let id = layer.add_clip_instance(effect); let id = layer.add_clip_instance(effect);
@ -233,11 +233,11 @@ mod tests {
let def = create_test_effect_def(); let def = create_test_effect_def();
// Effect 1: active from 0 to 5 // 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); layer.add_clip_instance(effect1);
// Effect 2: active from 3 to 10 // 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); layer.add_clip_instance(effect2);
// At 60 BPM the tempo map is identity (1 beat == 1 second), so the // 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 mut layer = EffectLayer::new("Effects");
let def = create_test_effect_def(); 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; let id1 = effect1.id;
layer.add_clip_instance(effect1); 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; let id2 = effect2.id;
layer.add_clip_instance(effect2); layer.add_clip_instance(effect2);

View File

@ -260,15 +260,15 @@ pub fn hit_test_clip_instances(
for clip_instance in clip_instances.iter().rev() { for clip_instance in clip_instances.iter().rev() {
// Check time bounds: skip clip instances not active at this time // Check time bounds: skip clip instances not active at this time
// timeline_start/instance_end are in beats; convert timeline_time (seconds) to beats. // 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 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 { if timeline_beats < clip_instance.timeline_start || timeline_beats >= instance_end {
continue; continue;
} }
// clip_time is in seconds; offset from clip start (in seconds) + trim_start (seconds) // 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 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) { 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 { for clip_instance in clip_instances {
// Check time bounds: skip clip instances not active at this time // Check time bounds: skip clip instances not active at this time
// timeline_start/instance_end are in beats; convert timeline_time (seconds) to beats. // 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 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 { if timeline_beats < clip_instance.timeline_start || timeline_beats >= instance_end {
continue; 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 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) { let content_bounds = if let Some(vector_clip) = document.get_vector_clip(&clip_instance.clip_id) {

View File

@ -11,6 +11,7 @@
use crate::animation::TransformProperty; use crate::animation::TransformProperty;
use crate::clip::{ClipInstance, ImageAsset}; use crate::clip::{ClipInstance, ImageAsset};
use crate::document::Document; use crate::document::Document;
use daw_backend::Seconds;
use crate::gpu::BlendMode; use crate::gpu::BlendMode;
use crate::layer::{AnyLayer, LayerTrait, VectorLayer}; use crate::layer::{AnyLayer, LayerTrait, VectorLayer};
use kurbo::Affine; use kurbo::Affine;
@ -567,7 +568,8 @@ pub fn render_layer_isolated(
let tempo_map = document.tempo_map(); let tempo_map = document.tempo_map();
for clip_instance in &video_layer.clip_instances { for clip_instance in &video_layer.clip_instances {
let Some(video_clip) = document.video_clips.get(&clip_instance.clip_id) else { continue }; 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 }; let Some(frame) = video_mgr.get_frame(&clip_instance.clip_id, clip_time, target_w, target_h) else { continue };
// Evaluate animated transform properties. // Evaluate animated transform properties.
@ -975,7 +977,7 @@ pub fn render_single_clip_instance(
.filter(|vc| vc.is_group) .filter(|vc| vc.is_group)
.map(|_| { .map(|_| {
let frame_duration = 1.0 / document.framerate; 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( render_clip_instance(
@ -1010,18 +1012,18 @@ fn render_clip_instance(
let clip_time = if vector_clip.is_group { let clip_time = if vector_clip.is_group {
// Groups are static — visible from timeline_start to the next keyframe boundary. // 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). // 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); let end = group_end_time.unwrap_or(start_secs);
if time < start_secs || time >= end { if time < start_secs || time >= end {
return; return;
} }
0.0 0.0
} else { } else {
let clip_dur = document.get_clip_duration(&vector_clip.id).unwrap_or(vector_clip.duration); let clip_dur = document.get_clip_duration(&vector_clip.id).unwrap_or(Seconds(vector_clip.duration));
let Some(t) = clip_instance.remap_time(time, clip_dur, tempo_map) else { let Some(t) = clip_instance.remap_time(Seconds(time), clip_dur, tempo_map) else {
return; // Clip instance not active at this time return; // Clip instance not active at this time
}; };
t t.seconds_to_f64()
}; };
// Evaluate animated transform properties // Evaluate animated transform properties
@ -1172,9 +1174,10 @@ fn render_video_layer(
// Remap timeline time to clip's internal time // Remap timeline time to clip's internal time
let tempo_map = document.tempo_map(); 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 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. // Get video frame from VideoManager at the output (export/preview) resolution.
let (target_w, target_h) = video_decode_target(document, base_transform); let (target_w, target_h) = video_decode_target(document, base_transform);
@ -1568,7 +1571,7 @@ fn render_vector_layer(
.filter(|vc| vc.is_group) .filter(|vc| vc.is_group)
.map(|_| { .map(|_| {
let frame_duration = 1.0 / document.framerate; 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()); 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) .filter(|vc| vc.is_group)
.map(|_| { .map(|_| {
let frame_duration = 1.0 / document.framerate; 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( render_clip_instance_cpu(
document, time, clip_instance, layer_opacity, pixmap, base_transform, 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 tempo_map = document.tempo_map();
let clip_time = if vector_clip.is_group { 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); let end = group_end_time.unwrap_or(start_secs);
if time < start_secs || time >= end { return; } if time < start_secs || time >= end { return; }
0.0 0.0
} else { } else {
let clip_dur = document.get_clip_duration(&vector_clip.id).unwrap_or(vector_clip.duration); let clip_dur = document.get_clip_duration(&vector_clip.id).unwrap_or(Seconds(vector_clip.duration));
let Some(t) = clip_instance.remap_time(time, clip_dur, tempo_map) else { return }; let Some(t) = clip_instance.remap_time(Seconds(time), clip_dur, tempo_map) else { return };
t t.seconds_to_f64()
}; };
let transform = &clip_instance.transform; let transform = &clip_instance.transform;