From 83736ec9e310ec4708896d2c3c1a49fedf6aa7b4 Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Sun, 1 Mar 2026 13:48:43 -0500 Subject: [PATCH] Record to multiple layers --- .../lightningbeam-core/src/webcam.rs | 94 ++++- .../lightningbeam-editor/src/main.rs | 149 ++++--- .../lightningbeam-editor/src/panes/mod.rs | 4 +- .../src/panes/timeline.rs | 394 +++++++++++------- 4 files changed, 420 insertions(+), 221 deletions(-) diff --git a/lightningbeam-ui/lightningbeam-core/src/webcam.rs b/lightningbeam-ui/lightningbeam-core/src/webcam.rs index 3338ad3..c275ead 100644 --- a/lightningbeam-ui/lightningbeam-core/src/webcam.rs +++ b/lightningbeam-ui/lightningbeam-core/src/webcam.rs @@ -359,12 +359,19 @@ fn capture_thread_main( let mut decoded_frame = ffmpeg::frame::Video::empty(); let mut rgba_frame = ffmpeg::frame::Video::empty(); + // Helper closure: decode current packet, scale, send preview frame, and + // optionally encode into the active recorder. Returns updated frame_count. + let row_bytes = (width * 4) as usize; + + let mut stop_result_tx: Option>> = None; + 'outer: for (stream_ref, packet) in input.packets() { if stream_ref.index() != stream_index { continue; } - // Check for commands (non-blocking). + // Check for commands BEFORE decoding so that StartRecording takes effect + // on the current packet (no lost frame at the start). while let Ok(cmd) = cmd_rx.try_recv() { match cmd { CaptureCommand::StartRecording { @@ -384,20 +391,19 @@ fn capture_thread_main( } } CaptureCommand::StopRecording { result_tx } => { - if let Some(rec) = recorder.take() { - let _ = result_tx.send(rec.finish()); - } else { - let _ = result_tx.send(Err("Not recording".into())); - } + eprintln!("[WEBCAM stop] StopRecording command received on capture thread"); + // Defer stop until AFTER we decode this packet, so the + // current frame is captured before we finalize. + stop_result_tx = Some(result_tx); } CaptureCommand::Shutdown => break 'outer, } } + // Decode current packet and process frames. decoder.send_packet(&packet).ok(); while decoder.receive_frame(&mut decoded_frame).is_ok() { - // Skip initial corrupt frames from v4l2 if frame_count < SKIP_INITIAL_FRAMES { frame_count += 1; continue; @@ -407,10 +413,8 @@ fn capture_thread_main( let timestamp = start_time.elapsed().as_secs_f64(); - // Build tightly-packed RGBA data (remove stride padding). let data = rgba_frame.data(0); let stride = rgba_frame.stride(0); - let row_bytes = (width * 4) as usize; let rgba_data = if stride == row_bytes { data[..row_bytes * height as usize].to_vec() @@ -433,13 +437,52 @@ fn capture_thread_main( let _ = frame_tx.try_send(frame); if let Some(ref mut rec) = recorder { - if let Err(e) = rec.encode_rgba(&rgba_arc, width, height, frame_count) { + if let Err(e) = rec.encode_rgba(&rgba_arc, width, height, timestamp) { eprintln!("[webcam] recording encode error: {e}"); } } frame_count += 1; } + + // Now handle deferred StopRecording (after the current packet is decoded). + if let Some(result_tx) = stop_result_tx.take() { + if let Some(mut rec) = recorder.take() { + // Flush any frames still buffered in the decoder. + let pre_drain_count = frame_count; + decoder.send_eof().ok(); + while decoder.receive_frame(&mut decoded_frame).is_ok() { + if frame_count < SKIP_INITIAL_FRAMES { + frame_count += 1; + continue; + } + scaler.run(&decoded_frame, &mut rgba_frame).ok(); + let timestamp = start_time.elapsed().as_secs_f64(); + let data = rgba_frame.data(0); + let stride = rgba_frame.stride(0); + let rgba_data = if stride == row_bytes { + data[..row_bytes * height as usize].to_vec() + } else { + let mut buf = Vec::with_capacity(row_bytes * height as usize); + for y in 0..height as usize { + buf.extend_from_slice(&data[y * stride..y * stride + row_bytes]); + } + buf + }; + let _ = rec.encode_rgba(&rgba_data, width, height, timestamp); + frame_count += 1; + } + eprintln!( + "[WEBCAM stop] drained {} extra frames from decoder (total frames={})", + frame_count - pre_drain_count, frame_count + ); + // Reset the decoder so it can accept new packets for preview. + decoder.flush(); + let _ = result_tx.send(rec.finish()); + } else { + let _ = result_tx.send(Err("Not recording".into())); + } + } } // Clean up: if still recording when shutting down, finalize. @@ -463,6 +506,10 @@ struct FrameRecorder { path: PathBuf, frame_count: u64, fps: f64, + /// Timestamp of the first recorded frame (for offsetting PTS to start at 0) + first_timestamp: Option, + /// Timestamp of the most recent frame (for computing actual duration) + last_timestamp: f64, } impl FrameRecorder { @@ -510,7 +557,10 @@ impl FrameRecorder { encoder.set_width(aligned_width); encoder.set_height(aligned_height); encoder.set_format(pixel_format); - encoder.set_time_base(ffmpeg::Rational(1, fps as i32)); + // Use microsecond time base for precise timestamp-based PTS. + // This avoids speedup artifacts when the camera delivers frames + // at irregular intervals (common under CPU load or with USB cameras). + encoder.set_time_base(ffmpeg::Rational(1, 1_000_000)); encoder.set_frame_rate(Some(ffmpeg::Rational(fps as i32, 1))); if codec_id == ffmpeg::codec::Id::H264 { @@ -549,6 +599,8 @@ impl FrameRecorder { path: path.clone(), frame_count: 0, fps, + first_timestamp: None, + last_timestamp: 0.0, }) } @@ -557,7 +609,7 @@ impl FrameRecorder { rgba_data: &[u8], width: u32, height: u32, - _global_frame: u64, + timestamp: f64, ) -> Result<(), String> { let mut src_frame = ffmpeg::frame::Video::new(ffmpeg::format::Pixel::RGBA, width, height); @@ -576,8 +628,15 @@ impl FrameRecorder { .run(&src_frame, &mut dst_frame) .map_err(|e| format!("Scale: {e}"))?; - dst_frame.set_pts(Some(self.frame_count as i64)); + // PTS in microseconds from actual capture timestamps. + // Time base is 1/1000000, so PTS = elapsed_seconds * 1000000. + // This ensures correct playback timing even when the camera delivers + // frames at irregular intervals (e.g. under CPU load). + let first_ts = *self.first_timestamp.get_or_insert(timestamp); + let elapsed_us = ((timestamp - first_ts).max(0.0) * 1_000_000.0) as i64; + dst_frame.set_pts(Some(elapsed_us)); self.frame_count += 1; + self.last_timestamp = timestamp; self.encoder .send_frame(&dst_frame) @@ -616,7 +675,14 @@ impl FrameRecorder { .write_trailer() .map_err(|e| format!("Write trailer: {e}"))?; - let duration = self.frame_count as f64 / self.fps; + let duration = match self.first_timestamp { + Some(first_ts) => self.last_timestamp - first_ts, + None => self.frame_count as f64 / self.fps, + }; + eprintln!( + "[WEBCAM finish] frames={}, first_ts={:?}, last_ts={:.4}, duration={:.4}s, fps={}", + self.frame_count, self.first_timestamp, self.last_timestamp, duration, self.fps, + ); Ok(RecordingResult { file_path: self.path, duration, diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index ddc8020..a45164d 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -771,8 +771,6 @@ struct EditorApp { webcam_frame: Option, /// Pending webcam recording command (set by timeline, processed in update) webcam_record_command: Option, - /// Layer being recorded to via webcam - webcam_recording_layer_id: Option, // Track ID mapping (Document layer UUIDs <-> daw-backend TrackIds) layer_to_track_map: HashMap, track_to_layer_map: HashMap, @@ -793,7 +791,7 @@ struct EditorApp { is_recording: bool, // Whether recording is currently active recording_clips: HashMap, // layer_id -> backend clip_id during recording recording_start_time: f64, // Playback time when recording started - recording_layer_id: Option, // Layer being recorded to (for creating clips) + recording_layer_ids: Vec, // Layers being recorded to (for creating clips) // Asset drag-and-drop state dragging_asset: Option, // Asset being dragged from Asset Library // Clipboard @@ -1032,7 +1030,6 @@ impl EditorApp { webcam: None, webcam_frame: None, webcam_record_command: None, - webcam_recording_layer_id: None, layer_to_track_map: HashMap::new(), track_to_layer_map: HashMap::new(), clip_to_metatrack_map: HashMap::new(), @@ -1045,7 +1042,7 @@ impl EditorApp { is_recording: false, // Not recording initially recording_clips: HashMap::new(), // No active recording clips recording_start_time: 0.0, // Will be set when recording starts - recording_layer_id: None, // Will be set when recording starts + recording_layer_ids: Vec::new(), // Will be populated when recording starts dragging_asset: None, // No asset being dragged initially clipboard_manager: lightningbeam_core::clipboard::ClipboardManager::new(), effect_to_load: None, @@ -4333,28 +4330,30 @@ impl eframe::App for EditorApp { AudioEvent::RecordingStarted(track_id, backend_clip_id, rec_sample_rate, rec_channels) => { println!("🎤 Recording started on track {:?}, backend_clip_id={}", track_id, backend_clip_id); - // Create clip in document and add instance to layer - if let Some(layer_id) = self.recording_layer_id { - use lightningbeam_core::clip::{AudioClip, ClipInstance}; + // Create clip in document and add instance to the layer for this track + if let Some(&layer_id) = self.track_to_layer_map.get(&track_id) { + if self.recording_layer_ids.contains(&layer_id) { + use lightningbeam_core::clip::{AudioClip, ClipInstance}; - // Create a recording-in-progress clip (no pool index yet) - let clip = AudioClip::new_recording("Recording..."); - let doc_clip_id = self.action_executor.document_mut().add_audio_clip(clip); + // Create a recording-in-progress clip (no pool index yet) + let clip = AudioClip::new_recording("Recording..."); + let doc_clip_id = self.action_executor.document_mut().add_audio_clip(clip); - // Create clip instance on the layer - let clip_instance = ClipInstance::new(doc_clip_id) - .with_timeline_start(self.recording_start_time); + // Create clip instance on the layer + let clip_instance = ClipInstance::new(doc_clip_id) + .with_timeline_start(self.recording_start_time); - // Add instance to layer (works for root and inside movie clips) - if let Some(layer) = self.action_executor.document_mut().get_layer_mut(&layer_id) { - if let lightningbeam_core::layer::AnyLayer::Audio(audio_layer) = layer { - audio_layer.clip_instances.push(clip_instance); - println!("✅ Created recording clip instance on layer {}", layer_id); + // Add instance to layer (works for root and inside movie clips) + if let Some(layer) = self.action_executor.document_mut().get_layer_mut(&layer_id) { + if let lightningbeam_core::layer::AnyLayer::Audio(audio_layer) = layer { + audio_layer.clip_instances.push(clip_instance); + println!("✅ Created recording clip instance on layer {}", layer_id); + } } - } - // Store mapping for later updates - self.recording_clips.insert(layer_id, backend_clip_id); + // Store mapping for later updates + self.recording_clips.insert(layer_id, backend_clip_id); + } } // Initialize live waveform cache for recording @@ -4362,11 +4361,15 @@ impl eframe::App for EditorApp { ctx.request_repaint(); } - AudioEvent::RecordingProgress(_clip_id, duration) => { + AudioEvent::RecordingProgress(_backend_clip_id, duration) => { // Update clip duration as recording progresses - if let Some(layer_id) = self.recording_layer_id { - // First, find the clip_id from the layer (read-only borrow) - let clip_id = { + // Find which layer this backend clip belongs to via recording_clips + let layer_id = self.recording_clips.iter() + .find(|(_, &cid)| cid == _backend_clip_id) + .map(|(&lid, _)| lid); + if let Some(layer_id) = layer_id { + // First, find the doc clip_id from the layer (read-only borrow) + let doc_clip_id = { let document = self.action_executor.document(); document.get_layer(&layer_id) .and_then(|layer| { @@ -4379,8 +4382,8 @@ impl eframe::App for EditorApp { }; // Then update the clip duration (mutable borrow) - if let Some(clip_id) = clip_id { - if let Some(clip) = self.action_executor.document_mut().audio_clips.get_mut(&clip_id) { + if let Some(doc_clip_id) = doc_clip_id { + if let Some(clip) = self.action_executor.document_mut().audio_clips.get_mut(&doc_clip_id) { if clip.is_recording() { clip.duration = duration; } @@ -4390,7 +4393,7 @@ impl eframe::App for EditorApp { ctx.request_repaint(); } AudioEvent::RecordingStopped(_backend_clip_id, pool_index, _waveform) => { - println!("🎤 Recording stopped: pool_index={}", pool_index); + eprintln!("[STOP] AudioEvent::RecordingStopped received (pool_index={})", pool_index); // Clean up live recording waveform cache self.raw_audio_cache.remove(&usize::MAX); @@ -4414,7 +4417,7 @@ impl eframe::App for EditorApp { let mut controller = controller_arc.lock().unwrap(); match controller.get_pool_file_info(pool_index) { Ok((dur, _, _)) => { - println!("✅ Got duration from backend: {:.2}s", dur); + eprintln!("[AUDIO] Got duration from backend: {:.4}s", dur); self.audio_duration_cache.insert(pool_index, dur); dur } @@ -4429,7 +4432,11 @@ impl eframe::App for EditorApp { // Finalize the recording clip with real pool_index and duration // and sync to backend for playback - if let Some(layer_id) = self.recording_layer_id { + // Find which layer this recording belongs to via recording_clips + let recording_layer = self.recording_clips.iter() + .find(|(_, &cid)| cid == _backend_clip_id) + .map(|(&lid, _)| lid); + if let Some(layer_id) = recording_layer { // First, find the clip instance and clip id let (clip_id, instance_id, timeline_start, trim_start) = { let document = self.action_executor.document(); @@ -4451,7 +4458,7 @@ impl eframe::App for EditorApp { if let Some(clip) = self.action_executor.document_mut().audio_clips.get_mut(&clip_id) { if clip.finalize_recording(pool_index, duration) { clip.name = format!("Recording {}", pool_index); - println!("✅ Finalized recording clip: pool={}, duration={:.2}s", pool_index, duration); + eprintln!("[AUDIO] Finalized recording clip: pool={}, duration={:.4}s", pool_index, duration); } } @@ -4493,22 +4500,32 @@ impl eframe::App for EditorApp { } } - // Clear recording state - self.is_recording = false; - self.recording_clips.clear(); - self.recording_layer_id = None; + // Remove this layer from active recordings + if let Some(layer_id) = recording_layer { + self.recording_layer_ids.retain(|id| *id != layer_id); + self.recording_clips.remove(&layer_id); + } + // Clear global recording state only when all recordings are done + if self.recording_layer_ids.is_empty() { + self.is_recording = false; + self.recording_clips.clear(); + } ctx.request_repaint(); } AudioEvent::RecordingError(message) => { eprintln!("❌ Recording error: {}", message); self.is_recording = false; self.recording_clips.clear(); - self.recording_layer_id = None; + self.recording_layer_ids.clear(); ctx.request_repaint(); } AudioEvent::MidiRecordingProgress(_track_id, clip_id, duration, notes) => { // Update clip duration in document (so timeline bar grows) - if let Some(layer_id) = self.recording_layer_id { + // Find layer for this track via track_to_layer_map + let midi_layer_id = self.track_to_layer_map.get(&_track_id) + .filter(|lid| self.recording_layer_ids.contains(lid)) + .copied(); + if let Some(layer_id) = midi_layer_id { let doc_clip_id = { let document = self.action_executor.document(); document.get_layer(&layer_id) @@ -4567,7 +4584,10 @@ impl eframe::App for EditorApp { self.midi_event_cache.insert(clip_id, cache_events); // Update document clip with final duration and name - if let Some(layer_id) = self.recording_layer_id { + let midi_layer_id = self.track_to_layer_map.get(&track_id) + .filter(|lid| self.recording_layer_ids.contains(lid)) + .copied(); + if let Some(layer_id) = midi_layer_id { let doc_clip_id = { let document = self.action_executor.document(); document.get_layer(&layer_id) @@ -4601,10 +4621,15 @@ impl eframe::App for EditorApp { // The backend created the instance in create_midi_clip(), but doesn't // report the instance_id back. Needed for move/trim operations later. - // Clear recording state - self.is_recording = false; - self.recording_clips.clear(); - self.recording_layer_id = None; + // Remove this MIDI layer from active recordings + if let Some(&layer_id) = self.track_to_layer_map.get(&track_id) { + self.recording_layer_ids.retain(|id| *id != layer_id); + self.recording_clips.remove(&layer_id); + } + if self.recording_layer_ids.is_empty() { + self.is_recording = false; + self.recording_clips.clear(); + } ctx.request_repaint(); } AudioEvent::AudioFileReady { pool_index, path, channels, sample_rate, duration, format } => { @@ -5031,7 +5056,7 @@ impl eframe::App for EditorApp { is_recording: &mut self.is_recording, recording_clips: &mut self.recording_clips, recording_start_time: &mut self.recording_start_time, - recording_layer_id: &mut self.recording_layer_id, + recording_layer_ids: &mut self.recording_layer_ids, dragging_asset: &mut self.dragging_asset, stroke_width: &mut self.stroke_width, fill_enabled: &mut self.fill_enabled, @@ -5157,7 +5182,7 @@ impl eframe::App for EditorApp { // Process webcam recording commands from timeline if let Some(cmd) = self.webcam_record_command.take() { match cmd { - panes::WebcamRecordCommand::Start { layer_id } => { + panes::WebcamRecordCommand::Start { .. } => { // Ensure webcam is open if self.webcam.is_none() { if let Some(device) = lightningbeam_core::webcam::default_camera() { @@ -5191,7 +5216,6 @@ impl eframe::App for EditorApp { let recording_path = recording_dir.join(format!("webcam_recording_{}.{}", timestamp, ext)); match webcam.start_recording(recording_path, codec) { Ok(()) => { - self.webcam_recording_layer_id = Some(layer_id); eprintln!("[WEBCAM] Recording started"); } Err(e) => { @@ -5201,13 +5225,25 @@ impl eframe::App for EditorApp { } } panes::WebcamRecordCommand::Stop => { + eprintln!("[STOP] Webcam stop command processed (main.rs handler)"); + // Find the webcam recording layer before stopping (need it for cleanup) + let webcam_layer_id = { + let document = self.action_executor.document(); + self.recording_layer_ids.iter().copied().find(|lid| { + document.get_layer(lid).map_or(false, |l| { + matches!(l, lightningbeam_core::layer::AnyLayer::Video(v) if v.camera_enabled) + }) + }) + }; if let Some(webcam) = &mut self.webcam { + let stop_t = std::time::Instant::now(); match webcam.stop_recording() { Ok(result) => { + eprintln!("[STOP] webcam.stop_recording() returned in {:.1}ms", stop_t.elapsed().as_secs_f64() * 1000.0); let file_path_str = result.file_path.to_string_lossy().to_string(); - eprintln!("[WEBCAM] Recording saved to: {}", file_path_str); + eprintln!("[WEBCAM] Recording saved to: {} (recorder duration={:.4}s)", file_path_str, result.duration); // Create VideoClip + ClipInstance from recorded file - if let Some(layer_id) = self.webcam_recording_layer_id.take() { + if let Some(layer_id) = webcam_layer_id { match lightningbeam_core::video::probe_video(&file_path_str) { Ok(info) => { use lightningbeam_core::clip::{VideoClip, ClipInstance}; @@ -5285,7 +5321,10 @@ impl eframe::App for EditorApp { } }); - eprintln!("[WEBCAM] Created video clip: {:.1}s @ {:.1}fps", duration, info.fps); + eprintln!( + "[WEBCAM] probe_video: duration={:.4}s, fps={:.1}, {}x{}. Using probe duration for clip.", + info.duration, info.fps, info.width, info.height, + ); } Err(e) => { eprintln!("[WEBCAM] Failed to probe recorded video: {}", e); @@ -5295,12 +5334,18 @@ impl eframe::App for EditorApp { } Err(e) => { eprintln!("[WEBCAM] Failed to stop recording: {}", e); - self.webcam_recording_layer_id = None; + // webcam layer cleanup handled by recording_layer_ids.clear() below } } } - self.is_recording = false; - self.recording_layer_id = None; + // Remove webcam layer from active recordings + if let Some(wid) = webcam_layer_id { + self.recording_layer_ids.retain(|id| *id != wid); + } + if self.recording_layer_ids.is_empty() { + self.is_recording = false; + self.recording_clips.clear(); + } } } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs index 3faa6d5..81d3910 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs @@ -57,8 +57,10 @@ pub struct DraggingAsset { /// Command for webcam recording (issued by timeline, processed by main) #[derive(Debug)] +#[allow(dead_code)] pub enum WebcamRecordCommand { /// Start recording on the given video layer + // TODO: remove layer_id — recording_layer_ids now tracks which layers are recording Start { layer_id: uuid::Uuid }, /// Stop current webcam recording Stop, @@ -198,7 +200,7 @@ pub struct SharedPaneState<'a> { pub is_recording: &'a mut bool, // Whether recording is currently active pub recording_clips: &'a mut std::collections::HashMap, // layer_id -> clip_id pub recording_start_time: &'a mut f64, // Playback time when recording started - pub recording_layer_id: &'a mut Option, // Layer being recorded to + pub recording_layer_ids: &'a mut Vec, // Layers being recorded to /// Asset being dragged from Asset Library (for cross-pane drag-and-drop) pub dragging_asset: &'a mut Option, // Tool-specific options for infopanel diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs index e8775c1..3ed9fc0 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs @@ -138,13 +138,6 @@ enum TimeDisplayFormat { Measures, } -/// Type of recording in progress (for stop logic dispatch) -enum RecordingType { - Audio, - Midi, - Webcam, -} - /// State for an in-progress layer header drag-to-reorder operation. struct LayerDragState { /// IDs of the layers being dragged (in visual order, top to bottom) @@ -516,184 +509,254 @@ impl TimelinePane { } } - /// Start recording on the active layer (audio or video with camera) + /// Start recording on all selected recordable layers (or the active layer as fallback). + /// Groups are recursed into. At most one layer per recording type is recorded to + /// (topmost in visual order wins). fn start_recording(&mut self, shared: &mut SharedPaneState) { use lightningbeam_core::clip::{AudioClip, ClipInstance}; - let Some(active_layer_id) = *shared.active_layer_id else { - println!("⚠️ No active layer selected for recording"); - return; - }; - - // Check if this is a video layer with camera enabled - let is_video_camera = { - let document = shared.action_executor.document(); - let context_layers = document.context_layers(shared.editing_clip_id.as_ref()); - context_layers.iter().copied() - .find(|l| l.id() == active_layer_id) - .map(|layer| { - if let AnyLayer::Video(v) = layer { - v.camera_enabled - } else { - false - } - }) - .unwrap_or(false) - }; - - if is_video_camera { - // Issue webcam recording start command (processed by main.rs) - *shared.webcam_record_command = Some(super::WebcamRecordCommand::Start { - layer_id: active_layer_id, - }); - *shared.is_recording = true; - *shared.recording_start_time = *shared.playback_time; - *shared.recording_layer_id = Some(active_layer_id); - - // Auto-start playback for recording - if !*shared.is_playing { - if let Some(controller_arc) = shared.audio_controller { - let mut controller = controller_arc.lock().unwrap(); - controller.play(); - *shared.is_playing = true; - println!("▶ Auto-started playback for webcam recording"); + // Step 1: Collect candidate layer IDs from focus selection, falling back to active layer + let candidate_ids: Vec = match shared.focus { + lightningbeam_core::selection::FocusSelection::Layers(ref ids) if !ids.is_empty() => { + ids.clone() + } + _ => { + if let Some(id) = *shared.active_layer_id { + vec![id] + } else { + println!("⚠️ No active layer selected for recording"); + return; } } - println!("📹 Started webcam recording on layer {}", active_layer_id); + }; + + // Step 2: Resolve layers, recursing into groups to collect recordable leaves. + // Categorize by recording type. Use visual ordering (build_timeline_rows) to pick topmost. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + enum RecordCandidate { + AudioSampled, + AudioMidi, + VideoCamera, + } + + let mut candidates: Vec<(uuid::Uuid, RecordCandidate, usize)> = Vec::new(); // (layer_id, type, visual_row_index) + + { + let document = shared.action_executor.document(); + let context_layers = document.context_layers(shared.editing_clip_id.as_ref()); + let rows = build_timeline_rows(&context_layers); + + // Helper: collect recordable leaf layer IDs from a layer (recurse into groups) + fn collect_recordable_leaves(layer: &AnyLayer, out: &mut Vec) { + match layer { + AnyLayer::Audio(_) => out.push(layer.id()), + AnyLayer::Video(v) if v.camera_enabled => out.push(layer.id()), + AnyLayer::Group(g) => { + for child in &g.children { + collect_recordable_leaves(child, out); + } + } + _ => {} + } + } + + let mut leaf_ids: Vec = Vec::new(); + for cid in &candidate_ids { + if let Some(layer) = context_layers.iter().copied().find(|l| l.id() == *cid) { + collect_recordable_leaves(layer, &mut leaf_ids); + } else { + // Try deeper in the tree (for layers inside groups) + if let Some(layer) = document.root.get_child(cid) { + collect_recordable_leaves(layer, &mut leaf_ids); + } + } + } + + // Deduplicate + leaf_ids.sort(); + leaf_ids.dedup(); + + // Categorize and find visual row index for ordering + for leaf_id in &leaf_ids { + let visual_idx = rows.iter().position(|r| r.layer_id() == *leaf_id).unwrap_or(usize::MAX); + if let Some(layer) = document.root.get_child(leaf_id).or_else(|| { + context_layers.iter().copied().find(|l| l.id() == *leaf_id) + }) { + let cat = match layer { + AnyLayer::Audio(a) => match a.audio_layer_type { + AudioLayerType::Sampled => Some(RecordCandidate::AudioSampled), + AudioLayerType::Midi => Some(RecordCandidate::AudioMidi), + }, + AnyLayer::Video(v) if v.camera_enabled => Some(RecordCandidate::VideoCamera), + _ => None, + }; + if let Some(cat) = cat { + candidates.push((*leaf_id, cat, visual_idx)); + } + } + } + } + + if candidates.is_empty() { + println!("⚠️ No recordable layers in selection"); return; } - // Get layer type (copy it so we can drop the document borrow before mutating) - let layer_type = { - let document = shared.action_executor.document(); - let context_layers = document.context_layers(shared.editing_clip_id.as_ref()); - let Some(layer) = context_layers.iter().copied().find(|l| l.id() == active_layer_id) else { - println!("⚠️ Active layer not found in document"); - return; - }; - let AnyLayer::Audio(audio_layer) = layer else { - println!("⚠️ Active layer is not an audio layer - cannot record"); - return; - }; - audio_layer.audio_layer_type - }; - - // Get the backend track ID for this layer - let Some(&track_id) = shared.layer_to_track_map.get(&active_layer_id) else { - println!("⚠️ No backend track mapped for layer {}", active_layer_id); - return; - }; - - let start_time = *shared.playback_time; - - // Start recording based on layer type - if let Some(controller_arc) = shared.audio_controller { - let mut controller = controller_arc.lock().unwrap(); - - match layer_type { - AudioLayerType::Midi => { - // Create backend MIDI clip and start recording - let clip_id = controller.create_midi_clip(track_id, start_time, 0.0); - controller.start_midi_recording(track_id, clip_id, start_time); - shared.recording_clips.insert(active_layer_id, clip_id); - println!("🎹 Started MIDI recording on track {:?} at {:.2}s, clip_id={}", - track_id, start_time, clip_id); - - // Drop controller lock before document mutation - drop(controller); - - // Create document clip + clip instance immediately (clip_id is known synchronously) - let doc_clip = AudioClip::new_midi("Recording...", clip_id, 0.0); - let doc_clip_id = shared.action_executor.document_mut().add_audio_clip(doc_clip); - - let clip_instance = ClipInstance::new(doc_clip_id) - .with_timeline_start(start_time); - - if let Some(layer) = shared.action_executor.document_mut().get_layer_mut(&active_layer_id) { - if let lightningbeam_core::layer::AnyLayer::Audio(audio_layer) = layer { - audio_layer.clip_instances.push(clip_instance); - } - } - - // Initialize empty cache entry for this clip - shared.midi_event_cache.insert(clip_id, Vec::new()); + // Step 3: Sort by visual position (topmost first) and deduplicate by type + candidates.sort_by_key(|c| c.2); + let mut seen_sampled = false; + let mut seen_midi = false; + let mut seen_webcam = false; + candidates.retain(|c| { + match c.1 { + RecordCandidate::AudioSampled => { + if seen_sampled { return false; } + seen_sampled = true; } - AudioLayerType::Sampled => { - // For audio recording, backend creates the clip - controller.start_recording(track_id, start_time); - println!("🎤 Started audio recording on track {:?} at {:.2}s", track_id, start_time); - drop(controller); + RecordCandidate::AudioMidi => { + if seen_midi { return false; } + seen_midi = true; + } + RecordCandidate::VideoCamera => { + if seen_webcam { return false; } + seen_webcam = true; } } + true + }); - // Re-acquire lock for playback start - if !*shared.is_playing { + let start_time = *shared.playback_time; + shared.recording_layer_ids.clear(); + + // Step 4: Dispatch recording for each candidate + for &(layer_id, ref cat, _) in &candidates { + match cat { + RecordCandidate::VideoCamera => { + *shared.webcam_record_command = Some(super::WebcamRecordCommand::Start { + layer_id, + }); + shared.recording_layer_ids.push(layer_id); + println!("📹 Started webcam recording on layer {}", layer_id); + } + RecordCandidate::AudioSampled => { + if let Some(&track_id) = shared.layer_to_track_map.get(&layer_id) { + if let Some(controller_arc) = shared.audio_controller { + let mut controller = controller_arc.lock().unwrap(); + controller.start_recording(track_id, start_time); + println!("🎤 Started audio recording on track {:?} at {:.2}s", track_id, start_time); + } + shared.recording_layer_ids.push(layer_id); + } else { + println!("⚠️ No backend track mapped for layer {}", layer_id); + } + } + RecordCandidate::AudioMidi => { + if let Some(&track_id) = shared.layer_to_track_map.get(&layer_id) { + if let Some(controller_arc) = shared.audio_controller { + let mut controller = controller_arc.lock().unwrap(); + let clip_id = controller.create_midi_clip(track_id, start_time, 0.0); + controller.start_midi_recording(track_id, clip_id, start_time); + shared.recording_clips.insert(layer_id, clip_id); + println!("🎹 Started MIDI recording on track {:?} at {:.2}s, clip_id={}", + track_id, start_time, clip_id); + } + + // Create document clip + clip instance immediately + let doc_clip = AudioClip::new_midi("Recording...", + *shared.recording_clips.get(&layer_id).unwrap_or(&0), 0.0); + let doc_clip_id = shared.action_executor.document_mut().add_audio_clip(doc_clip); + + let clip_instance = ClipInstance::new(doc_clip_id) + .with_timeline_start(start_time); + + if let Some(layer) = shared.action_executor.document_mut().get_layer_mut(&layer_id) { + if let lightningbeam_core::layer::AnyLayer::Audio(audio_layer) = layer { + audio_layer.clip_instances.push(clip_instance); + } + } + + // Initialize empty cache entry + if let Some(&clip_id) = shared.recording_clips.get(&layer_id) { + shared.midi_event_cache.insert(clip_id, Vec::new()); + } + + shared.recording_layer_ids.push(layer_id); + } else { + println!("⚠️ No backend track mapped for layer {}", layer_id); + } + } + } + } + + if shared.recording_layer_ids.is_empty() { + println!("⚠️ Failed to start recording on any layer"); + return; + } + + // Auto-start playback if needed + if !*shared.is_playing { + if let Some(controller_arc) = shared.audio_controller { let mut controller = controller_arc.lock().unwrap(); controller.play(); *shared.is_playing = true; println!("▶ Auto-started playback for recording"); } - - // Store recording state - *shared.is_recording = true; - *shared.recording_start_time = start_time; - *shared.recording_layer_id = Some(active_layer_id); - } else { - println!("⚠️ No audio controller available"); } + + *shared.is_recording = true; + *shared.recording_start_time = start_time; } - /// Stop the current recording + /// Stop all active recordings fn stop_recording(&mut self, shared: &mut SharedPaneState) { - // Determine recording type by checking the layer - let recording_type = if let Some(layer_id) = *shared.recording_layer_id { - let context_layers = shared.action_executor.document().context_layers(shared.editing_clip_id.as_ref()); - context_layers.iter().copied() - .find(|l| l.id() == layer_id) - .map(|layer| { + let stop_wall = std::time::Instant::now(); + eprintln!("[STOP] stop_recording called at {:?}", stop_wall); + + // Determine which recording types are active by checking recording_layer_ids + let mut has_audio = false; + let mut has_midi = false; + let mut has_webcam = false; + + { + let document = shared.action_executor.document(); + for layer_id in shared.recording_layer_ids.iter() { + if let Some(layer) = document.root.get_child(layer_id) { match layer { - lightningbeam_core::layer::AnyLayer::Audio(audio_layer) => { - if matches!(audio_layer.audio_layer_type, lightningbeam_core::layer::AudioLayerType::Midi) { - RecordingType::Midi - } else { - RecordingType::Audio + lightningbeam_core::layer::AnyLayer::Audio(a) => { + match a.audio_layer_type { + lightningbeam_core::layer::AudioLayerType::Sampled => has_audio = true, + lightningbeam_core::layer::AudioLayerType::Midi => has_midi = true, } } lightningbeam_core::layer::AnyLayer::Video(v) if v.camera_enabled => { - RecordingType::Webcam + has_webcam = true; } - _ => RecordingType::Audio, - } - }) - .unwrap_or(RecordingType::Audio) - } else { - RecordingType::Audio - }; - - match recording_type { - RecordingType::Webcam => { - // Issue webcam stop command (processed by main.rs) - *shared.webcam_record_command = Some(super::WebcamRecordCommand::Stop); - println!("📹 Stopped webcam recording"); - } - _ => { - if let Some(controller_arc) = shared.audio_controller { - let mut controller = controller_arc.lock().unwrap(); - - if matches!(recording_type, RecordingType::Midi) { - controller.stop_midi_recording(); - println!("🎹 Stopped MIDI recording"); - } else { - controller.stop_recording(); - println!("🎤 Stopped audio recording"); + _ => {} } } } } - // Note: Don't clear recording_layer_id here! - // The RecordingStopped/MidiRecordingStopped event handler in main.rs - // needs it to finalize the clip. It will clear the state after processing. + if has_webcam { + *shared.webcam_record_command = Some(super::WebcamRecordCommand::Stop); + eprintln!("[STOP] Webcam stop command queued at +{:.1}ms", stop_wall.elapsed().as_secs_f64() * 1000.0); + } + + if let Some(controller_arc) = shared.audio_controller { + let mut controller = controller_arc.lock().unwrap(); + if has_midi { + controller.stop_midi_recording(); + eprintln!("[STOP] MIDI stop command sent at +{:.1}ms", stop_wall.elapsed().as_secs_f64() * 1000.0); + } + if has_audio { + controller.stop_recording(); + eprintln!("[STOP] Audio stop command sent at +{:.1}ms", stop_wall.elapsed().as_secs_f64() * 1000.0); + } + } + + // Note: Don't clear recording_layer_ids here! + // The RecordingStopped/MidiRecordingStopped event handlers in main.rs + // need them to finalize clips. They will clear the state after processing. // Only clear is_recording to update UI state immediately. *shared.is_recording = false; } @@ -3155,8 +3218,21 @@ impl TimelinePane { if clicked_layer_index < header_rows.len() { let layer_id = header_rows[clicked_layer_index].layer_id(); let clicked_parent = header_rows[clicked_layer_index].parent_id(); + let prev_active = *active_layer_id; *active_layer_id = Some(layer_id); if shift_held { + // If focus doesn't already contain the previously active layer + // (e.g. it was set by creating a layer rather than clicking), + // seed the selection with it so shift-click extends from it. + if let Some(prev) = prev_active.filter(|id| *id != layer_id) { + let active_in_focus = matches!( + &focus, + lightningbeam_core::selection::FocusSelection::Layers(ids) if ids.contains(&prev) + ); + if !active_in_focus { + *focus = lightningbeam_core::selection::FocusSelection::Layers(vec![prev]); + } + } shift_toggle_layer(focus, layer_id, clicked_parent, &header_rows); } else { // Only change selection if the clicked layer isn't already selected @@ -3742,8 +3818,18 @@ impl TimelinePane { if clicked_layer_index < empty_click_rows.len() { let layer_id = empty_click_rows[clicked_layer_index].layer_id(); let clicked_parent = empty_click_rows[clicked_layer_index].parent_id(); + let prev_active = *active_layer_id; *active_layer_id = Some(layer_id); if shift_held { + if let Some(prev) = prev_active.filter(|id| *id != layer_id) { + let active_in_focus = matches!( + &focus, + lightningbeam_core::selection::FocusSelection::Layers(ids) if ids.contains(&prev) + ); + if !active_in_focus { + *focus = lightningbeam_core::selection::FocusSelection::Layers(vec![prev]); + } + } shift_toggle_layer(focus, layer_id, clicked_parent, &empty_click_rows); } else { selection.clear_clip_instances();