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
|
if self.frames_since_last_event >= self.event_interval_frames / self.channels as usize
|
||||||
{
|
{
|
||||||
// Clamp to 0 during count-in pre-roll (negative playhead = before project start)
|
// Clamp to 0 during count-in pre-roll (negative playhead = before project start)
|
||||||
let position_seconds = self.playhead.max(0) as f64 / self.sample_rate as f64;
|
let position_seconds = Seconds(self.playhead.max(0) as f64 / self.sample_rate as f64);
|
||||||
let _ = self
|
let _ = self
|
||||||
.event_tx
|
.event_tx
|
||||||
.push(AudioEvent::PlaybackPosition(position_seconds));
|
.push(AudioEvent::PlaybackPosition(position_seconds));
|
||||||
|
|
@ -922,7 +922,7 @@ impl Engine {
|
||||||
self.project.stop_all_notes();
|
self.project.stop_all_notes();
|
||||||
}
|
}
|
||||||
Command::Seek(seconds) => {
|
Command::Seek(seconds) => {
|
||||||
self.playhead = (seconds * self.sample_rate as f64) as i64;
|
self.playhead = (seconds.seconds_to_f64() * self.sample_rate as f64) as i64;
|
||||||
// Clamp to 0 for atomic/disk-reader; negative = count-in pre-roll (no disk reads needed)
|
// Clamp to 0 for atomic/disk-reader; negative = count-in pre-roll (no disk reads needed)
|
||||||
let clamped = self.playhead.max(0) as u64;
|
let clamped = self.playhead.max(0) as u64;
|
||||||
self.playhead_atomic.store(clamped, Ordering::Relaxed);
|
self.playhead_atomic.store(clamped, Ordering::Relaxed);
|
||||||
|
|
@ -1014,12 +1014,12 @@ impl Engine {
|
||||||
match self.project.get_track_mut(track_id) {
|
match self.project.get_track_mut(track_id) {
|
||||||
Some(crate::audio::track::TrackNode::Audio(track)) => {
|
Some(crate::audio::track::TrackNode::Audio(track)) => {
|
||||||
if let Some(clip) = track.clips.iter_mut().find(|c| c.id == clip_id) {
|
if let Some(clip) = track.clips.iter_mut().find(|c| c.id == clip_id) {
|
||||||
clip.external_duration = Beats(new_external_duration);
|
clip.external_duration = new_external_duration;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(crate::audio::track::TrackNode::Midi(track)) => {
|
Some(crate::audio::track::TrackNode::Midi(track)) => {
|
||||||
if let Some(instance) = track.clip_instances.iter_mut().find(|c| c.clip_id == clip_id) {
|
if let Some(instance) = track.clip_instances.iter_mut().find(|c| c.clip_id == clip_id) {
|
||||||
instance.external_duration = Beats(new_external_duration);
|
instance.external_duration = new_external_duration;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
|
|
@ -1046,7 +1046,7 @@ impl Engine {
|
||||||
}
|
}
|
||||||
Command::SetOffset(track_id, offset) => {
|
Command::SetOffset(track_id, offset) => {
|
||||||
if let Some(crate::audio::track::TrackNode::Group(metatrack)) = self.project.get_track_mut(track_id) {
|
if let Some(crate::audio::track::TrackNode::Group(metatrack)) = self.project.get_track_mut(track_id) {
|
||||||
metatrack.offset = Seconds(offset);
|
metatrack.offset = offset;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Command::SetPitchShift(track_id, semitones) => {
|
Command::SetPitchShift(track_id, semitones) => {
|
||||||
|
|
@ -1056,12 +1056,12 @@ impl Engine {
|
||||||
}
|
}
|
||||||
Command::SetTrimStart(track_id, trim_start) => {
|
Command::SetTrimStart(track_id, trim_start) => {
|
||||||
if let Some(crate::audio::track::TrackNode::Group(metatrack)) = self.project.get_track_mut(track_id) {
|
if let Some(crate::audio::track::TrackNode::Group(metatrack)) = self.project.get_track_mut(track_id) {
|
||||||
metatrack.trim_start = Seconds(trim_start.max(0.0));
|
metatrack.trim_start = Seconds(trim_start.seconds_to_f64().max(0.0));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Command::SetTrimEnd(track_id, trim_end) => {
|
Command::SetTrimEnd(track_id, trim_end) => {
|
||||||
if let Some(crate::audio::track::TrackNode::Group(metatrack)) = self.project.get_track_mut(track_id) {
|
if let Some(crate::audio::track::TrackNode::Group(metatrack)) = self.project.get_track_mut(track_id) {
|
||||||
metatrack.trim_end = trim_end.map(|t| Seconds(t.max(0.0)));
|
metatrack.trim_end = trim_end.map(|t| Seconds(t.seconds_to_f64().max(0.0)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Command::CreateAudioTrack(name, parent_id) => {
|
Command::CreateAudioTrack(name, parent_id) => {
|
||||||
|
|
@ -1115,9 +1115,13 @@ impl Engine {
|
||||||
// Send chunks via MPSC channel (will be forwarded by audio thread)
|
// Send chunks via MPSC channel (will be forwarded by audio thread)
|
||||||
if !chunks.is_empty() {
|
if !chunks.is_empty() {
|
||||||
println!("📤 [BACKGROUND] Generated {} chunks, sending to audio thread (pool {})", chunks.len(), pool_index);
|
println!("📤 [BACKGROUND] Generated {} chunks, sending to audio thread (pool {})", chunks.len(), pool_index);
|
||||||
let event_chunks: Vec<(u32, (f64, f64), Vec<crate::io::WaveformPeak>)> = chunks
|
let event_chunks: Vec<(u32, (Seconds, Seconds), Vec<crate::io::WaveformPeak>)> = chunks
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|chunk| (chunk.chunk_index, chunk.time_range, chunk.peaks))
|
.map(|chunk| {
|
||||||
|
// A chunk's time_range is a wall-clock span into the audio file.
|
||||||
|
let (start, end) = chunk.time_range;
|
||||||
|
(chunk.chunk_index, (Seconds(start), Seconds(end)), chunk.peaks)
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
match chunk_tx.send(AudioEvent::WaveformChunksReady {
|
match chunk_tx.send(AudioEvent::WaveformChunksReady {
|
||||||
|
|
@ -1181,12 +1185,12 @@ impl Engine {
|
||||||
let clip_id = self.next_midi_clip_id_atomic.fetch_add(1, Ordering::Relaxed);
|
let clip_id = self.next_midi_clip_id_atomic.fetch_add(1, Ordering::Relaxed);
|
||||||
|
|
||||||
// Create clip content in the pool
|
// Create clip content in the pool
|
||||||
let clip = MidiClip::empty(clip_id, Beats(duration), format!("MIDI Clip {}", clip_id));
|
let clip = MidiClip::empty(clip_id, duration, format!("MIDI Clip {}", clip_id));
|
||||||
self.project.midi_clip_pool.add_existing_clip(clip);
|
self.project.midi_clip_pool.add_existing_clip(clip);
|
||||||
|
|
||||||
// Create an instance for this clip on the track
|
// Create an instance for this clip on the track
|
||||||
let instance_id = self.project.next_midi_clip_instance_id();
|
let instance_id = self.project.next_midi_clip_instance_id();
|
||||||
let instance = MidiClipInstance::from_full_clip(instance_id, clip_id, Beats(duration), Beats(start_time));
|
let instance = MidiClipInstance::from_full_clip(instance_id, clip_id, duration, start_time);
|
||||||
|
|
||||||
if let Some(crate::audio::track::TrackNode::Midi(track)) = self.project.get_track_mut(track_id) {
|
if let Some(crate::audio::track::TrackNode::Midi(track)) = self.project.get_track_mut(track_id) {
|
||||||
track.clip_instances.push(instance);
|
track.clip_instances.push(instance);
|
||||||
|
|
@ -1201,11 +1205,11 @@ impl Engine {
|
||||||
// Note: clip_id here refers to the clip in the pool, not the instance
|
// Note: clip_id here refers to the clip in the pool, not the instance
|
||||||
if let Some(clip) = self.project.midi_clip_pool.get_clip_mut(clip_id) {
|
if let Some(clip) = self.project.midi_clip_pool.get_clip_mut(clip_id) {
|
||||||
// Timestamp is in beats (canonical)
|
// Timestamp is in beats (canonical)
|
||||||
let note_on = MidiEvent::note_on(Beats(time_offset), 0, note, velocity);
|
let note_on = MidiEvent::note_on(time_offset, 0, note, velocity);
|
||||||
clip.add_event(note_on);
|
clip.add_event(note_on);
|
||||||
|
|
||||||
// Add note off event
|
// Add note off event
|
||||||
let note_off_time = Beats(time_offset + duration);
|
let note_off_time = time_offset + duration;
|
||||||
let note_off = MidiEvent::note_off(note_off_time, 0, note, 64);
|
let note_off = MidiEvent::note_off(note_off_time, 0, note, 64);
|
||||||
clip.add_event(note_off);
|
clip.add_event(note_off);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -1214,9 +1218,9 @@ impl Engine {
|
||||||
if let Some(instance) = track.clip_instances.iter().find(|c| c.clip_id == clip_id) {
|
if let Some(instance) = track.clip_instances.iter().find(|c| c.clip_id == clip_id) {
|
||||||
let actual_clip_id = instance.clip_id;
|
let actual_clip_id = instance.clip_id;
|
||||||
if let Some(clip) = self.project.midi_clip_pool.get_clip_mut(actual_clip_id) {
|
if let Some(clip) = self.project.midi_clip_pool.get_clip_mut(actual_clip_id) {
|
||||||
let note_on = MidiEvent::note_on(Beats(time_offset), 0, note, velocity);
|
let note_on = MidiEvent::note_on(time_offset, 0, note, velocity);
|
||||||
clip.add_event(note_on);
|
clip.add_event(note_on);
|
||||||
let note_off_time = Beats(time_offset + duration);
|
let note_off_time = time_offset + duration;
|
||||||
let note_off = MidiEvent::note_off(note_off_time, 0, note, 64);
|
let note_off = MidiEvent::note_off(note_off_time, 0, note, 64);
|
||||||
clip.add_event(note_off);
|
clip.add_event(note_off);
|
||||||
}
|
}
|
||||||
|
|
@ -1226,7 +1230,7 @@ impl Engine {
|
||||||
}
|
}
|
||||||
Command::AddLoadedMidiClip(track_id, clip, start_time) => {
|
Command::AddLoadedMidiClip(track_id, clip, start_time) => {
|
||||||
// Add a pre-loaded MIDI clip to the track with the given start time
|
// Add a pre-loaded MIDI clip to the track with the given start time
|
||||||
if let Ok(_instance_id) = self.project.add_midi_clip_at(track_id, clip, crate::time::Beats(start_time)) {
|
if let Ok(_instance_id) = self.project.add_midi_clip_at(track_id, clip, start_time) {
|
||||||
// instance positions are already in beats; nothing to sync
|
// instance positions are already in beats; nothing to sync
|
||||||
}
|
}
|
||||||
self.refresh_clip_snapshot();
|
self.refresh_clip_snapshot();
|
||||||
|
|
@ -1240,11 +1244,11 @@ impl Engine {
|
||||||
// Add new events from the notes array
|
// Add new events from the notes array
|
||||||
// Timestamps are in beats (canonical)
|
// Timestamps are in beats (canonical)
|
||||||
for (start_time, note, velocity, duration) in notes {
|
for (start_time, note, velocity, duration) in notes {
|
||||||
let note_on = MidiEvent::note_on(Beats(start_time), 0, note, velocity);
|
let note_on = MidiEvent::note_on(start_time, 0, note, velocity);
|
||||||
clip.events.push(note_on);
|
clip.events.push(note_on);
|
||||||
|
|
||||||
// Add note off event
|
// Add note off event
|
||||||
let note_off_time = Beats(start_time + duration);
|
let note_off_time = start_time + duration;
|
||||||
let note_off = MidiEvent::note_off(note_off_time, 0, note, 64);
|
let note_off = MidiEvent::note_off(note_off_time, 0, note, 64);
|
||||||
clip.events.push(note_off);
|
clip.events.push(note_off);
|
||||||
}
|
}
|
||||||
|
|
@ -1306,7 +1310,7 @@ impl Engine {
|
||||||
}
|
}
|
||||||
Command::AddAutomationPoint(track_id, lane_id, time, value, curve) => {
|
Command::AddAutomationPoint(track_id, lane_id, time, value, curve) => {
|
||||||
// Add an automation point to the specified lane
|
// Add an automation point to the specified lane
|
||||||
let point = crate::audio::AutomationPoint::new(Beats(time), value, curve);
|
let point = crate::audio::AutomationPoint::new(time, value, curve);
|
||||||
|
|
||||||
match self.project.get_track_mut(track_id) {
|
match self.project.get_track_mut(track_id) {
|
||||||
Some(crate::audio::track::TrackNode::Audio(track)) => {
|
Some(crate::audio::track::TrackNode::Audio(track)) => {
|
||||||
|
|
@ -1332,17 +1336,17 @@ impl Engine {
|
||||||
match self.project.get_track_mut(track_id) {
|
match self.project.get_track_mut(track_id) {
|
||||||
Some(crate::audio::track::TrackNode::Audio(track)) => {
|
Some(crate::audio::track::TrackNode::Audio(track)) => {
|
||||||
if let Some(lane) = track.get_automation_lane_mut(lane_id) {
|
if let Some(lane) = track.get_automation_lane_mut(lane_id) {
|
||||||
lane.remove_point_at_time(Beats(time), Beats(tolerance));
|
lane.remove_point_at_time(time, tolerance);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(crate::audio::track::TrackNode::Midi(track)) => {
|
Some(crate::audio::track::TrackNode::Midi(track)) => {
|
||||||
if let Some(lane) = track.get_automation_lane_mut(lane_id) {
|
if let Some(lane) = track.get_automation_lane_mut(lane_id) {
|
||||||
lane.remove_point_at_time(Beats(time), Beats(tolerance));
|
lane.remove_point_at_time(time, tolerance);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(crate::audio::track::TrackNode::Group(group)) => {
|
Some(crate::audio::track::TrackNode::Group(group)) => {
|
||||||
if let Some(lane) = group.get_automation_lane_mut(lane_id) {
|
if let Some(lane) = group.get_automation_lane_mut(lane_id) {
|
||||||
lane.remove_point_at_time(Beats(time), Beats(tolerance));
|
lane.remove_point_at_time(time, tolerance);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None => {}
|
None => {}
|
||||||
|
|
@ -2431,7 +2435,7 @@ impl Engine {
|
||||||
// Downcast to AutomationInputNode using as_any_mut
|
// Downcast to AutomationInputNode using as_any_mut
|
||||||
if let Some(auto_node) = graph_node.node.as_any_mut().downcast_mut::<AutomationInputNode>() {
|
if let Some(auto_node) = graph_node.node.as_any_mut().downcast_mut::<AutomationInputNode>() {
|
||||||
let keyframe = AutomationKeyframe {
|
let keyframe = AutomationKeyframe {
|
||||||
time: Beats(time),
|
time,
|
||||||
value,
|
value,
|
||||||
interpolation,
|
interpolation,
|
||||||
ease_out,
|
ease_out,
|
||||||
|
|
@ -2459,7 +2463,7 @@ impl Engine {
|
||||||
|
|
||||||
if let Some(graph_node) = graph.get_graph_node_mut(node_idx) {
|
if let Some(graph_node) = graph.get_graph_node_mut(node_idx) {
|
||||||
if let Some(auto_node) = graph_node.node.as_any_mut().downcast_mut::<AutomationInputNode>() {
|
if let Some(auto_node) = graph_node.node.as_any_mut().downcast_mut::<AutomationInputNode>() {
|
||||||
auto_node.remove_keyframe_at_time(Beats(time), Beats(0.001)); // 1ms tolerance
|
auto_node.remove_keyframe_at_time(time, Beats(0.001)); // 1ms tolerance
|
||||||
} else {
|
} else {
|
||||||
eprintln!("Node {} is not an AutomationInputNode", node_id);
|
eprintln!("Node {} is not an AutomationInputNode", node_id);
|
||||||
}
|
}
|
||||||
|
|
@ -2528,9 +2532,12 @@ impl Engine {
|
||||||
|
|
||||||
// Send chunks via MPSC channel (will be forwarded by audio thread)
|
// Send chunks via MPSC channel (will be forwarded by audio thread)
|
||||||
if !chunks.is_empty() {
|
if !chunks.is_empty() {
|
||||||
let event_chunks: Vec<(u32, (f64, f64), Vec<crate::io::WaveformPeak>)> = chunks
|
let event_chunks: Vec<(u32, (Seconds, Seconds), Vec<crate::io::WaveformPeak>)> = chunks
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|chunk| (chunk.chunk_index, chunk.time_range, chunk.peaks))
|
.map(|chunk| {
|
||||||
|
let (start, end) = chunk.time_range;
|
||||||
|
(chunk.chunk_index, (Seconds(start), Seconds(end)), chunk.peaks)
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let _ = chunk_tx.send(AudioEvent::WaveformChunksReady {
|
let _ = chunk_tx.send(AudioEvent::WaveformChunksReady {
|
||||||
|
|
@ -2694,7 +2701,7 @@ impl Engine {
|
||||||
path: path_str,
|
path: path_str,
|
||||||
channels: metadata.channels,
|
channels: metadata.channels,
|
||||||
sample_rate: metadata.sample_rate,
|
sample_rate: metadata.sample_rate,
|
||||||
duration: metadata.duration,
|
duration: Seconds(metadata.duration),
|
||||||
format: metadata.format,
|
format: metadata.format,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -2802,7 +2809,7 @@ impl Engine {
|
||||||
if let Some(clip) = self.project.midi_clip_pool.get_clip(clip_id) {
|
if let Some(clip) = self.project.midi_clip_pool.get_clip(clip_id) {
|
||||||
use crate::command::MidiClipData;
|
use crate::command::MidiClipData;
|
||||||
QueryResponse::MidiClipData(Ok(MidiClipData {
|
QueryResponse::MidiClipData(Ok(MidiClipData {
|
||||||
duration: clip.duration.0,
|
duration: clip.duration,
|
||||||
events: clip.events.clone(),
|
events: clip.events.clone(),
|
||||||
}))
|
}))
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -2834,7 +2841,7 @@ impl Engine {
|
||||||
InterpolationType::Hold => "hold",
|
InterpolationType::Hold => "hold",
|
||||||
}.to_string();
|
}.to_string();
|
||||||
AutomationKeyframeData {
|
AutomationKeyframeData {
|
||||||
time: kf.time.0,
|
time: kf.time,
|
||||||
value: kf.value,
|
value: kf.value,
|
||||||
interpolation: interpolation_str,
|
interpolation: interpolation_str,
|
||||||
ease_out: kf.ease_out,
|
ease_out: kf.ease_out,
|
||||||
|
|
@ -3003,7 +3010,11 @@ impl Engine {
|
||||||
}
|
}
|
||||||
Query::GetPoolFileInfo(pool_index) => {
|
Query::GetPoolFileInfo(pool_index) => {
|
||||||
match self.audio_pool.get_file_info(pool_index) {
|
match self.audio_pool.get_file_info(pool_index) {
|
||||||
Some(info) => QueryResponse::PoolFileInfo(Ok(info)),
|
// The pool measures a file's length in wall-clock seconds; name it as such at
|
||||||
|
// the boundary rather than handing a bare f64 to the UI.
|
||||||
|
Some((duration, sample_rate, channels)) => {
|
||||||
|
QueryResponse::PoolFileInfo(Ok((Seconds(duration), sample_rate, channels)))
|
||||||
|
}
|
||||||
None => QueryResponse::PoolFileInfo(Err(format!("Pool index {} not found", pool_index))),
|
None => QueryResponse::PoolFileInfo(Err(format!("Pool index {} not found", pool_index))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3050,7 +3061,7 @@ impl Engine {
|
||||||
}
|
}
|
||||||
Query::AddMidiClipSync(track_id, clip, start_time) => {
|
Query::AddMidiClipSync(track_id, clip, start_time) => {
|
||||||
// Add MIDI clip to track and return the instance ID (positions already in beats)
|
// Add MIDI clip to track and return the instance ID (positions already in beats)
|
||||||
let result = match self.project.add_midi_clip_at(track_id, clip, crate::time::Beats(start_time)) {
|
let result = match self.project.add_midi_clip_at(track_id, clip, start_time) {
|
||||||
Ok(instance_id) => QueryResponse::MidiClipInstanceAdded(Ok(instance_id)),
|
Ok(instance_id) => QueryResponse::MidiClipInstanceAdded(Ok(instance_id)),
|
||||||
Err(e) => QueryResponse::MidiClipInstanceAdded(Err(e.to_string())),
|
Err(e) => QueryResponse::MidiClipInstanceAdded(Err(e.to_string())),
|
||||||
};
|
};
|
||||||
|
|
@ -3644,7 +3655,7 @@ impl EngineController {
|
||||||
|
|
||||||
/// Seek to a specific position in seconds
|
/// Seek to a specific position in seconds
|
||||||
pub fn seek(&mut self, seconds: Seconds) {
|
pub fn seek(&mut self, seconds: Seconds) {
|
||||||
let _ = self.command_tx.push(Command::Seek(seconds.seconds_to_f64()));
|
let _ = self.command_tx.push(Command::Seek(seconds));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set track volume (0.0 = silence, 1.0 = unity gain)
|
/// Set track volume (0.0 = silence, 1.0 = unity gain)
|
||||||
|
|
@ -3691,7 +3702,7 @@ impl EngineController {
|
||||||
|
|
||||||
/// Extend or shrink a clip's external duration (enables looping if > internal duration)
|
/// Extend or shrink a clip's external duration (enables looping if > internal duration)
|
||||||
pub fn extend_clip(&mut self, track_id: TrackId, clip_id: ClipId, new_external_duration: Beats) {
|
pub fn extend_clip(&mut self, track_id: TrackId, clip_id: ClipId, new_external_duration: Beats) {
|
||||||
let _ = self.command_tx.push(Command::ExtendClip(track_id, clip_id, new_external_duration.beats_to_f64()));
|
let _ = self.command_tx.push(Command::ExtendClip(track_id, clip_id, new_external_duration));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send a generic command to the audio thread
|
/// Send a generic command to the audio thread
|
||||||
|
|
@ -3705,9 +3716,9 @@ impl EngineController {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get current playhead position in seconds
|
/// Get current playhead position in seconds
|
||||||
pub fn get_playhead_seconds(&self) -> f64 {
|
pub fn get_playhead_seconds(&self) -> Seconds {
|
||||||
let frames = self.playhead.load(Ordering::Relaxed);
|
let frames = self.playhead.load(Ordering::Relaxed);
|
||||||
frames as f64 / self.sample_rate as f64
|
Seconds(frames as f64 / self.sample_rate as f64)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the shared clip snapshot. The UI can read this each frame to display
|
/// Get the shared clip snapshot. The UI can read this each frame to display
|
||||||
|
|
@ -3740,7 +3751,7 @@ impl EngineController {
|
||||||
/// Set metatrack time offset in seconds
|
/// Set metatrack time offset in seconds
|
||||||
/// Positive = shift content later, negative = shift earlier
|
/// Positive = shift content later, negative = shift earlier
|
||||||
pub fn set_offset(&mut self, track_id: TrackId, offset: Seconds) {
|
pub fn set_offset(&mut self, track_id: TrackId, offset: Seconds) {
|
||||||
let _ = self.command_tx.push(Command::SetOffset(track_id, offset.seconds_to_f64()));
|
let _ = self.command_tx.push(Command::SetOffset(track_id, offset));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set metatrack pitch shift in semitones (for future use)
|
/// Set metatrack pitch shift in semitones (for future use)
|
||||||
|
|
@ -3750,12 +3761,12 @@ impl EngineController {
|
||||||
|
|
||||||
/// Set metatrack trim start in seconds
|
/// Set metatrack trim start in seconds
|
||||||
pub fn set_trim_start(&mut self, track_id: TrackId, trim_start: Seconds) {
|
pub fn set_trim_start(&mut self, track_id: TrackId, trim_start: Seconds) {
|
||||||
let _ = self.command_tx.push(Command::SetTrimStart(track_id, trim_start.seconds_to_f64()));
|
let _ = self.command_tx.push(Command::SetTrimStart(track_id, trim_start));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set metatrack trim end in seconds (None = no end trim)
|
/// Set metatrack trim end in seconds (None = no end trim)
|
||||||
pub fn set_trim_end(&mut self, track_id: TrackId, trim_end: Option<Seconds>) {
|
pub fn set_trim_end(&mut self, track_id: TrackId, trim_end: Option<Seconds>) {
|
||||||
let _ = self.command_tx.push(Command::SetTrimEnd(track_id, trim_end.map(|s| s.seconds_to_f64())));
|
let _ = self.command_tx.push(Command::SetTrimEnd(track_id, trim_end));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a new audio track
|
/// Create a new audio track
|
||||||
|
|
@ -3909,23 +3920,22 @@ impl EngineController {
|
||||||
pub fn create_midi_clip(&mut self, track_id: TrackId, start_time: Beats, duration: Beats) -> MidiClipId {
|
pub fn create_midi_clip(&mut self, track_id: TrackId, start_time: Beats, duration: Beats) -> MidiClipId {
|
||||||
// Peek at the next clip ID that will be used
|
// Peek at the next clip ID that will be used
|
||||||
let clip_id = self.next_midi_clip_id.load(Ordering::Relaxed);
|
let clip_id = self.next_midi_clip_id.load(Ordering::Relaxed);
|
||||||
let _ = self.command_tx.push(Command::CreateMidiClip(track_id, start_time.beats_to_f64(), duration.beats_to_f64()));
|
let _ = self.command_tx.push(Command::CreateMidiClip(track_id, start_time, duration));
|
||||||
clip_id
|
clip_id
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add a MIDI note to a clip
|
/// Add a MIDI note to a clip
|
||||||
pub fn add_midi_note(&mut self, track_id: TrackId, clip_id: MidiClipId, time_offset: Beats, note: u8, velocity: u8, duration: Beats) {
|
pub fn add_midi_note(&mut self, track_id: TrackId, clip_id: MidiClipId, time_offset: Beats, note: u8, velocity: u8, duration: Beats) {
|
||||||
let _ = self.command_tx.push(Command::AddMidiNote(track_id, clip_id, time_offset.beats_to_f64(), note, velocity, duration.beats_to_f64()));
|
let _ = self.command_tx.push(Command::AddMidiNote(track_id, clip_id, time_offset, note, velocity, duration));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add a pre-loaded MIDI clip to a track at the given timeline position (beats)
|
/// Add a pre-loaded MIDI clip to a track at the given timeline position (beats)
|
||||||
pub fn add_loaded_midi_clip(&mut self, track_id: TrackId, clip: MidiClip, start_time: Beats) {
|
pub fn add_loaded_midi_clip(&mut self, track_id: TrackId, clip: MidiClip, start_time: Beats) {
|
||||||
let _ = self.command_tx.push(Command::AddLoadedMidiClip(track_id, clip, start_time.beats_to_f64()));
|
let _ = self.command_tx.push(Command::AddLoadedMidiClip(track_id, clip, start_time));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update all notes in a MIDI clip. Note tuples are (start [beats], note, velocity, duration [beats]).
|
/// Update all notes in a MIDI clip. Note tuples are (start [beats], note, velocity, duration [beats]).
|
||||||
pub fn update_midi_clip_notes(&mut self, track_id: TrackId, clip_id: MidiClipId, notes: Vec<(Beats, u8, u8, Beats)>) {
|
pub fn update_midi_clip_notes(&mut self, track_id: TrackId, clip_id: MidiClipId, notes: Vec<(Beats, u8, u8, Beats)>) {
|
||||||
let notes = notes.into_iter().map(|(t, n, v, d)| (t.beats_to_f64(), n, v, d.beats_to_f64())).collect();
|
|
||||||
let _ = self.command_tx.push(Command::UpdateMidiClipNotes(track_id, clip_id, notes));
|
let _ = self.command_tx.push(Command::UpdateMidiClipNotes(track_id, clip_id, notes));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3966,7 +3976,7 @@ impl EngineController {
|
||||||
curve: crate::audio::CurveType,
|
curve: crate::audio::CurveType,
|
||||||
) {
|
) {
|
||||||
let _ = self.command_tx.push(Command::AddAutomationPoint(
|
let _ = self.command_tx.push(Command::AddAutomationPoint(
|
||||||
track_id, lane_id, time.beats_to_f64(), value, curve,
|
track_id, lane_id, time, value, curve,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3979,7 +3989,7 @@ impl EngineController {
|
||||||
tolerance: Beats,
|
tolerance: Beats,
|
||||||
) {
|
) {
|
||||||
let _ = self.command_tx.push(Command::RemoveAutomationPoint(
|
let _ = self.command_tx.push(Command::RemoveAutomationPoint(
|
||||||
track_id, lane_id, time.beats_to_f64(), tolerance.beats_to_f64(),
|
track_id, lane_id, time, tolerance,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -4018,13 +4028,13 @@ impl EngineController {
|
||||||
time: Beats, value: f32, interpolation: String,
|
time: Beats, value: f32, interpolation: String,
|
||||||
ease_out: (f32, f32), ease_in: (f32, f32)) {
|
ease_out: (f32, f32), ease_in: (f32, f32)) {
|
||||||
let _ = self.command_tx.push(Command::AutomationAddKeyframe(
|
let _ = self.command_tx.push(Command::AutomationAddKeyframe(
|
||||||
track_id, node_id, time.beats_to_f64(), value, interpolation, ease_out, ease_in));
|
track_id, node_id, time, value, interpolation, ease_out, ease_in));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove a keyframe from an AutomationInput node
|
/// Remove a keyframe from an AutomationInput node
|
||||||
pub fn automation_remove_keyframe(&mut self, track_id: TrackId, node_id: u32, time: Beats) {
|
pub fn automation_remove_keyframe(&mut self, track_id: TrackId, node_id: u32, time: Beats) {
|
||||||
let _ = self.command_tx.push(Command::AutomationRemoveKeyframe(
|
let _ = self.command_tx.push(Command::AutomationRemoveKeyframe(
|
||||||
track_id, node_id, time.beats_to_f64()));
|
track_id, node_id, time));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the display name of an AutomationInput node
|
/// Set the display name of an AutomationInput node
|
||||||
|
|
@ -4560,7 +4570,7 @@ impl EngineController {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get file info from pool (duration, sample_rate, channels)
|
/// Get file info from pool (duration, sample_rate, channels)
|
||||||
pub fn get_pool_file_info(&mut self, pool_index: usize) -> Result<(f64, u32, u32), String> {
|
pub fn get_pool_file_info(&mut self, pool_index: usize) -> Result<(Seconds, u32, u32), String> {
|
||||||
// Send query
|
// Send query
|
||||||
if let Err(_) = self.query_tx.push(Query::GetPoolFileInfo(pool_index)) {
|
if let Err(_) = self.query_tx.push(Query::GetPoolFileInfo(pool_index)) {
|
||||||
return Err("Failed to send query - queue full".to_string());
|
return Err("Failed to send query - queue full".to_string());
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ pub enum Command {
|
||||||
/// Pause playback (maintains position)
|
/// Pause playback (maintains position)
|
||||||
Pause,
|
Pause,
|
||||||
/// Seek to a specific position in seconds
|
/// Seek to a specific position in seconds
|
||||||
Seek(f64),
|
Seek(Seconds),
|
||||||
|
|
||||||
// Track management commands
|
// Track management commands
|
||||||
/// Set track volume (0.0 = silence, 1.0 = unity gain)
|
/// Set track volume (0.0 = silence, 1.0 = unity gain)
|
||||||
|
|
@ -51,7 +51,7 @@ pub enum Command {
|
||||||
TrimClip(TrackId, ClipId, TrimRange),
|
TrimClip(TrackId, ClipId, TrimRange),
|
||||||
/// Extend/shrink a clip's external duration (track_id, clip_id, new_external_duration)
|
/// Extend/shrink a clip's external duration (track_id, clip_id, new_external_duration)
|
||||||
/// If duration > internal duration, the clip will loop
|
/// If duration > internal duration, the clip will loop
|
||||||
ExtendClip(TrackId, ClipId, f64),
|
ExtendClip(TrackId, ClipId, Beats),
|
||||||
|
|
||||||
// Metatrack management commands
|
// Metatrack management commands
|
||||||
/// Create a new metatrack with a name and optional parent group
|
/// Create a new metatrack with a name and optional parent group
|
||||||
|
|
@ -67,15 +67,15 @@ pub enum Command {
|
||||||
SetTimeStretch(TrackId, f32),
|
SetTimeStretch(TrackId, f32),
|
||||||
/// Set metatrack time offset in seconds (track_id, offset)
|
/// Set metatrack time offset in seconds (track_id, offset)
|
||||||
/// Positive = shift content later, negative = shift earlier
|
/// Positive = shift content later, negative = shift earlier
|
||||||
SetOffset(TrackId, f64),
|
SetOffset(TrackId, Seconds),
|
||||||
/// Set metatrack pitch shift in semitones (track_id, semitones) - for future use
|
/// Set metatrack pitch shift in semitones (track_id, semitones) - for future use
|
||||||
SetPitchShift(TrackId, f32),
|
SetPitchShift(TrackId, f32),
|
||||||
/// Set metatrack trim start in seconds (track_id, trim_start)
|
/// Set metatrack trim start in seconds (track_id, trim_start)
|
||||||
/// Children won't hear content before this point
|
/// Children won't hear content before this point
|
||||||
SetTrimStart(TrackId, f64),
|
SetTrimStart(TrackId, Seconds),
|
||||||
/// Set metatrack trim end in seconds (track_id, trim_end)
|
/// Set metatrack trim end in seconds (track_id, trim_end)
|
||||||
/// None means no end trim
|
/// None means no end trim
|
||||||
SetTrimEnd(TrackId, Option<f64>),
|
SetTrimEnd(TrackId, Option<Seconds>),
|
||||||
|
|
||||||
// Audio track commands
|
// Audio track commands
|
||||||
/// Create a new audio track with a name and optional parent group
|
/// Create a new audio track with a name and optional parent group
|
||||||
|
|
@ -94,14 +94,14 @@ pub enum Command {
|
||||||
/// Add a MIDI clip to the pool without placing it on a track
|
/// Add a MIDI clip to the pool without placing it on a track
|
||||||
AddMidiClipToPool(MidiClip),
|
AddMidiClipToPool(MidiClip),
|
||||||
/// Create a new MIDI clip on a track (track_id, start_time, duration)
|
/// Create a new MIDI clip on a track (track_id, start_time, duration)
|
||||||
CreateMidiClip(TrackId, f64, f64),
|
CreateMidiClip(TrackId, Beats, Beats),
|
||||||
/// Add a MIDI note to a clip (track_id, clip_id, time_offset, note, velocity, duration)
|
/// Add a MIDI note to a clip (track_id, clip_id, time_offset, note, velocity, duration)
|
||||||
AddMidiNote(TrackId, MidiClipId, f64, u8, u8, f64),
|
AddMidiNote(TrackId, MidiClipId, Beats, u8, u8, Beats),
|
||||||
/// Add a pre-loaded MIDI clip to a track (track_id, clip, start_time)
|
/// Add a pre-loaded MIDI clip to a track (track_id, clip, start_time)
|
||||||
AddLoadedMidiClip(TrackId, MidiClip, f64),
|
AddLoadedMidiClip(TrackId, MidiClip, Beats),
|
||||||
/// Update MIDI clip notes (track_id, clip_id, notes: Vec<(start_time, note, velocity, duration)>)
|
/// Update MIDI clip notes (track_id, clip_id, notes: Vec<(start_time, note, velocity, duration)>)
|
||||||
/// NOTE: May need to switch to individual note operations if this becomes slow on clips with many notes
|
/// NOTE: May need to switch to individual note operations if this becomes slow on clips with many notes
|
||||||
UpdateMidiClipNotes(TrackId, MidiClipId, Vec<(f64, u8, u8, f64)>),
|
UpdateMidiClipNotes(TrackId, MidiClipId, Vec<(Beats, u8, u8, Beats)>),
|
||||||
/// Replace all events in a MIDI clip (track_id, clip_id, events). Used for CC/pitch bend editing.
|
/// Replace all events in a MIDI clip (track_id, clip_id, events). Used for CC/pitch bend editing.
|
||||||
UpdateMidiClipEvents(TrackId, MidiClipId, Vec<MidiEvent>),
|
UpdateMidiClipEvents(TrackId, MidiClipId, Vec<MidiEvent>),
|
||||||
/// Remove a MIDI clip instance from a track (track_id, instance_id) - for undo/redo support
|
/// Remove a MIDI clip instance from a track (track_id, instance_id) - for undo/redo support
|
||||||
|
|
@ -117,9 +117,9 @@ pub enum Command {
|
||||||
/// Create a new automation lane on a track (track_id, parameter_id)
|
/// Create a new automation lane on a track (track_id, parameter_id)
|
||||||
CreateAutomationLane(TrackId, ParameterId),
|
CreateAutomationLane(TrackId, ParameterId),
|
||||||
/// Add an automation point to a lane (track_id, lane_id, time, value, curve)
|
/// Add an automation point to a lane (track_id, lane_id, time, value, curve)
|
||||||
AddAutomationPoint(TrackId, AutomationLaneId, f64, f32, CurveType),
|
AddAutomationPoint(TrackId, AutomationLaneId, Beats, f32, CurveType),
|
||||||
/// Remove an automation point at a specific time (track_id, lane_id, time, tolerance)
|
/// Remove an automation point at a specific time (track_id, lane_id, time, tolerance)
|
||||||
RemoveAutomationPoint(TrackId, AutomationLaneId, f64, f64),
|
RemoveAutomationPoint(TrackId, AutomationLaneId, Beats, Beats),
|
||||||
/// Clear all automation points from a lane (track_id, lane_id)
|
/// Clear all automation points from a lane (track_id, lane_id)
|
||||||
ClearAutomationLane(TrackId, AutomationLaneId),
|
ClearAutomationLane(TrackId, AutomationLaneId),
|
||||||
/// Remove an automation lane (track_id, lane_id)
|
/// Remove an automation lane (track_id, lane_id)
|
||||||
|
|
@ -258,9 +258,9 @@ pub enum Command {
|
||||||
|
|
||||||
// Automation Input Node commands
|
// Automation Input Node commands
|
||||||
/// Add or update a keyframe on an AutomationInput node (track_id, node_id, time, value, interpolation, ease_out, ease_in)
|
/// Add or update a keyframe on an AutomationInput node (track_id, node_id, time, value, interpolation, ease_out, ease_in)
|
||||||
AutomationAddKeyframe(TrackId, u32, f64, f32, String, (f32, f32), (f32, f32)),
|
AutomationAddKeyframe(TrackId, u32, Beats, f32, String, (f32, f32), (f32, f32)),
|
||||||
/// Remove a keyframe from an AutomationInput node (track_id, node_id, time)
|
/// Remove a keyframe from an AutomationInput node (track_id, node_id, time)
|
||||||
AutomationRemoveKeyframe(TrackId, u32, f64),
|
AutomationRemoveKeyframe(TrackId, u32, Beats),
|
||||||
/// Set the display name of an AutomationInput node (track_id, node_id, name)
|
/// Set the display name of an AutomationInput node (track_id, node_id, name)
|
||||||
AutomationSetName(TrackId, u32, String),
|
AutomationSetName(TrackId, u32, String),
|
||||||
|
|
||||||
|
|
@ -292,7 +292,7 @@ pub enum Command {
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum AudioEvent {
|
pub enum AudioEvent {
|
||||||
/// Current playback position in seconds
|
/// Current playback position in seconds
|
||||||
PlaybackPosition(f64),
|
PlaybackPosition(Seconds),
|
||||||
/// Playback has stopped (reached end of audio)
|
/// Playback has stopped (reached end of audio)
|
||||||
PlaybackStopped,
|
PlaybackStopped,
|
||||||
/// Audio buffer underrun detected
|
/// Audio buffer underrun detected
|
||||||
|
|
@ -379,7 +379,7 @@ pub enum AudioEvent {
|
||||||
WaveformChunksReady {
|
WaveformChunksReady {
|
||||||
pool_index: usize,
|
pool_index: usize,
|
||||||
detail_level: u8,
|
detail_level: u8,
|
||||||
chunks: Vec<(u32, (f64, f64), Vec<WaveformPeak>)>,
|
chunks: Vec<(u32, (Seconds, Seconds), Vec<WaveformPeak>)>,
|
||||||
},
|
},
|
||||||
|
|
||||||
/// An audio file has been imported and is ready for playback.
|
/// An audio file has been imported and is ready for playback.
|
||||||
|
|
@ -390,7 +390,7 @@ pub enum AudioEvent {
|
||||||
path: String,
|
path: String,
|
||||||
channels: u32,
|
channels: u32,
|
||||||
sample_rate: u32,
|
sample_rate: u32,
|
||||||
duration: f64,
|
duration: Seconds,
|
||||||
format: crate::io::audio_file::AudioFormat,
|
format: crate::io::audio_file::AudioFormat,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -464,7 +464,7 @@ pub enum Query {
|
||||||
/// Export audio to file (settings, output_path)
|
/// Export audio to file (settings, output_path)
|
||||||
ExportAudio(crate::audio::ExportSettings, std::path::PathBuf),
|
ExportAudio(crate::audio::ExportSettings, std::path::PathBuf),
|
||||||
/// Add a MIDI clip to a track synchronously (track_id, clip, start_time) - returns instance ID
|
/// Add a MIDI clip to a track synchronously (track_id, clip, start_time) - returns instance ID
|
||||||
AddMidiClipSync(TrackId, crate::audio::midi::MidiClip, f64),
|
AddMidiClipSync(TrackId, crate::audio::midi::MidiClip, Beats),
|
||||||
/// Add a MIDI clip instance to a track synchronously (track_id, instance) - returns instance ID
|
/// Add a MIDI clip instance to a track synchronously (track_id, instance) - returns instance ID
|
||||||
/// The clip must already exist in the MidiClipPool
|
/// The clip must already exist in the MidiClipPool
|
||||||
AddMidiClipInstanceSync(TrackId, crate::audio::midi::MidiClipInstance),
|
AddMidiClipInstanceSync(TrackId, crate::audio::midi::MidiClipInstance),
|
||||||
|
|
@ -509,16 +509,21 @@ pub struct OscilloscopeData {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// MIDI clip data for serialization
|
/// MIDI clip data for serialization
|
||||||
|
///
|
||||||
|
/// `Beats`/`Seconds` are `#[serde(transparent)]`, so naming the domain here costs nothing on disk —
|
||||||
|
/// the `.beam` still holds a plain number.
|
||||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
pub struct MidiClipData {
|
pub struct MidiClipData {
|
||||||
pub duration: f64,
|
/// MIDI content length is musical, so beats.
|
||||||
|
pub duration: Beats,
|
||||||
pub events: Vec<crate::audio::midi::MidiEvent>,
|
pub events: Vec<crate::audio::midi::MidiEvent>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Automation keyframe data for serialization
|
/// Automation keyframe data for serialization
|
||||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
pub struct AutomationKeyframeData {
|
pub struct AutomationKeyframeData {
|
||||||
pub time: f64,
|
/// Automation x-axes are all beats.
|
||||||
|
pub time: Beats,
|
||||||
pub value: f32,
|
pub value: f32,
|
||||||
pub interpolation: String,
|
pub interpolation: String,
|
||||||
pub ease_out: (f32, f32),
|
pub ease_out: (f32, f32),
|
||||||
|
|
@ -555,7 +560,7 @@ pub enum QueryResponse {
|
||||||
/// Pool waveform data
|
/// Pool waveform data
|
||||||
PoolWaveform(Result<Vec<crate::io::WaveformPeak>, String>),
|
PoolWaveform(Result<Vec<crate::io::WaveformPeak>, String>),
|
||||||
/// Pool file info (duration, sample_rate, channels)
|
/// Pool file info (duration, sample_rate, channels)
|
||||||
PoolFileInfo(Result<(f64, u32, u32), String>),
|
PoolFileInfo(Result<(Seconds, u32, u32), String>),
|
||||||
/// Audio exported
|
/// Audio exported
|
||||||
AudioExported(Result<(), String>),
|
AudioExported(Result<(), String>),
|
||||||
/// MIDI clip instance added (returns instance ID)
|
/// MIDI clip instance added (returns instance ID)
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ pub use audio::{
|
||||||
TrackNode,
|
TrackNode,
|
||||||
};
|
};
|
||||||
pub use audio::node_graph::{GraphPreset, AudioGraph, PresetMetadata, SerializedConnection, SerializedNode};
|
pub use audio::node_graph::{GraphPreset, AudioGraph, PresetMetadata, SerializedConnection, SerializedNode};
|
||||||
pub use time::{Beats, Seconds};
|
pub use time::{Beats, ContentTime, Seconds};
|
||||||
pub use tempo_map::{TempoEntry, TempoInterpolation, TempoMap, beats_to_seconds_stack, seconds_to_beats_stack};
|
pub use tempo_map::{TempoEntry, TempoInterpolation, TempoMap, beats_to_seconds_stack, seconds_to_beats_stack};
|
||||||
pub use command::{AudioEvent, Command, OscilloscopeData};
|
pub use command::{AudioEvent, Command, OscilloscopeData};
|
||||||
pub use command::types::AutomationKeyframeData;
|
pub use command::types::AutomationKeyframeData;
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,50 @@ pub struct Beats(pub f64);
|
||||||
#[serde(transparent)]
|
#[serde(transparent)]
|
||||||
pub struct Seconds(pub f64);
|
pub struct Seconds(pub f64);
|
||||||
|
|
||||||
|
/// A time *inside a clip's own content*, in whatever unit that clip measures content in.
|
||||||
|
///
|
||||||
|
/// Clip content time is domain-polymorphic: SECONDS for sampled audio, video and vector, but BEATS
|
||||||
|
/// for MIDI (musical, so it survives tempo changes). `ClipInstance::trim_start`/`trim_end` are
|
||||||
|
/// content times, and storing them as bare `f64`s is what let a seconds delta get added to a MIDI
|
||||||
|
/// clip's beats trim — splitting a MIDI clip at beat 4 landed at beat 2 at 120 BPM.
|
||||||
|
///
|
||||||
|
/// This type is deliberately a **dead end**: it has no `.to_seconds()`, no `.to_beats()`, and no
|
||||||
|
/// arithmetic with `Seconds` or `Beats`. Content times can be compared and combined with each other
|
||||||
|
/// (that's domain-safe — both operands are in the same clip's domain), but the only way to get a
|
||||||
|
/// real timeline duration out is to resolve it against the clip that knows the domain, via
|
||||||
|
/// `AudioClip::resolve_content_time` / `Document::resolve_content_time`. So a passthrough costs
|
||||||
|
/// nothing, and mixing domains won't compile.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct ContentTime(pub f64);
|
||||||
|
|
||||||
|
impl ContentTime {
|
||||||
|
pub const ZERO: Self = Self(0.0);
|
||||||
|
|
||||||
|
pub fn max(self, other: Self) -> Self { Self(self.0.max(other.0)) }
|
||||||
|
pub fn min(self, other: Self) -> Self { Self(self.0.min(other.0)) }
|
||||||
|
|
||||||
|
/// The raw magnitude, with the domain discarded.
|
||||||
|
///
|
||||||
|
/// Only for code that is *already* working in this clip's content domain (trim arithmetic,
|
||||||
|
/// serialization, drawing a waveform whose x-axis is the clip's own content). If you are about
|
||||||
|
/// to combine this with a timeline position, resolve it against the clip instead.
|
||||||
|
pub fn raw(self) -> f64 { self.0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Add for ContentTime {
|
||||||
|
type Output = Self;
|
||||||
|
fn add(self, rhs: Self) -> Self { Self(self.0 + rhs.0) }
|
||||||
|
}
|
||||||
|
impl Sub for ContentTime {
|
||||||
|
type Output = Self;
|
||||||
|
fn sub(self, rhs: Self) -> Self { Self(self.0 - rhs.0) }
|
||||||
|
}
|
||||||
|
impl Rem for ContentTime {
|
||||||
|
type Output = Self;
|
||||||
|
fn rem(self, rhs: Self) -> Self { Self(self.0 % rhs.0) }
|
||||||
|
}
|
||||||
|
|
||||||
impl Beats {
|
impl Beats {
|
||||||
pub const ZERO: Self = Self(0.0);
|
pub const ZERO: Self = Self(0.0);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -556,7 +556,7 @@ pub fn run_tui(
|
||||||
while let Ok(event) = rx.pop() {
|
while let Ok(event) = rx.pop() {
|
||||||
match event {
|
match event {
|
||||||
AudioEvent::PlaybackPosition(pos) => {
|
AudioEvent::PlaybackPosition(pos) => {
|
||||||
app.update_playback_position(pos);
|
app.update_playback_position(pos.seconds_to_f64());
|
||||||
}
|
}
|
||||||
AudioEvent::PlaybackStopped => {
|
AudioEvent::PlaybackStopped => {
|
||||||
app.set_playing(false);
|
app.set_playing(false);
|
||||||
|
|
|
||||||
|
|
@ -77,11 +77,18 @@ impl BackendContext<'_> {
|
||||||
.ok_or_else(|| format!("Layer {} not mapped to backend track", layer_id))?;
|
.ok_or_else(|| format!("Layer {} not mapped to backend track", layer_id))?;
|
||||||
|
|
||||||
let resolved = clip.resolve(instance.active_take);
|
let resolved = clip.resolve(instance.active_take);
|
||||||
let content_duration = clip.content_duration().native();
|
let content = clip.content_duration();
|
||||||
let internal_start = instance.trim_start;
|
let internal_start = instance.trim_start;
|
||||||
let internal_end = instance.trim_end.unwrap_or(content_duration);
|
let internal_end = instance
|
||||||
|
.trim_end
|
||||||
|
.unwrap_or(daw_backend::ContentTime(content.native()));
|
||||||
let start_time = instance.timeline_start;
|
let start_time = instance.timeline_start;
|
||||||
|
|
||||||
|
// How long the clip occupies the timeline, in BEATS. `effective_duration_beats` resolves the
|
||||||
|
// content window in the clip's own domain — beats content carries over directly, wall-clock
|
||||||
|
// content converts at the clip's position — so neither kind can be read as the other here.
|
||||||
|
let effective_duration = instance.effective_duration_beats(content, document.tempo_map());
|
||||||
|
|
||||||
let controller = self
|
let controller = self
|
||||||
.audio_controller
|
.audio_controller
|
||||||
.as_mut()
|
.as_mut()
|
||||||
|
|
@ -91,18 +98,14 @@ impl BackendContext<'_> {
|
||||||
ResolvedContent::Midi { midi_clip_id } => {
|
ResolvedContent::Midi { midi_clip_id } => {
|
||||||
use daw_backend::command::{Query, QueryResponse};
|
use daw_backend::command::{Query, QueryResponse};
|
||||||
|
|
||||||
// MIDI trims are in the BEATS domain, so the fallback span is beats too.
|
// MIDI content time IS beats, so the trims carry straight over.
|
||||||
let external_duration = instance
|
|
||||||
.timeline_duration
|
|
||||||
.unwrap_or(daw_backend::Beats(internal_end - internal_start));
|
|
||||||
|
|
||||||
let midi_instance = daw_backend::MidiClipInstance::new(
|
let midi_instance = daw_backend::MidiClipInstance::new(
|
||||||
0, // assigned by the backend
|
0, // assigned by the backend
|
||||||
midi_clip_id,
|
midi_clip_id,
|
||||||
daw_backend::Beats(internal_start),
|
daw_backend::Beats(internal_start.raw()),
|
||||||
daw_backend::Beats(internal_end),
|
daw_backend::Beats(internal_end.raw()),
|
||||||
start_time,
|
start_time,
|
||||||
external_duration,
|
effective_duration,
|
||||||
);
|
);
|
||||||
|
|
||||||
match controller
|
match controller
|
||||||
|
|
@ -114,26 +117,13 @@ impl BackendContext<'_> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ResolvedContent::Audio { audio_pool_index } => {
|
ResolvedContent::Audio { audio_pool_index } => {
|
||||||
// `trim_*` and the clip's content duration are SECONDS (audio content time); the
|
// Sampled-audio content time is SECONDS; the backend's start/duration are BEATS.
|
||||||
// backend's start/duration are BEATS.
|
|
||||||
//
|
|
||||||
// When `timeline_duration` is set it's already beats; otherwise the clip occupies
|
|
||||||
// its natural content length, so convert that seconds-span to beats *at the clip's
|
|
||||||
// start* (NOT `internal_end - internal_start`, which is seconds — that was the
|
|
||||||
// seconds-as-beats bug that made clips stop early at anything but 60 BPM).
|
|
||||||
let effective_duration = instance.timeline_duration.unwrap_or_else(|| {
|
|
||||||
let tempo_map = document.tempo_map();
|
|
||||||
let content_secs = daw_backend::Seconds(internal_end - internal_start);
|
|
||||||
tempo_map.seconds_to_beats(tempo_map.beats_to_seconds(start_time) + content_secs)
|
|
||||||
- start_time
|
|
||||||
});
|
|
||||||
|
|
||||||
let id = controller.add_audio_clip(
|
let id = controller.add_audio_clip(
|
||||||
track_id,
|
track_id,
|
||||||
audio_pool_index,
|
audio_pool_index,
|
||||||
start_time,
|
start_time,
|
||||||
effective_duration,
|
effective_duration,
|
||||||
daw_backend::Seconds(internal_start),
|
daw_backend::Seconds(internal_start.raw()),
|
||||||
);
|
);
|
||||||
BackendClipInstanceId::Audio(id)
|
BackendClipInstanceId::Audio(id)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -89,10 +89,11 @@ impl Action for AddClipInstanceAction {
|
||||||
// `get_clip_duration` is the content length in seconds; the placement span
|
// `get_clip_duration` is the content length in seconds; the placement span
|
||||||
// must be beats (the timeline is beats-domain), so convert via the clip's
|
// must be beats (the timeline is beats-domain), so convert via the clip's
|
||||||
// typed helper rather than treating the seconds span as beats.
|
// typed helper rather than treating the seconds span as beats.
|
||||||
let clip_duration = document.get_clip_duration(&self.clip_instance.clip_id)
|
// The clip's content duration in ITS OWN domain, so the trims resolve correctly for MIDI.
|
||||||
|
let clip_content = document.clip_trim_duration(&self.clip_instance.clip_id)
|
||||||
.ok_or_else(|| format!("Clip {} not found", self.clip_instance.clip_id))?;
|
.ok_or_else(|| format!("Clip {} not found", self.clip_instance.clip_id))?;
|
||||||
let effective_duration = self.clip_instance
|
let effective_duration = self.clip_instance
|
||||||
.effective_duration_beats(clip_duration, document.tempo_map());
|
.effective_duration_beats(clip_content, document.tempo_map());
|
||||||
|
|
||||||
// Auto-adjust position for audio/video layers to avoid overlaps
|
// Auto-adjust position for audio/video layers to avoid overlaps
|
||||||
let adjusted_start = document.find_nearest_valid_position(
|
let adjusted_start = document.find_nearest_valid_position(
|
||||||
|
|
|
||||||
|
|
@ -128,17 +128,13 @@ impl LoopClipInstancesAction {
|
||||||
(new_dur, new_lb)
|
(new_dur, new_lb)
|
||||||
};
|
};
|
||||||
|
|
||||||
let content_window = {
|
// Natural content length as a beats span (the fallback when no explicit
|
||||||
let trim_end = instance.trim_end.unwrap_or(clip.content_duration().native());
|
// timeline_duration is set). Resolved in the clip's own domain, so MIDI's beats
|
||||||
(trim_end - instance.trim_start).max(0.0) // seconds
|
// content carries over directly rather than being read as seconds.
|
||||||
};
|
let content_window_beats = instance.effective_duration_beats(
|
||||||
// Natural content length as a beats span at the clip's start (the
|
clip.content_duration(),
|
||||||
// fallback when no explicit timeline_duration is set).
|
document.tempo_map(),
|
||||||
let tempo_map = document.tempo_map();
|
);
|
||||||
let content_window_beats = tempo_map.seconds_to_beats(
|
|
||||||
tempo_map.beats_to_seconds(instance.timeline_start)
|
|
||||||
+ daw_backend::Seconds(content_window),
|
|
||||||
) - instance.timeline_start;
|
|
||||||
let right_duration = target_duration.unwrap_or(content_window_beats);
|
let right_duration = target_duration.unwrap_or(content_window_beats);
|
||||||
let left_duration = target_loop_before.unwrap_or(daw_backend::Beats::ZERO);
|
let left_duration = target_loop_before.unwrap_or(daw_backend::Beats::ZERO);
|
||||||
let external_duration = left_duration + right_duration;
|
let external_duration = left_duration + right_duration;
|
||||||
|
|
|
||||||
|
|
@ -105,7 +105,7 @@ impl Action for MoveClipInstancesAction {
|
||||||
|
|
||||||
let group: Vec<(Uuid, Beats, Beats)> = moves.iter().filter_map(|(id, old_start, _)| {
|
let group: Vec<(Uuid, Beats, Beats)> = moves.iter().filter_map(|(id, old_start, _)| {
|
||||||
let inst = clip_instances.iter().find(|ci| &ci.id == id)?;
|
let inst = clip_instances.iter().find(|ci| &ci.id == id)?;
|
||||||
let dur = document.get_clip_duration(&inst.clip_id)?;
|
let dur = document.clip_trim_duration(&inst.clip_id)?;
|
||||||
let eff = inst.effective_duration_beats(dur, document.tempo_map());
|
let eff = inst.effective_duration_beats(dur, document.tempo_map());
|
||||||
Some((*id, *old_start, eff))
|
Some((*id, *old_start, eff))
|
||||||
}).collect();
|
}).collect();
|
||||||
|
|
@ -211,8 +211,9 @@ impl Action for MoveClipInstancesAction {
|
||||||
// Check if this clip has a metatrack
|
// Check if this clip has a metatrack
|
||||||
if let Some(&metatrack_id) = backend.layer_to_track_map.get(&instance.clip_id) {
|
if let Some(&metatrack_id) = backend.layer_to_track_map.get(&instance.clip_id) {
|
||||||
controller.set_offset(metatrack_id, document.tempo_map().beats_to_seconds(*new_start));
|
controller.set_offset(metatrack_id, document.tempo_map().beats_to_seconds(*new_start));
|
||||||
controller.set_trim_start(metatrack_id, daw_backend::Seconds(instance.trim_start));
|
// A vector clip's content is wall-clock, so its content times ARE seconds.
|
||||||
controller.set_trim_end(metatrack_id, instance.trim_end.map(daw_backend::Seconds));
|
controller.set_trim_start(metatrack_id, daw_backend::Seconds(instance.trim_start.raw()));
|
||||||
|
controller.set_trim_end(metatrack_id, instance.trim_end.map(|t| daw_backend::Seconds(t.raw())));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -295,8 +296,9 @@ impl Action for MoveClipInstancesAction {
|
||||||
if let Some(instance) = vl.clip_instances.iter().find(|ci| ci.id == *instance_id) {
|
if let Some(instance) = vl.clip_instances.iter().find(|ci| ci.id == *instance_id) {
|
||||||
if let Some(&metatrack_id) = backend.layer_to_track_map.get(&instance.clip_id) {
|
if let Some(&metatrack_id) = backend.layer_to_track_map.get(&instance.clip_id) {
|
||||||
controller.set_offset(metatrack_id, document.tempo_map().beats_to_seconds(*old_start));
|
controller.set_offset(metatrack_id, document.tempo_map().beats_to_seconds(*old_start));
|
||||||
controller.set_trim_start(metatrack_id, daw_backend::Seconds(instance.trim_start));
|
// A vector clip's content is wall-clock, so its content times ARE seconds.
|
||||||
controller.set_trim_end(metatrack_id, instance.trim_end.map(daw_backend::Seconds));
|
controller.set_trim_start(metatrack_id, daw_backend::Seconds(instance.trim_start.raw()));
|
||||||
|
controller.set_trim_end(metatrack_id, instance.trim_end.map(|t| daw_backend::Seconds(t.raw())));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -138,92 +138,23 @@ impl Action for RemoveClipInstancesAction {
|
||||||
backend: &mut BackendContext,
|
backend: &mut BackendContext,
|
||||||
document: &Document,
|
document: &Document,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
use crate::clip::ResolvedContent;
|
if backend.audio_controller.is_none() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
let controller = match backend.audio_controller.as_mut() {
|
// Re-add the clips that were removed. `BackendContext::add_clip_instance` is the same
|
||||||
Some(c) => c,
|
// helper the add and split actions use, so the trim/duration conversions (and take-folder
|
||||||
None => return Ok(()),
|
// resolution) stay in exactly one place instead of being copied into every action that has
|
||||||
};
|
// to put a clip back.
|
||||||
|
let saved = std::mem::take(&mut self.saved);
|
||||||
// Re-add clips that were removed from backend
|
for (layer_id, instance) in &saved {
|
||||||
for (layer_id, instance) in &self.saved {
|
if !matches!(document.get_layer(layer_id), Some(AnyLayer::Audio(_))) {
|
||||||
let layer = match document.get_layer(layer_id) {
|
|
||||||
Some(l) => l,
|
|
||||||
None => continue,
|
|
||||||
};
|
|
||||||
if !matches!(layer, AnyLayer::Audio(_)) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// A missing track/clip just means there's nothing to restore on the backend.
|
||||||
let track_id = match backend.layer_to_track_map.get(layer_id) {
|
let _ = backend.add_clip_instance(document, layer_id, instance);
|
||||||
Some(id) => *id,
|
|
||||||
None => continue,
|
|
||||||
};
|
|
||||||
|
|
||||||
let clip = match document.get_audio_clip(&instance.clip_id) {
|
|
||||||
Some(c) => c,
|
|
||||||
None => continue,
|
|
||||||
};
|
|
||||||
|
|
||||||
match &clip.resolve(instance.active_take) {
|
|
||||||
ResolvedContent::Midi { midi_clip_id } => {
|
|
||||||
use daw_backend::command::{Query, QueryResponse};
|
|
||||||
|
|
||||||
let internal_start = instance.trim_start;
|
|
||||||
let internal_end = instance.trim_end.unwrap_or(clip.content_duration().native());
|
|
||||||
let external_start = instance.timeline_start;
|
|
||||||
// MIDI trims are beats-domain, so the fallback span is beats too.
|
|
||||||
let external_duration = instance
|
|
||||||
.timeline_duration
|
|
||||||
.unwrap_or(daw_backend::Beats(internal_end - internal_start));
|
|
||||||
|
|
||||||
let midi_instance = daw_backend::MidiClipInstance::new(
|
|
||||||
0,
|
|
||||||
*midi_clip_id,
|
|
||||||
daw_backend::Beats(internal_start),
|
|
||||||
daw_backend::Beats(internal_end),
|
|
||||||
external_start,
|
|
||||||
external_duration,
|
|
||||||
);
|
|
||||||
|
|
||||||
let query = Query::AddMidiClipInstanceSync(track_id, midi_instance);
|
|
||||||
if let Ok(QueryResponse::MidiClipInstanceAdded(Ok(new_id))) =
|
|
||||||
controller.send_query(query)
|
|
||||||
{
|
|
||||||
backend.clip_instance_to_backend_map.insert(
|
|
||||||
instance.id,
|
|
||||||
BackendClipInstanceId::Midi(new_id),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ResolvedContent::Audio { audio_pool_index } => {
|
|
||||||
let internal_start = instance.trim_start;
|
|
||||||
let internal_end = instance.trim_end.unwrap_or(clip.content_duration().native());
|
|
||||||
let start_time = instance.timeline_start;
|
|
||||||
// Fallback span is the content seconds converted to beats at the
|
|
||||||
// clip's start (not the seconds span treated as beats).
|
|
||||||
let effective_duration = instance.timeline_duration.unwrap_or_else(|| {
|
|
||||||
let tempo_map = document.tempo_map();
|
|
||||||
let content_secs = daw_backend::Seconds(internal_end - internal_start);
|
|
||||||
tempo_map.seconds_to_beats(tempo_map.beats_to_seconds(start_time) + content_secs)
|
|
||||||
- start_time
|
|
||||||
});
|
|
||||||
|
|
||||||
let new_id = controller.add_audio_clip(
|
|
||||||
track_id,
|
|
||||||
*audio_pool_index,
|
|
||||||
start_time,
|
|
||||||
effective_duration,
|
|
||||||
daw_backend::Seconds(internal_start),
|
|
||||||
);
|
|
||||||
backend.clip_instance_to_backend_map.insert(
|
|
||||||
instance.id,
|
|
||||||
BackendClipInstanceId::Audio(new_id),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
ResolvedContent::Recording => {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
self.saved = saved;
|
||||||
|
|
||||||
// Clear saved backend IDs
|
// Clear saved backend IDs
|
||||||
self.saved_backend_ids.clear();
|
self.saved_backend_ids.clear();
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ use crate::action::{Action, BackendContext};
|
||||||
use crate::clip::ClipInstance;
|
use crate::clip::ClipInstance;
|
||||||
use crate::document::Document;
|
use crate::document::Document;
|
||||||
use crate::layer::AnyLayer;
|
use crate::layer::AnyLayer;
|
||||||
|
use daw_backend::ContentTime;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
/// Action that splits a clip instance at a specific timeline position
|
/// Action that splits a clip instance at a specific timeline position
|
||||||
|
|
@ -25,7 +26,7 @@ pub struct SplitClipInstanceAction {
|
||||||
|
|
||||||
// Stored during execute for rollback
|
// Stored during execute for rollback
|
||||||
/// Original trim_end value of the left (original) instance
|
/// Original trim_end value of the left (original) instance
|
||||||
original_trim_end: Option<f64>,
|
original_trim_end: Option<ContentTime>,
|
||||||
/// Original timeline_duration value of the left (original) instance (beats)
|
/// Original timeline_duration value of the left (original) instance (beats)
|
||||||
original_timeline_duration: Option<daw_backend::Beats>,
|
original_timeline_duration: Option<daw_backend::Beats>,
|
||||||
/// ID of the new (right) instance created by the split
|
/// ID of the new (right) instance created by the split
|
||||||
|
|
@ -122,13 +123,15 @@ impl Action for SplitClipInstanceAction {
|
||||||
.find(|ci| ci.id == self.instance_id)
|
.find(|ci| ci.id == self.instance_id)
|
||||||
.ok_or_else(|| format!("Clip instance {} not found", self.instance_id))?;
|
.ok_or_else(|| format!("Clip instance {} not found", self.instance_id))?;
|
||||||
|
|
||||||
// Get the clip's duration
|
// The clip's content duration in its OWN domain — seconds for audio/video/vector, beats for
|
||||||
let clip_duration = document
|
// MIDI. All the content math below is trim-domain, so it has to be done in whichever domain
|
||||||
.get_clip_duration(&instance.clip_id)
|
// this clip uses; a seconds duration would silently be added to a MIDI clip's beats trim.
|
||||||
|
let trim_duration = document
|
||||||
|
.clip_trim_duration(&instance.clip_id)
|
||||||
.ok_or_else(|| format!("Clip {} not found", instance.clip_id))?;
|
.ok_or_else(|| format!("Clip {} not found", instance.clip_id))?;
|
||||||
|
|
||||||
// Calculate the effective duration and timeline end (both in beats)
|
// Calculate the effective duration and timeline end (both in beats)
|
||||||
let effective_duration = instance.effective_duration(clip_duration, document.tempo_map());
|
let effective_duration = instance.effective_duration(trim_duration, document.tempo_map());
|
||||||
let timeline_end = instance.timeline_start + effective_duration;
|
let timeline_end = instance.timeline_start + effective_duration;
|
||||||
|
|
||||||
// Validate: split_time must be strictly within the clip's timeline span
|
// Validate: split_time must be strictly within the clip's timeline span
|
||||||
|
|
@ -146,33 +149,26 @@ impl Action for SplitClipInstanceAction {
|
||||||
self.original_trim_end = instance.trim_end;
|
self.original_trim_end = instance.trim_end;
|
||||||
self.original_timeline_duration = instance.timeline_duration;
|
self.original_timeline_duration = instance.timeline_duration;
|
||||||
|
|
||||||
// The clip's content duration in the SAME domain as its trims — seconds for audio/video/
|
|
||||||
// vector, beats for MIDI. All the content math below is trim-domain, so it has to be done
|
|
||||||
// in whichever domain this clip uses; `clip_duration` above is always seconds and would
|
|
||||||
// silently add a seconds delta to a MIDI clip's beats trim.
|
|
||||||
let trim_duration = document
|
|
||||||
.clip_trim_duration(&instance.clip_id)
|
|
||||||
.ok_or_else(|| format!("Clip {} not found", instance.clip_id))?;
|
|
||||||
|
|
||||||
let is_looping = instance.timeline_duration.is_some();
|
let is_looping = instance.timeline_duration.is_some();
|
||||||
let content_duration = instance.trim_end.unwrap_or(trim_duration.native()) - instance.trim_start;
|
let content_duration = ContentTime(instance.content_window(trim_duration).native());
|
||||||
|
|
||||||
// Timeline split point (beats).
|
// Timeline split point (beats).
|
||||||
let time_into_clip = self.split_time - instance.timeline_start;
|
let time_into_clip = self.split_time - instance.timeline_start;
|
||||||
let left_duration = time_into_clip;
|
let left_duration = time_into_clip;
|
||||||
let right_duration = effective_duration - left_duration;
|
let right_duration = effective_duration - left_duration;
|
||||||
|
|
||||||
// How far the split lands into the clip's *content*, expressed in the trim domain.
|
// How far the split lands into the clip's *content*, expressed in the content domain: beats
|
||||||
|
// content takes the beats delta directly, wall-clock content takes the seconds delta.
|
||||||
let tempo_map = document.tempo_map();
|
let tempo_map = document.tempo_map();
|
||||||
let time_into_content = match trim_duration {
|
let time_into_content = ContentTime(match trim_duration {
|
||||||
crate::clip::ClipDuration::Beats(_) => time_into_clip.beats_to_f64(),
|
crate::clip::ClipDuration::Beats(_) => time_into_clip.beats_to_f64(),
|
||||||
crate::clip::ClipDuration::Seconds(_) => (tempo_map.beats_to_seconds(self.split_time)
|
crate::clip::ClipDuration::Seconds(_) => (tempo_map.beats_to_seconds(self.split_time)
|
||||||
- tempo_map.beats_to_seconds(instance.timeline_start))
|
- tempo_map.beats_to_seconds(instance.timeline_start))
|
||||||
.seconds_to_f64(),
|
.seconds_to_f64(),
|
||||||
};
|
});
|
||||||
|
|
||||||
// Calculate the content split point (trim domain).
|
// Calculate the content split point (content domain).
|
||||||
let content_split_time = if is_looping && content_duration > 0.0 {
|
let content_split_time = if is_looping && content_duration > ContentTime::ZERO {
|
||||||
// For looping clips, wrap around content
|
// For looping clips, wrap around content
|
||||||
instance.trim_start + (time_into_content % content_duration)
|
instance.trim_start + (time_into_content % content_duration)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -367,119 +363,63 @@ impl Action for SplitClipInstanceAction {
|
||||||
.get_audio_clip(&new_instance.clip_id)
|
.get_audio_clip(&new_instance.clip_id)
|
||||||
.ok_or_else(|| "Audio clip not found".to_string())?;
|
.ok_or_else(|| "Audio clip not found".to_string())?;
|
||||||
|
|
||||||
// Look up backend track ID from layer mapping
|
use crate::clip::ResolvedContent;
|
||||||
let backend_track_id = backend
|
if matches!(clip.resolve(original_instance.active_take), ResolvedContent::Recording) {
|
||||||
|
return Err("Cannot split a clip that is currently recording".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// A split is: shorten the left half's backend clip, then add the right half as a new one.
|
||||||
|
//
|
||||||
|
// 1. Trim the left (original) instance. `trim_range` tags the bounds with the clip's own
|
||||||
|
// content domain, so a MIDI clip's beats trims can't be sent as seconds.
|
||||||
|
let left_trim = clip.trim_range(
|
||||||
|
original_instance.trim_start,
|
||||||
|
original_instance
|
||||||
|
.trim_end
|
||||||
|
.unwrap_or(ContentTime(clip.content_duration().native())),
|
||||||
|
);
|
||||||
|
let new_instance = new_instance.clone();
|
||||||
|
|
||||||
|
let backend_track_id = *backend
|
||||||
.layer_to_track_map
|
.layer_to_track_map
|
||||||
.get(&self.layer_id)
|
.get(&self.layer_id)
|
||||||
.ok_or_else(|| format!("Layer {} not mapped to backend track", self.layer_id))?;
|
.ok_or_else(|| format!("Layer {} not mapped to backend track", self.layer_id))?;
|
||||||
|
let left_backend_id = backend
|
||||||
|
.clip_instance_to_backend_map
|
||||||
|
.get(&self.instance_id)
|
||||||
|
.copied();
|
||||||
|
|
||||||
// Get audio controller
|
{
|
||||||
let controller = backend
|
let controller = backend
|
||||||
.audio_controller
|
.audio_controller
|
||||||
.as_mut()
|
.as_mut()
|
||||||
.ok_or_else(|| "Audio controller not available".to_string())?;
|
.ok_or_else(|| "Audio controller not available".to_string())?;
|
||||||
|
match left_backend_id {
|
||||||
// Handle different clip types
|
Some(crate::action::BackendClipInstanceId::Midi(id))
|
||||||
use crate::clip::ResolvedContent;
|
| Some(crate::action::BackendClipInstanceId::Audio(id)) => {
|
||||||
match &clip.resolve(original_instance.active_take) {
|
controller.trim_clip(backend_track_id, id, left_trim);
|
||||||
ResolvedContent::Midi { midi_clip_id } => {
|
|
||||||
use daw_backend::command::{Query, QueryResponse};
|
|
||||||
|
|
||||||
// 1. Trim the original (left) instance
|
|
||||||
let orig_internal_start = original_instance.trim_start;
|
|
||||||
let orig_internal_end = original_instance.trim_end.unwrap_or(clip.content_duration().native());
|
|
||||||
|
|
||||||
// Look up the original backend instance ID
|
|
||||||
if let Some(crate::action::BackendClipInstanceId::Midi(orig_backend_id)) =
|
|
||||||
backend.clip_instance_to_backend_map.get(&self.instance_id)
|
|
||||||
{
|
|
||||||
controller.trim_clip(*backend_track_id, *orig_backend_id, clip.trim_range(orig_internal_start, orig_internal_end));
|
|
||||||
}
|
}
|
||||||
|
None => {}
|
||||||
// 2. Add the new (right) instance
|
|
||||||
let internal_start = new_instance.trim_start;
|
|
||||||
let internal_end = new_instance.trim_end.unwrap_or(clip.content_duration().native());
|
|
||||||
let external_start = new_instance.timeline_start;
|
|
||||||
// MIDI trims are beats-domain, so the fallback span is beats too.
|
|
||||||
let external_duration = new_instance
|
|
||||||
.timeline_duration
|
|
||||||
.unwrap_or(daw_backend::Beats(internal_end - internal_start));
|
|
||||||
|
|
||||||
let instance = daw_backend::MidiClipInstance::new(
|
|
||||||
0,
|
|
||||||
*midi_clip_id,
|
|
||||||
daw_backend::Beats(internal_start),
|
|
||||||
daw_backend::Beats(internal_end),
|
|
||||||
external_start,
|
|
||||||
external_duration,
|
|
||||||
);
|
|
||||||
|
|
||||||
let query = Query::AddMidiClipInstanceSync(*backend_track_id, instance);
|
|
||||||
|
|
||||||
match controller.send_query(query)? {
|
|
||||||
QueryResponse::MidiClipInstanceAdded(Ok(instance_id)) => {
|
|
||||||
self.backend_track_id = Some(*backend_track_id);
|
|
||||||
self.backend_midi_instance_id = Some(instance_id);
|
|
||||||
|
|
||||||
backend.clip_instance_to_backend_map.insert(
|
|
||||||
new_instance_id,
|
|
||||||
crate::action::BackendClipInstanceId::Midi(instance_id),
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
QueryResponse::MidiClipInstanceAdded(Err(e)) => Err(e),
|
|
||||||
_ => Err("Unexpected query response".to_string()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ResolvedContent::Audio { audio_pool_index } => {
|
|
||||||
// 1. Trim the original (left) instance
|
|
||||||
let orig_internal_start = original_instance.trim_start;
|
|
||||||
let orig_internal_end = original_instance.trim_end.unwrap_or(clip.content_duration().native());
|
|
||||||
|
|
||||||
// Look up the original backend instance ID
|
|
||||||
if let Some(crate::action::BackendClipInstanceId::Audio(orig_backend_id)) =
|
|
||||||
backend.clip_instance_to_backend_map.get(&self.instance_id)
|
|
||||||
{
|
|
||||||
controller.trim_clip(*backend_track_id, *orig_backend_id, clip.trim_range(orig_internal_start, orig_internal_end));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Add the new (right) instance
|
|
||||||
let internal_start = new_instance.trim_start;
|
|
||||||
let internal_end = new_instance.trim_end.unwrap_or(clip.content_duration().native());
|
|
||||||
let start_time = new_instance.timeline_start;
|
|
||||||
// Fallback span is the content seconds converted to beats at the
|
|
||||||
// clip's start (not the seconds span treated as beats).
|
|
||||||
let effective_duration = new_instance.timeline_duration.unwrap_or_else(|| {
|
|
||||||
let tempo_map = document.tempo_map();
|
|
||||||
let content_secs = daw_backend::Seconds(internal_end - internal_start);
|
|
||||||
tempo_map.seconds_to_beats(tempo_map.beats_to_seconds(start_time) + content_secs)
|
|
||||||
- start_time
|
|
||||||
});
|
|
||||||
|
|
||||||
let instance_id = controller.add_audio_clip(
|
|
||||||
*backend_track_id,
|
|
||||||
*audio_pool_index,
|
|
||||||
start_time,
|
|
||||||
effective_duration,
|
|
||||||
daw_backend::Seconds(internal_start),
|
|
||||||
);
|
|
||||||
|
|
||||||
self.backend_track_id = Some(*backend_track_id);
|
|
||||||
self.backend_audio_instance_id = Some(instance_id);
|
|
||||||
|
|
||||||
backend.clip_instance_to_backend_map.insert(
|
|
||||||
new_instance_id,
|
|
||||||
crate::action::BackendClipInstanceId::Audio(instance_id),
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
ResolvedContent::Recording => {
|
|
||||||
// Recording clips cannot be split
|
|
||||||
Err("Cannot split a clip that is currently recording".to_string())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2. Add the right (new) instance via the shared helper — same one AddClipInstanceAction
|
||||||
|
// uses, so the trim/duration conversions live in exactly one place.
|
||||||
|
if let Some((track_id, backend_id)) =
|
||||||
|
backend.add_clip_instance(document, &self.layer_id, &new_instance)?
|
||||||
|
{
|
||||||
|
self.backend_track_id = Some(track_id);
|
||||||
|
match backend_id {
|
||||||
|
crate::action::BackendClipInstanceId::Midi(id) => {
|
||||||
|
self.backend_midi_instance_id = Some(id)
|
||||||
|
}
|
||||||
|
crate::action::BackendClipInstanceId::Audio(id) => {
|
||||||
|
self.backend_audio_instance_id = Some(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rollback_backend(
|
fn rollback_backend(
|
||||||
|
|
@ -509,7 +449,9 @@ impl Action for SplitClipInstanceAction {
|
||||||
if let Some(instance) = al.clip_instances.iter().find(|ci| ci.id == self.instance_id) {
|
if let Some(instance) = al.clip_instances.iter().find(|ci| ci.id == self.instance_id) {
|
||||||
if let Some(clip) = document.get_audio_clip(&instance.clip_id) {
|
if let Some(clip) = document.get_audio_clip(&instance.clip_id) {
|
||||||
let orig_internal_start = instance.trim_start;
|
let orig_internal_start = instance.trim_start;
|
||||||
let orig_internal_end = self.original_trim_end.unwrap_or(clip.content_duration().native());
|
let orig_internal_end = self
|
||||||
|
.original_trim_end
|
||||||
|
.unwrap_or(ContentTime(clip.content_duration().native()));
|
||||||
|
|
||||||
// Restore based on clip type
|
// Restore based on clip type
|
||||||
use crate::clip::ResolvedContent;
|
use crate::clip::ResolvedContent;
|
||||||
|
|
@ -564,8 +506,8 @@ mod tests {
|
||||||
// Create a clip instance at timeline 0, with trim 0-10 (10 seconds)
|
// Create a clip instance at timeline 0, with trim 0-10 (10 seconds)
|
||||||
let mut clip_instance = ClipInstance::new(clip_id);
|
let mut clip_instance = ClipInstance::new(clip_id);
|
||||||
clip_instance.timeline_start = daw_backend::Beats::ZERO;
|
clip_instance.timeline_start = daw_backend::Beats::ZERO;
|
||||||
clip_instance.trim_start = 0.0;
|
clip_instance.trim_start = ContentTime::ZERO;
|
||||||
clip_instance.trim_end = Some(10.0);
|
clip_instance.trim_end = Some(ContentTime(10.0));
|
||||||
let instance_id = clip_instance.id;
|
let instance_id = clip_instance.id;
|
||||||
vector_layer.clip_instances.push(clip_instance);
|
vector_layer.clip_instances.push(clip_instance);
|
||||||
|
|
||||||
|
|
@ -606,8 +548,8 @@ mod tests {
|
||||||
let mut audio_layer = crate::layer::AudioLayer::new("Layer 1");
|
let mut audio_layer = crate::layer::AudioLayer::new("Layer 1");
|
||||||
let mut instance = ClipInstance::new(clip_id);
|
let mut instance = ClipInstance::new(clip_id);
|
||||||
instance.timeline_start = daw_backend::Beats::ZERO;
|
instance.timeline_start = daw_backend::Beats::ZERO;
|
||||||
instance.trim_start = 0.0;
|
instance.trim_start = ContentTime::ZERO;
|
||||||
instance.trim_end = Some(8.0); // beats
|
instance.trim_end = Some(ContentTime(8.0)); // beats
|
||||||
let instance_id = instance.id;
|
let instance_id = instance.id;
|
||||||
audio_layer.clip_instances.push(instance);
|
audio_layer.clip_instances.push(instance);
|
||||||
let layer_id = document.root.add_child(AnyLayer::Audio(audio_layer));
|
let layer_id = document.root.add_child(AnyLayer::Audio(audio_layer));
|
||||||
|
|
@ -620,7 +562,7 @@ mod tests {
|
||||||
let right = al.clip_instances.iter().find(|ci| ci.id == new_id).unwrap();
|
let right = al.clip_instances.iter().find(|ci| ci.id == new_id).unwrap();
|
||||||
let left = al.clip_instances.iter().find(|ci| ci.id == instance_id).unwrap();
|
let left = al.clip_instances.iter().find(|ci| ci.id == instance_id).unwrap();
|
||||||
|
|
||||||
assert_eq!(right.trim_start, 4.0, "right half must start 4 BEATS into the content");
|
assert_eq!(right.trim_start, ContentTime(4.0), "right half must start 4 BEATS into the content");
|
||||||
assert_eq!(left.trim_end, Some(4.0), "left half must end 4 BEATS into the content");
|
assert_eq!(left.trim_end, Some(ContentTime(4.0)), "left half must end 4 BEATS into the content");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ use crate::action::Action;
|
||||||
use crate::clip::ClipInstance;
|
use crate::clip::ClipInstance;
|
||||||
use crate::document::Document;
|
use crate::document::Document;
|
||||||
use crate::layer::AnyLayer;
|
use crate::layer::AnyLayer;
|
||||||
use daw_backend::{Beats, Seconds};
|
use daw_backend::{Beats, ContentTime, Seconds};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
|
@ -32,15 +32,57 @@ pub struct TrimClipInstancesAction {
|
||||||
pub struct TrimData {
|
pub struct TrimData {
|
||||||
/// For TrimLeft: trim_start value
|
/// For TrimLeft: trim_start value
|
||||||
/// For TrimRight: trim_end value (Option because it can be None)
|
/// For TrimRight: trim_end value (Option because it can be None)
|
||||||
pub trim_value: Option<f64>,
|
///
|
||||||
|
/// A content time — measured in the clip's own domain (seconds for audio/video/vector, beats
|
||||||
|
/// for MIDI), so it must be resolved against the clip before meeting a timeline position.
|
||||||
|
pub trim_value: Option<ContentTime>,
|
||||||
/// For TrimLeft: timeline_start value (where the clip appears on timeline, beats)
|
/// For TrimLeft: timeline_start value (where the clip appears on timeline, beats)
|
||||||
/// For TrimRight: unused (None)
|
/// For TrimRight: unused (None)
|
||||||
pub timeline_start: Option<Beats>,
|
pub timeline_start: Option<Beats>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A wall-clock gap on the timeline, expressed in a clip's content domain.
|
||||||
|
///
|
||||||
|
/// Trim validation clamps how far a clip may be dragged against the empty space next to it, and that
|
||||||
|
/// space is measured on the timeline (seconds) while the trim lives in the clip's content domain. For
|
||||||
|
/// wall-clock content they're the same number; for MIDI (beats content) the gap has to be converted
|
||||||
|
/// at the clip's position, or a seconds gap silently clamps a beats trim.
|
||||||
|
fn gap_to_content(
|
||||||
|
gap: Seconds,
|
||||||
|
clip_content: crate::clip::ClipDuration,
|
||||||
|
timeline_start: Beats,
|
||||||
|
tempo_map: &crate::tempo_map::TempoMap,
|
||||||
|
) -> ContentTime {
|
||||||
|
match clip_content {
|
||||||
|
crate::clip::ClipDuration::Seconds(_) => ContentTime(gap.seconds_to_f64()),
|
||||||
|
crate::clip::ClipDuration::Beats(_) => {
|
||||||
|
let beats = tempo_map
|
||||||
|
.seconds_to_beats(tempo_map.beats_to_seconds(timeline_start) + gap)
|
||||||
|
- timeline_start;
|
||||||
|
ContentTime(beats.beats_to_f64())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The inverse: a content-domain span as wall-clock seconds at the clip's position.
|
||||||
|
fn content_to_secs(
|
||||||
|
span: ContentTime,
|
||||||
|
clip_content: crate::clip::ClipDuration,
|
||||||
|
timeline_start: Beats,
|
||||||
|
tempo_map: &crate::tempo_map::TempoMap,
|
||||||
|
) -> Seconds {
|
||||||
|
match clip_content {
|
||||||
|
crate::clip::ClipDuration::Seconds(_) => Seconds(span.raw()),
|
||||||
|
crate::clip::ClipDuration::Beats(_) => {
|
||||||
|
tempo_map.beats_to_seconds(timeline_start + Beats(span.raw()))
|
||||||
|
- tempo_map.beats_to_seconds(timeline_start)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl TrimData {
|
impl TrimData {
|
||||||
/// Create TrimData for left trim
|
/// Create TrimData for left trim
|
||||||
pub fn left(trim_start: f64, timeline_start: Beats) -> Self {
|
pub fn left(trim_start: ContentTime, timeline_start: Beats) -> Self {
|
||||||
Self {
|
Self {
|
||||||
trim_value: Some(trim_start),
|
trim_value: Some(trim_start),
|
||||||
timeline_start: Some(timeline_start),
|
timeline_start: Some(timeline_start),
|
||||||
|
|
@ -48,7 +90,7 @@ impl TrimData {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create TrimData for right trim
|
/// Create TrimData for right trim
|
||||||
pub fn right(trim_end: Option<f64>) -> Self {
|
pub fn right(trim_end: Option<ContentTime>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
trim_value: trim_end,
|
trim_value: trim_end,
|
||||||
timeline_start: None,
|
timeline_start: None,
|
||||||
|
|
@ -192,7 +234,8 @@ impl Action for TrimClipInstancesAction {
|
||||||
.find(|ci| &ci.id == instance_id)
|
.find(|ci| &ci.id == instance_id)
|
||||||
.ok_or_else(|| format!("Instance {} not found", instance_id))?;
|
.ok_or_else(|| format!("Instance {} not found", instance_id))?;
|
||||||
|
|
||||||
let clip_duration = document.get_clip_duration(&instance.clip_id)
|
// The clip's content duration in ITS OWN domain, so trims resolve correctly for MIDI.
|
||||||
|
let clip_content = document.clip_trim_duration(&instance.clip_id)
|
||||||
.ok_or_else(|| format!("Clip {} not found", instance.clip_id))?;
|
.ok_or_else(|| format!("Clip {} not found", instance.clip_id))?;
|
||||||
|
|
||||||
let mut clamped_new = new.clone();
|
let mut clamped_new = new.clone();
|
||||||
|
|
@ -204,23 +247,34 @@ impl Action for TrimClipInstancesAction {
|
||||||
{
|
{
|
||||||
// If extending to the left (new_trim < old_trim)
|
// If extending to the left (new_trim < old_trim)
|
||||||
if should_validate && new_trim < old_trim {
|
if should_validate && new_trim < old_trim {
|
||||||
// Max leftward extension as content seconds (the gap's wall-clock span).
|
|
||||||
let max_extend_secs = document.find_max_trim_extend_left(
|
|
||||||
layer_id,
|
|
||||||
instance_id,
|
|
||||||
instance.timeline_start,
|
|
||||||
).seconds_to_f64();
|
|
||||||
|
|
||||||
// Calculate how much we want to extend (content seconds)
|
|
||||||
let desired_extend = old_trim - new_trim;
|
|
||||||
|
|
||||||
// Clamp to max allowed
|
|
||||||
let actual_extend = desired_extend.min(max_extend_secs);
|
|
||||||
let clamped_trim_start = old_trim - actual_extend;
|
|
||||||
// Move the timeline left by the same wall-clock seconds.
|
|
||||||
let tempo_map = document.tempo_map();
|
let tempo_map = document.tempo_map();
|
||||||
|
|
||||||
|
// Max leftward extension: the gap's wall-clock span, converted into
|
||||||
|
// the clip's content domain so it can clamp a content-domain trim.
|
||||||
|
let max_extend = gap_to_content(
|
||||||
|
document.find_max_trim_extend_left(
|
||||||
|
layer_id,
|
||||||
|
instance_id,
|
||||||
|
instance.timeline_start,
|
||||||
|
),
|
||||||
|
clip_content,
|
||||||
|
instance.timeline_start,
|
||||||
|
tempo_map,
|
||||||
|
);
|
||||||
|
|
||||||
|
let desired_extend = old_trim - new_trim;
|
||||||
|
let actual_extend = desired_extend.min(max_extend);
|
||||||
|
let clamped_trim_start = old_trim - actual_extend;
|
||||||
|
|
||||||
|
// Move the timeline left by the same span, as wall-clock seconds.
|
||||||
|
let shift = content_to_secs(
|
||||||
|
actual_extend,
|
||||||
|
clip_content,
|
||||||
|
instance.timeline_start,
|
||||||
|
tempo_map,
|
||||||
|
);
|
||||||
let clamped_timeline_start = tempo_map
|
let clamped_timeline_start = tempo_map
|
||||||
.seconds_to_beats(tempo_map.beats_to_seconds(old_timeline) - Seconds(actual_extend))
|
.seconds_to_beats(tempo_map.beats_to_seconds(old_timeline) - shift)
|
||||||
.max(Beats::ZERO);
|
.max(Beats::ZERO);
|
||||||
|
|
||||||
clamped_new = TrimData::left(clamped_trim_start, clamped_timeline_start);
|
clamped_new = TrimData::left(clamped_trim_start, clamped_timeline_start);
|
||||||
|
|
@ -228,36 +282,39 @@ impl Action for TrimClipInstancesAction {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
TrimType::TrimRight => {
|
TrimType::TrimRight => {
|
||||||
let old_trim_end = old.trim_value.unwrap_or(clip_duration.seconds_to_f64());
|
let content_end = ContentTime(clip_content.native());
|
||||||
let new_trim_end = new.trim_value.unwrap_or(clip_duration.seconds_to_f64());
|
let old_trim_end = old.trim_value.unwrap_or(content_end);
|
||||||
|
let new_trim_end = new.trim_value.unwrap_or(content_end);
|
||||||
|
|
||||||
// If extending to the right (new_trim_end > old_trim_end)
|
// If extending to the right (new_trim_end > old_trim_end)
|
||||||
if should_validate && new_trim_end > old_trim_end {
|
if should_validate && new_trim_end > old_trim_end {
|
||||||
let tempo_map = document.tempo_map();
|
let tempo_map = document.tempo_map();
|
||||||
// Current effective duration in beats (content seconds
|
|
||||||
// converted to beats at the clip's start).
|
|
||||||
let content_secs = Seconds(old_trim_end - instance.trim_start);
|
|
||||||
let current_effective_duration = tempo_map.seconds_to_beats(
|
|
||||||
tempo_map.beats_to_seconds(instance.timeline_start) + content_secs,
|
|
||||||
) - instance.timeline_start;
|
|
||||||
|
|
||||||
// Max rightward extension as content seconds (the gap's wall-clock span).
|
// How long the clip currently occupies the timeline, in beats. Resolved
|
||||||
let max_extend_secs = document.find_max_trim_extend_right(
|
// in the clip's own domain, so a MIDI clip's beats content isn't run
|
||||||
layer_id,
|
// through the seconds→beats conversion a second time.
|
||||||
instance_id,
|
let current_effective_duration = instance
|
||||||
|
.effective_duration_beats(clip_content, tempo_map);
|
||||||
|
|
||||||
|
// Max rightward extension: the gap's wall-clock span, in content domain.
|
||||||
|
let max_extend = gap_to_content(
|
||||||
|
document.find_max_trim_extend_right(
|
||||||
|
layer_id,
|
||||||
|
instance_id,
|
||||||
|
instance.timeline_start,
|
||||||
|
current_effective_duration,
|
||||||
|
),
|
||||||
|
clip_content,
|
||||||
instance.timeline_start,
|
instance.timeline_start,
|
||||||
current_effective_duration,
|
tempo_map,
|
||||||
).seconds_to_f64();
|
);
|
||||||
|
|
||||||
// Calculate how much we want to extend (content seconds)
|
|
||||||
let desired_extend = new_trim_end - old_trim_end;
|
let desired_extend = new_trim_end - old_trim_end;
|
||||||
|
let actual_extend = desired_extend.min(max_extend);
|
||||||
// Clamp to max allowed
|
|
||||||
let actual_extend = desired_extend.min(max_extend_secs);
|
|
||||||
let clamped_trim_end = old_trim_end + actual_extend;
|
let clamped_trim_end = old_trim_end + actual_extend;
|
||||||
|
|
||||||
// Don't exceed clip duration
|
// Don't exceed the clip's content.
|
||||||
let final_trim_end = clamped_trim_end.min(clip_duration.seconds_to_f64());
|
let final_trim_end = clamped_trim_end.min(content_end);
|
||||||
|
|
||||||
clamped_new = TrimData::right(Some(final_trim_end));
|
clamped_new = TrimData::right(Some(final_trim_end));
|
||||||
}
|
}
|
||||||
|
|
@ -387,8 +444,9 @@ impl Action for TrimClipInstancesAction {
|
||||||
if let Some(&metatrack_id) = backend.layer_to_track_map.get(&instance.clip_id) {
|
if let Some(&metatrack_id) = backend.layer_to_track_map.get(&instance.clip_id) {
|
||||||
// Instance already has new values after execute()
|
// Instance already has new values after execute()
|
||||||
controller.set_offset(metatrack_id, document.tempo_map().beats_to_seconds(instance.timeline_start));
|
controller.set_offset(metatrack_id, document.tempo_map().beats_to_seconds(instance.timeline_start));
|
||||||
controller.set_trim_start(metatrack_id, daw_backend::Seconds(instance.trim_start));
|
// A vector clip's content is wall-clock, so its content times ARE seconds.
|
||||||
controller.set_trim_end(metatrack_id, instance.trim_end.map(daw_backend::Seconds));
|
controller.set_trim_start(metatrack_id, daw_backend::Seconds(instance.trim_start.raw()));
|
||||||
|
controller.set_trim_end(metatrack_id, instance.trim_end.map(|t| daw_backend::Seconds(t.raw())));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -424,7 +482,9 @@ impl Action for TrimClipInstancesAction {
|
||||||
// Calculate new internal_start and internal_end for backend
|
// Calculate new internal_start and internal_end for backend
|
||||||
// Note: instance already has the new trim values after execute()
|
// Note: instance already has the new trim values after execute()
|
||||||
let internal_start = instance.trim_start;
|
let internal_start = instance.trim_start;
|
||||||
let internal_end = instance.trim_end.unwrap_or(clip.content_duration().native());
|
let internal_end = instance
|
||||||
|
.trim_end
|
||||||
|
.unwrap_or(ContentTime(clip.content_duration().native()));
|
||||||
|
|
||||||
// Handle trim based on clip type
|
// Handle trim based on clip type
|
||||||
match &clip.resolve(instance.active_take) {
|
match &clip.resolve(instance.active_take) {
|
||||||
|
|
@ -477,8 +537,9 @@ impl Action for TrimClipInstancesAction {
|
||||||
if let Some(&metatrack_id) = backend.layer_to_track_map.get(&instance.clip_id) {
|
if let Some(&metatrack_id) = backend.layer_to_track_map.get(&instance.clip_id) {
|
||||||
// Instance already has old values after rollback()
|
// Instance already has old values after rollback()
|
||||||
controller.set_offset(metatrack_id, document.tempo_map().beats_to_seconds(instance.timeline_start));
|
controller.set_offset(metatrack_id, document.tempo_map().beats_to_seconds(instance.timeline_start));
|
||||||
controller.set_trim_start(metatrack_id, daw_backend::Seconds(instance.trim_start));
|
// A vector clip's content is wall-clock, so its content times ARE seconds.
|
||||||
controller.set_trim_end(metatrack_id, instance.trim_end.map(daw_backend::Seconds));
|
controller.set_trim_start(metatrack_id, daw_backend::Seconds(instance.trim_start.raw()));
|
||||||
|
controller.set_trim_end(metatrack_id, instance.trim_end.map(|t| daw_backend::Seconds(t.raw())));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -512,13 +573,14 @@ impl Action for TrimClipInstancesAction {
|
||||||
.ok_or_else(|| format!("Audio clip {} not found", instance.clip_id))?;
|
.ok_or_else(|| format!("Audio clip {} not found", instance.clip_id))?;
|
||||||
|
|
||||||
// Calculate old internal_start and internal_end for backend
|
// Calculate old internal_start and internal_end for backend
|
||||||
|
let content_end = ContentTime(clip.content_duration().native());
|
||||||
let internal_start = match trim_type {
|
let internal_start = match trim_type {
|
||||||
TrimType::TrimLeft => old.trim_value.unwrap_or(0.0),
|
TrimType::TrimLeft => old.trim_value.unwrap_or(ContentTime::ZERO),
|
||||||
TrimType::TrimRight => instance.trim_start, // trim_start wasn't changed
|
TrimType::TrimRight => instance.trim_start, // trim_start wasn't changed
|
||||||
};
|
};
|
||||||
let internal_end = match trim_type {
|
let internal_end = match trim_type {
|
||||||
TrimType::TrimLeft => instance.trim_end.unwrap_or(clip.content_duration().native()), // trim_end wasn't changed
|
TrimType::TrimLeft => instance.trim_end.unwrap_or(content_end), // trim_end wasn't changed
|
||||||
TrimType::TrimRight => old.trim_value.unwrap_or(clip.content_duration().native()),
|
TrimType::TrimRight => old.trim_value.unwrap_or(content_end),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle trim based on clip type
|
// Handle trim based on clip type
|
||||||
|
|
@ -569,7 +631,7 @@ mod tests {
|
||||||
|
|
||||||
let mut clip_instance = ClipInstance::new(clip_id);
|
let mut clip_instance = ClipInstance::new(clip_id);
|
||||||
clip_instance.timeline_start = Beats::ZERO;
|
clip_instance.timeline_start = Beats::ZERO;
|
||||||
clip_instance.trim_start = 0.0;
|
clip_instance.trim_start = ContentTime::ZERO;
|
||||||
let instance_id = clip_instance.id;
|
let instance_id = clip_instance.id;
|
||||||
vector_layer.clip_instances.push(clip_instance);
|
vector_layer.clip_instances.push(clip_instance);
|
||||||
|
|
||||||
|
|
@ -582,8 +644,8 @@ mod tests {
|
||||||
vec![(
|
vec![(
|
||||||
instance_id,
|
instance_id,
|
||||||
TrimType::TrimLeft,
|
TrimType::TrimLeft,
|
||||||
TrimData::left(0.0, Beats::ZERO),
|
TrimData::left(ContentTime::ZERO, Beats::ZERO),
|
||||||
TrimData::left(2.0, Beats(2.0)),
|
TrimData::left(ContentTime(2.0), Beats(2.0)),
|
||||||
)],
|
)],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -599,7 +661,7 @@ mod tests {
|
||||||
.iter()
|
.iter()
|
||||||
.find(|ci| ci.id == instance_id)
|
.find(|ci| ci.id == instance_id)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(instance.trim_start, 2.0);
|
assert_eq!(instance.trim_start, ContentTime(2.0));
|
||||||
assert_eq!(instance.timeline_start, Beats(2.0));
|
assert_eq!(instance.timeline_start, Beats(2.0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -613,7 +675,7 @@ mod tests {
|
||||||
.iter()
|
.iter()
|
||||||
.find(|ci| ci.id == instance_id)
|
.find(|ci| ci.id == instance_id)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(instance.trim_start, 0.0);
|
assert_eq!(instance.trim_start, ContentTime::ZERO);
|
||||||
assert_eq!(instance.timeline_start, Beats::ZERO);
|
assert_eq!(instance.timeline_start, Beats::ZERO);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -644,7 +706,7 @@ mod tests {
|
||||||
instance_id,
|
instance_id,
|
||||||
TrimType::TrimRight,
|
TrimType::TrimRight,
|
||||||
TrimData::right(None),
|
TrimData::right(None),
|
||||||
TrimData::right(Some(8.0)),
|
TrimData::right(Some(ContentTime(8.0))),
|
||||||
)],
|
)],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -660,7 +722,7 @@ mod tests {
|
||||||
.iter()
|
.iter()
|
||||||
.find(|ci| ci.id == instance_id)
|
.find(|ci| ci.id == instance_id)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(instance.trim_end, Some(8.0));
|
assert_eq!(instance.trim_end, Some(ContentTime(8.0)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rollback
|
// Rollback
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
use crate::layer::AnyLayer;
|
use crate::layer::AnyLayer;
|
||||||
use crate::layer_tree::LayerTree;
|
use crate::layer_tree::LayerTree;
|
||||||
use crate::object::Transform;
|
use crate::object::Transform;
|
||||||
use daw_backend::{Beats, Seconds};
|
use daw_backend::{Beats, ContentTime, Seconds};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
@ -130,10 +130,14 @@ impl VectorClip {
|
||||||
let end_beats: Beats = if let Some(td_beats) = ci.timeline_duration {
|
let end_beats: Beats = if let Some(td_beats) = ci.timeline_duration {
|
||||||
ci.timeline_start + td_beats
|
ci.timeline_start + td_beats
|
||||||
} else if let Some(te) = ci.trim_end {
|
} else if let Some(te) = ci.trim_end {
|
||||||
let secs = (te - ci.trim_start).max(0.0);
|
// `clip_duration_fn` hands back seconds, so this whole path treats nested
|
||||||
|
// content as wall-clock. That's right for the vector/video/audio clips a vector
|
||||||
|
// clip actually nests; a nested MIDI clip (beats content) would need resolving
|
||||||
|
// against its clip, which this callback can't do. Pre-existing limitation.
|
||||||
|
let secs = (te - ci.trim_start).raw().max(0.0);
|
||||||
tempo_map.seconds_to_beats(tempo_map.beats_to_seconds(ci.timeline_start) + Seconds(secs))
|
tempo_map.seconds_to_beats(tempo_map.beats_to_seconds(ci.timeline_start) + Seconds(secs))
|
||||||
} else if let Some(clip_dur_secs) = clip_duration_fn(&ci.clip_id) {
|
} else if let Some(clip_dur_secs) = clip_duration_fn(&ci.clip_id) {
|
||||||
let secs = (clip_dur_secs - ci.trim_start).max(0.0);
|
let secs = (clip_dur_secs - ci.trim_start.raw()).max(0.0);
|
||||||
tempo_map.seconds_to_beats(tempo_map.beats_to_seconds(ci.timeline_start) + Seconds(secs))
|
tempo_map.seconds_to_beats(tempo_map.beats_to_seconds(ci.timeline_start) + Seconds(secs))
|
||||||
} else {
|
} else {
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -201,7 +205,9 @@ impl VectorClip {
|
||||||
// Convert parent clip time (seconds) to nested clip local time (seconds).
|
// Convert parent clip time (seconds) to nested clip local time (seconds).
|
||||||
// timeline_start is in beats; convert to seconds using document BPM.
|
// timeline_start is in beats; convert to seconds using document BPM.
|
||||||
let start_secs = document.tempo_map().beats_to_seconds(clip_instance.timeline_start).seconds_to_f64();
|
let start_secs = document.tempo_map().beats_to_seconds(clip_instance.timeline_start).seconds_to_f64();
|
||||||
let nested_clip_time = ((clip_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start;
|
// Nested clips here are vector clips, whose content is wall-clock seconds.
|
||||||
|
let nested_clip_time =
|
||||||
|
((clip_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start.raw();
|
||||||
|
|
||||||
// Look up the nested clip definition
|
// Look up the nested clip definition
|
||||||
let nested_bounds = if let Some(nested_clip) = document.get_vector_clip(&clip_instance.clip_id) {
|
let nested_bounds = if let Some(nested_clip) = document.get_vector_clip(&clip_instance.clip_id) {
|
||||||
|
|
@ -535,6 +541,17 @@ impl ClipDuration {
|
||||||
ClipDuration::Beats(b) => b.beats_to_f64(),
|
ClipDuration::Beats(b) => b.beats_to_f64(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Tag a [`ContentTime`] with *this* duration's domain.
|
||||||
|
///
|
||||||
|
/// Handy when you already hold a clip's content duration (so you know the domain) and need to
|
||||||
|
/// resolve one of its trim bounds, without going back to the clip.
|
||||||
|
pub fn same_domain(self, t: ContentTime) -> ClipDuration {
|
||||||
|
match self {
|
||||||
|
ClipDuration::Seconds(_) => ClipDuration::Seconds(Seconds(t.raw())),
|
||||||
|
ClipDuration::Beats(_) => ClipDuration::Beats(Beats(t.raw())),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Audio clip
|
/// Audio clip
|
||||||
|
|
@ -739,21 +756,33 @@ impl AudioClip {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tag a pair of raw trim bounds with this clip's content domain, ready for the backend.
|
/// Resolve a content time against this clip's domain.
|
||||||
///
|
///
|
||||||
/// `ClipInstance::trim_start`/`trim_end` are bare `f64`s whose unit depends on the clip —
|
/// This is the ONLY sanctioned way to turn a [`ContentTime`] into a real duration — the type has
|
||||||
/// SECONDS for sampled audio, BEATS for MIDI. Building the [`TrimRange`] from the clip means a
|
/// no `.to_seconds()` of its own precisely so that the clip, which is the one thing that knows
|
||||||
/// caller can't reach for the wrong variant: the clip is the one thing that knows.
|
/// whether its content is measured in seconds or beats, has to be consulted.
|
||||||
pub fn trim_range(&self, start: f64, end: f64) -> daw_backend::command::TrimRange {
|
pub fn resolve_content_time(&self, t: ContentTime) -> ClipDuration {
|
||||||
|
if self.is_midi_domain() {
|
||||||
|
ClipDuration::Beats(Beats(t.raw()))
|
||||||
|
} else {
|
||||||
|
ClipDuration::Seconds(Seconds(t.raw()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tag a pair of trim bounds with this clip's content domain, ready for the backend.
|
||||||
|
///
|
||||||
|
/// Building the [`TrimRange`] from the clip means a caller can't reach for the wrong variant:
|
||||||
|
/// the clip is the one thing that knows the domain.
|
||||||
|
pub fn trim_range(&self, start: ContentTime, end: ContentTime) -> daw_backend::command::TrimRange {
|
||||||
if self.is_midi_domain() {
|
if self.is_midi_domain() {
|
||||||
daw_backend::command::TrimRange::Beats {
|
daw_backend::command::TrimRange::Beats {
|
||||||
start: Beats(start),
|
start: Beats(start.raw()),
|
||||||
end: Beats(end),
|
end: Beats(end.raw()),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
daw_backend::command::TrimRange::Seconds {
|
daw_backend::command::TrimRange::Seconds {
|
||||||
start: Seconds(start),
|
start: Seconds(start.raw()),
|
||||||
end: Seconds(end),
|
end: Seconds(end.raw()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -885,16 +914,17 @@ pub struct ClipInstance {
|
||||||
/// Default: None (use trimmed clip duration, no looping)
|
/// Default: None (use trimmed clip duration, no looping)
|
||||||
pub timeline_duration: Option<Beats>,
|
pub timeline_duration: Option<Beats>,
|
||||||
|
|
||||||
/// Trim start: offset into the clip's internal content, in **seconds**.
|
/// Trim start: offset into the clip's internal content.
|
||||||
/// - For audio: byte-offset into the audio file
|
///
|
||||||
/// - For video: seek position in the video file
|
/// A [`ContentTime`] — measured in the CLIP's content domain, which is seconds for sampled
|
||||||
/// - For vector: time offset into the animation
|
/// audio/video/vector but BEATS for MIDI. Resolve it against the clip
|
||||||
|
/// ([`Document::resolve_content_time`]) before combining it with anything on the timeline.
|
||||||
/// Default: 0.0
|
/// Default: 0.0
|
||||||
pub trim_start: f64,
|
pub trim_start: ContentTime,
|
||||||
|
|
||||||
/// Trim end: offset into the clip's internal content, in **seconds**.
|
/// Trim end: offset into the clip's internal content. See [`Self::trim_start`].
|
||||||
/// Default: None (use full clip duration)
|
/// Default: None (use full clip duration)
|
||||||
pub trim_end: Option<f64>,
|
pub trim_end: Option<ContentTime>,
|
||||||
|
|
||||||
/// Playback speed multiplier
|
/// Playback speed multiplier
|
||||||
/// 1.0 = normal speed, 0.5 = half speed, 2.0 = double speed
|
/// 1.0 = normal speed, 0.5 = half speed, 2.0 = double speed
|
||||||
|
|
@ -973,7 +1003,7 @@ impl ClipInstance {
|
||||||
name: None,
|
name: None,
|
||||||
timeline_start: Beats::ZERO,
|
timeline_start: Beats::ZERO,
|
||||||
timeline_duration: None,
|
timeline_duration: None,
|
||||||
trim_start: 0.0,
|
trim_start: ContentTime::ZERO,
|
||||||
trim_end: None,
|
trim_end: None,
|
||||||
playback_speed: 1.0,
|
playback_speed: 1.0,
|
||||||
gain: 1.0,
|
gain: 1.0,
|
||||||
|
|
@ -992,7 +1022,7 @@ impl ClipInstance {
|
||||||
name: None,
|
name: None,
|
||||||
timeline_start: Beats::ZERO,
|
timeline_start: Beats::ZERO,
|
||||||
timeline_duration: None,
|
timeline_duration: None,
|
||||||
trim_start: 0.0,
|
trim_start: ContentTime::ZERO,
|
||||||
trim_end: None,
|
trim_end: None,
|
||||||
playback_speed: 1.0,
|
playback_speed: 1.0,
|
||||||
gain: 1.0,
|
gain: 1.0,
|
||||||
|
|
@ -1033,7 +1063,7 @@ impl ClipInstance {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set trimming (start and end time within the clip's internal content)
|
/// Set trimming (start and end time within the clip's internal content)
|
||||||
pub fn with_trimming(mut self, trim_start: f64, trim_end: Option<f64>) -> Self {
|
pub fn with_trimming(mut self, trim_start: ContentTime, trim_end: Option<ContentTime>) -> Self {
|
||||||
self.trim_start = trim_start;
|
self.trim_start = trim_start;
|
||||||
self.trim_end = trim_end;
|
self.trim_end = trim_end;
|
||||||
self
|
self
|
||||||
|
|
@ -1057,24 +1087,40 @@ impl ClipInstance {
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Content window size in seconds: `trim_end - trim_start`.
|
/// Content window (`trim_end - trim_start`) in the clip's own content domain.
|
||||||
/// Used for internal looping calculations.
|
/// Used for internal looping calculations.
|
||||||
pub fn content_window_secs(&self, clip_duration_secs: Seconds) -> Seconds {
|
pub fn content_window(&self, clip_content: ClipDuration) -> ClipDuration {
|
||||||
let end = self.trim_end.unwrap_or(clip_duration_secs.seconds_to_f64());
|
let end = self.trim_end.map_or(clip_content.native(), |t| t.raw());
|
||||||
Seconds((end - self.trim_start).max(0.0))
|
let window = (end - self.trim_start.raw()).max(0.0);
|
||||||
|
match clip_content {
|
||||||
|
ClipDuration::Beats(_) => ClipDuration::Beats(Beats(window)),
|
||||||
|
ClipDuration::Seconds(_) => ClipDuration::Seconds(Seconds(window)),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How long this instance appears on the timeline, in **beats**.
|
/// How long this instance appears on the timeline, in **beats**.
|
||||||
///
|
///
|
||||||
/// If `timeline_duration` is set, returns that (enabling content looping).
|
/// If `timeline_duration` is set, returns that (enabling content looping). Otherwise the clip
|
||||||
/// Otherwise converts the content window from seconds to beats using the tempo map.
|
/// occupies its content window — converted to beats *in the clip's own domain*:
|
||||||
pub fn effective_duration_beats(&self, clip_duration_secs: Seconds, tempo_map: &crate::tempo_map::TempoMap) -> Beats {
|
///
|
||||||
|
/// - MIDI content is already beats and is tempo-invariant, so it carries over directly.
|
||||||
|
/// - Wall-clock content (audio/video/vector) is a seconds span, so it converts at the clip's
|
||||||
|
/// position on the timeline.
|
||||||
|
///
|
||||||
|
/// Taking a `ClipDuration` rather than a bare `Seconds` is what keeps those apart: this used to
|
||||||
|
/// take seconds and subtract `trim_start` from it, which for a TRIMMED MIDI clip subtracted a
|
||||||
|
/// beats offset from a seconds duration and got the clip's length wrong.
|
||||||
|
pub fn effective_duration_beats(&self, clip_content: ClipDuration, tempo_map: &crate::tempo_map::TempoMap) -> Beats {
|
||||||
if let Some(td) = self.timeline_duration {
|
if let Some(td) = self.timeline_duration {
|
||||||
return td;
|
return td;
|
||||||
}
|
}
|
||||||
let window = self.content_window_secs(clip_duration_secs);
|
match self.content_window(clip_content) {
|
||||||
let start_secs = tempo_map.beats_to_seconds(self.timeline_start);
|
ClipDuration::Beats(b) => b,
|
||||||
tempo_map.seconds_to_beats(start_secs + window) - self.timeline_start
|
ClipDuration::Seconds(s) => {
|
||||||
|
let start_secs = tempo_map.beats_to_seconds(self.timeline_start);
|
||||||
|
tempo_map.seconds_to_beats(start_secs + s) - self.timeline_start
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Left edge of the clip's visual extent on the timeline, in **beats**.
|
/// Left edge of the clip's visual extent on the timeline, in **beats**.
|
||||||
|
|
@ -1083,27 +1129,32 @@ impl ClipInstance {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Total visual duration (loop_before + effective_duration), in **beats**.
|
/// Total visual duration (loop_before + effective_duration), in **beats**.
|
||||||
pub fn total_duration(&self, clip_duration_secs: Seconds, tempo_map: &crate::tempo_map::TempoMap) -> Beats {
|
pub fn total_duration(&self, clip_content: ClipDuration, tempo_map: &crate::tempo_map::TempoMap) -> Beats {
|
||||||
self.loop_before.unwrap_or(Beats::ZERO) + self.effective_duration_beats(clip_duration_secs, tempo_map)
|
self.loop_before.unwrap_or(Beats::ZERO) + self.effective_duration_beats(clip_content, tempo_map)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Map a playback time (in **seconds**) to clip-local content time (in **seconds**).
|
/// Map a playback time (in **seconds**) to clip-local content time (in **seconds**).
|
||||||
///
|
///
|
||||||
/// Returns `None` if the clip instance is not active at `time_secs`.
|
/// The trim bounds are resolved through `clip_content`'s domain first, so a MIDI clip's beats
|
||||||
pub fn remap_time_secs(&self, time: Seconds, clip_duration_secs: Seconds, tempo_map: &crate::tempo_map::TempoMap) -> Option<Seconds> {
|
/// trims are converted rather than read as seconds. Callers are the wall-clock consumers (video
|
||||||
|
/// seek, vector/raster rendering), which want seconds regardless of how the clip stores content.
|
||||||
|
///
|
||||||
|
/// Returns `None` if the clip instance is not active at `time`.
|
||||||
|
pub fn remap_time_secs(&self, time: Seconds, clip_content: ClipDuration, tempo_map: &crate::tempo_map::TempoMap) -> Option<Seconds> {
|
||||||
let start_secs = tempo_map.beats_to_seconds(self.timeline_start);
|
let start_secs = tempo_map.beats_to_seconds(self.timeline_start);
|
||||||
let dur_beats = self.effective_duration_beats(clip_duration_secs, tempo_map);
|
let dur_beats = self.effective_duration_beats(clip_content, tempo_map);
|
||||||
let end_secs = tempo_map.beats_to_seconds(self.timeline_start + dur_beats);
|
let end_secs = tempo_map.beats_to_seconds(self.timeline_start + dur_beats);
|
||||||
|
|
||||||
if time < start_secs || time >= end_secs {
|
if time < start_secs || time >= end_secs {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let trim_start_secs = clip_content.same_domain(self.trim_start).to_seconds(tempo_map);
|
||||||
let content_time = (time - start_secs) * self.playback_speed;
|
let content_time = (time - start_secs) * self.playback_speed;
|
||||||
let content_window = self.content_window_secs(clip_duration_secs);
|
let content_window = self.content_window(clip_content).to_seconds(tempo_map);
|
||||||
|
|
||||||
if content_window == Seconds::ZERO {
|
if content_window == Seconds::ZERO {
|
||||||
return Some(Seconds(self.trim_start));
|
return Some(trim_start_secs);
|
||||||
}
|
}
|
||||||
|
|
||||||
let looped = if content_time > content_window {
|
let looped = if content_time > content_window {
|
||||||
|
|
@ -1112,19 +1163,19 @@ impl ClipInstance {
|
||||||
content_time
|
content_time
|
||||||
};
|
};
|
||||||
|
|
||||||
Some(Seconds(self.trim_start) + looped)
|
Some(trim_start_secs + looped)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Alias for `remap_time_secs`.
|
/// Alias for `remap_time_secs`.
|
||||||
#[inline]
|
#[inline]
|
||||||
pub fn remap_time(&self, time: Seconds, clip_duration_secs: Seconds, tempo_map: &crate::tempo_map::TempoMap) -> Option<Seconds> {
|
pub fn remap_time(&self, time: Seconds, clip_content: ClipDuration, tempo_map: &crate::tempo_map::TempoMap) -> Option<Seconds> {
|
||||||
self.remap_time_secs(time, clip_duration_secs, tempo_map)
|
self.remap_time_secs(time, clip_content, tempo_map)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Alias for `effective_duration_beats`.
|
/// Alias for `effective_duration_beats`.
|
||||||
#[inline]
|
#[inline]
|
||||||
pub fn effective_duration(&self, clip_duration_secs: Seconds, tempo_map: &crate::tempo_map::TempoMap) -> Beats {
|
pub fn effective_duration(&self, clip_content: ClipDuration, tempo_map: &crate::tempo_map::TempoMap) -> Beats {
|
||||||
self.effective_duration_beats(clip_duration_secs, tempo_map)
|
self.effective_duration_beats(clip_content, tempo_map)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert to affine transform
|
/// Convert to affine transform
|
||||||
|
|
@ -1201,7 +1252,7 @@ mod tests {
|
||||||
assert_eq!(instance.clip_id, clip_id);
|
assert_eq!(instance.clip_id, clip_id);
|
||||||
assert_eq!(instance.opacity, 1.0);
|
assert_eq!(instance.opacity, 1.0);
|
||||||
assert_eq!(instance.timeline_start, Beats::ZERO);
|
assert_eq!(instance.timeline_start, Beats::ZERO);
|
||||||
assert_eq!(instance.trim_start, 0.0);
|
assert_eq!(instance.trim_start, ContentTime::ZERO);
|
||||||
assert_eq!(instance.trim_end, None);
|
assert_eq!(instance.trim_end, None);
|
||||||
assert_eq!(instance.playback_speed, 1.0);
|
assert_eq!(instance.playback_speed, 1.0);
|
||||||
assert_eq!(instance.gain, 1.0);
|
assert_eq!(instance.gain, 1.0);
|
||||||
|
|
@ -1211,27 +1262,52 @@ mod tests {
|
||||||
fn test_clip_instance_trimming() {
|
fn test_clip_instance_trimming() {
|
||||||
let clip_id = Uuid::new_v4();
|
let clip_id = Uuid::new_v4();
|
||||||
let instance = ClipInstance::new(clip_id)
|
let instance = ClipInstance::new(clip_id)
|
||||||
.with_trimming(2.0, Some(8.0));
|
.with_trimming(ContentTime(2.0), Some(ContentTime(8.0)));
|
||||||
|
|
||||||
assert_eq!(instance.trim_start, 2.0);
|
assert_eq!(instance.trim_start, ContentTime(2.0));
|
||||||
assert_eq!(instance.trim_end, Some(8.0));
|
assert_eq!(instance.trim_end, Some(ContentTime(8.0)));
|
||||||
// At 60 BPM the tempo map is identity (1 beat == 1 second), so the
|
// At 60 BPM the tempo map is identity (1 beat == 1 second), so the
|
||||||
// beats-domain effective duration equals the seconds content window.
|
// beats-domain effective duration equals the seconds content window.
|
||||||
let tempo_map = crate::tempo_map::TempoMap::constant(60.0);
|
let tempo_map = crate::tempo_map::TempoMap::constant(60.0);
|
||||||
assert_eq!(instance.effective_duration(Seconds(10.0), &tempo_map), Beats(6.0));
|
let content = ClipDuration::Seconds(Seconds(10.0));
|
||||||
|
assert_eq!(instance.effective_duration(content, &tempo_map), Beats(6.0));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_clip_instance_no_end_trim() {
|
fn test_clip_instance_no_end_trim() {
|
||||||
let clip_id = Uuid::new_v4();
|
let clip_id = Uuid::new_v4();
|
||||||
let instance = ClipInstance::new(clip_id)
|
let instance = ClipInstance::new(clip_id)
|
||||||
.with_trimming(2.0, None);
|
.with_trimming(ContentTime(2.0), None);
|
||||||
|
|
||||||
assert_eq!(instance.trim_start, 2.0);
|
assert_eq!(instance.trim_start, ContentTime(2.0));
|
||||||
assert_eq!(instance.trim_end, None);
|
assert_eq!(instance.trim_end, None);
|
||||||
// At 60 BPM the tempo map is identity (1 beat == 1 second).
|
// At 60 BPM the tempo map is identity (1 beat == 1 second).
|
||||||
let tempo_map = crate::tempo_map::TempoMap::constant(60.0);
|
let tempo_map = crate::tempo_map::TempoMap::constant(60.0);
|
||||||
assert_eq!(instance.effective_duration(Seconds(10.0), &tempo_map), Beats(8.0));
|
let content = ClipDuration::Seconds(Seconds(10.0));
|
||||||
|
assert_eq!(instance.effective_duration(content, &tempo_map), Beats(8.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trimmed_midi_clip_keeps_its_beats_length_across_tempo() {
|
||||||
|
// Regression: `effective_duration_beats` used to take a SECONDS clip duration and subtract
|
||||||
|
// `trim_start` from it. For a TRIMMED MIDI clip that subtracted a beats offset from a
|
||||||
|
// seconds duration, so the clip's timeline length came out wrong at any tempo but 60 BPM.
|
||||||
|
//
|
||||||
|
// MIDI content is beats and tempo-invariant: a clip trimmed to beats 2..6 is 4 beats long
|
||||||
|
// whatever the tempo says.
|
||||||
|
let clip_id = Uuid::new_v4();
|
||||||
|
let instance = ClipInstance::new(clip_id)
|
||||||
|
.with_trimming(ContentTime(2.0), Some(ContentTime(6.0)));
|
||||||
|
let content = ClipDuration::Beats(Beats(8.0));
|
||||||
|
|
||||||
|
for bpm in [60.0, 120.0, 90.0] {
|
||||||
|
let tempo_map = crate::tempo_map::TempoMap::constant(bpm);
|
||||||
|
assert_eq!(
|
||||||
|
instance.effective_duration(content, &tempo_map),
|
||||||
|
Beats(4.0),
|
||||||
|
"a MIDI clip trimmed to beats 2..6 is 4 beats long at {bpm} BPM",
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -464,8 +464,11 @@ impl Document {
|
||||||
let end_beats: Beats = if let Some(timeline_duration) = instance.timeline_duration {
|
let end_beats: Beats = if let Some(timeline_duration) = instance.timeline_duration {
|
||||||
instance.timeline_start + timeline_duration
|
instance.timeline_start + timeline_duration
|
||||||
} else {
|
} else {
|
||||||
let trim_end = instance.trim_end.unwrap_or(clip_duration);
|
// `clip_duration` arrives as seconds (the recursive helper's signature), so this
|
||||||
let trimmed_secs = ((trim_end - instance.trim_start) / instance.playback_speed).max(0.0);
|
// path is the wall-clock one; MIDI content would need resolving against its clip.
|
||||||
|
let trim_end = instance.trim_end.map_or(clip_duration, |t| t.raw());
|
||||||
|
let trimmed_secs =
|
||||||
|
((trim_end - instance.trim_start.raw()) / instance.playback_speed).max(0.0);
|
||||||
let start_secs = tempo_map.beats_to_seconds(instance.timeline_start);
|
let start_secs = tempo_map.beats_to_seconds(instance.timeline_start);
|
||||||
tempo_map.seconds_to_beats(start_secs + Seconds(trimmed_secs))
|
tempo_map.seconds_to_beats(start_secs + Seconds(trimmed_secs))
|
||||||
};
|
};
|
||||||
|
|
@ -915,11 +918,10 @@ impl Document {
|
||||||
/// have infinite internal duration.
|
/// have infinite internal duration.
|
||||||
/// A clip's content duration **in the domain its `trim_start`/`trim_end` are measured in**.
|
/// A clip's content duration **in the domain its `trim_start`/`trim_end` are measured in**.
|
||||||
///
|
///
|
||||||
/// `ClipInstance::trim_*` is domain-polymorphic exactly like `AudioClip::duration`: SECONDS for
|
/// Content time is domain-polymorphic exactly like `AudioClip::duration`: SECONDS for sampled
|
||||||
/// sampled audio, video and vector, but BEATS for MIDI (the backend takes MIDI trims as
|
/// audio, video and vector, but BEATS for MIDI. Anything doing arithmetic against a trim value —
|
||||||
/// `Beats`). Anything doing arithmetic against a trim value — mapping a timeline position into
|
/// mapping a timeline position into the clip's content, say — has to work in that same domain,
|
||||||
/// the clip's content, say — has to work in that same domain, and [`Self::get_clip_duration`]
|
/// and [`Self::get_clip_duration`] can't tell it which: that one always converts to seconds.
|
||||||
/// can't tell it which: that one always converts to seconds.
|
|
||||||
///
|
///
|
||||||
/// Returns `None` for unknown clips.
|
/// Returns `None` for unknown clips.
|
||||||
pub fn clip_trim_duration(&self, clip_id: &Uuid) -> Option<crate::clip::ClipDuration> {
|
pub fn clip_trim_duration(&self, clip_id: &Uuid) -> Option<crate::clip::ClipDuration> {
|
||||||
|
|
@ -930,6 +932,29 @@ impl Document {
|
||||||
self.get_clip_duration(clip_id).map(crate::clip::ClipDuration::Seconds)
|
self.get_clip_duration(clip_id).map(crate::clip::ClipDuration::Seconds)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolve a [`ContentTime`] (a trim bound) against the clip it belongs to.
|
||||||
|
///
|
||||||
|
/// The clip is the only thing that knows whether its content is measured in seconds or beats, so
|
||||||
|
/// this is the sanctioned exit from `ContentTime`. Works for every clip kind, not just audio.
|
||||||
|
/// Returns `None` for unknown clips.
|
||||||
|
pub fn resolve_content_time(
|
||||||
|
&self,
|
||||||
|
clip_id: &Uuid,
|
||||||
|
t: daw_backend::ContentTime,
|
||||||
|
) -> Option<crate::clip::ClipDuration> {
|
||||||
|
if let Some(clip) = self.audio_clips.get(clip_id) {
|
||||||
|
return Some(clip.resolve_content_time(t));
|
||||||
|
}
|
||||||
|
if self.vector_clips.contains_key(clip_id)
|
||||||
|
|| self.video_clips.contains_key(clip_id)
|
||||||
|
|| self.effect_definitions.contains_key(clip_id)
|
||||||
|
{
|
||||||
|
// Wall-clock content.
|
||||||
|
return Some(crate::clip::ClipDuration::Seconds(Seconds(t.raw())));
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
pub fn get_clip_duration(&self, clip_id: &Uuid) -> Option<Seconds> {
|
pub fn get_clip_duration(&self, clip_id: &Uuid) -> Option<Seconds> {
|
||||||
if let Some(clip) = self.vector_clips.get(clip_id) {
|
if let Some(clip) = self.vector_clips.get(clip_id) {
|
||||||
if clip.is_group {
|
if clip.is_group {
|
||||||
|
|
@ -983,9 +1008,9 @@ impl Document {
|
||||||
};
|
};
|
||||||
|
|
||||||
let instance = instances.iter().find(|inst| &inst.id == instance_id)?;
|
let instance = instances.iter().find(|inst| &inst.id == instance_id)?;
|
||||||
let clip_duration = self.get_clip_duration(&instance.clip_id)?;
|
// The clip's content duration in ITS OWN domain, so the trims resolve correctly for MIDI.
|
||||||
// End position on the timeline, in beats (convert the seconds content window via tempo map).
|
let clip_content = self.clip_trim_duration(&instance.clip_id)?;
|
||||||
Some(instance.timeline_start + instance.effective_duration_beats(clip_duration, self.tempo_map()))
|
Some(instance.timeline_start + instance.effective_duration_beats(clip_content, self.tempo_map()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a time range overlaps with any existing clip on the layer
|
/// Check if a time range overlaps with any existing clip on the layer
|
||||||
|
|
@ -1025,13 +1050,14 @@ impl Document {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate instance extent (accounting for loop_before)
|
// Calculate instance extent (accounting for loop_before). Content duration in the clip's
|
||||||
let Some(clip_duration) = self.get_clip_duration(&instance.clip_id) else {
|
// own domain, so the trims resolve correctly for MIDI.
|
||||||
|
let Some(clip_content) = self.clip_trim_duration(&instance.clip_id) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
let instance_start = instance.effective_start();
|
let instance_start = instance.effective_start();
|
||||||
let instance_end = instance.timeline_start + instance.effective_duration(clip_duration, self.tempo_map());
|
let instance_end = instance.timeline_start + instance.effective_duration(clip_content, self.tempo_map());
|
||||||
|
|
||||||
// Check overlap: start_a < end_b AND start_b < end_a
|
// Check overlap: start_a < end_b AND start_b < end_a
|
||||||
if start_time < instance_end && instance_start < end_time {
|
if start_time < instance_end && instance_start < end_time {
|
||||||
|
|
@ -1088,7 +1114,7 @@ impl Document {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(clip_dur) = self.get_clip_duration(&instance.clip_id) {
|
if let Some(clip_dur) = self.clip_trim_duration(&instance.clip_id) {
|
||||||
let inst_start = instance.effective_start();
|
let inst_start = instance.effective_start();
|
||||||
let inst_end = instance.timeline_start + instance.effective_duration(clip_dur, self.tempo_map());
|
let inst_end = instance.timeline_start + instance.effective_duration(clip_dur, self.tempo_map());
|
||||||
occupied_ranges.push((inst_start, inst_end, instance.id));
|
occupied_ranges.push((inst_start, inst_end, instance.id));
|
||||||
|
|
@ -1184,7 +1210,7 @@ impl Document {
|
||||||
if group_ids.contains(&inst.id) {
|
if group_ids.contains(&inst.id) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if let Some(dur) = self.get_clip_duration(&inst.clip_id) {
|
if let Some(dur) = self.clip_trim_duration(&inst.clip_id) {
|
||||||
let start = inst.effective_start();
|
let start = inst.effective_start();
|
||||||
let end = inst.timeline_start + inst.effective_duration(dur, self.tempo_map());
|
let end = inst.timeline_start + inst.effective_duration(dur, self.tempo_map());
|
||||||
non_group.push((start, end));
|
non_group.push((start, end));
|
||||||
|
|
@ -1258,9 +1284,8 @@ impl Document {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate other clip's extent (accounting for loop_before)
|
// Calculate other clip's extent (accounting for loop_before)
|
||||||
if let Some(clip_duration) = self.get_clip_duration(&other.clip_id) {
|
if let Some(clip_duration) = self.clip_trim_duration(&other.clip_id) {
|
||||||
let other_end = other.timeline_start + other.effective_duration(clip_duration, self.tempo_map());
|
let other_end = other.timeline_start + other.effective_duration(clip_duration, self.tempo_map());
|
||||||
// (clip_duration is Seconds via get_clip_duration; effective_duration converts.)
|
|
||||||
|
|
||||||
// If this clip is to the left and closer than current nearest
|
// If this clip is to the left and closer than current nearest
|
||||||
if other_end <= current_timeline_start && other_end > nearest_end {
|
if other_end <= current_timeline_start && other_end > nearest_end {
|
||||||
|
|
@ -1358,7 +1383,7 @@ impl Document {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(clip_duration) = self.get_clip_duration(&other.clip_id) {
|
if let Some(clip_duration) = self.clip_trim_duration(&other.clip_id) {
|
||||||
let other_end = other.timeline_start + other.effective_duration(clip_duration, self.tempo_map());
|
let other_end = other.timeline_start + other.effective_duration(clip_duration, self.tempo_map());
|
||||||
|
|
||||||
if other_end <= current_effective_start && other_end > nearest_end {
|
if other_end <= current_effective_start && other_end > nearest_end {
|
||||||
|
|
|
||||||
|
|
@ -152,7 +152,12 @@ impl EffectLayer {
|
||||||
self.clip_instances
|
self.clip_instances
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|e| {
|
.filter(|e| {
|
||||||
let end = e.timeline_start + e.effective_duration(daw_backend::Seconds(EFFECT_DURATION), tempo_map);
|
// Effects have an "infinite" wall-clock content length.
|
||||||
|
let end = e.timeline_start
|
||||||
|
+ e.effective_duration(
|
||||||
|
crate::clip::ClipDuration::Seconds(daw_backend::Seconds(EFFECT_DURATION)),
|
||||||
|
tempo_map,
|
||||||
|
);
|
||||||
time_beats >= e.timeline_start && time_beats < end
|
time_beats >= e.timeline_start && time_beats < end
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
//! Provides functions for testing if points or rectangles intersect with
|
//! Provides functions for testing if points or rectangles intersect with
|
||||||
//! vector graph elements and clip instances, taking into account transform hierarchies.
|
//! vector graph elements and clip instances, taking into account transform hierarchies.
|
||||||
|
|
||||||
use crate::clip::ClipInstance;
|
use crate::clip::{ClipDuration, ClipInstance};
|
||||||
use crate::vector_graph::{VertexId, EdgeId, FillId};
|
use crate::vector_graph::{VertexId, EdgeId, FillId};
|
||||||
use crate::layer::VectorLayer;
|
use crate::layer::VectorLayer;
|
||||||
use crate::shape::Shape;
|
use crate::shape::Shape;
|
||||||
|
|
@ -260,7 +260,10 @@ pub fn hit_test_clip_instances(
|
||||||
for clip_instance in clip_instances.iter().rev() {
|
for clip_instance in clip_instances.iter().rev() {
|
||||||
// Check time bounds: skip clip instances not active at this time
|
// Check time bounds: skip clip instances not active at this time
|
||||||
// timeline_start/instance_end are in beats; convert timeline_time (seconds) to beats.
|
// timeline_start/instance_end are in beats; convert timeline_time (seconds) to beats.
|
||||||
let clip_duration = document.get_clip_duration(&clip_instance.clip_id).unwrap_or(daw_backend::Seconds::ZERO);
|
// Hit-testing runs on vector/raster content, which is wall-clock seconds.
|
||||||
|
let clip_duration = ClipDuration::Seconds(
|
||||||
|
document.get_clip_duration(&clip_instance.clip_id).unwrap_or(daw_backend::Seconds::ZERO),
|
||||||
|
);
|
||||||
let instance_end = clip_instance.timeline_start + clip_instance.effective_duration(clip_duration, tempo_map);
|
let instance_end = clip_instance.timeline_start + clip_instance.effective_duration(clip_duration, tempo_map);
|
||||||
let timeline_beats = tempo_map.seconds_to_beats(daw_backend::Seconds(timeline_time));
|
let timeline_beats = tempo_map.seconds_to_beats(daw_backend::Seconds(timeline_time));
|
||||||
if timeline_beats < clip_instance.timeline_start || timeline_beats >= instance_end {
|
if timeline_beats < clip_instance.timeline_start || timeline_beats >= instance_end {
|
||||||
|
|
@ -269,7 +272,8 @@ pub fn hit_test_clip_instances(
|
||||||
|
|
||||||
// clip_time is in seconds; offset from clip start (in seconds) + trim_start (seconds)
|
// clip_time is in seconds; offset from clip start (in seconds) + trim_start (seconds)
|
||||||
let start_secs = tempo_map.beats_to_seconds(clip_instance.timeline_start).seconds_to_f64();
|
let start_secs = tempo_map.beats_to_seconds(clip_instance.timeline_start).seconds_to_f64();
|
||||||
let clip_time = ((timeline_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start;
|
let clip_time =
|
||||||
|
((timeline_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start.raw();
|
||||||
|
|
||||||
let content_bounds = if let Some(vector_clip) = document.get_vector_clip(&clip_instance.clip_id) {
|
let content_bounds = if let Some(vector_clip) = document.get_vector_clip(&clip_instance.clip_id) {
|
||||||
vector_clip.calculate_content_bounds(document, clip_time)
|
vector_clip.calculate_content_bounds(document, clip_time)
|
||||||
|
|
@ -304,7 +308,10 @@ pub fn hit_test_clip_instances_in_rect(
|
||||||
for clip_instance in clip_instances {
|
for clip_instance in clip_instances {
|
||||||
// Check time bounds: skip clip instances not active at this time
|
// Check time bounds: skip clip instances not active at this time
|
||||||
// timeline_start/instance_end are in beats; convert timeline_time (seconds) to beats.
|
// timeline_start/instance_end are in beats; convert timeline_time (seconds) to beats.
|
||||||
let clip_duration = document.get_clip_duration(&clip_instance.clip_id).unwrap_or(daw_backend::Seconds::ZERO);
|
// Hit-testing runs on vector/raster content, which is wall-clock seconds.
|
||||||
|
let clip_duration = ClipDuration::Seconds(
|
||||||
|
document.get_clip_duration(&clip_instance.clip_id).unwrap_or(daw_backend::Seconds::ZERO),
|
||||||
|
);
|
||||||
let instance_end = clip_instance.timeline_start + clip_instance.effective_duration(clip_duration, tempo_map);
|
let instance_end = clip_instance.timeline_start + clip_instance.effective_duration(clip_duration, tempo_map);
|
||||||
let timeline_beats = tempo_map.seconds_to_beats(daw_backend::Seconds(timeline_time));
|
let timeline_beats = tempo_map.seconds_to_beats(daw_backend::Seconds(timeline_time));
|
||||||
if timeline_beats < clip_instance.timeline_start || timeline_beats >= instance_end {
|
if timeline_beats < clip_instance.timeline_start || timeline_beats >= instance_end {
|
||||||
|
|
@ -312,7 +319,8 @@ pub fn hit_test_clip_instances_in_rect(
|
||||||
}
|
}
|
||||||
|
|
||||||
let start_secs = tempo_map.beats_to_seconds(clip_instance.timeline_start).seconds_to_f64();
|
let start_secs = tempo_map.beats_to_seconds(clip_instance.timeline_start).seconds_to_f64();
|
||||||
let clip_time = ((timeline_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start;
|
let clip_time =
|
||||||
|
((timeline_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start.raw();
|
||||||
|
|
||||||
let content_bounds = if let Some(vector_clip) = document.get_vector_clip(&clip_instance.clip_id) {
|
let content_bounds = if let Some(vector_clip) = document.get_vector_clip(&clip_instance.clip_id) {
|
||||||
vector_clip.calculate_content_bounds(document, clip_time)
|
vector_clip.calculate_content_bounds(document, clip_time)
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
//! The compositing mode enables proper per-layer opacity, blend modes, and effects.
|
//! The compositing mode enables proper per-layer opacity, blend modes, and effects.
|
||||||
|
|
||||||
use crate::animation::TransformProperty;
|
use crate::animation::TransformProperty;
|
||||||
use crate::clip::{ClipInstance, ImageAsset};
|
use crate::clip::{ClipDuration, ClipInstance, ImageAsset};
|
||||||
use crate::document::Document;
|
use crate::document::Document;
|
||||||
use daw_backend::Seconds;
|
use daw_backend::Seconds;
|
||||||
use crate::gpu::BlendMode;
|
use crate::gpu::BlendMode;
|
||||||
|
|
@ -568,7 +568,7 @@ pub fn render_layer_isolated(
|
||||||
let tempo_map = document.tempo_map();
|
let tempo_map = document.tempo_map();
|
||||||
for clip_instance in &video_layer.clip_instances {
|
for clip_instance in &video_layer.clip_instances {
|
||||||
let Some(video_clip) = document.video_clips.get(&clip_instance.clip_id) else { continue };
|
let Some(video_clip) = document.video_clips.get(&clip_instance.clip_id) else { continue };
|
||||||
let Some(clip_time) = clip_instance.remap_time(Seconds(time), Seconds(video_clip.duration), tempo_map) else { continue };
|
let Some(clip_time) = clip_instance.remap_time(Seconds(time), ClipDuration::Seconds(Seconds(video_clip.duration)), tempo_map) else { continue };
|
||||||
let clip_time = clip_time.seconds_to_f64();
|
let clip_time = clip_time.seconds_to_f64();
|
||||||
let Some(frame) = video_mgr.get_frame(&clip_instance.clip_id, clip_time, target_w, target_h) else { continue };
|
let Some(frame) = video_mgr.get_frame(&clip_instance.clip_id, clip_time, target_w, target_h) else { continue };
|
||||||
|
|
||||||
|
|
@ -1019,7 +1019,10 @@ fn render_clip_instance(
|
||||||
}
|
}
|
||||||
0.0
|
0.0
|
||||||
} else {
|
} else {
|
||||||
let clip_dur = document.get_clip_duration(&vector_clip.id).unwrap_or(Seconds(vector_clip.duration));
|
// A vector clip's content is wall-clock seconds.
|
||||||
|
let clip_dur = ClipDuration::Seconds(
|
||||||
|
document.get_clip_duration(&vector_clip.id).unwrap_or(Seconds(vector_clip.duration)),
|
||||||
|
);
|
||||||
let Some(t) = clip_instance.remap_time(Seconds(time), clip_dur, tempo_map) else {
|
let Some(t) = clip_instance.remap_time(Seconds(time), clip_dur, tempo_map) else {
|
||||||
return; // Clip instance not active at this time
|
return; // Clip instance not active at this time
|
||||||
};
|
};
|
||||||
|
|
@ -1174,7 +1177,7 @@ fn render_video_layer(
|
||||||
|
|
||||||
// Remap timeline time to clip's internal time
|
// Remap timeline time to clip's internal time
|
||||||
let tempo_map = document.tempo_map();
|
let tempo_map = document.tempo_map();
|
||||||
let Some(clip_time) = clip_instance.remap_time(Seconds(time), Seconds(video_clip.duration), tempo_map) else {
|
let Some(clip_time) = clip_instance.remap_time(Seconds(time), ClipDuration::Seconds(Seconds(video_clip.duration)), tempo_map) else {
|
||||||
continue; // Clip instance not active at this time
|
continue; // Clip instance not active at this time
|
||||||
};
|
};
|
||||||
let clip_time = clip_time.seconds_to_f64();
|
let clip_time = clip_time.seconds_to_f64();
|
||||||
|
|
@ -1910,7 +1913,10 @@ fn render_clip_instance_cpu(
|
||||||
if time < start_secs || time >= end { return; }
|
if time < start_secs || time >= end { return; }
|
||||||
0.0
|
0.0
|
||||||
} else {
|
} else {
|
||||||
let clip_dur = document.get_clip_duration(&vector_clip.id).unwrap_or(Seconds(vector_clip.duration));
|
// A vector clip's content is wall-clock seconds.
|
||||||
|
let clip_dur = ClipDuration::Seconds(
|
||||||
|
document.get_clip_duration(&vector_clip.id).unwrap_or(Seconds(vector_clip.duration)),
|
||||||
|
);
|
||||||
let Some(t) = clip_instance.remap_time(Seconds(time), clip_dur, tempo_map) else { return };
|
let Some(t) = clip_instance.remap_time(Seconds(time), clip_dur, tempo_map) else { return };
|
||||||
t.seconds_to_f64()
|
t.seconds_to_f64()
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1064,7 +1064,12 @@ fn composite_document_to_hdr(
|
||||||
}
|
}
|
||||||
let tempo_map = document.tempo_map();
|
let tempo_map = document.tempo_map();
|
||||||
let effect_end_beats = effect_instance.timeline_start
|
let effect_end_beats = effect_instance.timeline_start
|
||||||
+ effect_instance.effective_duration(daw_backend::Seconds(lightningbeam_core::effect::EFFECT_DURATION), tempo_map);
|
+ effect_instance.effective_duration(
|
||||||
|
lightningbeam_core::clip::ClipDuration::Seconds(daw_backend::Seconds(
|
||||||
|
lightningbeam_core::effect::EFFECT_DURATION,
|
||||||
|
)),
|
||||||
|
tempo_map,
|
||||||
|
);
|
||||||
let effect_inst = lightningbeam_core::effect::EffectInstance::new(
|
let effect_inst = lightningbeam_core::effect::EffectInstance::new(
|
||||||
effect_def,
|
effect_def,
|
||||||
tempo_map.beats_to_seconds(effect_instance.timeline_start).seconds_to_f64(),
|
tempo_map.beats_to_seconds(effect_instance.timeline_start).seconds_to_f64(),
|
||||||
|
|
|
||||||
|
|
@ -2387,7 +2387,7 @@ impl EditorApp {
|
||||||
) -> Vec<uuid::Uuid> {
|
) -> Vec<uuid::Uuid> {
|
||||||
let mut result = Vec::new();
|
let mut result = Vec::new();
|
||||||
for instance in clip_instances {
|
for instance in clip_instances {
|
||||||
if let Some(clip_duration) = document.get_clip_duration(&instance.clip_id) {
|
if let Some(clip_duration) = document.clip_trim_duration(&instance.clip_id) {
|
||||||
let effective_duration = instance.effective_duration(clip_duration, document.tempo_map());
|
let effective_duration = instance.effective_duration(clip_duration, document.tempo_map());
|
||||||
let timeline_end = instance.timeline_start + effective_duration;
|
let timeline_end = instance.timeline_start + effective_duration;
|
||||||
|
|
||||||
|
|
@ -3276,7 +3276,9 @@ impl EditorApp {
|
||||||
let duplicates: Vec<lightningbeam_core::clip::ClipInstance> = clips_to_duplicate.iter().map(|original| {
|
let duplicates: Vec<lightningbeam_core::clip::ClipInstance> = clips_to_duplicate.iter().map(|original| {
|
||||||
let mut duplicate = original.clone();
|
let mut duplicate = original.clone();
|
||||||
duplicate.id = uuid::Uuid::new_v4();
|
duplicate.id = uuid::Uuid::new_v4();
|
||||||
let clip_duration = document.get_clip_duration(&original.clip_id).unwrap_or(Seconds(1.0));
|
let clip_duration = document
|
||||||
|
.clip_trim_duration(&original.clip_id)
|
||||||
|
.unwrap_or(ClipDuration::Seconds(Seconds(1.0)));
|
||||||
let effective_duration = original.effective_duration(clip_duration, document.tempo_map());
|
let effective_duration = original.effective_duration(clip_duration, document.tempo_map());
|
||||||
duplicate.timeline_start = original.timeline_start + effective_duration;
|
duplicate.timeline_start = original.timeline_start + effective_duration;
|
||||||
if let Some((new_clip_def_id, _)) = midi_clip_replacements.get(&original.clip_id) {
|
if let Some((new_clip_def_id, _)) = midi_clip_replacements.get(&original.clip_id) {
|
||||||
|
|
@ -5477,7 +5479,7 @@ impl EditorApp {
|
||||||
// matches the video clip exactly).
|
// matches the video clip exactly).
|
||||||
let (_dur, sample_rate, channels) = controller
|
let (_dur, sample_rate, channels) = controller
|
||||||
.get_pool_file_info(pool_index)
|
.get_pool_file_info(pool_index)
|
||||||
.unwrap_or((video_duration, 0, 0));
|
.unwrap_or((Seconds(video_duration), 0, 0));
|
||||||
drop(controller);
|
drop(controller);
|
||||||
|
|
||||||
let audio_clip_name = format!("{} (Audio)", video_name);
|
let audio_clip_name = format!("{} (Audio)", video_name);
|
||||||
|
|
@ -6384,7 +6386,9 @@ impl eframe::App for EditorApp {
|
||||||
use daw_backend::AudioEvent;
|
use daw_backend::AudioEvent;
|
||||||
match event {
|
match event {
|
||||||
AudioEvent::PlaybackPosition(time) => {
|
AudioEvent::PlaybackPosition(time) => {
|
||||||
self.playback_time = time;
|
// `playback_time` is the UI's seconds playhead (see the timeline's
|
||||||
|
// seconds/beats model); unwrap at this boundary, not before it.
|
||||||
|
self.playback_time = time.seconds_to_f64();
|
||||||
}
|
}
|
||||||
AudioEvent::PlaybackStopped => {
|
AudioEvent::PlaybackStopped => {
|
||||||
self.is_playing = false;
|
self.is_playing = false;
|
||||||
|
|
@ -6595,8 +6599,11 @@ impl eframe::App for EditorApp {
|
||||||
if let Some(inst) = al.clip_instances.iter_mut().find(|ci| ci.id == instance_id) {
|
if let Some(inst) = al.clip_instances.iter_mut().find(|ci| ci.id == instance_id) {
|
||||||
inst.timeline_start = loop_start;
|
inst.timeline_start = loop_start;
|
||||||
inst.timeline_duration = None;
|
inst.timeline_duration = None;
|
||||||
inst.trim_start = 0.0;
|
// Audio take content is seconds, and each take spans
|
||||||
inst.trim_end = Some(loop_len_seconds.seconds_to_f64());
|
// exactly one cycle region.
|
||||||
|
inst.trim_start = daw_backend::ContentTime::ZERO;
|
||||||
|
inst.trim_end =
|
||||||
|
Some(daw_backend::ContentTime(loop_len_seconds.seconds_to_f64()));
|
||||||
inst.active_take = Some(last_take);
|
inst.active_take = Some(last_take);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -6655,6 +6662,7 @@ impl eframe::App for EditorApp {
|
||||||
let mut controller = controller_arc.lock().unwrap();
|
let mut controller = controller_arc.lock().unwrap();
|
||||||
match controller.get_pool_file_info(pool_index) {
|
match controller.get_pool_file_info(pool_index) {
|
||||||
Ok((dur, _, _)) => {
|
Ok((dur, _, _)) => {
|
||||||
|
let dur = dur.seconds_to_f64();
|
||||||
eprintln!("[AUDIO] Got duration from backend: {:.4}s", dur);
|
eprintln!("[AUDIO] Got duration from backend: {:.4}s", dur);
|
||||||
self.audio_duration_cache.insert(pool_index, dur);
|
self.audio_duration_cache.insert(pool_index, dur);
|
||||||
dur
|
dur
|
||||||
|
|
@ -6688,7 +6696,7 @@ impl eframe::App for EditorApp {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.unwrap_or((uuid::Uuid::nil(), uuid::Uuid::nil(), Beats::ZERO, 0.0))
|
.unwrap_or((uuid::Uuid::nil(), uuid::Uuid::nil(), Beats::ZERO, daw_backend::ContentTime::ZERO))
|
||||||
};
|
};
|
||||||
|
|
||||||
if !clip_id.is_nil() {
|
if !clip_id.is_nil() {
|
||||||
|
|
@ -6850,7 +6858,7 @@ impl eframe::App for EditorApp {
|
||||||
.map(|(id, _)| id);
|
.map(|(id, _)| id);
|
||||||
if let Some(doc_clip_id) = doc_clip_id {
|
if let Some(doc_clip_id) = doc_clip_id {
|
||||||
if let Some(clip) = self.action_executor.document_mut().audio_clips.get_mut(&doc_clip_id) {
|
if let Some(clip) = self.action_executor.document_mut().audio_clips.get_mut(&doc_clip_id) {
|
||||||
clip.set_content_duration(ClipDuration::Beats(Beats(midi_clip_data.duration)));
|
clip.set_content_duration(ClipDuration::Beats(midi_clip_data.duration));
|
||||||
clip.name = format!("MIDI Recording {}", clip_id);
|
clip.name = format!("MIDI Recording {}", clip_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1612,8 +1612,12 @@ impl InfopanelPane {
|
||||||
ui.label(format!("{:.2}s", document.tempo_map().beats_to_seconds(ci.effective_start()).seconds_to_f64()));
|
ui.label(format!("{:.2}s", document.tempo_map().beats_to_seconds(ci.effective_start()).seconds_to_f64()));
|
||||||
});
|
});
|
||||||
|
|
||||||
let clip_dur = document.get_clip_duration(&ci.clip_id)
|
let clip_dur = document.clip_trim_duration(&ci.clip_id)
|
||||||
.unwrap_or_else(|| daw_backend::Seconds(ci.trim_end.unwrap_or(1.0) - ci.trim_start));
|
.unwrap_or_else(|| lightningbeam_core::clip::ClipDuration::Seconds(
|
||||||
|
daw_backend::Seconds(
|
||||||
|
(ci.trim_end.unwrap_or(daw_backend::ContentTime(1.0)) - ci.trim_start).raw(),
|
||||||
|
),
|
||||||
|
));
|
||||||
let total_dur = ci.total_duration(clip_dur, document.tempo_map());
|
let total_dur = ci.total_duration(clip_dur, document.tempo_map());
|
||||||
let total_dur_secs = (document.tempo_map().beats_to_seconds(ci.effective_start() + total_dur)
|
let total_dur_secs = (document.tempo_map().beats_to_seconds(ci.effective_start() + total_dur)
|
||||||
- document.tempo_map().beats_to_seconds(ci.effective_start())).seconds_to_f64();
|
- document.tempo_map().beats_to_seconds(ci.effective_start())).seconds_to_f64();
|
||||||
|
|
@ -1622,10 +1626,16 @@ impl InfopanelPane {
|
||||||
ui.label(format!("{:.2}s", total_dur_secs));
|
ui.label(format!("{:.2}s", total_dur_secs));
|
||||||
});
|
});
|
||||||
|
|
||||||
if ci.trim_start > 0.0 {
|
if ci.trim_start > daw_backend::ContentTime::ZERO {
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.label("Trim Start:");
|
ui.label("Trim Start:");
|
||||||
ui.label(format!("{:.2}s", ci.trim_start));
|
// Content time in the clip's own domain: seconds for sampled
|
||||||
|
// audio/video/vector, beats for MIDI. Label it accordingly.
|
||||||
|
let unit = match clip_dur {
|
||||||
|
lightningbeam_core::clip::ClipDuration::Beats(_) => "beats",
|
||||||
|
lightningbeam_core::clip::ClipDuration::Seconds(_) => "s",
|
||||||
|
};
|
||||||
|
ui.label(format!("{:.2}{}", ci.trim_start.raw(), unit));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -466,8 +466,10 @@ impl PianoRollPane {
|
||||||
for instance in &audio_layer.clip_instances {
|
for instance in &audio_layer.clip_instances {
|
||||||
if let Some(clip) = document.audio_clips.get(&instance.clip_id) {
|
if let Some(clip) = document.audio_clips.get(&instance.clip_id) {
|
||||||
if let AudioClipType::Midi { midi_clip_id } = clip.clip_type {
|
if let AudioClipType::Midi { midi_clip_id } = clip.clip_type {
|
||||||
let duration = instance.effective_duration(clip.content_duration().to_seconds(document.tempo_map()), document.tempo_map());
|
let duration = instance.effective_duration(clip.content_duration(), document.tempo_map());
|
||||||
clip_data.push((midi_clip_id, instance.timeline_start.beats_to_f64(), instance.trim_start, duration.beats_to_f64(), instance.id));
|
// A MIDI clip's content time IS beats, which is what the piano roll's
|
||||||
|
// x-axis uses.
|
||||||
|
clip_data.push((midi_clip_id, instance.timeline_start.beats_to_f64(), instance.trim_start.raw(), duration.beats_to_f64(), instance.id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2463,7 +2465,8 @@ impl PianoRollPane {
|
||||||
});
|
});
|
||||||
// Get sample rate from raw_audio_cache
|
// Get sample rate from raw_audio_cache
|
||||||
if let Some((_samples, sr, _ch)) = shared.raw_audio_cache.get(&audio_pool_index) {
|
if let Some((_samples, sr, _ch)) = shared.raw_audio_cache.get(&audio_pool_index) {
|
||||||
clip_infos.push((audio_pool_index, instance.timeline_start.beats_to_f64(), instance.trim_start, duration.beats_to_f64(), *sr));
|
// A sampled clip's content time is seconds.
|
||||||
|
clip_infos.push((audio_pool_index, instance.timeline_start.beats_to_f64(), instance.trim_start.raw(), duration.beats_to_f64(), *sr));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
use eframe::egui;
|
use eframe::egui;
|
||||||
use daw_backend::Seconds;
|
use daw_backend::Seconds;
|
||||||
use lightningbeam_core::action::Action;
|
use lightningbeam_core::action::Action;
|
||||||
use lightningbeam_core::clip::ClipInstance;
|
use lightningbeam_core::clip::{ClipDuration, ClipInstance};
|
||||||
use lightningbeam_core::gpu::{BufferPool, BufferFormat, BufferSpec, Compositor, EffectProcessor, SrgbToLinearConverter};
|
use lightningbeam_core::gpu::{BufferPool, BufferFormat, BufferSpec, Compositor, EffectProcessor, SrgbToLinearConverter};
|
||||||
use lightningbeam_core::layer::{AnyLayer, AudioLayer};
|
use lightningbeam_core::layer::{AnyLayer, AudioLayer};
|
||||||
use lightningbeam_core::renderer::RenderedLayerType;
|
use lightningbeam_core::renderer::RenderedLayerType;
|
||||||
|
|
@ -1854,7 +1854,10 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
|
||||||
// For now, create a simple effect instance with default parameters
|
// For now, create a simple effect instance with default parameters
|
||||||
let tempo_map = self.ctx.document.tempo_map();
|
let tempo_map = self.ctx.document.tempo_map();
|
||||||
let effect_end_beats = effect_instance.timeline_start
|
let effect_end_beats = effect_instance.timeline_start
|
||||||
+ effect_instance.effective_duration(Seconds(lightningbeam_core::effect::EFFECT_DURATION), tempo_map);
|
+ effect_instance.effective_duration(
|
||||||
|
ClipDuration::Seconds(Seconds(lightningbeam_core::effect::EFFECT_DURATION)),
|
||||||
|
tempo_map,
|
||||||
|
);
|
||||||
let effect_inst = lightningbeam_core::effect::EffectInstance::new(
|
let effect_inst = lightningbeam_core::effect::EffectInstance::new(
|
||||||
effect_def,
|
effect_def,
|
||||||
tempo_map.beats_to_seconds(effect_instance.timeline_start).seconds_to_f64(),
|
tempo_map.beats_to_seconds(effect_instance.timeline_start).seconds_to_f64(),
|
||||||
|
|
@ -2209,7 +2212,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
|
||||||
|
|
||||||
// Calculate clip bounds for preview
|
// Calculate clip bounds for preview
|
||||||
let start_secs = self.ctx.document.tempo_map().beats_to_seconds(clip_inst.timeline_start).seconds_to_f64();
|
let start_secs = self.ctx.document.tempo_map().beats_to_seconds(clip_inst.timeline_start).seconds_to_f64();
|
||||||
let clip_time = ((self.ctx.playback_time - start_secs) * clip_inst.playback_speed) + clip_inst.trim_start;
|
let clip_time = ((self.ctx.playback_time - start_secs) * clip_inst.playback_speed) + clip_inst.trim_start.raw();
|
||||||
let content_bounds = if let Some(vector_clip) = self.ctx.document.get_vector_clip(&clip_inst.clip_id) {
|
let content_bounds = if let Some(vector_clip) = self.ctx.document.get_vector_clip(&clip_inst.clip_id) {
|
||||||
vector_clip.calculate_content_bounds(&self.ctx.document, clip_time)
|
vector_clip.calculate_content_bounds(&self.ctx.document, clip_time)
|
||||||
} else if let Some(video_clip) = self.ctx.document.get_video_clip(&clip_inst.clip_id) {
|
} else if let Some(video_clip) = self.ctx.document.get_video_clip(&clip_inst.clip_id) {
|
||||||
|
|
@ -2299,7 +2302,9 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
|
||||||
for &clip_id in self.ctx.selection.clip_instances() {
|
for &clip_id in self.ctx.selection.clip_instances() {
|
||||||
if let Some(clip_instance) = vector_layer.clip_instances.iter().find(|ci| ci.id == clip_id) {
|
if let Some(clip_instance) = vector_layer.clip_instances.iter().find(|ci| ci.id == clip_id) {
|
||||||
// Skip clip instances not active at current time (compare in seconds).
|
// Skip clip instances not active at current time (compare in seconds).
|
||||||
let clip_dur = self.ctx.document.get_clip_duration(&clip_instance.clip_id).unwrap_or(Seconds::ZERO);
|
let clip_dur = ClipDuration::Seconds(
|
||||||
|
self.ctx.document.get_clip_duration(&clip_instance.clip_id).unwrap_or(Seconds::ZERO),
|
||||||
|
);
|
||||||
let tempo_map = self.ctx.document.tempo_map();
|
let tempo_map = self.ctx.document.tempo_map();
|
||||||
let start_secs = tempo_map.beats_to_seconds(clip_instance.timeline_start).seconds_to_f64();
|
let start_secs = tempo_map.beats_to_seconds(clip_instance.timeline_start).seconds_to_f64();
|
||||||
let instance_end = tempo_map.beats_to_seconds(
|
let instance_end = tempo_map.beats_to_seconds(
|
||||||
|
|
@ -2310,7 +2315,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate clip-local time
|
// Calculate clip-local time
|
||||||
let clip_time = ((self.ctx.playback_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start;
|
let clip_time = ((self.ctx.playback_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start.raw();
|
||||||
|
|
||||||
// Get dynamic clip bounds from content at current time
|
// Get dynamic clip bounds from content at current time
|
||||||
let bbox = if let Some(vector_clip) = self.ctx.document.get_vector_clip(&clip_instance.clip_id) {
|
let bbox = if let Some(vector_clip) = self.ctx.document.get_vector_clip(&clip_instance.clip_id) {
|
||||||
|
|
@ -2680,7 +2685,9 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
|
||||||
|
|
||||||
// Find clip instance visible at playback time
|
// Find clip instance visible at playback time
|
||||||
let visible_clip = video_layer.clip_instances.iter().find(|inst| {
|
let visible_clip = video_layer.clip_instances.iter().find(|inst| {
|
||||||
let clip_duration = self.ctx.document.get_clip_duration(&inst.clip_id).unwrap_or(Seconds::ZERO);
|
let clip_duration = ClipDuration::Seconds(
|
||||||
|
self.ctx.document.get_clip_duration(&inst.clip_id).unwrap_or(Seconds::ZERO),
|
||||||
|
);
|
||||||
let tempo_map = self.ctx.document.tempo_map();
|
let tempo_map = self.ctx.document.tempo_map();
|
||||||
let start_secs = tempo_map.beats_to_seconds(inst.timeline_start).seconds_to_f64();
|
let start_secs = tempo_map.beats_to_seconds(inst.timeline_start).seconds_to_f64();
|
||||||
let end_secs = tempo_map.beats_to_seconds(inst.timeline_start + inst.effective_duration(clip_duration, tempo_map)).seconds_to_f64();
|
let end_secs = tempo_map.beats_to_seconds(inst.timeline_start + inst.effective_duration(clip_duration, tempo_map)).seconds_to_f64();
|
||||||
|
|
@ -10142,7 +10149,7 @@ impl StagePane {
|
||||||
if let Some(clip_instance) = vector_layer.clip_instances.iter().find(|ci| ci.id == clip_id) {
|
if let Some(clip_instance) = vector_layer.clip_instances.iter().find(|ci| ci.id == clip_id) {
|
||||||
// Calculate clip-local time
|
// Calculate clip-local time
|
||||||
let start_secs = shared.action_executor.document().tempo_map().beats_to_seconds(clip_instance.timeline_start).seconds_to_f64();
|
let start_secs = shared.action_executor.document().tempo_map().beats_to_seconds(clip_instance.timeline_start).seconds_to_f64();
|
||||||
let clip_time = ((*shared.playback_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start;
|
let clip_time = ((*shared.playback_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start.raw();
|
||||||
|
|
||||||
// Get dynamic clip bounds from content at current time
|
// Get dynamic clip bounds from content at current time
|
||||||
use vello::kurbo::Rect as KurboRect;
|
use vello::kurbo::Rect as KurboRect;
|
||||||
|
|
@ -10343,7 +10350,7 @@ impl StagePane {
|
||||||
if let Some(clip_instance) = vector_layer.clip_instances.iter().find(|ci| ci.id == object_id) {
|
if let Some(clip_instance) = vector_layer.clip_instances.iter().find(|ci| ci.id == object_id) {
|
||||||
// Calculate clip-local time
|
// Calculate clip-local time
|
||||||
let start_secs = shared.action_executor.document().tempo_map().beats_to_seconds(clip_instance.timeline_start).seconds_to_f64();
|
let start_secs = shared.action_executor.document().tempo_map().beats_to_seconds(clip_instance.timeline_start).seconds_to_f64();
|
||||||
let clip_time = ((*shared.playback_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start;
|
let clip_time = ((*shared.playback_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start.raw();
|
||||||
|
|
||||||
// Get dynamic clip bounds from content at current time
|
// Get dynamic clip bounds from content at current time
|
||||||
let local_bbox = if let Some(vector_clip) = shared.action_executor.document().get_vector_clip(&clip_instance.clip_id) {
|
let local_bbox = if let Some(vector_clip) = shared.action_executor.document().get_vector_clip(&clip_instance.clip_id) {
|
||||||
|
|
@ -11059,7 +11066,9 @@ impl StagePane {
|
||||||
let document = shared.action_executor.document();
|
let document = shared.action_executor.document();
|
||||||
if let Some(AnyLayer::Video(video_layer)) = document.get_layer(layer_id) {
|
if let Some(AnyLayer::Video(video_layer)) = document.get_layer(layer_id) {
|
||||||
video_layer.clip_instances.iter().find(|inst| {
|
video_layer.clip_instances.iter().find(|inst| {
|
||||||
let clip_duration = document.get_clip_duration(&inst.clip_id).unwrap_or(Seconds::ZERO);
|
let clip_duration = ClipDuration::Seconds(
|
||||||
|
document.get_clip_duration(&inst.clip_id).unwrap_or(Seconds::ZERO),
|
||||||
|
);
|
||||||
let tempo_map = document.tempo_map();
|
let tempo_map = document.tempo_map();
|
||||||
let start_secs = tempo_map.beats_to_seconds(inst.timeline_start).seconds_to_f64();
|
let start_secs = tempo_map.beats_to_seconds(inst.timeline_start).seconds_to_f64();
|
||||||
let end_secs = tempo_map.beats_to_seconds(inst.timeline_start + inst.effective_duration(clip_duration, tempo_map)).seconds_to_f64();
|
let end_secs = tempo_map.beats_to_seconds(inst.timeline_start + inst.effective_duration(clip_duration, tempo_map)).seconds_to_f64();
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,8 @@
|
||||||
/// - Basic layer visualization
|
/// - Basic layer visualization
|
||||||
|
|
||||||
use eframe::egui;
|
use eframe::egui;
|
||||||
use daw_backend::{Beats, Seconds};
|
use daw_backend::{Beats, ContentTime, Seconds};
|
||||||
use lightningbeam_core::clip::ClipInstance;
|
use lightningbeam_core::clip::{ClipDuration, ClipInstance};
|
||||||
use lightningbeam_core::layer::{AnyLayer, AudioLayerType, GroupLayer, LayerTrait};
|
use lightningbeam_core::layer::{AnyLayer, AudioLayerType, GroupLayer, LayerTrait};
|
||||||
use super::{DragClipType, NodePath, PaneRenderer, SharedPaneState};
|
use super::{DragClipType, NodePath, PaneRenderer, SharedPaneState};
|
||||||
|
|
||||||
|
|
@ -89,7 +89,7 @@ fn compute_clip_stacking(
|
||||||
let tempo_map = document.tempo_map();
|
let tempo_map = document.tempo_map();
|
||||||
// Stacking only needs relative overlap, so compare in the beats domain.
|
// Stacking only needs relative overlap, so compare in the beats domain.
|
||||||
let ranges: Vec<(f64, f64)> = clip_instances.iter().map(|ci| {
|
let ranges: Vec<(f64, f64)> = clip_instances.iter().map(|ci| {
|
||||||
let clip_dur = effective_clip_duration(document, layer, ci).unwrap_or(Seconds::ZERO);
|
let clip_dur = effective_clip_duration(document, layer, ci).unwrap_or(ClipDuration::Seconds(Seconds::ZERO));
|
||||||
let start = ci.effective_start();
|
let start = ci.effective_start();
|
||||||
let end = start + ci.total_duration(clip_dur, tempo_map);
|
let end = start + ci.total_duration(clip_dur, tempo_map);
|
||||||
(start.beats_to_f64(), end.beats_to_f64())
|
(start.beats_to_f64(), end.beats_to_f64())
|
||||||
|
|
@ -203,11 +203,14 @@ fn draw_video_thumbnail_strip(
|
||||||
/// Get the effective clip duration for a clip instance on a given layer.
|
/// Get the effective clip duration for a clip instance on a given layer.
|
||||||
/// For groups on vector layers, the duration spans all consecutive keyframes
|
/// For groups on vector layers, the duration spans all consecutive keyframes
|
||||||
/// where the group is present. For regular clips, returns the clip's internal duration.
|
/// where the group is present. For regular clips, returns the clip's internal duration.
|
||||||
|
/// A clip's content duration **in its own domain** — seconds for vector/video/effect and sampled
|
||||||
|
/// audio, BEATS for MIDI. Returning a `ClipDuration` rather than bare `Seconds` is what lets the
|
||||||
|
/// instance's trim bounds (which are content times, in that same domain) be resolved correctly.
|
||||||
fn effective_clip_duration(
|
fn effective_clip_duration(
|
||||||
document: &lightningbeam_core::document::Document,
|
document: &lightningbeam_core::document::Document,
|
||||||
layer: &AnyLayer,
|
layer: &AnyLayer,
|
||||||
clip_instance: &ClipInstance,
|
clip_instance: &ClipInstance,
|
||||||
) -> Option<Seconds> {
|
) -> Option<ClipDuration> {
|
||||||
match layer {
|
match layer {
|
||||||
AnyLayer::Vector(vl) => {
|
AnyLayer::Vector(vl) => {
|
||||||
let vc = document.get_vector_clip(&clip_instance.clip_id)?;
|
let vc = document.get_vector_clip(&clip_instance.clip_id)?;
|
||||||
|
|
@ -215,17 +218,21 @@ fn effective_clip_duration(
|
||||||
let frame_duration = 1.0 / document.framerate;
|
let frame_duration = 1.0 / document.framerate;
|
||||||
let start_secs = document.tempo_map().beats_to_seconds(clip_instance.timeline_start).seconds_to_f64();
|
let start_secs = document.tempo_map().beats_to_seconds(clip_instance.timeline_start).seconds_to_f64();
|
||||||
let end = vl.group_visibility_end(&clip_instance.id, start_secs, frame_duration);
|
let end = vl.group_visibility_end(&clip_instance.id, start_secs, frame_duration);
|
||||||
Some(Seconds((end - start_secs).max(0.0)))
|
Some(ClipDuration::Seconds(Seconds((end - start_secs).max(0.0))))
|
||||||
} else {
|
} else {
|
||||||
// Movie clips: duration based on all internal content (keyframes + clip instances)
|
// Movie clips: duration based on all internal content (keyframes + clip instances)
|
||||||
document.get_clip_duration(&clip_instance.clip_id)
|
document.get_clip_duration(&clip_instance.clip_id).map(ClipDuration::Seconds)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Delegate to get_clip_duration so MIDI clips (whose `duration` is stored in beats,
|
// An audio layer can hold a sampled clip (seconds content) or a MIDI clip (beats content),
|
||||||
// not seconds) are converted correctly rather than read as raw seconds.
|
// so ask the clip which it is rather than flattening both to seconds.
|
||||||
AnyLayer::Audio(_) => document.get_clip_duration(&clip_instance.clip_id),
|
AnyLayer::Audio(_) => document.clip_trim_duration(&clip_instance.clip_id),
|
||||||
AnyLayer::Video(_) => document.get_video_clip(&clip_instance.clip_id).map(|c| Seconds(c.duration)),
|
AnyLayer::Video(_) => document
|
||||||
AnyLayer::Effect(_) => Some(Seconds(lightningbeam_core::effect::EFFECT_DURATION)),
|
.get_video_clip(&clip_instance.clip_id)
|
||||||
|
.map(|c| ClipDuration::Seconds(Seconds(c.duration))),
|
||||||
|
AnyLayer::Effect(_) => Some(ClipDuration::Seconds(Seconds(
|
||||||
|
lightningbeam_core::effect::EFFECT_DURATION,
|
||||||
|
))),
|
||||||
AnyLayer::Group(_) => None,
|
AnyLayer::Group(_) => None,
|
||||||
AnyLayer::Raster(_) => None,
|
AnyLayer::Raster(_) => None,
|
||||||
AnyLayer::Text(_) => None,
|
AnyLayer::Text(_) => None,
|
||||||
|
|
@ -859,7 +866,9 @@ impl TimelinePane {
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.iter()
|
.iter()
|
||||||
.map(|k| crate::curve_editor::CurvePoint {
|
.map(|k| crate::curve_editor::CurvePoint {
|
||||||
time: k.time, // beats (backend stores beats; curve editor x-axis is beats)
|
// The curve editor's x-axis is beats, same as the backend — unwrap at this
|
||||||
|
// boundary because CurvePoint stores a plain f64.
|
||||||
|
time: k.time.beats_to_f64(),
|
||||||
value: k.value,
|
value: k.value,
|
||||||
interpolation: match k.interpolation.as_str() {
|
interpolation: match k.interpolation.as_str() {
|
||||||
"bezier" => crate::curve_editor::CurveInterpolation::Bezier,
|
"bezier" => crate::curve_editor::CurveInterpolation::Bezier,
|
||||||
|
|
@ -1441,8 +1450,10 @@ impl TimelinePane {
|
||||||
|
|
||||||
let tempo_map = document.tempo_map();
|
let tempo_map = document.tempo_map();
|
||||||
for (_child_layer_id, ci) in &child_clips {
|
for (_child_layer_id, ci) in &child_clips {
|
||||||
let clip_dur = document.get_clip_duration(&ci.clip_id).unwrap_or_else(|| {
|
let clip_dur = document.clip_trim_duration(&ci.clip_id).unwrap_or_else(|| {
|
||||||
Seconds(ci.trim_end.unwrap_or(1.0) - ci.trim_start)
|
ClipDuration::Seconds(Seconds(
|
||||||
|
(ci.trim_end.unwrap_or(ContentTime(1.0)) - ci.trim_start).raw(),
|
||||||
|
))
|
||||||
});
|
});
|
||||||
let start = ci.effective_start();
|
let start = ci.effective_start();
|
||||||
let end = start + ci.total_duration(clip_dur, tempo_map);
|
let end = start + ci.total_duration(clip_dur, tempo_map);
|
||||||
|
|
@ -1756,15 +1767,18 @@ impl TimelinePane {
|
||||||
/// Effective on-timeline duration for a clip instance, in seconds.
|
/// Effective on-timeline duration for a clip instance, in seconds.
|
||||||
///
|
///
|
||||||
/// `total_duration` is in beats; converts to seconds using the current (preview) BPM.
|
/// `total_duration` is in beats; converts to seconds using the current (preview) BPM.
|
||||||
fn instance_display_duration(&self, ci: &lightningbeam_core::clip::ClipInstance, clip_dur_secs: Seconds, tempo_map: &daw_backend::TempoMap) -> f64 {
|
fn instance_display_duration(&self, ci: &lightningbeam_core::clip::ClipInstance, clip_content: ClipDuration, tempo_map: &daw_backend::TempoMap) -> f64 {
|
||||||
(tempo_map.beats_to_seconds(ci.timeline_start + ci.total_duration(clip_dur_secs, tempo_map))
|
(tempo_map.beats_to_seconds(ci.timeline_start + ci.total_duration(clip_content, tempo_map))
|
||||||
- tempo_map.beats_to_seconds(ci.effective_start())).seconds_to_f64()
|
- tempo_map.beats_to_seconds(ci.effective_start())).seconds_to_f64()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the clip content start (trim_start) and duration in display seconds.
|
/// The clip's content start (trim_start) and window length, as raw magnitudes in the clip's own
|
||||||
fn content_display_range(&self, ci: &lightningbeam_core::clip::ClipInstance, clip_dur_secs: Seconds, _bpm: f64) -> (f64, f64) {
|
/// content domain — seconds for audio/video/vector, beats for MIDI. The drag/preview math below
|
||||||
let trim_end = ci.trim_end.unwrap_or(clip_dur_secs.seconds_to_f64());
|
/// works in that domain throughout, converting to the timeline only at the edges.
|
||||||
(ci.trim_start, (trim_end - ci.trim_start).max(0.0))
|
fn content_display_range(&self, ci: &lightningbeam_core::clip::ClipInstance, clip_content: ClipDuration, _bpm: f64) -> (f64, f64) {
|
||||||
|
let trim_end = ci.trim_end.map_or(clip_content.native(), |t| t.raw());
|
||||||
|
let start = ci.trim_start.raw();
|
||||||
|
(start, (trim_end - start).max(0.0))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert pixel x-coordinate to time (seconds)
|
/// Convert pixel x-coordinate to time (seconds)
|
||||||
|
|
@ -3255,8 +3269,10 @@ impl TimelinePane {
|
||||||
let is_move_drag = self.clip_drag_state == Some(ClipDragType::Move);
|
let is_move_drag = self.clip_drag_state == Some(ClipDragType::Move);
|
||||||
let mut ranges: Vec<(Beats, Beats)> = Vec::new();
|
let mut ranges: Vec<(Beats, Beats)> = Vec::new();
|
||||||
for (_child_layer_id, ci) in &child_clips {
|
for (_child_layer_id, ci) in &child_clips {
|
||||||
let clip_dur = document.get_clip_duration(&ci.clip_id).unwrap_or_else(|| {
|
let clip_dur = document.clip_trim_duration(&ci.clip_id).unwrap_or_else(|| {
|
||||||
Seconds(ci.trim_end.unwrap_or(1.0) - ci.trim_start)
|
ClipDuration::Seconds(Seconds(
|
||||||
|
(ci.trim_end.unwrap_or(ContentTime(1.0)) - ci.trim_start).raw(),
|
||||||
|
))
|
||||||
});
|
});
|
||||||
let mut start = ci.effective_start();
|
let mut start = ci.effective_start();
|
||||||
let dur = ci.total_duration(clip_dur, document.tempo_map());
|
let dur = ci.total_duration(clip_dur, document.tempo_map());
|
||||||
|
|
@ -3329,8 +3345,10 @@ impl TimelinePane {
|
||||||
if let Some(video_child) = g.children.iter().find(|c| matches!(c, AnyLayer::Video(_))) {
|
if let Some(video_child) = g.children.iter().find(|c| matches!(c, AnyLayer::Video(_))) {
|
||||||
if let AnyLayer::Video(vl) = video_child {
|
if let AnyLayer::Video(vl) = video_child {
|
||||||
for ci in &vl.clip_instances {
|
for ci in &vl.clip_instances {
|
||||||
let clip_dur = document.get_clip_duration(&ci.clip_id)
|
let clip_dur = document.clip_trim_duration(&ci.clip_id)
|
||||||
.unwrap_or_else(|| Seconds(ci.trim_end.unwrap_or(1.0) - ci.trim_start));
|
.unwrap_or_else(|| ClipDuration::Seconds(Seconds(
|
||||||
|
(ci.trim_end.unwrap_or(ContentTime(1.0)) - ci.trim_start).raw(),
|
||||||
|
)));
|
||||||
let mut ci_start = ci.effective_start();
|
let mut ci_start = ci.effective_start();
|
||||||
if is_move_drag && selection.contains_clip_instance(&ci.id) {
|
if is_move_drag && selection.contains_clip_instance(&ci.id) {
|
||||||
ci_start = self.moved_start(ci_start, document.tempo_map(), &document.time_signature, document.framerate);
|
ci_start = self.moved_start(ci_start, document.tempo_map(), &document.time_signature, document.framerate);
|
||||||
|
|
@ -3357,7 +3375,8 @@ impl TimelinePane {
|
||||||
);
|
);
|
||||||
// 4th elem = clip's TRUE (unclamped) origin x, for correct
|
// 4th elem = clip's TRUE (unclamped) origin x, for correct
|
||||||
// hover content time when scrolled partly off the left.
|
// hover content time when scrolled partly off the left.
|
||||||
video_clip_hovers.push((hover_rect, ci.clip_id, ci.trim_start, rect.min.x + sx));
|
// Video content is wall-clock, so its content time IS seconds.
|
||||||
|
video_clip_hovers.push((hover_rect, ci.clip_id, ci.trim_start.raw(), rect.min.x + sx));
|
||||||
|
|
||||||
let thumb_display_height = (thumb_y_max - span_y_min) - 4.0;
|
let thumb_display_height = (thumb_y_max - span_y_min) - 4.0;
|
||||||
if thumb_display_height > 8.0 {
|
if thumb_display_height > 8.0 {
|
||||||
|
|
@ -3370,7 +3389,7 @@ impl TimelinePane {
|
||||||
&video_mgr,
|
&video_mgr,
|
||||||
&mut self.video_thumbnail_textures,
|
&mut self.video_thumbnail_textures,
|
||||||
ci.clip_id,
|
ci.clip_id,
|
||||||
ci.trim_start,
|
ci.trim_start.raw(),
|
||||||
rect.min.x + sx,
|
rect.min.x + sx,
|
||||||
ex - sx,
|
ex - sx,
|
||||||
ci_rect,
|
ci_rect,
|
||||||
|
|
@ -3419,7 +3438,7 @@ impl TimelinePane {
|
||||||
};
|
};
|
||||||
let audio_file_duration = total_frames as f64 / eff_sr as f64;
|
let audio_file_duration = total_frames as f64 / eff_sr as f64;
|
||||||
|
|
||||||
let clip_dur = audio_clip.content_duration().to_seconds(document.tempo_map());
|
let clip_dur = audio_clip.content_duration();
|
||||||
let mut ci_start = ci.effective_start();
|
let mut ci_start = ci.effective_start();
|
||||||
if is_move_drag && selection.contains_clip_instance(&ci.id) {
|
if is_move_drag && selection.contains_clip_instance(&ci.id) {
|
||||||
ci_start = self.moved_start(ci_start, document.tempo_map(), &document.time_signature, document.framerate);
|
ci_start = self.moved_start(ci_start, document.tempo_map(), &document.time_signature, document.framerate);
|
||||||
|
|
@ -3470,7 +3489,8 @@ impl TimelinePane {
|
||||||
audio_duration: audio_file_duration as f32,
|
audio_duration: audio_file_duration as f32,
|
||||||
sample_rate: eff_sr,
|
sample_rate: eff_sr,
|
||||||
clip_start_time: ci_screen_start,
|
clip_start_time: ci_screen_start,
|
||||||
trim_start: ci.trim_start as f32,
|
// A sampled clip's content time is seconds.
|
||||||
|
trim_start: ci.trim_start.raw() as f32,
|
||||||
tex_width: crate::waveform_gpu::tex_width() as f32,
|
tex_width: crate::waveform_gpu::tex_width() as f32,
|
||||||
total_frames: total_frames as f32,
|
total_frames: total_frames as f32,
|
||||||
segment_start_frame: 0.0,
|
segment_start_frame: 0.0,
|
||||||
|
|
@ -3553,7 +3573,7 @@ impl TimelinePane {
|
||||||
let group: Vec<(uuid::Uuid, Beats, Beats)> = clip_instances.iter()
|
let group: Vec<(uuid::Uuid, Beats, Beats)> = clip_instances.iter()
|
||||||
.filter(|ci| selection.contains_clip_instance(&ci.id))
|
.filter(|ci| selection.contains_clip_instance(&ci.id))
|
||||||
.filter_map(|ci| {
|
.filter_map(|ci| {
|
||||||
let dur = document.get_clip_duration(&ci.clip_id)?;
|
let dur = document.clip_trim_duration(&ci.clip_id)?;
|
||||||
Some((ci.id, ci.effective_start(), ci.total_duration(dur, document.tempo_map())))
|
Some((ci.id, ci.effective_start(), ci.total_duration(dur, document.tempo_map())))
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
@ -3577,8 +3597,11 @@ impl TimelinePane {
|
||||||
let shift_beats = |anchor: Beats, secs: f64|
|
let shift_beats = |anchor: Beats, secs: f64|
|
||||||
tmap.seconds_to_beats(tmap.beats_to_seconds(anchor) + Seconds(secs));
|
tmap.seconds_to_beats(tmap.beats_to_seconds(anchor) + Seconds(secs));
|
||||||
|
|
||||||
let clip_dur = effective_clip_duration(document, layer, ci).unwrap_or(Seconds::ZERO);
|
let clip_dur = effective_clip_duration(document, layer, ci).unwrap_or(ClipDuration::Seconds(Seconds::ZERO));
|
||||||
let clip_dur_secs = clip_dur.seconds_to_f64();
|
// Raw magnitudes in the clip's own content domain — the drag math below stays in
|
||||||
|
// that domain and only converts at the timeline edges.
|
||||||
|
let clip_dur_secs = clip_dur.native();
|
||||||
|
let ci_trim_start = ci.trim_start.raw();
|
||||||
let mut start = ci.effective_start();
|
let mut start = ci.effective_start();
|
||||||
let mut duration = ci.total_duration(clip_dur, tmap);
|
let mut duration = ci.total_duration(clip_dur, tmap);
|
||||||
|
|
||||||
|
|
@ -3600,25 +3623,25 @@ impl TimelinePane {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ClipDragType::TrimLeft => {
|
ClipDragType::TrimLeft => {
|
||||||
let new_trim = self.snap_to_grid(ci.trim_start + self.drag_offset, tmap, &document.time_signature, document.framerate, SNAP_PX_FINE).max(0.0).min(clip_dur_secs);
|
let new_trim = self.snap_to_grid(ci_trim_start + self.drag_offset, tmap, &document.time_signature, document.framerate, SNAP_PX_FINE).max(0.0).min(clip_dur_secs);
|
||||||
let trim_offset_secs = new_trim - ci.trim_start;
|
let trim_offset_secs = new_trim - ci_trim_start;
|
||||||
start = shift_beats(ci.timeline_start, trim_offset_secs).max(Beats::ZERO);
|
start = shift_beats(ci.timeline_start, trim_offset_secs).max(Beats::ZERO);
|
||||||
let dur_secs = if let Some(trim_end) = ci.trim_end {
|
let dur_secs = if let Some(trim_end) = ci.trim_end {
|
||||||
(trim_end - new_trim).max(0.0)
|
(trim_end.raw() - new_trim).max(0.0)
|
||||||
} else {
|
} else {
|
||||||
(clip_dur_secs - new_trim).max(0.0)
|
(clip_dur_secs - new_trim).max(0.0)
|
||||||
};
|
};
|
||||||
duration = secs_to_beats_at(start, dur_secs);
|
duration = secs_to_beats_at(start, dur_secs);
|
||||||
}
|
}
|
||||||
ClipDragType::TrimRight => {
|
ClipDragType::TrimRight => {
|
||||||
let old_trim_end = ci.trim_end.unwrap_or(clip_dur_secs);
|
let old_trim_end = ci.trim_end.map_or(clip_dur_secs, |t| t.raw());
|
||||||
let new_trim_end = self.snap_to_grid(old_trim_end + self.drag_offset, tmap, &document.time_signature, document.framerate, SNAP_PX_FINE).max(ci.trim_start).min(clip_dur_secs);
|
let new_trim_end = self.snap_to_grid(old_trim_end + self.drag_offset, tmap, &document.time_signature, document.framerate, SNAP_PX_FINE).max(ci_trim_start).min(clip_dur_secs);
|
||||||
let dur_secs = (new_trim_end - ci.trim_start).max(0.0);
|
let dur_secs = (new_trim_end - ci_trim_start).max(0.0);
|
||||||
duration = secs_to_beats_at(start, dur_secs);
|
duration = secs_to_beats_at(start, dur_secs);
|
||||||
}
|
}
|
||||||
ClipDragType::LoopExtendRight => {
|
ClipDragType::LoopExtendRight => {
|
||||||
let trim_end = ci.trim_end.unwrap_or(clip_dur_secs);
|
let trim_end = ci.trim_end.map_or(clip_dur_secs, |t| t.raw());
|
||||||
let content_window_secs = (trim_end - ci.trim_start).max(0.0);
|
let content_window_secs = (trim_end - ci_trim_start).max(0.0);
|
||||||
let content_window = secs_to_beats_at(ci.timeline_start, content_window_secs);
|
let content_window = secs_to_beats_at(ci.timeline_start, content_window_secs);
|
||||||
let current_right = ci.timeline_duration.unwrap_or(content_window);
|
let current_right = ci.timeline_duration.unwrap_or(content_window);
|
||||||
let right_edge_secs = tmap.beats_to_seconds(ci.timeline_start + current_right).seconds_to_f64() + self.drag_offset;
|
let right_edge_secs = tmap.beats_to_seconds(ci.timeline_start + current_right).seconds_to_f64() + self.drag_offset;
|
||||||
|
|
@ -3629,8 +3652,8 @@ impl TimelinePane {
|
||||||
duration = loop_before + new_right;
|
duration = loop_before + new_right;
|
||||||
}
|
}
|
||||||
ClipDragType::LoopExtendLeft => {
|
ClipDragType::LoopExtendLeft => {
|
||||||
let trim_end = ci.trim_end.unwrap_or(clip_dur_secs);
|
let trim_end = ci.trim_end.map_or(clip_dur_secs, |t| t.raw());
|
||||||
let content_window_secs = (trim_end - ci.trim_start).max(0.001);
|
let content_window_secs = (trim_end - ci_trim_start).max(0.001);
|
||||||
let content_window = secs_to_beats_at(ci.timeline_start, content_window_secs);
|
let content_window = secs_to_beats_at(ci.timeline_start, content_window_secs);
|
||||||
let current_loop_before = ci.loop_before.unwrap_or(Beats::ZERO);
|
let current_loop_before = ci.loop_before.unwrap_or(Beats::ZERO);
|
||||||
// drag_offset (seconds) as a beats delta at this clip's start.
|
// drag_offset (seconds) as a beats delta at this clip's start.
|
||||||
|
|
@ -3691,6 +3714,9 @@ impl TimelinePane {
|
||||||
// Track preview trim values for note/waveform rendering.
|
// Track preview trim values for note/waveform rendering.
|
||||||
// In Measures mode, derive from beats so they track BPM during live drag.
|
// In Measures mode, derive from beats so they track BPM during live drag.
|
||||||
let (base_trim_start, base_clip_duration) = self.content_display_range(clip_instance, clip_duration, document.bpm());
|
let (base_trim_start, base_clip_duration) = self.content_display_range(clip_instance, clip_duration, document.bpm());
|
||||||
|
// The instance's trim start as a raw magnitude in the clip's content domain; the
|
||||||
|
// preview math below stays in that domain.
|
||||||
|
let ci_trim_start = clip_instance.trim_start.raw();
|
||||||
let mut preview_trim_start = base_trim_start;
|
let mut preview_trim_start = base_trim_start;
|
||||||
let mut preview_clip_duration = base_clip_duration;
|
let mut preview_clip_duration = base_clip_duration;
|
||||||
|
|
||||||
|
|
@ -3706,11 +3732,11 @@ impl TimelinePane {
|
||||||
}
|
}
|
||||||
ClipDragType::TrimLeft => {
|
ClipDragType::TrimLeft => {
|
||||||
// Trim left: calculate new trim_start with snap to adjacent clips
|
// Trim left: calculate new trim_start with snap to adjacent clips
|
||||||
let desired_trim_start = self.snap_to_grid(clip_instance.trim_start + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate, SNAP_PX_FINE)
|
let desired_trim_start = self.snap_to_grid(ci_trim_start + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate, SNAP_PX_FINE)
|
||||||
.max(0.0)
|
.max(0.0)
|
||||||
.min(clip_duration.seconds_to_f64());
|
.min(clip_duration.native());
|
||||||
|
|
||||||
let new_trim_start = if desired_trim_start < clip_instance.trim_start {
|
let new_trim_start = if desired_trim_start < ci_trim_start {
|
||||||
// Extending left - limit is the content-seconds gap to the previous clip.
|
// Extending left - limit is the content-seconds gap to the previous clip.
|
||||||
let max_extend_secs = document.find_max_trim_extend_left(
|
let max_extend_secs = document.find_max_trim_extend_left(
|
||||||
&layer.id(),
|
&layer.id(),
|
||||||
|
|
@ -3718,25 +3744,25 @@ impl TimelinePane {
|
||||||
clip_instance.effective_start(),
|
clip_instance.effective_start(),
|
||||||
).seconds_to_f64();
|
).seconds_to_f64();
|
||||||
|
|
||||||
let desired_extend = clip_instance.trim_start - desired_trim_start;
|
let desired_extend = ci_trim_start - desired_trim_start;
|
||||||
let actual_extend = desired_extend.min(max_extend_secs);
|
let actual_extend = desired_extend.min(max_extend_secs);
|
||||||
clip_instance.trim_start - actual_extend
|
ci_trim_start - actual_extend
|
||||||
} else {
|
} else {
|
||||||
// Shrinking - no snap needed
|
// Shrinking - no snap needed
|
||||||
desired_trim_start
|
desired_trim_start
|
||||||
};
|
};
|
||||||
|
|
||||||
let actual_offset = new_trim_start - clip_instance.trim_start;
|
let actual_offset = new_trim_start - ci_trim_start;
|
||||||
|
|
||||||
// Move start (display seconds) and reduce duration by the clamped offset.
|
// Move start (display seconds) and reduce duration by the clamped offset.
|
||||||
instance_start = (document.tempo_map().beats_to_seconds(clip_instance.timeline_start).seconds_to_f64() + actual_offset)
|
instance_start = (document.tempo_map().beats_to_seconds(clip_instance.timeline_start).seconds_to_f64() + actual_offset)
|
||||||
.max(0.0);
|
.max(0.0);
|
||||||
|
|
||||||
instance_duration = (clip_duration.seconds_to_f64() - new_trim_start).max(0.0);
|
instance_duration = (clip_duration.native() - new_trim_start).max(0.0);
|
||||||
|
|
||||||
// Adjust for existing trim_end
|
// Adjust for existing trim_end
|
||||||
if let Some(trim_end) = clip_instance.trim_end {
|
if let Some(trim_end) = clip_instance.trim_end {
|
||||||
instance_duration = (trim_end - new_trim_start).max(0.0);
|
instance_duration = (trim_end.raw() - new_trim_start).max(0.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update preview trim for waveform rendering
|
// Update preview trim for waveform rendering
|
||||||
|
|
@ -3745,14 +3771,14 @@ impl TimelinePane {
|
||||||
}
|
}
|
||||||
ClipDragType::TrimRight => {
|
ClipDragType::TrimRight => {
|
||||||
// Trim right: extend or reduce duration with snap to adjacent clips
|
// Trim right: extend or reduce duration with snap to adjacent clips
|
||||||
let old_trim_end = clip_instance.trim_end.unwrap_or(clip_duration.seconds_to_f64());
|
let old_trim_end = clip_instance.trim_end.map_or(clip_duration.native(), |t| t.raw());
|
||||||
let desired_trim_end = self.snap_to_grid(old_trim_end + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate, SNAP_PX_FINE)
|
let desired_trim_end = self.snap_to_grid(old_trim_end + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate, SNAP_PX_FINE)
|
||||||
.max(clip_instance.trim_start)
|
.max(ci_trim_start)
|
||||||
.min(clip_duration.seconds_to_f64());
|
.min(clip_duration.native());
|
||||||
|
|
||||||
let new_trim_end = if desired_trim_end > old_trim_end {
|
let new_trim_end = if desired_trim_end > old_trim_end {
|
||||||
// Extending right - limit is the content-seconds gap to the next clip.
|
// Extending right - limit is the content-seconds gap to the next clip.
|
||||||
let current_duration_secs = old_trim_end - clip_instance.trim_start;
|
let current_duration_secs = old_trim_end - ci_trim_start;
|
||||||
let tmap = document.tempo_map();
|
let tmap = document.tempo_map();
|
||||||
let current_duration = tmap.seconds_to_beats(
|
let current_duration = tmap.seconds_to_beats(
|
||||||
tmap.beats_to_seconds(clip_instance.timeline_start) + Seconds(current_duration_secs)
|
tmap.beats_to_seconds(clip_instance.timeline_start) + Seconds(current_duration_secs)
|
||||||
|
|
@ -3772,7 +3798,7 @@ impl TimelinePane {
|
||||||
desired_trim_end
|
desired_trim_end
|
||||||
};
|
};
|
||||||
|
|
||||||
instance_duration = (new_trim_end - clip_instance.trim_start).max(0.0);
|
instance_duration = (new_trim_end - ci_trim_start).max(0.0);
|
||||||
|
|
||||||
// Update preview clip duration for waveform rendering
|
// Update preview clip duration for waveform rendering
|
||||||
// (the waveform system uses clip_duration to determine visible range)
|
// (the waveform system uses clip_duration to determine visible range)
|
||||||
|
|
@ -3780,8 +3806,8 @@ impl TimelinePane {
|
||||||
}
|
}
|
||||||
ClipDragType::LoopExtendRight => {
|
ClipDragType::LoopExtendRight => {
|
||||||
// Loop extend right: extend clip beyond content window
|
// Loop extend right: extend clip beyond content window
|
||||||
let trim_end = clip_instance.trim_end.unwrap_or(clip_duration.seconds_to_f64());
|
let trim_end = clip_instance.trim_end.map_or(clip_duration.native(), |t| t.raw());
|
||||||
let content_window_secs = (trim_end - clip_instance.trim_start).max(0.0);
|
let content_window_secs = (trim_end - ci_trim_start).max(0.0);
|
||||||
let tmap = document.tempo_map();
|
let tmap = document.tempo_map();
|
||||||
let ts = clip_instance.timeline_start;
|
let ts = clip_instance.timeline_start;
|
||||||
// content window and right-duration are beats-domain timeline spans.
|
// content window and right-duration are beats-domain timeline spans.
|
||||||
|
|
@ -3816,8 +3842,8 @@ impl TimelinePane {
|
||||||
ClipDragType::LoopExtendLeft => {
|
ClipDragType::LoopExtendLeft => {
|
||||||
// Loop extend left: extend loop_before (pre-loop region)
|
// Loop extend left: extend loop_before (pre-loop region)
|
||||||
// Snap to multiples of content_window so iterations align with backend
|
// Snap to multiples of content_window so iterations align with backend
|
||||||
let trim_end = clip_instance.trim_end.unwrap_or(clip_duration.seconds_to_f64());
|
let trim_end = clip_instance.trim_end.map_or(clip_duration.native(), |t| t.raw());
|
||||||
let content_window_secs = (trim_end - clip_instance.trim_start).max(0.001);
|
let content_window_secs = (trim_end - ci_trim_start).max(0.001);
|
||||||
let tmap = document.tempo_map();
|
let tmap = document.tempo_map();
|
||||||
let ts = clip_instance.timeline_start;
|
let ts = clip_instance.timeline_start;
|
||||||
// content window is a beats-domain span; guard against zero for division.
|
// content window is a beats-domain span; guard against zero for division.
|
||||||
|
|
@ -4057,7 +4083,7 @@ impl TimelinePane {
|
||||||
|
|
||||||
// Calculate content window for loop detection
|
// Calculate content window for loop detection
|
||||||
// Use trimmed content window (preview_trim_start accounts for TrimLeft drag)
|
// Use trimmed content window (preview_trim_start accounts for TrimLeft drag)
|
||||||
let preview_trim_end = clip_instance.trim_end.unwrap_or(clip_duration.seconds_to_f64());
|
let preview_trim_end = clip_instance.trim_end.map_or(clip_duration.native(), |t| t.raw());
|
||||||
let content_window = (preview_trim_end - preview_trim_start).max(0.0);
|
let content_window = (preview_trim_end - preview_trim_start).max(0.0);
|
||||||
let is_looping = instance_duration > content_window + 0.001;
|
let is_looping = instance_duration > content_window + 0.001;
|
||||||
|
|
||||||
|
|
@ -4254,7 +4280,7 @@ impl TimelinePane {
|
||||||
&video_mgr,
|
&video_mgr,
|
||||||
&mut self.video_thumbnail_textures,
|
&mut self.video_thumbnail_textures,
|
||||||
clip_instance.clip_id,
|
clip_instance.clip_id,
|
||||||
clip_instance.trim_start,
|
clip_instance.trim_start.raw(),
|
||||||
rect.min.x + start_x,
|
rect.min.x + start_x,
|
||||||
end_x - start_x,
|
end_x - start_x,
|
||||||
clip_rect,
|
clip_rect,
|
||||||
|
|
@ -4269,7 +4295,7 @@ impl TimelinePane {
|
||||||
// clip's TRUE (unclamped) origin x so the hover content time is
|
// clip's TRUE (unclamped) origin x so the hover content time is
|
||||||
// correct even when the clip is scrolled partly off the left.
|
// correct even when the clip is scrolled partly off the left.
|
||||||
if let lightningbeam_core::layer::AnyLayer::Video(_) = layer {
|
if let lightningbeam_core::layer::AnyLayer::Video(_) = layer {
|
||||||
video_clip_hovers.push((clip_rect, clip_instance.clip_id, clip_instance.trim_start, rect.min.x + start_x));
|
video_clip_hovers.push((clip_rect, clip_instance.clip_id, clip_instance.trim_start.raw(), rect.min.x + start_x));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw border per segment (per loop iteration for looping clips)
|
// Draw border per segment (per loop iteration for looping clips)
|
||||||
|
|
@ -5009,18 +5035,23 @@ impl TimelinePane {
|
||||||
for clip_instance in clip_instances {
|
for clip_instance in clip_instances {
|
||||||
if selection.contains_clip_instance(&clip_instance.id) {
|
if selection.contains_clip_instance(&clip_instance.id) {
|
||||||
let clip_duration = effective_clip_duration(document, layer, clip_instance);
|
let clip_duration = effective_clip_duration(document, layer, clip_instance);
|
||||||
|
// Raw magnitude in the clip's content domain; re-tagged as a
|
||||||
|
// ContentTime when it goes back into TrimData.
|
||||||
|
let ci_trim_start = clip_instance.trim_start.raw();
|
||||||
|
|
||||||
if let Some(clip_duration) = clip_duration {
|
if let Some(clip_duration) = clip_duration {
|
||||||
match drag_type {
|
match drag_type {
|
||||||
ClipDragType::TrimLeft => {
|
ClipDragType::TrimLeft => {
|
||||||
let old_trim_start = clip_instance.trim_start;
|
// Raw magnitude in the clip's content domain; re-tagged as a
|
||||||
|
// ContentTime when it goes back into TrimData below.
|
||||||
|
let old_trim_start = clip_instance.trim_start.raw();
|
||||||
let old_timeline_start =
|
let old_timeline_start =
|
||||||
clip_instance.timeline_start;
|
clip_instance.timeline_start;
|
||||||
|
|
||||||
// New trim_start is snapped then clamped to valid range
|
// New trim_start is snapped then clamped to valid range
|
||||||
let desired_trim_start = self.snap_to_grid(
|
let desired_trim_start = self.snap_to_grid(
|
||||||
old_trim_start + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate, SNAP_PX_FINE,
|
old_trim_start + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate, SNAP_PX_FINE,
|
||||||
).max(0.0).min(clip_duration.seconds_to_f64());
|
).max(0.0).min(clip_duration.native());
|
||||||
|
|
||||||
// Apply overlap prevention when extending left (content-seconds gap).
|
// Apply overlap prevention when extending left (content-seconds gap).
|
||||||
let new_trim_start = if desired_trim_start < old_trim_start {
|
let new_trim_start = if desired_trim_start < old_trim_start {
|
||||||
|
|
@ -5050,11 +5081,11 @@ impl TimelinePane {
|
||||||
clip_instance.id,
|
clip_instance.id,
|
||||||
lightningbeam_core::actions::TrimType::TrimLeft,
|
lightningbeam_core::actions::TrimType::TrimLeft,
|
||||||
lightningbeam_core::actions::TrimData::left(
|
lightningbeam_core::actions::TrimData::left(
|
||||||
old_trim_start,
|
ContentTime(old_trim_start),
|
||||||
old_timeline_start,
|
old_timeline_start,
|
||||||
),
|
),
|
||||||
lightningbeam_core::actions::TrimData::left(
|
lightningbeam_core::actions::TrimData::left(
|
||||||
new_trim_start,
|
ContentTime(new_trim_start),
|
||||||
new_timeline_start,
|
new_timeline_start,
|
||||||
),
|
),
|
||||||
));
|
));
|
||||||
|
|
@ -5065,10 +5096,10 @@ impl TimelinePane {
|
||||||
// Calculate new trim_end based on current duration
|
// Calculate new trim_end based on current duration
|
||||||
let current_duration =
|
let current_duration =
|
||||||
clip_instance.effective_duration(clip_duration, document.tempo_map());
|
clip_instance.effective_duration(clip_duration, document.tempo_map());
|
||||||
let old_trim_end_val = clip_instance.trim_end.unwrap_or(clip_duration.seconds_to_f64());
|
let old_trim_end_val = clip_instance.trim_end.map_or(clip_duration.native(), |t| t.raw());
|
||||||
let desired_trim_end = self.snap_to_grid(
|
let desired_trim_end = self.snap_to_grid(
|
||||||
old_trim_end_val + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate, SNAP_PX_FINE,
|
old_trim_end_val + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate, SNAP_PX_FINE,
|
||||||
).max(clip_instance.trim_start).min(clip_duration.seconds_to_f64());
|
).max(ci_trim_start).min(clip_duration.native());
|
||||||
|
|
||||||
// Apply overlap prevention when extending right (content-seconds gap).
|
// Apply overlap prevention when extending right (content-seconds gap).
|
||||||
let new_trim_end_val = if desired_trim_end > old_trim_end_val {
|
let new_trim_end_val = if desired_trim_end > old_trim_end_val {
|
||||||
|
|
@ -5085,13 +5116,15 @@ impl TimelinePane {
|
||||||
desired_trim_end
|
desired_trim_end
|
||||||
};
|
};
|
||||||
|
|
||||||
let new_duration = (new_trim_end_val - clip_instance.trim_start).max(0.0);
|
let new_duration = (new_trim_end_val - ci_trim_start).max(0.0);
|
||||||
|
|
||||||
// Convert new duration back to trim_end value
|
// Convert new duration back to trim_end value
|
||||||
let new_trim_end = if new_duration >= clip_duration.seconds_to_f64() {
|
let new_trim_end = if new_duration >= clip_duration.native() {
|
||||||
None // Use full clip duration
|
None // Use full clip duration
|
||||||
} else {
|
} else {
|
||||||
Some((clip_instance.trim_start + new_duration).min(clip_duration.seconds_to_f64()))
|
Some(ContentTime(
|
||||||
|
(ci_trim_start + new_duration).min(clip_duration.native()),
|
||||||
|
))
|
||||||
};
|
};
|
||||||
|
|
||||||
layer_trims
|
layer_trims
|
||||||
|
|
@ -5143,8 +5176,9 @@ impl TimelinePane {
|
||||||
if let Some(clip_duration) = clip_duration {
|
if let Some(clip_duration) = clip_duration {
|
||||||
let tmap = document.tempo_map();
|
let tmap = document.tempo_map();
|
||||||
let ts = clip_instance.timeline_start;
|
let ts = clip_instance.timeline_start;
|
||||||
let trim_end = clip_instance.trim_end.unwrap_or(clip_duration);
|
let ci_trim_start = clip_instance.trim_start.raw();
|
||||||
let content_window_secs = (trim_end - clip_instance.trim_start).max(0.0);
|
let trim_end = clip_instance.trim_end.map_or(clip_duration, |t| t.raw());
|
||||||
|
let content_window_secs = (trim_end - ci_trim_start).max(0.0);
|
||||||
let content_window = tmap.seconds_to_beats(tmap.beats_to_seconds(ts) + Seconds(content_window_secs)) - ts;
|
let content_window = tmap.seconds_to_beats(tmap.beats_to_seconds(ts) + Seconds(content_window_secs)) - ts;
|
||||||
let current_right = clip_instance.timeline_duration.unwrap_or(content_window);
|
let current_right = clip_instance.timeline_duration.unwrap_or(content_window);
|
||||||
// Snap the right edge in the seconds/pixel domain.
|
// Snap the right edge in the seconds/pixel domain.
|
||||||
|
|
@ -5217,8 +5251,9 @@ impl TimelinePane {
|
||||||
if let Some(clip_duration) = clip_duration {
|
if let Some(clip_duration) = clip_duration {
|
||||||
let tmap = document.tempo_map();
|
let tmap = document.tempo_map();
|
||||||
let ts = clip_instance.timeline_start;
|
let ts = clip_instance.timeline_start;
|
||||||
let trim_end = clip_instance.trim_end.unwrap_or(clip_duration);
|
let ci_trim_start = clip_instance.trim_start.raw();
|
||||||
let content_window_secs = (trim_end - clip_instance.trim_start).max(0.001);
|
let trim_end = clip_instance.trim_end.map_or(clip_duration, |t| t.raw());
|
||||||
|
let content_window_secs = (trim_end - ci_trim_start).max(0.001);
|
||||||
let content_window = tmap.seconds_to_beats(tmap.beats_to_seconds(ts) + Seconds(content_window_secs)) - ts;
|
let content_window = tmap.seconds_to_beats(tmap.beats_to_seconds(ts) + Seconds(content_window_secs)) - ts;
|
||||||
let cw = content_window.beats_to_f64().max(1e-9);
|
let cw = content_window.beats_to_f64().max(1e-9);
|
||||||
let current_loop_before = clip_instance.loop_before.unwrap_or(Beats::ZERO);
|
let current_loop_before = clip_instance.loop_before.unwrap_or(Beats::ZERO);
|
||||||
|
|
@ -6461,7 +6496,7 @@ impl PaneRenderer for TimelinePane {
|
||||||
let instances = layer_clips(layer);
|
let instances = layer_clips(layer);
|
||||||
for inst in instances {
|
for inst in instances {
|
||||||
if !shared.selection.contains_clip_instance(&inst.id) { continue; }
|
if !shared.selection.contains_clip_instance(&inst.id) { continue; }
|
||||||
if let Some(dur) = document.get_clip_duration(&inst.clip_id) {
|
if let Some(dur) = document.clip_trim_duration(&inst.clip_id) {
|
||||||
let eff = inst.effective_duration(dur, document.tempo_map());
|
let eff = inst.effective_duration(dur, document.tempo_map());
|
||||||
let start = document.tempo_map().beats_to_seconds(inst.timeline_start).seconds_to_f64();
|
let start = document.tempo_map().beats_to_seconds(inst.timeline_start).seconds_to_f64();
|
||||||
let end = document.tempo_map().beats_to_seconds(inst.timeline_start + eff).seconds_to_f64();
|
let end = document.tempo_map().beats_to_seconds(inst.timeline_start + eff).seconds_to_f64();
|
||||||
|
|
@ -6487,7 +6522,7 @@ impl PaneRenderer for TimelinePane {
|
||||||
enabled = instances.iter()
|
enabled = instances.iter()
|
||||||
.filter(|ci| shared.selection.contains_clip_instance(&ci.id))
|
.filter(|ci| shared.selection.contains_clip_instance(&ci.id))
|
||||||
.all(|ci| {
|
.all(|ci| {
|
||||||
if let Some(dur) = document.get_clip_duration(&ci.clip_id) {
|
if let Some(dur) = document.clip_trim_duration(&ci.clip_id) {
|
||||||
let eff = ci.effective_duration(dur, document.tempo_map());
|
let eff = ci.effective_duration(dur, document.tempo_map());
|
||||||
// Room to duplicate = seconds gap to the right ≥ this clip's own length.
|
// Room to duplicate = seconds gap to the right ≥ this clip's own length.
|
||||||
let max_extend_secs = document.find_max_trim_extend_right(
|
let max_extend_secs = document.find_max_trim_extend_right(
|
||||||
|
|
@ -6532,7 +6567,7 @@ impl PaneRenderer for TimelinePane {
|
||||||
|
|
||||||
enabled = instances.iter().all(|ci| {
|
enabled = instances.iter().all(|ci| {
|
||||||
let paste_start = (ci.timeline_start + offset).max(Beats::ZERO);
|
let paste_start = (ci.timeline_start + offset).max(Beats::ZERO);
|
||||||
if let Some(dur) = document.get_clip_duration(&ci.clip_id) {
|
if let Some(dur) = document.clip_trim_duration(&ci.clip_id) {
|
||||||
let eff = ci.effective_duration(dur, document.tempo_map());
|
let eff = ci.effective_duration(dur, document.tempo_map());
|
||||||
document
|
document
|
||||||
.find_nearest_valid_position(
|
.find_nearest_valid_position(
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue