Type the time domains: no raw f64 in any time-carrying API

Three bugs in a row came from the same root: a time value crossing an API
boundary as a bare f64, with the caller and the callee disagreeing about
whether it meant seconds or beats. Recording landed at the wrong time,
MIDI clips grew too fast, and a 1-second split played back as half a
second. Each was "obviously" one domain at the call site and read as the
other on the far side. This makes the mismatch a compile error.

Backend API — every time-carrying f64 is gone:
- Commands: Seek/SetOffset/SetTrimStart/SetTrimEnd -> Seconds; MoveClip/
  ExtendClip/CreateMidiClip/AddMidiNote/AddLoadedMidiClip/
  UpdateMidiClipNotes/AddMidiClipSync and all four automation commands ->
  Beats; TrimClip -> TrimRange.
- Events/queries: PlaybackPosition, WaveformChunksReady's time range,
  AudioFileReady::duration, PoolFileInfo, get_playhead_seconds -> Seconds.
- Serialized: MidiClipData::duration and AutomationKeyframeData::time ->
  Beats. Both newtypes are #[serde(transparent)], so the .beam on-disk
  format is unchanged.
- Several controller methods ALREADY took Beats and unwrapped it to shove
  into the command — the newtype was being discarded at the very boundary
  it existed to protect.

TrimRange, for the domain-polymorphic case: a clip's content time is
SECONDS for sampled audio but BEATS for MIDI, so a single newtype can't
express it (there was even a comment in engine.rs saying so, and that
rationalization is what let the bug through). A domain-tagged enum can.
The engine rejects a range whose domain doesn't match the track, and the
range is built from the clip (clip.trim_range()) so callers can't pick
the wrong variant.

ContentTime, for the trim fields: ClipInstance::trim_start/trim_end are
content times, and were the last untyped f64 — the actual root of the
split bug. ContentTime is deliberately a DEAD END: no .to_seconds(), no
.to_beats(), no arithmetic with Seconds or Beats. Content times combine
freely with each other (same clip, same domain — safe), so the ~100
passthrough sites cost nothing; the only exit is resolving against the
clip that knows the domain (AudioClip::resolve_content_time /
Document::resolve_content_time / ClipDuration::same_domain). Mixing
domains no longer compiles.

Two more live bugs the types surfaced:
- ClipInstance::effective_duration_beats took a SECONDS clip duration and
  subtracted trim_start from it. For a TRIMMED MIDI clip that subtracted
  a beats offset from a seconds duration, so the clip's timeline length
  was wrong at any tempo but 60 BPM. Untrimmed clips happened to work,
  which is why it hid. It now takes a ClipDuration and resolves in the
  clip's own domain: beats content carries over directly (tempo-
  invariant), wall-clock content converts at the clip's position.
  Regression test asserts a clip trimmed to beats 2..6 is 4 beats long at
  60/90/120 BPM.
- Trim validation clamped a content-domain trim against a wall-clock gap.
  gap_to_content/content_to_secs now convert at the clip's position.

Also folds two more copies of the backend add-logic into
BackendContext::add_clip_instance (split and remove_clip_instances both
re-add clips), so the trim/duration conversions live in exactly one place
instead of four.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Skyler Lehmkuhl 2026-07-13 21:58:36 -04:00
parent 16e3d676d6
commit a5cdbfd0fd
23 changed files with 738 additions and 565 deletions

View File

@ -572,7 +572,7 @@ impl Engine {
if self.frames_since_last_event >= self.event_interval_frames / self.channels as usize if self.frames_since_last_event >= self.event_interval_frames / self.channels as usize
{ {
// Clamp to 0 during count-in pre-roll (negative playhead = before project start) // Clamp to 0 during count-in pre-roll (negative playhead = before project start)
let position_seconds = self.playhead.max(0) as f64 / self.sample_rate as f64; let position_seconds = Seconds(self.playhead.max(0) as f64 / self.sample_rate as f64);
let _ = self let _ = self
.event_tx .event_tx
.push(AudioEvent::PlaybackPosition(position_seconds)); .push(AudioEvent::PlaybackPosition(position_seconds));
@ -922,7 +922,7 @@ impl Engine {
self.project.stop_all_notes(); self.project.stop_all_notes();
} }
Command::Seek(seconds) => { Command::Seek(seconds) => {
self.playhead = (seconds * self.sample_rate as f64) as i64; self.playhead = (seconds.seconds_to_f64() * self.sample_rate as f64) as i64;
// Clamp to 0 for atomic/disk-reader; negative = count-in pre-roll (no disk reads needed) // Clamp to 0 for atomic/disk-reader; negative = count-in pre-roll (no disk reads needed)
let clamped = self.playhead.max(0) as u64; let clamped = self.playhead.max(0) as u64;
self.playhead_atomic.store(clamped, Ordering::Relaxed); self.playhead_atomic.store(clamped, Ordering::Relaxed);
@ -1014,12 +1014,12 @@ impl Engine {
match self.project.get_track_mut(track_id) { match self.project.get_track_mut(track_id) {
Some(crate::audio::track::TrackNode::Audio(track)) => { Some(crate::audio::track::TrackNode::Audio(track)) => {
if let Some(clip) = track.clips.iter_mut().find(|c| c.id == clip_id) { if let Some(clip) = track.clips.iter_mut().find(|c| c.id == clip_id) {
clip.external_duration = Beats(new_external_duration); clip.external_duration = new_external_duration;
} }
} }
Some(crate::audio::track::TrackNode::Midi(track)) => { Some(crate::audio::track::TrackNode::Midi(track)) => {
if let Some(instance) = track.clip_instances.iter_mut().find(|c| c.clip_id == clip_id) { if let Some(instance) = track.clip_instances.iter_mut().find(|c| c.clip_id == clip_id) {
instance.external_duration = Beats(new_external_duration); instance.external_duration = new_external_duration;
} }
} }
_ => {} _ => {}
@ -1046,7 +1046,7 @@ impl Engine {
} }
Command::SetOffset(track_id, offset) => { Command::SetOffset(track_id, offset) => {
if let Some(crate::audio::track::TrackNode::Group(metatrack)) = self.project.get_track_mut(track_id) { if let Some(crate::audio::track::TrackNode::Group(metatrack)) = self.project.get_track_mut(track_id) {
metatrack.offset = Seconds(offset); metatrack.offset = offset;
} }
} }
Command::SetPitchShift(track_id, semitones) => { Command::SetPitchShift(track_id, semitones) => {
@ -1056,12 +1056,12 @@ impl Engine {
} }
Command::SetTrimStart(track_id, trim_start) => { Command::SetTrimStart(track_id, trim_start) => {
if let Some(crate::audio::track::TrackNode::Group(metatrack)) = self.project.get_track_mut(track_id) { if let Some(crate::audio::track::TrackNode::Group(metatrack)) = self.project.get_track_mut(track_id) {
metatrack.trim_start = Seconds(trim_start.max(0.0)); metatrack.trim_start = Seconds(trim_start.seconds_to_f64().max(0.0));
} }
} }
Command::SetTrimEnd(track_id, trim_end) => { Command::SetTrimEnd(track_id, trim_end) => {
if let Some(crate::audio::track::TrackNode::Group(metatrack)) = self.project.get_track_mut(track_id) { if let Some(crate::audio::track::TrackNode::Group(metatrack)) = self.project.get_track_mut(track_id) {
metatrack.trim_end = trim_end.map(|t| Seconds(t.max(0.0))); metatrack.trim_end = trim_end.map(|t| Seconds(t.seconds_to_f64().max(0.0)));
} }
} }
Command::CreateAudioTrack(name, parent_id) => { Command::CreateAudioTrack(name, parent_id) => {
@ -1115,9 +1115,13 @@ impl Engine {
// Send chunks via MPSC channel (will be forwarded by audio thread) // Send chunks via MPSC channel (will be forwarded by audio thread)
if !chunks.is_empty() { if !chunks.is_empty() {
println!("📤 [BACKGROUND] Generated {} chunks, sending to audio thread (pool {})", chunks.len(), pool_index); println!("📤 [BACKGROUND] Generated {} chunks, sending to audio thread (pool {})", chunks.len(), pool_index);
let event_chunks: Vec<(u32, (f64, f64), Vec<crate::io::WaveformPeak>)> = chunks let event_chunks: Vec<(u32, (Seconds, Seconds), Vec<crate::io::WaveformPeak>)> = chunks
.into_iter() .into_iter()
.map(|chunk| (chunk.chunk_index, chunk.time_range, chunk.peaks)) .map(|chunk| {
// A chunk's time_range is a wall-clock span into the audio file.
let (start, end) = chunk.time_range;
(chunk.chunk_index, (Seconds(start), Seconds(end)), chunk.peaks)
})
.collect(); .collect();
match chunk_tx.send(AudioEvent::WaveformChunksReady { match chunk_tx.send(AudioEvent::WaveformChunksReady {
@ -1181,12 +1185,12 @@ impl Engine {
let clip_id = self.next_midi_clip_id_atomic.fetch_add(1, Ordering::Relaxed); let clip_id = self.next_midi_clip_id_atomic.fetch_add(1, Ordering::Relaxed);
// Create clip content in the pool // Create clip content in the pool
let clip = MidiClip::empty(clip_id, Beats(duration), format!("MIDI Clip {}", clip_id)); let clip = MidiClip::empty(clip_id, duration, format!("MIDI Clip {}", clip_id));
self.project.midi_clip_pool.add_existing_clip(clip); self.project.midi_clip_pool.add_existing_clip(clip);
// Create an instance for this clip on the track // Create an instance for this clip on the track
let instance_id = self.project.next_midi_clip_instance_id(); let instance_id = self.project.next_midi_clip_instance_id();
let instance = MidiClipInstance::from_full_clip(instance_id, clip_id, Beats(duration), Beats(start_time)); let instance = MidiClipInstance::from_full_clip(instance_id, clip_id, duration, start_time);
if let Some(crate::audio::track::TrackNode::Midi(track)) = self.project.get_track_mut(track_id) { if let Some(crate::audio::track::TrackNode::Midi(track)) = self.project.get_track_mut(track_id) {
track.clip_instances.push(instance); track.clip_instances.push(instance);
@ -1201,11 +1205,11 @@ impl Engine {
// Note: clip_id here refers to the clip in the pool, not the instance // Note: clip_id here refers to the clip in the pool, not the instance
if let Some(clip) = self.project.midi_clip_pool.get_clip_mut(clip_id) { if let Some(clip) = self.project.midi_clip_pool.get_clip_mut(clip_id) {
// Timestamp is in beats (canonical) // Timestamp is in beats (canonical)
let note_on = MidiEvent::note_on(Beats(time_offset), 0, note, velocity); let note_on = MidiEvent::note_on(time_offset, 0, note, velocity);
clip.add_event(note_on); clip.add_event(note_on);
// Add note off event // Add note off event
let note_off_time = Beats(time_offset + duration); let note_off_time = time_offset + duration;
let note_off = MidiEvent::note_off(note_off_time, 0, note, 64); let note_off = MidiEvent::note_off(note_off_time, 0, note, 64);
clip.add_event(note_off); clip.add_event(note_off);
} else { } else {
@ -1214,9 +1218,9 @@ impl Engine {
if let Some(instance) = track.clip_instances.iter().find(|c| c.clip_id == clip_id) { if let Some(instance) = track.clip_instances.iter().find(|c| c.clip_id == clip_id) {
let actual_clip_id = instance.clip_id; let actual_clip_id = instance.clip_id;
if let Some(clip) = self.project.midi_clip_pool.get_clip_mut(actual_clip_id) { if let Some(clip) = self.project.midi_clip_pool.get_clip_mut(actual_clip_id) {
let note_on = MidiEvent::note_on(Beats(time_offset), 0, note, velocity); let note_on = MidiEvent::note_on(time_offset, 0, note, velocity);
clip.add_event(note_on); clip.add_event(note_on);
let note_off_time = Beats(time_offset + duration); let note_off_time = time_offset + duration;
let note_off = MidiEvent::note_off(note_off_time, 0, note, 64); let note_off = MidiEvent::note_off(note_off_time, 0, note, 64);
clip.add_event(note_off); clip.add_event(note_off);
} }
@ -1226,7 +1230,7 @@ impl Engine {
} }
Command::AddLoadedMidiClip(track_id, clip, start_time) => { Command::AddLoadedMidiClip(track_id, clip, start_time) => {
// Add a pre-loaded MIDI clip to the track with the given start time // Add a pre-loaded MIDI clip to the track with the given start time
if let Ok(_instance_id) = self.project.add_midi_clip_at(track_id, clip, crate::time::Beats(start_time)) { if let Ok(_instance_id) = self.project.add_midi_clip_at(track_id, clip, start_time) {
// instance positions are already in beats; nothing to sync // instance positions are already in beats; nothing to sync
} }
self.refresh_clip_snapshot(); self.refresh_clip_snapshot();
@ -1240,11 +1244,11 @@ impl Engine {
// Add new events from the notes array // Add new events from the notes array
// Timestamps are in beats (canonical) // Timestamps are in beats (canonical)
for (start_time, note, velocity, duration) in notes { for (start_time, note, velocity, duration) in notes {
let note_on = MidiEvent::note_on(Beats(start_time), 0, note, velocity); let note_on = MidiEvent::note_on(start_time, 0, note, velocity);
clip.events.push(note_on); clip.events.push(note_on);
// Add note off event // Add note off event
let note_off_time = Beats(start_time + duration); let note_off_time = start_time + duration;
let note_off = MidiEvent::note_off(note_off_time, 0, note, 64); let note_off = MidiEvent::note_off(note_off_time, 0, note, 64);
clip.events.push(note_off); clip.events.push(note_off);
} }
@ -1306,7 +1310,7 @@ impl Engine {
} }
Command::AddAutomationPoint(track_id, lane_id, time, value, curve) => { Command::AddAutomationPoint(track_id, lane_id, time, value, curve) => {
// Add an automation point to the specified lane // Add an automation point to the specified lane
let point = crate::audio::AutomationPoint::new(Beats(time), value, curve); let point = crate::audio::AutomationPoint::new(time, value, curve);
match self.project.get_track_mut(track_id) { match self.project.get_track_mut(track_id) {
Some(crate::audio::track::TrackNode::Audio(track)) => { Some(crate::audio::track::TrackNode::Audio(track)) => {
@ -1332,17 +1336,17 @@ impl Engine {
match self.project.get_track_mut(track_id) { match self.project.get_track_mut(track_id) {
Some(crate::audio::track::TrackNode::Audio(track)) => { Some(crate::audio::track::TrackNode::Audio(track)) => {
if let Some(lane) = track.get_automation_lane_mut(lane_id) { if let Some(lane) = track.get_automation_lane_mut(lane_id) {
lane.remove_point_at_time(Beats(time), Beats(tolerance)); lane.remove_point_at_time(time, tolerance);
} }
} }
Some(crate::audio::track::TrackNode::Midi(track)) => { Some(crate::audio::track::TrackNode::Midi(track)) => {
if let Some(lane) = track.get_automation_lane_mut(lane_id) { if let Some(lane) = track.get_automation_lane_mut(lane_id) {
lane.remove_point_at_time(Beats(time), Beats(tolerance)); lane.remove_point_at_time(time, tolerance);
} }
} }
Some(crate::audio::track::TrackNode::Group(group)) => { Some(crate::audio::track::TrackNode::Group(group)) => {
if let Some(lane) = group.get_automation_lane_mut(lane_id) { if let Some(lane) = group.get_automation_lane_mut(lane_id) {
lane.remove_point_at_time(Beats(time), Beats(tolerance)); lane.remove_point_at_time(time, tolerance);
} }
} }
None => {} None => {}
@ -2431,7 +2435,7 @@ impl Engine {
// Downcast to AutomationInputNode using as_any_mut // Downcast to AutomationInputNode using as_any_mut
if let Some(auto_node) = graph_node.node.as_any_mut().downcast_mut::<AutomationInputNode>() { if let Some(auto_node) = graph_node.node.as_any_mut().downcast_mut::<AutomationInputNode>() {
let keyframe = AutomationKeyframe { let keyframe = AutomationKeyframe {
time: Beats(time), time,
value, value,
interpolation, interpolation,
ease_out, ease_out,
@ -2459,7 +2463,7 @@ impl Engine {
if let Some(graph_node) = graph.get_graph_node_mut(node_idx) { if let Some(graph_node) = graph.get_graph_node_mut(node_idx) {
if let Some(auto_node) = graph_node.node.as_any_mut().downcast_mut::<AutomationInputNode>() { if let Some(auto_node) = graph_node.node.as_any_mut().downcast_mut::<AutomationInputNode>() {
auto_node.remove_keyframe_at_time(Beats(time), Beats(0.001)); // 1ms tolerance auto_node.remove_keyframe_at_time(time, Beats(0.001)); // 1ms tolerance
} else { } else {
eprintln!("Node {} is not an AutomationInputNode", node_id); eprintln!("Node {} is not an AutomationInputNode", node_id);
} }
@ -2528,9 +2532,12 @@ impl Engine {
// Send chunks via MPSC channel (will be forwarded by audio thread) // Send chunks via MPSC channel (will be forwarded by audio thread)
if !chunks.is_empty() { if !chunks.is_empty() {
let event_chunks: Vec<(u32, (f64, f64), Vec<crate::io::WaveformPeak>)> = chunks let event_chunks: Vec<(u32, (Seconds, Seconds), Vec<crate::io::WaveformPeak>)> = chunks
.into_iter() .into_iter()
.map(|chunk| (chunk.chunk_index, chunk.time_range, chunk.peaks)) .map(|chunk| {
let (start, end) = chunk.time_range;
(chunk.chunk_index, (Seconds(start), Seconds(end)), chunk.peaks)
})
.collect(); .collect();
let _ = chunk_tx.send(AudioEvent::WaveformChunksReady { let _ = chunk_tx.send(AudioEvent::WaveformChunksReady {
@ -2694,7 +2701,7 @@ impl Engine {
path: path_str, path: path_str,
channels: metadata.channels, channels: metadata.channels,
sample_rate: metadata.sample_rate, sample_rate: metadata.sample_rate,
duration: metadata.duration, duration: Seconds(metadata.duration),
format: metadata.format, format: metadata.format,
}); });
@ -2802,7 +2809,7 @@ impl Engine {
if let Some(clip) = self.project.midi_clip_pool.get_clip(clip_id) { if let Some(clip) = self.project.midi_clip_pool.get_clip(clip_id) {
use crate::command::MidiClipData; use crate::command::MidiClipData;
QueryResponse::MidiClipData(Ok(MidiClipData { QueryResponse::MidiClipData(Ok(MidiClipData {
duration: clip.duration.0, duration: clip.duration,
events: clip.events.clone(), events: clip.events.clone(),
})) }))
} else { } else {
@ -2834,7 +2841,7 @@ impl Engine {
InterpolationType::Hold => "hold", InterpolationType::Hold => "hold",
}.to_string(); }.to_string();
AutomationKeyframeData { AutomationKeyframeData {
time: kf.time.0, time: kf.time,
value: kf.value, value: kf.value,
interpolation: interpolation_str, interpolation: interpolation_str,
ease_out: kf.ease_out, ease_out: kf.ease_out,
@ -3003,7 +3010,11 @@ impl Engine {
} }
Query::GetPoolFileInfo(pool_index) => { Query::GetPoolFileInfo(pool_index) => {
match self.audio_pool.get_file_info(pool_index) { match self.audio_pool.get_file_info(pool_index) {
Some(info) => QueryResponse::PoolFileInfo(Ok(info)), // The pool measures a file's length in wall-clock seconds; name it as such at
// the boundary rather than handing a bare f64 to the UI.
Some((duration, sample_rate, channels)) => {
QueryResponse::PoolFileInfo(Ok((Seconds(duration), sample_rate, channels)))
}
None => QueryResponse::PoolFileInfo(Err(format!("Pool index {} not found", pool_index))), None => QueryResponse::PoolFileInfo(Err(format!("Pool index {} not found", pool_index))),
} }
} }
@ -3050,7 +3061,7 @@ impl Engine {
} }
Query::AddMidiClipSync(track_id, clip, start_time) => { Query::AddMidiClipSync(track_id, clip, start_time) => {
// Add MIDI clip to track and return the instance ID (positions already in beats) // Add MIDI clip to track and return the instance ID (positions already in beats)
let result = match self.project.add_midi_clip_at(track_id, clip, crate::time::Beats(start_time)) { let result = match self.project.add_midi_clip_at(track_id, clip, start_time) {
Ok(instance_id) => QueryResponse::MidiClipInstanceAdded(Ok(instance_id)), Ok(instance_id) => QueryResponse::MidiClipInstanceAdded(Ok(instance_id)),
Err(e) => QueryResponse::MidiClipInstanceAdded(Err(e.to_string())), Err(e) => QueryResponse::MidiClipInstanceAdded(Err(e.to_string())),
}; };
@ -3644,7 +3655,7 @@ impl EngineController {
/// Seek to a specific position in seconds /// Seek to a specific position in seconds
pub fn seek(&mut self, seconds: Seconds) { pub fn seek(&mut self, seconds: Seconds) {
let _ = self.command_tx.push(Command::Seek(seconds.seconds_to_f64())); let _ = self.command_tx.push(Command::Seek(seconds));
} }
/// Set track volume (0.0 = silence, 1.0 = unity gain) /// Set track volume (0.0 = silence, 1.0 = unity gain)
@ -3691,7 +3702,7 @@ 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: Beats) { pub fn extend_clip(&mut self, track_id: TrackId, clip_id: ClipId, new_external_duration: Beats) {
let _ = self.command_tx.push(Command::ExtendClip(track_id, clip_id, new_external_duration.beats_to_f64())); let _ = self.command_tx.push(Command::ExtendClip(track_id, clip_id, new_external_duration));
} }
/// Send a generic command to the audio thread /// Send a generic command to the audio thread
@ -3705,9 +3716,9 @@ impl EngineController {
} }
/// Get current playhead position in seconds /// Get current playhead position in seconds
pub fn get_playhead_seconds(&self) -> f64 { pub fn get_playhead_seconds(&self) -> Seconds {
let frames = self.playhead.load(Ordering::Relaxed); let frames = self.playhead.load(Ordering::Relaxed);
frames as f64 / self.sample_rate as f64 Seconds(frames as f64 / self.sample_rate as f64)
} }
/// Get the shared clip snapshot. The UI can read this each frame to display /// Get the shared clip snapshot. The UI can read this each frame to display
@ -3740,7 +3751,7 @@ 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: Seconds) { pub fn set_offset(&mut self, track_id: TrackId, offset: Seconds) {
let _ = self.command_tx.push(Command::SetOffset(track_id, offset.seconds_to_f64())); let _ = self.command_tx.push(Command::SetOffset(track_id, offset));
} }
/// Set metatrack pitch shift in semitones (for future use) /// Set metatrack pitch shift in semitones (for future use)
@ -3750,12 +3761,12 @@ impl EngineController {
/// Set metatrack trim start in seconds /// Set metatrack trim start in seconds
pub fn set_trim_start(&mut self, track_id: TrackId, trim_start: Seconds) { pub fn set_trim_start(&mut self, track_id: TrackId, trim_start: Seconds) {
let _ = self.command_tx.push(Command::SetTrimStart(track_id, trim_start.seconds_to_f64())); let _ = self.command_tx.push(Command::SetTrimStart(track_id, trim_start));
} }
/// Set metatrack trim end in seconds (None = no end trim) /// Set metatrack trim end in seconds (None = no end trim)
pub fn set_trim_end(&mut self, track_id: TrackId, trim_end: Option<Seconds>) { pub fn set_trim_end(&mut self, track_id: TrackId, trim_end: Option<Seconds>) {
let _ = self.command_tx.push(Command::SetTrimEnd(track_id, trim_end.map(|s| s.seconds_to_f64()))); let _ = self.command_tx.push(Command::SetTrimEnd(track_id, trim_end));
} }
/// Create a new audio track /// Create a new audio track
@ -3909,23 +3920,22 @@ impl EngineController {
pub fn create_midi_clip(&mut self, track_id: TrackId, start_time: Beats, duration: Beats) -> MidiClipId { pub fn create_midi_clip(&mut self, track_id: TrackId, start_time: Beats, duration: Beats) -> MidiClipId {
// Peek at the next clip ID that will be used // Peek at the next clip ID that will be used
let clip_id = self.next_midi_clip_id.load(Ordering::Relaxed); let clip_id = self.next_midi_clip_id.load(Ordering::Relaxed);
let _ = self.command_tx.push(Command::CreateMidiClip(track_id, start_time.beats_to_f64(), duration.beats_to_f64())); let _ = self.command_tx.push(Command::CreateMidiClip(track_id, start_time, duration));
clip_id clip_id
} }
/// Add a MIDI note to a clip /// Add a MIDI note to a clip
pub fn add_midi_note(&mut self, track_id: TrackId, clip_id: MidiClipId, time_offset: Beats, note: u8, velocity: u8, duration: Beats) { pub fn add_midi_note(&mut self, track_id: TrackId, clip_id: MidiClipId, time_offset: Beats, note: u8, velocity: u8, duration: Beats) {
let _ = self.command_tx.push(Command::AddMidiNote(track_id, clip_id, time_offset.beats_to_f64(), note, velocity, duration.beats_to_f64())); let _ = self.command_tx.push(Command::AddMidiNote(track_id, clip_id, time_offset, note, velocity, duration));
} }
/// Add a pre-loaded MIDI clip to a track at the given timeline position (beats) /// Add a pre-loaded MIDI clip to a track at the given timeline position (beats)
pub fn add_loaded_midi_clip(&mut self, track_id: TrackId, clip: MidiClip, start_time: Beats) { pub fn add_loaded_midi_clip(&mut self, track_id: TrackId, clip: MidiClip, start_time: Beats) {
let _ = self.command_tx.push(Command::AddLoadedMidiClip(track_id, clip, start_time.beats_to_f64())); let _ = self.command_tx.push(Command::AddLoadedMidiClip(track_id, clip, start_time));
} }
/// Update all notes in a MIDI clip. Note tuples are (start [beats], note, velocity, duration [beats]). /// Update all notes in a MIDI clip. Note tuples are (start [beats], note, velocity, duration [beats]).
pub fn update_midi_clip_notes(&mut self, track_id: TrackId, clip_id: MidiClipId, notes: Vec<(Beats, u8, u8, Beats)>) { pub fn update_midi_clip_notes(&mut self, track_id: TrackId, clip_id: MidiClipId, notes: Vec<(Beats, u8, u8, Beats)>) {
let notes = notes.into_iter().map(|(t, n, v, d)| (t.beats_to_f64(), n, v, d.beats_to_f64())).collect();
let _ = self.command_tx.push(Command::UpdateMidiClipNotes(track_id, clip_id, notes)); let _ = self.command_tx.push(Command::UpdateMidiClipNotes(track_id, clip_id, notes));
} }
@ -3966,7 +3976,7 @@ impl EngineController {
curve: crate::audio::CurveType, curve: crate::audio::CurveType,
) { ) {
let _ = self.command_tx.push(Command::AddAutomationPoint( let _ = self.command_tx.push(Command::AddAutomationPoint(
track_id, lane_id, time.beats_to_f64(), value, curve, track_id, lane_id, time, value, curve,
)); ));
} }
@ -3979,7 +3989,7 @@ impl EngineController {
tolerance: Beats, tolerance: Beats,
) { ) {
let _ = self.command_tx.push(Command::RemoveAutomationPoint( let _ = self.command_tx.push(Command::RemoveAutomationPoint(
track_id, lane_id, time.beats_to_f64(), tolerance.beats_to_f64(), track_id, lane_id, time, tolerance,
)); ));
} }
@ -4018,13 +4028,13 @@ impl EngineController {
time: Beats, value: f32, interpolation: String, time: Beats, value: f32, interpolation: String,
ease_out: (f32, f32), ease_in: (f32, f32)) { ease_out: (f32, f32), ease_in: (f32, f32)) {
let _ = self.command_tx.push(Command::AutomationAddKeyframe( let _ = self.command_tx.push(Command::AutomationAddKeyframe(
track_id, node_id, time.beats_to_f64(), value, interpolation, ease_out, ease_in)); track_id, node_id, time, value, interpolation, ease_out, ease_in));
} }
/// Remove a keyframe from an AutomationInput node /// Remove a keyframe from an AutomationInput node
pub fn automation_remove_keyframe(&mut self, track_id: TrackId, node_id: u32, time: Beats) { pub fn automation_remove_keyframe(&mut self, track_id: TrackId, node_id: u32, time: Beats) {
let _ = self.command_tx.push(Command::AutomationRemoveKeyframe( let _ = self.command_tx.push(Command::AutomationRemoveKeyframe(
track_id, node_id, time.beats_to_f64())); track_id, node_id, time));
} }
/// Set the display name of an AutomationInput node /// Set the display name of an AutomationInput node
@ -4560,7 +4570,7 @@ impl EngineController {
} }
/// Get file info from pool (duration, sample_rate, channels) /// Get file info from pool (duration, sample_rate, channels)
pub fn get_pool_file_info(&mut self, pool_index: usize) -> Result<(f64, u32, u32), String> { pub fn get_pool_file_info(&mut self, pool_index: usize) -> Result<(Seconds, u32, u32), String> {
// Send query // Send query
if let Err(_) = self.query_tx.push(Query::GetPoolFileInfo(pool_index)) { if let Err(_) = self.query_tx.push(Query::GetPoolFileInfo(pool_index)) {
return Err("Failed to send query - queue full".to_string()); return Err("Failed to send query - queue full".to_string());

View File

@ -34,7 +34,7 @@ pub enum Command {
/// Pause playback (maintains position) /// Pause playback (maintains position)
Pause, Pause,
/// Seek to a specific position in seconds /// Seek to a specific position in seconds
Seek(f64), Seek(Seconds),
// Track management commands // Track management commands
/// Set track volume (0.0 = silence, 1.0 = unity gain) /// Set track volume (0.0 = silence, 1.0 = unity gain)
@ -51,7 +51,7 @@ pub enum Command {
TrimClip(TrackId, ClipId, TrimRange), TrimClip(TrackId, ClipId, TrimRange),
/// Extend/shrink a clip's external duration (track_id, clip_id, new_external_duration) /// Extend/shrink a clip's external duration (track_id, clip_id, new_external_duration)
/// If duration > internal duration, the clip will loop /// If duration > internal duration, the clip will loop
ExtendClip(TrackId, ClipId, f64), ExtendClip(TrackId, ClipId, Beats),
// Metatrack management commands // Metatrack management commands
/// Create a new metatrack with a name and optional parent group /// Create a new metatrack with a name and optional parent group
@ -67,15 +67,15 @@ pub enum Command {
SetTimeStretch(TrackId, f32), SetTimeStretch(TrackId, f32),
/// Set metatrack time offset in seconds (track_id, offset) /// Set metatrack time offset in seconds (track_id, offset)
/// Positive = shift content later, negative = shift earlier /// Positive = shift content later, negative = shift earlier
SetOffset(TrackId, f64), SetOffset(TrackId, Seconds),
/// Set metatrack pitch shift in semitones (track_id, semitones) - for future use /// Set metatrack pitch shift in semitones (track_id, semitones) - for future use
SetPitchShift(TrackId, f32), SetPitchShift(TrackId, f32),
/// Set metatrack trim start in seconds (track_id, trim_start) /// Set metatrack trim start in seconds (track_id, trim_start)
/// Children won't hear content before this point /// Children won't hear content before this point
SetTrimStart(TrackId, f64), SetTrimStart(TrackId, Seconds),
/// Set metatrack trim end in seconds (track_id, trim_end) /// Set metatrack trim end in seconds (track_id, trim_end)
/// None means no end trim /// None means no end trim
SetTrimEnd(TrackId, Option<f64>), SetTrimEnd(TrackId, Option<Seconds>),
// Audio track commands // Audio track commands
/// Create a new audio track with a name and optional parent group /// Create a new audio track with a name and optional parent group
@ -94,14 +94,14 @@ pub enum Command {
/// Add a MIDI clip to the pool without placing it on a track /// Add a MIDI clip to the pool without placing it on a track
AddMidiClipToPool(MidiClip), AddMidiClipToPool(MidiClip),
/// Create a new MIDI clip on a track (track_id, start_time, duration) /// Create a new MIDI clip on a track (track_id, start_time, duration)
CreateMidiClip(TrackId, f64, f64), CreateMidiClip(TrackId, Beats, Beats),
/// Add a MIDI note to a clip (track_id, clip_id, time_offset, note, velocity, duration) /// Add a MIDI note to a clip (track_id, clip_id, time_offset, note, velocity, duration)
AddMidiNote(TrackId, MidiClipId, f64, u8, u8, f64), AddMidiNote(TrackId, MidiClipId, Beats, u8, u8, Beats),
/// Add a pre-loaded MIDI clip to a track (track_id, clip, start_time) /// Add a pre-loaded MIDI clip to a track (track_id, clip, start_time)
AddLoadedMidiClip(TrackId, MidiClip, f64), AddLoadedMidiClip(TrackId, MidiClip, Beats),
/// Update MIDI clip notes (track_id, clip_id, notes: Vec<(start_time, note, velocity, duration)>) /// Update MIDI clip notes (track_id, clip_id, notes: Vec<(start_time, note, velocity, duration)>)
/// NOTE: May need to switch to individual note operations if this becomes slow on clips with many notes /// NOTE: May need to switch to individual note operations if this becomes slow on clips with many notes
UpdateMidiClipNotes(TrackId, MidiClipId, Vec<(f64, u8, u8, f64)>), UpdateMidiClipNotes(TrackId, MidiClipId, Vec<(Beats, u8, u8, Beats)>),
/// Replace all events in a MIDI clip (track_id, clip_id, events). Used for CC/pitch bend editing. /// Replace all events in a MIDI clip (track_id, clip_id, events). Used for CC/pitch bend editing.
UpdateMidiClipEvents(TrackId, MidiClipId, Vec<MidiEvent>), UpdateMidiClipEvents(TrackId, MidiClipId, Vec<MidiEvent>),
/// Remove a MIDI clip instance from a track (track_id, instance_id) - for undo/redo support /// Remove a MIDI clip instance from a track (track_id, instance_id) - for undo/redo support
@ -117,9 +117,9 @@ pub enum Command {
/// Create a new automation lane on a track (track_id, parameter_id) /// Create a new automation lane on a track (track_id, parameter_id)
CreateAutomationLane(TrackId, ParameterId), CreateAutomationLane(TrackId, ParameterId),
/// Add an automation point to a lane (track_id, lane_id, time, value, curve) /// Add an automation point to a lane (track_id, lane_id, time, value, curve)
AddAutomationPoint(TrackId, AutomationLaneId, f64, f32, CurveType), AddAutomationPoint(TrackId, AutomationLaneId, Beats, f32, CurveType),
/// Remove an automation point at a specific time (track_id, lane_id, time, tolerance) /// Remove an automation point at a specific time (track_id, lane_id, time, tolerance)
RemoveAutomationPoint(TrackId, AutomationLaneId, f64, f64), RemoveAutomationPoint(TrackId, AutomationLaneId, Beats, Beats),
/// Clear all automation points from a lane (track_id, lane_id) /// Clear all automation points from a lane (track_id, lane_id)
ClearAutomationLane(TrackId, AutomationLaneId), ClearAutomationLane(TrackId, AutomationLaneId),
/// Remove an automation lane (track_id, lane_id) /// Remove an automation lane (track_id, lane_id)
@ -258,9 +258,9 @@ pub enum Command {
// Automation Input Node commands // Automation Input Node commands
/// Add or update a keyframe on an AutomationInput node (track_id, node_id, time, value, interpolation, ease_out, ease_in) /// Add or update a keyframe on an AutomationInput node (track_id, node_id, time, value, interpolation, ease_out, ease_in)
AutomationAddKeyframe(TrackId, u32, f64, f32, String, (f32, f32), (f32, f32)), AutomationAddKeyframe(TrackId, u32, Beats, f32, String, (f32, f32), (f32, f32)),
/// Remove a keyframe from an AutomationInput node (track_id, node_id, time) /// Remove a keyframe from an AutomationInput node (track_id, node_id, time)
AutomationRemoveKeyframe(TrackId, u32, f64), AutomationRemoveKeyframe(TrackId, u32, Beats),
/// Set the display name of an AutomationInput node (track_id, node_id, name) /// Set the display name of an AutomationInput node (track_id, node_id, name)
AutomationSetName(TrackId, u32, String), AutomationSetName(TrackId, u32, String),
@ -292,7 +292,7 @@ pub enum Command {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum AudioEvent { pub enum AudioEvent {
/// Current playback position in seconds /// Current playback position in seconds
PlaybackPosition(f64), PlaybackPosition(Seconds),
/// Playback has stopped (reached end of audio) /// Playback has stopped (reached end of audio)
PlaybackStopped, PlaybackStopped,
/// Audio buffer underrun detected /// Audio buffer underrun detected
@ -379,7 +379,7 @@ pub enum AudioEvent {
WaveformChunksReady { WaveformChunksReady {
pool_index: usize, pool_index: usize,
detail_level: u8, detail_level: u8,
chunks: Vec<(u32, (f64, f64), Vec<WaveformPeak>)>, chunks: Vec<(u32, (Seconds, Seconds), Vec<WaveformPeak>)>,
}, },
/// An audio file has been imported and is ready for playback. /// An audio file has been imported and is ready for playback.
@ -390,7 +390,7 @@ pub enum AudioEvent {
path: String, path: String,
channels: u32, channels: u32,
sample_rate: u32, sample_rate: u32,
duration: f64, duration: Seconds,
format: crate::io::audio_file::AudioFormat, format: crate::io::audio_file::AudioFormat,
}, },
@ -464,7 +464,7 @@ pub enum Query {
/// Export audio to file (settings, output_path) /// Export audio to file (settings, output_path)
ExportAudio(crate::audio::ExportSettings, std::path::PathBuf), ExportAudio(crate::audio::ExportSettings, std::path::PathBuf),
/// Add a MIDI clip to a track synchronously (track_id, clip, start_time) - returns instance ID /// Add a MIDI clip to a track synchronously (track_id, clip, start_time) - returns instance ID
AddMidiClipSync(TrackId, crate::audio::midi::MidiClip, f64), AddMidiClipSync(TrackId, crate::audio::midi::MidiClip, Beats),
/// Add a MIDI clip instance to a track synchronously (track_id, instance) - returns instance ID /// Add a MIDI clip instance to a track synchronously (track_id, instance) - returns instance ID
/// The clip must already exist in the MidiClipPool /// The clip must already exist in the MidiClipPool
AddMidiClipInstanceSync(TrackId, crate::audio::midi::MidiClipInstance), AddMidiClipInstanceSync(TrackId, crate::audio::midi::MidiClipInstance),
@ -509,16 +509,21 @@ pub struct OscilloscopeData {
} }
/// MIDI clip data for serialization /// MIDI clip data for serialization
///
/// `Beats`/`Seconds` are `#[serde(transparent)]`, so naming the domain here costs nothing on disk —
/// the `.beam` still holds a plain number.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct MidiClipData { pub struct MidiClipData {
pub duration: f64, /// MIDI content length is musical, so beats.
pub duration: Beats,
pub events: Vec<crate::audio::midi::MidiEvent>, pub events: Vec<crate::audio::midi::MidiEvent>,
} }
/// Automation keyframe data for serialization /// Automation keyframe data for serialization
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct AutomationKeyframeData { pub struct AutomationKeyframeData {
pub time: f64, /// Automation x-axes are all beats.
pub time: Beats,
pub value: f32, pub value: f32,
pub interpolation: String, pub interpolation: String,
pub ease_out: (f32, f32), pub ease_out: (f32, f32),
@ -555,7 +560,7 @@ pub enum QueryResponse {
/// Pool waveform data /// Pool waveform data
PoolWaveform(Result<Vec<crate::io::WaveformPeak>, String>), PoolWaveform(Result<Vec<crate::io::WaveformPeak>, String>),
/// Pool file info (duration, sample_rate, channels) /// Pool file info (duration, sample_rate, channels)
PoolFileInfo(Result<(f64, u32, u32), String>), PoolFileInfo(Result<(Seconds, u32, u32), String>),
/// Audio exported /// Audio exported
AudioExported(Result<(), String>), AudioExported(Result<(), String>),
/// MIDI clip instance added (returns instance ID) /// MIDI clip instance added (returns instance ID)

View File

@ -20,7 +20,7 @@ pub use audio::{
TrackNode, TrackNode,
}; };
pub use audio::node_graph::{GraphPreset, AudioGraph, PresetMetadata, SerializedConnection, SerializedNode}; pub use audio::node_graph::{GraphPreset, AudioGraph, PresetMetadata, SerializedConnection, SerializedNode};
pub use time::{Beats, Seconds}; pub use time::{Beats, ContentTime, Seconds};
pub use tempo_map::{TempoEntry, TempoInterpolation, TempoMap, beats_to_seconds_stack, seconds_to_beats_stack}; pub use tempo_map::{TempoEntry, TempoInterpolation, TempoMap, beats_to_seconds_stack, seconds_to_beats_stack};
pub use command::{AudioEvent, Command, OscilloscopeData}; pub use command::{AudioEvent, Command, OscilloscopeData};
pub use command::types::AutomationKeyframeData; pub use command::types::AutomationKeyframeData;

View File

@ -16,6 +16,50 @@ pub struct Beats(pub f64);
#[serde(transparent)] #[serde(transparent)]
pub struct Seconds(pub f64); pub struct Seconds(pub f64);
/// A time *inside a clip's own content*, in whatever unit that clip measures content in.
///
/// Clip content time is domain-polymorphic: SECONDS for sampled audio, video and vector, but BEATS
/// for MIDI (musical, so it survives tempo changes). `ClipInstance::trim_start`/`trim_end` are
/// content times, and storing them as bare `f64`s is what let a seconds delta get added to a MIDI
/// clip's beats trim — splitting a MIDI clip at beat 4 landed at beat 2 at 120 BPM.
///
/// This type is deliberately a **dead end**: it has no `.to_seconds()`, no `.to_beats()`, and no
/// arithmetic with `Seconds` or `Beats`. Content times can be compared and combined with each other
/// (that's domain-safe — both operands are in the same clip's domain), but the only way to get a
/// real timeline duration out is to resolve it against the clip that knows the domain, via
/// `AudioClip::resolve_content_time` / `Document::resolve_content_time`. So a passthrough costs
/// nothing, and mixing domains won't compile.
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Default, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ContentTime(pub f64);
impl ContentTime {
pub const ZERO: Self = Self(0.0);
pub fn max(self, other: Self) -> Self { Self(self.0.max(other.0)) }
pub fn min(self, other: Self) -> Self { Self(self.0.min(other.0)) }
/// The raw magnitude, with the domain discarded.
///
/// Only for code that is *already* working in this clip's content domain (trim arithmetic,
/// serialization, drawing a waveform whose x-axis is the clip's own content). If you are about
/// to combine this with a timeline position, resolve it against the clip instead.
pub fn raw(self) -> f64 { self.0 }
}
impl Add for ContentTime {
type Output = Self;
fn add(self, rhs: Self) -> Self { Self(self.0 + rhs.0) }
}
impl Sub for ContentTime {
type Output = Self;
fn sub(self, rhs: Self) -> Self { Self(self.0 - rhs.0) }
}
impl Rem for ContentTime {
type Output = Self;
fn rem(self, rhs: Self) -> Self { Self(self.0 % rhs.0) }
}
impl Beats { impl Beats {
pub const ZERO: Self = Self(0.0); pub const ZERO: Self = Self(0.0);

View File

@ -556,7 +556,7 @@ pub fn run_tui(
while let Ok(event) = rx.pop() { while let Ok(event) = rx.pop() {
match event { match event {
AudioEvent::PlaybackPosition(pos) => { AudioEvent::PlaybackPosition(pos) => {
app.update_playback_position(pos); app.update_playback_position(pos.seconds_to_f64());
} }
AudioEvent::PlaybackStopped => { AudioEvent::PlaybackStopped => {
app.set_playing(false); app.set_playing(false);

View File

@ -77,11 +77,18 @@ impl BackendContext<'_> {
.ok_or_else(|| format!("Layer {} not mapped to backend track", layer_id))?; .ok_or_else(|| format!("Layer {} not mapped to backend track", layer_id))?;
let resolved = clip.resolve(instance.active_take); let resolved = clip.resolve(instance.active_take);
let content_duration = clip.content_duration().native(); let content = clip.content_duration();
let internal_start = instance.trim_start; let internal_start = instance.trim_start;
let internal_end = instance.trim_end.unwrap_or(content_duration); let internal_end = instance
.trim_end
.unwrap_or(daw_backend::ContentTime(content.native()));
let start_time = instance.timeline_start; let start_time = instance.timeline_start;
// How long the clip occupies the timeline, in BEATS. `effective_duration_beats` resolves the
// content window in the clip's own domain — beats content carries over directly, wall-clock
// content converts at the clip's position — so neither kind can be read as the other here.
let effective_duration = instance.effective_duration_beats(content, document.tempo_map());
let controller = self let controller = self
.audio_controller .audio_controller
.as_mut() .as_mut()
@ -91,18 +98,14 @@ impl BackendContext<'_> {
ResolvedContent::Midi { midi_clip_id } => { ResolvedContent::Midi { midi_clip_id } => {
use daw_backend::command::{Query, QueryResponse}; use daw_backend::command::{Query, QueryResponse};
// MIDI trims are in the BEATS domain, so the fallback span is beats too. // MIDI content time IS beats, so the trims carry straight over.
let external_duration = instance
.timeline_duration
.unwrap_or(daw_backend::Beats(internal_end - internal_start));
let midi_instance = daw_backend::MidiClipInstance::new( let midi_instance = daw_backend::MidiClipInstance::new(
0, // assigned by the backend 0, // assigned by the backend
midi_clip_id, midi_clip_id,
daw_backend::Beats(internal_start), daw_backend::Beats(internal_start.raw()),
daw_backend::Beats(internal_end), daw_backend::Beats(internal_end.raw()),
start_time, start_time,
external_duration, effective_duration,
); );
match controller match controller
@ -114,26 +117,13 @@ impl BackendContext<'_> {
} }
} }
ResolvedContent::Audio { audio_pool_index } => { ResolvedContent::Audio { audio_pool_index } => {
// `trim_*` and the clip's content duration are SECONDS (audio content time); the // Sampled-audio content time is SECONDS; the backend's start/duration are BEATS.
// backend's start/duration are BEATS.
//
// When `timeline_duration` is set it's already beats; otherwise the clip occupies
// its natural content length, so convert that seconds-span to beats *at the clip's
// start* (NOT `internal_end - internal_start`, which is seconds — that was the
// seconds-as-beats bug that made clips stop early at anything but 60 BPM).
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 id = controller.add_audio_clip( let id = controller.add_audio_clip(
track_id, track_id,
audio_pool_index, audio_pool_index,
start_time, start_time,
effective_duration, effective_duration,
daw_backend::Seconds(internal_start), daw_backend::Seconds(internal_start.raw()),
); );
BackendClipInstanceId::Audio(id) BackendClipInstanceId::Audio(id)
} }

View File

@ -89,10 +89,11 @@ impl Action for AddClipInstanceAction {
// `get_clip_duration` is the content length in seconds; the placement span // `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 // must be beats (the timeline is beats-domain), so convert via the clip's
// typed helper rather than treating the seconds span as beats. // typed helper rather than treating the seconds span as beats.
let clip_duration = document.get_clip_duration(&self.clip_instance.clip_id) // The clip's content duration in ITS OWN domain, so the trims resolve correctly for MIDI.
let clip_content = document.clip_trim_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 effective_duration = self.clip_instance
.effective_duration_beats(clip_duration, document.tempo_map()); .effective_duration_beats(clip_content, document.tempo_map());
// 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(

View File

@ -128,17 +128,13 @@ impl LoopClipInstancesAction {
(new_dur, new_lb) (new_dur, new_lb)
}; };
let content_window = { // Natural content length as a beats span (the fallback when no explicit
let trim_end = instance.trim_end.unwrap_or(clip.content_duration().native()); // timeline_duration is set). Resolved in the clip's own domain, so MIDI's beats
(trim_end - instance.trim_start).max(0.0) // seconds // content carries over directly rather than being read as seconds.
}; let content_window_beats = instance.effective_duration_beats(
// Natural content length as a beats span at the clip's start (the clip.content_duration(),
// fallback when no explicit timeline_duration is set). document.tempo_map(),
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 right_duration = target_duration.unwrap_or(content_window_beats);
let left_duration = target_loop_before.unwrap_or(daw_backend::Beats::ZERO); 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;

View File

@ -105,7 +105,7 @@ impl Action for MoveClipInstancesAction {
let group: Vec<(Uuid, Beats, Beats)> = 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.clip_trim_duration(&inst.clip_id)?;
let eff = inst.effective_duration_beats(dur, document.tempo_map()); let eff = inst.effective_duration_beats(dur, document.tempo_map());
Some((*id, *old_start, eff)) Some((*id, *old_start, eff))
}).collect(); }).collect();
@ -211,8 +211,9 @@ impl Action for MoveClipInstancesAction {
// 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, document.tempo_map().beats_to_seconds(*new_start)); controller.set_offset(metatrack_id, document.tempo_map().beats_to_seconds(*new_start));
controller.set_trim_start(metatrack_id, daw_backend::Seconds(instance.trim_start)); // A vector clip's content is wall-clock, so its content times ARE seconds.
controller.set_trim_end(metatrack_id, instance.trim_end.map(daw_backend::Seconds)); controller.set_trim_start(metatrack_id, daw_backend::Seconds(instance.trim_start.raw()));
controller.set_trim_end(metatrack_id, instance.trim_end.map(|t| daw_backend::Seconds(t.raw())));
} }
} }
} }
@ -295,8 +296,9 @@ 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) {
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, document.tempo_map().beats_to_seconds(*old_start)); controller.set_offset(metatrack_id, document.tempo_map().beats_to_seconds(*old_start));
controller.set_trim_start(metatrack_id, daw_backend::Seconds(instance.trim_start)); // A vector clip's content is wall-clock, so its content times ARE seconds.
controller.set_trim_end(metatrack_id, instance.trim_end.map(daw_backend::Seconds)); controller.set_trim_start(metatrack_id, daw_backend::Seconds(instance.trim_start.raw()));
controller.set_trim_end(metatrack_id, instance.trim_end.map(|t| daw_backend::Seconds(t.raw())));
} }
} }
} }

View File

@ -138,92 +138,23 @@ impl Action for RemoveClipInstancesAction {
backend: &mut BackendContext, backend: &mut BackendContext,
document: &Document, document: &Document,
) -> Result<(), String> { ) -> Result<(), String> {
use crate::clip::ResolvedContent; if backend.audio_controller.is_none() {
return Ok(());
}
let controller = match backend.audio_controller.as_mut() { // Re-add the clips that were removed. `BackendContext::add_clip_instance` is the same
Some(c) => c, // helper the add and split actions use, so the trim/duration conversions (and take-folder
None => return Ok(()), // resolution) stay in exactly one place instead of being copied into every action that has
}; // to put a clip back.
let saved = std::mem::take(&mut self.saved);
// Re-add clips that were removed from backend for (layer_id, instance) in &saved {
for (layer_id, instance) in &self.saved { if !matches!(document.get_layer(layer_id), Some(AnyLayer::Audio(_))) {
let layer = match document.get_layer(layer_id) {
Some(l) => l,
None => continue,
};
if !matches!(layer, AnyLayer::Audio(_)) {
continue; continue;
} }
// A missing track/clip just means there's nothing to restore on the backend.
let track_id = match backend.layer_to_track_map.get(layer_id) { let _ = backend.add_clip_instance(document, layer_id, instance);
Some(id) => *id,
None => continue,
};
let clip = match document.get_audio_clip(&instance.clip_id) {
Some(c) => c,
None => continue,
};
match &clip.resolve(instance.active_take) {
ResolvedContent::Midi { midi_clip_id } => {
use daw_backend::command::{Query, QueryResponse};
let internal_start = instance.trim_start;
let internal_end = instance.trim_end.unwrap_or(clip.content_duration().native());
let external_start = instance.timeline_start;
// MIDI trims are beats-domain, so the fallback span is beats too.
let external_duration = instance
.timeline_duration
.unwrap_or(daw_backend::Beats(internal_end - internal_start));
let midi_instance = daw_backend::MidiClipInstance::new(
0,
*midi_clip_id,
daw_backend::Beats(internal_start),
daw_backend::Beats(internal_end),
external_start,
external_duration,
);
let query = Query::AddMidiClipInstanceSync(track_id, midi_instance);
if let Ok(QueryResponse::MidiClipInstanceAdded(Ok(new_id))) =
controller.send_query(query)
{
backend.clip_instance_to_backend_map.insert(
instance.id,
BackendClipInstanceId::Midi(new_id),
);
}
}
ResolvedContent::Audio { audio_pool_index } => {
let internal_start = instance.trim_start;
let internal_end = instance.trim_end.unwrap_or(clip.content_duration().native());
let start_time = instance.timeline_start;
// Fallback span is the content seconds converted to beats at the
// clip's start (not the seconds span treated as beats).
let effective_duration = instance.timeline_duration.unwrap_or_else(|| {
let tempo_map = document.tempo_map();
let content_secs = daw_backend::Seconds(internal_end - internal_start);
tempo_map.seconds_to_beats(tempo_map.beats_to_seconds(start_time) + content_secs)
- start_time
});
let new_id = controller.add_audio_clip(
track_id,
*audio_pool_index,
start_time,
effective_duration,
daw_backend::Seconds(internal_start),
);
backend.clip_instance_to_backend_map.insert(
instance.id,
BackendClipInstanceId::Audio(new_id),
);
}
ResolvedContent::Recording => {}
}
} }
self.saved = saved;
// Clear saved backend IDs // Clear saved backend IDs
self.saved_backend_ids.clear(); self.saved_backend_ids.clear();

View File

@ -7,6 +7,7 @@ use crate::action::{Action, BackendContext};
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::ContentTime;
use uuid::Uuid; use uuid::Uuid;
/// Action that splits a clip instance at a specific timeline position /// Action that splits a clip instance at a specific timeline position
@ -25,7 +26,7 @@ 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<ContentTime>,
/// Original timeline_duration value of the left (original) instance (beats) /// Original timeline_duration value of the left (original) instance (beats)
original_timeline_duration: Option<daw_backend::Beats>, 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
@ -122,13 +123,15 @@ impl Action for SplitClipInstanceAction {
.find(|ci| ci.id == self.instance_id) .find(|ci| ci.id == self.instance_id)
.ok_or_else(|| format!("Clip instance {} not found", self.instance_id))?; .ok_or_else(|| format!("Clip instance {} not found", self.instance_id))?;
// Get the clip's duration // The clip's content duration in its OWN domain — seconds for audio/video/vector, beats for
let clip_duration = document // MIDI. All the content math below is trim-domain, so it has to be done in whichever domain
.get_clip_duration(&instance.clip_id) // this clip uses; a seconds duration would silently be added to a MIDI clip's beats trim.
let trim_duration = document
.clip_trim_duration(&instance.clip_id)
.ok_or_else(|| format!("Clip {} not found", instance.clip_id))?; .ok_or_else(|| format!("Clip {} not found", instance.clip_id))?;
// Calculate the effective duration and timeline end (both in beats) // Calculate the effective duration and timeline end (both in beats)
let effective_duration = instance.effective_duration(clip_duration, document.tempo_map()); let effective_duration = instance.effective_duration(trim_duration, document.tempo_map());
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
@ -146,33 +149,26 @@ 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;
// The clip's content duration in the SAME domain as its trims — seconds for audio/video/
// vector, beats for MIDI. All the content math below is trim-domain, so it has to be done
// in whichever domain this clip uses; `clip_duration` above is always seconds and would
// silently add a seconds delta to a MIDI clip's beats trim.
let trim_duration = document
.clip_trim_duration(&instance.clip_id)
.ok_or_else(|| format!("Clip {} not found", instance.clip_id))?;
let is_looping = instance.timeline_duration.is_some(); let is_looping = instance.timeline_duration.is_some();
let content_duration = instance.trim_end.unwrap_or(trim_duration.native()) - instance.trim_start; let content_duration = ContentTime(instance.content_window(trim_duration).native());
// Timeline split point (beats). // 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;
// How far the split lands into the clip's *content*, expressed in the trim domain. // How far the split lands into the clip's *content*, expressed in the content domain: beats
// content takes the beats delta directly, wall-clock content takes the seconds delta.
let tempo_map = document.tempo_map(); let tempo_map = document.tempo_map();
let time_into_content = match trim_duration { let time_into_content = ContentTime(match trim_duration {
crate::clip::ClipDuration::Beats(_) => time_into_clip.beats_to_f64(), crate::clip::ClipDuration::Beats(_) => time_into_clip.beats_to_f64(),
crate::clip::ClipDuration::Seconds(_) => (tempo_map.beats_to_seconds(self.split_time) crate::clip::ClipDuration::Seconds(_) => (tempo_map.beats_to_seconds(self.split_time)
- tempo_map.beats_to_seconds(instance.timeline_start)) - tempo_map.beats_to_seconds(instance.timeline_start))
.seconds_to_f64(), .seconds_to_f64(),
}; });
// Calculate the content split point (trim domain). // Calculate the content split point (content domain).
let content_split_time = if is_looping && content_duration > 0.0 { let content_split_time = if is_looping && content_duration > ContentTime::ZERO {
// For looping clips, wrap around content // For looping clips, wrap around content
instance.trim_start + (time_into_content % content_duration) instance.trim_start + (time_into_content % content_duration)
} else { } else {
@ -367,119 +363,63 @@ impl Action for SplitClipInstanceAction {
.get_audio_clip(&new_instance.clip_id) .get_audio_clip(&new_instance.clip_id)
.ok_or_else(|| "Audio clip not found".to_string())?; .ok_or_else(|| "Audio clip not found".to_string())?;
// Look up backend track ID from layer mapping use crate::clip::ResolvedContent;
let backend_track_id = backend if matches!(clip.resolve(original_instance.active_take), ResolvedContent::Recording) {
return Err("Cannot split a clip that is currently recording".to_string());
}
// A split is: shorten the left half's backend clip, then add the right half as a new one.
//
// 1. Trim the left (original) instance. `trim_range` tags the bounds with the clip's own
// content domain, so a MIDI clip's beats trims can't be sent as seconds.
let left_trim = clip.trim_range(
original_instance.trim_start,
original_instance
.trim_end
.unwrap_or(ContentTime(clip.content_duration().native())),
);
let new_instance = new_instance.clone();
let backend_track_id = *backend
.layer_to_track_map .layer_to_track_map
.get(&self.layer_id) .get(&self.layer_id)
.ok_or_else(|| format!("Layer {} not mapped to backend track", self.layer_id))?; .ok_or_else(|| format!("Layer {} not mapped to backend track", self.layer_id))?;
let left_backend_id = backend
.clip_instance_to_backend_map
.get(&self.instance_id)
.copied();
// Get audio controller {
let controller = backend let controller = backend
.audio_controller .audio_controller
.as_mut() .as_mut()
.ok_or_else(|| "Audio controller not available".to_string())?; .ok_or_else(|| "Audio controller not available".to_string())?;
match left_backend_id {
// Handle different clip types Some(crate::action::BackendClipInstanceId::Midi(id))
use crate::clip::ResolvedContent; | Some(crate::action::BackendClipInstanceId::Audio(id)) => {
match &clip.resolve(original_instance.active_take) { controller.trim_clip(backend_track_id, id, left_trim);
ResolvedContent::Midi { midi_clip_id } => {
use daw_backend::command::{Query, QueryResponse};
// 1. Trim the original (left) instance
let orig_internal_start = original_instance.trim_start;
let orig_internal_end = original_instance.trim_end.unwrap_or(clip.content_duration().native());
// Look up the original backend instance ID
if let Some(crate::action::BackendClipInstanceId::Midi(orig_backend_id)) =
backend.clip_instance_to_backend_map.get(&self.instance_id)
{
controller.trim_clip(*backend_track_id, *orig_backend_id, clip.trim_range(orig_internal_start, orig_internal_end));
} }
None => {}
// 2. Add the new (right) instance
let internal_start = new_instance.trim_start;
let internal_end = new_instance.trim_end.unwrap_or(clip.content_duration().native());
let external_start = new_instance.timeline_start;
// MIDI trims are beats-domain, so the fallback span is beats too.
let external_duration = new_instance
.timeline_duration
.unwrap_or(daw_backend::Beats(internal_end - internal_start));
let instance = daw_backend::MidiClipInstance::new(
0,
*midi_clip_id,
daw_backend::Beats(internal_start),
daw_backend::Beats(internal_end),
external_start,
external_duration,
);
let query = Query::AddMidiClipInstanceSync(*backend_track_id, instance);
match controller.send_query(query)? {
QueryResponse::MidiClipInstanceAdded(Ok(instance_id)) => {
self.backend_track_id = Some(*backend_track_id);
self.backend_midi_instance_id = Some(instance_id);
backend.clip_instance_to_backend_map.insert(
new_instance_id,
crate::action::BackendClipInstanceId::Midi(instance_id),
);
Ok(())
}
QueryResponse::MidiClipInstanceAdded(Err(e)) => Err(e),
_ => Err("Unexpected query response".to_string()),
}
}
ResolvedContent::Audio { audio_pool_index } => {
// 1. Trim the original (left) instance
let orig_internal_start = original_instance.trim_start;
let orig_internal_end = original_instance.trim_end.unwrap_or(clip.content_duration().native());
// Look up the original backend instance ID
if let Some(crate::action::BackendClipInstanceId::Audio(orig_backend_id)) =
backend.clip_instance_to_backend_map.get(&self.instance_id)
{
controller.trim_clip(*backend_track_id, *orig_backend_id, clip.trim_range(orig_internal_start, orig_internal_end));
}
// 2. Add the new (right) instance
let internal_start = new_instance.trim_start;
let internal_end = new_instance.trim_end.unwrap_or(clip.content_duration().native());
let start_time = new_instance.timeline_start;
// Fallback span is the content seconds converted to beats at the
// clip's start (not the seconds span treated as beats).
let effective_duration = new_instance.timeline_duration.unwrap_or_else(|| {
let tempo_map = document.tempo_map();
let content_secs = daw_backend::Seconds(internal_end - internal_start);
tempo_map.seconds_to_beats(tempo_map.beats_to_seconds(start_time) + content_secs)
- start_time
});
let instance_id = controller.add_audio_clip(
*backend_track_id,
*audio_pool_index,
start_time,
effective_duration,
daw_backend::Seconds(internal_start),
);
self.backend_track_id = Some(*backend_track_id);
self.backend_audio_instance_id = Some(instance_id);
backend.clip_instance_to_backend_map.insert(
new_instance_id,
crate::action::BackendClipInstanceId::Audio(instance_id),
);
Ok(())
}
ResolvedContent::Recording => {
// Recording clips cannot be split
Err("Cannot split a clip that is currently recording".to_string())
} }
} }
// 2. Add the right (new) instance via the shared helper — same one AddClipInstanceAction
// uses, so the trim/duration conversions live in exactly one place.
if let Some((track_id, backend_id)) =
backend.add_clip_instance(document, &self.layer_id, &new_instance)?
{
self.backend_track_id = Some(track_id);
match backend_id {
crate::action::BackendClipInstanceId::Midi(id) => {
self.backend_midi_instance_id = Some(id)
}
crate::action::BackendClipInstanceId::Audio(id) => {
self.backend_audio_instance_id = Some(id)
}
}
}
Ok(())
} }
fn rollback_backend( fn rollback_backend(
@ -509,7 +449,9 @@ impl Action for SplitClipInstanceAction {
if let Some(instance) = al.clip_instances.iter().find(|ci| ci.id == self.instance_id) { if let Some(instance) = al.clip_instances.iter().find(|ci| ci.id == self.instance_id) {
if let Some(clip) = document.get_audio_clip(&instance.clip_id) { if let Some(clip) = document.get_audio_clip(&instance.clip_id) {
let orig_internal_start = instance.trim_start; let orig_internal_start = instance.trim_start;
let orig_internal_end = self.original_trim_end.unwrap_or(clip.content_duration().native()); let orig_internal_end = self
.original_trim_end
.unwrap_or(ContentTime(clip.content_duration().native()));
// Restore based on clip type // Restore based on clip type
use crate::clip::ResolvedContent; use crate::clip::ResolvedContent;
@ -564,8 +506,8 @@ 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 = daw_backend::Beats::ZERO; clip_instance.timeline_start = daw_backend::Beats::ZERO;
clip_instance.trim_start = 0.0; clip_instance.trim_start = ContentTime::ZERO;
clip_instance.trim_end = Some(10.0); clip_instance.trim_end = Some(ContentTime(10.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);
@ -606,8 +548,8 @@ mod tests {
let mut audio_layer = crate::layer::AudioLayer::new("Layer 1"); let mut audio_layer = crate::layer::AudioLayer::new("Layer 1");
let mut instance = ClipInstance::new(clip_id); let mut instance = ClipInstance::new(clip_id);
instance.timeline_start = daw_backend::Beats::ZERO; instance.timeline_start = daw_backend::Beats::ZERO;
instance.trim_start = 0.0; instance.trim_start = ContentTime::ZERO;
instance.trim_end = Some(8.0); // beats instance.trim_end = Some(ContentTime(8.0)); // beats
let instance_id = instance.id; let instance_id = instance.id;
audio_layer.clip_instances.push(instance); audio_layer.clip_instances.push(instance);
let layer_id = document.root.add_child(AnyLayer::Audio(audio_layer)); let layer_id = document.root.add_child(AnyLayer::Audio(audio_layer));
@ -620,7 +562,7 @@ mod tests {
let right = al.clip_instances.iter().find(|ci| ci.id == new_id).unwrap(); let right = al.clip_instances.iter().find(|ci| ci.id == new_id).unwrap();
let left = al.clip_instances.iter().find(|ci| ci.id == instance_id).unwrap(); let left = al.clip_instances.iter().find(|ci| ci.id == instance_id).unwrap();
assert_eq!(right.trim_start, 4.0, "right half must start 4 BEATS into the content"); assert_eq!(right.trim_start, ContentTime(4.0), "right half must start 4 BEATS into the content");
assert_eq!(left.trim_end, Some(4.0), "left half must end 4 BEATS into the content"); assert_eq!(left.trim_end, Some(ContentTime(4.0)), "left half must end 4 BEATS into the content");
} }
} }

View File

@ -6,7 +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 daw_backend::{Beats, ContentTime, Seconds};
use std::collections::HashMap; use std::collections::HashMap;
use uuid::Uuid; use uuid::Uuid;
@ -32,15 +32,57 @@ pub struct TrimClipInstancesAction {
pub struct TrimData { 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>, ///
/// A content time — measured in the clip's own domain (seconds for audio/video/vector, beats
/// for MIDI), so it must be resolved against the clip before meeting a timeline position.
pub trim_value: Option<ContentTime>,
/// For TrimLeft: timeline_start value (where the clip appears on timeline, beats) /// For TrimLeft: timeline_start value (where the clip appears on timeline, beats)
/// For TrimRight: unused (None) /// For TrimRight: unused (None)
pub timeline_start: Option<Beats>, pub timeline_start: Option<Beats>,
} }
/// A wall-clock gap on the timeline, expressed in a clip's content domain.
///
/// Trim validation clamps how far a clip may be dragged against the empty space next to it, and that
/// space is measured on the timeline (seconds) while the trim lives in the clip's content domain. For
/// wall-clock content they're the same number; for MIDI (beats content) the gap has to be converted
/// at the clip's position, or a seconds gap silently clamps a beats trim.
fn gap_to_content(
gap: Seconds,
clip_content: crate::clip::ClipDuration,
timeline_start: Beats,
tempo_map: &crate::tempo_map::TempoMap,
) -> ContentTime {
match clip_content {
crate::clip::ClipDuration::Seconds(_) => ContentTime(gap.seconds_to_f64()),
crate::clip::ClipDuration::Beats(_) => {
let beats = tempo_map
.seconds_to_beats(tempo_map.beats_to_seconds(timeline_start) + gap)
- timeline_start;
ContentTime(beats.beats_to_f64())
}
}
}
/// The inverse: a content-domain span as wall-clock seconds at the clip's position.
fn content_to_secs(
span: ContentTime,
clip_content: crate::clip::ClipDuration,
timeline_start: Beats,
tempo_map: &crate::tempo_map::TempoMap,
) -> Seconds {
match clip_content {
crate::clip::ClipDuration::Seconds(_) => Seconds(span.raw()),
crate::clip::ClipDuration::Beats(_) => {
tempo_map.beats_to_seconds(timeline_start + Beats(span.raw()))
- tempo_map.beats_to_seconds(timeline_start)
}
}
}
impl TrimData { impl TrimData {
/// Create TrimData for left trim /// Create TrimData for left trim
pub fn left(trim_start: f64, timeline_start: Beats) -> Self { pub fn left(trim_start: ContentTime, 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),
@ -48,7 +90,7 @@ impl TrimData {
} }
/// Create TrimData for right trim /// Create TrimData for right trim
pub fn right(trim_end: Option<f64>) -> Self { pub fn right(trim_end: Option<ContentTime>) -> Self {
Self { Self {
trim_value: trim_end, trim_value: trim_end,
timeline_start: None, timeline_start: None,
@ -192,7 +234,8 @@ impl Action for TrimClipInstancesAction {
.find(|ci| &ci.id == instance_id) .find(|ci| &ci.id == instance_id)
.ok_or_else(|| format!("Instance {} not found", instance_id))?; .ok_or_else(|| format!("Instance {} not found", instance_id))?;
let clip_duration = document.get_clip_duration(&instance.clip_id) // The clip's content duration in ITS OWN domain, so trims resolve correctly for MIDI.
let clip_content = document.clip_trim_duration(&instance.clip_id)
.ok_or_else(|| format!("Clip {} not found", instance.clip_id))?; .ok_or_else(|| format!("Clip {} not found", instance.clip_id))?;
let mut clamped_new = new.clone(); let mut clamped_new = new.clone();
@ -204,23 +247,34 @@ impl Action for TrimClipInstancesAction {
{ {
// If extending to the left (new_trim < old_trim) // If extending to the left (new_trim < old_trim)
if should_validate && new_trim < old_trim { if should_validate && new_trim < old_trim {
// Max leftward extension as content seconds (the gap's wall-clock span).
let max_extend_secs = document.find_max_trim_extend_left(
layer_id,
instance_id,
instance.timeline_start,
).seconds_to_f64();
// Calculate how much we want to extend (content seconds)
let desired_extend = old_trim - new_trim;
// Clamp to max allowed
let actual_extend = desired_extend.min(max_extend_secs);
let clamped_trim_start = old_trim - actual_extend;
// Move the timeline left by the same wall-clock seconds.
let tempo_map = document.tempo_map(); let tempo_map = document.tempo_map();
// Max leftward extension: the gap's wall-clock span, converted into
// the clip's content domain so it can clamp a content-domain trim.
let max_extend = gap_to_content(
document.find_max_trim_extend_left(
layer_id,
instance_id,
instance.timeline_start,
),
clip_content,
instance.timeline_start,
tempo_map,
);
let desired_extend = old_trim - new_trim;
let actual_extend = desired_extend.min(max_extend);
let clamped_trim_start = old_trim - actual_extend;
// Move the timeline left by the same span, as wall-clock seconds.
let shift = content_to_secs(
actual_extend,
clip_content,
instance.timeline_start,
tempo_map,
);
let clamped_timeline_start = tempo_map let clamped_timeline_start = tempo_map
.seconds_to_beats(tempo_map.beats_to_seconds(old_timeline) - Seconds(actual_extend)) .seconds_to_beats(tempo_map.beats_to_seconds(old_timeline) - shift)
.max(Beats::ZERO); .max(Beats::ZERO);
clamped_new = TrimData::left(clamped_trim_start, clamped_timeline_start); clamped_new = TrimData::left(clamped_trim_start, clamped_timeline_start);
@ -228,36 +282,39 @@ impl Action for TrimClipInstancesAction {
} }
} }
TrimType::TrimRight => { TrimType::TrimRight => {
let old_trim_end = old.trim_value.unwrap_or(clip_duration.seconds_to_f64()); let content_end = ContentTime(clip_content.native());
let new_trim_end = new.trim_value.unwrap_or(clip_duration.seconds_to_f64()); let old_trim_end = old.trim_value.unwrap_or(content_end);
let new_trim_end = new.trim_value.unwrap_or(content_end);
// 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 {
let tempo_map = document.tempo_map(); let tempo_map = document.tempo_map();
// Current effective duration in beats (content seconds
// converted to beats at the clip's start).
let content_secs = Seconds(old_trim_end - instance.trim_start);
let current_effective_duration = tempo_map.seconds_to_beats(
tempo_map.beats_to_seconds(instance.timeline_start) + content_secs,
) - instance.timeline_start;
// Max rightward extension as content seconds (the gap's wall-clock span). // How long the clip currently occupies the timeline, in beats. Resolved
let max_extend_secs = document.find_max_trim_extend_right( // in the clip's own domain, so a MIDI clip's beats content isn't run
layer_id, // through the seconds→beats conversion a second time.
instance_id, let current_effective_duration = instance
.effective_duration_beats(clip_content, tempo_map);
// Max rightward extension: the gap's wall-clock span, in content domain.
let max_extend = gap_to_content(
document.find_max_trim_extend_right(
layer_id,
instance_id,
instance.timeline_start,
current_effective_duration,
),
clip_content,
instance.timeline_start, instance.timeline_start,
current_effective_duration, tempo_map,
).seconds_to_f64(); );
// 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;
let actual_extend = desired_extend.min(max_extend);
// Clamp to max allowed
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 the clip's content.
let final_trim_end = clamped_trim_end.min(clip_duration.seconds_to_f64()); let final_trim_end = clamped_trim_end.min(content_end);
clamped_new = TrimData::right(Some(final_trim_end)); clamped_new = TrimData::right(Some(final_trim_end));
} }
@ -387,8 +444,9 @@ impl Action for TrimClipInstancesAction {
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, document.tempo_map().beats_to_seconds(instance.timeline_start)); controller.set_offset(metatrack_id, document.tempo_map().beats_to_seconds(instance.timeline_start));
controller.set_trim_start(metatrack_id, daw_backend::Seconds(instance.trim_start)); // A vector clip's content is wall-clock, so its content times ARE seconds.
controller.set_trim_end(metatrack_id, instance.trim_end.map(daw_backend::Seconds)); controller.set_trim_start(metatrack_id, daw_backend::Seconds(instance.trim_start.raw()));
controller.set_trim_end(metatrack_id, instance.trim_end.map(|t| daw_backend::Seconds(t.raw())));
} }
} }
} }
@ -424,7 +482,9 @@ impl Action for TrimClipInstancesAction {
// Calculate new internal_start and internal_end for backend // Calculate new internal_start and internal_end for backend
// Note: instance already has the new trim values after execute() // Note: instance already has the new trim values after execute()
let internal_start = instance.trim_start; let internal_start = instance.trim_start;
let internal_end = instance.trim_end.unwrap_or(clip.content_duration().native()); let internal_end = instance
.trim_end
.unwrap_or(ContentTime(clip.content_duration().native()));
// Handle trim based on clip type // Handle trim based on clip type
match &clip.resolve(instance.active_take) { match &clip.resolve(instance.active_take) {
@ -477,8 +537,9 @@ impl Action for TrimClipInstancesAction {
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, document.tempo_map().beats_to_seconds(instance.timeline_start)); controller.set_offset(metatrack_id, document.tempo_map().beats_to_seconds(instance.timeline_start));
controller.set_trim_start(metatrack_id, daw_backend::Seconds(instance.trim_start)); // A vector clip's content is wall-clock, so its content times ARE seconds.
controller.set_trim_end(metatrack_id, instance.trim_end.map(daw_backend::Seconds)); controller.set_trim_start(metatrack_id, daw_backend::Seconds(instance.trim_start.raw()));
controller.set_trim_end(metatrack_id, instance.trim_end.map(|t| daw_backend::Seconds(t.raw())));
} }
} }
} }
@ -512,13 +573,14 @@ impl Action for TrimClipInstancesAction {
.ok_or_else(|| format!("Audio clip {} not found", instance.clip_id))?; .ok_or_else(|| format!("Audio clip {} not found", instance.clip_id))?;
// Calculate old internal_start and internal_end for backend // Calculate old internal_start and internal_end for backend
let content_end = ContentTime(clip.content_duration().native());
let internal_start = match trim_type { let internal_start = match trim_type {
TrimType::TrimLeft => old.trim_value.unwrap_or(0.0), TrimType::TrimLeft => old.trim_value.unwrap_or(ContentTime::ZERO),
TrimType::TrimRight => instance.trim_start, // trim_start wasn't changed TrimType::TrimRight => instance.trim_start, // trim_start wasn't changed
}; };
let internal_end = match trim_type { let internal_end = match trim_type {
TrimType::TrimLeft => instance.trim_end.unwrap_or(clip.content_duration().native()), // trim_end wasn't changed TrimType::TrimLeft => instance.trim_end.unwrap_or(content_end), // trim_end wasn't changed
TrimType::TrimRight => old.trim_value.unwrap_or(clip.content_duration().native()), TrimType::TrimRight => old.trim_value.unwrap_or(content_end),
}; };
// Handle trim based on clip type // Handle trim based on clip type
@ -569,7 +631,7 @@ mod tests {
let mut clip_instance = ClipInstance::new(clip_id); let mut clip_instance = ClipInstance::new(clip_id);
clip_instance.timeline_start = Beats::ZERO; clip_instance.timeline_start = Beats::ZERO;
clip_instance.trim_start = 0.0; clip_instance.trim_start = ContentTime::ZERO;
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);
@ -582,8 +644,8 @@ mod tests {
vec![( vec![(
instance_id, instance_id,
TrimType::TrimLeft, TrimType::TrimLeft,
TrimData::left(0.0, Beats::ZERO), TrimData::left(ContentTime::ZERO, Beats::ZERO),
TrimData::left(2.0, Beats(2.0)), TrimData::left(ContentTime(2.0), Beats(2.0)),
)], )],
); );
@ -599,7 +661,7 @@ mod tests {
.iter() .iter()
.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, ContentTime(2.0));
assert_eq!(instance.timeline_start, Beats(2.0)); assert_eq!(instance.timeline_start, Beats(2.0));
} }
@ -613,7 +675,7 @@ mod tests {
.iter() .iter()
.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, ContentTime::ZERO);
assert_eq!(instance.timeline_start, Beats::ZERO); assert_eq!(instance.timeline_start, Beats::ZERO);
} }
} }
@ -644,7 +706,7 @@ mod tests {
instance_id, instance_id,
TrimType::TrimRight, TrimType::TrimRight,
TrimData::right(None), TrimData::right(None),
TrimData::right(Some(8.0)), TrimData::right(Some(ContentTime(8.0))),
)], )],
); );
@ -660,7 +722,7 @@ mod tests {
.iter() .iter()
.find(|ci| ci.id == instance_id) .find(|ci| ci.id == instance_id)
.unwrap(); .unwrap();
assert_eq!(instance.trim_end, Some(8.0)); assert_eq!(instance.trim_end, Some(ContentTime(8.0)));
} }
// Rollback // Rollback

View File

@ -14,7 +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 daw_backend::{Beats, ContentTime, Seconds};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::path::PathBuf; use std::path::PathBuf;
use uuid::Uuid; use uuid::Uuid;
@ -130,10 +130,14 @@ impl VectorClip {
let end_beats: 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); // `clip_duration_fn` hands back seconds, so this whole path treats nested
// content as wall-clock. That's right for the vector/video/audio clips a vector
// clip actually nests; a nested MIDI clip (beats content) would need resolving
// against its clip, which this callback can't do. Pre-existing limitation.
let secs = (te - ci.trim_start).raw().max(0.0);
tempo_map.seconds_to_beats(tempo_map.beats_to_seconds(ci.timeline_start) + Seconds(secs)) 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.raw()).max(0.0);
tempo_map.seconds_to_beats(tempo_map.beats_to_seconds(ci.timeline_start) + Seconds(secs)) tempo_map.seconds_to_beats(tempo_map.beats_to_seconds(ci.timeline_start) + Seconds(secs))
} else { } else {
continue; continue;
@ -201,7 +205,9 @@ impl VectorClip {
// 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().beats_to_seconds(clip_instance.timeline_start).seconds_to_f64(); 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; // Nested clips here are vector clips, whose content is wall-clock seconds.
let nested_clip_time =
((clip_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start.raw();
// Look up the nested clip definition // Look up the nested clip definition
let nested_bounds = if let Some(nested_clip) = document.get_vector_clip(&clip_instance.clip_id) { let nested_bounds = if let Some(nested_clip) = document.get_vector_clip(&clip_instance.clip_id) {
@ -535,6 +541,17 @@ impl ClipDuration {
ClipDuration::Beats(b) => b.beats_to_f64(), ClipDuration::Beats(b) => b.beats_to_f64(),
} }
} }
/// Tag a [`ContentTime`] with *this* duration's domain.
///
/// Handy when you already hold a clip's content duration (so you know the domain) and need to
/// resolve one of its trim bounds, without going back to the clip.
pub fn same_domain(self, t: ContentTime) -> ClipDuration {
match self {
ClipDuration::Seconds(_) => ClipDuration::Seconds(Seconds(t.raw())),
ClipDuration::Beats(_) => ClipDuration::Beats(Beats(t.raw())),
}
}
} }
/// Audio clip /// Audio clip
@ -739,21 +756,33 @@ impl AudioClip {
} }
} }
/// Tag a pair of raw trim bounds with this clip's content domain, ready for the backend. /// Resolve a content time against this clip's domain.
/// ///
/// `ClipInstance::trim_start`/`trim_end` are bare `f64`s whose unit depends on the clip — /// This is the ONLY sanctioned way to turn a [`ContentTime`] into a real duration — the type has
/// SECONDS for sampled audio, BEATS for MIDI. Building the [`TrimRange`] from the clip means a /// no `.to_seconds()` of its own precisely so that the clip, which is the one thing that knows
/// caller can't reach for the wrong variant: the clip is the one thing that knows. /// whether its content is measured in seconds or beats, has to be consulted.
pub fn trim_range(&self, start: f64, end: f64) -> daw_backend::command::TrimRange { pub fn resolve_content_time(&self, t: ContentTime) -> ClipDuration {
if self.is_midi_domain() {
ClipDuration::Beats(Beats(t.raw()))
} else {
ClipDuration::Seconds(Seconds(t.raw()))
}
}
/// Tag a pair of trim bounds with this clip's content domain, ready for the backend.
///
/// Building the [`TrimRange`] from the clip means a caller can't reach for the wrong variant:
/// the clip is the one thing that knows the domain.
pub fn trim_range(&self, start: ContentTime, end: ContentTime) -> daw_backend::command::TrimRange {
if self.is_midi_domain() { if self.is_midi_domain() {
daw_backend::command::TrimRange::Beats { daw_backend::command::TrimRange::Beats {
start: Beats(start), start: Beats(start.raw()),
end: Beats(end), end: Beats(end.raw()),
} }
} else { } else {
daw_backend::command::TrimRange::Seconds { daw_backend::command::TrimRange::Seconds {
start: Seconds(start), start: Seconds(start.raw()),
end: Seconds(end), end: Seconds(end.raw()),
} }
} }
} }
@ -885,16 +914,17 @@ pub struct ClipInstance {
/// Default: None (use trimmed clip duration, no looping) /// Default: None (use trimmed clip duration, no looping)
pub timeline_duration: Option<Beats>, 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.
/// - For audio: byte-offset into the audio file ///
/// - For video: seek position in the video file /// A [`ContentTime`] — measured in the CLIP's content domain, which is seconds for sampled
/// - For vector: time offset into the animation /// audio/video/vector but BEATS for MIDI. Resolve it against the clip
/// ([`Document::resolve_content_time`]) before combining it with anything on the timeline.
/// Default: 0.0 /// Default: 0.0
pub trim_start: f64, pub trim_start: ContentTime,
/// Trim end: offset into the clip's internal content, in **seconds**. /// Trim end: offset into the clip's internal content. See [`Self::trim_start`].
/// Default: None (use full clip duration) /// Default: None (use full clip duration)
pub trim_end: Option<f64>, pub trim_end: Option<ContentTime>,
/// Playback speed multiplier /// Playback speed multiplier
/// 1.0 = normal speed, 0.5 = half speed, 2.0 = double speed /// 1.0 = normal speed, 0.5 = half speed, 2.0 = double speed
@ -973,7 +1003,7 @@ impl ClipInstance {
name: None, name: None,
timeline_start: Beats::ZERO, timeline_start: Beats::ZERO,
timeline_duration: None, timeline_duration: None,
trim_start: 0.0, trim_start: ContentTime::ZERO,
trim_end: None, trim_end: None,
playback_speed: 1.0, playback_speed: 1.0,
gain: 1.0, gain: 1.0,
@ -992,7 +1022,7 @@ impl ClipInstance {
name: None, name: None,
timeline_start: Beats::ZERO, timeline_start: Beats::ZERO,
timeline_duration: None, timeline_duration: None,
trim_start: 0.0, trim_start: ContentTime::ZERO,
trim_end: None, trim_end: None,
playback_speed: 1.0, playback_speed: 1.0,
gain: 1.0, gain: 1.0,
@ -1033,7 +1063,7 @@ impl ClipInstance {
} }
/// Set trimming (start and end time within the clip's internal content) /// Set trimming (start and end time within the clip's internal content)
pub fn with_trimming(mut self, trim_start: f64, trim_end: Option<f64>) -> Self { pub fn with_trimming(mut self, trim_start: ContentTime, trim_end: Option<ContentTime>) -> Self {
self.trim_start = trim_start; self.trim_start = trim_start;
self.trim_end = trim_end; self.trim_end = trim_end;
self self
@ -1057,24 +1087,40 @@ impl ClipInstance {
self self
} }
/// Content window size in seconds: `trim_end - trim_start`. /// Content window (`trim_end - trim_start`) in the clip's own content domain.
/// Used for internal looping calculations. /// Used for internal looping calculations.
pub fn content_window_secs(&self, clip_duration_secs: Seconds) -> Seconds { pub fn content_window(&self, clip_content: ClipDuration) -> ClipDuration {
let end = self.trim_end.unwrap_or(clip_duration_secs.seconds_to_f64()); let end = self.trim_end.map_or(clip_content.native(), |t| t.raw());
Seconds((end - self.trim_start).max(0.0)) let window = (end - self.trim_start.raw()).max(0.0);
match clip_content {
ClipDuration::Beats(_) => ClipDuration::Beats(Beats(window)),
ClipDuration::Seconds(_) => ClipDuration::Seconds(Seconds(window)),
}
} }
/// 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 the clip
/// Otherwise converts the content window from seconds to beats using the tempo map. /// occupies its content window — converted to beats *in the clip's own domain*:
pub fn effective_duration_beats(&self, clip_duration_secs: Seconds, tempo_map: &crate::tempo_map::TempoMap) -> Beats { ///
/// - MIDI content is already beats and is tempo-invariant, so it carries over directly.
/// - Wall-clock content (audio/video/vector) is a seconds span, so it converts at the clip's
/// position on the timeline.
///
/// Taking a `ClipDuration` rather than a bare `Seconds` is what keeps those apart: this used to
/// take seconds and subtract `trim_start` from it, which for a TRIMMED MIDI clip subtracted a
/// beats offset from a seconds duration and got the clip's length wrong.
pub fn effective_duration_beats(&self, clip_content: ClipDuration, 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 = self.content_window_secs(clip_duration_secs); match self.content_window(clip_content) {
let start_secs = tempo_map.beats_to_seconds(self.timeline_start); ClipDuration::Beats(b) => b,
tempo_map.seconds_to_beats(start_secs + window) - self.timeline_start ClipDuration::Seconds(s) => {
let start_secs = tempo_map.beats_to_seconds(self.timeline_start);
tempo_map.seconds_to_beats(start_secs + s) - 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**.
@ -1083,27 +1129,32 @@ impl ClipInstance {
} }
/// 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: Seconds, tempo_map: &crate::tempo_map::TempoMap) -> Beats { pub fn total_duration(&self, clip_content: ClipDuration, tempo_map: &crate::tempo_map::TempoMap) -> Beats {
self.loop_before.unwrap_or(Beats::ZERO) + self.effective_duration_beats(clip_duration_secs, tempo_map) self.loop_before.unwrap_or(Beats::ZERO) + self.effective_duration_beats(clip_content, 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`. /// The trim bounds are resolved through `clip_content`'s domain first, so a MIDI clip's beats
pub fn remap_time_secs(&self, time: Seconds, clip_duration_secs: Seconds, tempo_map: &crate::tempo_map::TempoMap) -> Option<Seconds> { /// trims are converted rather than read as seconds. Callers are the wall-clock consumers (video
/// seek, vector/raster rendering), which want seconds regardless of how the clip stores content.
///
/// Returns `None` if the clip instance is not active at `time`.
pub fn remap_time_secs(&self, time: Seconds, clip_content: ClipDuration, tempo_map: &crate::tempo_map::TempoMap) -> Option<Seconds> {
let start_secs = tempo_map.beats_to_seconds(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_content, tempo_map);
let end_secs = tempo_map.beats_to_seconds(self.timeline_start + dur_beats); let end_secs = tempo_map.beats_to_seconds(self.timeline_start + dur_beats);
if time < start_secs || time >= end_secs { if time < start_secs || time >= end_secs {
return None; return None;
} }
let trim_start_secs = clip_content.same_domain(self.trim_start).to_seconds(tempo_map);
let content_time = (time - start_secs) * self.playback_speed; let content_time = (time - start_secs) * self.playback_speed;
let content_window = self.content_window_secs(clip_duration_secs); let content_window = self.content_window(clip_content).to_seconds(tempo_map);
if content_window == Seconds::ZERO { if content_window == Seconds::ZERO {
return Some(Seconds(self.trim_start)); return Some(trim_start_secs);
} }
let looped = if content_time > content_window { let looped = if content_time > content_window {
@ -1112,19 +1163,19 @@ impl ClipInstance {
content_time content_time
}; };
Some(Seconds(self.trim_start) + looped) Some(trim_start_secs + looped)
} }
/// Alias for `remap_time_secs`. /// Alias for `remap_time_secs`.
#[inline] #[inline]
pub fn remap_time(&self, time: Seconds, clip_duration_secs: Seconds, tempo_map: &crate::tempo_map::TempoMap) -> Option<Seconds> { pub fn remap_time(&self, time: Seconds, clip_content: ClipDuration, tempo_map: &crate::tempo_map::TempoMap) -> Option<Seconds> {
self.remap_time_secs(time, clip_duration_secs, tempo_map) self.remap_time_secs(time, clip_content, tempo_map)
} }
/// Alias for `effective_duration_beats`. /// Alias for `effective_duration_beats`.
#[inline] #[inline]
pub fn effective_duration(&self, clip_duration_secs: Seconds, tempo_map: &crate::tempo_map::TempoMap) -> Beats { pub fn effective_duration(&self, clip_content: ClipDuration, tempo_map: &crate::tempo_map::TempoMap) -> Beats {
self.effective_duration_beats(clip_duration_secs, tempo_map) self.effective_duration_beats(clip_content, tempo_map)
} }
/// Convert to affine transform /// Convert to affine transform
@ -1201,7 +1252,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, Beats::ZERO); assert_eq!(instance.timeline_start, Beats::ZERO);
assert_eq!(instance.trim_start, 0.0); assert_eq!(instance.trim_start, ContentTime::ZERO);
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);
assert_eq!(instance.gain, 1.0); assert_eq!(instance.gain, 1.0);
@ -1211,27 +1262,52 @@ mod tests {
fn test_clip_instance_trimming() { fn test_clip_instance_trimming() {
let clip_id = Uuid::new_v4(); let clip_id = Uuid::new_v4();
let instance = ClipInstance::new(clip_id) let instance = ClipInstance::new(clip_id)
.with_trimming(2.0, Some(8.0)); .with_trimming(ContentTime(2.0), Some(ContentTime(8.0)));
assert_eq!(instance.trim_start, 2.0); assert_eq!(instance.trim_start, ContentTime(2.0));
assert_eq!(instance.trim_end, Some(8.0)); assert_eq!(instance.trim_end, Some(ContentTime(8.0)));
// 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(Seconds(10.0), &tempo_map), Beats(6.0)); let content = ClipDuration::Seconds(Seconds(10.0));
assert_eq!(instance.effective_duration(content, &tempo_map), Beats(6.0));
} }
#[test] #[test]
fn test_clip_instance_no_end_trim() { fn test_clip_instance_no_end_trim() {
let clip_id = Uuid::new_v4(); let clip_id = Uuid::new_v4();
let instance = ClipInstance::new(clip_id) let instance = ClipInstance::new(clip_id)
.with_trimming(2.0, None); .with_trimming(ContentTime(2.0), None);
assert_eq!(instance.trim_start, 2.0); assert_eq!(instance.trim_start, ContentTime(2.0));
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(Seconds(10.0), &tempo_map), Beats(8.0)); let content = ClipDuration::Seconds(Seconds(10.0));
assert_eq!(instance.effective_duration(content, &tempo_map), Beats(8.0));
}
#[test]
fn trimmed_midi_clip_keeps_its_beats_length_across_tempo() {
// Regression: `effective_duration_beats` used to take a SECONDS clip duration and subtract
// `trim_start` from it. For a TRIMMED MIDI clip that subtracted a beats offset from a
// seconds duration, so the clip's timeline length came out wrong at any tempo but 60 BPM.
//
// MIDI content is beats and tempo-invariant: a clip trimmed to beats 2..6 is 4 beats long
// whatever the tempo says.
let clip_id = Uuid::new_v4();
let instance = ClipInstance::new(clip_id)
.with_trimming(ContentTime(2.0), Some(ContentTime(6.0)));
let content = ClipDuration::Beats(Beats(8.0));
for bpm in [60.0, 120.0, 90.0] {
let tempo_map = crate::tempo_map::TempoMap::constant(bpm);
assert_eq!(
instance.effective_duration(content, &tempo_map),
Beats(4.0),
"a MIDI clip trimmed to beats 2..6 is 4 beats long at {bpm} BPM",
);
}
} }
#[test] #[test]

View File

@ -464,8 +464,11 @@ impl Document {
let end_beats: Beats = if let Some(timeline_duration) = instance.timeline_duration { let end_beats: Beats = if let Some(timeline_duration) = instance.timeline_duration {
instance.timeline_start + timeline_duration instance.timeline_start + timeline_duration
} else { } else {
let trim_end = instance.trim_end.unwrap_or(clip_duration); // `clip_duration` arrives as seconds (the recursive helper's signature), so this
let trimmed_secs = ((trim_end - instance.trim_start) / instance.playback_speed).max(0.0); // path is the wall-clock one; MIDI content would need resolving against its clip.
let trim_end = instance.trim_end.map_or(clip_duration, |t| t.raw());
let trimmed_secs =
((trim_end - instance.trim_start.raw()) / instance.playback_speed).max(0.0);
let start_secs = tempo_map.beats_to_seconds(instance.timeline_start); let start_secs = tempo_map.beats_to_seconds(instance.timeline_start);
tempo_map.seconds_to_beats(start_secs + Seconds(trimmed_secs)) tempo_map.seconds_to_beats(start_secs + Seconds(trimmed_secs))
}; };
@ -915,11 +918,10 @@ impl Document {
/// have infinite internal duration. /// have infinite internal duration.
/// A clip's content duration **in the domain its `trim_start`/`trim_end` are measured in**. /// A clip's content duration **in the domain its `trim_start`/`trim_end` are measured in**.
/// ///
/// `ClipInstance::trim_*` is domain-polymorphic exactly like `AudioClip::duration`: SECONDS for /// Content time is domain-polymorphic exactly like `AudioClip::duration`: SECONDS for sampled
/// sampled audio, video and vector, but BEATS for MIDI (the backend takes MIDI trims as /// audio, video and vector, but BEATS for MIDI. Anything doing arithmetic against a trim value —
/// `Beats`). Anything doing arithmetic against a trim value — mapping a timeline position into /// mapping a timeline position into the clip's content, say — has to work in that same domain,
/// the clip's content, say — has to work in that same domain, and [`Self::get_clip_duration`] /// and [`Self::get_clip_duration`] can't tell it which: that one always converts to seconds.
/// can't tell it which: that one always converts to seconds.
/// ///
/// Returns `None` for unknown clips. /// Returns `None` for unknown clips.
pub fn clip_trim_duration(&self, clip_id: &Uuid) -> Option<crate::clip::ClipDuration> { pub fn clip_trim_duration(&self, clip_id: &Uuid) -> Option<crate::clip::ClipDuration> {
@ -930,6 +932,29 @@ impl Document {
self.get_clip_duration(clip_id).map(crate::clip::ClipDuration::Seconds) self.get_clip_duration(clip_id).map(crate::clip::ClipDuration::Seconds)
} }
/// Resolve a [`ContentTime`] (a trim bound) against the clip it belongs to.
///
/// The clip is the only thing that knows whether its content is measured in seconds or beats, so
/// this is the sanctioned exit from `ContentTime`. Works for every clip kind, not just audio.
/// Returns `None` for unknown clips.
pub fn resolve_content_time(
&self,
clip_id: &Uuid,
t: daw_backend::ContentTime,
) -> Option<crate::clip::ClipDuration> {
if let Some(clip) = self.audio_clips.get(clip_id) {
return Some(clip.resolve_content_time(t));
}
if self.vector_clips.contains_key(clip_id)
|| self.video_clips.contains_key(clip_id)
|| self.effect_definitions.contains_key(clip_id)
{
// Wall-clock content.
return Some(crate::clip::ClipDuration::Seconds(Seconds(t.raw())));
}
None
}
pub fn get_clip_duration(&self, clip_id: &Uuid) -> Option<Seconds> { 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 {
@ -983,9 +1008,9 @@ 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)?; // The clip's content duration in ITS OWN domain, so the trims resolve correctly for MIDI.
// End position on the timeline, in beats (convert the seconds content window via tempo map). let clip_content = self.clip_trim_duration(&instance.clip_id)?;
Some(instance.timeline_start + instance.effective_duration_beats(clip_duration, self.tempo_map())) Some(instance.timeline_start + instance.effective_duration_beats(clip_content, self.tempo_map()))
} }
/// 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
@ -1025,13 +1050,14 @@ impl Document {
continue; continue;
} }
// Calculate instance extent (accounting for loop_before) // Calculate instance extent (accounting for loop_before). Content duration in the clip's
let Some(clip_duration) = self.get_clip_duration(&instance.clip_id) else { // own domain, so the trims resolve correctly for MIDI.
let Some(clip_content) = self.clip_trim_duration(&instance.clip_id) else {
continue; continue;
}; };
let instance_start = instance.effective_start(); let instance_start = instance.effective_start();
let instance_end = instance.timeline_start + instance.effective_duration(clip_duration, self.tempo_map()); let instance_end = instance.timeline_start + instance.effective_duration(clip_content, self.tempo_map());
// Check overlap: start_a < end_b AND start_b < end_a // Check overlap: start_a < end_b AND start_b < end_a
if start_time < instance_end && instance_start < end_time { if start_time < instance_end && instance_start < end_time {
@ -1088,7 +1114,7 @@ impl Document {
continue; continue;
} }
if let Some(clip_dur) = self.get_clip_duration(&instance.clip_id) { if let Some(clip_dur) = self.clip_trim_duration(&instance.clip_id) {
let inst_start = instance.effective_start(); let inst_start = instance.effective_start();
let inst_end = instance.timeline_start + instance.effective_duration(clip_dur, self.tempo_map()); let inst_end = instance.timeline_start + instance.effective_duration(clip_dur, self.tempo_map());
occupied_ranges.push((inst_start, inst_end, instance.id)); occupied_ranges.push((inst_start, inst_end, instance.id));
@ -1184,7 +1210,7 @@ impl Document {
if group_ids.contains(&inst.id) { if group_ids.contains(&inst.id) {
continue; continue;
} }
if let Some(dur) = self.get_clip_duration(&inst.clip_id) { if let Some(dur) = self.clip_trim_duration(&inst.clip_id) {
let start = inst.effective_start(); let start = inst.effective_start();
let end = inst.timeline_start + inst.effective_duration(dur, self.tempo_map()); let end = inst.timeline_start + inst.effective_duration(dur, self.tempo_map());
non_group.push((start, end)); non_group.push((start, end));
@ -1258,9 +1284,8 @@ 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.clip_trim_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 {
@ -1358,7 +1383,7 @@ impl Document {
continue; continue;
} }
if let Some(clip_duration) = self.get_clip_duration(&other.clip_id) { if let Some(clip_duration) = self.clip_trim_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());
if other_end <= current_effective_start && other_end > nearest_end { if other_end <= current_effective_start && other_end > nearest_end {

View File

@ -152,7 +152,12 @@ impl EffectLayer {
self.clip_instances self.clip_instances
.iter() .iter()
.filter(|e| { .filter(|e| {
let end = e.timeline_start + e.effective_duration(daw_backend::Seconds(EFFECT_DURATION), tempo_map); // Effects have an "infinite" wall-clock content length.
let end = e.timeline_start
+ e.effective_duration(
crate::clip::ClipDuration::Seconds(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()

View File

@ -3,7 +3,7 @@
//! Provides functions for testing if points or rectangles intersect with //! Provides functions for testing if points or rectangles intersect with
//! vector graph elements and clip instances, taking into account transform hierarchies. //! vector graph elements and clip instances, taking into account transform hierarchies.
use crate::clip::ClipInstance; use crate::clip::{ClipDuration, ClipInstance};
use crate::vector_graph::{VertexId, EdgeId, FillId}; use crate::vector_graph::{VertexId, EdgeId, FillId};
use crate::layer::VectorLayer; use crate::layer::VectorLayer;
use crate::shape::Shape; use crate::shape::Shape;
@ -260,7 +260,10 @@ 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(daw_backend::Seconds::ZERO); // Hit-testing runs on vector/raster content, which is wall-clock seconds.
let clip_duration = ClipDuration::Seconds(
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.seconds_to_beats(daw_backend::Seconds(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 {
@ -269,7 +272,8 @@ pub fn hit_test_clip_instances(
// 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.beats_to_seconds(clip_instance.timeline_start).seconds_to_f64(); 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.raw();
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) {
vector_clip.calculate_content_bounds(document, clip_time) vector_clip.calculate_content_bounds(document, clip_time)
@ -304,7 +308,10 @@ 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(daw_backend::Seconds::ZERO); // Hit-testing runs on vector/raster content, which is wall-clock seconds.
let clip_duration = ClipDuration::Seconds(
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.seconds_to_beats(daw_backend::Seconds(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 {
@ -312,7 +319,8 @@ pub fn hit_test_clip_instances_in_rect(
} }
let start_secs = tempo_map.beats_to_seconds(clip_instance.timeline_start).seconds_to_f64(); 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.raw();
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) {
vector_clip.calculate_content_bounds(document, clip_time) vector_clip.calculate_content_bounds(document, clip_time)

View File

@ -9,7 +9,7 @@
//! The compositing mode enables proper per-layer opacity, blend modes, and effects. //! The compositing mode enables proper per-layer opacity, blend modes, and effects.
use crate::animation::TransformProperty; use crate::animation::TransformProperty;
use crate::clip::{ClipInstance, ImageAsset}; use crate::clip::{ClipDuration, ClipInstance, ImageAsset};
use crate::document::Document; use crate::document::Document;
use daw_backend::Seconds; use daw_backend::Seconds;
use crate::gpu::BlendMode; use crate::gpu::BlendMode;
@ -568,7 +568,7 @@ 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(Seconds(time), Seconds(video_clip.duration), tempo_map) else { continue }; let Some(clip_time) = clip_instance.remap_time(Seconds(time), ClipDuration::Seconds(Seconds(video_clip.duration)), tempo_map) else { continue };
let clip_time = clip_time.seconds_to_f64(); 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 };
@ -1019,7 +1019,10 @@ fn render_clip_instance(
} }
0.0 0.0
} else { } else {
let clip_dur = document.get_clip_duration(&vector_clip.id).unwrap_or(Seconds(vector_clip.duration)); // A vector clip's content is wall-clock seconds.
let clip_dur = ClipDuration::Seconds(
document.get_clip_duration(&vector_clip.id).unwrap_or(Seconds(vector_clip.duration)),
);
let Some(t) = clip_instance.remap_time(Seconds(time), clip_dur, tempo_map) else { 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
}; };
@ -1174,7 +1177,7 @@ 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(Seconds(time), Seconds(video_clip.duration), tempo_map) else { let Some(clip_time) = clip_instance.remap_time(Seconds(time), ClipDuration::Seconds(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(); let clip_time = clip_time.seconds_to_f64();
@ -1910,7 +1913,10 @@ fn render_clip_instance_cpu(
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(Seconds(vector_clip.duration)); // A vector clip's content is wall-clock seconds.
let clip_dur = ClipDuration::Seconds(
document.get_clip_duration(&vector_clip.id).unwrap_or(Seconds(vector_clip.duration)),
);
let Some(t) = clip_instance.remap_time(Seconds(time), clip_dur, tempo_map) else { return }; let Some(t) = clip_instance.remap_time(Seconds(time), clip_dur, tempo_map) else { return };
t.seconds_to_f64() t.seconds_to_f64()
}; };

View File

@ -1064,7 +1064,12 @@ fn composite_document_to_hdr(
} }
let tempo_map = document.tempo_map(); let tempo_map = document.tempo_map();
let effect_end_beats = effect_instance.timeline_start let effect_end_beats = effect_instance.timeline_start
+ effect_instance.effective_duration(daw_backend::Seconds(lightningbeam_core::effect::EFFECT_DURATION), tempo_map); + effect_instance.effective_duration(
lightningbeam_core::clip::ClipDuration::Seconds(daw_backend::Seconds(
lightningbeam_core::effect::EFFECT_DURATION,
)),
tempo_map,
);
let effect_inst = lightningbeam_core::effect::EffectInstance::new( let effect_inst = lightningbeam_core::effect::EffectInstance::new(
effect_def, effect_def,
tempo_map.beats_to_seconds(effect_instance.timeline_start).seconds_to_f64(), tempo_map.beats_to_seconds(effect_instance.timeline_start).seconds_to_f64(),

View File

@ -2387,7 +2387,7 @@ impl EditorApp {
) -> Vec<uuid::Uuid> { ) -> Vec<uuid::Uuid> {
let mut result = Vec::new(); let mut result = Vec::new();
for instance in clip_instances { for instance in clip_instances {
if let Some(clip_duration) = document.get_clip_duration(&instance.clip_id) { if let Some(clip_duration) = document.clip_trim_duration(&instance.clip_id) {
let effective_duration = instance.effective_duration(clip_duration, document.tempo_map()); let effective_duration = instance.effective_duration(clip_duration, document.tempo_map());
let timeline_end = instance.timeline_start + effective_duration; let timeline_end = instance.timeline_start + effective_duration;
@ -3276,7 +3276,9 @@ impl EditorApp {
let duplicates: Vec<lightningbeam_core::clip::ClipInstance> = clips_to_duplicate.iter().map(|original| { let duplicates: Vec<lightningbeam_core::clip::ClipInstance> = clips_to_duplicate.iter().map(|original| {
let mut duplicate = original.clone(); let mut duplicate = original.clone();
duplicate.id = uuid::Uuid::new_v4(); duplicate.id = uuid::Uuid::new_v4();
let clip_duration = document.get_clip_duration(&original.clip_id).unwrap_or(Seconds(1.0)); let clip_duration = document
.clip_trim_duration(&original.clip_id)
.unwrap_or(ClipDuration::Seconds(Seconds(1.0)));
let effective_duration = original.effective_duration(clip_duration, document.tempo_map()); let effective_duration = original.effective_duration(clip_duration, document.tempo_map());
duplicate.timeline_start = original.timeline_start + effective_duration; duplicate.timeline_start = original.timeline_start + effective_duration;
if let Some((new_clip_def_id, _)) = midi_clip_replacements.get(&original.clip_id) { if let Some((new_clip_def_id, _)) = midi_clip_replacements.get(&original.clip_id) {
@ -5477,7 +5479,7 @@ impl EditorApp {
// matches the video clip exactly). // matches the video clip exactly).
let (_dur, sample_rate, channels) = controller let (_dur, sample_rate, channels) = controller
.get_pool_file_info(pool_index) .get_pool_file_info(pool_index)
.unwrap_or((video_duration, 0, 0)); .unwrap_or((Seconds(video_duration), 0, 0));
drop(controller); drop(controller);
let audio_clip_name = format!("{} (Audio)", video_name); let audio_clip_name = format!("{} (Audio)", video_name);
@ -6384,7 +6386,9 @@ impl eframe::App for EditorApp {
use daw_backend::AudioEvent; use daw_backend::AudioEvent;
match event { match event {
AudioEvent::PlaybackPosition(time) => { AudioEvent::PlaybackPosition(time) => {
self.playback_time = time; // `playback_time` is the UI's seconds playhead (see the timeline's
// seconds/beats model); unwrap at this boundary, not before it.
self.playback_time = time.seconds_to_f64();
} }
AudioEvent::PlaybackStopped => { AudioEvent::PlaybackStopped => {
self.is_playing = false; self.is_playing = false;
@ -6595,8 +6599,11 @@ impl eframe::App for EditorApp {
if let Some(inst) = al.clip_instances.iter_mut().find(|ci| ci.id == instance_id) { if let Some(inst) = al.clip_instances.iter_mut().find(|ci| ci.id == instance_id) {
inst.timeline_start = loop_start; inst.timeline_start = loop_start;
inst.timeline_duration = None; inst.timeline_duration = None;
inst.trim_start = 0.0; // Audio take content is seconds, and each take spans
inst.trim_end = Some(loop_len_seconds.seconds_to_f64()); // exactly one cycle region.
inst.trim_start = daw_backend::ContentTime::ZERO;
inst.trim_end =
Some(daw_backend::ContentTime(loop_len_seconds.seconds_to_f64()));
inst.active_take = Some(last_take); inst.active_take = Some(last_take);
} }
} }
@ -6655,6 +6662,7 @@ impl eframe::App for EditorApp {
let mut controller = controller_arc.lock().unwrap(); let mut controller = controller_arc.lock().unwrap();
match controller.get_pool_file_info(pool_index) { match controller.get_pool_file_info(pool_index) {
Ok((dur, _, _)) => { Ok((dur, _, _)) => {
let dur = dur.seconds_to_f64();
eprintln!("[AUDIO] Got duration from backend: {:.4}s", dur); eprintln!("[AUDIO] Got duration from backend: {:.4}s", dur);
self.audio_duration_cache.insert(pool_index, dur); self.audio_duration_cache.insert(pool_index, dur);
dur dur
@ -6688,7 +6696,7 @@ impl eframe::App for EditorApp {
None None
} }
}) })
.unwrap_or((uuid::Uuid::nil(), uuid::Uuid::nil(), Beats::ZERO, 0.0)) .unwrap_or((uuid::Uuid::nil(), uuid::Uuid::nil(), Beats::ZERO, daw_backend::ContentTime::ZERO))
}; };
if !clip_id.is_nil() { if !clip_id.is_nil() {
@ -6850,7 +6858,7 @@ impl eframe::App for EditorApp {
.map(|(id, _)| id); .map(|(id, _)| id);
if let Some(doc_clip_id) = doc_clip_id { if let Some(doc_clip_id) = doc_clip_id {
if let Some(clip) = self.action_executor.document_mut().audio_clips.get_mut(&doc_clip_id) { if let Some(clip) = self.action_executor.document_mut().audio_clips.get_mut(&doc_clip_id) {
clip.set_content_duration(ClipDuration::Beats(Beats(midi_clip_data.duration))); clip.set_content_duration(ClipDuration::Beats(midi_clip_data.duration));
clip.name = format!("MIDI Recording {}", clip_id); clip.name = format!("MIDI Recording {}", clip_id);
} }
} }

View File

@ -1612,8 +1612,12 @@ impl InfopanelPane {
ui.label(format!("{:.2}s", document.tempo_map().beats_to_seconds(ci.effective_start()).seconds_to_f64())); ui.label(format!("{:.2}s", document.tempo_map().beats_to_seconds(ci.effective_start()).seconds_to_f64()));
}); });
let clip_dur = document.get_clip_duration(&ci.clip_id) let clip_dur = document.clip_trim_duration(&ci.clip_id)
.unwrap_or_else(|| daw_backend::Seconds(ci.trim_end.unwrap_or(1.0) - ci.trim_start)); .unwrap_or_else(|| lightningbeam_core::clip::ClipDuration::Seconds(
daw_backend::Seconds(
(ci.trim_end.unwrap_or(daw_backend::ContentTime(1.0)) - ci.trim_start).raw(),
),
));
let total_dur = ci.total_duration(clip_dur, document.tempo_map()); let total_dur = ci.total_duration(clip_dur, document.tempo_map());
let total_dur_secs = (document.tempo_map().beats_to_seconds(ci.effective_start() + total_dur) let total_dur_secs = (document.tempo_map().beats_to_seconds(ci.effective_start() + total_dur)
- document.tempo_map().beats_to_seconds(ci.effective_start())).seconds_to_f64(); - document.tempo_map().beats_to_seconds(ci.effective_start())).seconds_to_f64();
@ -1622,10 +1626,16 @@ impl InfopanelPane {
ui.label(format!("{:.2}s", total_dur_secs)); ui.label(format!("{:.2}s", total_dur_secs));
}); });
if ci.trim_start > 0.0 { if ci.trim_start > daw_backend::ContentTime::ZERO {
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.label("Trim Start:"); ui.label("Trim Start:");
ui.label(format!("{:.2}s", ci.trim_start)); // Content time in the clip's own domain: seconds for sampled
// audio/video/vector, beats for MIDI. Label it accordingly.
let unit = match clip_dur {
lightningbeam_core::clip::ClipDuration::Beats(_) => "beats",
lightningbeam_core::clip::ClipDuration::Seconds(_) => "s",
};
ui.label(format!("{:.2}{}", ci.trim_start.raw(), unit));
}); });
} }

View File

@ -466,8 +466,10 @@ impl PianoRollPane {
for instance in &audio_layer.clip_instances { for instance in &audio_layer.clip_instances {
if let Some(clip) = document.audio_clips.get(&instance.clip_id) { if let Some(clip) = document.audio_clips.get(&instance.clip_id) {
if let AudioClipType::Midi { midi_clip_id } = clip.clip_type { if let AudioClipType::Midi { midi_clip_id } = clip.clip_type {
let duration = instance.effective_duration(clip.content_duration().to_seconds(document.tempo_map()), document.tempo_map()); let duration = instance.effective_duration(clip.content_duration(), document.tempo_map());
clip_data.push((midi_clip_id, instance.timeline_start.beats_to_f64(), instance.trim_start, duration.beats_to_f64(), instance.id)); // A MIDI clip's content time IS beats, which is what the piano roll's
// x-axis uses.
clip_data.push((midi_clip_id, instance.timeline_start.beats_to_f64(), instance.trim_start.raw(), duration.beats_to_f64(), instance.id));
} }
} }
} }
@ -2463,7 +2465,8 @@ impl PianoRollPane {
}); });
// Get sample rate from raw_audio_cache // Get sample rate from raw_audio_cache
if let Some((_samples, sr, _ch)) = shared.raw_audio_cache.get(&audio_pool_index) { if let Some((_samples, sr, _ch)) = shared.raw_audio_cache.get(&audio_pool_index) {
clip_infos.push((audio_pool_index, instance.timeline_start.beats_to_f64(), instance.trim_start, duration.beats_to_f64(), *sr)); // A sampled clip's content time is seconds.
clip_infos.push((audio_pool_index, instance.timeline_start.beats_to_f64(), instance.trim_start.raw(), duration.beats_to_f64(), *sr));
} }
} }
} }

View File

@ -6,7 +6,7 @@
use eframe::egui; use eframe::egui;
use daw_backend::Seconds; use daw_backend::Seconds;
use lightningbeam_core::action::Action; use lightningbeam_core::action::Action;
use lightningbeam_core::clip::ClipInstance; use lightningbeam_core::clip::{ClipDuration, ClipInstance};
use lightningbeam_core::gpu::{BufferPool, BufferFormat, BufferSpec, Compositor, EffectProcessor, SrgbToLinearConverter}; use lightningbeam_core::gpu::{BufferPool, BufferFormat, BufferSpec, Compositor, EffectProcessor, SrgbToLinearConverter};
use lightningbeam_core::layer::{AnyLayer, AudioLayer}; use lightningbeam_core::layer::{AnyLayer, AudioLayer};
use lightningbeam_core::renderer::RenderedLayerType; use lightningbeam_core::renderer::RenderedLayerType;
@ -1854,7 +1854,10 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
// For now, create a simple effect instance with default parameters // For now, create a simple effect instance with default parameters
let tempo_map = self.ctx.document.tempo_map(); let tempo_map = self.ctx.document.tempo_map();
let effect_end_beats = effect_instance.timeline_start let effect_end_beats = effect_instance.timeline_start
+ effect_instance.effective_duration(Seconds(lightningbeam_core::effect::EFFECT_DURATION), tempo_map); + effect_instance.effective_duration(
ClipDuration::Seconds(Seconds(lightningbeam_core::effect::EFFECT_DURATION)),
tempo_map,
);
let effect_inst = lightningbeam_core::effect::EffectInstance::new( let effect_inst = lightningbeam_core::effect::EffectInstance::new(
effect_def, effect_def,
tempo_map.beats_to_seconds(effect_instance.timeline_start).seconds_to_f64(), tempo_map.beats_to_seconds(effect_instance.timeline_start).seconds_to_f64(),
@ -2209,7 +2212,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
// Calculate clip bounds for preview // Calculate clip bounds for preview
let start_secs = self.ctx.document.tempo_map().beats_to_seconds(clip_inst.timeline_start).seconds_to_f64(); let start_secs = self.ctx.document.tempo_map().beats_to_seconds(clip_inst.timeline_start).seconds_to_f64();
let clip_time = ((self.ctx.playback_time - start_secs) * clip_inst.playback_speed) + clip_inst.trim_start; let clip_time = ((self.ctx.playback_time - start_secs) * clip_inst.playback_speed) + clip_inst.trim_start.raw();
let content_bounds = if let Some(vector_clip) = self.ctx.document.get_vector_clip(&clip_inst.clip_id) { let content_bounds = if let Some(vector_clip) = self.ctx.document.get_vector_clip(&clip_inst.clip_id) {
vector_clip.calculate_content_bounds(&self.ctx.document, clip_time) vector_clip.calculate_content_bounds(&self.ctx.document, clip_time)
} else if let Some(video_clip) = self.ctx.document.get_video_clip(&clip_inst.clip_id) { } else if let Some(video_clip) = self.ctx.document.get_video_clip(&clip_inst.clip_id) {
@ -2299,7 +2302,9 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
for &clip_id in self.ctx.selection.clip_instances() { for &clip_id in self.ctx.selection.clip_instances() {
if let Some(clip_instance) = vector_layer.clip_instances.iter().find(|ci| ci.id == clip_id) { if let Some(clip_instance) = vector_layer.clip_instances.iter().find(|ci| ci.id == clip_id) {
// Skip clip instances not active at current time (compare in seconds). // Skip clip instances not active at current time (compare in seconds).
let clip_dur = self.ctx.document.get_clip_duration(&clip_instance.clip_id).unwrap_or(Seconds::ZERO); let clip_dur = ClipDuration::Seconds(
self.ctx.document.get_clip_duration(&clip_instance.clip_id).unwrap_or(Seconds::ZERO),
);
let tempo_map = self.ctx.document.tempo_map(); let tempo_map = self.ctx.document.tempo_map();
let start_secs = tempo_map.beats_to_seconds(clip_instance.timeline_start).seconds_to_f64(); let start_secs = tempo_map.beats_to_seconds(clip_instance.timeline_start).seconds_to_f64();
let instance_end = tempo_map.beats_to_seconds( let instance_end = tempo_map.beats_to_seconds(
@ -2310,7 +2315,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
} }
// Calculate clip-local time // Calculate clip-local time
let clip_time = ((self.ctx.playback_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start; let clip_time = ((self.ctx.playback_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start.raw();
// Get dynamic clip bounds from content at current time // Get dynamic clip bounds from content at current time
let bbox = if let Some(vector_clip) = self.ctx.document.get_vector_clip(&clip_instance.clip_id) { let bbox = if let Some(vector_clip) = self.ctx.document.get_vector_clip(&clip_instance.clip_id) {
@ -2680,7 +2685,9 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
// Find clip instance visible at playback time // Find clip instance visible at playback time
let visible_clip = video_layer.clip_instances.iter().find(|inst| { let visible_clip = video_layer.clip_instances.iter().find(|inst| {
let clip_duration = self.ctx.document.get_clip_duration(&inst.clip_id).unwrap_or(Seconds::ZERO); let clip_duration = ClipDuration::Seconds(
self.ctx.document.get_clip_duration(&inst.clip_id).unwrap_or(Seconds::ZERO),
);
let tempo_map = self.ctx.document.tempo_map(); let tempo_map = self.ctx.document.tempo_map();
let start_secs = tempo_map.beats_to_seconds(inst.timeline_start).seconds_to_f64(); let start_secs = tempo_map.beats_to_seconds(inst.timeline_start).seconds_to_f64();
let end_secs = tempo_map.beats_to_seconds(inst.timeline_start + inst.effective_duration(clip_duration, tempo_map)).seconds_to_f64(); let end_secs = tempo_map.beats_to_seconds(inst.timeline_start + inst.effective_duration(clip_duration, tempo_map)).seconds_to_f64();
@ -10142,7 +10149,7 @@ impl StagePane {
if let Some(clip_instance) = vector_layer.clip_instances.iter().find(|ci| ci.id == clip_id) { if let Some(clip_instance) = vector_layer.clip_instances.iter().find(|ci| ci.id == clip_id) {
// Calculate clip-local time // Calculate clip-local time
let start_secs = shared.action_executor.document().tempo_map().beats_to_seconds(clip_instance.timeline_start).seconds_to_f64(); let start_secs = shared.action_executor.document().tempo_map().beats_to_seconds(clip_instance.timeline_start).seconds_to_f64();
let clip_time = ((*shared.playback_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start; let clip_time = ((*shared.playback_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start.raw();
// Get dynamic clip bounds from content at current time // Get dynamic clip bounds from content at current time
use vello::kurbo::Rect as KurboRect; use vello::kurbo::Rect as KurboRect;
@ -10343,7 +10350,7 @@ impl StagePane {
if let Some(clip_instance) = vector_layer.clip_instances.iter().find(|ci| ci.id == object_id) { if let Some(clip_instance) = vector_layer.clip_instances.iter().find(|ci| ci.id == object_id) {
// Calculate clip-local time // Calculate clip-local time
let start_secs = shared.action_executor.document().tempo_map().beats_to_seconds(clip_instance.timeline_start).seconds_to_f64(); let start_secs = shared.action_executor.document().tempo_map().beats_to_seconds(clip_instance.timeline_start).seconds_to_f64();
let clip_time = ((*shared.playback_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start; let clip_time = ((*shared.playback_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start.raw();
// Get dynamic clip bounds from content at current time // Get dynamic clip bounds from content at current time
let local_bbox = if let Some(vector_clip) = shared.action_executor.document().get_vector_clip(&clip_instance.clip_id) { let local_bbox = if let Some(vector_clip) = shared.action_executor.document().get_vector_clip(&clip_instance.clip_id) {
@ -11059,7 +11066,9 @@ impl StagePane {
let document = shared.action_executor.document(); let document = shared.action_executor.document();
if let Some(AnyLayer::Video(video_layer)) = document.get_layer(layer_id) { if let Some(AnyLayer::Video(video_layer)) = document.get_layer(layer_id) {
video_layer.clip_instances.iter().find(|inst| { video_layer.clip_instances.iter().find(|inst| {
let clip_duration = document.get_clip_duration(&inst.clip_id).unwrap_or(Seconds::ZERO); let clip_duration = ClipDuration::Seconds(
document.get_clip_duration(&inst.clip_id).unwrap_or(Seconds::ZERO),
);
let tempo_map = document.tempo_map(); let tempo_map = document.tempo_map();
let start_secs = tempo_map.beats_to_seconds(inst.timeline_start).seconds_to_f64(); let start_secs = tempo_map.beats_to_seconds(inst.timeline_start).seconds_to_f64();
let end_secs = tempo_map.beats_to_seconds(inst.timeline_start + inst.effective_duration(clip_duration, tempo_map)).seconds_to_f64(); let end_secs = tempo_map.beats_to_seconds(inst.timeline_start + inst.effective_duration(clip_duration, tempo_map)).seconds_to_f64();

View File

@ -7,8 +7,8 @@
/// - Basic layer visualization /// - Basic layer visualization
use eframe::egui; use eframe::egui;
use daw_backend::{Beats, Seconds}; use daw_backend::{Beats, ContentTime, Seconds};
use lightningbeam_core::clip::ClipInstance; use lightningbeam_core::clip::{ClipDuration, ClipInstance};
use lightningbeam_core::layer::{AnyLayer, AudioLayerType, GroupLayer, LayerTrait}; use lightningbeam_core::layer::{AnyLayer, AudioLayerType, GroupLayer, LayerTrait};
use super::{DragClipType, NodePath, PaneRenderer, SharedPaneState}; use super::{DragClipType, NodePath, PaneRenderer, SharedPaneState};
@ -89,7 +89,7 @@ fn compute_clip_stacking(
let tempo_map = document.tempo_map(); let tempo_map = document.tempo_map();
// Stacking only needs relative overlap, so compare in the beats domain. // Stacking only needs relative overlap, so compare in the beats domain.
let ranges: Vec<(f64, f64)> = clip_instances.iter().map(|ci| { let ranges: Vec<(f64, f64)> = clip_instances.iter().map(|ci| {
let clip_dur = effective_clip_duration(document, layer, ci).unwrap_or(Seconds::ZERO); let clip_dur = effective_clip_duration(document, layer, ci).unwrap_or(ClipDuration::Seconds(Seconds::ZERO));
let start = ci.effective_start(); let start = ci.effective_start();
let end = start + ci.total_duration(clip_dur, tempo_map); let end = start + ci.total_duration(clip_dur, tempo_map);
(start.beats_to_f64(), end.beats_to_f64()) (start.beats_to_f64(), end.beats_to_f64())
@ -203,11 +203,14 @@ fn draw_video_thumbnail_strip(
/// Get the effective clip duration for a clip instance on a given layer. /// Get the effective clip duration for a clip instance on a given layer.
/// For groups on vector layers, the duration spans all consecutive keyframes /// For groups on vector layers, the duration spans all consecutive keyframes
/// where the group is present. For regular clips, returns the clip's internal duration. /// where the group is present. For regular clips, returns the clip's internal duration.
/// A clip's content duration **in its own domain** — seconds for vector/video/effect and sampled
/// audio, BEATS for MIDI. Returning a `ClipDuration` rather than bare `Seconds` is what lets the
/// instance's trim bounds (which are content times, in that same domain) be resolved correctly.
fn effective_clip_duration( fn effective_clip_duration(
document: &lightningbeam_core::document::Document, document: &lightningbeam_core::document::Document,
layer: &AnyLayer, layer: &AnyLayer,
clip_instance: &ClipInstance, clip_instance: &ClipInstance,
) -> Option<Seconds> { ) -> Option<ClipDuration> {
match layer { match layer {
AnyLayer::Vector(vl) => { AnyLayer::Vector(vl) => {
let vc = document.get_vector_clip(&clip_instance.clip_id)?; let vc = document.get_vector_clip(&clip_instance.clip_id)?;
@ -215,17 +218,21 @@ fn effective_clip_duration(
let frame_duration = 1.0 / document.framerate; let frame_duration = 1.0 / document.framerate;
let start_secs = document.tempo_map().beats_to_seconds(clip_instance.timeline_start).seconds_to_f64(); let start_secs = document.tempo_map().beats_to_seconds(clip_instance.timeline_start).seconds_to_f64();
let end = vl.group_visibility_end(&clip_instance.id, start_secs, frame_duration); let end = vl.group_visibility_end(&clip_instance.id, start_secs, frame_duration);
Some(Seconds((end - start_secs).max(0.0))) Some(ClipDuration::Seconds(Seconds((end - start_secs).max(0.0))))
} else { } else {
// Movie clips: duration based on all internal content (keyframes + clip instances) // Movie clips: duration based on all internal content (keyframes + clip instances)
document.get_clip_duration(&clip_instance.clip_id) document.get_clip_duration(&clip_instance.clip_id).map(ClipDuration::Seconds)
} }
} }
// Delegate to get_clip_duration so MIDI clips (whose `duration` is stored in beats, // An audio layer can hold a sampled clip (seconds content) or a MIDI clip (beats content),
// not seconds) are converted correctly rather than read as raw seconds. // so ask the clip which it is rather than flattening both to seconds.
AnyLayer::Audio(_) => document.get_clip_duration(&clip_instance.clip_id), AnyLayer::Audio(_) => document.clip_trim_duration(&clip_instance.clip_id),
AnyLayer::Video(_) => document.get_video_clip(&clip_instance.clip_id).map(|c| Seconds(c.duration)), AnyLayer::Video(_) => document
AnyLayer::Effect(_) => Some(Seconds(lightningbeam_core::effect::EFFECT_DURATION)), .get_video_clip(&clip_instance.clip_id)
.map(|c| ClipDuration::Seconds(Seconds(c.duration))),
AnyLayer::Effect(_) => Some(ClipDuration::Seconds(Seconds(
lightningbeam_core::effect::EFFECT_DURATION,
))),
AnyLayer::Group(_) => None, AnyLayer::Group(_) => None,
AnyLayer::Raster(_) => None, AnyLayer::Raster(_) => None,
AnyLayer::Text(_) => None, AnyLayer::Text(_) => None,
@ -859,7 +866,9 @@ impl TimelinePane {
.unwrap_or_default() .unwrap_or_default()
.iter() .iter()
.map(|k| crate::curve_editor::CurvePoint { .map(|k| crate::curve_editor::CurvePoint {
time: k.time, // beats (backend stores beats; curve editor x-axis is beats) // The curve editor's x-axis is beats, same as the backend — unwrap at this
// boundary because CurvePoint stores a plain f64.
time: k.time.beats_to_f64(),
value: k.value, value: k.value,
interpolation: match k.interpolation.as_str() { interpolation: match k.interpolation.as_str() {
"bezier" => crate::curve_editor::CurveInterpolation::Bezier, "bezier" => crate::curve_editor::CurveInterpolation::Bezier,
@ -1441,8 +1450,10 @@ impl TimelinePane {
let tempo_map = document.tempo_map(); let tempo_map = document.tempo_map();
for (_child_layer_id, ci) in &child_clips { for (_child_layer_id, ci) in &child_clips {
let clip_dur = document.get_clip_duration(&ci.clip_id).unwrap_or_else(|| { let clip_dur = document.clip_trim_duration(&ci.clip_id).unwrap_or_else(|| {
Seconds(ci.trim_end.unwrap_or(1.0) - ci.trim_start) ClipDuration::Seconds(Seconds(
(ci.trim_end.unwrap_or(ContentTime(1.0)) - ci.trim_start).raw(),
))
}); });
let start = ci.effective_start(); let start = ci.effective_start();
let end = start + ci.total_duration(clip_dur, tempo_map); let end = start + ci.total_duration(clip_dur, tempo_map);
@ -1756,15 +1767,18 @@ impl TimelinePane {
/// Effective on-timeline duration for a clip instance, in seconds. /// Effective on-timeline duration for a clip instance, in seconds.
/// ///
/// `total_duration` is in beats; converts to seconds using the current (preview) BPM. /// `total_duration` is in beats; converts to seconds using the current (preview) BPM.
fn instance_display_duration(&self, ci: &lightningbeam_core::clip::ClipInstance, clip_dur_secs: Seconds, tempo_map: &daw_backend::TempoMap) -> f64 { fn instance_display_duration(&self, ci: &lightningbeam_core::clip::ClipInstance, clip_content: ClipDuration, tempo_map: &daw_backend::TempoMap) -> f64 {
(tempo_map.beats_to_seconds(ci.timeline_start + ci.total_duration(clip_dur_secs, tempo_map)) (tempo_map.beats_to_seconds(ci.timeline_start + ci.total_duration(clip_content, tempo_map))
- tempo_map.beats_to_seconds(ci.effective_start())).seconds_to_f64() - tempo_map.beats_to_seconds(ci.effective_start())).seconds_to_f64()
} }
/// Returns the clip content start (trim_start) and duration in display seconds. /// The clip's content start (trim_start) and window length, as raw magnitudes in the clip's own
fn content_display_range(&self, ci: &lightningbeam_core::clip::ClipInstance, clip_dur_secs: Seconds, _bpm: f64) -> (f64, f64) { /// content domain — seconds for audio/video/vector, beats for MIDI. The drag/preview math below
let trim_end = ci.trim_end.unwrap_or(clip_dur_secs.seconds_to_f64()); /// works in that domain throughout, converting to the timeline only at the edges.
(ci.trim_start, (trim_end - ci.trim_start).max(0.0)) fn content_display_range(&self, ci: &lightningbeam_core::clip::ClipInstance, clip_content: ClipDuration, _bpm: f64) -> (f64, f64) {
let trim_end = ci.trim_end.map_or(clip_content.native(), |t| t.raw());
let start = ci.trim_start.raw();
(start, (trim_end - start).max(0.0))
} }
/// Convert pixel x-coordinate to time (seconds) /// Convert pixel x-coordinate to time (seconds)
@ -3255,8 +3269,10 @@ impl TimelinePane {
let is_move_drag = self.clip_drag_state == Some(ClipDragType::Move); let is_move_drag = self.clip_drag_state == Some(ClipDragType::Move);
let mut ranges: Vec<(Beats, Beats)> = Vec::new(); let mut ranges: Vec<(Beats, Beats)> = Vec::new();
for (_child_layer_id, ci) in &child_clips { for (_child_layer_id, ci) in &child_clips {
let clip_dur = document.get_clip_duration(&ci.clip_id).unwrap_or_else(|| { let clip_dur = document.clip_trim_duration(&ci.clip_id).unwrap_or_else(|| {
Seconds(ci.trim_end.unwrap_or(1.0) - ci.trim_start) ClipDuration::Seconds(Seconds(
(ci.trim_end.unwrap_or(ContentTime(1.0)) - ci.trim_start).raw(),
))
}); });
let mut start = ci.effective_start(); let mut start = ci.effective_start();
let dur = ci.total_duration(clip_dur, document.tempo_map()); let dur = ci.total_duration(clip_dur, document.tempo_map());
@ -3329,8 +3345,10 @@ impl TimelinePane {
if let Some(video_child) = g.children.iter().find(|c| matches!(c, AnyLayer::Video(_))) { if let Some(video_child) = g.children.iter().find(|c| matches!(c, AnyLayer::Video(_))) {
if let AnyLayer::Video(vl) = video_child { if let AnyLayer::Video(vl) = video_child {
for ci in &vl.clip_instances { for ci in &vl.clip_instances {
let clip_dur = document.get_clip_duration(&ci.clip_id) let clip_dur = document.clip_trim_duration(&ci.clip_id)
.unwrap_or_else(|| Seconds(ci.trim_end.unwrap_or(1.0) - ci.trim_start)); .unwrap_or_else(|| ClipDuration::Seconds(Seconds(
(ci.trim_end.unwrap_or(ContentTime(1.0)) - ci.trim_start).raw(),
)));
let mut ci_start = ci.effective_start(); let mut ci_start = ci.effective_start();
if is_move_drag && selection.contains_clip_instance(&ci.id) { if is_move_drag && selection.contains_clip_instance(&ci.id) {
ci_start = self.moved_start(ci_start, document.tempo_map(), &document.time_signature, document.framerate); ci_start = self.moved_start(ci_start, document.tempo_map(), &document.time_signature, document.framerate);
@ -3357,7 +3375,8 @@ impl TimelinePane {
); );
// 4th elem = clip's TRUE (unclamped) origin x, for correct // 4th elem = clip's TRUE (unclamped) origin x, for correct
// hover content time when scrolled partly off the left. // hover content time when scrolled partly off the left.
video_clip_hovers.push((hover_rect, ci.clip_id, ci.trim_start, rect.min.x + sx)); // Video content is wall-clock, so its content time IS seconds.
video_clip_hovers.push((hover_rect, ci.clip_id, ci.trim_start.raw(), rect.min.x + sx));
let thumb_display_height = (thumb_y_max - span_y_min) - 4.0; let thumb_display_height = (thumb_y_max - span_y_min) - 4.0;
if thumb_display_height > 8.0 { if thumb_display_height > 8.0 {
@ -3370,7 +3389,7 @@ impl TimelinePane {
&video_mgr, &video_mgr,
&mut self.video_thumbnail_textures, &mut self.video_thumbnail_textures,
ci.clip_id, ci.clip_id,
ci.trim_start, ci.trim_start.raw(),
rect.min.x + sx, rect.min.x + sx,
ex - sx, ex - sx,
ci_rect, ci_rect,
@ -3419,7 +3438,7 @@ impl TimelinePane {
}; };
let audio_file_duration = total_frames as f64 / eff_sr as f64; let audio_file_duration = total_frames as f64 / eff_sr as f64;
let clip_dur = audio_clip.content_duration().to_seconds(document.tempo_map()); let clip_dur = audio_clip.content_duration();
let mut ci_start = ci.effective_start(); let mut ci_start = ci.effective_start();
if is_move_drag && selection.contains_clip_instance(&ci.id) { if is_move_drag && selection.contains_clip_instance(&ci.id) {
ci_start = self.moved_start(ci_start, document.tempo_map(), &document.time_signature, document.framerate); ci_start = self.moved_start(ci_start, document.tempo_map(), &document.time_signature, document.framerate);
@ -3470,7 +3489,8 @@ impl TimelinePane {
audio_duration: audio_file_duration as f32, audio_duration: audio_file_duration as f32,
sample_rate: eff_sr, sample_rate: eff_sr,
clip_start_time: ci_screen_start, clip_start_time: ci_screen_start,
trim_start: ci.trim_start as f32, // A sampled clip's content time is seconds.
trim_start: ci.trim_start.raw() as f32,
tex_width: crate::waveform_gpu::tex_width() as f32, tex_width: crate::waveform_gpu::tex_width() as f32,
total_frames: total_frames as f32, total_frames: total_frames as f32,
segment_start_frame: 0.0, segment_start_frame: 0.0,
@ -3553,7 +3573,7 @@ impl TimelinePane {
let group: Vec<(uuid::Uuid, Beats, Beats)> = clip_instances.iter() let group: Vec<(uuid::Uuid, Beats, Beats)> = clip_instances.iter()
.filter(|ci| selection.contains_clip_instance(&ci.id)) .filter(|ci| selection.contains_clip_instance(&ci.id))
.filter_map(|ci| { .filter_map(|ci| {
let dur = document.get_clip_duration(&ci.clip_id)?; let dur = document.clip_trim_duration(&ci.clip_id)?;
Some((ci.id, ci.effective_start(), ci.total_duration(dur, document.tempo_map()))) Some((ci.id, ci.effective_start(), ci.total_duration(dur, document.tempo_map())))
}) })
.collect(); .collect();
@ -3577,8 +3597,11 @@ impl TimelinePane {
let shift_beats = |anchor: Beats, secs: f64| let shift_beats = |anchor: Beats, secs: f64|
tmap.seconds_to_beats(tmap.beats_to_seconds(anchor) + Seconds(secs)); tmap.seconds_to_beats(tmap.beats_to_seconds(anchor) + Seconds(secs));
let clip_dur = effective_clip_duration(document, layer, ci).unwrap_or(Seconds::ZERO); let clip_dur = effective_clip_duration(document, layer, ci).unwrap_or(ClipDuration::Seconds(Seconds::ZERO));
let clip_dur_secs = clip_dur.seconds_to_f64(); // Raw magnitudes in the clip's own content domain — the drag math below stays in
// that domain and only converts at the timeline edges.
let clip_dur_secs = clip_dur.native();
let ci_trim_start = ci.trim_start.raw();
let mut start = ci.effective_start(); let mut start = ci.effective_start();
let mut duration = ci.total_duration(clip_dur, tmap); let mut duration = ci.total_duration(clip_dur, tmap);
@ -3600,25 +3623,25 @@ impl TimelinePane {
} }
} }
ClipDragType::TrimLeft => { ClipDragType::TrimLeft => {
let new_trim = self.snap_to_grid(ci.trim_start + self.drag_offset, tmap, &document.time_signature, document.framerate, SNAP_PX_FINE).max(0.0).min(clip_dur_secs); let new_trim = self.snap_to_grid(ci_trim_start + self.drag_offset, tmap, &document.time_signature, document.framerate, SNAP_PX_FINE).max(0.0).min(clip_dur_secs);
let trim_offset_secs = new_trim - ci.trim_start; let trim_offset_secs = new_trim - ci_trim_start;
start = shift_beats(ci.timeline_start, trim_offset_secs).max(Beats::ZERO); start = shift_beats(ci.timeline_start, trim_offset_secs).max(Beats::ZERO);
let dur_secs = if let Some(trim_end) = ci.trim_end { let dur_secs = if let Some(trim_end) = ci.trim_end {
(trim_end - new_trim).max(0.0) (trim_end.raw() - new_trim).max(0.0)
} else { } else {
(clip_dur_secs - new_trim).max(0.0) (clip_dur_secs - new_trim).max(0.0)
}; };
duration = secs_to_beats_at(start, dur_secs); duration = secs_to_beats_at(start, dur_secs);
} }
ClipDragType::TrimRight => { ClipDragType::TrimRight => {
let old_trim_end = ci.trim_end.unwrap_or(clip_dur_secs); let old_trim_end = ci.trim_end.map_or(clip_dur_secs, |t| t.raw());
let new_trim_end = self.snap_to_grid(old_trim_end + self.drag_offset, tmap, &document.time_signature, document.framerate, SNAP_PX_FINE).max(ci.trim_start).min(clip_dur_secs); let new_trim_end = self.snap_to_grid(old_trim_end + self.drag_offset, tmap, &document.time_signature, document.framerate, SNAP_PX_FINE).max(ci_trim_start).min(clip_dur_secs);
let dur_secs = (new_trim_end - ci.trim_start).max(0.0); let dur_secs = (new_trim_end - ci_trim_start).max(0.0);
duration = secs_to_beats_at(start, dur_secs); duration = secs_to_beats_at(start, dur_secs);
} }
ClipDragType::LoopExtendRight => { ClipDragType::LoopExtendRight => {
let trim_end = ci.trim_end.unwrap_or(clip_dur_secs); let trim_end = ci.trim_end.map_or(clip_dur_secs, |t| t.raw());
let content_window_secs = (trim_end - ci.trim_start).max(0.0); let content_window_secs = (trim_end - ci_trim_start).max(0.0);
let content_window = secs_to_beats_at(ci.timeline_start, content_window_secs); let content_window = secs_to_beats_at(ci.timeline_start, content_window_secs);
let current_right = ci.timeline_duration.unwrap_or(content_window); let current_right = ci.timeline_duration.unwrap_or(content_window);
let right_edge_secs = tmap.beats_to_seconds(ci.timeline_start + current_right).seconds_to_f64() + self.drag_offset; let right_edge_secs = tmap.beats_to_seconds(ci.timeline_start + current_right).seconds_to_f64() + self.drag_offset;
@ -3629,8 +3652,8 @@ impl TimelinePane {
duration = loop_before + new_right; duration = loop_before + new_right;
} }
ClipDragType::LoopExtendLeft => { ClipDragType::LoopExtendLeft => {
let trim_end = ci.trim_end.unwrap_or(clip_dur_secs); let trim_end = ci.trim_end.map_or(clip_dur_secs, |t| t.raw());
let content_window_secs = (trim_end - ci.trim_start).max(0.001); let content_window_secs = (trim_end - ci_trim_start).max(0.001);
let content_window = secs_to_beats_at(ci.timeline_start, content_window_secs); let content_window = secs_to_beats_at(ci.timeline_start, content_window_secs);
let current_loop_before = ci.loop_before.unwrap_or(Beats::ZERO); let current_loop_before = ci.loop_before.unwrap_or(Beats::ZERO);
// drag_offset (seconds) as a beats delta at this clip's start. // drag_offset (seconds) as a beats delta at this clip's start.
@ -3691,6 +3714,9 @@ impl TimelinePane {
// Track preview trim values for note/waveform rendering. // Track preview trim values for note/waveform rendering.
// In Measures mode, derive from beats so they track BPM during live drag. // In Measures mode, derive from beats so they track BPM during live drag.
let (base_trim_start, base_clip_duration) = self.content_display_range(clip_instance, clip_duration, document.bpm()); let (base_trim_start, base_clip_duration) = self.content_display_range(clip_instance, clip_duration, document.bpm());
// The instance's trim start as a raw magnitude in the clip's content domain; the
// preview math below stays in that domain.
let ci_trim_start = clip_instance.trim_start.raw();
let mut preview_trim_start = base_trim_start; let mut preview_trim_start = base_trim_start;
let mut preview_clip_duration = base_clip_duration; let mut preview_clip_duration = base_clip_duration;
@ -3706,11 +3732,11 @@ impl TimelinePane {
} }
ClipDragType::TrimLeft => { ClipDragType::TrimLeft => {
// Trim left: calculate new trim_start with snap to adjacent clips // Trim left: calculate new trim_start with snap to adjacent clips
let desired_trim_start = self.snap_to_grid(clip_instance.trim_start + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate, SNAP_PX_FINE) let desired_trim_start = self.snap_to_grid(ci_trim_start + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate, SNAP_PX_FINE)
.max(0.0) .max(0.0)
.min(clip_duration.seconds_to_f64()); .min(clip_duration.native());
let new_trim_start = if desired_trim_start < clip_instance.trim_start { let new_trim_start = if desired_trim_start < ci_trim_start {
// Extending left - limit is the content-seconds gap to the previous clip. // Extending left - limit is the content-seconds gap to the previous clip.
let max_extend_secs = document.find_max_trim_extend_left( let max_extend_secs = document.find_max_trim_extend_left(
&layer.id(), &layer.id(),
@ -3718,25 +3744,25 @@ impl TimelinePane {
clip_instance.effective_start(), clip_instance.effective_start(),
).seconds_to_f64(); ).seconds_to_f64();
let desired_extend = clip_instance.trim_start - desired_trim_start; let desired_extend = ci_trim_start - desired_trim_start;
let actual_extend = desired_extend.min(max_extend_secs); let actual_extend = desired_extend.min(max_extend_secs);
clip_instance.trim_start - actual_extend ci_trim_start - actual_extend
} else { } else {
// Shrinking - no snap needed // Shrinking - no snap needed
desired_trim_start desired_trim_start
}; };
let actual_offset = new_trim_start - clip_instance.trim_start; let actual_offset = new_trim_start - ci_trim_start;
// Move start (display seconds) and reduce duration by the clamped offset. // Move start (display seconds) and reduce duration by the clamped offset.
instance_start = (document.tempo_map().beats_to_seconds(clip_instance.timeline_start).seconds_to_f64() + actual_offset) instance_start = (document.tempo_map().beats_to_seconds(clip_instance.timeline_start).seconds_to_f64() + actual_offset)
.max(0.0); .max(0.0);
instance_duration = (clip_duration.seconds_to_f64() - new_trim_start).max(0.0); instance_duration = (clip_duration.native() - new_trim_start).max(0.0);
// Adjust for existing trim_end // Adjust for existing trim_end
if let Some(trim_end) = clip_instance.trim_end { if let Some(trim_end) = clip_instance.trim_end {
instance_duration = (trim_end - new_trim_start).max(0.0); instance_duration = (trim_end.raw() - new_trim_start).max(0.0);
} }
// Update preview trim for waveform rendering // Update preview trim for waveform rendering
@ -3745,14 +3771,14 @@ impl TimelinePane {
} }
ClipDragType::TrimRight => { ClipDragType::TrimRight => {
// Trim right: extend or reduce duration with snap to adjacent clips // Trim right: extend or reduce duration with snap to adjacent clips
let old_trim_end = clip_instance.trim_end.unwrap_or(clip_duration.seconds_to_f64()); let old_trim_end = clip_instance.trim_end.map_or(clip_duration.native(), |t| t.raw());
let desired_trim_end = self.snap_to_grid(old_trim_end + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate, SNAP_PX_FINE) let desired_trim_end = self.snap_to_grid(old_trim_end + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate, SNAP_PX_FINE)
.max(clip_instance.trim_start) .max(ci_trim_start)
.min(clip_duration.seconds_to_f64()); .min(clip_duration.native());
let new_trim_end = if desired_trim_end > old_trim_end { let new_trim_end = if desired_trim_end > old_trim_end {
// Extending right - limit is the content-seconds gap to the next clip. // Extending right - limit is the content-seconds gap to the next clip.
let current_duration_secs = old_trim_end - clip_instance.trim_start; let current_duration_secs = old_trim_end - ci_trim_start;
let tmap = document.tempo_map(); let tmap = document.tempo_map();
let current_duration = tmap.seconds_to_beats( let current_duration = tmap.seconds_to_beats(
tmap.beats_to_seconds(clip_instance.timeline_start) + Seconds(current_duration_secs) tmap.beats_to_seconds(clip_instance.timeline_start) + Seconds(current_duration_secs)
@ -3772,7 +3798,7 @@ impl TimelinePane {
desired_trim_end desired_trim_end
}; };
instance_duration = (new_trim_end - clip_instance.trim_start).max(0.0); instance_duration = (new_trim_end - ci_trim_start).max(0.0);
// Update preview clip duration for waveform rendering // Update preview clip duration for waveform rendering
// (the waveform system uses clip_duration to determine visible range) // (the waveform system uses clip_duration to determine visible range)
@ -3780,8 +3806,8 @@ impl TimelinePane {
} }
ClipDragType::LoopExtendRight => { ClipDragType::LoopExtendRight => {
// Loop extend right: extend clip beyond content window // Loop extend right: extend clip beyond content window
let trim_end = clip_instance.trim_end.unwrap_or(clip_duration.seconds_to_f64()); let trim_end = clip_instance.trim_end.map_or(clip_duration.native(), |t| t.raw());
let content_window_secs = (trim_end - clip_instance.trim_start).max(0.0); let content_window_secs = (trim_end - ci_trim_start).max(0.0);
let tmap = document.tempo_map(); let tmap = document.tempo_map();
let ts = clip_instance.timeline_start; let ts = clip_instance.timeline_start;
// content window and right-duration are beats-domain timeline spans. // content window and right-duration are beats-domain timeline spans.
@ -3816,8 +3842,8 @@ impl TimelinePane {
ClipDragType::LoopExtendLeft => { ClipDragType::LoopExtendLeft => {
// Loop extend left: extend loop_before (pre-loop region) // Loop extend left: extend loop_before (pre-loop region)
// Snap to multiples of content_window so iterations align with backend // Snap to multiples of content_window so iterations align with backend
let trim_end = clip_instance.trim_end.unwrap_or(clip_duration.seconds_to_f64()); let trim_end = clip_instance.trim_end.map_or(clip_duration.native(), |t| t.raw());
let content_window_secs = (trim_end - clip_instance.trim_start).max(0.001); let content_window_secs = (trim_end - ci_trim_start).max(0.001);
let tmap = document.tempo_map(); let tmap = document.tempo_map();
let ts = clip_instance.timeline_start; let ts = clip_instance.timeline_start;
// content window is a beats-domain span; guard against zero for division. // content window is a beats-domain span; guard against zero for division.
@ -4057,7 +4083,7 @@ impl TimelinePane {
// Calculate content window for loop detection // Calculate content window for loop detection
// Use trimmed content window (preview_trim_start accounts for TrimLeft drag) // Use trimmed content window (preview_trim_start accounts for TrimLeft drag)
let preview_trim_end = clip_instance.trim_end.unwrap_or(clip_duration.seconds_to_f64()); let preview_trim_end = clip_instance.trim_end.map_or(clip_duration.native(), |t| t.raw());
let content_window = (preview_trim_end - preview_trim_start).max(0.0); let content_window = (preview_trim_end - preview_trim_start).max(0.0);
let is_looping = instance_duration > content_window + 0.001; let is_looping = instance_duration > content_window + 0.001;
@ -4254,7 +4280,7 @@ impl TimelinePane {
&video_mgr, &video_mgr,
&mut self.video_thumbnail_textures, &mut self.video_thumbnail_textures,
clip_instance.clip_id, clip_instance.clip_id,
clip_instance.trim_start, clip_instance.trim_start.raw(),
rect.min.x + start_x, rect.min.x + start_x,
end_x - start_x, end_x - start_x,
clip_rect, clip_rect,
@ -4269,7 +4295,7 @@ impl TimelinePane {
// clip's TRUE (unclamped) origin x so the hover content time is // clip's TRUE (unclamped) origin x so the hover content time is
// correct even when the clip is scrolled partly off the left. // correct even when the clip is scrolled partly off the left.
if let lightningbeam_core::layer::AnyLayer::Video(_) = layer { if let lightningbeam_core::layer::AnyLayer::Video(_) = layer {
video_clip_hovers.push((clip_rect, clip_instance.clip_id, clip_instance.trim_start, rect.min.x + start_x)); video_clip_hovers.push((clip_rect, clip_instance.clip_id, clip_instance.trim_start.raw(), rect.min.x + start_x));
} }
// Draw border per segment (per loop iteration for looping clips) // Draw border per segment (per loop iteration for looping clips)
@ -5009,18 +5035,23 @@ impl TimelinePane {
for clip_instance in clip_instances { for clip_instance in clip_instances {
if selection.contains_clip_instance(&clip_instance.id) { if selection.contains_clip_instance(&clip_instance.id) {
let clip_duration = effective_clip_duration(document, layer, clip_instance); let clip_duration = effective_clip_duration(document, layer, clip_instance);
// Raw magnitude in the clip's content domain; re-tagged as a
// ContentTime when it goes back into TrimData.
let ci_trim_start = clip_instance.trim_start.raw();
if let Some(clip_duration) = clip_duration { if let Some(clip_duration) = clip_duration {
match drag_type { match drag_type {
ClipDragType::TrimLeft => { ClipDragType::TrimLeft => {
let old_trim_start = clip_instance.trim_start; // Raw magnitude in the clip's content domain; re-tagged as a
// ContentTime when it goes back into TrimData below.
let old_trim_start = clip_instance.trim_start.raw();
let old_timeline_start = let old_timeline_start =
clip_instance.timeline_start; clip_instance.timeline_start;
// New trim_start is snapped then clamped to valid range // New trim_start is snapped then clamped to valid range
let desired_trim_start = self.snap_to_grid( let desired_trim_start = self.snap_to_grid(
old_trim_start + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate, SNAP_PX_FINE, old_trim_start + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate, SNAP_PX_FINE,
).max(0.0).min(clip_duration.seconds_to_f64()); ).max(0.0).min(clip_duration.native());
// Apply overlap prevention when extending left (content-seconds gap). // Apply overlap prevention when extending left (content-seconds gap).
let new_trim_start = if desired_trim_start < old_trim_start { let new_trim_start = if desired_trim_start < old_trim_start {
@ -5050,11 +5081,11 @@ impl TimelinePane {
clip_instance.id, clip_instance.id,
lightningbeam_core::actions::TrimType::TrimLeft, lightningbeam_core::actions::TrimType::TrimLeft,
lightningbeam_core::actions::TrimData::left( lightningbeam_core::actions::TrimData::left(
old_trim_start, ContentTime(old_trim_start),
old_timeline_start, old_timeline_start,
), ),
lightningbeam_core::actions::TrimData::left( lightningbeam_core::actions::TrimData::left(
new_trim_start, ContentTime(new_trim_start),
new_timeline_start, new_timeline_start,
), ),
)); ));
@ -5065,10 +5096,10 @@ impl TimelinePane {
// Calculate new trim_end based on current duration // Calculate new trim_end based on current duration
let current_duration = let current_duration =
clip_instance.effective_duration(clip_duration, document.tempo_map()); clip_instance.effective_duration(clip_duration, document.tempo_map());
let old_trim_end_val = clip_instance.trim_end.unwrap_or(clip_duration.seconds_to_f64()); let old_trim_end_val = clip_instance.trim_end.map_or(clip_duration.native(), |t| t.raw());
let desired_trim_end = self.snap_to_grid( let desired_trim_end = self.snap_to_grid(
old_trim_end_val + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate, SNAP_PX_FINE, old_trim_end_val + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate, SNAP_PX_FINE,
).max(clip_instance.trim_start).min(clip_duration.seconds_to_f64()); ).max(ci_trim_start).min(clip_duration.native());
// Apply overlap prevention when extending right (content-seconds gap). // Apply overlap prevention when extending right (content-seconds gap).
let new_trim_end_val = if desired_trim_end > old_trim_end_val { let new_trim_end_val = if desired_trim_end > old_trim_end_val {
@ -5085,13 +5116,15 @@ impl TimelinePane {
desired_trim_end desired_trim_end
}; };
let new_duration = (new_trim_end_val - clip_instance.trim_start).max(0.0); let new_duration = (new_trim_end_val - ci_trim_start).max(0.0);
// Convert new duration back to trim_end value // Convert new duration back to trim_end value
let new_trim_end = if new_duration >= clip_duration.seconds_to_f64() { let new_trim_end = if new_duration >= clip_duration.native() {
None // Use full clip duration None // Use full clip duration
} else { } else {
Some((clip_instance.trim_start + new_duration).min(clip_duration.seconds_to_f64())) Some(ContentTime(
(ci_trim_start + new_duration).min(clip_duration.native()),
))
}; };
layer_trims layer_trims
@ -5143,8 +5176,9 @@ impl TimelinePane {
if let Some(clip_duration) = clip_duration { if let Some(clip_duration) = clip_duration {
let tmap = document.tempo_map(); let tmap = document.tempo_map();
let ts = clip_instance.timeline_start; let ts = clip_instance.timeline_start;
let trim_end = clip_instance.trim_end.unwrap_or(clip_duration); let ci_trim_start = clip_instance.trim_start.raw();
let content_window_secs = (trim_end - clip_instance.trim_start).max(0.0); let trim_end = clip_instance.trim_end.map_or(clip_duration, |t| t.raw());
let content_window_secs = (trim_end - ci_trim_start).max(0.0);
let content_window = tmap.seconds_to_beats(tmap.beats_to_seconds(ts) + Seconds(content_window_secs)) - ts; let content_window = tmap.seconds_to_beats(tmap.beats_to_seconds(ts) + Seconds(content_window_secs)) - ts;
let current_right = clip_instance.timeline_duration.unwrap_or(content_window); let current_right = clip_instance.timeline_duration.unwrap_or(content_window);
// Snap the right edge in the seconds/pixel domain. // Snap the right edge in the seconds/pixel domain.
@ -5217,8 +5251,9 @@ impl TimelinePane {
if let Some(clip_duration) = clip_duration { if let Some(clip_duration) = clip_duration {
let tmap = document.tempo_map(); let tmap = document.tempo_map();
let ts = clip_instance.timeline_start; let ts = clip_instance.timeline_start;
let trim_end = clip_instance.trim_end.unwrap_or(clip_duration); let ci_trim_start = clip_instance.trim_start.raw();
let content_window_secs = (trim_end - clip_instance.trim_start).max(0.001); let trim_end = clip_instance.trim_end.map_or(clip_duration, |t| t.raw());
let content_window_secs = (trim_end - ci_trim_start).max(0.001);
let content_window = tmap.seconds_to_beats(tmap.beats_to_seconds(ts) + Seconds(content_window_secs)) - ts; let content_window = tmap.seconds_to_beats(tmap.beats_to_seconds(ts) + Seconds(content_window_secs)) - ts;
let cw = content_window.beats_to_f64().max(1e-9); let cw = content_window.beats_to_f64().max(1e-9);
let current_loop_before = clip_instance.loop_before.unwrap_or(Beats::ZERO); let current_loop_before = clip_instance.loop_before.unwrap_or(Beats::ZERO);
@ -6461,7 +6496,7 @@ impl PaneRenderer for TimelinePane {
let instances = layer_clips(layer); let instances = layer_clips(layer);
for inst in instances { for inst in instances {
if !shared.selection.contains_clip_instance(&inst.id) { continue; } if !shared.selection.contains_clip_instance(&inst.id) { continue; }
if let Some(dur) = document.get_clip_duration(&inst.clip_id) { if let Some(dur) = document.clip_trim_duration(&inst.clip_id) {
let eff = inst.effective_duration(dur, document.tempo_map()); let eff = inst.effective_duration(dur, document.tempo_map());
let start = document.tempo_map().beats_to_seconds(inst.timeline_start).seconds_to_f64(); let start = document.tempo_map().beats_to_seconds(inst.timeline_start).seconds_to_f64();
let end = document.tempo_map().beats_to_seconds(inst.timeline_start + eff).seconds_to_f64(); let end = document.tempo_map().beats_to_seconds(inst.timeline_start + eff).seconds_to_f64();
@ -6487,7 +6522,7 @@ impl PaneRenderer for TimelinePane {
enabled = instances.iter() enabled = instances.iter()
.filter(|ci| shared.selection.contains_clip_instance(&ci.id)) .filter(|ci| shared.selection.contains_clip_instance(&ci.id))
.all(|ci| { .all(|ci| {
if let Some(dur) = document.get_clip_duration(&ci.clip_id) { if let Some(dur) = document.clip_trim_duration(&ci.clip_id) {
let eff = ci.effective_duration(dur, document.tempo_map()); let eff = ci.effective_duration(dur, document.tempo_map());
// Room to duplicate = seconds gap to the right ≥ this clip's own length. // Room to duplicate = seconds gap to the right ≥ this clip's own length.
let max_extend_secs = document.find_max_trim_extend_right( let max_extend_secs = document.find_max_trim_extend_right(
@ -6532,7 +6567,7 @@ impl PaneRenderer for TimelinePane {
enabled = instances.iter().all(|ci| { enabled = instances.iter().all(|ci| {
let paste_start = (ci.timeline_start + offset).max(Beats::ZERO); let paste_start = (ci.timeline_start + offset).max(Beats::ZERO);
if let Some(dur) = document.get_clip_duration(&ci.clip_id) { if let Some(dur) = document.clip_trim_duration(&ci.clip_id) {
let eff = ci.effective_duration(dur, document.tempo_map()); let eff = ci.effective_duration(dur, document.tempo_map());
document document
.find_nearest_valid_position( .find_nearest_valid_position(