Compare commits
2 Commits
8d8f94a547
...
8e9d90ed92
| Author | SHA1 | Date |
|---|---|---|
|
|
8e9d90ed92 | |
|
|
83736ec9e3 |
|
|
@ -359,12 +359,19 @@ fn capture_thread_main(
|
||||||
let mut decoded_frame = ffmpeg::frame::Video::empty();
|
let mut decoded_frame = ffmpeg::frame::Video::empty();
|
||||||
let mut rgba_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() {
|
'outer: for (stream_ref, packet) in input.packets() {
|
||||||
if stream_ref.index() != stream_index {
|
if stream_ref.index() != stream_index {
|
||||||
continue;
|
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() {
|
while let Ok(cmd) = cmd_rx.try_recv() {
|
||||||
match cmd {
|
match cmd {
|
||||||
CaptureCommand::StartRecording {
|
CaptureCommand::StartRecording {
|
||||||
|
|
@ -384,20 +391,19 @@ fn capture_thread_main(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
CaptureCommand::StopRecording { result_tx } => {
|
CaptureCommand::StopRecording { result_tx } => {
|
||||||
if let Some(rec) = recorder.take() {
|
eprintln!("[WEBCAM stop] StopRecording command received on capture thread");
|
||||||
let _ = result_tx.send(rec.finish());
|
// Defer stop until AFTER we decode this packet, so the
|
||||||
} else {
|
// current frame is captured before we finalize.
|
||||||
let _ = result_tx.send(Err("Not recording".into()));
|
stop_result_tx = Some(result_tx);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
CaptureCommand::Shutdown => break 'outer,
|
CaptureCommand::Shutdown => break 'outer,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Decode current packet and process frames.
|
||||||
decoder.send_packet(&packet).ok();
|
decoder.send_packet(&packet).ok();
|
||||||
|
|
||||||
while decoder.receive_frame(&mut decoded_frame).is_ok() {
|
while decoder.receive_frame(&mut decoded_frame).is_ok() {
|
||||||
// Skip initial corrupt frames from v4l2
|
|
||||||
if frame_count < SKIP_INITIAL_FRAMES {
|
if frame_count < SKIP_INITIAL_FRAMES {
|
||||||
frame_count += 1;
|
frame_count += 1;
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -407,10 +413,8 @@ fn capture_thread_main(
|
||||||
|
|
||||||
let timestamp = start_time.elapsed().as_secs_f64();
|
let timestamp = start_time.elapsed().as_secs_f64();
|
||||||
|
|
||||||
// Build tightly-packed RGBA data (remove stride padding).
|
|
||||||
let data = rgba_frame.data(0);
|
let data = rgba_frame.data(0);
|
||||||
let stride = rgba_frame.stride(0);
|
let stride = rgba_frame.stride(0);
|
||||||
let row_bytes = (width * 4) as usize;
|
|
||||||
|
|
||||||
let rgba_data = if stride == row_bytes {
|
let rgba_data = if stride == row_bytes {
|
||||||
data[..row_bytes * height as usize].to_vec()
|
data[..row_bytes * height as usize].to_vec()
|
||||||
|
|
@ -433,13 +437,52 @@ fn capture_thread_main(
|
||||||
let _ = frame_tx.try_send(frame);
|
let _ = frame_tx.try_send(frame);
|
||||||
|
|
||||||
if let Some(ref mut rec) = recorder {
|
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}");
|
eprintln!("[webcam] recording encode error: {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
frame_count += 1;
|
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.
|
// Clean up: if still recording when shutting down, finalize.
|
||||||
|
|
@ -463,6 +506,10 @@ struct FrameRecorder {
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
frame_count: u64,
|
frame_count: u64,
|
||||||
fps: f64,
|
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 {
|
impl FrameRecorder {
|
||||||
|
|
@ -510,7 +557,10 @@ impl FrameRecorder {
|
||||||
encoder.set_width(aligned_width);
|
encoder.set_width(aligned_width);
|
||||||
encoder.set_height(aligned_height);
|
encoder.set_height(aligned_height);
|
||||||
encoder.set_format(pixel_format);
|
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)));
|
encoder.set_frame_rate(Some(ffmpeg::Rational(fps as i32, 1)));
|
||||||
|
|
||||||
if codec_id == ffmpeg::codec::Id::H264 {
|
if codec_id == ffmpeg::codec::Id::H264 {
|
||||||
|
|
@ -549,6 +599,8 @@ impl FrameRecorder {
|
||||||
path: path.clone(),
|
path: path.clone(),
|
||||||
frame_count: 0,
|
frame_count: 0,
|
||||||
fps,
|
fps,
|
||||||
|
first_timestamp: None,
|
||||||
|
last_timestamp: 0.0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -557,7 +609,7 @@ impl FrameRecorder {
|
||||||
rgba_data: &[u8],
|
rgba_data: &[u8],
|
||||||
width: u32,
|
width: u32,
|
||||||
height: u32,
|
height: u32,
|
||||||
_global_frame: u64,
|
timestamp: f64,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let mut src_frame =
|
let mut src_frame =
|
||||||
ffmpeg::frame::Video::new(ffmpeg::format::Pixel::RGBA, width, height);
|
ffmpeg::frame::Video::new(ffmpeg::format::Pixel::RGBA, width, height);
|
||||||
|
|
@ -576,8 +628,15 @@ impl FrameRecorder {
|
||||||
.run(&src_frame, &mut dst_frame)
|
.run(&src_frame, &mut dst_frame)
|
||||||
.map_err(|e| format!("Scale: {e}"))?;
|
.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.frame_count += 1;
|
||||||
|
self.last_timestamp = timestamp;
|
||||||
|
|
||||||
self.encoder
|
self.encoder
|
||||||
.send_frame(&dst_frame)
|
.send_frame(&dst_frame)
|
||||||
|
|
@ -616,7 +675,14 @@ impl FrameRecorder {
|
||||||
.write_trailer()
|
.write_trailer()
|
||||||
.map_err(|e| format!("Write trailer: {e}"))?;
|
.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 {
|
Ok(RecordingResult {
|
||||||
file_path: self.path,
|
file_path: self.path,
|
||||||
duration,
|
duration,
|
||||||
|
|
|
||||||
|
|
@ -771,8 +771,6 @@ struct EditorApp {
|
||||||
webcam_frame: Option<lightningbeam_core::webcam::CaptureFrame>,
|
webcam_frame: Option<lightningbeam_core::webcam::CaptureFrame>,
|
||||||
/// Pending webcam recording command (set by timeline, processed in update)
|
/// Pending webcam recording command (set by timeline, processed in update)
|
||||||
webcam_record_command: Option<panes::WebcamRecordCommand>,
|
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)
|
// Track ID mapping (Document layer UUIDs <-> daw-backend TrackIds)
|
||||||
layer_to_track_map: HashMap<Uuid, daw_backend::TrackId>,
|
layer_to_track_map: HashMap<Uuid, daw_backend::TrackId>,
|
||||||
track_to_layer_map: HashMap<daw_backend::TrackId, Uuid>,
|
track_to_layer_map: HashMap<daw_backend::TrackId, Uuid>,
|
||||||
|
|
@ -793,7 +791,7 @@ struct EditorApp {
|
||||||
is_recording: bool, // Whether recording is currently active
|
is_recording: bool, // Whether recording is currently active
|
||||||
recording_clips: HashMap<Uuid, u32>, // layer_id -> backend clip_id during recording
|
recording_clips: HashMap<Uuid, u32>, // layer_id -> backend clip_id during recording
|
||||||
recording_start_time: f64, // Playback time when recording started
|
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
|
// Asset drag-and-drop state
|
||||||
dragging_asset: Option<panes::DraggingAsset>, // Asset being dragged from Asset Library
|
dragging_asset: Option<panes::DraggingAsset>, // Asset being dragged from Asset Library
|
||||||
// Clipboard
|
// Clipboard
|
||||||
|
|
@ -1032,7 +1030,6 @@ impl EditorApp {
|
||||||
webcam: None,
|
webcam: None,
|
||||||
webcam_frame: None,
|
webcam_frame: None,
|
||||||
webcam_record_command: None,
|
webcam_record_command: None,
|
||||||
webcam_recording_layer_id: None,
|
|
||||||
layer_to_track_map: HashMap::new(),
|
layer_to_track_map: HashMap::new(),
|
||||||
track_to_layer_map: HashMap::new(),
|
track_to_layer_map: HashMap::new(),
|
||||||
clip_to_metatrack_map: HashMap::new(),
|
clip_to_metatrack_map: HashMap::new(),
|
||||||
|
|
@ -1045,7 +1042,7 @@ impl EditorApp {
|
||||||
is_recording: false, // Not recording initially
|
is_recording: false, // Not recording initially
|
||||||
recording_clips: HashMap::new(), // No active recording clips
|
recording_clips: HashMap::new(), // No active recording clips
|
||||||
recording_start_time: 0.0, // Will be set when recording starts
|
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
|
dragging_asset: None, // No asset being dragged initially
|
||||||
clipboard_manager: lightningbeam_core::clipboard::ClipboardManager::new(),
|
clipboard_manager: lightningbeam_core::clipboard::ClipboardManager::new(),
|
||||||
effect_to_load: None,
|
effect_to_load: None,
|
||||||
|
|
@ -4044,12 +4041,8 @@ impl eframe::App for EditorApp {
|
||||||
|
|
||||||
// Webcam management: open/close based on camera_enabled layers, poll frames
|
// Webcam management: open/close based on camera_enabled layers, poll frames
|
||||||
{
|
{
|
||||||
let any_camera_enabled = self.action_executor.document().root.children.iter().any(|layer| {
|
let any_camera_enabled = self.action_executor.document().all_layers().iter().any(|layer| {
|
||||||
if let lightningbeam_core::layer::AnyLayer::Video(v) = layer {
|
matches!(layer, lightningbeam_core::layer::AnyLayer::Video(v) if v.camera_enabled)
|
||||||
v.camera_enabled
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if any_camera_enabled && self.webcam.is_none() {
|
if any_camera_enabled && self.webcam.is_none() {
|
||||||
|
|
@ -4333,8 +4326,9 @@ impl eframe::App for EditorApp {
|
||||||
AudioEvent::RecordingStarted(track_id, backend_clip_id, rec_sample_rate, rec_channels) => {
|
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);
|
println!("🎤 Recording started on track {:?}, backend_clip_id={}", track_id, backend_clip_id);
|
||||||
|
|
||||||
// Create clip in document and add instance to layer
|
// Create clip in document and add instance to the layer for this track
|
||||||
if let Some(layer_id) = self.recording_layer_id {
|
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};
|
use lightningbeam_core::clip::{AudioClip, ClipInstance};
|
||||||
|
|
||||||
// Create a recording-in-progress clip (no pool index yet)
|
// Create a recording-in-progress clip (no pool index yet)
|
||||||
|
|
@ -4356,17 +4350,22 @@ impl eframe::App for EditorApp {
|
||||||
// Store mapping for later updates
|
// Store mapping for later updates
|
||||||
self.recording_clips.insert(layer_id, backend_clip_id);
|
self.recording_clips.insert(layer_id, backend_clip_id);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize live waveform cache for recording
|
// Initialize live waveform cache for recording
|
||||||
self.raw_audio_cache.insert(usize::MAX, (Arc::new(Vec::new()), rec_sample_rate, rec_channels));
|
self.raw_audio_cache.insert(usize::MAX, (Arc::new(Vec::new()), rec_sample_rate, rec_channels));
|
||||||
|
|
||||||
ctx.request_repaint();
|
ctx.request_repaint();
|
||||||
}
|
}
|
||||||
AudioEvent::RecordingProgress(_clip_id, duration) => {
|
AudioEvent::RecordingProgress(_backend_clip_id, duration) => {
|
||||||
// Update clip duration as recording progresses
|
// Update clip duration as recording progresses
|
||||||
if let Some(layer_id) = self.recording_layer_id {
|
// Find which layer this backend clip belongs to via recording_clips
|
||||||
// First, find the clip_id from the layer (read-only borrow)
|
let layer_id = self.recording_clips.iter()
|
||||||
let clip_id = {
|
.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();
|
let document = self.action_executor.document();
|
||||||
document.get_layer(&layer_id)
|
document.get_layer(&layer_id)
|
||||||
.and_then(|layer| {
|
.and_then(|layer| {
|
||||||
|
|
@ -4379,8 +4378,8 @@ impl eframe::App for EditorApp {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Then update the clip duration (mutable borrow)
|
// Then update the clip duration (mutable borrow)
|
||||||
if let Some(clip_id) = clip_id {
|
if let Some(doc_clip_id) = doc_clip_id {
|
||||||
if let Some(clip) = self.action_executor.document_mut().audio_clips.get_mut(&clip_id) {
|
if let Some(clip) = self.action_executor.document_mut().audio_clips.get_mut(&doc_clip_id) {
|
||||||
if clip.is_recording() {
|
if clip.is_recording() {
|
||||||
clip.duration = duration;
|
clip.duration = duration;
|
||||||
}
|
}
|
||||||
|
|
@ -4390,7 +4389,7 @@ impl eframe::App for EditorApp {
|
||||||
ctx.request_repaint();
|
ctx.request_repaint();
|
||||||
}
|
}
|
||||||
AudioEvent::RecordingStopped(_backend_clip_id, pool_index, _waveform) => {
|
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
|
// Clean up live recording waveform cache
|
||||||
self.raw_audio_cache.remove(&usize::MAX);
|
self.raw_audio_cache.remove(&usize::MAX);
|
||||||
|
|
@ -4414,7 +4413,7 @@ impl eframe::App for EditorApp {
|
||||||
let mut controller = controller_arc.lock().unwrap();
|
let mut controller = controller_arc.lock().unwrap();
|
||||||
match controller.get_pool_file_info(pool_index) {
|
match controller.get_pool_file_info(pool_index) {
|
||||||
Ok((dur, _, _)) => {
|
Ok((dur, _, _)) => {
|
||||||
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);
|
self.audio_duration_cache.insert(pool_index, dur);
|
||||||
dur
|
dur
|
||||||
}
|
}
|
||||||
|
|
@ -4429,7 +4428,11 @@ impl eframe::App for EditorApp {
|
||||||
|
|
||||||
// Finalize the recording clip with real pool_index and duration
|
// Finalize the recording clip with real pool_index and duration
|
||||||
// and sync to backend for playback
|
// 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
|
// First, find the clip instance and clip id
|
||||||
let (clip_id, instance_id, timeline_start, trim_start) = {
|
let (clip_id, instance_id, timeline_start, trim_start) = {
|
||||||
let document = self.action_executor.document();
|
let document = self.action_executor.document();
|
||||||
|
|
@ -4451,7 +4454,7 @@ impl eframe::App for EditorApp {
|
||||||
if let Some(clip) = self.action_executor.document_mut().audio_clips.get_mut(&clip_id) {
|
if let Some(clip) = self.action_executor.document_mut().audio_clips.get_mut(&clip_id) {
|
||||||
if clip.finalize_recording(pool_index, duration) {
|
if clip.finalize_recording(pool_index, duration) {
|
||||||
clip.name = format!("Recording {}", pool_index);
|
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 +4496,32 @@ impl eframe::App for EditorApp {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear recording state
|
// 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.is_recording = false;
|
||||||
self.recording_clips.clear();
|
self.recording_clips.clear();
|
||||||
self.recording_layer_id = None;
|
}
|
||||||
ctx.request_repaint();
|
ctx.request_repaint();
|
||||||
}
|
}
|
||||||
AudioEvent::RecordingError(message) => {
|
AudioEvent::RecordingError(message) => {
|
||||||
eprintln!("❌ Recording error: {}", message);
|
eprintln!("❌ Recording error: {}", message);
|
||||||
self.is_recording = false;
|
self.is_recording = false;
|
||||||
self.recording_clips.clear();
|
self.recording_clips.clear();
|
||||||
self.recording_layer_id = None;
|
self.recording_layer_ids.clear();
|
||||||
ctx.request_repaint();
|
ctx.request_repaint();
|
||||||
}
|
}
|
||||||
AudioEvent::MidiRecordingProgress(_track_id, clip_id, duration, notes) => {
|
AudioEvent::MidiRecordingProgress(_track_id, clip_id, duration, notes) => {
|
||||||
// Update clip duration in document (so timeline bar grows)
|
// 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 doc_clip_id = {
|
||||||
let document = self.action_executor.document();
|
let document = self.action_executor.document();
|
||||||
document.get_layer(&layer_id)
|
document.get_layer(&layer_id)
|
||||||
|
|
@ -4567,7 +4580,10 @@ impl eframe::App for EditorApp {
|
||||||
self.midi_event_cache.insert(clip_id, cache_events);
|
self.midi_event_cache.insert(clip_id, cache_events);
|
||||||
|
|
||||||
// Update document clip with final duration and name
|
// 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 doc_clip_id = {
|
||||||
let document = self.action_executor.document();
|
let document = self.action_executor.document();
|
||||||
document.get_layer(&layer_id)
|
document.get_layer(&layer_id)
|
||||||
|
|
@ -4601,10 +4617,15 @@ impl eframe::App for EditorApp {
|
||||||
// The backend created the instance in create_midi_clip(), but doesn't
|
// The backend created the instance in create_midi_clip(), but doesn't
|
||||||
// report the instance_id back. Needed for move/trim operations later.
|
// report the instance_id back. Needed for move/trim operations later.
|
||||||
|
|
||||||
// Clear recording state
|
// 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.is_recording = false;
|
||||||
self.recording_clips.clear();
|
self.recording_clips.clear();
|
||||||
self.recording_layer_id = None;
|
}
|
||||||
ctx.request_repaint();
|
ctx.request_repaint();
|
||||||
}
|
}
|
||||||
AudioEvent::AudioFileReady { pool_index, path, channels, sample_rate, duration, format } => {
|
AudioEvent::AudioFileReady { pool_index, path, channels, sample_rate, duration, format } => {
|
||||||
|
|
@ -5031,7 +5052,7 @@ impl eframe::App for EditorApp {
|
||||||
is_recording: &mut self.is_recording,
|
is_recording: &mut self.is_recording,
|
||||||
recording_clips: &mut self.recording_clips,
|
recording_clips: &mut self.recording_clips,
|
||||||
recording_start_time: &mut self.recording_start_time,
|
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,
|
dragging_asset: &mut self.dragging_asset,
|
||||||
stroke_width: &mut self.stroke_width,
|
stroke_width: &mut self.stroke_width,
|
||||||
fill_enabled: &mut self.fill_enabled,
|
fill_enabled: &mut self.fill_enabled,
|
||||||
|
|
@ -5157,7 +5178,7 @@ impl eframe::App for EditorApp {
|
||||||
// Process webcam recording commands from timeline
|
// Process webcam recording commands from timeline
|
||||||
if let Some(cmd) = self.webcam_record_command.take() {
|
if let Some(cmd) = self.webcam_record_command.take() {
|
||||||
match cmd {
|
match cmd {
|
||||||
panes::WebcamRecordCommand::Start { layer_id } => {
|
panes::WebcamRecordCommand::Start { .. } => {
|
||||||
// Ensure webcam is open
|
// Ensure webcam is open
|
||||||
if self.webcam.is_none() {
|
if self.webcam.is_none() {
|
||||||
if let Some(device) = lightningbeam_core::webcam::default_camera() {
|
if let Some(device) = lightningbeam_core::webcam::default_camera() {
|
||||||
|
|
@ -5191,7 +5212,6 @@ impl eframe::App for EditorApp {
|
||||||
let recording_path = recording_dir.join(format!("webcam_recording_{}.{}", timestamp, ext));
|
let recording_path = recording_dir.join(format!("webcam_recording_{}.{}", timestamp, ext));
|
||||||
match webcam.start_recording(recording_path, codec) {
|
match webcam.start_recording(recording_path, codec) {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
self.webcam_recording_layer_id = Some(layer_id);
|
|
||||||
eprintln!("[WEBCAM] Recording started");
|
eprintln!("[WEBCAM] Recording started");
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|
@ -5201,13 +5221,25 @@ impl eframe::App for EditorApp {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
panes::WebcamRecordCommand::Stop => {
|
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 {
|
if let Some(webcam) = &mut self.webcam {
|
||||||
|
let stop_t = std::time::Instant::now();
|
||||||
match webcam.stop_recording() {
|
match webcam.stop_recording() {
|
||||||
Ok(result) => {
|
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();
|
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
|
// 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) {
|
match lightningbeam_core::video::probe_video(&file_path_str) {
|
||||||
Ok(info) => {
|
Ok(info) => {
|
||||||
use lightningbeam_core::clip::{VideoClip, ClipInstance};
|
use lightningbeam_core::clip::{VideoClip, ClipInstance};
|
||||||
|
|
@ -5285,7 +5317,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) => {
|
Err(e) => {
|
||||||
eprintln!("[WEBCAM] Failed to probe recorded video: {}", e);
|
eprintln!("[WEBCAM] Failed to probe recorded video: {}", e);
|
||||||
|
|
@ -5295,12 +5330,18 @@ impl eframe::App for EditorApp {
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("[WEBCAM] Failed to stop recording: {}", e);
|
eprintln!("[WEBCAM] Failed to stop recording: {}", e);
|
||||||
self.webcam_recording_layer_id = None;
|
// webcam layer cleanup handled by recording_layer_ids.clear() below
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 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.is_recording = false;
|
||||||
self.recording_layer_id = None;
|
self.recording_clips.clear();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -57,8 +57,10 @@ pub struct DraggingAsset {
|
||||||
|
|
||||||
/// Command for webcam recording (issued by timeline, processed by main)
|
/// Command for webcam recording (issued by timeline, processed by main)
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
|
#[allow(dead_code)]
|
||||||
pub enum WebcamRecordCommand {
|
pub enum WebcamRecordCommand {
|
||||||
/// Start recording on the given video layer
|
/// 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 },
|
Start { layer_id: uuid::Uuid },
|
||||||
/// Stop current webcam recording
|
/// Stop current webcam recording
|
||||||
Stop,
|
Stop,
|
||||||
|
|
@ -198,7 +200,7 @@ pub struct SharedPaneState<'a> {
|
||||||
pub is_recording: &'a mut bool, // Whether recording is currently active
|
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_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_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)
|
/// Asset being dragged from Asset Library (for cross-pane drag-and-drop)
|
||||||
pub dragging_asset: &'a mut Option<DraggingAsset>,
|
pub dragging_asset: &'a mut Option<DraggingAsset>,
|
||||||
// Tool-specific options for infopanel
|
// Tool-specific options for infopanel
|
||||||
|
|
|
||||||
|
|
@ -138,13 +138,6 @@ enum TimeDisplayFormat {
|
||||||
Measures,
|
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.
|
/// State for an in-progress layer header drag-to-reorder operation.
|
||||||
struct LayerDragState {
|
struct LayerDragState {
|
||||||
/// IDs of the layers being dragged (in visual order, top to bottom)
|
/// 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) {
|
fn start_recording(&mut self, shared: &mut SharedPaneState) {
|
||||||
use lightningbeam_core::clip::{AudioClip, ClipInstance};
|
use lightningbeam_core::clip::{AudioClip, ClipInstance};
|
||||||
|
|
||||||
let Some(active_layer_id) = *shared.active_layer_id else {
|
// 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");
|
println!("⚠️ No active layer selected for recording");
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check if this is a video layer with camera enabled
|
// Step 2: Resolve layers, recursing into groups to collect recordable leaves.
|
||||||
let is_video_camera = {
|
// 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 document = shared.action_executor.document();
|
||||||
let context_layers = document.context_layers(shared.editing_clip_id.as_ref());
|
let context_layers = document.context_layers(shared.editing_clip_id.as_ref());
|
||||||
context_layers.iter().copied()
|
let rows = build_timeline_rows(&context_layers);
|
||||||
.find(|l| l.id() == active_layer_id)
|
|
||||||
.map(|layer| {
|
// Helper: collect recordable leaf layer IDs from a layer (recurse into groups)
|
||||||
if let AnyLayer::Video(v) = layer {
|
fn collect_recordable_leaves(layer: &AnyLayer, out: &mut Vec<uuid::Uuid>) {
|
||||||
v.camera_enabled
|
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 {
|
} else {
|
||||||
false
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
|
||||||
.unwrap_or(false)
|
|
||||||
};
|
|
||||||
|
|
||||||
if is_video_camera {
|
// Deduplicate
|
||||||
// Issue webcam recording start command (processed by main.rs)
|
leaf_ids.sort();
|
||||||
*shared.webcam_record_command = Some(super::WebcamRecordCommand::Start {
|
leaf_ids.dedup();
|
||||||
layer_id: active_layer_id,
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
RecordCandidate::AudioMidi => {
|
||||||
|
if seen_midi { return false; }
|
||||||
|
seen_midi = true;
|
||||||
|
}
|
||||||
|
RecordCandidate::VideoCamera => {
|
||||||
|
if seen_webcam { return false; }
|
||||||
|
seen_webcam = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
});
|
});
|
||||||
*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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
println!("📹 Started webcam recording on layer {}", active_layer_id);
|
|
||||||
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;
|
let start_time = *shared.playback_time;
|
||||||
|
shared.recording_layer_ids.clear();
|
||||||
|
|
||||||
// Start recording based on layer type
|
// 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 {
|
if let Some(controller_arc) = shared.audio_controller {
|
||||||
let mut controller = controller_arc.lock().unwrap();
|
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);
|
let clip_id = controller.create_midi_clip(track_id, start_time, 0.0);
|
||||||
controller.start_midi_recording(track_id, clip_id, start_time);
|
controller.start_midi_recording(track_id, clip_id, start_time);
|
||||||
shared.recording_clips.insert(active_layer_id, clip_id);
|
shared.recording_clips.insert(layer_id, clip_id);
|
||||||
println!("🎹 Started MIDI recording on track {:?} at {:.2}s, clip_id={}",
|
println!("🎹 Started MIDI recording on track {:?} at {:.2}s, clip_id={}",
|
||||||
track_id, start_time, clip_id);
|
track_id, start_time, clip_id);
|
||||||
|
}
|
||||||
|
|
||||||
// Drop controller lock before document mutation
|
// Create document clip + clip instance immediately
|
||||||
drop(controller);
|
let doc_clip = AudioClip::new_midi("Recording...",
|
||||||
|
*shared.recording_clips.get(&layer_id).unwrap_or(&0), 0.0);
|
||||||
// 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 doc_clip_id = shared.action_executor.document_mut().add_audio_clip(doc_clip);
|
||||||
|
|
||||||
let clip_instance = ClipInstance::new(doc_clip_id)
|
let clip_instance = ClipInstance::new(doc_clip_id)
|
||||||
.with_timeline_start(start_time);
|
.with_timeline_start(start_time);
|
||||||
|
|
||||||
if let Some(layer) = shared.action_executor.document_mut().get_layer_mut(&active_layer_id) {
|
if let Some(layer) = shared.action_executor.document_mut().get_layer_mut(&layer_id) {
|
||||||
if let lightningbeam_core::layer::AnyLayer::Audio(audio_layer) = layer {
|
if let lightningbeam_core::layer::AnyLayer::Audio(audio_layer) = layer {
|
||||||
audio_layer.clip_instances.push(clip_instance);
|
audio_layer.clip_instances.push(clip_instance);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize empty cache entry for this clip
|
// 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.midi_event_cache.insert(clip_id, Vec::new());
|
||||||
}
|
}
|
||||||
AudioLayerType::Sampled => {
|
|
||||||
// For audio recording, backend creates the clip
|
shared.recording_layer_ids.push(layer_id);
|
||||||
controller.start_recording(track_id, start_time);
|
} else {
|
||||||
println!("🎤 Started audio recording on track {:?} at {:.2}s", track_id, start_time);
|
println!("⚠️ No backend track mapped for layer {}", layer_id);
|
||||||
drop(controller);
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-acquire lock for playback start
|
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 !*shared.is_playing {
|
||||||
|
if let Some(controller_arc) = shared.audio_controller {
|
||||||
let mut controller = controller_arc.lock().unwrap();
|
let mut controller = controller_arc.lock().unwrap();
|
||||||
controller.play();
|
controller.play();
|
||||||
*shared.is_playing = true;
|
*shared.is_playing = true;
|
||||||
println!("▶ Auto-started playback for recording");
|
println!("▶ Auto-started playback for recording");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Store recording state
|
|
||||||
*shared.is_recording = true;
|
*shared.is_recording = true;
|
||||||
*shared.recording_start_time = start_time;
|
*shared.recording_start_time = start_time;
|
||||||
*shared.recording_layer_id = Some(active_layer_id);
|
|
||||||
} else {
|
|
||||||
println!("⚠️ No audio controller available");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stop the current recording
|
/// Stop all active recordings
|
||||||
fn stop_recording(&mut self, shared: &mut SharedPaneState) {
|
fn stop_recording(&mut self, shared: &mut SharedPaneState) {
|
||||||
// Determine recording type by checking the layer
|
let stop_wall = std::time::Instant::now();
|
||||||
let recording_type = if let Some(layer_id) = *shared.recording_layer_id {
|
eprintln!("[STOP] stop_recording called at {:?}", stop_wall);
|
||||||
let context_layers = shared.action_executor.document().context_layers(shared.editing_clip_id.as_ref());
|
|
||||||
context_layers.iter().copied()
|
// Determine which recording types are active by checking recording_layer_ids
|
||||||
.find(|l| l.id() == layer_id)
|
let mut has_audio = false;
|
||||||
.map(|layer| {
|
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 {
|
match layer {
|
||||||
lightningbeam_core::layer::AnyLayer::Audio(audio_layer) => {
|
lightningbeam_core::layer::AnyLayer::Audio(a) => {
|
||||||
if matches!(audio_layer.audio_layer_type, lightningbeam_core::layer::AudioLayerType::Midi) {
|
match a.audio_layer_type {
|
||||||
RecordingType::Midi
|
lightningbeam_core::layer::AudioLayerType::Sampled => has_audio = true,
|
||||||
} else {
|
lightningbeam_core::layer::AudioLayerType::Midi => has_midi = true,
|
||||||
RecordingType::Audio
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
lightningbeam_core::layer::AnyLayer::Video(v) if v.camera_enabled => {
|
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 {
|
if has_webcam {
|
||||||
RecordingType::Webcam => {
|
|
||||||
// Issue webcam stop command (processed by main.rs)
|
|
||||||
*shared.webcam_record_command = Some(super::WebcamRecordCommand::Stop);
|
*shared.webcam_record_command = Some(super::WebcamRecordCommand::Stop);
|
||||||
println!("📹 Stopped webcam recording");
|
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 {
|
if let Some(controller_arc) = shared.audio_controller {
|
||||||
let mut controller = controller_arc.lock().unwrap();
|
let mut controller = controller_arc.lock().unwrap();
|
||||||
|
if has_midi {
|
||||||
if matches!(recording_type, RecordingType::Midi) {
|
|
||||||
controller.stop_midi_recording();
|
controller.stop_midi_recording();
|
||||||
println!("🎹 Stopped MIDI recording");
|
eprintln!("[STOP] MIDI stop command sent at +{:.1}ms", stop_wall.elapsed().as_secs_f64() * 1000.0);
|
||||||
} else {
|
}
|
||||||
|
if has_audio {
|
||||||
controller.stop_recording();
|
controller.stop_recording();
|
||||||
println!("🎤 Stopped audio recording");
|
eprintln!("[STOP] Audio stop command sent at +{:.1}ms", stop_wall.elapsed().as_secs_f64() * 1000.0);
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: Don't clear recording_layer_id here!
|
// Note: Don't clear recording_layer_ids here!
|
||||||
// The RecordingStopped/MidiRecordingStopped event handler in main.rs
|
// The RecordingStopped/MidiRecordingStopped event handlers in main.rs
|
||||||
// needs it to finalize the clip. It will clear the state after processing.
|
// need them to finalize clips. They will clear the state after processing.
|
||||||
// Only clear is_recording to update UI state immediately.
|
// Only clear is_recording to update UI state immediately.
|
||||||
*shared.is_recording = false;
|
*shared.is_recording = false;
|
||||||
}
|
}
|
||||||
|
|
@ -3155,8 +3218,21 @@ impl TimelinePane {
|
||||||
if clicked_layer_index < header_rows.len() {
|
if clicked_layer_index < header_rows.len() {
|
||||||
let layer_id = header_rows[clicked_layer_index].layer_id();
|
let layer_id = header_rows[clicked_layer_index].layer_id();
|
||||||
let clicked_parent = header_rows[clicked_layer_index].parent_id();
|
let clicked_parent = header_rows[clicked_layer_index].parent_id();
|
||||||
|
let prev_active = *active_layer_id;
|
||||||
*active_layer_id = Some(layer_id);
|
*active_layer_id = Some(layer_id);
|
||||||
if shift_held {
|
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);
|
shift_toggle_layer(focus, layer_id, clicked_parent, &header_rows);
|
||||||
} else {
|
} else {
|
||||||
// Only change selection if the clicked layer isn't already selected
|
// 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() {
|
if clicked_layer_index < empty_click_rows.len() {
|
||||||
let layer_id = empty_click_rows[clicked_layer_index].layer_id();
|
let layer_id = empty_click_rows[clicked_layer_index].layer_id();
|
||||||
let clicked_parent = empty_click_rows[clicked_layer_index].parent_id();
|
let clicked_parent = empty_click_rows[clicked_layer_index].parent_id();
|
||||||
|
let prev_active = *active_layer_id;
|
||||||
*active_layer_id = Some(layer_id);
|
*active_layer_id = Some(layer_id);
|
||||||
if shift_held {
|
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);
|
shift_toggle_layer(focus, layer_id, clicked_parent, &empty_click_rows);
|
||||||
} else {
|
} else {
|
||||||
selection.clear_clip_instances();
|
selection.clear_clip_instances();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue