Timeline types stage 2: fix editor seconds↔beats at every clip boundary
Thread Beats/Seconds through the editor now that lightningbeam-core is typed. The timeline UI is seconds-domain (pixels_per_second, viewport_start_time, playback_time, drag_offset), while ClipInstance.timeline_start/duration/ loop_before are beats — the compiler flagged every place the two were mixed. Fixes the reported bugs and the whole class behind them: - Recording placement (audio + MIDI + webcam): the new clip's timeline_start was written from playback_time (seconds) into a beats field, so a second recording landed at the wrong time. Now converted via the tempo map. - Drag/move: introduce snapped_move_offset -> Beats (snap in the seconds/pixel domain, express the anchor's movement in beats) and moved_start(); the group clamp, live preview, and commit all use one uniform beats offset instead of adding a seconds delta to a beats position. - Trim/loop drag preview + commit: overlap limits come from the timeline (beats) but trims are content seconds — converted at the boundary. - Drop (asset drag, stage + timeline), paste, duplicate, split-at-playhead: all convert the seconds drop/playhead position to beats before placing. - Stage playback gates + clip-local time remap: compared seconds playback_time against beats timeline_start; now both in seconds. - Piano roll / infopanel / effect export: clip_dur is Seconds; effect and waveform data converted at use. Core API refinement (motivated by the above): find_max_trim_extend_left/right now return Seconds (the content-seconds gap) instead of raw Beats, since every trim caller wants seconds; loop-extend callers convert to beats. Added a beats_to_x() timeline helper. Whole workspace compiles; 299 core tests pass.
This commit is contained in:
parent
053a77cfa1
commit
0050c18623
|
|
@ -204,20 +204,12 @@ 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 we can extend left is the timeline gap to the
|
// Max leftward extension as content seconds (the gap's wall-clock span).
|
||||||
// previous clip (beats).
|
let max_extend_secs = document.find_max_trim_extend_left(
|
||||||
let max_extend_beats = document.find_max_trim_extend_left(
|
|
||||||
layer_id,
|
layer_id,
|
||||||
instance_id,
|
instance_id,
|
||||||
instance.timeline_start,
|
instance.timeline_start,
|
||||||
);
|
).seconds_to_f64();
|
||||||
// Audio plays 1 content-second per timeline-second, so the
|
|
||||||
// revealable content equals that gap's wall-clock span.
|
|
||||||
let tempo_map = document.tempo_map();
|
|
||||||
let old_timeline_secs = tempo_map.beats_to_seconds(old_timeline);
|
|
||||||
let max_extend_secs = (old_timeline_secs
|
|
||||||
- tempo_map.beats_to_seconds(old_timeline - max_extend_beats))
|
|
||||||
.seconds_to_f64();
|
|
||||||
|
|
||||||
// Calculate how much we want to extend (content seconds)
|
// Calculate how much we want to extend (content seconds)
|
||||||
let desired_extend = old_trim - new_trim;
|
let desired_extend = old_trim - new_trim;
|
||||||
|
|
@ -226,8 +218,9 @@ impl Action for TrimClipInstancesAction {
|
||||||
let actual_extend = desired_extend.min(max_extend_secs);
|
let actual_extend = desired_extend.min(max_extend_secs);
|
||||||
let clamped_trim_start = old_trim - actual_extend;
|
let clamped_trim_start = old_trim - actual_extend;
|
||||||
// Move the timeline left by the same wall-clock seconds.
|
// Move the timeline left by the same wall-clock seconds.
|
||||||
|
let tempo_map = document.tempo_map();
|
||||||
let clamped_timeline_start = tempo_map
|
let clamped_timeline_start = tempo_map
|
||||||
.seconds_to_beats(old_timeline_secs - Seconds(actual_extend))
|
.seconds_to_beats(tempo_map.beats_to_seconds(old_timeline) - Seconds(actual_extend))
|
||||||
.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);
|
||||||
|
|
@ -248,19 +241,13 @@ impl Action for TrimClipInstancesAction {
|
||||||
tempo_map.beats_to_seconds(instance.timeline_start) + content_secs,
|
tempo_map.beats_to_seconds(instance.timeline_start) + content_secs,
|
||||||
) - instance.timeline_start;
|
) - instance.timeline_start;
|
||||||
|
|
||||||
// Max we can extend right is the timeline gap to the next
|
// Max rightward extension as content seconds (the gap's wall-clock span).
|
||||||
// clip (beats).
|
let max_extend_secs = document.find_max_trim_extend_right(
|
||||||
let max_extend_beats = document.find_max_trim_extend_right(
|
|
||||||
layer_id,
|
layer_id,
|
||||||
instance_id,
|
instance_id,
|
||||||
instance.timeline_start,
|
instance.timeline_start,
|
||||||
current_effective_duration,
|
current_effective_duration,
|
||||||
);
|
).seconds_to_f64();
|
||||||
// Convert that gap to content seconds at the clip's right edge.
|
|
||||||
let right_edge = instance.timeline_start + current_effective_duration;
|
|
||||||
let max_extend_secs = (tempo_map.beats_to_seconds(right_edge + max_extend_beats)
|
|
||||||
- tempo_map.beats_to_seconds(right_edge))
|
|
||||||
.seconds_to_f64();
|
|
||||||
|
|
||||||
// Calculate how much we want to extend (content seconds)
|
// 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;
|
||||||
|
|
|
||||||
|
|
@ -1186,19 +1186,21 @@ impl Document {
|
||||||
///
|
///
|
||||||
/// Returns the distance to the nearest clip to the left, or the distance to
|
/// Returns the distance to the nearest clip to the left, or the distance to
|
||||||
/// timeline start (0.0) if no clips exist to the left.
|
/// timeline start (0.0) if no clips exist to the left.
|
||||||
|
/// Returns the max leftward trim extension as a content-seconds span (the wall-clock
|
||||||
|
/// length of the timeline gap to the previous clip); the trim domain is seconds.
|
||||||
pub fn find_max_trim_extend_left(
|
pub fn find_max_trim_extend_left(
|
||||||
&self,
|
&self,
|
||||||
layer_id: &Uuid,
|
layer_id: &Uuid,
|
||||||
instance_id: &Uuid,
|
instance_id: &Uuid,
|
||||||
current_timeline_start: Beats,
|
current_timeline_start: Beats,
|
||||||
) -> Beats {
|
) -> Seconds {
|
||||||
let Some(layer) = self.get_layer(layer_id) else {
|
let Some(layer) = self.get_layer(layer_id) else {
|
||||||
return current_timeline_start; // No limit if layer not found
|
return self.tempo_map().beats_to_seconds(current_timeline_start); // No limit if layer not found
|
||||||
};
|
};
|
||||||
|
|
||||||
// Only check audio, video, and effect layers
|
// Only check audio, video, and effect layers
|
||||||
if matches!(layer, AnyLayer::Vector(_) | AnyLayer::Group(_)) {
|
if matches!(layer, AnyLayer::Vector(_) | AnyLayer::Group(_)) {
|
||||||
return current_timeline_start; // No limit for vector/group layers
|
return self.tempo_map().beats_to_seconds(current_timeline_start); // No limit for vector/group layers
|
||||||
};
|
};
|
||||||
|
|
||||||
// Find the nearest clip to the left
|
// Find the nearest clip to the left
|
||||||
|
|
@ -1231,27 +1233,27 @@ impl Document {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
current_timeline_start - nearest_end
|
self.tempo_map().beats_to_seconds(current_timeline_start) - self.tempo_map().beats_to_seconds(nearest_end)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Find the maximum amount we can extend a clip to the right without overlapping
|
/// Find the maximum amount we can extend a clip to the right without overlapping.
|
||||||
///
|
///
|
||||||
/// Returns the distance to the nearest clip to the right, or f64::MAX if no
|
/// Returns the content-seconds span of the timeline gap to the nearest clip on the
|
||||||
/// clips exist to the right.
|
/// right, or Seconds(f64::MAX) if none. `current_effective_duration` is beats (timeline).
|
||||||
pub fn find_max_trim_extend_right(
|
pub fn find_max_trim_extend_right(
|
||||||
&self,
|
&self,
|
||||||
layer_id: &Uuid,
|
layer_id: &Uuid,
|
||||||
instance_id: &Uuid,
|
instance_id: &Uuid,
|
||||||
current_timeline_start: Beats,
|
current_timeline_start: Beats,
|
||||||
current_effective_duration: Beats,
|
current_effective_duration: Beats,
|
||||||
) -> Beats {
|
) -> Seconds {
|
||||||
let Some(layer) = self.get_layer(layer_id) else {
|
let Some(layer) = self.get_layer(layer_id) else {
|
||||||
return Beats(f64::MAX); // No limit if layer not found
|
return Seconds(f64::MAX); // No limit if layer not found
|
||||||
};
|
};
|
||||||
|
|
||||||
// Only check audio, video, and effect layers
|
// Only check audio, video, and effect layers
|
||||||
if matches!(layer, AnyLayer::Vector(_) | AnyLayer::Group(_)) {
|
if matches!(layer, AnyLayer::Vector(_) | AnyLayer::Group(_)) {
|
||||||
return Beats(f64::MAX); // No limit for vector/group layers
|
return Seconds(f64::MAX); // No limit for vector/group layers
|
||||||
}
|
}
|
||||||
|
|
||||||
let instances: &[ClipInstance] = match layer {
|
let instances: &[ClipInstance] = match layer {
|
||||||
|
|
@ -1280,9 +1282,10 @@ impl Document {
|
||||||
}
|
}
|
||||||
|
|
||||||
if nearest_start == Beats(f64::MAX) {
|
if nearest_start == Beats(f64::MAX) {
|
||||||
Beats(f64::MAX) // No clip to the right, can extend freely
|
Seconds(f64::MAX) // No clip to the right, can extend freely
|
||||||
} else {
|
} else {
|
||||||
(nearest_start - current_end).max(Beats::ZERO) // Gap between our end and next clip's start
|
// Gap between our end and next clip's start, as content seconds.
|
||||||
|
(self.tempo_map().beats_to_seconds(nearest_start) - self.tempo_map().beats_to_seconds(current_end)).max(Seconds::ZERO)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/// Find the maximum amount we can extend loop_before to the left without overlapping.
|
/// Find the maximum amount we can extend loop_before to the left without overlapping.
|
||||||
|
|
|
||||||
|
|
@ -1062,10 +1062,13 @@ fn composite_document_to_hdr(
|
||||||
let success = gpu_resources.effect_processor.compile_effect(device, effect_def);
|
let success = gpu_resources.effect_processor.compile_effect(device, effect_def);
|
||||||
if !success { eprintln!("Failed to compile effect: {}", effect_def.name); continue; }
|
if !success { eprintln!("Failed to compile effect: {}", effect_def.name); continue; }
|
||||||
}
|
}
|
||||||
|
let tempo_map = document.tempo_map();
|
||||||
|
let effect_end_beats = effect_instance.timeline_start
|
||||||
|
+ effect_instance.effective_duration(daw_backend::Seconds(lightningbeam_core::effect::EFFECT_DURATION), tempo_map);
|
||||||
let effect_inst = lightningbeam_core::effect::EffectInstance::new(
|
let effect_inst = lightningbeam_core::effect::EffectInstance::new(
|
||||||
effect_def,
|
effect_def,
|
||||||
effect_instance.timeline_start,
|
tempo_map.beats_to_seconds(effect_instance.timeline_start).seconds_to_f64(),
|
||||||
effect_instance.timeline_start + effect_instance.effective_duration(lightningbeam_core::effect::EFFECT_DURATION, document.tempo_map()),
|
tempo_map.beats_to_seconds(effect_end_beats).seconds_to_f64(),
|
||||||
);
|
);
|
||||||
let effect_output_handle = gpu_resources.buffer_pool.acquire(device, hdr_spec);
|
let effect_output_handle = gpu_resources.buffer_pool.acquire(device, hdr_spec);
|
||||||
if let Some(effect_output_view) = gpu_resources.buffer_pool.get_view(effect_output_handle) {
|
if let Some(effect_output_view) = gpu_resources.buffer_pool.get_view(effect_output_handle) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
use eframe::egui;
|
use eframe::egui;
|
||||||
|
use daw_backend::{Beats, Seconds};
|
||||||
use lightningbeam_core::layer::{AnyLayer, AudioLayer};
|
use lightningbeam_core::layer::{AnyLayer, AudioLayer};
|
||||||
use lightningbeam_core::layout::{LayoutDefinition, LayoutNode};
|
use lightningbeam_core::layout::{LayoutDefinition, LayoutNode};
|
||||||
use lightningbeam_core::pane::PaneType;
|
use lightningbeam_core::pane::PaneType;
|
||||||
|
|
@ -2352,7 +2353,8 @@ impl EditorApp {
|
||||||
use lightningbeam_core::instance_group::InstanceGroup;
|
use lightningbeam_core::instance_group::InstanceGroup;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
let split_time = self.playback_time;
|
// Split position as a beats timeline position (playback_time is seconds).
|
||||||
|
let split_time = self.action_executor.document().tempo_map().seconds_to_beats(Seconds(self.playback_time));
|
||||||
let active_layer_id = match self.active_layer_id {
|
let active_layer_id = match self.active_layer_id {
|
||||||
Some(id) => id,
|
Some(id) => id,
|
||||||
None => return, // No active layer, nothing to split
|
None => return, // No active layer, nothing to split
|
||||||
|
|
@ -2363,7 +2365,7 @@ impl EditorApp {
|
||||||
// Helper to find clips that span the playhead in a specific layer
|
// Helper to find clips that span the playhead in a specific layer
|
||||||
fn find_splittable_clips(
|
fn find_splittable_clips(
|
||||||
clip_instances: &[lightningbeam_core::clip::ClipInstance],
|
clip_instances: &[lightningbeam_core::clip::ClipInstance],
|
||||||
split_time: f64,
|
split_time: Beats,
|
||||||
document: &lightningbeam_core::document::Document,
|
document: &lightningbeam_core::document::Document,
|
||||||
) -> Vec<uuid::Uuid> {
|
) -> Vec<uuid::Uuid> {
|
||||||
let mut result = Vec::new();
|
let mut result = Vec::new();
|
||||||
|
|
@ -2372,9 +2374,9 @@ impl EditorApp {
|
||||||
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;
|
||||||
|
|
||||||
const EPSILON: f64 = 0.001;
|
let epsilon = Beats(0.001);
|
||||||
if split_time > instance.timeline_start + EPSILON
|
if split_time > instance.timeline_start + epsilon
|
||||||
&& split_time < timeline_end - EPSILON
|
&& split_time < timeline_end - epsilon
|
||||||
{
|
{
|
||||||
result.push(instance.id);
|
result.push(instance.id);
|
||||||
}
|
}
|
||||||
|
|
@ -3051,10 +3053,11 @@ impl EditorApp {
|
||||||
let min_start = instances
|
let min_start = instances
|
||||||
.iter()
|
.iter()
|
||||||
.map(|i| i.timeline_start)
|
.map(|i| i.timeline_start)
|
||||||
.fold(f64::INFINITY, f64::min);
|
.fold(Beats(f64::INFINITY), |a, b| a.min(b));
|
||||||
let offset = self.playback_time - min_start;
|
let playhead_beats = self.action_executor.document().tempo_map().seconds_to_beats(Seconds(self.playback_time));
|
||||||
|
let offset = playhead_beats - min_start;
|
||||||
for inst in &mut instances {
|
for inst in &mut instances {
|
||||||
inst.timeline_start = (inst.timeline_start + offset).max(0.0);
|
inst.timeline_start = (inst.timeline_start + offset).max(Beats::ZERO);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3256,7 +3259,7 @@ 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(1.0);
|
let clip_duration = document.get_clip_duration(&original.clip_id).unwrap_or(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) {
|
||||||
|
|
@ -5553,6 +5556,7 @@ impl EditorApp {
|
||||||
use lightningbeam_core::layer::*;
|
use lightningbeam_core::layer::*;
|
||||||
|
|
||||||
let drop_time = self.playback_time;
|
let drop_time = self.playback_time;
|
||||||
|
let drop_beats = self.action_executor.document().tempo_map().seconds_to_beats(Seconds(drop_time));
|
||||||
|
|
||||||
// Find or create a compatible layer
|
// Find or create a compatible layer
|
||||||
let document = self.action_executor.document();
|
let document = self.action_executor.document();
|
||||||
|
|
@ -5645,7 +5649,7 @@ impl EditorApp {
|
||||||
} else {
|
} else {
|
||||||
// For clips, create a clip instance
|
// For clips, create a clip instance
|
||||||
let mut clip_instance = ClipInstance::new(asset_info.clip_id)
|
let mut clip_instance = ClipInstance::new(asset_info.clip_id)
|
||||||
.with_timeline_start(drop_time);
|
.with_timeline_start(drop_beats);
|
||||||
|
|
||||||
// For video clips, scale to fit and center in document
|
// For video clips, scale to fit and center in document
|
||||||
if asset_info.clip_type == panes::DragClipType::Video {
|
if asset_info.clip_type == panes::DragClipType::Video {
|
||||||
|
|
@ -5706,7 +5710,7 @@ impl EditorApp {
|
||||||
|
|
||||||
// Create audio clip instance at same timeline position
|
// Create audio clip instance at same timeline position
|
||||||
let audio_instance = ClipInstance::new(linked_audio_clip_id)
|
let audio_instance = ClipInstance::new(linked_audio_clip_id)
|
||||||
.with_timeline_start(drop_time);
|
.with_timeline_start(drop_beats);
|
||||||
let audio_instance_id = audio_instance.id;
|
let audio_instance_id = audio_instance.id;
|
||||||
|
|
||||||
// Execute audio action with backend sync
|
// Execute audio action with backend sync
|
||||||
|
|
@ -5748,7 +5752,7 @@ impl EditorApp {
|
||||||
|
|
||||||
// Find the video clip instance in the document
|
// Find the video clip instance in the document
|
||||||
let document = self.action_executor.document();
|
let document = self.action_executor.document();
|
||||||
let mut video_instance_info: Option<(uuid::Uuid, f64, bool)> = None; // (layer_id, timeline_start, already_in_group)
|
let mut video_instance_info: Option<(uuid::Uuid, Beats, bool)> = None; // (layer_id, timeline_start [beats], already_in_group)
|
||||||
|
|
||||||
// Search root layers for a video clip instance with matching clip_id
|
// Search root layers for a video clip instance with matching clip_id
|
||||||
for layer in &document.root.children {
|
for layer in &document.root.children {
|
||||||
|
|
@ -6418,9 +6422,10 @@ impl eframe::App for EditorApp {
|
||||||
let clip = AudioClip::new_recording("Recording...");
|
let clip = AudioClip::new_recording("Recording...");
|
||||||
let doc_clip_id = self.action_executor.document_mut().add_audio_clip(clip);
|
let doc_clip_id = self.action_executor.document_mut().add_audio_clip(clip);
|
||||||
|
|
||||||
// Create clip instance on the layer
|
// Create clip instance on the layer (recording_start_time is seconds)
|
||||||
|
let rec_start_beats = self.action_executor.document().tempo_map().seconds_to_beats(Seconds(self.recording_start_time));
|
||||||
let clip_instance = ClipInstance::new(doc_clip_id)
|
let clip_instance = ClipInstance::new(doc_clip_id)
|
||||||
.with_timeline_start(self.recording_start_time);
|
.with_timeline_start(rec_start_beats);
|
||||||
|
|
||||||
let clip_instance_id = clip_instance.id;
|
let clip_instance_id = clip_instance.id;
|
||||||
|
|
||||||
|
|
@ -6537,7 +6542,7 @@ impl eframe::App for EditorApp {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.unwrap_or((uuid::Uuid::nil(), uuid::Uuid::nil(), 0.0, 0.0))
|
.unwrap_or((uuid::Uuid::nil(), uuid::Uuid::nil(), Beats::ZERO, 0.0))
|
||||||
};
|
};
|
||||||
|
|
||||||
if !clip_id.is_nil() {
|
if !clip_id.is_nil() {
|
||||||
|
|
@ -7494,9 +7499,13 @@ impl eframe::App for EditorApp {
|
||||||
let duration = clip.duration;
|
let duration = clip.duration;
|
||||||
self.action_executor.document_mut().video_clips.insert(clip_id, clip);
|
self.action_executor.document_mut().video_clips.insert(clip_id, clip);
|
||||||
|
|
||||||
|
// recording_start_time and duration are seconds; convert to beats.
|
||||||
|
let tempo_map = self.action_executor.document().tempo_map();
|
||||||
|
let rec_start_beats = tempo_map.seconds_to_beats(Seconds(self.recording_start_time));
|
||||||
|
let dur_beats = tempo_map.seconds_to_beats(tempo_map.beats_to_seconds(rec_start_beats) + Seconds(duration)) - rec_start_beats;
|
||||||
let mut clip_instance = ClipInstance::new(clip_id)
|
let mut clip_instance = ClipInstance::new(clip_id)
|
||||||
.with_timeline_start(self.recording_start_time)
|
.with_timeline_start(rec_start_beats)
|
||||||
.with_timeline_duration(duration);
|
.with_timeline_duration(dur_beats);
|
||||||
|
|
||||||
// Scale to fit document and center (like drag-dropped videos)
|
// Scale to fit document and center (like drag-dropped videos)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1609,15 +1609,17 @@ impl InfopanelPane {
|
||||||
|
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.label("Start:");
|
ui.label("Start:");
|
||||||
ui.label(format!("{:.2}s", ci.effective_start()));
|
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.get_clip_duration(&ci.clip_id)
|
||||||
.unwrap_or_else(|| ci.trim_end.unwrap_or(1.0) - ci.trim_start);
|
.unwrap_or_else(|| daw_backend::Seconds(ci.trim_end.unwrap_or(1.0) - ci.trim_start));
|
||||||
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)
|
||||||
|
- document.tempo_map().beats_to_seconds(ci.effective_start())).seconds_to_f64();
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.label("Duration:");
|
ui.label("Duration:");
|
||||||
ui.label(format!("{:.2}s", total_dur));
|
ui.label(format!("{:.2}s", total_dur_secs));
|
||||||
});
|
});
|
||||||
|
|
||||||
if ci.trim_start > 0.0 {
|
if ci.trim_start > 0.0 {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
/// When a sampled audio layer is selected, shows a GPU-rendered spectrogram.
|
/// When a sampled audio layer is selected, shows a GPU-rendered spectrogram.
|
||||||
|
|
||||||
use eframe::egui;
|
use eframe::egui;
|
||||||
|
use daw_backend::Seconds;
|
||||||
use egui::{pos2, vec2, Align2, Color32, FontId, Rect, Stroke, StrokeKind};
|
use egui::{pos2, vec2, Align2, Color32, FontId, Rect, Stroke, StrokeKind};
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
@ -465,8 +466,8 @@ 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.duration, document.tempo_map());
|
let duration = instance.effective_duration(Seconds(clip.duration), document.tempo_map());
|
||||||
clip_data.push((midi_clip_id, instance.timeline_start, instance.trim_start, duration, instance.id));
|
clip_data.push((midi_clip_id, instance.timeline_start.beats_to_f64(), instance.trim_start, duration.beats_to_f64(), instance.id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2454,10 +2455,15 @@ 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::Sampled { audio_pool_index } = clip.clip_type {
|
if let AudioClipType::Sampled { audio_pool_index } = clip.clip_type {
|
||||||
let duration = instance.timeline_duration.unwrap_or(clip.duration);
|
// Duration in beats: explicit timeline_duration, else the clip's content
|
||||||
|
// length converted to beats at the clip's start.
|
||||||
|
let duration = instance.timeline_duration.unwrap_or_else(|| {
|
||||||
|
let tmap = document.tempo_map();
|
||||||
|
tmap.seconds_to_beats(tmap.beats_to_seconds(instance.timeline_start) + Seconds(clip.duration)) - instance.timeline_start
|
||||||
|
});
|
||||||
// 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, instance.trim_start, duration, *sr));
|
clip_infos.push((audio_pool_index, instance.timeline_start.beats_to_f64(), instance.trim_start, duration.beats_to_f64(), *sr));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
/// Supports HDR compositing pipeline with per-layer buffers and effects.
|
/// Supports HDR compositing pipeline with per-layer buffers and effects.
|
||||||
|
|
||||||
use eframe::egui;
|
use eframe::egui;
|
||||||
|
use daw_backend::Seconds;
|
||||||
use lightningbeam_core::action::Action;
|
use lightningbeam_core::action::Action;
|
||||||
use lightningbeam_core::clip::ClipInstance;
|
use lightningbeam_core::clip::ClipInstance;
|
||||||
use lightningbeam_core::gpu::{BufferPool, BufferFormat, BufferSpec, Compositor, EffectProcessor, SrgbToLinearConverter};
|
use lightningbeam_core::gpu::{BufferPool, BufferFormat, BufferSpec, Compositor, EffectProcessor, SrgbToLinearConverter};
|
||||||
|
|
@ -1851,10 +1852,13 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
|
||||||
|
|
||||||
// Create EffectInstance from ClipInstance for the processor
|
// Create EffectInstance from ClipInstance for the processor
|
||||||
// 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 effect_end_beats = effect_instance.timeline_start
|
||||||
|
+ effect_instance.effective_duration(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,
|
||||||
effect_instance.timeline_start,
|
tempo_map.beats_to_seconds(effect_instance.timeline_start).seconds_to_f64(),
|
||||||
effect_instance.timeline_start + effect_instance.effective_duration(lightningbeam_core::effect::EFFECT_DURATION, self.ctx.document.tempo_map()),
|
tempo_map.beats_to_seconds(effect_end_beats).seconds_to_f64(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Acquire temp buffer for effect output (HDR format)
|
// Acquire temp buffer for effect output (HDR format)
|
||||||
|
|
@ -2204,7 +2208,8 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
|
||||||
let combined_transform = overlay_transform * clip_transform;
|
let combined_transform = overlay_transform * clip_transform;
|
||||||
|
|
||||||
// Calculate clip bounds for preview
|
// Calculate clip bounds for preview
|
||||||
let clip_time = ((self.ctx.playback_time - clip_inst.timeline_start) * clip_inst.playback_speed) + clip_inst.trim_start;
|
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 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) {
|
||||||
|
|
@ -2293,15 +2298,19 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
|
||||||
// Also draw selection outlines for clip instances
|
// Also draw selection outlines for clip instances
|
||||||
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
|
// 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(0.0);
|
let clip_dur = self.ctx.document.get_clip_duration(&clip_instance.clip_id).unwrap_or(Seconds::ZERO);
|
||||||
let instance_end = clip_instance.timeline_start + clip_instance.effective_duration(clip_dur, self.ctx.document.tempo_map());
|
let tempo_map = self.ctx.document.tempo_map();
|
||||||
if self.ctx.playback_time < clip_instance.timeline_start || self.ctx.playback_time >= instance_end {
|
let start_secs = tempo_map.beats_to_seconds(clip_instance.timeline_start).seconds_to_f64();
|
||||||
|
let instance_end = tempo_map.beats_to_seconds(
|
||||||
|
clip_instance.timeline_start + clip_instance.effective_duration(clip_dur, tempo_map)
|
||||||
|
).seconds_to_f64();
|
||||||
|
if self.ctx.playback_time < start_secs || self.ctx.playback_time >= instance_end {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate clip-local time
|
// Calculate clip-local time
|
||||||
let clip_time = ((self.ctx.playback_time - clip_instance.timeline_start) * 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;
|
||||||
|
|
||||||
// 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) {
|
||||||
|
|
@ -2671,9 +2680,11 @@ 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(0.0);
|
let clip_duration = self.ctx.document.get_clip_duration(&inst.clip_id).unwrap_or(Seconds::ZERO);
|
||||||
let effective_duration = inst.effective_duration(clip_duration, self.ctx.document.tempo_map());
|
let tempo_map = self.ctx.document.tempo_map();
|
||||||
playback_time >= inst.timeline_start && playback_time < inst.timeline_start + effective_duration
|
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();
|
||||||
|
playback_time >= start_secs && playback_time < end_secs
|
||||||
});
|
});
|
||||||
|
|
||||||
if let Some(clip_inst) = visible_clip {
|
if let Some(clip_inst) = visible_clip {
|
||||||
|
|
@ -10130,7 +10141,8 @@ impl StagePane {
|
||||||
for &clip_id in shared.selection.clip_instances() {
|
for &clip_id in shared.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) {
|
||||||
// Calculate clip-local time
|
// Calculate clip-local time
|
||||||
let clip_time = ((*shared.playback_time - clip_instance.timeline_start) * clip_instance.playback_speed) + clip_instance.trim_start;
|
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;
|
||||||
|
|
||||||
// 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;
|
||||||
|
|
@ -10330,7 +10342,8 @@ impl StagePane {
|
||||||
// Try clip instance
|
// Try clip instance
|
||||||
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 clip_time = ((*shared.playback_time - clip_instance.timeline_start) * clip_instance.playback_speed) + clip_instance.trim_start;
|
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;
|
||||||
|
|
||||||
// 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) {
|
||||||
|
|
@ -11046,9 +11059,11 @@ 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(0.0);
|
let clip_duration = document.get_clip_duration(&inst.clip_id).unwrap_or(Seconds::ZERO);
|
||||||
let effective_duration = inst.effective_duration(clip_duration, document.tempo_map());
|
let tempo_map = document.tempo_map();
|
||||||
playback_time >= inst.timeline_start && playback_time < inst.timeline_start + effective_duration
|
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();
|
||||||
|
playback_time >= start_secs && playback_time < end_secs
|
||||||
}).map(|inst| inst.id)
|
}).map(|inst| inst.id)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
|
|
@ -12487,8 +12502,14 @@ impl PaneRenderer for StagePane {
|
||||||
let canvas_pos = pointer_pos - rect.min;
|
let canvas_pos = pointer_pos - rect.min;
|
||||||
let world_pos = (canvas_pos - self.pan_offset) / self.zoom;
|
let world_pos = (canvas_pos - self.pan_offset) / self.zoom;
|
||||||
|
|
||||||
// Use playhead time
|
// Use playhead time (seconds); the beats placement position for clips.
|
||||||
let drop_time = *shared.playback_time;
|
let drop_time = *shared.playback_time;
|
||||||
|
let drop_beats = shared.action_executor.document().tempo_map().seconds_to_beats(Seconds(drop_time));
|
||||||
|
// 5-second default effect duration as a beats span at the drop point.
|
||||||
|
let effect_dur_beats = {
|
||||||
|
let tmap = shared.action_executor.document().tempo_map();
|
||||||
|
tmap.seconds_to_beats(tmap.beats_to_seconds(drop_beats) + Seconds(5.0)) - drop_beats
|
||||||
|
};
|
||||||
|
|
||||||
// Find or create a compatible layer
|
// Find or create a compatible layer
|
||||||
let document = shared.action_executor.document();
|
let document = shared.action_executor.document();
|
||||||
|
|
@ -12559,8 +12580,8 @@ impl PaneRenderer for StagePane {
|
||||||
|
|
||||||
// Create clip instance for effect with 5 second default duration
|
// Create clip instance for effect with 5 second default duration
|
||||||
let clip_instance = ClipInstance::new(def.id)
|
let clip_instance = ClipInstance::new(def.id)
|
||||||
.with_timeline_start(drop_time)
|
.with_timeline_start(drop_beats)
|
||||||
.with_timeline_duration(5.0);
|
.with_timeline_duration(effect_dur_beats);
|
||||||
|
|
||||||
// Use AddEffectAction for effect layers
|
// Use AddEffectAction for effect layers
|
||||||
let action = lightningbeam_core::actions::AddEffectAction::new(
|
let action = lightningbeam_core::actions::AddEffectAction::new(
|
||||||
|
|
@ -12572,7 +12593,7 @@ impl PaneRenderer for StagePane {
|
||||||
} else {
|
} else {
|
||||||
// For clips, create a clip instance
|
// For clips, create a clip instance
|
||||||
let mut clip_instance = ClipInstance::new(dragging.clip_id)
|
let mut clip_instance = ClipInstance::new(dragging.clip_id)
|
||||||
.with_timeline_start(drop_time);
|
.with_timeline_start(drop_beats);
|
||||||
|
|
||||||
// For video clips, scale to fit and center in document
|
// For video clips, scale to fit and center in document
|
||||||
if dragging.clip_type == DragClipType::Video {
|
if dragging.clip_type == DragClipType::Video {
|
||||||
|
|
@ -12642,7 +12663,7 @@ impl PaneRenderer for StagePane {
|
||||||
|
|
||||||
// Create audio clip instance at same timeline position
|
// Create audio clip instance at same timeline position
|
||||||
let audio_instance = ClipInstance::new(linked_audio_clip_id)
|
let audio_instance = ClipInstance::new(linked_audio_clip_id)
|
||||||
.with_timeline_start(drop_time);
|
.with_timeline_start(drop_beats);
|
||||||
let audio_instance_id = audio_instance.id;
|
let audio_instance_id = audio_instance.id;
|
||||||
|
|
||||||
eprintln!("DEBUG STAGE: Created audio instance: {} for clip: {}", audio_instance_id, linked_audio_clip_id);
|
eprintln!("DEBUG STAGE: Created audio instance: {} for clip: {}", audio_instance_id, linked_audio_clip_id);
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
/// - Basic layer visualization
|
/// - Basic layer visualization
|
||||||
|
|
||||||
use eframe::egui;
|
use eframe::egui;
|
||||||
|
use daw_backend::{Beats, Seconds};
|
||||||
use lightningbeam_core::clip::{
|
use lightningbeam_core::clip::{
|
||||||
ClipInstance, audio_backend_uuid, midi_backend_uuid,
|
ClipInstance, audio_backend_uuid, midi_backend_uuid,
|
||||||
};
|
};
|
||||||
|
|
@ -77,11 +78,12 @@ 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.
|
||||||
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(0.0);
|
let clip_dur = effective_clip_duration(document, layer, ci).unwrap_or(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, end)
|
(start.beats_to_f64(), end.beats_to_f64())
|
||||||
}).collect();
|
}).collect();
|
||||||
|
|
||||||
compute_clip_stacking_from_ranges(&ranges)
|
compute_clip_stacking_from_ranges(&ranges)
|
||||||
|
|
@ -196,22 +198,23 @@ 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<f64> {
|
) -> Option<Seconds> {
|
||||||
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)?;
|
||||||
if vc.is_group {
|
if vc.is_group {
|
||||||
let frame_duration = 1.0 / document.framerate;
|
let frame_duration = 1.0 / document.framerate;
|
||||||
let end = vl.group_visibility_end(&clip_instance.id, clip_instance.timeline_start, frame_duration);
|
let start_secs = document.tempo_map().beats_to_seconds(clip_instance.timeline_start).seconds_to_f64();
|
||||||
Some((end - clip_instance.timeline_start).max(0.0))
|
let end = vl.group_visibility_end(&clip_instance.id, start_secs, frame_duration);
|
||||||
|
Some(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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AnyLayer::Audio(_) => document.get_audio_clip(&clip_instance.clip_id).map(|c| c.duration),
|
AnyLayer::Audio(_) => document.get_audio_clip(&clip_instance.clip_id).map(|c| Seconds(c.duration)),
|
||||||
AnyLayer::Video(_) => document.get_video_clip(&clip_instance.clip_id).map(|c| c.duration),
|
AnyLayer::Video(_) => document.get_video_clip(&clip_instance.clip_id).map(|c| Seconds(c.duration)),
|
||||||
AnyLayer::Effect(_) => Some(lightningbeam_core::effect::EFFECT_DURATION),
|
AnyLayer::Effect(_) => Some(Seconds(lightningbeam_core::effect::EFFECT_DURATION)),
|
||||||
AnyLayer::Group(_) => None,
|
AnyLayer::Group(_) => None,
|
||||||
AnyLayer::Raster(_) => None,
|
AnyLayer::Raster(_) => None,
|
||||||
AnyLayer::Text(_) => None,
|
AnyLayer::Text(_) => None,
|
||||||
|
|
@ -626,14 +629,14 @@ fn build_audio_clip_cache(
|
||||||
.unwrap_or_else(|| audio_backend_uuid(ac.id));
|
.unwrap_or_else(|| audio_backend_uuid(ac.id));
|
||||||
let mut ci = ClipInstance::new(clip_id);
|
let mut ci = ClipInstance::new(clip_id);
|
||||||
ci.id = instance_id;
|
ci.id = instance_id;
|
||||||
ci.timeline_start = ac.external_start.beats_to_f64();
|
ci.timeline_start = ac.external_start;
|
||||||
ci.trim_start = ac.internal_start.seconds_to_f64();
|
ci.trim_start = ac.internal_start.seconds_to_f64();
|
||||||
ci.trim_end = Some(ac.internal_end.seconds_to_f64());
|
ci.trim_end = Some(ac.internal_end.seconds_to_f64());
|
||||||
let internal_dur_secs = (ac.internal_end - ac.internal_start).seconds_to_f64();
|
let internal_dur_secs = (ac.internal_end - ac.internal_start).seconds_to_f64();
|
||||||
let tempo_map = document.tempo_map();
|
let tempo_map = document.tempo_map();
|
||||||
let external_dur_secs = tempo_map.transform(ac.external_duration.beats_to_f64());
|
let external_dur_secs = tempo_map.transform(ac.external_duration.beats_to_f64());
|
||||||
if (external_dur_secs - internal_dur_secs).abs() > 1e-9 {
|
if (external_dur_secs - internal_dur_secs).abs() > 1e-9 {
|
||||||
ci.timeline_duration = Some(ac.external_duration.beats_to_f64());
|
ci.timeline_duration = Some(ac.external_duration);
|
||||||
}
|
}
|
||||||
ci.gain = ac.gain;
|
ci.gain = ac.gain;
|
||||||
instances.push(ci);
|
instances.push(ci);
|
||||||
|
|
@ -650,12 +653,12 @@ fn build_audio_clip_cache(
|
||||||
.unwrap_or_else(|| midi_backend_uuid(mc.id));
|
.unwrap_or_else(|| midi_backend_uuid(mc.id));
|
||||||
let mut ci = ClipInstance::new(clip_id);
|
let mut ci = ClipInstance::new(clip_id);
|
||||||
ci.id = instance_id;
|
ci.id = instance_id;
|
||||||
ci.timeline_start = mc.external_start.beats_to_f64();
|
ci.timeline_start = mc.external_start;
|
||||||
ci.trim_start = mc.internal_start.beats_to_f64();
|
ci.trim_start = mc.internal_start.beats_to_f64();
|
||||||
ci.trim_end = Some(mc.internal_end.beats_to_f64());
|
ci.trim_end = Some(mc.internal_end.beats_to_f64());
|
||||||
// Always set timeline_duration for MIDI clips: duration is in beats, so we
|
// Always set timeline_duration for MIDI clips: duration is in beats, so we
|
||||||
// must bypass the content_window_secs * bpm/60 formula (which expects seconds).
|
// must bypass the content_window_secs * bpm/60 formula (which expects seconds).
|
||||||
ci.timeline_duration = Some(mc.external_duration.beats_to_f64());
|
ci.timeline_duration = Some(mc.external_duration);
|
||||||
instances.push(ci);
|
instances.push(ci);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -734,12 +737,13 @@ fn collect_clip_instances<'a>(
|
||||||
fn find_sampled_audio_track_for_clip(
|
fn find_sampled_audio_track_for_clip(
|
||||||
document: &lightningbeam_core::document::Document,
|
document: &lightningbeam_core::document::Document,
|
||||||
clip_id: uuid::Uuid,
|
clip_id: uuid::Uuid,
|
||||||
timeline_start: f64,
|
timeline_start: Beats,
|
||||||
editing_clip_id: Option<&uuid::Uuid>,
|
editing_clip_id: Option<&uuid::Uuid>,
|
||||||
) -> Option<uuid::Uuid> {
|
) -> Option<uuid::Uuid> {
|
||||||
// Get the clip duration
|
// Get the clip duration (content seconds) and convert its span to beats at the drop point.
|
||||||
let clip_duration = document.get_clip_duration(&clip_id)?;
|
let clip_duration = document.get_clip_duration(&clip_id)?;
|
||||||
let clip_end = timeline_start + clip_duration;
|
let tempo_map = document.tempo_map();
|
||||||
|
let clip_end = tempo_map.seconds_to_beats(tempo_map.beats_to_seconds(timeline_start) + clip_duration);
|
||||||
|
|
||||||
// Check each sampled audio layer
|
// Check each sampled audio layer
|
||||||
let context_layers = document.context_layers(editing_clip_id);
|
let context_layers = document.context_layers(editing_clip_id);
|
||||||
|
|
@ -1170,8 +1174,10 @@ impl TimelinePane {
|
||||||
*shared.recording_clips.get(&layer_id).unwrap_or(&0), daw_backend::Beats::ZERO);
|
*shared.recording_clips.get(&layer_id).unwrap_or(&0), daw_backend::Beats::ZERO);
|
||||||
let doc_clip_id = shared.action_executor.document_mut().add_audio_clip(doc_clip);
|
let doc_clip_id = shared.action_executor.document_mut().add_audio_clip(doc_clip);
|
||||||
|
|
||||||
|
let start_beats = shared.action_executor.document().tempo_map()
|
||||||
|
.seconds_to_beats(Seconds(start_time));
|
||||||
let clip_instance = ClipInstance::new(doc_clip_id)
|
let clip_instance = ClipInstance::new(doc_clip_id)
|
||||||
.with_timeline_start(start_time);
|
.with_timeline_start(start_beats);
|
||||||
|
|
||||||
if let Some(layer) = shared.action_executor.document_mut().get_layer_mut(&layer_id) {
|
if let Some(layer) = shared.action_executor.document_mut().get_layer_mut(&layer_id) {
|
||||||
if let lightningbeam_core::layer::AnyLayer::Audio(audio_layer) = layer {
|
if let lightningbeam_core::layer::AnyLayer::Audio(audio_layer) = layer {
|
||||||
|
|
@ -1347,8 +1353,8 @@ impl TimelinePane {
|
||||||
let instance_duration = clip_instance.total_duration(clip_duration, tempo_map);
|
let instance_duration = clip_instance.total_duration(clip_duration, tempo_map);
|
||||||
let instance_end = instance_start + instance_duration;
|
let instance_end = instance_start + instance_duration;
|
||||||
|
|
||||||
let start_x = self.time_to_x(instance_start);
|
let start_x = self.beats_to_x(instance_start, tempo_map);
|
||||||
let end_x = self.time_to_x(instance_end).max(start_x + MIN_CLIP_WIDTH_PX);
|
let end_x = self.beats_to_x(instance_end, tempo_map).max(start_x + MIN_CLIP_WIDTH_PX);
|
||||||
let mouse_x = pointer_pos.x - content_rect.min.x;
|
let mouse_x = pointer_pos.x - content_rect.min.x;
|
||||||
|
|
||||||
if mouse_x >= start_x && mouse_x <= end_x {
|
if mouse_x >= start_x && mouse_x <= end_x {
|
||||||
|
|
@ -1422,12 +1428,12 @@ impl TimelinePane {
|
||||||
|
|
||||||
// Compute merged spans with the child clip IDs that contribute to each
|
// Compute merged spans with the child clip IDs that contribute to each
|
||||||
let child_clips = group.all_child_clip_instances();
|
let child_clips = group.all_child_clip_instances();
|
||||||
let mut spans: Vec<(f64, f64, Vec<uuid::Uuid>)> = Vec::new(); // (start, end, clip_ids)
|
let mut spans: Vec<(Beats, Beats, Vec<uuid::Uuid>)> = Vec::new(); // (start, end, clip_ids) in beats
|
||||||
|
|
||||||
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.get_clip_duration(&ci.clip_id).unwrap_or_else(|| {
|
||||||
ci.trim_end.unwrap_or(1.0) - ci.trim_start
|
Seconds(ci.trim_end.unwrap_or(1.0) - ci.trim_start)
|
||||||
});
|
});
|
||||||
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);
|
||||||
|
|
@ -1437,7 +1443,7 @@ impl TimelinePane {
|
||||||
spans.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
|
spans.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
|
||||||
|
|
||||||
// Merge overlapping spans
|
// Merge overlapping spans
|
||||||
let mut merged: Vec<(f64, f64, Vec<uuid::Uuid>)> = Vec::new();
|
let mut merged: Vec<(Beats, Beats, Vec<uuid::Uuid>)> = Vec::new();
|
||||||
for (s, e, ids) in spans {
|
for (s, e, ids) in spans {
|
||||||
if let Some(last) = merged.last_mut() {
|
if let Some(last) = merged.last_mut() {
|
||||||
if s <= last.1 {
|
if s <= last.1 {
|
||||||
|
|
@ -1454,8 +1460,8 @@ impl TimelinePane {
|
||||||
// Check which merged span the pointer is over
|
// Check which merged span the pointer is over
|
||||||
let mouse_x = pointer_pos.x - content_rect.min.x;
|
let mouse_x = pointer_pos.x - content_rect.min.x;
|
||||||
for (s, e, ids) in merged {
|
for (s, e, ids) in merged {
|
||||||
let sx = self.time_to_x(s);
|
let sx = self.beats_to_x(s, tempo_map);
|
||||||
let ex = self.time_to_x(e).max(sx + MIN_CLIP_WIDTH_PX);
|
let ex = self.beats_to_x(e, tempo_map).max(sx + MIN_CLIP_WIDTH_PX);
|
||||||
if mouse_x >= sx && mouse_x <= ex {
|
if mouse_x >= sx && mouse_x <= ex {
|
||||||
return Some(ids);
|
return Some(ids);
|
||||||
}
|
}
|
||||||
|
|
@ -1517,24 +1523,30 @@ impl TimelinePane {
|
||||||
((time - self.viewport_start_time) * self.pixels_per_second as f64) as f32
|
((time - self.viewport_start_time) * self.pixels_per_second as f64) as f32
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Convert a beats timeline position to pixel x-coordinate (via seconds).
|
||||||
|
fn beats_to_x(&self, beats: Beats, tempo_map: &daw_backend::TempoMap) -> f32 {
|
||||||
|
self.time_to_x(tempo_map.beats_to_seconds(beats).seconds_to_f64())
|
||||||
|
}
|
||||||
|
|
||||||
/// Effective display start for a clip instance, in seconds.
|
/// Effective display start for a clip instance, in seconds.
|
||||||
///
|
///
|
||||||
/// `timeline_start` is in beats; converts to seconds using the current (preview) BPM so
|
/// `timeline_start` is in beats; converts to seconds using the current (preview) BPM so
|
||||||
/// clips stay anchored to their beat position during live BPM drag.
|
/// clips stay anchored to their beat position during live BPM drag.
|
||||||
fn instance_display_start(&self, ci: &lightningbeam_core::clip::ClipInstance, tempo_map: &daw_backend::TempoMap) -> f64 {
|
fn instance_display_start(&self, ci: &lightningbeam_core::clip::ClipInstance, tempo_map: &daw_backend::TempoMap) -> f64 {
|
||||||
tempo_map.transform(ci.effective_start())
|
tempo_map.beats_to_seconds(ci.effective_start()).seconds_to_f64()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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: f64, tempo_map: &daw_backend::TempoMap) -> f64 {
|
fn instance_display_duration(&self, ci: &lightningbeam_core::clip::ClipInstance, clip_dur_secs: Seconds, tempo_map: &daw_backend::TempoMap) -> f64 {
|
||||||
tempo_map.transform(ci.timeline_start + ci.total_duration(clip_dur_secs, tempo_map)) - tempo_map.transform(ci.effective_start())
|
(tempo_map.beats_to_seconds(ci.timeline_start + ci.total_duration(clip_dur_secs, tempo_map))
|
||||||
|
- tempo_map.beats_to_seconds(ci.effective_start())).seconds_to_f64()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the clip content start (trim_start) and duration in display seconds.
|
/// Returns the clip content start (trim_start) and duration in display seconds.
|
||||||
fn content_display_range(&self, ci: &lightningbeam_core::clip::ClipInstance, clip_dur_secs: f64, _bpm: f64) -> (f64, f64) {
|
fn content_display_range(&self, ci: &lightningbeam_core::clip::ClipInstance, clip_dur_secs: Seconds, _bpm: f64) -> (f64, f64) {
|
||||||
let trim_end = ci.trim_end.unwrap_or(clip_dur_secs);
|
let trim_end = ci.trim_end.unwrap_or(clip_dur_secs.seconds_to_f64());
|
||||||
(ci.trim_start, (trim_end - ci.trim_start).max(0.0))
|
(ci.trim_start, (trim_end - ci.trim_start).max(0.0))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1593,21 +1605,33 @@ impl TimelinePane {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Effective drag offset for Move operations.
|
/// Effective drag offset for Move operations, as a uniform beats delta.
|
||||||
/// Snaps the anchor clip's resulting position to the grid; all selected clips use the same offset.
|
/// Snapping happens in the seconds/pixel domain (where the grid and `drag_anchor_start`/
|
||||||
|
/// `drag_offset` live); the result is the anchor's net snapped movement expressed in beats,
|
||||||
|
/// so every selected clip shifts by the same beats amount (preserving beat-relative spacing).
|
||||||
fn snapped_move_offset(
|
fn snapped_move_offset(
|
||||||
&self,
|
&self,
|
||||||
tempo_map: &daw_backend::TempoMap,
|
tempo_map: &daw_backend::TempoMap,
|
||||||
time_sig: &lightningbeam_core::document::TimeSignature,
|
time_sig: &lightningbeam_core::document::TimeSignature,
|
||||||
framerate: f64,
|
framerate: f64,
|
||||||
) -> f64 {
|
) -> Beats {
|
||||||
match self.quantize_grid_size(tempo_map, time_sig, framerate) {
|
let anchor = self.drag_anchor_start; // seconds
|
||||||
Some(grid) => {
|
let target = match self.quantize_grid_size(tempo_map, time_sig, framerate) {
|
||||||
let snapped = ((self.drag_anchor_start + self.drag_offset) / grid).round() * grid;
|
Some(grid) => ((anchor + self.drag_offset) / grid).round() * grid,
|
||||||
snapped - self.drag_anchor_start
|
None => anchor + self.drag_offset,
|
||||||
}
|
};
|
||||||
None => self.drag_offset,
|
tempo_map.seconds_to_beats(Seconds(target)) - tempo_map.seconds_to_beats(Seconds(anchor))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Apply the current snapped move-drag offset to a clip's beats start, clamped to ≥ 0.
|
||||||
|
fn moved_start(
|
||||||
|
&self,
|
||||||
|
start: Beats,
|
||||||
|
tempo_map: &daw_backend::TempoMap,
|
||||||
|
time_sig: &lightningbeam_core::document::TimeSignature,
|
||||||
|
framerate: f64,
|
||||||
|
) -> Beats {
|
||||||
|
(start + self.snapped_move_offset(tempo_map, time_sig, framerate)).max(Beats::ZERO)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calculate appropriate interval for time ruler based on zoom level
|
/// Calculate appropriate interval for time ruler based on zoom level
|
||||||
|
|
@ -2995,23 +3019,23 @@ impl TimelinePane {
|
||||||
// Collect all child clip time ranges (with drag preview offset)
|
// Collect all child clip time ranges (with drag preview offset)
|
||||||
let child_clips = g.all_child_clip_instances();
|
let child_clips = g.all_child_clip_instances();
|
||||||
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<(f64, f64)> = 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.get_clip_duration(&ci.clip_id).unwrap_or_else(|| {
|
||||||
ci.trim_end.unwrap_or(1.0) - ci.trim_start
|
Seconds(ci.trim_end.unwrap_or(1.0) - ci.trim_start)
|
||||||
});
|
});
|
||||||
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());
|
||||||
// Apply drag offset for selected clips during move
|
// Apply drag offset for selected clips during move
|
||||||
if is_move_drag && selection.contains_clip_instance(&ci.id) {
|
if is_move_drag && selection.contains_clip_instance(&ci.id) {
|
||||||
start = (start + self.snapped_move_offset(document.tempo_map(), &document.time_signature, document.framerate)).max(0.0);
|
start = self.moved_start(start, document.tempo_map(), &document.time_signature, document.framerate);
|
||||||
}
|
}
|
||||||
ranges.push((start, start + dur));
|
ranges.push((start, start + dur));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort and merge overlapping ranges
|
// Sort and merge overlapping ranges
|
||||||
ranges.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
|
ranges.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
|
||||||
let mut merged: Vec<(f64, f64)> = Vec::new();
|
let mut merged: Vec<(Beats, Beats)> = Vec::new();
|
||||||
for (s, e) in ranges {
|
for (s, e) in ranges {
|
||||||
if let Some(last) = merged.last_mut() {
|
if let Some(last) = merged.last_mut() {
|
||||||
if s <= last.1 {
|
if s <= last.1 {
|
||||||
|
|
@ -3038,9 +3062,9 @@ impl TimelinePane {
|
||||||
theme.text_color(&["#timeline", ".group-bar"], ui.ctx(), egui::Color32::from_rgb(100, 220, 220))
|
theme.text_color(&["#timeline", ".group-bar"], ui.ctx(), egui::Color32::from_rgb(100, 220, 220))
|
||||||
};
|
};
|
||||||
for (s, e) in &merged {
|
for (s, e) in &merged {
|
||||||
// `merged` ranges are in beats; convert to seconds for time_to_x.
|
// `merged` ranges are in beats.
|
||||||
let sx = self.time_to_x(document.tempo_map().transform(*s));
|
let sx = self.beats_to_x(*s, document.tempo_map());
|
||||||
let ex = self.time_to_x(document.tempo_map().transform(*e)).max(sx + MIN_CLIP_WIDTH_PX);
|
let ex = self.beats_to_x(*e, document.tempo_map()).max(sx + MIN_CLIP_WIDTH_PX);
|
||||||
if ex >= 0.0 && sx <= rect.width() {
|
if ex >= 0.0 && sx <= rect.width() {
|
||||||
let vsx = sx.max(0.0);
|
let vsx = sx.max(0.0);
|
||||||
let vex = ex.min(rect.width());
|
let vex = ex.min(rect.width());
|
||||||
|
|
@ -3072,17 +3096,17 @@ impl TimelinePane {
|
||||||
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.get_clip_duration(&ci.clip_id)
|
||||||
.unwrap_or_else(|| ci.trim_end.unwrap_or(1.0) - ci.trim_start);
|
.unwrap_or_else(|| Seconds(ci.trim_end.unwrap_or(1.0) - ci.trim_start));
|
||||||
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 = (ci_start + self.snapped_move_offset(document.tempo_map(), &document.time_signature, document.framerate)).max(0.0);
|
ci_start = self.moved_start(ci_start, document.tempo_map(), &document.time_signature, document.framerate);
|
||||||
}
|
}
|
||||||
let ci_duration = ci.total_duration(clip_dur, document.tempo_map());
|
let ci_duration = ci.total_duration(clip_dur, document.tempo_map());
|
||||||
let ci_end = ci_start + ci_duration;
|
let ci_end = ci_start + ci_duration;
|
||||||
|
|
||||||
// ci_start/ci_end are in beats; convert to seconds for time_to_x.
|
// ci_start/ci_end are in beats.
|
||||||
let sx = self.time_to_x(document.tempo_map().transform(ci_start));
|
let sx = self.beats_to_x(ci_start, document.tempo_map());
|
||||||
let ex = self.time_to_x(document.tempo_map().transform(ci_end));
|
let ex = self.beats_to_x(ci_end, document.tempo_map());
|
||||||
if ex < 0.0 || sx > rect.width() { continue; }
|
if ex < 0.0 || sx > rect.width() { continue; }
|
||||||
|
|
||||||
let ci_rect = egui::Rect::from_min_max(
|
let ci_rect = egui::Rect::from_min_max(
|
||||||
|
|
@ -3161,16 +3185,16 @@ 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.duration;
|
let clip_dur = Seconds(audio_clip.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 = (ci_start + self.snapped_move_offset(document.tempo_map(), &document.time_signature, document.framerate)).max(0.0);
|
ci_start = self.moved_start(ci_start, document.tempo_map(), &document.time_signature, document.framerate);
|
||||||
}
|
}
|
||||||
let ci_duration = ci.total_duration(clip_dur, document.tempo_map());
|
let ci_duration = ci.total_duration(clip_dur, document.tempo_map());
|
||||||
|
|
||||||
// ci_start/ci_duration are in beats; convert to seconds for time_to_x.
|
// ci_start/ci_duration are in beats.
|
||||||
let ci_screen_start = rect.min.x + self.time_to_x(document.tempo_map().transform(ci_start));
|
let ci_screen_start = rect.min.x + self.beats_to_x(ci_start, document.tempo_map());
|
||||||
let ci_screen_end = rect.min.x + self.time_to_x(document.tempo_map().transform(ci_start + ci_duration));
|
let ci_screen_end = rect.min.x + self.beats_to_x(ci_start + ci_duration, document.tempo_map());
|
||||||
|
|
||||||
let waveform_rect = egui::Rect::from_min_max(
|
let waveform_rect = egui::Rect::from_min_max(
|
||||||
egui::pos2(ci_screen_start.max(rect.min.x), wave_y_min),
|
egui::pos2(ci_screen_start.max(rect.min.x), wave_y_min),
|
||||||
|
|
@ -3292,7 +3316,7 @@ impl TimelinePane {
|
||||||
|
|
||||||
// For moves, precompute the clamped offset so all selected clips move uniformly
|
// For moves, precompute the clamped offset so all selected clips move uniformly
|
||||||
let group_move_offset = if self.clip_drag_state == Some(ClipDragType::Move) {
|
let group_move_offset = if self.clip_drag_state == Some(ClipDragType::Move) {
|
||||||
let group: Vec<(uuid::Uuid, f64, f64)> = 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.get_clip_duration(&ci.clip_id)?;
|
||||||
|
|
@ -3311,9 +3335,18 @@ impl TimelinePane {
|
||||||
// Compute stacking using preview positions (with drag offsets) for vector layers
|
// Compute stacking using preview positions (with drag offsets) for vector layers
|
||||||
let clip_stacking = if matches!(layer, AnyLayer::Vector(_)) && clip_instances.len() > 1 {
|
let clip_stacking = if matches!(layer, AnyLayer::Vector(_)) && clip_instances.len() > 1 {
|
||||||
let preview_ranges: Vec<(f64, f64)> = clip_instances.iter().map(|ci| {
|
let preview_ranges: Vec<(f64, f64)> = clip_instances.iter().map(|ci| {
|
||||||
let clip_dur = effective_clip_duration(document, layer, ci).unwrap_or(0.0);
|
let tmap = document.tempo_map();
|
||||||
|
// Beats span occupied by `secs` of content starting at beats position `anchor`.
|
||||||
|
let secs_to_beats_at = |anchor: Beats, secs: f64|
|
||||||
|
tmap.seconds_to_beats(tmap.beats_to_seconds(anchor) + Seconds(secs)) - anchor;
|
||||||
|
// Beats position `secs` seconds (wall-clock) after beats position `anchor`.
|
||||||
|
let shift_beats = |anchor: Beats, secs: f64|
|
||||||
|
tmap.seconds_to_beats(tmap.beats_to_seconds(anchor) + Seconds(secs));
|
||||||
|
|
||||||
|
let clip_dur = effective_clip_duration(document, layer, ci).unwrap_or(Seconds::ZERO);
|
||||||
|
let clip_dur_secs = clip_dur.seconds_to_f64();
|
||||||
let mut start = ci.effective_start();
|
let mut start = ci.effective_start();
|
||||||
let mut duration = ci.total_duration(clip_dur, document.tempo_map());
|
let mut duration = ci.total_duration(clip_dur, tmap);
|
||||||
|
|
||||||
let is_selected = selection.contains_clip_instance(&ci.id);
|
let is_selected = selection.contains_clip_instance(&ci.id);
|
||||||
let is_linked = if self.clip_drag_state.is_some() {
|
let is_linked = if self.clip_drag_state.is_some() {
|
||||||
|
|
@ -3329,47 +3362,57 @@ impl TimelinePane {
|
||||||
match drag_type {
|
match drag_type {
|
||||||
ClipDragType::Move => {
|
ClipDragType::Move => {
|
||||||
if let Some(offset) = group_move_offset {
|
if let Some(offset) = group_move_offset {
|
||||||
start = (ci.effective_start() + offset).max(0.0);
|
start = (ci.effective_start() + offset).max(Beats::ZERO);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ClipDragType::TrimLeft => {
|
ClipDragType::TrimLeft => {
|
||||||
let new_trim = self.snap_to_grid(ci.trim_start + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate).max(0.0).min(clip_dur);
|
let new_trim = self.snap_to_grid(ci.trim_start + self.drag_offset, tmap, &document.time_signature, document.framerate).max(0.0).min(clip_dur_secs);
|
||||||
let offset = new_trim - ci.trim_start;
|
let trim_offset_secs = new_trim - ci.trim_start;
|
||||||
start = (ci.timeline_start + offset).max(0.0);
|
start = shift_beats(ci.timeline_start, trim_offset_secs).max(Beats::ZERO);
|
||||||
duration = (clip_dur - new_trim).max(0.0);
|
let dur_secs = if let Some(trim_end) = ci.trim_end {
|
||||||
if let Some(trim_end) = ci.trim_end {
|
(trim_end - new_trim).max(0.0)
|
||||||
duration = (trim_end - new_trim).max(0.0);
|
} else {
|
||||||
}
|
(clip_dur_secs - new_trim).max(0.0)
|
||||||
|
};
|
||||||
|
duration = secs_to_beats_at(start, dur_secs);
|
||||||
}
|
}
|
||||||
ClipDragType::TrimRight => {
|
ClipDragType::TrimRight => {
|
||||||
let old_trim_end = ci.trim_end.unwrap_or(clip_dur);
|
let old_trim_end = ci.trim_end.unwrap_or(clip_dur_secs);
|
||||||
let new_trim_end = self.snap_to_grid(old_trim_end + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate).max(ci.trim_start).min(clip_dur);
|
let new_trim_end = self.snap_to_grid(old_trim_end + self.drag_offset, tmap, &document.time_signature, document.framerate).max(ci.trim_start).min(clip_dur_secs);
|
||||||
duration = (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);
|
||||||
}
|
}
|
||||||
ClipDragType::LoopExtendRight => {
|
ClipDragType::LoopExtendRight => {
|
||||||
let trim_end = ci.trim_end.unwrap_or(clip_dur);
|
let trim_end = ci.trim_end.unwrap_or(clip_dur_secs);
|
||||||
let content_window = (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 current_right = ci.timeline_duration.unwrap_or(content_window);
|
let current_right = ci.timeline_duration.unwrap_or(content_window);
|
||||||
let right_edge = ci.timeline_start + current_right + self.drag_offset;
|
let right_edge_secs = tmap.beats_to_seconds(ci.timeline_start + current_right).seconds_to_f64() + self.drag_offset;
|
||||||
let snapped_edge = self.snap_to_grid(right_edge, document.tempo_map(), &document.time_signature, document.framerate);
|
let snapped_edge_secs = self.snap_to_grid(right_edge_secs, tmap, &document.time_signature, document.framerate);
|
||||||
|
let snapped_edge = tmap.seconds_to_beats(Seconds(snapped_edge_secs));
|
||||||
let new_right = (snapped_edge - ci.timeline_start).max(content_window);
|
let new_right = (snapped_edge - ci.timeline_start).max(content_window);
|
||||||
let loop_before = ci.loop_before.unwrap_or(0.0);
|
let loop_before = ci.loop_before.unwrap_or(Beats::ZERO);
|
||||||
duration = loop_before + new_right;
|
duration = loop_before + new_right;
|
||||||
}
|
}
|
||||||
ClipDragType::LoopExtendLeft => {
|
ClipDragType::LoopExtendLeft => {
|
||||||
let trim_end = ci.trim_end.unwrap_or(clip_dur);
|
let trim_end = ci.trim_end.unwrap_or(clip_dur_secs);
|
||||||
let content_window = (trim_end - ci.trim_start).max(0.001);
|
let content_window_secs = (trim_end - ci.trim_start).max(0.001);
|
||||||
let current_loop_before = ci.loop_before.unwrap_or(0.0);
|
let content_window = secs_to_beats_at(ci.timeline_start, content_window_secs);
|
||||||
let desired = (current_loop_before - self.drag_offset).max(0.0);
|
let current_loop_before = ci.loop_before.unwrap_or(Beats::ZERO);
|
||||||
let snapped = (desired / content_window).round() * content_window;
|
// drag_offset (seconds) as a beats delta at this clip's start.
|
||||||
|
let drag_beats = shift_beats(ci.timeline_start, self.drag_offset) - ci.timeline_start;
|
||||||
|
let desired = (current_loop_before - drag_beats).max(Beats::ZERO);
|
||||||
|
// Snap loop-before to whole content-window multiples.
|
||||||
|
let cw = content_window.beats_to_f64().max(1e-9);
|
||||||
|
let snapped = Beats((desired.beats_to_f64() / cw).round() * cw);
|
||||||
start = ci.timeline_start - snapped;
|
start = ci.timeline_start - snapped;
|
||||||
duration = snapped + ci.effective_duration(clip_dur, document.tempo_map());
|
duration = snapped + ci.effective_duration(clip_dur, tmap);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
(start, start + duration)
|
(start.beats_to_f64(), (start + duration).beats_to_f64())
|
||||||
}).collect();
|
}).collect();
|
||||||
compute_clip_stacking_from_ranges(&preview_ranges)
|
compute_clip_stacking_from_ranges(&preview_ranges)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -3409,7 +3452,7 @@ impl TimelinePane {
|
||||||
|
|
||||||
// Content origin: where the first "real" content iteration starts
|
// Content origin: where the first "real" content iteration starts
|
||||||
// Loop iterations tile outward from this point
|
// Loop iterations tile outward from this point
|
||||||
let mut content_origin = instance_start + clip_instance.loop_before.unwrap_or(0.0);
|
let mut content_origin = document.tempo_map().beats_to_seconds(clip_instance.timeline_start).seconds_to_f64();
|
||||||
|
|
||||||
// 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.
|
||||||
|
|
@ -3422,26 +3465,27 @@ impl TimelinePane {
|
||||||
match drag_type {
|
match drag_type {
|
||||||
ClipDragType::Move => {
|
ClipDragType::Move => {
|
||||||
if let Some(offset) = group_move_offset {
|
if let Some(offset) = group_move_offset {
|
||||||
instance_start = (clip_instance.effective_start() + offset).max(0.0);
|
let tmap = document.tempo_map();
|
||||||
content_origin = instance_start + clip_instance.loop_before.unwrap_or(0.0);
|
instance_start = tmap.beats_to_seconds((clip_instance.effective_start() + offset).max(Beats::ZERO)).seconds_to_f64();
|
||||||
|
content_origin = tmap.beats_to_seconds(clip_instance.timeline_start + offset).seconds_to_f64();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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)
|
let desired_trim_start = self.snap_to_grid(clip_instance.trim_start + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate)
|
||||||
.max(0.0)
|
.max(0.0)
|
||||||
.min(clip_duration);
|
.min(clip_duration.seconds_to_f64());
|
||||||
|
|
||||||
let new_trim_start = if desired_trim_start < clip_instance.trim_start {
|
let new_trim_start = if desired_trim_start < clip_instance.trim_start {
|
||||||
// Extending left - check for adjacent clips
|
// Extending left - limit is the content-seconds gap to the previous clip.
|
||||||
let max_extend = document.find_max_trim_extend_left(
|
let max_extend_secs = document.find_max_trim_extend_left(
|
||||||
&layer.id(),
|
&layer.id(),
|
||||||
&clip_instance.id,
|
&clip_instance.id,
|
||||||
clip_instance.effective_start(),
|
clip_instance.effective_start(),
|
||||||
);
|
).seconds_to_f64();
|
||||||
|
|
||||||
let desired_extend = clip_instance.trim_start - desired_trim_start;
|
let desired_extend = clip_instance.trim_start - desired_trim_start;
|
||||||
let actual_extend = desired_extend.min(max_extend);
|
let actual_extend = desired_extend.min(max_extend_secs);
|
||||||
clip_instance.trim_start - actual_extend
|
clip_instance.trim_start - actual_extend
|
||||||
} else {
|
} else {
|
||||||
// Shrinking - no snap needed
|
// Shrinking - no snap needed
|
||||||
|
|
@ -3450,11 +3494,11 @@ impl TimelinePane {
|
||||||
|
|
||||||
let actual_offset = new_trim_start - clip_instance.trim_start;
|
let actual_offset = new_trim_start - clip_instance.trim_start;
|
||||||
|
|
||||||
// Move start and reduce duration by actual clamped offset
|
// Move start (display seconds) and reduce duration by the clamped offset.
|
||||||
instance_start = (clip_instance.timeline_start + 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 - new_trim_start).max(0.0);
|
instance_duration = (clip_duration.seconds_to_f64() - 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 {
|
||||||
|
|
@ -3467,23 +3511,27 @@ 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);
|
let old_trim_end = clip_instance.trim_end.unwrap_or(clip_duration.seconds_to_f64());
|
||||||
let desired_trim_end = self.snap_to_grid(old_trim_end + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate)
|
let desired_trim_end = self.snap_to_grid(old_trim_end + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate)
|
||||||
.max(clip_instance.trim_start)
|
.max(clip_instance.trim_start)
|
||||||
.min(clip_duration);
|
.min(clip_duration.seconds_to_f64());
|
||||||
|
|
||||||
let new_trim_end = if desired_trim_end > old_trim_end {
|
let new_trim_end = if desired_trim_end > old_trim_end {
|
||||||
// Extending right - check for adjacent clips
|
// Extending right - limit is the content-seconds gap to the next clip.
|
||||||
let current_duration = old_trim_end - clip_instance.trim_start;
|
let current_duration_secs = old_trim_end - clip_instance.trim_start;
|
||||||
let max_extend = document.find_max_trim_extend_right(
|
let tmap = document.tempo_map();
|
||||||
|
let current_duration = tmap.seconds_to_beats(
|
||||||
|
tmap.beats_to_seconds(clip_instance.timeline_start) + Seconds(current_duration_secs)
|
||||||
|
) - clip_instance.timeline_start;
|
||||||
|
let max_extend_secs = document.find_max_trim_extend_right(
|
||||||
&layer.id(),
|
&layer.id(),
|
||||||
&clip_instance.id,
|
&clip_instance.id,
|
||||||
clip_instance.timeline_start,
|
clip_instance.timeline_start,
|
||||||
current_duration,
|
current_duration,
|
||||||
);
|
).seconds_to_f64();
|
||||||
|
|
||||||
let desired_extend = desired_trim_end - old_trim_end;
|
let desired_extend = desired_trim_end - old_trim_end;
|
||||||
let actual_extend = desired_extend.min(max_extend);
|
let actual_extend = desired_extend.min(max_extend_secs);
|
||||||
old_trim_end + actual_extend
|
old_trim_end + actual_extend
|
||||||
} else {
|
} else {
|
||||||
// Shrinking - no snap needed
|
// Shrinking - no snap needed
|
||||||
|
|
@ -3498,41 +3546,56 @@ 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);
|
let trim_end = clip_instance.trim_end.unwrap_or(clip_duration.seconds_to_f64());
|
||||||
let content_window = (trim_end - clip_instance.trim_start).max(0.0);
|
let content_window_secs = (trim_end - clip_instance.trim_start).max(0.0);
|
||||||
|
let tmap = document.tempo_map();
|
||||||
|
let ts = clip_instance.timeline_start;
|
||||||
|
// content window and right-duration are beats-domain timeline spans.
|
||||||
|
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);
|
||||||
let right_edge = clip_instance.timeline_start + current_right + self.drag_offset;
|
// Snap the right edge in the seconds/pixel domain (drag_offset is seconds).
|
||||||
let snapped_edge = self.snap_to_grid(right_edge, document.tempo_map(), &document.time_signature, document.framerate);
|
let right_edge_secs = tmap.beats_to_seconds(ts + current_right).seconds_to_f64() + self.drag_offset;
|
||||||
let desired_right = (snapped_edge - clip_instance.timeline_start).max(content_window);
|
let snapped_edge_secs = self.snap_to_grid(right_edge_secs, tmap, &document.time_signature, document.framerate);
|
||||||
|
let snapped_edge = tmap.seconds_to_beats(Seconds(snapped_edge_secs));
|
||||||
|
let desired_right = (snapped_edge - ts).max(content_window);
|
||||||
|
|
||||||
let new_right = if desired_right > current_right {
|
let new_right = if desired_right > current_right {
|
||||||
let max_extend = document.find_max_trim_extend_right(
|
// Gap to the next clip comes back as content seconds; convert to a
|
||||||
|
// beats span at the clip's right edge for the loop-length limit.
|
||||||
|
let max_extend_secs = document.find_max_trim_extend_right(
|
||||||
&layer.id(),
|
&layer.id(),
|
||||||
&clip_instance.id,
|
&clip_instance.id,
|
||||||
clip_instance.timeline_start,
|
ts,
|
||||||
current_right,
|
current_right,
|
||||||
);
|
);
|
||||||
|
let right_edge = ts + current_right;
|
||||||
|
let max_extend = tmap.seconds_to_beats(tmap.beats_to_seconds(right_edge) + max_extend_secs) - right_edge;
|
||||||
let extend_amount = (desired_right - current_right).min(max_extend);
|
let extend_amount = (desired_right - current_right).min(max_extend);
|
||||||
current_right + extend_amount
|
current_right + extend_amount
|
||||||
} else {
|
} else {
|
||||||
desired_right
|
desired_right
|
||||||
};
|
};
|
||||||
|
|
||||||
// Total duration = loop_before + right duration
|
// Right edge lands at ts + new_right (beats); duration in display seconds.
|
||||||
let loop_before = clip_instance.loop_before.unwrap_or(0.0);
|
instance_duration = tmap.beats_to_seconds(ts + new_right).seconds_to_f64() - instance_start;
|
||||||
instance_duration = loop_before + new_right;
|
|
||||||
}
|
}
|
||||||
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);
|
let trim_end = clip_instance.trim_end.unwrap_or(clip_duration.seconds_to_f64());
|
||||||
let content_window = (trim_end - clip_instance.trim_start).max(0.001);
|
let content_window_secs = (trim_end - clip_instance.trim_start).max(0.001);
|
||||||
let current_loop_before = clip_instance.loop_before.unwrap_or(0.0);
|
let tmap = document.tempo_map();
|
||||||
// Invert: dragging left (negative offset) = extend
|
let ts = clip_instance.timeline_start;
|
||||||
let desired_loop_before = (current_loop_before - self.drag_offset).max(0.0);
|
// content window is a beats-domain span; guard against zero for division.
|
||||||
// Snap to whole iterations
|
let content_window = tmap.seconds_to_beats(tmap.beats_to_seconds(ts) + Seconds(content_window_secs)) - ts;
|
||||||
let desired_iters = (desired_loop_before / content_window).round();
|
let cw = content_window.beats_to_f64().max(1e-9);
|
||||||
let snapped_loop_before = desired_iters * content_window;
|
let current_loop_before = clip_instance.loop_before.unwrap_or(Beats::ZERO);
|
||||||
|
// Invert: dragging left (negative seconds offset) = extend. Convert to a beats delta.
|
||||||
|
let drag_beats = tmap.seconds_to_beats(tmap.beats_to_seconds(ts) + Seconds(self.drag_offset)) - ts;
|
||||||
|
let desired_loop_before = (current_loop_before - drag_beats).max(Beats::ZERO);
|
||||||
|
// Snap to whole content-window iterations
|
||||||
|
let desired_iters = (desired_loop_before.beats_to_f64() / cw).round();
|
||||||
|
let snapped_loop_before = Beats(desired_iters * cw);
|
||||||
|
|
||||||
let new_loop_before = if snapped_loop_before > current_loop_before {
|
let new_loop_before = if snapped_loop_before > current_loop_before {
|
||||||
// Extending left - check for adjacent clips
|
// Extending left - check for adjacent clips
|
||||||
|
|
@ -3544,16 +3607,17 @@ impl TimelinePane {
|
||||||
let extend_amount = (snapped_loop_before - current_loop_before).min(max_extend);
|
let extend_amount = (snapped_loop_before - current_loop_before).min(max_extend);
|
||||||
// Re-snap after clamping
|
// Re-snap after clamping
|
||||||
let clamped = current_loop_before + extend_amount;
|
let clamped = current_loop_before + extend_amount;
|
||||||
(clamped / content_window).floor() * content_window
|
Beats((clamped.beats_to_f64() / cw).floor() * cw)
|
||||||
} else {
|
} else {
|
||||||
snapped_loop_before
|
snapped_loop_before
|
||||||
};
|
};
|
||||||
|
|
||||||
// Recompute instance_start and instance_duration
|
// Recompute instance_start and instance_duration (display seconds).
|
||||||
let right_duration = clip_instance.effective_duration(clip_duration, document.tempo_map());
|
// Real content ends at ts + right_duration (beats), independent of loop_before.
|
||||||
instance_start = clip_instance.timeline_start - new_loop_before;
|
let right_duration = clip_instance.effective_duration(clip_duration, tmap);
|
||||||
instance_duration = new_loop_before + right_duration;
|
instance_start = tmap.beats_to_seconds(ts - new_loop_before).seconds_to_f64();
|
||||||
content_origin = clip_instance.timeline_start;
|
instance_duration = tmap.beats_to_seconds(ts + right_duration).seconds_to_f64() - instance_start;
|
||||||
|
content_origin = tmap.beats_to_seconds(ts).seconds_to_f64();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3757,7 +3821,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);
|
let preview_trim_end = clip_instance.trim_end.unwrap_or(clip_duration.seconds_to_f64());
|
||||||
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;
|
||||||
|
|
||||||
|
|
@ -4211,8 +4275,8 @@ impl TimelinePane {
|
||||||
let instance_end = instance_start + instance_duration;
|
let instance_end = instance_start + instance_duration;
|
||||||
|
|
||||||
// Check if click is within this clip instance's pixel range and vertical bounds
|
// Check if click is within this clip instance's pixel range and vertical bounds
|
||||||
let ci_start_x = self.time_to_x(instance_start);
|
let ci_start_x = self.beats_to_x(instance_start, document.tempo_map());
|
||||||
let ci_end_x = self.time_to_x(instance_end).max(ci_start_x + MIN_CLIP_WIDTH_PX);
|
let ci_end_x = self.beats_to_x(instance_end, document.tempo_map()).max(ci_start_x + MIN_CLIP_WIDTH_PX);
|
||||||
let click_x = pos.x - content_rect.min.x;
|
let click_x = pos.x - content_rect.min.x;
|
||||||
let (row, total_rows) = click_stacking[ci_idx];
|
let (row, total_rows) = click_stacking[ci_idx];
|
||||||
let (cy_min, cy_max) = clip_instance_y_bounds(row, total_rows);
|
let (cy_min, cy_max) = clip_instance_y_bounds(row, total_rows);
|
||||||
|
|
@ -4503,8 +4567,9 @@ impl TimelinePane {
|
||||||
let mut earliest = f64::MAX;
|
let mut earliest = f64::MAX;
|
||||||
for (_, clip_instances) in all_layer_clip_instances(context_layers, &audio_cache) {
|
for (_, clip_instances) in all_layer_clip_instances(context_layers, &audio_cache) {
|
||||||
for ci in clip_instances {
|
for ci in clip_instances {
|
||||||
if selection.contains_clip_instance(&ci.id) && ci.timeline_start < earliest {
|
if selection.contains_clip_instance(&ci.id) {
|
||||||
earliest = ci.timeline_start;
|
let start_secs = document.tempo_map().beats_to_seconds(ci.timeline_start).seconds_to_f64();
|
||||||
|
if start_secs < earliest { earliest = start_secs; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -4532,8 +4597,9 @@ impl TimelinePane {
|
||||||
let mut earliest = f64::MAX;
|
let mut earliest = f64::MAX;
|
||||||
for (_, clip_instances) in all_layer_clip_instances(context_layers, &audio_cache) {
|
for (_, clip_instances) in all_layer_clip_instances(context_layers, &audio_cache) {
|
||||||
for ci in clip_instances {
|
for ci in clip_instances {
|
||||||
if selection.contains_clip_instance(&ci.id) && ci.timeline_start < earliest {
|
if selection.contains_clip_instance(&ci.id) {
|
||||||
earliest = ci.timeline_start;
|
let start_secs = document.tempo_map().beats_to_seconds(ci.timeline_start).seconds_to_f64();
|
||||||
|
if start_secs < earliest { earliest = start_secs; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -4555,10 +4621,11 @@ impl TimelinePane {
|
||||||
if response.drag_stopped() {
|
if response.drag_stopped() {
|
||||||
// Build layer_moves map for the action
|
// Build layer_moves map for the action
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
let mut layer_moves: HashMap<uuid::Uuid, Vec<(uuid::Uuid, f64, f64)>> =
|
let mut layer_moves: HashMap<uuid::Uuid, Vec<(uuid::Uuid, Beats, Beats)>> =
|
||||||
HashMap::new();
|
HashMap::new();
|
||||||
|
|
||||||
// Compute snapped offset once for all selected clips (preserves relative spacing)
|
// Compute snapped offset once for all selected clips (preserves relative spacing).
|
||||||
|
// `snapped_move_offset` is a uniform beats delta.
|
||||||
let move_offset = self.snapped_move_offset(document.tempo_map(), &document.time_signature, document.framerate);
|
let move_offset = self.snapped_move_offset(document.tempo_map(), &document.time_signature, document.framerate);
|
||||||
|
|
||||||
// Iterate through all layers (including group children) to find selected clip instances
|
// Iterate through all layers (including group children) to find selected clip instances
|
||||||
|
|
@ -4622,26 +4689,28 @@ impl TimelinePane {
|
||||||
// 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,
|
old_trim_start + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate,
|
||||||
).max(0.0).min(clip_duration);
|
).max(0.0).min(clip_duration.seconds_to_f64());
|
||||||
|
|
||||||
// Apply overlap prevention when extending left
|
// 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 {
|
||||||
let max_extend = document.find_max_trim_extend_left(
|
let max_extend_secs = document.find_max_trim_extend_left(
|
||||||
&layer_id,
|
&layer_id,
|
||||||
&clip_instance.id,
|
&clip_instance.id,
|
||||||
old_timeline_start,
|
old_timeline_start,
|
||||||
);
|
).seconds_to_f64();
|
||||||
let desired_extend = old_trim_start - desired_trim_start;
|
let desired_extend = old_trim_start - desired_trim_start;
|
||||||
let actual_extend = desired_extend.min(max_extend);
|
let actual_extend = desired_extend.min(max_extend_secs);
|
||||||
old_trim_start - actual_extend
|
old_trim_start - actual_extend
|
||||||
} else {
|
} else {
|
||||||
desired_trim_start
|
desired_trim_start
|
||||||
};
|
};
|
||||||
|
|
||||||
// Calculate actual offset after clamping
|
// Calculate actual offset after clamping (seconds), then the
|
||||||
|
// new timeline start in beats.
|
||||||
let actual_offset = new_trim_start - old_trim_start;
|
let actual_offset = new_trim_start - old_trim_start;
|
||||||
let new_timeline_start =
|
let new_timeline_start = document.tempo_map().seconds_to_beats(
|
||||||
old_timeline_start + actual_offset;
|
document.tempo_map().beats_to_seconds(old_timeline_start) + Seconds(actual_offset)
|
||||||
|
);
|
||||||
|
|
||||||
layer_trims
|
layer_trims
|
||||||
.entry(layer_id)
|
.entry(layer_id)
|
||||||
|
|
@ -4665,21 +4734,21 @@ 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);
|
let old_trim_end_val = clip_instance.trim_end.unwrap_or(clip_duration.seconds_to_f64());
|
||||||
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,
|
old_trim_end_val + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate,
|
||||||
).max(clip_instance.trim_start).min(clip_duration);
|
).max(clip_instance.trim_start).min(clip_duration.seconds_to_f64());
|
||||||
|
|
||||||
// Apply overlap prevention when extending right
|
// 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 {
|
||||||
let max_extend = document.find_max_trim_extend_right(
|
let max_extend_secs = document.find_max_trim_extend_right(
|
||||||
&layer_id,
|
&layer_id,
|
||||||
&clip_instance.id,
|
&clip_instance.id,
|
||||||
clip_instance.timeline_start,
|
clip_instance.timeline_start,
|
||||||
current_duration,
|
current_duration,
|
||||||
);
|
).seconds_to_f64();
|
||||||
let desired_extend = desired_trim_end - old_trim_end_val;
|
let desired_extend = desired_trim_end - old_trim_end_val;
|
||||||
let actual_extend = desired_extend.min(max_extend);
|
let actual_extend = desired_extend.min(max_extend_secs);
|
||||||
old_trim_end_val + actual_extend
|
old_trim_end_val + actual_extend
|
||||||
} else {
|
} else {
|
||||||
desired_trim_end
|
desired_trim_end
|
||||||
|
|
@ -4688,10 +4757,10 @@ impl TimelinePane {
|
||||||
let new_duration = (new_trim_end_val - clip_instance.trim_start).max(0.0);
|
let new_duration = (new_trim_end_val - clip_instance.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 {
|
let new_trim_end = if new_duration >= clip_duration.seconds_to_f64() {
|
||||||
None // Use full clip duration
|
None // Use full clip duration
|
||||||
} else {
|
} else {
|
||||||
Some((clip_instance.trim_start + new_duration).min(clip_duration))
|
Some((clip_instance.trim_start + new_duration).min(clip_duration.seconds_to_f64()))
|
||||||
};
|
};
|
||||||
|
|
||||||
layer_trims
|
layer_trims
|
||||||
|
|
@ -4741,20 +4810,26 @@ impl TimelinePane {
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(clip_duration) = clip_duration {
|
if let Some(clip_duration) = clip_duration {
|
||||||
|
let tmap = document.tempo_map();
|
||||||
|
let ts = clip_instance.timeline_start;
|
||||||
let trim_end = clip_instance.trim_end.unwrap_or(clip_duration);
|
let trim_end = clip_instance.trim_end.unwrap_or(clip_duration);
|
||||||
let content_window = (trim_end - clip_instance.trim_start).max(0.0);
|
let content_window_secs = (trim_end - clip_instance.trim_start).max(0.0);
|
||||||
|
let content_window = tmap.seconds_to_beats(tmap.beats_to_seconds(ts) + Seconds(content_window_secs)) - ts;
|
||||||
let current_right = clip_instance.timeline_duration.unwrap_or(content_window);
|
let current_right = clip_instance.timeline_duration.unwrap_or(content_window);
|
||||||
let right_edge = clip_instance.timeline_start + current_right + self.drag_offset;
|
// Snap the right edge in the seconds/pixel domain.
|
||||||
let snapped_edge = self.snap_to_grid(right_edge, document.tempo_map(), &document.time_signature, document.framerate);
|
let right_edge_secs = tmap.beats_to_seconds(ts + current_right).seconds_to_f64() + self.drag_offset;
|
||||||
let desired_right = snapped_edge - clip_instance.timeline_start;
|
let snapped_edge_secs = self.snap_to_grid(right_edge_secs, tmap, &document.time_signature, document.framerate);
|
||||||
|
let desired_right = tmap.seconds_to_beats(Seconds(snapped_edge_secs)) - ts;
|
||||||
|
|
||||||
let new_right = if desired_right > current_right {
|
let new_right = if desired_right > current_right {
|
||||||
let max_extend = document.find_max_trim_extend_right(
|
let max_extend_secs = document.find_max_trim_extend_right(
|
||||||
&layer_id,
|
&layer_id,
|
||||||
&clip_instance.id,
|
&clip_instance.id,
|
||||||
clip_instance.timeline_start,
|
ts,
|
||||||
current_right,
|
current_right,
|
||||||
);
|
);
|
||||||
|
let right_edge = ts + current_right;
|
||||||
|
let max_extend = tmap.seconds_to_beats(tmap.beats_to_seconds(right_edge) + max_extend_secs) - right_edge;
|
||||||
let extend_amount = (desired_right - current_right).min(max_extend);
|
let extend_amount = (desired_right - current_right).min(max_extend);
|
||||||
current_right + extend_amount
|
current_right + extend_amount
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -4762,7 +4837,7 @@ impl TimelinePane {
|
||||||
};
|
};
|
||||||
|
|
||||||
let old_timeline_duration = clip_instance.timeline_duration;
|
let old_timeline_duration = clip_instance.timeline_duration;
|
||||||
let new_timeline_duration = if new_right > content_window + 0.001 {
|
let new_timeline_duration = if new_right > content_window + Beats(0.001) {
|
||||||
Some(new_right)
|
Some(new_right)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
|
|
@ -4809,14 +4884,19 @@ impl TimelinePane {
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(clip_duration) = clip_duration {
|
if let Some(clip_duration) = clip_duration {
|
||||||
|
let tmap = document.tempo_map();
|
||||||
|
let ts = clip_instance.timeline_start;
|
||||||
let trim_end = clip_instance.trim_end.unwrap_or(clip_duration);
|
let trim_end = clip_instance.trim_end.unwrap_or(clip_duration);
|
||||||
let content_window = (trim_end - clip_instance.trim_start).max(0.001);
|
let content_window_secs = (trim_end - clip_instance.trim_start).max(0.001);
|
||||||
let current_loop_before = clip_instance.loop_before.unwrap_or(0.0);
|
let content_window = tmap.seconds_to_beats(tmap.beats_to_seconds(ts) + Seconds(content_window_secs)) - ts;
|
||||||
// Invert: dragging left (negative offset) = extend
|
let cw = content_window.beats_to_f64().max(1e-9);
|
||||||
let desired_loop_before = (current_loop_before - self.drag_offset).max(0.0);
|
let current_loop_before = clip_instance.loop_before.unwrap_or(Beats::ZERO);
|
||||||
|
// Invert: dragging left (negative seconds offset) = extend.
|
||||||
|
let drag_beats = tmap.seconds_to_beats(tmap.beats_to_seconds(ts) + Seconds(self.drag_offset)) - ts;
|
||||||
|
let desired_loop_before = (current_loop_before - drag_beats).max(Beats::ZERO);
|
||||||
// Snap to whole iterations so backend modulo aligns
|
// Snap to whole iterations so backend modulo aligns
|
||||||
let desired_iters = (desired_loop_before / content_window).round();
|
let desired_iters = (desired_loop_before.beats_to_f64() / cw).round();
|
||||||
let snapped = desired_iters * content_window;
|
let snapped = Beats(desired_iters * cw);
|
||||||
|
|
||||||
let new_loop_before = if snapped > current_loop_before {
|
let new_loop_before = if snapped > current_loop_before {
|
||||||
let max_extend = document.find_max_loop_extend_left(
|
let max_extend = document.find_max_loop_extend_left(
|
||||||
|
|
@ -4826,13 +4906,13 @@ impl TimelinePane {
|
||||||
);
|
);
|
||||||
let extend_amount = (snapped - current_loop_before).min(max_extend);
|
let extend_amount = (snapped - current_loop_before).min(max_extend);
|
||||||
let clamped = current_loop_before + extend_amount;
|
let clamped = current_loop_before + extend_amount;
|
||||||
(clamped / content_window).floor() * content_window
|
Beats((clamped.beats_to_f64() / cw).floor() * cw)
|
||||||
} else {
|
} else {
|
||||||
snapped
|
snapped
|
||||||
};
|
};
|
||||||
|
|
||||||
let old_loop_before = clip_instance.loop_before;
|
let old_loop_before = clip_instance.loop_before;
|
||||||
let new_lb = if new_loop_before > 0.001 {
|
let new_lb = if new_loop_before > Beats(0.001) {
|
||||||
Some(new_loop_before)
|
Some(new_loop_before)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
|
|
@ -5512,7 +5592,7 @@ impl PaneRenderer for TimelinePane {
|
||||||
if let Some(clip_duration) = clip_duration {
|
if let Some(clip_duration) = clip_duration {
|
||||||
let instance_duration = clip_instance.effective_duration(clip_duration, document.tempo_map());
|
let instance_duration = clip_instance.effective_duration(clip_duration, document.tempo_map());
|
||||||
let instance_end = clip_instance.timeline_start + instance_duration;
|
let instance_end = clip_instance.timeline_start + instance_duration;
|
||||||
max_endpoint = max_endpoint.max(instance_end);
|
max_endpoint = max_endpoint.max(document.tempo_map().beats_to_seconds(instance_end).seconds_to_f64());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -5943,8 +6023,8 @@ impl PaneRenderer for TimelinePane {
|
||||||
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.get_clip_duration(&inst.clip_id) {
|
||||||
let eff = inst.effective_duration(dur, document.tempo_map());
|
let eff = inst.effective_duration(dur, document.tempo_map());
|
||||||
let start = inst.timeline_start;
|
let start = document.tempo_map().beats_to_seconds(inst.timeline_start).seconds_to_f64();
|
||||||
let end = start + eff;
|
let end = document.tempo_map().beats_to_seconds(inst.timeline_start + eff).seconds_to_f64();
|
||||||
let min_dist = min_split_px as f64 / self.pixels_per_second as f64;
|
let min_dist = min_split_px as f64 / self.pixels_per_second as f64;
|
||||||
if playback_time > start + min_dist && playback_time < end - min_dist {
|
if playback_time > start + min_dist && playback_time < end - min_dist {
|
||||||
enabled = true;
|
enabled = true;
|
||||||
|
|
@ -5969,10 +6049,14 @@ impl PaneRenderer for TimelinePane {
|
||||||
.all(|ci| {
|
.all(|ci| {
|
||||||
if let Some(dur) = document.get_clip_duration(&ci.clip_id) {
|
if let Some(dur) = document.get_clip_duration(&ci.clip_id) {
|
||||||
let eff = ci.effective_duration(dur, document.tempo_map());
|
let eff = ci.effective_duration(dur, document.tempo_map());
|
||||||
let max_extend = document.find_max_trim_extend_right(
|
// Room to duplicate = seconds gap to the right ≥ this clip's own length.
|
||||||
|
let max_extend_secs = document.find_max_trim_extend_right(
|
||||||
&layer_id, &ci.id, ci.timeline_start, eff,
|
&layer_id, &ci.id, ci.timeline_start, eff,
|
||||||
);
|
).seconds_to_f64();
|
||||||
max_extend >= eff
|
let tmap = document.tempo_map();
|
||||||
|
let eff_secs = (tmap.beats_to_seconds(ci.timeline_start + eff)
|
||||||
|
- tmap.beats_to_seconds(ci.timeline_start)).seconds_to_f64();
|
||||||
|
max_extend_secs >= eff_secs
|
||||||
} else {
|
} else {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
@ -6001,11 +6085,13 @@ impl PaneRenderer for TimelinePane {
|
||||||
let min_start = instances
|
let min_start = instances
|
||||||
.iter()
|
.iter()
|
||||||
.map(|i| i.timeline_start)
|
.map(|i| i.timeline_start)
|
||||||
.fold(f64::INFINITY, f64::min);
|
.fold(Beats(f64::INFINITY), |a, b| a.min(b));
|
||||||
let offset = *shared.playback_time - min_start;
|
// Place so the earliest clip lands at the playhead (beats).
|
||||||
|
let playhead_beats = document.tempo_map().seconds_to_beats(Seconds(*shared.playback_time));
|
||||||
|
let offset = playhead_beats - min_start;
|
||||||
|
|
||||||
enabled = instances.iter().all(|ci| {
|
enabled = instances.iter().all(|ci| {
|
||||||
let paste_start = (ci.timeline_start + offset).max(0.0);
|
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.get_clip_duration(&ci.clip_id) {
|
||||||
let eff = ci.effective_duration(dur, document.tempo_map());
|
let eff = ci.effective_duration(dur, document.tempo_map());
|
||||||
document
|
document
|
||||||
|
|
@ -6210,24 +6296,22 @@ impl PaneRenderer for TimelinePane {
|
||||||
// Show drop time indicator with snap preview
|
// Show drop time indicator with snap preview
|
||||||
let raw_drop_time = self.x_to_time(pointer_pos.x - content_rect.min.x).max(0.0);
|
let raw_drop_time = self.x_to_time(pointer_pos.x - content_rect.min.x).max(0.0);
|
||||||
|
|
||||||
// Calculate snapped drop time for preview
|
// Calculate snapped drop time for preview (seconds, for time_to_x)
|
||||||
let drop_time = if is_compatible {
|
let drop_time = if is_compatible {
|
||||||
// Get clip duration to calculate snapped position
|
let doc = shared.action_executor.document();
|
||||||
let clip_duration = {
|
let tmap = doc.tempo_map();
|
||||||
let doc = shared.action_executor.document();
|
let clip_duration = doc.get_clip_duration(&dragging.clip_id).unwrap_or(Seconds(1.0));
|
||||||
doc.get_clip_duration(&dragging.clip_id).unwrap_or(1.0)
|
// Overlap testing is beats-domain: convert the drop point and the clip's
|
||||||
};
|
// content span to beats.
|
||||||
|
let raw_drop_beats = tmap.seconds_to_beats(Seconds(raw_drop_time));
|
||||||
// Find nearest valid position (auto-snap for preview)
|
let clip_dur_beats = tmap.seconds_to_beats(tmap.beats_to_seconds(raw_drop_beats) + clip_duration) - raw_drop_beats;
|
||||||
let snapped = shared.action_executor.document()
|
let snapped = doc.find_nearest_valid_position(
|
||||||
.find_nearest_valid_position(
|
&layer.id(),
|
||||||
&layer.id(),
|
raw_drop_beats,
|
||||||
raw_drop_time,
|
clip_dur_beats,
|
||||||
clip_duration,
|
&[],
|
||||||
&[],
|
);
|
||||||
);
|
snapped.map(|b| tmap.beats_to_seconds(b).seconds_to_f64()).unwrap_or(raw_drop_time)
|
||||||
|
|
||||||
snapped.unwrap_or(raw_drop_time)
|
|
||||||
} else {
|
} else {
|
||||||
raw_drop_time
|
raw_drop_time
|
||||||
};
|
};
|
||||||
|
|
@ -6261,9 +6345,12 @@ impl PaneRenderer for TimelinePane {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create clip instance for effect with 5 second default duration
|
// Create clip instance for effect with 5 second default duration
|
||||||
|
let tmap = shared.action_executor.document().tempo_map();
|
||||||
|
let drop_beats = tmap.seconds_to_beats(Seconds(drop_time));
|
||||||
|
let dur_beats = tmap.seconds_to_beats(tmap.beats_to_seconds(drop_beats) + Seconds(5.0)) - drop_beats;
|
||||||
let clip_instance = ClipInstance::new(def.id)
|
let clip_instance = ClipInstance::new(def.id)
|
||||||
.with_timeline_start(drop_time)
|
.with_timeline_start(drop_beats)
|
||||||
.with_timeline_duration(5.0);
|
.with_timeline_duration(dur_beats);
|
||||||
|
|
||||||
// Use AddEffectAction for effect layers
|
// Use AddEffectAction for effect layers
|
||||||
let action = lightningbeam_core::actions::AddEffectAction::new(
|
let action = lightningbeam_core::actions::AddEffectAction::new(
|
||||||
|
|
@ -6283,7 +6370,7 @@ impl PaneRenderer for TimelinePane {
|
||||||
let center_y = doc.height / 2.0;
|
let center_y = doc.height / 2.0;
|
||||||
|
|
||||||
let mut clip_instance = ClipInstance::new(dragging.clip_id)
|
let mut clip_instance = ClipInstance::new(dragging.clip_id)
|
||||||
.with_timeline_start(drop_time);
|
.with_timeline_start(doc.tempo_map().seconds_to_beats(Seconds(drop_time)));
|
||||||
|
|
||||||
// For video clips, fit uniformly + centered (preserve aspect).
|
// For video clips, fit uniformly + centered (preserve aspect).
|
||||||
// Shared with the direct-import path via Transform::fit_centered.
|
// Shared with the direct-import path via Transform::fit_centered.
|
||||||
|
|
@ -6321,7 +6408,7 @@ impl PaneRenderer for TimelinePane {
|
||||||
// Find or create sampled audio track where the audio won't overlap
|
// Find or create sampled audio track where the audio won't overlap
|
||||||
let audio_layer_id = {
|
let audio_layer_id = {
|
||||||
let doc = shared.action_executor.document();
|
let doc = shared.action_executor.document();
|
||||||
let result = find_sampled_audio_track_for_clip(doc, linked_audio_clip_id, drop_time, editing_clip_id.as_ref());
|
let result = find_sampled_audio_track_for_clip(doc, linked_audio_clip_id, doc.tempo_map().seconds_to_beats(Seconds(drop_time)), editing_clip_id.as_ref());
|
||||||
if let Some(id) = result {
|
if let Some(id) = result {
|
||||||
eprintln!("DEBUG: Found existing audio track without overlap: {}", id);
|
eprintln!("DEBUG: Found existing audio track without overlap: {}", id);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -6343,7 +6430,7 @@ impl PaneRenderer for TimelinePane {
|
||||||
|
|
||||||
// Create audio clip instance at same timeline position
|
// Create audio clip instance at same timeline position
|
||||||
let audio_instance = ClipInstance::new(linked_audio_clip_id)
|
let audio_instance = ClipInstance::new(linked_audio_clip_id)
|
||||||
.with_timeline_start(drop_time);
|
.with_timeline_start(shared.action_executor.document().tempo_map().seconds_to_beats(Seconds(drop_time)));
|
||||||
let audio_instance_id = audio_instance.id;
|
let audio_instance_id = audio_instance.id;
|
||||||
|
|
||||||
eprintln!("DEBUG: Created audio instance: {} for clip: {}", audio_instance_id, linked_audio_clip_id);
|
eprintln!("DEBUG: Created audio instance: {} for clip: {}", audio_instance_id, linked_audio_clip_id);
|
||||||
|
|
@ -6436,9 +6523,12 @@ impl PaneRenderer for TimelinePane {
|
||||||
shared.action_executor.document_mut().add_effect_definition(def.clone());
|
shared.action_executor.document_mut().add_effect_definition(def.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let tmap = shared.action_executor.document().tempo_map();
|
||||||
|
let drop_beats = tmap.seconds_to_beats(Seconds(drop_time));
|
||||||
|
let dur_beats = tmap.seconds_to_beats(tmap.beats_to_seconds(drop_beats) + Seconds(5.0)) - drop_beats;
|
||||||
let clip_instance = ClipInstance::new(def.id)
|
let clip_instance = ClipInstance::new(def.id)
|
||||||
.with_timeline_start(drop_time)
|
.with_timeline_start(drop_beats)
|
||||||
.with_timeline_duration(5.0);
|
.with_timeline_duration(dur_beats);
|
||||||
|
|
||||||
let action = lightningbeam_core::actions::AddEffectAction::new(
|
let action = lightningbeam_core::actions::AddEffectAction::new(
|
||||||
new_layer_id,
|
new_layer_id,
|
||||||
|
|
@ -6449,7 +6539,7 @@ impl PaneRenderer for TimelinePane {
|
||||||
} else {
|
} else {
|
||||||
// Handle other clip types
|
// Handle other clip types
|
||||||
let clip_instance = ClipInstance::new(dragging.clip_id)
|
let clip_instance = ClipInstance::new(dragging.clip_id)
|
||||||
.with_timeline_start(drop_time);
|
.with_timeline_start(shared.action_executor.document().tempo_map().seconds_to_beats(Seconds(drop_time)));
|
||||||
|
|
||||||
let action = lightningbeam_core::actions::AddClipInstanceAction::new(
|
let action = lightningbeam_core::actions::AddClipInstanceAction::new(
|
||||||
new_layer_id,
|
new_layer_id,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue