diff --git a/lightningbeam-ui/lightningbeam-editor/Cargo.toml b/lightningbeam-ui/lightningbeam-editor/Cargo.toml index ead4c8a..4acb188 100644 --- a/lightningbeam-ui/lightningbeam-editor/Cargo.toml +++ b/lightningbeam-ui/lightningbeam-editor/Cargo.toml @@ -51,3 +51,6 @@ directories = "5.0" # Desktop notifications notify-rust = { workspace = true } + +# Debug overlay - memory tracking +memory-stats = "1.1" diff --git a/lightningbeam-ui/lightningbeam-editor/src/debug_overlay.rs b/lightningbeam-ui/lightningbeam-editor/src/debug_overlay.rs new file mode 100644 index 0000000..c840682 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/debug_overlay.rs @@ -0,0 +1,280 @@ +//! F3 Debug Overlay +//! +//! Displays performance metrics and system info similar to Minecraft's F3 screen. +//! Press F3 to toggle visibility. + +use eframe::egui; +use std::collections::VecDeque; +use std::time::{Duration, Instant}; + +const FRAME_HISTORY_SIZE: usize = 60; // Track last 60 frames for FPS stats +const DEVICE_REFRESH_INTERVAL: Duration = Duration::from_secs(2); // Refresh devices every 2 seconds +const MEMORY_REFRESH_INTERVAL: Duration = Duration::from_millis(500); // Refresh memory every 500ms + +/// Statistics displayed in debug overlay +#[derive(Debug, Clone)] +pub struct DebugStats { + pub fps_current: f32, // Current frame FPS (unsmoothed) + pub fps_min: f32, // Minimum FPS over last 60 frames + pub fps_avg: f32, // Average FPS over last 60 frames + pub fps_max: f32, // Maximum FPS over last 60 frames + pub frame_time_ms: f32, // Current frame time in milliseconds + pub memory_physical_mb: usize, + pub memory_virtual_mb: usize, + pub gpu_name: String, + pub gpu_backend: String, + pub gpu_driver: String, + pub midi_devices: Vec, + pub audio_input_devices: Vec, + pub has_pointer: bool, + + // Performance metrics for each section + pub timing_memory_us: u64, + pub timing_gpu_us: u64, + pub timing_midi_us: u64, + pub timing_audio_us: u64, + pub timing_pointer_us: u64, + pub timing_total_us: u64, +} + +/// Collects and aggregates debug statistics +pub struct DebugStatsCollector { + frame_times: VecDeque, + last_frame_time: Option, + cached_audio_devices: Vec, + last_device_refresh: Option, + cached_memory_physical_mb: usize, + cached_memory_virtual_mb: usize, + last_memory_refresh: Option, +} + +impl DebugStatsCollector { + pub fn new() -> Self { + Self { + frame_times: VecDeque::with_capacity(FRAME_HISTORY_SIZE), + last_frame_time: None, + cached_audio_devices: Vec::new(), + last_device_refresh: None, + cached_memory_physical_mb: 0, + cached_memory_virtual_mb: 0, + last_memory_refresh: None, + } + } + + /// Collect current debug statistics + pub fn collect( + &mut self, + ctx: &egui::Context, + gpu_info: &Option, + audio_controller: Option<&std::sync::Arc>>, + ) -> DebugStats { + let collection_start = Instant::now(); + + // Calculate actual frame time based on real elapsed time + let now = Instant::now(); + let frame_duration = if let Some(last_time) = self.last_frame_time { + now.duration_since(last_time) + } else { + Duration::from_secs_f32(1.0 / 60.0) // Default to 60 FPS for first frame + }; + self.last_frame_time = Some(now); + + // Store frame duration in history + self.frame_times.push_back(frame_duration); + if self.frame_times.len() > FRAME_HISTORY_SIZE { + self.frame_times.pop_front(); + } + + // Calculate FPS stats from actual frame durations + let frame_time_ms = frame_duration.as_secs_f32() * 1000.0; + let fps_current = 1.0 / frame_duration.as_secs_f32(); + + let (fps_min, fps_avg, fps_max) = if !self.frame_times.is_empty() { + let fps_values: Vec = self.frame_times + .iter() + .map(|dt| 1.0 / dt.as_secs_f32()) + .collect(); + let min = fps_values.iter().copied().fold(f32::INFINITY, f32::min); + let max = fps_values.iter().copied().fold(f32::NEG_INFINITY, f32::max); + let sum: f32 = fps_values.iter().sum(); + let avg = sum / fps_values.len() as f32; + (min, avg, max) + } else { + (fps_current, fps_current, fps_current) + }; + + // Collect memory stats with timing - cache and refresh every 500ms + let t0 = Instant::now(); + let should_refresh_memory = self.last_memory_refresh + .map(|last| now.duration_since(last) >= MEMORY_REFRESH_INTERVAL) + .unwrap_or(true); + + if should_refresh_memory { + if let Some(usage) = memory_stats::memory_stats() { + self.cached_memory_physical_mb = usage.physical_mem / 1024 / 1024; + self.cached_memory_virtual_mb = usage.virtual_mem / 1024 / 1024; + } + self.last_memory_refresh = Some(now); + } + + let memory_physical_mb = self.cached_memory_physical_mb; + let memory_virtual_mb = self.cached_memory_virtual_mb; + let timing_memory_us = t0.elapsed().as_micros() as u64; + + // Extract GPU info with timing + let t1 = Instant::now(); + let (gpu_name, gpu_backend, gpu_driver) = if let Some(info) = gpu_info { + ( + info.name.clone(), + format!("{:?}", info.backend), + format!("{} ({})", info.driver, info.driver_info), + ) + } else { + ("Unknown".to_string(), "Unknown".to_string(), "Unknown".to_string()) + }; + let timing_gpu_us = t1.elapsed().as_micros() as u64; + + // Collect MIDI devices with timing + let t2 = Instant::now(); + let midi_devices = if let Some(_controller) = audio_controller { + // TODO: Add method to audio controller to get MIDI device names + // For now, return empty vec + vec![] + } else { + vec![] + }; + let timing_midi_us = t2.elapsed().as_micros() as u64; + + // Refresh audio input devices only every 2 seconds to avoid performance issues + let t3 = Instant::now(); + let should_refresh_devices = self.last_device_refresh + .map(|last| now.duration_since(last) >= DEVICE_REFRESH_INTERVAL) + .unwrap_or(true); + + if should_refresh_devices { + self.cached_audio_devices = enumerate_audio_input_devices(); + self.last_device_refresh = Some(now); + } + + let audio_input_devices = self.cached_audio_devices.clone(); + let timing_audio_us = t3.elapsed().as_micros() as u64; + + // Detect pointer usage with timing + let t4 = Instant::now(); + let has_pointer = ctx.input(|i| { + i.pointer.is_decidedly_dragging() + || i.pointer.any_down() + || i.pointer.any_pressed() + }); + let timing_pointer_us = t4.elapsed().as_micros() as u64; + + let timing_total_us = collection_start.elapsed().as_micros() as u64; + + DebugStats { + fps_current, + fps_min, + fps_avg, + fps_max, + frame_time_ms, + memory_physical_mb, + memory_virtual_mb, + gpu_name, + gpu_backend, + gpu_driver, + midi_devices, + audio_input_devices, + has_pointer, + timing_memory_us, + timing_gpu_us, + timing_midi_us, + timing_audio_us, + timing_pointer_us, + timing_total_us, + } + } +} + +/// Enumerate audio input devices using cpal +fn enumerate_audio_input_devices() -> Vec { + use cpal::traits::{HostTrait, DeviceTrait}; + + let host = cpal::default_host(); + host.input_devices() + .ok() + .map(|devices| { + devices + .filter_map(|d| d.name().ok()) + .collect() + }) + .unwrap_or_default() +} + +/// Render the debug overlay in-window using egui::Area +pub fn render_debug_overlay(ctx: &egui::Context, stats: &DebugStats) { + egui::Area::new(egui::Id::new("debug_overlay_area")) + .fixed_pos(egui::pos2(10.0, 10.0)) + .show(ctx, |ui| { + egui::Frame::new() + .fill(egui::Color32::from_black_alpha(200)) + .inner_margin(8.0) + .show(ui, |ui| { + // Use monospace font for alignment + ui.style_mut().override_text_style = Some(egui::TextStyle::Monospace); + + // Performance section + ui.colored_label(egui::Color32::YELLOW, "Performance:"); + ui.label(format!( + "FPS: {:.1} (min: {:.1} / avg: {:.1} / max: {:.1})", + stats.fps_current, stats.fps_min, stats.fps_avg, stats.fps_max + )); + ui.label(format!("Frame time: {:.2} ms", stats.frame_time_ms)); + + ui.add_space(8.0); + + // Memory section with timing + ui.colored_label(egui::Color32::YELLOW, format!("Memory: ({}µs)", stats.timing_memory_us)); + ui.label(format!("Physical: {} MB", stats.memory_physical_mb)); + ui.label(format!("Virtual: {} MB", stats.memory_virtual_mb)); + + ui.add_space(8.0); + + // Graphics section with timing + ui.colored_label(egui::Color32::YELLOW, format!("Graphics: ({}µs)", stats.timing_gpu_us)); + ui.label(format!("GPU: {}", stats.gpu_name)); + ui.label(format!("Backend: {}", stats.gpu_backend)); + ui.label(format!("Driver: {}", stats.gpu_driver)); + + ui.add_space(8.0); + + // Input devices section with timing + ui.colored_label(egui::Color32::YELLOW, format!("Input Devices: ({}µs)", + stats.timing_midi_us + stats.timing_audio_us + stats.timing_pointer_us)); + + if stats.has_pointer { + ui.label(format!("• Mouse/Trackpad ({}µs)", stats.timing_pointer_us)); + } + + if !stats.audio_input_devices.is_empty() { + ui.label(format!("• {} Audio Input(s) ({}µs)", + stats.audio_input_devices.len(), stats.timing_audio_us)); + for device in &stats.audio_input_devices { + ui.label(format!(" - {}", device)); + } + } + + if !stats.midi_devices.is_empty() { + ui.label(format!("• {} MIDI Device(s) ({}µs)", + stats.midi_devices.len(), stats.timing_midi_us)); + for device in &stats.midi_devices { + ui.label(format!(" - {}", device)); + } + } + + ui.add_space(8.0); + ui.separator(); + ui.colored_label(egui::Color32::CYAN, format!("Collection time: {}µs ({:.2}ms)", + stats.timing_total_us, stats.timing_total_us as f32 / 1000.0)); + ui.colored_label(egui::Color32::GRAY, "Press F3 to close"); + }); + }); +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index bad5281..4d65c1d 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -33,6 +33,8 @@ mod notifications; mod effect_thumbnails; use effect_thumbnails::EffectThumbnailGenerator; +mod debug_overlay; + /// Lightningbeam Editor - Animation and video editing software #[derive(Parser, Debug)] #[command(name = "Lightningbeam Editor")] @@ -593,6 +595,11 @@ struct EditorApp { export_orchestrator: Option, /// GPU-rendered effect thumbnail generator effect_thumbnail_generator: Option, + + /// Debug overlay (F3) state + debug_overlay_visible: bool, + debug_stats_collector: debug_overlay::DebugStatsCollector, + gpu_info: Option, } /// Import filter types for the file dialog @@ -680,6 +687,9 @@ impl EditorApp { // Create audio extraction channel for background thread communication let (audio_extraction_tx, audio_extraction_rx) = std::sync::mpsc::channel(); + // Extract GPU info for debug overlay + let gpu_info = cc.wgpu_render_state.as_ref().map(|rs| rs.adapter.get_info()); + Self { layouts, current_layout_index: 0, @@ -743,6 +753,11 @@ impl EditorApp { export_progress_dialog: export::dialog::ExportProgressDialog::default(), export_orchestrator: None, effect_thumbnail_generator: None, // Initialized when GPU available + + // Debug overlay (F3) + debug_overlay_visible: false, + debug_stats_collector: debug_overlay::DebugStatsCollector::new(), + gpu_info, } } @@ -3235,12 +3250,27 @@ impl eframe::App for EditorApp { } }); + // F3 debug overlay toggle (works even when text input is active) + if ctx.input(|i| i.key_pressed(egui::Key::F3)) { + self.debug_overlay_visible = !self.debug_overlay_visible; + } + // Clear the set of audio pools with new waveforms at the end of the frame // (Thumbnails have been invalidated above, so this can be cleared for next frame) if !self.audio_pools_with_new_waveforms.is_empty() { println!("🧹 [UPDATE] Clearing waveform update set: {:?}", self.audio_pools_with_new_waveforms); } self.audio_pools_with_new_waveforms.clear(); + + // Render F3 debug overlay on top of everything + if self.debug_overlay_visible { + let stats = self.debug_stats_collector.collect( + ctx, + &self.gpu_info, + self.audio_controller.as_ref(), + ); + debug_overlay::render_debug_overlay(ctx, &stats); + } } }