diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/set_document_properties.rs b/lightningbeam-ui/lightningbeam-core/src/actions/set_document_properties.rs index f564ef7..48c5e38 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/set_document_properties.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/set_document_properties.rs @@ -5,6 +5,7 @@ use crate::action::Action; use crate::document::Document; +use crate::shape::ShapeColor; /// Individual property change for a document #[derive(Clone, Debug)] @@ -13,18 +14,14 @@ pub enum DocumentPropertyChange { Height(f64), Duration(f64), Framerate(f64), + BackgroundColor(ShapeColor), } -impl DocumentPropertyChange { - /// Extract the f64 value from any variant - fn value(&self) -> f64 { - match self { - DocumentPropertyChange::Width(v) => *v, - DocumentPropertyChange::Height(v) => *v, - DocumentPropertyChange::Duration(v) => *v, - DocumentPropertyChange::Framerate(v) => *v, - } - } +/// Stored old value for undo (either f64 or color) +#[derive(Clone, Debug)] +enum OldValue { + F64(f64), + Color(ShapeColor), } /// Action that sets a property on the document @@ -32,7 +29,7 @@ pub struct SetDocumentPropertiesAction { /// The new property value property: DocumentPropertyChange, /// The old value for undo - old_value: Option, + old_value: Option, } impl SetDocumentPropertiesAction { @@ -68,41 +65,53 @@ impl SetDocumentPropertiesAction { } } - fn get_current_value(&self, document: &Document) -> f64 { - match &self.property { - DocumentPropertyChange::Width(_) => document.width, - DocumentPropertyChange::Height(_) => document.height, - DocumentPropertyChange::Duration(_) => document.duration, - DocumentPropertyChange::Framerate(_) => document.framerate, - } - } - - fn apply_value(&self, document: &mut Document, value: f64) { - match &self.property { - DocumentPropertyChange::Width(_) => document.width = value, - DocumentPropertyChange::Height(_) => document.height = value, - DocumentPropertyChange::Duration(_) => document.duration = value, - DocumentPropertyChange::Framerate(_) => document.framerate = value, + /// Create a new action to set background color + pub fn set_background_color(color: ShapeColor) -> Self { + Self { + property: DocumentPropertyChange::BackgroundColor(color), + old_value: None, } } } impl Action for SetDocumentPropertiesAction { fn execute(&mut self, document: &mut Document) -> Result<(), String> { - // Store old value if not already stored if self.old_value.is_none() { - self.old_value = Some(self.get_current_value(document)); + self.old_value = Some(match &self.property { + DocumentPropertyChange::Width(_) => OldValue::F64(document.width), + DocumentPropertyChange::Height(_) => OldValue::F64(document.height), + DocumentPropertyChange::Duration(_) => OldValue::F64(document.duration), + DocumentPropertyChange::Framerate(_) => OldValue::F64(document.framerate), + DocumentPropertyChange::BackgroundColor(_) => OldValue::Color(document.background_color), + }); } - // Apply new value - let new_value = self.property.value(); - self.apply_value(document, new_value); + match &self.property { + DocumentPropertyChange::Width(v) => document.width = *v, + DocumentPropertyChange::Height(v) => document.height = *v, + DocumentPropertyChange::Duration(v) => document.duration = *v, + DocumentPropertyChange::Framerate(v) => document.framerate = *v, + DocumentPropertyChange::BackgroundColor(c) => document.background_color = *c, + } Ok(()) } fn rollback(&mut self, document: &mut Document) -> Result<(), String> { - if let Some(old_value) = self.old_value { - self.apply_value(document, old_value); + match &self.old_value { + Some(OldValue::F64(v)) => { + let v = *v; + match &self.property { + DocumentPropertyChange::Width(_) => document.width = v, + DocumentPropertyChange::Height(_) => document.height = v, + DocumentPropertyChange::Duration(_) => document.duration = v, + DocumentPropertyChange::Framerate(_) => document.framerate = v, + DocumentPropertyChange::BackgroundColor(_) => {} + } + } + Some(OldValue::Color(c)) => { + document.background_color = *c; + } + None => {} } Ok(()) } @@ -113,6 +122,7 @@ impl Action for SetDocumentPropertiesAction { DocumentPropertyChange::Height(_) => "canvas height", DocumentPropertyChange::Duration(_) => "duration", DocumentPropertyChange::Framerate(_) => "framerate", + DocumentPropertyChange::BackgroundColor(_) => "background color", }; format!("Set {}", property_name) } diff --git a/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs b/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs index ea72cc2..27c15f8 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs @@ -747,6 +747,7 @@ pub fn render_frame_to_rgba_hdr( base_transform, image_cache, video_manager, + None, // No webcam during export ); // Buffer specs for layer rendering @@ -1132,6 +1133,7 @@ pub fn render_frame_to_gpu_rgba( base_transform, image_cache, video_manager, + None, // No webcam during export ); // Buffer specs for layer rendering diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index 8743272..e0ff858 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -760,6 +760,14 @@ struct EditorApp { audio_channels: u32, // Video decoding and management video_manager: std::sync::Arc>, // Shared video manager + // Webcam capture state + webcam: Option, + /// Latest polled webcam frame (updated each frame for preview) + 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, @@ -1013,6 +1021,10 @@ impl EditorApp { video_manager: std::sync::Arc::new(std::sync::Mutex::new( lightningbeam_core::video::VideoManager::new() )), + 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(), @@ -1341,7 +1353,8 @@ impl EditorApp { document.root.add_child(AnyLayer::Vector(layer)) } 1 => { - // Video editing focus -> VideoLayer + // Video editing focus -> VideoLayer + black background + document.background_color = lightningbeam_core::shape::ShapeColor::rgb(0, 0, 0); let layer = VideoLayer::new("Video 1"); document.root.add_child(AnyLayer::Video(layer)) } @@ -3893,6 +3906,44 @@ impl eframe::App for EditorApp { self.handle_audio_extraction_result(result); } + // Webcam management: open/close based on camera_enabled layers, poll frames + { + let any_camera_enabled = self.action_executor.document().root.children.iter().any(|layer| { + if let lightningbeam_core::layer::AnyLayer::Video(v) = layer { + v.camera_enabled + } else { + false + } + }); + + if any_camera_enabled && self.webcam.is_none() { + // Try to open the default camera + if let Some(device) = lightningbeam_core::webcam::default_camera() { + match lightningbeam_core::webcam::WebcamCapture::open(&device) { + Ok(cam) => { + eprintln!("[WEBCAM] Opened camera: {}", device.name); + self.webcam = Some(cam); + } + Err(e) => { + eprintln!("[WEBCAM] Failed to open camera: {}", e); + } + } + } + } else if !any_camera_enabled && self.webcam.is_some() { + eprintln!("[WEBCAM] Closing camera (no layers with camera enabled)"); + self.webcam = None; + self.webcam_frame = None; + } + + // Poll latest frame from webcam + if let Some(webcam) = &mut self.webcam { + if let Some(frame) = webcam.poll_frame() { + self.webcam_frame = Some(frame.clone()); + ctx.request_repaint(); // Keep repainting while camera is active + } + } + } + // Check for native menu events (macOS) if let Some(menu_system) = &self.menu_system { if let Some(action) = menu_system.check_events() { @@ -4861,6 +4912,8 @@ impl eframe::App for EditorApp { .map(|g| g.thumbnail_cache()) .unwrap_or(&empty_thumbnail_cache), effect_thumbnails_to_invalidate: &mut self.effect_thumbnails_to_invalidate, + webcam_frame: self.webcam_frame.clone(), + webcam_record_command: &mut self.webcam_record_command, target_format: self.target_format, pending_menu_actions: &mut pending_menu_actions, clipboard_manager: &mut self.clipboard_manager, @@ -4960,6 +5013,157 @@ impl eframe::App for EditorApp { self.handle_menu_action(action); } + // Process webcam recording commands from timeline + if let Some(cmd) = self.webcam_record_command.take() { + match cmd { + panes::WebcamRecordCommand::Start { layer_id } => { + // Ensure webcam is open + if self.webcam.is_none() { + if let Some(device) = lightningbeam_core::webcam::default_camera() { + match lightningbeam_core::webcam::WebcamCapture::open(&device) { + Ok(cam) => { + eprintln!("[WEBCAM] Opened camera for recording: {}", device.name); + self.webcam = Some(cam); + } + Err(e) => { + eprintln!("[WEBCAM] Failed to open camera for recording: {}", e); + } + } + } + } + if let Some(webcam) = &mut self.webcam { + // Generate output path in project directory or temp + let recording_dir = if let Some(ref file_path) = self.current_file_path { + file_path.parent().unwrap_or(std::path::Path::new(".")).to_path_buf() + } else { + std::env::temp_dir() + }; + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let codec = lightningbeam_core::webcam::RecordingCodec::H264; // TODO: read from preferences + let ext = match codec { + lightningbeam_core::webcam::RecordingCodec::H264 => "mp4", + lightningbeam_core::webcam::RecordingCodec::Lossless => "mkv", + }; + 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) => { + eprintln!("[WEBCAM] Failed to start recording: {}", e); + } + } + } + } + panes::WebcamRecordCommand::Stop => { + if let Some(webcam) = &mut self.webcam { + match webcam.stop_recording() { + Ok(result) => { + let file_path_str = result.file_path.to_string_lossy().to_string(); + eprintln!("[WEBCAM] Recording saved to: {}", file_path_str); + // Create VideoClip + ClipInstance from recorded file + if let Some(layer_id) = self.webcam_recording_layer_id.take() { + match lightningbeam_core::video::probe_video(&file_path_str) { + Ok(info) => { + use lightningbeam_core::clip::{VideoClip, ClipInstance}; + let clip = VideoClip { + id: Uuid::new_v4(), + name: result.file_path.file_name() + .and_then(|n| n.to_str()) + .unwrap_or("Webcam Recording") + .to_string(), + file_path: file_path_str.clone(), + width: info.width as f64, + height: info.height as f64, + duration: info.duration, + frame_rate: info.fps, + linked_audio_clip_id: None, + folder_id: None, + }; + let clip_id = clip.id; + let duration = clip.duration; + self.action_executor.document_mut().video_clips.insert(clip_id, clip); + + let mut clip_instance = ClipInstance::new(clip_id) + .with_timeline_start(self.recording_start_time) + .with_timeline_duration(duration); + + // Scale to fit document and center (like drag-dropped videos) + { + let doc = self.action_executor.document(); + let video_width = info.width as f64; + let video_height = info.height as f64; + let scale_x = doc.width / video_width; + let scale_y = doc.height / video_height; + let uniform_scale = scale_x.min(scale_y); + clip_instance.transform.scale_x = uniform_scale; + clip_instance.transform.scale_y = uniform_scale; + let scaled_w = video_width * uniform_scale; + let scaled_h = video_height * uniform_scale; + clip_instance.transform.x = (doc.width - scaled_w) / 2.0; + clip_instance.transform.y = (doc.height - scaled_h) / 2.0; + } + + if let Some(layer) = self.action_executor.document_mut().get_layer_mut(&layer_id) { + if let lightningbeam_core::layer::AnyLayer::Video(video_layer) = layer { + video_layer.clip_instances.push(clip_instance); + } + } + + // Load into video manager for playback + // Use the video's native dimensions so decoded frames + // match the VideoClip width/height the renderer uses + // for the display rect. + { + let mut vm = self.video_manager.lock().unwrap(); + if let Err(e) = vm.load_video(clip_id, file_path_str, info.width, info.height) { + eprintln!("[WEBCAM] Failed to load recorded video: {}", e); + } + } + + // Generate thumbnails in background + let vm_clone = Arc::clone(&self.video_manager); + std::thread::spawn(move || { + // Build keyframe index first + { + let vm = vm_clone.lock().unwrap(); + if let Err(e) = vm.build_keyframe_index(&clip_id) { + eprintln!("[WEBCAM] Failed to build keyframe index: {e}"); + } + } + // Generate thumbnails + { + let mut vm = vm_clone.lock().unwrap(); + if let Err(e) = vm.generate_thumbnails(&clip_id, duration) { + eprintln!("[WEBCAM] Failed to generate thumbnails: {e}"); + } + } + }); + + eprintln!("[WEBCAM] Created video clip: {:.1}s @ {:.1}fps", duration, info.fps); + } + Err(e) => { + eprintln!("[WEBCAM] Failed to probe recorded video: {}", e); + } + } + } + } + Err(e) => { + eprintln!("[WEBCAM] Failed to stop recording: {}", e); + self.webcam_recording_layer_id = None; + } + } + } + self.is_recording = false; + self.recording_layer_id = None; + } + } + } + // Process editing context navigation (enter/exit movie clips) if let Some((clip_id, instance_id, parent_layer_id)) = pending_enter_clip { let entry = EditingContextEntry { @@ -5231,6 +5435,10 @@ struct RenderContext<'a> { effect_thumbnail_cache: &'a HashMap>, /// Effect IDs whose thumbnails should be invalidated effect_thumbnails_to_invalidate: &'a mut Vec, + /// Latest webcam capture frame (None if no camera active) + webcam_frame: Option, + /// Pending webcam recording command + webcam_record_command: &'a mut Option, /// Surface texture format for GPU rendering (Rgba8Unorm or Bgra8Unorm depending on platform) target_format: wgpu::TextureFormat, /// Menu actions queued by panes (e.g. context menus), processed after rendering @@ -5739,6 +5947,8 @@ fn render_pane( effect_thumbnail_requests: ctx.effect_thumbnail_requests, effect_thumbnail_cache: ctx.effect_thumbnail_cache, effect_thumbnails_to_invalidate: ctx.effect_thumbnails_to_invalidate, + webcam_frame: ctx.webcam_frame.clone(), + webcam_record_command: ctx.webcam_record_command, target_format: ctx.target_format, pending_menu_actions: ctx.pending_menu_actions, clipboard_manager: ctx.clipboard_manager, @@ -5827,6 +6037,8 @@ fn render_pane( effect_thumbnail_requests: ctx.effect_thumbnail_requests, effect_thumbnail_cache: ctx.effect_thumbnail_cache, effect_thumbnails_to_invalidate: ctx.effect_thumbnails_to_invalidate, + webcam_frame: ctx.webcam_frame.clone(), + webcam_record_command: ctx.webcam_record_command, target_format: ctx.target_format, pending_menu_actions: ctx.pending_menu_actions, clipboard_manager: ctx.clipboard_manager, diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs index 97031db..fa92383 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs @@ -481,6 +481,19 @@ impl InfopanelPane { } }); + // Background color + ui.horizontal(|ui| { + ui.label("Background:"); + let bg = document.background_color; + let mut color = [bg.r, bg.g, bg.b]; + if ui.color_edit_button_srgb(&mut color).changed() { + let action = SetDocumentPropertiesAction::set_background_color( + ShapeColor::rgb(color[0], color[1], color[2]), + ); + shared.pending_actions.push(Box::new(action)); + } + }); + // Layer count (read-only) ui.horizontal(|ui| { ui.label("Layers:"); diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs index b9cd303..db190d5 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs @@ -55,6 +55,15 @@ pub struct DraggingAsset { pub linked_audio_clip_id: Option, } +/// Command for webcam recording (issued by timeline, processed by main) +#[derive(Debug)] +pub enum WebcamRecordCommand { + /// Start recording on the given video layer + Start { layer_id: uuid::Uuid }, + /// Stop current webcam recording + Stop, +} + pub mod toolbar; pub mod stage; pub mod timeline; @@ -221,6 +230,10 @@ pub struct SharedPaneState<'a> { pub effect_thumbnail_cache: &'a std::collections::HashMap>, /// Effect IDs whose thumbnails should be invalidated (e.g., after shader edit) pub effect_thumbnails_to_invalidate: &'a mut Vec, + /// Latest webcam capture frame (None if no camera is active) + pub webcam_frame: Option, + /// Pending webcam recording commands (processed by main.rs after render) + pub webcam_record_command: &'a mut Option, /// Surface texture format for GPU rendering (Rgba8Unorm or Bgra8Unorm depending on platform) pub target_format: wgpu::TextureFormat, /// Menu actions queued by panes (e.g. context menu items), processed by main after rendering diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs index 0e3d87f..cf85014 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs @@ -388,6 +388,8 @@ struct VelloRenderContext { region_selection: Option, /// Mouse position in document-local (clip-local) world coordinates, for hover hit testing mouse_world_pos: Option, + /// Latest webcam frame for live preview (if any camera is active) + webcam_frame: Option, } /// Callback for Vello rendering within egui @@ -475,6 +477,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback { camera_transform, &mut image_cache, &shared.video_manager, + self.ctx.webcam_frame.as_ref(), ); drop(image_cache); @@ -6961,6 +6964,7 @@ impl PaneRenderer for StagePane { editing_parent_layer_id: shared.editing_parent_layer_id, region_selection: shared.region_selection.clone(), mouse_world_pos, + webcam_frame: shared.webcam_frame.clone(), }}; let cb = egui_wgpu::Callback::new_paint_callback( diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs index 4c420d8..91b31d5 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs @@ -137,6 +137,13 @@ enum TimeDisplayFormat { Measures, } +/// Type of recording in progress (for stop logic dispatch) +enum RecordingType { + Audio, + Midi, + Webcam, +} + pub struct TimelinePane { /// Horizontal zoom level (pixels per second) pixels_per_second: f32, @@ -260,7 +267,7 @@ impl TimelinePane { } /// Toggle recording on/off - /// In Auto mode, records to the active audio layer + /// In Auto mode, records to the active layer (audio or video with camera) fn toggle_recording(&mut self, shared: &mut SharedPaneState) { if *shared.is_recording { // Stop recording @@ -271,7 +278,7 @@ impl TimelinePane { } } - /// Start recording on the active audio layer + /// Start recording on the active layer (audio or video with camera) fn start_recording(&mut self, shared: &mut SharedPaneState) { use lightningbeam_core::clip::{AudioClip, ClipInstance}; @@ -280,6 +287,44 @@ impl TimelinePane { 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"); + } + } + 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(); @@ -362,32 +407,49 @@ impl TimelinePane { /// Stop the current recording fn stop_recording(&mut self, shared: &mut SharedPaneState) { - // Determine if this is MIDI or audio recording by checking the layer type - let is_midi_recording = if let Some(layer_id) = *shared.recording_layer_id { + // 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| { - if let lightningbeam_core::layer::AnyLayer::Audio(audio_layer) = layer { - matches!(audio_layer.audio_layer_type, lightningbeam_core::layer::AudioLayerType::Midi) - } else { - false + 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::Video(v) if v.camera_enabled => { + RecordingType::Webcam + } + _ => RecordingType::Audio, } }) - .unwrap_or(false) + .unwrap_or(RecordingType::Audio) } else { - false + RecordingType::Audio }; - if let Some(controller_arc) = shared.audio_controller { - let mut controller = controller_arc.lock().unwrap(); + 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 is_midi_recording { - controller.stop_midi_recording(); - println!("🎹 Stopped MIDI recording"); - } else { - controller.stop_recording(); - println!("🎤 Stopped audio recording"); + if matches!(recording_type, RecordingType::Midi) { + controller.stop_midi_recording(); + println!("🎹 Stopped MIDI recording"); + } else { + controller.stop_recording(); + println!("🎤 Stopped audio recording"); + } + } } } @@ -957,28 +1019,57 @@ impl TimelinePane { let is_soloed = layer.soloed(); let is_locked = layer.locked(); - // Mute button - // TODO: Replace with SVG icon (volume-up-fill.svg / volume-mute.svg) - let mute_response = ui.scope_builder(egui::UiBuilder::new().max_rect(mute_button_rect), |ui| { - let mute_text = if is_muted { "🔇" } else { "🔊" }; - let button = egui::Button::new(mute_text) - .fill(if is_muted { - egui::Color32::from_rgba_unmultiplied(255, 100, 100, 100) - } else { - egui::Color32::from_gray(40) - }) - .stroke(egui::Stroke::NONE); - ui.add(button) + // Mute button — or camera toggle for video layers + let is_video_layer = matches!(layer, lightningbeam_core::layer::AnyLayer::Video(_)); + let camera_enabled = if let lightningbeam_core::layer::AnyLayer::Video(v) = layer { + v.camera_enabled + } else { + false + }; + + let first_btn_response = ui.scope_builder(egui::UiBuilder::new().max_rect(mute_button_rect), |ui| { + if is_video_layer { + // Camera toggle for video layers + let cam_text = if camera_enabled { "📹" } else { "📷" }; + let button = egui::Button::new(cam_text) + .fill(if camera_enabled { + egui::Color32::from_rgba_unmultiplied(100, 200, 100, 100) + } else { + egui::Color32::from_gray(40) + }) + .stroke(egui::Stroke::NONE); + ui.add(button) + } else { + // Mute button for non-video layers + let mute_text = if is_muted { "🔇" } else { "🔊" }; + let button = egui::Button::new(mute_text) + .fill(if is_muted { + egui::Color32::from_rgba_unmultiplied(255, 100, 100, 100) + } else { + egui::Color32::from_gray(40) + }) + .stroke(egui::Stroke::NONE); + ui.add(button) + } }).inner; - if mute_response.clicked() { + if first_btn_response.clicked() { self.layer_control_clicked = true; - pending_actions.push(Box::new( - lightningbeam_core::actions::SetLayerPropertiesAction::new( - layer_id, - lightningbeam_core::actions::LayerProperty::Muted(!is_muted), - ) - )); + if is_video_layer { + pending_actions.push(Box::new( + lightningbeam_core::actions::SetLayerPropertiesAction::new( + layer_id, + lightningbeam_core::actions::LayerProperty::CameraEnabled(!camera_enabled), + ) + )); + } else { + pending_actions.push(Box::new( + lightningbeam_core::actions::SetLayerPropertiesAction::new( + layer_id, + lightningbeam_core::actions::LayerProperty::Muted(!is_muted), + ) + )); + } } // Solo button