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:
parent
16e3d676d6
commit
a5cdbfd0fd
|
|
@ -572,7 +572,7 @@ impl Engine {
|
|||
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)
|
||||
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
|
||||
.event_tx
|
||||
.push(AudioEvent::PlaybackPosition(position_seconds));
|
||||
|
|
@ -922,7 +922,7 @@ impl Engine {
|
|||
self.project.stop_all_notes();
|
||||
}
|
||||
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)
|
||||
let clamped = self.playhead.max(0) as u64;
|
||||
self.playhead_atomic.store(clamped, Ordering::Relaxed);
|
||||
|
|
@ -1014,12 +1014,12 @@ impl Engine {
|
|||
match self.project.get_track_mut(track_id) {
|
||||
Some(crate::audio::track::TrackNode::Audio(track)) => {
|
||||
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)) => {
|
||||
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) => {
|
||||
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) => {
|
||||
|
|
@ -1056,12 +1056,12 @@ impl Engine {
|
|||
}
|
||||
Command::SetTrimStart(track_id, trim_start) => {
|
||||
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) => {
|
||||
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) => {
|
||||
|
|
@ -1115,9 +1115,13 @@ impl Engine {
|
|||
// Send chunks via MPSC channel (will be forwarded by audio thread)
|
||||
if !chunks.is_empty() {
|
||||
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()
|
||||
.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();
|
||||
|
||||
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);
|
||||
|
||||
// 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);
|
||||
|
||||
// Create an instance for this clip on the track
|
||||
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) {
|
||||
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
|
||||
if let Some(clip) = self.project.midi_clip_pool.get_clip_mut(clip_id) {
|
||||
// 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);
|
||||
|
||||
// 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);
|
||||
clip.add_event(note_off);
|
||||
} else {
|
||||
|
|
@ -1214,9 +1218,9 @@ impl Engine {
|
|||
if let Some(instance) = track.clip_instances.iter().find(|c| c.clip_id == clip_id) {
|
||||
let actual_clip_id = instance.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);
|
||||
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);
|
||||
clip.add_event(note_off);
|
||||
}
|
||||
|
|
@ -1226,7 +1230,7 @@ impl Engine {
|
|||
}
|
||||
Command::AddLoadedMidiClip(track_id, clip, 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
|
||||
}
|
||||
self.refresh_clip_snapshot();
|
||||
|
|
@ -1240,11 +1244,11 @@ impl Engine {
|
|||
// Add new events from the notes array
|
||||
// Timestamps are in beats (canonical)
|
||||
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);
|
||||
|
||||
// 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);
|
||||
clip.events.push(note_off);
|
||||
}
|
||||
|
|
@ -1306,7 +1310,7 @@ impl Engine {
|
|||
}
|
||||
Command::AddAutomationPoint(track_id, lane_id, time, value, curve) => {
|
||||
// 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) {
|
||||
Some(crate::audio::track::TrackNode::Audio(track)) => {
|
||||
|
|
@ -1332,17 +1336,17 @@ impl Engine {
|
|||
match self.project.get_track_mut(track_id) {
|
||||
Some(crate::audio::track::TrackNode::Audio(track)) => {
|
||||
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)) => {
|
||||
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)) => {
|
||||
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 => {}
|
||||
|
|
@ -2431,7 +2435,7 @@ impl Engine {
|
|||
// Downcast to AutomationInputNode using as_any_mut
|
||||
if let Some(auto_node) = graph_node.node.as_any_mut().downcast_mut::<AutomationInputNode>() {
|
||||
let keyframe = AutomationKeyframe {
|
||||
time: Beats(time),
|
||||
time,
|
||||
value,
|
||||
interpolation,
|
||||
ease_out,
|
||||
|
|
@ -2459,7 +2463,7 @@ impl Engine {
|
|||
|
||||
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>() {
|
||||
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 {
|
||||
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)
|
||||
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()
|
||||
.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();
|
||||
|
||||
let _ = chunk_tx.send(AudioEvent::WaveformChunksReady {
|
||||
|
|
@ -2694,7 +2701,7 @@ impl Engine {
|
|||
path: path_str,
|
||||
channels: metadata.channels,
|
||||
sample_rate: metadata.sample_rate,
|
||||
duration: metadata.duration,
|
||||
duration: Seconds(metadata.duration),
|
||||
format: metadata.format,
|
||||
});
|
||||
|
||||
|
|
@ -2802,7 +2809,7 @@ impl Engine {
|
|||
if let Some(clip) = self.project.midi_clip_pool.get_clip(clip_id) {
|
||||
use crate::command::MidiClipData;
|
||||
QueryResponse::MidiClipData(Ok(MidiClipData {
|
||||
duration: clip.duration.0,
|
||||
duration: clip.duration,
|
||||
events: clip.events.clone(),
|
||||
}))
|
||||
} else {
|
||||
|
|
@ -2834,7 +2841,7 @@ impl Engine {
|
|||
InterpolationType::Hold => "hold",
|
||||
}.to_string();
|
||||
AutomationKeyframeData {
|
||||
time: kf.time.0,
|
||||
time: kf.time,
|
||||
value: kf.value,
|
||||
interpolation: interpolation_str,
|
||||
ease_out: kf.ease_out,
|
||||
|
|
@ -3003,7 +3010,11 @@ impl Engine {
|
|||
}
|
||||
Query::GetPoolFileInfo(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))),
|
||||
}
|
||||
}
|
||||
|
|
@ -3050,7 +3061,7 @@ impl Engine {
|
|||
}
|
||||
Query::AddMidiClipSync(track_id, clip, start_time) => {
|
||||
// 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)),
|
||||
Err(e) => QueryResponse::MidiClipInstanceAdded(Err(e.to_string())),
|
||||
};
|
||||
|
|
@ -3644,7 +3655,7 @@ impl EngineController {
|
|||
|
||||
/// Seek to a specific position in 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)
|
||||
|
|
@ -3691,7 +3702,7 @@ impl EngineController {
|
|||
|
||||
/// Extend or shrink a clip's external duration (enables looping if > internal duration)
|
||||
pub fn extend_clip(&mut self, track_id: TrackId, clip_id: ClipId, new_external_duration: 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
|
||||
|
|
@ -3705,9 +3716,9 @@ impl EngineController {
|
|||
}
|
||||
|
||||
/// 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);
|
||||
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
|
||||
|
|
@ -3740,7 +3751,7 @@ impl EngineController {
|
|||
/// Set metatrack time offset in seconds
|
||||
/// Positive = shift content later, negative = shift earlier
|
||||
pub fn set_offset(&mut self, track_id: TrackId, offset: 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)
|
||||
|
|
@ -3750,12 +3761,12 @@ impl EngineController {
|
|||
|
||||
/// Set metatrack trim start in 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)
|
||||
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
|
||||
|
|
@ -3909,23 +3920,22 @@ impl EngineController {
|
|||
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
|
||||
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
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
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)
|
||||
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]).
|
||||
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));
|
||||
}
|
||||
|
||||
|
|
@ -3966,7 +3976,7 @@ impl EngineController {
|
|||
curve: crate::audio::CurveType,
|
||||
) {
|
||||
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,
|
||||
) {
|
||||
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,
|
||||
ease_out: (f32, f32), ease_in: (f32, f32)) {
|
||||
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
|
||||
pub fn automation_remove_keyframe(&mut self, track_id: TrackId, node_id: u32, time: Beats) {
|
||||
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
|
||||
|
|
@ -4560,7 +4570,7 @@ impl EngineController {
|
|||
}
|
||||
|
||||
/// 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
|
||||
if let Err(_) = self.query_tx.push(Query::GetPoolFileInfo(pool_index)) {
|
||||
return Err("Failed to send query - queue full".to_string());
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ pub enum Command {
|
|||
/// Pause playback (maintains position)
|
||||
Pause,
|
||||
/// Seek to a specific position in seconds
|
||||
Seek(f64),
|
||||
Seek(Seconds),
|
||||
|
||||
// Track management commands
|
||||
/// Set track volume (0.0 = silence, 1.0 = unity gain)
|
||||
|
|
@ -51,7 +51,7 @@ pub enum Command {
|
|||
TrimClip(TrackId, ClipId, TrimRange),
|
||||
/// Extend/shrink a clip's external duration (track_id, clip_id, new_external_duration)
|
||||
/// If duration > internal duration, the clip will loop
|
||||
ExtendClip(TrackId, ClipId, f64),
|
||||
ExtendClip(TrackId, ClipId, Beats),
|
||||
|
||||
// Metatrack management commands
|
||||
/// Create a new metatrack with a name and optional parent group
|
||||
|
|
@ -67,15 +67,15 @@ pub enum Command {
|
|||
SetTimeStretch(TrackId, f32),
|
||||
/// Set metatrack time offset in seconds (track_id, offset)
|
||||
/// 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
|
||||
SetPitchShift(TrackId, f32),
|
||||
/// Set metatrack trim start in seconds (track_id, trim_start)
|
||||
/// Children won't hear content before this point
|
||||
SetTrimStart(TrackId, f64),
|
||||
SetTrimStart(TrackId, Seconds),
|
||||
/// Set metatrack trim end in seconds (track_id, trim_end)
|
||||
/// None means no end trim
|
||||
SetTrimEnd(TrackId, Option<f64>),
|
||||
SetTrimEnd(TrackId, Option<Seconds>),
|
||||
|
||||
// Audio track commands
|
||||
/// 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
|
||||
AddMidiClipToPool(MidiClip),
|
||||
/// 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)
|
||||
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)
|
||||
AddLoadedMidiClip(TrackId, MidiClip, f64),
|
||||
AddLoadedMidiClip(TrackId, MidiClip, Beats),
|
||||
/// 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
|
||||
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.
|
||||
UpdateMidiClipEvents(TrackId, MidiClipId, Vec<MidiEvent>),
|
||||
/// 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)
|
||||
CreateAutomationLane(TrackId, ParameterId),
|
||||
/// 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)
|
||||
RemoveAutomationPoint(TrackId, AutomationLaneId, f64, f64),
|
||||
RemoveAutomationPoint(TrackId, AutomationLaneId, Beats, Beats),
|
||||
/// Clear all automation points from a lane (track_id, lane_id)
|
||||
ClearAutomationLane(TrackId, AutomationLaneId),
|
||||
/// Remove an automation lane (track_id, lane_id)
|
||||
|
|
@ -258,9 +258,9 @@ pub enum Command {
|
|||
|
||||
// Automation Input Node commands
|
||||
/// 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)
|
||||
AutomationRemoveKeyframe(TrackId, u32, f64),
|
||||
AutomationRemoveKeyframe(TrackId, u32, Beats),
|
||||
/// Set the display name of an AutomationInput node (track_id, node_id, name)
|
||||
AutomationSetName(TrackId, u32, String),
|
||||
|
||||
|
|
@ -292,7 +292,7 @@ pub enum Command {
|
|||
#[derive(Debug, Clone)]
|
||||
pub enum AudioEvent {
|
||||
/// Current playback position in seconds
|
||||
PlaybackPosition(f64),
|
||||
PlaybackPosition(Seconds),
|
||||
/// Playback has stopped (reached end of audio)
|
||||
PlaybackStopped,
|
||||
/// Audio buffer underrun detected
|
||||
|
|
@ -379,7 +379,7 @@ pub enum AudioEvent {
|
|||
WaveformChunksReady {
|
||||
pool_index: usize,
|
||||
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.
|
||||
|
|
@ -390,7 +390,7 @@ pub enum AudioEvent {
|
|||
path: String,
|
||||
channels: u32,
|
||||
sample_rate: u32,
|
||||
duration: f64,
|
||||
duration: Seconds,
|
||||
format: crate::io::audio_file::AudioFormat,
|
||||
},
|
||||
|
||||
|
|
@ -464,7 +464,7 @@ pub enum Query {
|
|||
/// Export audio to file (settings, output_path)
|
||||
ExportAudio(crate::audio::ExportSettings, std::path::PathBuf),
|
||||
/// 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
|
||||
/// The clip must already exist in the MidiClipPool
|
||||
AddMidiClipInstanceSync(TrackId, crate::audio::midi::MidiClipInstance),
|
||||
|
|
@ -509,16 +509,21 @@ pub struct OscilloscopeData {
|
|||
}
|
||||
|
||||
/// 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)]
|
||||
pub struct MidiClipData {
|
||||
pub duration: f64,
|
||||
/// MIDI content length is musical, so beats.
|
||||
pub duration: Beats,
|
||||
pub events: Vec<crate::audio::midi::MidiEvent>,
|
||||
}
|
||||
|
||||
/// Automation keyframe data for serialization
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct AutomationKeyframeData {
|
||||
pub time: f64,
|
||||
/// Automation x-axes are all beats.
|
||||
pub time: Beats,
|
||||
pub value: f32,
|
||||
pub interpolation: String,
|
||||
pub ease_out: (f32, f32),
|
||||
|
|
@ -555,7 +560,7 @@ pub enum QueryResponse {
|
|||
/// Pool waveform data
|
||||
PoolWaveform(Result<Vec<crate::io::WaveformPeak>, String>),
|
||||
/// Pool file info (duration, sample_rate, channels)
|
||||
PoolFileInfo(Result<(f64, u32, u32), String>),
|
||||
PoolFileInfo(Result<(Seconds, u32, u32), String>),
|
||||
/// Audio exported
|
||||
AudioExported(Result<(), String>),
|
||||
/// MIDI clip instance added (returns instance ID)
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ pub use audio::{
|
|||
TrackNode,
|
||||
};
|
||||
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 command::{AudioEvent, Command, OscilloscopeData};
|
||||
pub use command::types::AutomationKeyframeData;
|
||||
|
|
|
|||
|
|
@ -16,6 +16,50 @@ pub struct Beats(pub f64);
|
|||
#[serde(transparent)]
|
||||
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 {
|
||||
pub const ZERO: Self = Self(0.0);
|
||||
|
||||
|
|
|
|||
|
|
@ -556,7 +556,7 @@ pub fn run_tui(
|
|||
while let Ok(event) = rx.pop() {
|
||||
match event {
|
||||
AudioEvent::PlaybackPosition(pos) => {
|
||||
app.update_playback_position(pos);
|
||||
app.update_playback_position(pos.seconds_to_f64());
|
||||
}
|
||||
AudioEvent::PlaybackStopped => {
|
||||
app.set_playing(false);
|
||||
|
|
|
|||
|
|
@ -77,11 +77,18 @@ impl BackendContext<'_> {
|
|||
.ok_or_else(|| format!("Layer {} not mapped to backend track", layer_id))?;
|
||||
|
||||
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_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;
|
||||
|
||||
// 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
|
||||
.audio_controller
|
||||
.as_mut()
|
||||
|
|
@ -91,18 +98,14 @@ impl BackendContext<'_> {
|
|||
ResolvedContent::Midi { midi_clip_id } => {
|
||||
use daw_backend::command::{Query, QueryResponse};
|
||||
|
||||
// MIDI trims are in the BEATS domain, so the fallback span is beats too.
|
||||
let external_duration = instance
|
||||
.timeline_duration
|
||||
.unwrap_or(daw_backend::Beats(internal_end - internal_start));
|
||||
|
||||
// MIDI content time IS beats, so the trims carry straight over.
|
||||
let midi_instance = daw_backend::MidiClipInstance::new(
|
||||
0, // assigned by the backend
|
||||
midi_clip_id,
|
||||
daw_backend::Beats(internal_start),
|
||||
daw_backend::Beats(internal_end),
|
||||
daw_backend::Beats(internal_start.raw()),
|
||||
daw_backend::Beats(internal_end.raw()),
|
||||
start_time,
|
||||
external_duration,
|
||||
effective_duration,
|
||||
);
|
||||
|
||||
match controller
|
||||
|
|
@ -114,26 +117,13 @@ impl BackendContext<'_> {
|
|||
}
|
||||
}
|
||||
ResolvedContent::Audio { audio_pool_index } => {
|
||||
// `trim_*` and the clip's content duration are SECONDS (audio content time); the
|
||||
// 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
|
||||
});
|
||||
|
||||
// Sampled-audio content time is SECONDS; the backend's start/duration are BEATS.
|
||||
let id = controller.add_audio_clip(
|
||||
track_id,
|
||||
audio_pool_index,
|
||||
start_time,
|
||||
effective_duration,
|
||||
daw_backend::Seconds(internal_start),
|
||||
daw_backend::Seconds(internal_start.raw()),
|
||||
);
|
||||
BackendClipInstanceId::Audio(id)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -89,10 +89,11 @@ impl Action for AddClipInstanceAction {
|
|||
// `get_clip_duration` is the content length in seconds; the placement span
|
||||
// must be beats (the timeline is beats-domain), so convert via the clip's
|
||||
// typed helper rather than treating the seconds span as beats.
|
||||
let clip_duration = document.get_clip_duration(&self.clip_instance.clip_id)
|
||||
// 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))?;
|
||||
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
|
||||
let adjusted_start = document.find_nearest_valid_position(
|
||||
|
|
|
|||
|
|
@ -128,17 +128,13 @@ impl LoopClipInstancesAction {
|
|||
(new_dur, new_lb)
|
||||
};
|
||||
|
||||
let content_window = {
|
||||
let trim_end = instance.trim_end.unwrap_or(clip.content_duration().native());
|
||||
(trim_end - instance.trim_start).max(0.0) // seconds
|
||||
};
|
||||
// Natural content length as a beats span at the clip's start (the
|
||||
// fallback when no explicit timeline_duration is set).
|
||||
let tempo_map = document.tempo_map();
|
||||
let content_window_beats = tempo_map.seconds_to_beats(
|
||||
tempo_map.beats_to_seconds(instance.timeline_start)
|
||||
+ daw_backend::Seconds(content_window),
|
||||
) - instance.timeline_start;
|
||||
// Natural content length as a beats span (the fallback when no explicit
|
||||
// timeline_duration is set). Resolved in the clip's own domain, so MIDI's beats
|
||||
// content carries over directly rather than being read as seconds.
|
||||
let content_window_beats = instance.effective_duration_beats(
|
||||
clip.content_duration(),
|
||||
document.tempo_map(),
|
||||
);
|
||||
let right_duration = target_duration.unwrap_or(content_window_beats);
|
||||
let left_duration = target_loop_before.unwrap_or(daw_backend::Beats::ZERO);
|
||||
let external_duration = left_duration + right_duration;
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ impl Action for MoveClipInstancesAction {
|
|||
|
||||
let group: Vec<(Uuid, Beats, Beats)> = moves.iter().filter_map(|(id, old_start, _)| {
|
||||
let inst = clip_instances.iter().find(|ci| &ci.id == id)?;
|
||||
let dur = document.get_clip_duration(&inst.clip_id)?;
|
||||
let dur = document.clip_trim_duration(&inst.clip_id)?;
|
||||
let eff = inst.effective_duration_beats(dur, document.tempo_map());
|
||||
Some((*id, *old_start, eff))
|
||||
}).collect();
|
||||
|
|
@ -211,8 +211,9 @@ impl Action for MoveClipInstancesAction {
|
|||
// Check if this clip has a metatrack
|
||||
if let Some(&metatrack_id) = backend.layer_to_track_map.get(&instance.clip_id) {
|
||||
controller.set_offset(metatrack_id, document.tempo_map().beats_to_seconds(*new_start));
|
||||
controller.set_trim_start(metatrack_id, daw_backend::Seconds(instance.trim_start));
|
||||
controller.set_trim_end(metatrack_id, instance.trim_end.map(daw_backend::Seconds));
|
||||
// A vector clip's content is wall-clock, so its content times ARE 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(&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_trim_start(metatrack_id, daw_backend::Seconds(instance.trim_start));
|
||||
controller.set_trim_end(metatrack_id, instance.trim_end.map(daw_backend::Seconds));
|
||||
// A vector clip's content is wall-clock, so its content times ARE 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())));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -138,92 +138,23 @@ impl Action for RemoveClipInstancesAction {
|
|||
backend: &mut BackendContext,
|
||||
document: &Document,
|
||||
) -> Result<(), String> {
|
||||
use crate::clip::ResolvedContent;
|
||||
if backend.audio_controller.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let controller = match backend.audio_controller.as_mut() {
|
||||
Some(c) => c,
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
// Re-add clips that were removed from backend
|
||||
for (layer_id, instance) in &self.saved {
|
||||
let layer = match document.get_layer(layer_id) {
|
||||
Some(l) => l,
|
||||
None => continue,
|
||||
};
|
||||
if !matches!(layer, AnyLayer::Audio(_)) {
|
||||
// Re-add the clips that were removed. `BackendContext::add_clip_instance` is the same
|
||||
// helper the add and split actions use, so the trim/duration conversions (and take-folder
|
||||
// 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);
|
||||
for (layer_id, instance) in &saved {
|
||||
if !matches!(document.get_layer(layer_id), Some(AnyLayer::Audio(_))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let track_id = match backend.layer_to_track_map.get(layer_id) {
|
||||
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 => {}
|
||||
}
|
||||
// A missing track/clip just means there's nothing to restore on the backend.
|
||||
let _ = backend.add_clip_instance(document, layer_id, instance);
|
||||
}
|
||||
self.saved = saved;
|
||||
|
||||
// Clear saved backend IDs
|
||||
self.saved_backend_ids.clear();
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ use crate::action::{Action, BackendContext};
|
|||
use crate::clip::ClipInstance;
|
||||
use crate::document::Document;
|
||||
use crate::layer::AnyLayer;
|
||||
use daw_backend::ContentTime;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Action that splits a clip instance at a specific timeline position
|
||||
|
|
@ -25,7 +26,7 @@ pub struct SplitClipInstanceAction {
|
|||
|
||||
// Stored during execute for rollback
|
||||
/// 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: Option<daw_backend::Beats>,
|
||||
/// 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)
|
||||
.ok_or_else(|| format!("Clip instance {} not found", self.instance_id))?;
|
||||
|
||||
// Get the clip's duration
|
||||
let clip_duration = document
|
||||
.get_clip_duration(&instance.clip_id)
|
||||
// The clip's content duration in its OWN domain — 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; 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))?;
|
||||
|
||||
// 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;
|
||||
|
||||
// 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_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 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).
|
||||
let time_into_clip = self.split_time - instance.timeline_start;
|
||||
let left_duration = time_into_clip;
|
||||
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 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::Seconds(_) => (tempo_map.beats_to_seconds(self.split_time)
|
||||
- tempo_map.beats_to_seconds(instance.timeline_start))
|
||||
.seconds_to_f64(),
|
||||
};
|
||||
});
|
||||
|
||||
// Calculate the content split point (trim domain).
|
||||
let content_split_time = if is_looping && content_duration > 0.0 {
|
||||
// Calculate the content split point (content domain).
|
||||
let content_split_time = if is_looping && content_duration > ContentTime::ZERO {
|
||||
// For looping clips, wrap around content
|
||||
instance.trim_start + (time_into_content % content_duration)
|
||||
} else {
|
||||
|
|
@ -367,119 +363,63 @@ impl Action for SplitClipInstanceAction {
|
|||
.get_audio_clip(&new_instance.clip_id)
|
||||
.ok_or_else(|| "Audio clip not found".to_string())?;
|
||||
|
||||
// Look up backend track ID from layer mapping
|
||||
let backend_track_id = backend
|
||||
use crate::clip::ResolvedContent;
|
||||
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
|
||||
.get(&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
|
||||
.audio_controller
|
||||
.as_mut()
|
||||
.ok_or_else(|| "Audio controller not available".to_string())?;
|
||||
|
||||
// Handle different clip types
|
||||
use crate::clip::ResolvedContent;
|
||||
match &clip.resolve(original_instance.active_take) {
|
||||
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));
|
||||
{
|
||||
let controller = backend
|
||||
.audio_controller
|
||||
.as_mut()
|
||||
.ok_or_else(|| "Audio controller not available".to_string())?;
|
||||
match left_backend_id {
|
||||
Some(crate::action::BackendClipInstanceId::Midi(id))
|
||||
| Some(crate::action::BackendClipInstanceId::Audio(id)) => {
|
||||
controller.trim_clip(backend_track_id, id, left_trim);
|
||||
}
|
||||
|
||||
// 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())
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
// 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(
|
||||
|
|
@ -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(clip) = document.get_audio_clip(&instance.clip_id) {
|
||||
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
|
||||
use crate::clip::ResolvedContent;
|
||||
|
|
@ -564,8 +506,8 @@ mod tests {
|
|||
// Create a clip instance at timeline 0, with trim 0-10 (10 seconds)
|
||||
let mut clip_instance = ClipInstance::new(clip_id);
|
||||
clip_instance.timeline_start = daw_backend::Beats::ZERO;
|
||||
clip_instance.trim_start = 0.0;
|
||||
clip_instance.trim_end = Some(10.0);
|
||||
clip_instance.trim_start = ContentTime::ZERO;
|
||||
clip_instance.trim_end = Some(ContentTime(10.0));
|
||||
let instance_id = clip_instance.id;
|
||||
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 instance = ClipInstance::new(clip_id);
|
||||
instance.timeline_start = daw_backend::Beats::ZERO;
|
||||
instance.trim_start = 0.0;
|
||||
instance.trim_end = Some(8.0); // beats
|
||||
instance.trim_start = ContentTime::ZERO;
|
||||
instance.trim_end = Some(ContentTime(8.0)); // beats
|
||||
let instance_id = instance.id;
|
||||
audio_layer.clip_instances.push(instance);
|
||||
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 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!(left.trim_end, Some(4.0), "left half must end 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(ContentTime(4.0)), "left half must end 4 BEATS into the content");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ use crate::action::Action;
|
|||
use crate::clip::ClipInstance;
|
||||
use crate::document::Document;
|
||||
use crate::layer::AnyLayer;
|
||||
use daw_backend::{Beats, Seconds};
|
||||
use daw_backend::{Beats, ContentTime, Seconds};
|
||||
use std::collections::HashMap;
|
||||
use uuid::Uuid;
|
||||
|
||||
|
|
@ -32,15 +32,57 @@ pub struct TrimClipInstancesAction {
|
|||
pub struct TrimData {
|
||||
/// For TrimLeft: trim_start value
|
||||
/// 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 TrimRight: unused (None)
|
||||
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 {
|
||||
/// 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 {
|
||||
trim_value: Some(trim_start),
|
||||
timeline_start: Some(timeline_start),
|
||||
|
|
@ -48,7 +90,7 @@ impl TrimData {
|
|||
}
|
||||
|
||||
/// Create TrimData for right trim
|
||||
pub fn right(trim_end: Option<f64>) -> Self {
|
||||
pub fn right(trim_end: Option<ContentTime>) -> Self {
|
||||
Self {
|
||||
trim_value: trim_end,
|
||||
timeline_start: None,
|
||||
|
|
@ -192,7 +234,8 @@ impl Action for TrimClipInstancesAction {
|
|||
.find(|ci| &ci.id == 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))?;
|
||||
|
||||
let mut clamped_new = new.clone();
|
||||
|
|
@ -204,23 +247,34 @@ impl Action for TrimClipInstancesAction {
|
|||
{
|
||||
// If extending to the left (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();
|
||||
|
||||
// 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
|
||||
.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);
|
||||
|
||||
clamped_new = TrimData::left(clamped_trim_start, clamped_timeline_start);
|
||||
|
|
@ -228,36 +282,39 @@ impl Action for TrimClipInstancesAction {
|
|||
}
|
||||
}
|
||||
TrimType::TrimRight => {
|
||||
let old_trim_end = old.trim_value.unwrap_or(clip_duration.seconds_to_f64());
|
||||
let new_trim_end = new.trim_value.unwrap_or(clip_duration.seconds_to_f64());
|
||||
let content_end = ContentTime(clip_content.native());
|
||||
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 should_validate && new_trim_end > old_trim_end {
|
||||
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).
|
||||
let max_extend_secs = document.find_max_trim_extend_right(
|
||||
layer_id,
|
||||
instance_id,
|
||||
// How long the clip currently occupies the timeline, in beats. Resolved
|
||||
// in the clip's own domain, so a MIDI clip's beats content isn't run
|
||||
// through the seconds→beats conversion a second time.
|
||||
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,
|
||||
current_effective_duration,
|
||||
).seconds_to_f64();
|
||||
tempo_map,
|
||||
);
|
||||
|
||||
// Calculate how much we want to extend (content seconds)
|
||||
let desired_extend = new_trim_end - old_trim_end;
|
||||
|
||||
// Clamp to max allowed
|
||||
let actual_extend = desired_extend.min(max_extend_secs);
|
||||
let actual_extend = desired_extend.min(max_extend);
|
||||
let clamped_trim_end = old_trim_end + actual_extend;
|
||||
|
||||
// Don't exceed clip duration
|
||||
let final_trim_end = clamped_trim_end.min(clip_duration.seconds_to_f64());
|
||||
// Don't exceed the clip's content.
|
||||
let final_trim_end = clamped_trim_end.min(content_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) {
|
||||
// Instance already has new values after execute()
|
||||
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));
|
||||
controller.set_trim_end(metatrack_id, instance.trim_end.map(daw_backend::Seconds));
|
||||
// A vector clip's content is wall-clock, so its content times ARE 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
|
||||
// Note: instance already has the new trim values after execute()
|
||||
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
|
||||
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) {
|
||||
// Instance already has old values after rollback()
|
||||
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));
|
||||
controller.set_trim_end(metatrack_id, instance.trim_end.map(daw_backend::Seconds));
|
||||
// A vector clip's content is wall-clock, so its content times ARE 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))?;
|
||||
|
||||
// Calculate old internal_start and internal_end for backend
|
||||
let content_end = ContentTime(clip.content_duration().native());
|
||||
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
|
||||
};
|
||||
let internal_end = match trim_type {
|
||||
TrimType::TrimLeft => instance.trim_end.unwrap_or(clip.content_duration().native()), // trim_end wasn't changed
|
||||
TrimType::TrimRight => old.trim_value.unwrap_or(clip.content_duration().native()),
|
||||
TrimType::TrimLeft => instance.trim_end.unwrap_or(content_end), // trim_end wasn't changed
|
||||
TrimType::TrimRight => old.trim_value.unwrap_or(content_end),
|
||||
};
|
||||
|
||||
// Handle trim based on clip type
|
||||
|
|
@ -569,7 +631,7 @@ mod tests {
|
|||
|
||||
let mut clip_instance = ClipInstance::new(clip_id);
|
||||
clip_instance.timeline_start = Beats::ZERO;
|
||||
clip_instance.trim_start = 0.0;
|
||||
clip_instance.trim_start = ContentTime::ZERO;
|
||||
let instance_id = clip_instance.id;
|
||||
vector_layer.clip_instances.push(clip_instance);
|
||||
|
||||
|
|
@ -582,8 +644,8 @@ mod tests {
|
|||
vec![(
|
||||
instance_id,
|
||||
TrimType::TrimLeft,
|
||||
TrimData::left(0.0, Beats::ZERO),
|
||||
TrimData::left(2.0, Beats(2.0)),
|
||||
TrimData::left(ContentTime::ZERO, Beats::ZERO),
|
||||
TrimData::left(ContentTime(2.0), Beats(2.0)),
|
||||
)],
|
||||
);
|
||||
|
||||
|
|
@ -599,7 +661,7 @@ mod tests {
|
|||
.iter()
|
||||
.find(|ci| ci.id == instance_id)
|
||||
.unwrap();
|
||||
assert_eq!(instance.trim_start, 2.0);
|
||||
assert_eq!(instance.trim_start, ContentTime(2.0));
|
||||
assert_eq!(instance.timeline_start, Beats(2.0));
|
||||
}
|
||||
|
||||
|
|
@ -613,7 +675,7 @@ mod tests {
|
|||
.iter()
|
||||
.find(|ci| ci.id == instance_id)
|
||||
.unwrap();
|
||||
assert_eq!(instance.trim_start, 0.0);
|
||||
assert_eq!(instance.trim_start, ContentTime::ZERO);
|
||||
assert_eq!(instance.timeline_start, Beats::ZERO);
|
||||
}
|
||||
}
|
||||
|
|
@ -644,7 +706,7 @@ mod tests {
|
|||
instance_id,
|
||||
TrimType::TrimRight,
|
||||
TrimData::right(None),
|
||||
TrimData::right(Some(8.0)),
|
||||
TrimData::right(Some(ContentTime(8.0))),
|
||||
)],
|
||||
);
|
||||
|
||||
|
|
@ -660,7 +722,7 @@ mod tests {
|
|||
.iter()
|
||||
.find(|ci| ci.id == instance_id)
|
||||
.unwrap();
|
||||
assert_eq!(instance.trim_end, Some(8.0));
|
||||
assert_eq!(instance.trim_end, Some(ContentTime(8.0)));
|
||||
}
|
||||
|
||||
// Rollback
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
use crate::layer::AnyLayer;
|
||||
use crate::layer_tree::LayerTree;
|
||||
use crate::object::Transform;
|
||||
use daw_backend::{Beats, Seconds};
|
||||
use daw_backend::{Beats, ContentTime, Seconds};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use uuid::Uuid;
|
||||
|
|
@ -130,10 +130,14 @@ impl VectorClip {
|
|||
let end_beats: Beats = if let Some(td_beats) = ci.timeline_duration {
|
||||
ci.timeline_start + td_beats
|
||||
} else if let Some(te) = ci.trim_end {
|
||||
let secs = (te - ci.trim_start).max(0.0);
|
||||
// `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))
|
||||
} 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))
|
||||
} else {
|
||||
continue;
|
||||
|
|
@ -201,7 +205,9 @@ impl VectorClip {
|
|||
// Convert parent clip time (seconds) to nested clip local time (seconds).
|
||||
// timeline_start is in beats; convert to seconds using document BPM.
|
||||
let start_secs = document.tempo_map().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
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
|
@ -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 —
|
||||
/// SECONDS for sampled audio, BEATS for MIDI. Building the [`TrimRange`] from the clip means a
|
||||
/// caller can't reach for the wrong variant: the clip is the one thing that knows.
|
||||
pub fn trim_range(&self, start: f64, end: f64) -> daw_backend::command::TrimRange {
|
||||
/// This is the ONLY sanctioned way to turn a [`ContentTime`] into a real duration — the type has
|
||||
/// no `.to_seconds()` of its own precisely so that the clip, which is the one thing that knows
|
||||
/// whether its content is measured in seconds or beats, has to be consulted.
|
||||
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() {
|
||||
daw_backend::command::TrimRange::Beats {
|
||||
start: Beats(start),
|
||||
end: Beats(end),
|
||||
start: Beats(start.raw()),
|
||||
end: Beats(end.raw()),
|
||||
}
|
||||
} else {
|
||||
daw_backend::command::TrimRange::Seconds {
|
||||
start: Seconds(start),
|
||||
end: Seconds(end),
|
||||
start: Seconds(start.raw()),
|
||||
end: Seconds(end.raw()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -885,16 +914,17 @@ pub struct ClipInstance {
|
|||
/// Default: None (use trimmed clip duration, no looping)
|
||||
pub timeline_duration: Option<Beats>,
|
||||
|
||||
/// Trim start: offset into the clip's internal content, in **seconds**.
|
||||
/// - For audio: byte-offset into the audio file
|
||||
/// - For video: seek position in the video file
|
||||
/// - For vector: time offset into the animation
|
||||
/// Trim start: offset into the clip's internal content.
|
||||
///
|
||||
/// A [`ContentTime`] — measured in the CLIP's content domain, which is seconds for sampled
|
||||
/// 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
|
||||
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)
|
||||
pub trim_end: Option<f64>,
|
||||
pub trim_end: Option<ContentTime>,
|
||||
|
||||
/// Playback speed multiplier
|
||||
/// 1.0 = normal speed, 0.5 = half speed, 2.0 = double speed
|
||||
|
|
@ -973,7 +1003,7 @@ impl ClipInstance {
|
|||
name: None,
|
||||
timeline_start: Beats::ZERO,
|
||||
timeline_duration: None,
|
||||
trim_start: 0.0,
|
||||
trim_start: ContentTime::ZERO,
|
||||
trim_end: None,
|
||||
playback_speed: 1.0,
|
||||
gain: 1.0,
|
||||
|
|
@ -992,7 +1022,7 @@ impl ClipInstance {
|
|||
name: None,
|
||||
timeline_start: Beats::ZERO,
|
||||
timeline_duration: None,
|
||||
trim_start: 0.0,
|
||||
trim_start: ContentTime::ZERO,
|
||||
trim_end: None,
|
||||
playback_speed: 1.0,
|
||||
gain: 1.0,
|
||||
|
|
@ -1033,7 +1063,7 @@ impl ClipInstance {
|
|||
}
|
||||
|
||||
/// 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_end = trim_end;
|
||||
self
|
||||
|
|
@ -1057,24 +1087,40 @@ impl ClipInstance {
|
|||
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.
|
||||
pub fn content_window_secs(&self, clip_duration_secs: Seconds) -> Seconds {
|
||||
let end = self.trim_end.unwrap_or(clip_duration_secs.seconds_to_f64());
|
||||
Seconds((end - self.trim_start).max(0.0))
|
||||
pub fn content_window(&self, clip_content: ClipDuration) -> ClipDuration {
|
||||
let end = self.trim_end.map_or(clip_content.native(), |t| t.raw());
|
||||
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**.
|
||||
///
|
||||
/// If `timeline_duration` is set, returns that (enabling content looping).
|
||||
/// Otherwise converts the content window from seconds to beats using the tempo map.
|
||||
pub fn effective_duration_beats(&self, clip_duration_secs: Seconds, tempo_map: &crate::tempo_map::TempoMap) -> Beats {
|
||||
/// If `timeline_duration` is set, returns that (enabling content looping). Otherwise the clip
|
||||
/// occupies its content window — converted to beats *in the clip's own domain*:
|
||||
///
|
||||
/// - 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 {
|
||||
return td;
|
||||
}
|
||||
let window = self.content_window_secs(clip_duration_secs);
|
||||
let start_secs = tempo_map.beats_to_seconds(self.timeline_start);
|
||||
tempo_map.seconds_to_beats(start_secs + window) - self.timeline_start
|
||||
match self.content_window(clip_content) {
|
||||
ClipDuration::Beats(b) => b,
|
||||
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**.
|
||||
|
|
@ -1083,27 +1129,32 @@ impl ClipInstance {
|
|||
}
|
||||
|
||||
/// 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 {
|
||||
self.loop_before.unwrap_or(Beats::ZERO) + self.effective_duration_beats(clip_duration_secs, tempo_map)
|
||||
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_content, tempo_map)
|
||||
}
|
||||
|
||||
/// Map a playback time (in **seconds**) to clip-local content time (in **seconds**).
|
||||
///
|
||||
/// Returns `None` if the clip instance is not active at `time_secs`.
|
||||
pub fn remap_time_secs(&self, time: Seconds, clip_duration_secs: Seconds, tempo_map: &crate::tempo_map::TempoMap) -> Option<Seconds> {
|
||||
/// The trim bounds are resolved through `clip_content`'s domain first, so a MIDI clip's beats
|
||||
/// 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 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);
|
||||
|
||||
if time < start_secs || time >= end_secs {
|
||||
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_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 {
|
||||
return Some(Seconds(self.trim_start));
|
||||
return Some(trim_start_secs);
|
||||
}
|
||||
|
||||
let looped = if content_time > content_window {
|
||||
|
|
@ -1112,19 +1163,19 @@ impl ClipInstance {
|
|||
content_time
|
||||
};
|
||||
|
||||
Some(Seconds(self.trim_start) + looped)
|
||||
Some(trim_start_secs + looped)
|
||||
}
|
||||
|
||||
/// Alias for `remap_time_secs`.
|
||||
#[inline]
|
||||
pub fn remap_time(&self, time: Seconds, clip_duration_secs: Seconds, tempo_map: &crate::tempo_map::TempoMap) -> Option<Seconds> {
|
||||
self.remap_time_secs(time, clip_duration_secs, tempo_map)
|
||||
pub fn remap_time(&self, time: Seconds, clip_content: ClipDuration, tempo_map: &crate::tempo_map::TempoMap) -> Option<Seconds> {
|
||||
self.remap_time_secs(time, clip_content, tempo_map)
|
||||
}
|
||||
|
||||
/// Alias for `effective_duration_beats`.
|
||||
#[inline]
|
||||
pub fn effective_duration(&self, clip_duration_secs: Seconds, tempo_map: &crate::tempo_map::TempoMap) -> Beats {
|
||||
self.effective_duration_beats(clip_duration_secs, tempo_map)
|
||||
pub fn effective_duration(&self, clip_content: ClipDuration, tempo_map: &crate::tempo_map::TempoMap) -> Beats {
|
||||
self.effective_duration_beats(clip_content, tempo_map)
|
||||
}
|
||||
|
||||
/// Convert to affine transform
|
||||
|
|
@ -1201,7 +1252,7 @@ mod tests {
|
|||
assert_eq!(instance.clip_id, clip_id);
|
||||
assert_eq!(instance.opacity, 1.0);
|
||||
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.playback_speed, 1.0);
|
||||
assert_eq!(instance.gain, 1.0);
|
||||
|
|
@ -1211,27 +1262,52 @@ mod tests {
|
|||
fn test_clip_instance_trimming() {
|
||||
let clip_id = Uuid::new_v4();
|
||||
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_end, Some(8.0));
|
||||
assert_eq!(instance.trim_start, ContentTime(2.0));
|
||||
assert_eq!(instance.trim_end, Some(ContentTime(8.0)));
|
||||
// At 60 BPM the tempo map is identity (1 beat == 1 second), so the
|
||||
// beats-domain effective duration equals the seconds content window.
|
||||
let tempo_map = crate::tempo_map::TempoMap::constant(60.0);
|
||||
assert_eq!(instance.effective_duration(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]
|
||||
fn test_clip_instance_no_end_trim() {
|
||||
let clip_id = Uuid::new_v4();
|
||||
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);
|
||||
// At 60 BPM the tempo map is identity (1 beat == 1 second).
|
||||
let tempo_map = crate::tempo_map::TempoMap::constant(60.0);
|
||||
assert_eq!(instance.effective_duration(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]
|
||||
|
|
|
|||
|
|
@ -464,8 +464,11 @@ impl Document {
|
|||
let end_beats: Beats = if let Some(timeline_duration) = instance.timeline_duration {
|
||||
instance.timeline_start + timeline_duration
|
||||
} else {
|
||||
let trim_end = instance.trim_end.unwrap_or(clip_duration);
|
||||
let trimmed_secs = ((trim_end - instance.trim_start) / instance.playback_speed).max(0.0);
|
||||
// `clip_duration` arrives as seconds (the recursive helper's signature), so this
|
||||
// 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);
|
||||
tempo_map.seconds_to_beats(start_secs + Seconds(trimmed_secs))
|
||||
};
|
||||
|
|
@ -915,11 +918,10 @@ impl Document {
|
|||
/// have infinite internal duration.
|
||||
/// 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
|
||||
/// sampled audio, video and vector, but BEATS for MIDI (the backend takes MIDI trims as
|
||||
/// `Beats`). Anything doing arithmetic against a trim value — mapping a timeline position into
|
||||
/// the clip's content, say — has to work in that same domain, and [`Self::get_clip_duration`]
|
||||
/// can't tell it which: that one always converts to seconds.
|
||||
/// Content time is domain-polymorphic exactly like `AudioClip::duration`: SECONDS for sampled
|
||||
/// audio, video and vector, but BEATS for MIDI. Anything doing arithmetic against a trim value —
|
||||
/// mapping a timeline position into the clip's content, say — has to work in that same domain,
|
||||
/// and [`Self::get_clip_duration`] can't tell it which: that one always converts to seconds.
|
||||
///
|
||||
/// Returns `None` for unknown clips.
|
||||
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)
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
if let Some(clip) = self.vector_clips.get(clip_id) {
|
||||
if clip.is_group {
|
||||
|
|
@ -983,9 +1008,9 @@ impl Document {
|
|||
};
|
||||
|
||||
let instance = instances.iter().find(|inst| &inst.id == instance_id)?;
|
||||
let clip_duration = self.get_clip_duration(&instance.clip_id)?;
|
||||
// End position on the timeline, in beats (convert the seconds content window via tempo map).
|
||||
Some(instance.timeline_start + instance.effective_duration_beats(clip_duration, self.tempo_map()))
|
||||
// The clip's content duration in ITS OWN domain, so the trims resolve correctly for MIDI.
|
||||
let clip_content = self.clip_trim_duration(&instance.clip_id)?;
|
||||
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
|
||||
|
|
@ -1025,13 +1050,14 @@ impl Document {
|
|||
continue;
|
||||
}
|
||||
|
||||
// Calculate instance extent (accounting for loop_before)
|
||||
let Some(clip_duration) = self.get_clip_duration(&instance.clip_id) else {
|
||||
// Calculate instance extent (accounting for loop_before). Content duration in the clip's
|
||||
// own domain, so the trims resolve correctly for MIDI.
|
||||
let Some(clip_content) = self.clip_trim_duration(&instance.clip_id) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
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
|
||||
if start_time < instance_end && instance_start < end_time {
|
||||
|
|
@ -1088,7 +1114,7 @@ impl Document {
|
|||
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_end = instance.timeline_start + instance.effective_duration(clip_dur, self.tempo_map());
|
||||
occupied_ranges.push((inst_start, inst_end, instance.id));
|
||||
|
|
@ -1184,7 +1210,7 @@ impl Document {
|
|||
if group_ids.contains(&inst.id) {
|
||||
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 end = inst.timeline_start + inst.effective_duration(dur, self.tempo_map());
|
||||
non_group.push((start, end));
|
||||
|
|
@ -1258,9 +1284,8 @@ impl Document {
|
|||
}
|
||||
|
||||
// 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());
|
||||
// (clip_duration is Seconds via get_clip_duration; effective_duration converts.)
|
||||
|
||||
// If this clip is to the left and closer than current nearest
|
||||
if other_end <= current_timeline_start && other_end > nearest_end {
|
||||
|
|
@ -1358,7 +1383,7 @@ impl Document {
|
|||
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());
|
||||
|
||||
if other_end <= current_effective_start && other_end > nearest_end {
|
||||
|
|
|
|||
|
|
@ -152,7 +152,12 @@ impl EffectLayer {
|
|||
self.clip_instances
|
||||
.iter()
|
||||
.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
|
||||
})
|
||||
.collect()
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
//! Provides functions for testing if points or rectangles intersect with
|
||||
//! 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::layer::VectorLayer;
|
||||
use crate::shape::Shape;
|
||||
|
|
@ -260,7 +260,10 @@ pub fn hit_test_clip_instances(
|
|||
for clip_instance in clip_instances.iter().rev() {
|
||||
// Check time bounds: skip clip instances not active at this time
|
||||
// timeline_start/instance_end are in beats; convert timeline_time (seconds) to beats.
|
||||
let clip_duration = document.get_clip_duration(&clip_instance.clip_id).unwrap_or(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 timeline_beats = tempo_map.seconds_to_beats(daw_backend::Seconds(timeline_time));
|
||||
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)
|
||||
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) {
|
||||
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 {
|
||||
// Check time bounds: skip clip instances not active at this time
|
||||
// timeline_start/instance_end are in beats; convert timeline_time (seconds) to beats.
|
||||
let clip_duration = document.get_clip_duration(&clip_instance.clip_id).unwrap_or(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 timeline_beats = tempo_map.seconds_to_beats(daw_backend::Seconds(timeline_time));
|
||||
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 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) {
|
||||
vector_clip.calculate_content_bounds(document, clip_time)
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
//! The compositing mode enables proper per-layer opacity, blend modes, and effects.
|
||||
|
||||
use crate::animation::TransformProperty;
|
||||
use crate::clip::{ClipInstance, ImageAsset};
|
||||
use crate::clip::{ClipDuration, ClipInstance, ImageAsset};
|
||||
use crate::document::Document;
|
||||
use daw_backend::Seconds;
|
||||
use crate::gpu::BlendMode;
|
||||
|
|
@ -568,7 +568,7 @@ pub fn render_layer_isolated(
|
|||
let tempo_map = document.tempo_map();
|
||||
for clip_instance in &video_layer.clip_instances {
|
||||
let Some(video_clip) = document.video_clips.get(&clip_instance.clip_id) else { continue };
|
||||
let Some(clip_time) = clip_instance.remap_time(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 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
|
||||
} 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; // Clip instance not active at this time
|
||||
};
|
||||
|
|
@ -1174,7 +1177,7 @@ fn render_video_layer(
|
|||
|
||||
// Remap timeline time to clip's internal time
|
||||
let tempo_map = document.tempo_map();
|
||||
let Some(clip_time) = clip_instance.remap_time(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
|
||||
};
|
||||
let clip_time = clip_time.seconds_to_f64();
|
||||
|
|
@ -1910,7 +1913,10 @@ fn render_clip_instance_cpu(
|
|||
if time < start_secs || time >= end { return; }
|
||||
0.0
|
||||
} 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 };
|
||||
t.seconds_to_f64()
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1064,7 +1064,12 @@ fn composite_document_to_hdr(
|
|||
}
|
||||
let tempo_map = document.tempo_map();
|
||||
let effect_end_beats = effect_instance.timeline_start
|
||||
+ effect_instance.effective_duration(daw_backend::Seconds(lightningbeam_core::effect::EFFECT_DURATION), tempo_map);
|
||||
+ 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(
|
||||
effect_def,
|
||||
tempo_map.beats_to_seconds(effect_instance.timeline_start).seconds_to_f64(),
|
||||
|
|
|
|||
|
|
@ -2387,7 +2387,7 @@ impl EditorApp {
|
|||
) -> Vec<uuid::Uuid> {
|
||||
let mut result = Vec::new();
|
||||
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 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 mut duplicate = original.clone();
|
||||
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());
|
||||
duplicate.timeline_start = original.timeline_start + effective_duration;
|
||||
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).
|
||||
let (_dur, sample_rate, channels) = controller
|
||||
.get_pool_file_info(pool_index)
|
||||
.unwrap_or((video_duration, 0, 0));
|
||||
.unwrap_or((Seconds(video_duration), 0, 0));
|
||||
drop(controller);
|
||||
|
||||
let audio_clip_name = format!("{} (Audio)", video_name);
|
||||
|
|
@ -6384,7 +6386,9 @@ impl eframe::App for EditorApp {
|
|||
use daw_backend::AudioEvent;
|
||||
match event {
|
||||
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 => {
|
||||
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) {
|
||||
inst.timeline_start = loop_start;
|
||||
inst.timeline_duration = None;
|
||||
inst.trim_start = 0.0;
|
||||
inst.trim_end = Some(loop_len_seconds.seconds_to_f64());
|
||||
// Audio take content is seconds, and each take spans
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -6655,6 +6662,7 @@ impl eframe::App for EditorApp {
|
|||
let mut controller = controller_arc.lock().unwrap();
|
||||
match controller.get_pool_file_info(pool_index) {
|
||||
Ok((dur, _, _)) => {
|
||||
let dur = dur.seconds_to_f64();
|
||||
eprintln!("[AUDIO] Got duration from backend: {:.4}s", dur);
|
||||
self.audio_duration_cache.insert(pool_index, dur);
|
||||
dur
|
||||
|
|
@ -6688,7 +6696,7 @@ impl eframe::App for EditorApp {
|
|||
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() {
|
||||
|
|
@ -6850,7 +6858,7 @@ impl eframe::App for EditorApp {
|
|||
.map(|(id, _)| 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) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1612,8 +1612,12 @@ impl InfopanelPane {
|
|||
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)
|
||||
.unwrap_or_else(|| daw_backend::Seconds(ci.trim_end.unwrap_or(1.0) - ci.trim_start));
|
||||
let clip_dur = document.clip_trim_duration(&ci.clip_id)
|
||||
.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_secs = (document.tempo_map().beats_to_seconds(ci.effective_start() + total_dur)
|
||||
- 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));
|
||||
});
|
||||
|
||||
if ci.trim_start > 0.0 {
|
||||
if ci.trim_start > daw_backend::ContentTime::ZERO {
|
||||
ui.horizontal(|ui| {
|
||||
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));
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -466,8 +466,10 @@ impl PianoRollPane {
|
|||
for instance in &audio_layer.clip_instances {
|
||||
if let Some(clip) = document.audio_clips.get(&instance.clip_id) {
|
||||
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());
|
||||
clip_data.push((midi_clip_id, instance.timeline_start.beats_to_f64(), instance.trim_start, duration.beats_to_f64(), instance.id));
|
||||
let duration = instance.effective_duration(clip.content_duration(), document.tempo_map());
|
||||
// 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
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
use eframe::egui;
|
||||
use daw_backend::Seconds;
|
||||
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::layer::{AnyLayer, AudioLayer};
|
||||
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
|
||||
let tempo_map = self.ctx.document.tempo_map();
|
||||
let effect_end_beats = effect_instance.timeline_start
|
||||
+ effect_instance.effective_duration(Seconds(lightningbeam_core::effect::EFFECT_DURATION), tempo_map);
|
||||
+ effect_instance.effective_duration(
|
||||
ClipDuration::Seconds(Seconds(lightningbeam_core::effect::EFFECT_DURATION)),
|
||||
tempo_map,
|
||||
);
|
||||
let effect_inst = lightningbeam_core::effect::EffectInstance::new(
|
||||
effect_def,
|
||||
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
|
||||
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) {
|
||||
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) {
|
||||
|
|
@ -2299,7 +2302,9 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
|
|||
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) {
|
||||
// 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 start_secs = tempo_map.beats_to_seconds(clip_instance.timeline_start).seconds_to_f64();
|
||||
let instance_end = tempo_map.beats_to_seconds(
|
||||
|
|
@ -2310,7 +2315,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
|
|||
}
|
||||
|
||||
// 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
|
||||
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
|
||||
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 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();
|
||||
|
|
@ -10142,7 +10149,7 @@ impl StagePane {
|
|||
if let Some(clip_instance) = vector_layer.clip_instances.iter().find(|ci| ci.id == clip_id) {
|
||||
// Calculate clip-local time
|
||||
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
|
||||
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) {
|
||||
// Calculate clip-local time
|
||||
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
|
||||
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();
|
||||
if let Some(AnyLayer::Video(video_layer)) = document.get_layer(layer_id) {
|
||||
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 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();
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@
|
|||
/// - Basic layer visualization
|
||||
|
||||
use eframe::egui;
|
||||
use daw_backend::{Beats, Seconds};
|
||||
use lightningbeam_core::clip::ClipInstance;
|
||||
use daw_backend::{Beats, ContentTime, Seconds};
|
||||
use lightningbeam_core::clip::{ClipDuration, ClipInstance};
|
||||
use lightningbeam_core::layer::{AnyLayer, AudioLayerType, GroupLayer, LayerTrait};
|
||||
use super::{DragClipType, NodePath, PaneRenderer, SharedPaneState};
|
||||
|
||||
|
|
@ -89,7 +89,7 @@ fn compute_clip_stacking(
|
|||
let tempo_map = document.tempo_map();
|
||||
// Stacking only needs relative overlap, so compare in the beats domain.
|
||||
let ranges: Vec<(f64, f64)> = clip_instances.iter().map(|ci| {
|
||||
let 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 end = start + ci.total_duration(clip_dur, tempo_map);
|
||||
(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.
|
||||
/// 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.
|
||||
/// 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(
|
||||
document: &lightningbeam_core::document::Document,
|
||||
layer: &AnyLayer,
|
||||
clip_instance: &ClipInstance,
|
||||
) -> Option<Seconds> {
|
||||
) -> Option<ClipDuration> {
|
||||
match layer {
|
||||
AnyLayer::Vector(vl) => {
|
||||
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 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);
|
||||
Some(Seconds((end - start_secs).max(0.0)))
|
||||
Some(ClipDuration::Seconds(Seconds((end - start_secs).max(0.0))))
|
||||
} else {
|
||||
// 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,
|
||||
// not seconds) are converted correctly rather than read as raw seconds.
|
||||
AnyLayer::Audio(_) => document.get_clip_duration(&clip_instance.clip_id),
|
||||
AnyLayer::Video(_) => document.get_video_clip(&clip_instance.clip_id).map(|c| Seconds(c.duration)),
|
||||
AnyLayer::Effect(_) => Some(Seconds(lightningbeam_core::effect::EFFECT_DURATION)),
|
||||
// An audio layer can hold a sampled clip (seconds content) or a MIDI clip (beats content),
|
||||
// so ask the clip which it is rather than flattening both to seconds.
|
||||
AnyLayer::Audio(_) => document.clip_trim_duration(&clip_instance.clip_id),
|
||||
AnyLayer::Video(_) => document
|
||||
.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::Raster(_) => None,
|
||||
AnyLayer::Text(_) => None,
|
||||
|
|
@ -859,7 +866,9 @@ impl TimelinePane {
|
|||
.unwrap_or_default()
|
||||
.iter()
|
||||
.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,
|
||||
interpolation: match k.interpolation.as_str() {
|
||||
"bezier" => crate::curve_editor::CurveInterpolation::Bezier,
|
||||
|
|
@ -1441,8 +1450,10 @@ impl TimelinePane {
|
|||
|
||||
let tempo_map = document.tempo_map();
|
||||
for (_child_layer_id, ci) in &child_clips {
|
||||
let clip_dur = document.get_clip_duration(&ci.clip_id).unwrap_or_else(|| {
|
||||
Seconds(ci.trim_end.unwrap_or(1.0) - ci.trim_start)
|
||||
let clip_dur = document.clip_trim_duration(&ci.clip_id).unwrap_or_else(|| {
|
||||
ClipDuration::Seconds(Seconds(
|
||||
(ci.trim_end.unwrap_or(ContentTime(1.0)) - ci.trim_start).raw(),
|
||||
))
|
||||
});
|
||||
let start = ci.effective_start();
|
||||
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.
|
||||
///
|
||||
/// `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 {
|
||||
(tempo_map.beats_to_seconds(ci.timeline_start + ci.total_duration(clip_dur_secs, tempo_map))
|
||||
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_content, tempo_map))
|
||||
- tempo_map.beats_to_seconds(ci.effective_start())).seconds_to_f64()
|
||||
}
|
||||
|
||||
/// Returns the clip content start (trim_start) and duration in display seconds.
|
||||
fn content_display_range(&self, ci: &lightningbeam_core::clip::ClipInstance, clip_dur_secs: Seconds, _bpm: f64) -> (f64, f64) {
|
||||
let trim_end = ci.trim_end.unwrap_or(clip_dur_secs.seconds_to_f64());
|
||||
(ci.trim_start, (trim_end - ci.trim_start).max(0.0))
|
||||
/// The clip's content start (trim_start) and window length, as raw magnitudes in the clip's own
|
||||
/// content domain — seconds for audio/video/vector, beats for MIDI. The drag/preview math below
|
||||
/// works in that domain throughout, converting to the timeline only at the edges.
|
||||
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)
|
||||
|
|
@ -3255,8 +3269,10 @@ impl TimelinePane {
|
|||
let is_move_drag = self.clip_drag_state == Some(ClipDragType::Move);
|
||||
let mut ranges: Vec<(Beats, Beats)> = Vec::new();
|
||||
for (_child_layer_id, ci) in &child_clips {
|
||||
let clip_dur = document.get_clip_duration(&ci.clip_id).unwrap_or_else(|| {
|
||||
Seconds(ci.trim_end.unwrap_or(1.0) - ci.trim_start)
|
||||
let clip_dur = document.clip_trim_duration(&ci.clip_id).unwrap_or_else(|| {
|
||||
ClipDuration::Seconds(Seconds(
|
||||
(ci.trim_end.unwrap_or(ContentTime(1.0)) - ci.trim_start).raw(),
|
||||
))
|
||||
});
|
||||
let mut start = ci.effective_start();
|
||||
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 AnyLayer::Video(vl) = video_child {
|
||||
for ci in &vl.clip_instances {
|
||||
let clip_dur = document.get_clip_duration(&ci.clip_id)
|
||||
.unwrap_or_else(|| Seconds(ci.trim_end.unwrap_or(1.0) - ci.trim_start));
|
||||
let clip_dur = document.clip_trim_duration(&ci.clip_id)
|
||||
.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();
|
||||
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);
|
||||
|
|
@ -3357,7 +3375,8 @@ impl TimelinePane {
|
|||
);
|
||||
// 4th elem = clip's TRUE (unclamped) origin x, for correct
|
||||
// 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;
|
||||
if thumb_display_height > 8.0 {
|
||||
|
|
@ -3370,7 +3389,7 @@ impl TimelinePane {
|
|||
&video_mgr,
|
||||
&mut self.video_thumbnail_textures,
|
||||
ci.clip_id,
|
||||
ci.trim_start,
|
||||
ci.trim_start.raw(),
|
||||
rect.min.x + sx,
|
||||
ex - sx,
|
||||
ci_rect,
|
||||
|
|
@ -3419,7 +3438,7 @@ impl TimelinePane {
|
|||
};
|
||||
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();
|
||||
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);
|
||||
|
|
@ -3470,7 +3489,8 @@ impl TimelinePane {
|
|||
audio_duration: audio_file_duration as f32,
|
||||
sample_rate: eff_sr,
|
||||
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,
|
||||
total_frames: total_frames as f32,
|
||||
segment_start_frame: 0.0,
|
||||
|
|
@ -3553,7 +3573,7 @@ impl TimelinePane {
|
|||
let group: Vec<(uuid::Uuid, Beats, Beats)> = clip_instances.iter()
|
||||
.filter(|ci| selection.contains_clip_instance(&ci.id))
|
||||
.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())))
|
||||
})
|
||||
.collect();
|
||||
|
|
@ -3577,8 +3597,11 @@ impl TimelinePane {
|
|||
let shift_beats = |anchor: Beats, secs: f64|
|
||||
tmap.seconds_to_beats(tmap.beats_to_seconds(anchor) + Seconds(secs));
|
||||
|
||||
let clip_dur = effective_clip_duration(document, layer, ci).unwrap_or(Seconds::ZERO);
|
||||
let clip_dur_secs = clip_dur.seconds_to_f64();
|
||||
let clip_dur = effective_clip_duration(document, layer, ci).unwrap_or(ClipDuration::Seconds(Seconds::ZERO));
|
||||
// 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 duration = ci.total_duration(clip_dur, tmap);
|
||||
|
||||
|
|
@ -3600,25 +3623,25 @@ impl TimelinePane {
|
|||
}
|
||||
}
|
||||
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 trim_offset_secs = new_trim - ci.trim_start;
|
||||
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;
|
||||
start = shift_beats(ci.timeline_start, trim_offset_secs).max(Beats::ZERO);
|
||||
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 {
|
||||
(clip_dur_secs - new_trim).max(0.0)
|
||||
};
|
||||
duration = secs_to_beats_at(start, dur_secs);
|
||||
}
|
||||
ClipDragType::TrimRight => {
|
||||
let old_trim_end = ci.trim_end.unwrap_or(clip_dur_secs);
|
||||
let new_trim_end = self.snap_to_grid(old_trim_end + self.drag_offset, 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 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 dur_secs = (new_trim_end - ci_trim_start).max(0.0);
|
||||
duration = secs_to_beats_at(start, dur_secs);
|
||||
}
|
||||
ClipDragType::LoopExtendRight => {
|
||||
let trim_end = ci.trim_end.unwrap_or(clip_dur_secs);
|
||||
let content_window_secs = (trim_end - ci.trim_start).max(0.0);
|
||||
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_to_beats_at(ci.timeline_start, content_window_secs);
|
||||
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;
|
||||
|
|
@ -3629,8 +3652,8 @@ impl TimelinePane {
|
|||
duration = loop_before + new_right;
|
||||
}
|
||||
ClipDragType::LoopExtendLeft => {
|
||||
let trim_end = ci.trim_end.unwrap_or(clip_dur_secs);
|
||||
let content_window_secs = (trim_end - ci.trim_start).max(0.001);
|
||||
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_to_beats_at(ci.timeline_start, content_window_secs);
|
||||
let current_loop_before = ci.loop_before.unwrap_or(Beats::ZERO);
|
||||
// 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.
|
||||
// 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());
|
||||
// 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_clip_duration = base_clip_duration;
|
||||
|
||||
|
|
@ -3706,11 +3732,11 @@ impl TimelinePane {
|
|||
}
|
||||
ClipDragType::TrimLeft => {
|
||||
// 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)
|
||||
.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.
|
||||
let max_extend_secs = document.find_max_trim_extend_left(
|
||||
&layer.id(),
|
||||
|
|
@ -3718,25 +3744,25 @@ impl TimelinePane {
|
|||
clip_instance.effective_start(),
|
||||
).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);
|
||||
clip_instance.trim_start - actual_extend
|
||||
ci_trim_start - actual_extend
|
||||
} else {
|
||||
// Shrinking - no snap needed
|
||||
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.
|
||||
instance_start = (document.tempo_map().beats_to_seconds(clip_instance.timeline_start).seconds_to_f64() + actual_offset)
|
||||
.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
|
||||
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
|
||||
|
|
@ -3745,14 +3771,14 @@ impl TimelinePane {
|
|||
}
|
||||
ClipDragType::TrimRight => {
|
||||
// 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)
|
||||
.max(clip_instance.trim_start)
|
||||
.min(clip_duration.seconds_to_f64());
|
||||
.max(ci_trim_start)
|
||||
.min(clip_duration.native());
|
||||
|
||||
let new_trim_end = if desired_trim_end > old_trim_end {
|
||||
// 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 current_duration = tmap.seconds_to_beats(
|
||||
tmap.beats_to_seconds(clip_instance.timeline_start) + Seconds(current_duration_secs)
|
||||
|
|
@ -3772,7 +3798,7 @@ impl TimelinePane {
|
|||
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
|
||||
// (the waveform system uses clip_duration to determine visible range)
|
||||
|
|
@ -3780,8 +3806,8 @@ impl TimelinePane {
|
|||
}
|
||||
ClipDragType::LoopExtendRight => {
|
||||
// Loop extend right: extend clip beyond content window
|
||||
let trim_end = clip_instance.trim_end.unwrap_or(clip_duration.seconds_to_f64());
|
||||
let content_window_secs = (trim_end - clip_instance.trim_start).max(0.0);
|
||||
let trim_end = clip_instance.trim_end.map_or(clip_duration.native(), |t| t.raw());
|
||||
let content_window_secs = (trim_end - ci_trim_start).max(0.0);
|
||||
let tmap = document.tempo_map();
|
||||
let ts = clip_instance.timeline_start;
|
||||
// content window and right-duration are beats-domain timeline spans.
|
||||
|
|
@ -3816,8 +3842,8 @@ impl TimelinePane {
|
|||
ClipDragType::LoopExtendLeft => {
|
||||
// Loop extend left: extend loop_before (pre-loop region)
|
||||
// Snap to multiples of content_window so iterations align with backend
|
||||
let trim_end = clip_instance.trim_end.unwrap_or(clip_duration.seconds_to_f64());
|
||||
let content_window_secs = (trim_end - clip_instance.trim_start).max(0.001);
|
||||
let trim_end = clip_instance.trim_end.map_or(clip_duration.native(), |t| t.raw());
|
||||
let content_window_secs = (trim_end - ci_trim_start).max(0.001);
|
||||
let tmap = document.tempo_map();
|
||||
let ts = clip_instance.timeline_start;
|
||||
// content window is a beats-domain span; guard against zero for division.
|
||||
|
|
@ -4057,7 +4083,7 @@ impl TimelinePane {
|
|||
|
||||
// Calculate content window for loop detection
|
||||
// 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 is_looping = instance_duration > content_window + 0.001;
|
||||
|
||||
|
|
@ -4254,7 +4280,7 @@ impl TimelinePane {
|
|||
&video_mgr,
|
||||
&mut self.video_thumbnail_textures,
|
||||
clip_instance.clip_id,
|
||||
clip_instance.trim_start,
|
||||
clip_instance.trim_start.raw(),
|
||||
rect.min.x + start_x,
|
||||
end_x - start_x,
|
||||
clip_rect,
|
||||
|
|
@ -4269,7 +4295,7 @@ impl TimelinePane {
|
|||
// clip's TRUE (unclamped) origin x so the hover content time is
|
||||
// correct even when the clip is scrolled partly off the left.
|
||||
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)
|
||||
|
|
@ -5009,18 +5035,23 @@ impl TimelinePane {
|
|||
for clip_instance in clip_instances {
|
||||
if selection.contains_clip_instance(&clip_instance.id) {
|
||||
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 {
|
||||
match drag_type {
|
||||
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 =
|
||||
clip_instance.timeline_start;
|
||||
|
||||
// New trim_start is snapped then clamped to valid range
|
||||
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,
|
||||
).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).
|
||||
let new_trim_start = if desired_trim_start < old_trim_start {
|
||||
|
|
@ -5050,11 +5081,11 @@ impl TimelinePane {
|
|||
clip_instance.id,
|
||||
lightningbeam_core::actions::TrimType::TrimLeft,
|
||||
lightningbeam_core::actions::TrimData::left(
|
||||
old_trim_start,
|
||||
ContentTime(old_trim_start),
|
||||
old_timeline_start,
|
||||
),
|
||||
lightningbeam_core::actions::TrimData::left(
|
||||
new_trim_start,
|
||||
ContentTime(new_trim_start),
|
||||
new_timeline_start,
|
||||
),
|
||||
));
|
||||
|
|
@ -5065,10 +5096,10 @@ impl TimelinePane {
|
|||
// Calculate new trim_end based on current duration
|
||||
let current_duration =
|
||||
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(
|
||||
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).
|
||||
let new_trim_end_val = if desired_trim_end > old_trim_end_val {
|
||||
|
|
@ -5085,13 +5116,15 @@ impl TimelinePane {
|
|||
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
|
||||
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
|
||||
} 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
|
||||
|
|
@ -5143,8 +5176,9 @@ impl TimelinePane {
|
|||
if let Some(clip_duration) = clip_duration {
|
||||
let tmap = document.tempo_map();
|
||||
let ts = clip_instance.timeline_start;
|
||||
let trim_end = clip_instance.trim_end.unwrap_or(clip_duration);
|
||||
let content_window_secs = (trim_end - clip_instance.trim_start).max(0.0);
|
||||
let ci_trim_start = clip_instance.trim_start.raw();
|
||||
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 current_right = clip_instance.timeline_duration.unwrap_or(content_window);
|
||||
// Snap the right edge in the seconds/pixel domain.
|
||||
|
|
@ -5217,8 +5251,9 @@ impl TimelinePane {
|
|||
if let Some(clip_duration) = clip_duration {
|
||||
let tmap = document.tempo_map();
|
||||
let ts = clip_instance.timeline_start;
|
||||
let trim_end = clip_instance.trim_end.unwrap_or(clip_duration);
|
||||
let content_window_secs = (trim_end - clip_instance.trim_start).max(0.001);
|
||||
let ci_trim_start = clip_instance.trim_start.raw();
|
||||
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 cw = content_window.beats_to_f64().max(1e-9);
|
||||
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);
|
||||
for inst in instances {
|
||||
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 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();
|
||||
|
|
@ -6487,7 +6522,7 @@ impl PaneRenderer for TimelinePane {
|
|||
enabled = instances.iter()
|
||||
.filter(|ci| shared.selection.contains_clip_instance(&ci.id))
|
||||
.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());
|
||||
// Room to duplicate = seconds gap to the right ≥ this clip's own length.
|
||||
let max_extend_secs = document.find_max_trim_extend_right(
|
||||
|
|
@ -6532,7 +6567,7 @@ impl PaneRenderer for TimelinePane {
|
|||
|
||||
enabled = instances.iter().all(|ci| {
|
||||
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());
|
||||
document
|
||||
.find_nearest_valid_position(
|
||||
|
|
|
|||
Loading…
Reference in New Issue