Record to multiple layers

This commit is contained in:
Skyler Lehmkuhl 2026-03-01 13:48:43 -05:00
parent 8d8f94a547
commit 83736ec9e3
4 changed files with 420 additions and 221 deletions

View File

@ -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<std::sync::mpsc::Sender<Result<RecordingResult, String>>> = 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<f64>,
/// 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,

View File

@ -771,8 +771,6 @@ struct EditorApp {
webcam_frame: Option<lightningbeam_core::webcam::CaptureFrame>,
/// Pending webcam recording command (set by timeline, processed in update)
webcam_record_command: Option<panes::WebcamRecordCommand>,
/// Layer being recorded to via webcam
webcam_recording_layer_id: Option<Uuid>,
// Track ID mapping (Document layer UUIDs <-> daw-backend TrackIds)
layer_to_track_map: HashMap<Uuid, daw_backend::TrackId>,
track_to_layer_map: HashMap<daw_backend::TrackId, Uuid>,
@ -793,7 +791,7 @@ struct EditorApp {
is_recording: bool, // Whether recording is currently active
recording_clips: HashMap<Uuid, u32>, // layer_id -> backend clip_id during recording
recording_start_time: f64, // Playback time when recording started
recording_layer_id: Option<Uuid>, // Layer being recorded to (for creating clips)
recording_layer_ids: Vec<Uuid>, // Layers being recorded to (for creating clips)
// Asset drag-and-drop state
dragging_asset: Option<panes::DraggingAsset>, // 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();
}
}
}
}

View File

@ -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<uuid::Uuid, u32>, // layer_id -> clip_id
pub recording_start_time: &'a mut f64, // Playback time when recording started
pub recording_layer_id: &'a mut Option<uuid::Uuid>, // Layer being recorded to
pub recording_layer_ids: &'a mut Vec<uuid::Uuid>, // Layers being recorded to
/// Asset being dragged from Asset Library (for cross-pane drag-and-drop)
pub dragging_asset: &'a mut Option<DraggingAsset>,
// Tool-specific options for infopanel

View File

@ -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<uuid::Uuid> = 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<uuid::Uuid>) {
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<uuid::Uuid> = 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();