Timeline: display audio/MIDI clips from the doc, not a backend snapshot

The timeline was the only place that reconstructed audio-layer clip instances
from a backend snapshot (build_audio_clip_cache) instead of reading the
document like every other layer type. But the document is the actual source of
truth: clip actions mutate *Layer::clip_instances, it's what persists to .beam,
and it drives the backend on load. The snapshot is downstream of it.

That dual source caused the second-recording preview bug: a recording clip
lives only in the document (its backend clip has a temporary pool index of 0,
so the snapshot can't represent it). layer_clips() only fell back to the doc
when the snapshot cache was *empty*, so the first recording previewed but the
second — once the layer already had a finalized clip — was dropped and showed
as zero-length until finalized.

Fix: make layer_clips() read <layer>.clip_instances for audio too, and delete
build_audio_clip_cache + the audio_cache plumbing threaded through ~7 methods
and ~25 call sites. Recording clips grow via the existing RecordingProgress /
MidiRecordingProgress mirror to the doc; finalized clips come from the actions;
both states now live in one place.

Root fix this exposed: get_clip_duration() wrapped every audio clip's duration
as Seconds, but MIDI clips store their duration in BEATS (they share AudioClip
with sampled clips). The snapshot had masked this by always forcing a MIDI
clip's timeline_duration; reading from the doc requires the value to be right,
so get_clip_duration now converts a MIDI clip's beats duration to seconds.

Net -120 lines. Whole workspace compiles; 299 core tests pass. The piano roll
still uses the snapshot for its own MIDI editor (separate pane, out of scope).
This commit is contained in:
Skyler Lehmkuhl 2026-07-11 16:42:34 -04:00
parent 64bf9bb431
commit c747126759
2 changed files with 38 additions and 159 deletions

View File

@ -921,7 +921,15 @@ impl Document {
} else if let Some(clip) = self.video_clips.get(clip_id) { } else if let Some(clip) = self.video_clips.get(clip_id) {
Some(Seconds(clip.duration)) Some(Seconds(clip.duration))
} else if let Some(clip) = self.audio_clips.get(clip_id) { } else if let Some(clip) = self.audio_clips.get(clip_id) {
Some(Seconds(clip.duration)) // MIDI clips store `duration` in BEATS (they share the AudioClip struct with
// sampled clips, whose duration is seconds). Convert to wall-clock seconds so
// the content-window sizing works uniformly.
match clip.clip_type {
crate::clip::AudioClipType::Midi { .. } => {
Some(self.tempo_map().beats_to_seconds(Beats(clip.duration)))
}
_ => Some(Seconds(clip.duration)),
}
} else if self.effect_definitions.contains_key(clip_id) { } else if self.effect_definitions.contains_key(clip_id) {
// Effects have infinite internal duration - their timeline length // Effects have infinite internal duration - their timeline length
// is controlled by ClipInstance.trim_end // is controlled by ClipInstance.trim_end

View File

@ -8,12 +8,9 @@
use eframe::egui; use eframe::egui;
use daw_backend::{Beats, Seconds}; use daw_backend::{Beats, Seconds};
use lightningbeam_core::clip::{ use lightningbeam_core::clip::ClipInstance;
ClipInstance, audio_backend_uuid, midi_backend_uuid,
};
use lightningbeam_core::layer::{AnyLayer, AudioLayerType, GroupLayer, LayerTrait}; use lightningbeam_core::layer::{AnyLayer, AudioLayerType, GroupLayer, LayerTrait};
use super::{DragClipType, NodePath, PaneRenderer, SharedPaneState}; use super::{DragClipType, NodePath, PaneRenderer, SharedPaneState};
use std::collections::HashMap;
const RULER_HEIGHT: f32 = 30.0; const RULER_HEIGHT: f32 = 30.0;
const LAYER_HEIGHT: f32 = 60.0; const LAYER_HEIGHT: f32 = 60.0;
@ -582,113 +579,16 @@ fn shift_toggle_layer(
*focus = lightningbeam_core::selection::FocusSelection::Layers(vec![layer_id]); *focus = lightningbeam_core::selection::FocusSelection::Layers(vec![layer_id]);
} }
/// Build a per-audio-layer clip instance cache from the backend snapshot. /// Get a layer's clip instances from the document.
/// ///
/// Audio layers read clip instances from the backend snapshot (source of truth) rather /// The document is the single source of truth for every layer type: clip actions mutate
/// than from `AudioLayer::clip_instances`. The cache maps layer_id → Vec<ClipInstance>. /// `*Layer::clip_instances`, it's what persists to the `.beam` file, and it drives the audio
/// /// backend on load. Recording clips grow here via the RecordingProgress/MidiRecordingProgress
/// Clip instance UUIDs in the cache are doc UUIDs when available (via reverse lookup of /// mirror. (Audio used to be reconstructed from a backend snapshot, which dropped the doc-only
/// `clip_instance_to_backend_map`), falling back to synthetic `audio_backend_uuid` / /// in-progress recording clip once a layer had any finalized clip.)
/// `midi_backend_uuid` values for clips not yet in the map. fn layer_clips(layer: &AnyLayer) -> &[ClipInstance] {
fn build_audio_clip_cache(
snap: &daw_backend::AudioClipSnapshot,
layer_to_track_map: &HashMap<uuid::Uuid, daw_backend::TrackId>,
document: &lightningbeam_core::document::Document,
clip_map: &HashMap<uuid::Uuid, lightningbeam_core::action::BackendClipInstanceId>,
) -> HashMap<uuid::Uuid, Vec<ClipInstance>> {
use lightningbeam_core::action::BackendClipInstanceId;
// Build reverse maps: backend_id → doc_instance_uuid
let mut audio_id_to_doc: HashMap<u32, uuid::Uuid> = HashMap::new();
let mut midi_id_to_doc: HashMap<u32, uuid::Uuid> = HashMap::new();
for (&doc_uuid, backend_id) in clip_map {
match backend_id {
BackendClipInstanceId::Audio(id) => { audio_id_to_doc.insert(*id, doc_uuid); }
BackendClipInstanceId::Midi(id) => { midi_id_to_doc.insert(*id, doc_uuid); }
}
}
let mut cache: HashMap<uuid::Uuid, Vec<ClipInstance>> = HashMap::new();
for (&layer_id, &track_id) in layer_to_track_map {
// Only process audio layers
match document.get_layer(&layer_id) {
Some(AnyLayer::Audio(_)) => {}
_ => continue,
}
let mut instances = Vec::new();
// Sampled audio clips
if let Some(audio_clips) = snap.audio.get(&track_id) {
for ac in audio_clips {
if let Some((clip_id, _)) = document.audio_clip_by_pool_index(ac.audio_pool_index) {
// Use doc UUID if we have it; otherwise fall back to synthetic UUID
let instance_id = audio_id_to_doc.get(&ac.id)
.copied()
.unwrap_or_else(|| audio_backend_uuid(ac.id));
let mut ci = ClipInstance::new(clip_id);
ci.id = instance_id;
ci.timeline_start = ac.external_start;
ci.trim_start = ac.internal_start.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 tempo_map = document.tempo_map();
let external_dur_secs = tempo_map.transform(ac.external_duration.beats_to_f64());
if (external_dur_secs - internal_dur_secs).abs() > 1e-9 {
ci.timeline_duration = Some(ac.external_duration);
}
ci.gain = ac.gain;
instances.push(ci);
}
}
}
// MIDI clips
if let Some(midi_clips) = snap.midi.get(&track_id) {
for mc in midi_clips {
if let Some((clip_id, _)) = document.audio_clip_by_midi_clip_id(mc.clip_id) {
let instance_id = midi_id_to_doc.get(&mc.id)
.copied()
.unwrap_or_else(|| midi_backend_uuid(mc.id));
let mut ci = ClipInstance::new(clip_id);
ci.id = instance_id;
ci.timeline_start = mc.external_start;
ci.trim_start = mc.internal_start.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
// must bypass the content_window_secs * bpm/60 formula (which expects seconds).
ci.timeline_duration = Some(mc.external_duration);
instances.push(ci);
}
}
}
// Only insert if we found clips (so layer_clips() can fall back to al.clip_instances
// for layers where the snapshot has no clips yet, e.g. during recording setup)
if !instances.is_empty() {
cache.insert(layer_id, instances);
}
}
cache
}
/// Get clip instances for a layer, using the snapshot-based cache for audio layers
/// and falling back to the doc's `clip_instances` if the cache has no entry OR is empty
/// while the doc has clips (e.g., a recording clip not yet reflected in the snapshot).
fn layer_clips<'a>(
layer: &'a AnyLayer,
audio_cache: &'a HashMap<uuid::Uuid, Vec<ClipInstance>>,
) -> &'a [ClipInstance] {
match layer { match layer {
AnyLayer::Audio(al) => { AnyLayer::Audio(l) => &l.clip_instances,
match audio_cache.get(&al.layer.id) {
Some(cached) if !cached.is_empty() => cached.as_slice(),
// Cache empty or missing: fall back to doc (covers recording-in-progress)
_ => &al.clip_instances,
}
}
AnyLayer::Vector(l) => &l.clip_instances, AnyLayer::Vector(l) => &l.clip_instances,
AnyLayer::Video(l) => &l.clip_instances, AnyLayer::Video(l) => &l.clip_instances,
AnyLayer::Effect(l) => &l.clip_instances, AnyLayer::Effect(l) => &l.clip_instances,
@ -703,28 +603,26 @@ fn layer_clips<'a>(
/// Returns (&AnyLayer, &[ClipInstance]) so callers have access to both layer info and clips. /// Returns (&AnyLayer, &[ClipInstance]) so callers have access to both layer info and clips.
fn all_layer_clip_instances<'a>( fn all_layer_clip_instances<'a>(
context_layers: &[&'a AnyLayer], context_layers: &[&'a AnyLayer],
audio_cache: &'a HashMap<uuid::Uuid, Vec<ClipInstance>>,
) -> Vec<(&'a AnyLayer, &'a [ClipInstance])> { ) -> Vec<(&'a AnyLayer, &'a [ClipInstance])> {
let mut result = Vec::new(); let mut result = Vec::new();
for &layer in context_layers { for &layer in context_layers {
collect_clip_instances(layer, audio_cache, &mut result); collect_clip_instances(layer, &mut result);
} }
result result
} }
fn collect_clip_instances<'a>( fn collect_clip_instances<'a>(
layer: &'a AnyLayer, layer: &'a AnyLayer,
audio_cache: &'a HashMap<uuid::Uuid, Vec<ClipInstance>>,
result: &mut Vec<(&'a AnyLayer, &'a [ClipInstance])>, result: &mut Vec<(&'a AnyLayer, &'a [ClipInstance])>,
) { ) {
match layer { match layer {
AnyLayer::Audio(_) => result.push((layer, layer_clips(layer, audio_cache))), AnyLayer::Audio(l) => result.push((layer, &l.clip_instances)),
AnyLayer::Vector(l) => result.push((layer, &l.clip_instances)), AnyLayer::Vector(l) => result.push((layer, &l.clip_instances)),
AnyLayer::Video(l) => result.push((layer, &l.clip_instances)), AnyLayer::Video(l) => result.push((layer, &l.clip_instances)),
AnyLayer::Effect(l) => result.push((layer, &l.clip_instances)), AnyLayer::Effect(l) => result.push((layer, &l.clip_instances)),
AnyLayer::Group(g) => { AnyLayer::Group(g) => {
for child in &g.children { for child in &g.children {
collect_clip_instances(child, audio_cache, result); collect_clip_instances(child, result);
} }
} }
AnyLayer::Raster(_) => {} AnyLayer::Raster(_) => {}
@ -1312,7 +1210,6 @@ impl TimelinePane {
content_rect: egui::Rect, content_rect: egui::Rect,
header_rect: egui::Rect, header_rect: egui::Rect,
editing_clip_id: Option<&uuid::Uuid>, editing_clip_id: Option<&uuid::Uuid>,
audio_cache: &HashMap<uuid::Uuid, Vec<ClipInstance>>,
) -> Option<(ClipDragType, uuid::Uuid)> { ) -> Option<(ClipDragType, uuid::Uuid)> {
let context_layers = document.context_layers(editing_clip_id); let context_layers = document.context_layers(editing_clip_id);
let rows = build_timeline_rows(&context_layers); let rows = build_timeline_rows(&context_layers);
@ -1342,7 +1239,7 @@ impl TimelinePane {
}; };
let _layer_data = layer.layer(); let _layer_data = layer.layer();
let clip_instances = layer_clips(layer, audio_cache); let clip_instances = layer_clips(layer);
// Check each clip instance // Check each clip instance
let stacking = compute_clip_stacking(document, layer, clip_instances); let stacking = compute_clip_stacking(document, layer, clip_instances);
@ -2798,7 +2695,6 @@ impl TimelinePane {
waveform_stereo: bool, waveform_stereo: bool,
context_layers: &[&lightningbeam_core::layer::AnyLayer], context_layers: &[&lightningbeam_core::layer::AnyLayer],
video_manager: &std::sync::Arc<std::sync::Mutex<lightningbeam_core::video::VideoManager>>, video_manager: &std::sync::Arc<std::sync::Mutex<lightningbeam_core::video::VideoManager>>,
audio_cache: &HashMap<uuid::Uuid, Vec<ClipInstance>>,
playback_time: f64, playback_time: f64,
) -> (Vec<(egui::Rect, uuid::Uuid, f64, f32)>, Vec<AutomationLaneRender>) { ) -> (Vec<(egui::Rect, uuid::Uuid, f64, f32)>, Vec<AutomationLaneRender>) {
let painter = ui.painter().clone(); let painter = ui.painter().clone();
@ -3160,7 +3056,7 @@ impl TimelinePane {
]; ];
for child in &g.children { for child in &g.children {
if let AnyLayer::Audio(_) = child { if let AnyLayer::Audio(_) = child {
for ci in layer_clips(child, &audio_cache) { for ci in layer_clips(child) {
let audio_clip = match document.get_audio_clip(&ci.clip_id) { let audio_clip = match document.get_audio_clip(&ci.clip_id) {
Some(c) => c, Some(c) => c,
None => continue, None => continue,
@ -3313,7 +3209,7 @@ impl TimelinePane {
}; };
// Draw clip instances for this layer // Draw clip instances for this layer
let clip_instances = layer_clips(layer, &audio_cache); let clip_instances = layer_clips(layer);
// 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) {
@ -4192,7 +4088,6 @@ impl TimelinePane {
audio_controller: Option<&std::sync::Arc<std::sync::Mutex<daw_backend::EngineController>>>, audio_controller: Option<&std::sync::Arc<std::sync::Mutex<daw_backend::EngineController>>>,
context_layers: &[&lightningbeam_core::layer::AnyLayer], context_layers: &[&lightningbeam_core::layer::AnyLayer],
editing_clip_id: Option<&uuid::Uuid>, editing_clip_id: Option<&uuid::Uuid>,
audio_cache: &HashMap<uuid::Uuid, Vec<ClipInstance>>,
) { ) {
// Only allocate content area (ruler + layers) with click and drag // Only allocate content area (ruler + layers) with click and drag
let content_response = ui.allocate_rect( let content_response = ui.allocate_rect(
@ -4262,7 +4157,7 @@ impl TimelinePane {
let _layer_data = layer.layer(); let _layer_data = layer.layer();
// Get clip instances for this layer // Get clip instances for this layer
let clip_instances = layer_clips(layer, &audio_cache); let clip_instances = layer_clips(layer);
// Check if click is within any clip instance // Check if click is within any clip instance
let click_stacking = compute_clip_stacking(document, layer, clip_instances); let click_stacking = compute_clip_stacking(document, layer, clip_instances);
@ -4548,7 +4443,6 @@ impl TimelinePane {
content_rect, content_rect,
header_rect, header_rect,
editing_clip_id, editing_clip_id,
&audio_cache,
) { ) {
// If this clip is not selected, select it (respecting shift key) // If this clip is not selected, select it (respecting shift key)
if !selection.contains_clip_instance(&clip_id) { if !selection.contains_clip_instance(&clip_id) {
@ -4566,7 +4460,7 @@ impl TimelinePane {
if drag_type == ClipDragType::Move { if drag_type == ClipDragType::Move {
// Find earliest selected clip as snap anchor for quantized moves // Find earliest selected clip as snap anchor for quantized moves
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) {
for ci in clip_instances { for ci in clip_instances {
if selection.contains_clip_instance(&ci.id) { if selection.contains_clip_instance(&ci.id) {
let start_secs = document.tempo_map().beats_to_seconds(ci.timeline_start).seconds_to_f64(); let start_secs = document.tempo_map().beats_to_seconds(ci.timeline_start).seconds_to_f64();
@ -4596,7 +4490,7 @@ impl TimelinePane {
self.drag_offset = 0.0; self.drag_offset = 0.0;
// Find earliest selected clip as snap anchor // Find earliest selected clip as snap anchor
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) {
for ci in clip_instances { for ci in clip_instances {
if selection.contains_clip_instance(&ci.id) { if selection.contains_clip_instance(&ci.id) {
let start_secs = document.tempo_map().beats_to_seconds(ci.timeline_start).seconds_to_f64(); let start_secs = document.tempo_map().beats_to_seconds(ci.timeline_start).seconds_to_f64();
@ -4630,7 +4524,7 @@ impl TimelinePane {
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
for (layer, clip_instances) in all_layer_clip_instances(context_layers, &audio_cache) { for (layer, clip_instances) in all_layer_clip_instances(context_layers) {
let layer_id = layer.id(); let layer_id = layer.id();
// Find selected clip instances in this layer // Find selected clip instances in this layer
for clip_instance in clip_instances { for clip_instance in clip_instances {
@ -4672,7 +4566,7 @@ impl TimelinePane {
> = HashMap::new(); > = HashMap::new();
// Iterate through all layers (including group children) to find selected clip instances // Iterate through all layers (including group children) to find selected clip instances
for (layer, clip_instances) in all_layer_clip_instances(context_layers, &audio_cache) { for (layer, clip_instances) in all_layer_clip_instances(context_layers) {
let layer_id = layer.id(); let layer_id = layer.id();
// Find selected clip instances in this layer // Find selected clip instances in this layer
@ -4798,7 +4692,7 @@ impl TimelinePane {
ClipDragType::LoopExtendRight => { ClipDragType::LoopExtendRight => {
let mut layer_loops: HashMap<uuid::Uuid, Vec<lightningbeam_core::actions::loop_clip_instances::LoopEntry>> = HashMap::new(); let mut layer_loops: HashMap<uuid::Uuid, Vec<lightningbeam_core::actions::loop_clip_instances::LoopEntry>> = HashMap::new();
for (layer, clip_instances) in all_layer_clip_instances(context_layers, &audio_cache) { for (layer, clip_instances) in all_layer_clip_instances(context_layers) {
let layer_id = layer.id(); let layer_id = layer.id();
for clip_instance in clip_instances { for clip_instance in clip_instances {
@ -4872,7 +4766,7 @@ impl TimelinePane {
// Extend loop_before (pre-loop region) // Extend loop_before (pre-loop region)
let mut layer_loops: HashMap<uuid::Uuid, Vec<lightningbeam_core::actions::loop_clip_instances::LoopEntry>> = HashMap::new(); let mut layer_loops: HashMap<uuid::Uuid, Vec<lightningbeam_core::actions::loop_clip_instances::LoopEntry>> = HashMap::new();
for (layer, clip_instances) in all_layer_clip_instances(context_layers, &audio_cache) { for (layer, clip_instances) in all_layer_clip_instances(context_layers) {
let layer_id = layer.id(); let layer_id = layer.id();
for clip_instance in clip_instances { for clip_instance in clip_instances {
@ -5139,7 +5033,7 @@ impl TimelinePane {
if response.double_clicked() { if response.double_clicked() {
if let Some(pos) = response.interact_pointer_pos() { if let Some(pos) = response.interact_pointer_pos() {
let over_clip = self let over_clip = self
.detect_clip_at_pointer(pos, document, content_rect, header_rect, editing_clip_id, &audio_cache) .detect_clip_at_pointer(pos, document, content_rect, header_rect, editing_clip_id)
.is_some(); .is_some();
if !over_clip { if !over_clip {
self.fit_to_project(content_rect.width()); self.fit_to_project(content_rect.width());
@ -5168,7 +5062,6 @@ impl TimelinePane {
content_rect, content_rect,
header_rect, header_rect,
editing_clip_id, editing_clip_id,
&audio_cache,
) { ) {
match drag_type { match drag_type {
ClipDragType::TrimLeft | ClipDragType::TrimRight => { ClipDragType::TrimLeft | ClipDragType::TrimRight => {
@ -5561,31 +5454,10 @@ impl PaneRenderer for TimelinePane {
// Use virtual row count (includes expanded group children) for height calculations // Use virtual row count (includes expanded group children) for height calculations
let layer_count = build_timeline_rows(&context_layers).len(); let layer_count = build_timeline_rows(&context_layers).len();
// Build audio clip cache from backend snapshot (backend-as-source-of-truth for audio).
// Uses doc UUIDs via reverse lookup of clip_instance_to_backend_map so that selection
// and action dispatch continue to work with doc UUIDs.
// Falls back to AudioLayer::clip_instances for layers with no snapshot data yet
// (e.g., layers where recording is in progress but not yet finalized).
let audio_cache: HashMap<uuid::Uuid, Vec<ClipInstance>> =
if let Some(snap_arc) = shared.clip_snapshot.as_ref() {
if let Ok(snap) = snap_arc.read() {
build_audio_clip_cache(
&snap,
shared.layer_to_track_map,
document,
shared.clip_instance_to_backend_map,
)
} else {
HashMap::new()
}
} else {
HashMap::new()
};
// Calculate project duration from last clip endpoint across all layers // Calculate project duration from last clip endpoint across all layers
let mut max_endpoint: f64 = 10.0; // Default minimum duration let mut max_endpoint: f64 = 10.0; // Default minimum duration
for &layer in &context_layers { for &layer in &context_layers {
let clip_instances = layer_clips(layer, &audio_cache); let clip_instances = layer_clips(layer);
for clip_instance in clip_instances { for clip_instance in clip_instances {
let clip_duration = effective_clip_duration(document, layer, clip_instance); let clip_duration = effective_clip_duration(document, layer, clip_instance);
@ -5660,7 +5532,7 @@ impl PaneRenderer for TimelinePane {
// Render layer rows with clipping // Render layer rows with clipping
ui.set_clip_rect(content_rect.intersect(original_clip_rect)); ui.set_clip_rect(content_rect.intersect(original_clip_rect));
let (video_clip_hovers, pending_lane_renders) = self.render_layers(ui, content_rect, shared.theme, document, shared.active_layer_id, shared.focus, shared.selection, shared.midi_event_cache, shared.raw_audio_cache, shared.waveform_gpu_dirty, shared.waveform_minmax_pools, shared.target_format, shared.waveform_stereo, &context_layers, shared.video_manager, &audio_cache, *shared.playback_time); let (video_clip_hovers, pending_lane_renders) = self.render_layers(ui, content_rect, shared.theme, document, shared.active_layer_id, shared.focus, shared.selection, shared.midi_event_cache, shared.raw_audio_cache, shared.waveform_gpu_dirty, shared.waveform_minmax_pools, shared.target_format, shared.waveform_stereo, &context_layers, shared.video_manager, *shared.playback_time);
// Render playhead on top (clip to timeline area) // Render playhead on top (clip to timeline area)
ui.set_clip_rect(timeline_rect.intersect(original_clip_rect)); ui.set_clip_rect(timeline_rect.intersect(original_clip_rect));
@ -5852,7 +5724,6 @@ impl PaneRenderer for TimelinePane {
shared.audio_controller, shared.audio_controller,
&context_layers, &context_layers,
editing_clip_id.as_ref(), editing_clip_id.as_ref(),
&audio_cache,
); );
// Render automation lanes AFTER handle_input so our ui.interact registers last and wins // Render automation lanes AFTER handle_input so our ui.interact registers last and wins
@ -5930,7 +5801,7 @@ impl PaneRenderer for TimelinePane {
if secondary_clicked { if secondary_clicked {
if let Some(pos) = ui.input(|i| i.pointer.interact_pos()) { if let Some(pos) = ui.input(|i| i.pointer.interact_pos()) {
if content_rect.contains(pos) { if content_rect.contains(pos) {
if let Some((_drag_type, clip_id)) = self.detect_clip_at_pointer(pos, document, content_rect, layer_headers_rect, editing_clip_id.as_ref(), &audio_cache) { if let Some((_drag_type, clip_id)) = self.detect_clip_at_pointer(pos, document, content_rect, layer_headers_rect, editing_clip_id.as_ref()) {
// Right-clicked on a clip // Right-clicked on a clip
if !shared.selection.contains_clip_instance(&clip_id) { if !shared.selection.contains_clip_instance(&clip_id) {
shared.selection.select_only_clip_instance(clip_id); shared.selection.select_only_clip_instance(clip_id);
@ -5983,7 +5854,7 @@ impl PaneRenderer for TimelinePane {
if let Some(pos) = long_press { if let Some(pos) = long_press {
use crate::menu::MenuAction; use crate::menu::MenuAction;
let mut items: Vec<(String, MenuAction)> = Vec::new(); let mut items: Vec<(String, MenuAction)> = Vec::new();
if let Some((_drag_type, clip_id)) = self.detect_clip_at_pointer(pos, document, content_rect, layer_headers_rect, editing_clip_id.as_ref(), &audio_cache) { if let Some((_drag_type, clip_id)) = self.detect_clip_at_pointer(pos, document, content_rect, layer_headers_rect, editing_clip_id.as_ref()) {
if !shared.selection.contains_clip_instance(&clip_id) { if !shared.selection.contains_clip_instance(&clip_id) {
shared.selection.select_only_clip_instance(clip_id); shared.selection.select_only_clip_instance(clip_id);
} }
@ -6019,7 +5890,7 @@ impl PaneRenderer for TimelinePane {
let mut enabled = false; let mut enabled = false;
if let Some(layer_id) = *shared.active_layer_id { if let Some(layer_id) = *shared.active_layer_id {
if let Some(layer) = document.get_layer(&layer_id) { if let Some(layer) = document.get_layer(&layer_id) {
let instances = layer_clips(layer, &audio_cache); let instances = layer_clips(layer);
for inst in instances { for inst in instances {
if !shared.selection.contains_clip_instance(&inst.id) { continue; } if !shared.selection.contains_clip_instance(&inst.id) { continue; }
if let Some(dur) = document.get_clip_duration(&inst.clip_id) { if let Some(dur) = document.get_clip_duration(&inst.clip_id) {
@ -6043,7 +5914,7 @@ impl PaneRenderer for TimelinePane {
let mut enabled = false; let mut enabled = false;
if let Some(layer_id) = *shared.active_layer_id { if let Some(layer_id) = *shared.active_layer_id {
if let Some(layer) = document.get_layer(&layer_id) { if let Some(layer) = document.get_layer(&layer_id) {
let instances = layer_clips(layer, &audio_cache); let instances = layer_clips(layer);
// Check each selected clip // Check each selected clip
enabled = instances.iter() enabled = instances.iter()
.filter(|ci| shared.selection.contains_clip_instance(&ci.id)) .filter(|ci| shared.selection.contains_clip_instance(&ci.id))