From efca9da2c93be462c54970459fcbc85e5befc456 Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Mon, 8 Dec 2025 13:32:11 -0500 Subject: [PATCH] show previews for effects --- .../lightningbeam-core/Cargo.toml | 2 +- .../src/gpu/color_convert.rs | 1 + .../lightningbeam-core/src/gpu/compositor.rs | 1 + .../src/gpu/effect_processor.rs | 1 + .../lightningbeam-core/src/pane.rs | 7 + .../lightningbeam-core/src/renderer.rs | 42 +- .../lightningbeam-editor/Cargo.toml | 1 + .../src/effect_thumbnails.rs | 323 +++++++++ .../src/export/video_exporter.rs | 5 +- .../lightningbeam-editor/src/main.rs | 149 ++++- .../src/panes/asset_library.rs | 623 ++++++++++++------ .../lightningbeam-editor/src/panes/mod.rs | 17 + .../src/panes/shader_editor.rs | 581 ++++++++++++++++ .../lightningbeam-editor/src/panes/stage.rs | 3 +- .../lightningbeam-editor/src/panes/toolbar.rs | 44 +- src/assets/still-life.jpg | Bin 0 -> 65038 bytes 16 files changed, 1511 insertions(+), 289 deletions(-) create mode 100644 lightningbeam-ui/lightningbeam-editor/src/effect_thumbnails.rs create mode 100644 lightningbeam-ui/lightningbeam-editor/src/panes/shader_editor.rs create mode 100644 src/assets/still-life.jpg diff --git a/lightningbeam-ui/lightningbeam-core/Cargo.toml b/lightningbeam-ui/lightningbeam-core/Cargo.toml index 790311b..8d8a8c0 100644 --- a/lightningbeam-ui/lightningbeam-core/Cargo.toml +++ b/lightningbeam-ui/lightningbeam-core/Cargo.toml @@ -8,7 +8,7 @@ serde = { workspace = true } serde_json = { workspace = true } # UI framework (for Color32 conversion) -egui = "0.31" +egui = { workspace = true } # GPU rendering infrastructure wgpu = { workspace = true } diff --git a/lightningbeam-ui/lightningbeam-core/src/gpu/color_convert.rs b/lightningbeam-ui/lightningbeam-core/src/gpu/color_convert.rs index 42cca3d..7f13e4f 100644 --- a/lightningbeam-ui/lightningbeam-core/src/gpu/color_convert.rs +++ b/lightningbeam-ui/lightningbeam-core/src/gpu/color_convert.rs @@ -147,6 +147,7 @@ impl SrgbToLinearConverter { load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), store: wgpu::StoreOp::Store, }, + depth_slice: None, })], depth_stencil_attachment: None, occlusion_query_set: None, diff --git a/lightningbeam-ui/lightningbeam-core/src/gpu/compositor.rs b/lightningbeam-ui/lightningbeam-core/src/gpu/compositor.rs index be8ab9d..d434101 100644 --- a/lightningbeam-ui/lightningbeam-core/src/gpu/compositor.rs +++ b/lightningbeam-ui/lightningbeam-core/src/gpu/compositor.rs @@ -355,6 +355,7 @@ impl Compositor { load: load_op, store: wgpu::StoreOp::Store, }, + depth_slice: None, })], depth_stencil_attachment: None, occlusion_query_set: None, diff --git a/lightningbeam-ui/lightningbeam-core/src/gpu/effect_processor.rs b/lightningbeam-ui/lightningbeam-core/src/gpu/effect_processor.rs index 3e8ff61..25cbeb3 100644 --- a/lightningbeam-ui/lightningbeam-core/src/gpu/effect_processor.rs +++ b/lightningbeam-ui/lightningbeam-core/src/gpu/effect_processor.rs @@ -293,6 +293,7 @@ impl EffectProcessor { load: wgpu::LoadOp::Load, store: wgpu::StoreOp::Store, }, + depth_slice: None, })], depth_stencil_attachment: None, occlusion_query_set: None, diff --git a/lightningbeam-ui/lightningbeam-core/src/pane.rs b/lightningbeam-ui/lightningbeam-core/src/pane.rs index 8ecf74c..ceb8f87 100644 --- a/lightningbeam-ui/lightningbeam-core/src/pane.rs +++ b/lightningbeam-ui/lightningbeam-core/src/pane.rs @@ -33,6 +33,8 @@ pub enum PaneType { PresetBrowser, /// Asset library for browsing clips AssetLibrary, + /// WGSL shader code editor for custom effects + ShaderEditor, } impl PaneType { @@ -49,6 +51,7 @@ impl PaneType { PaneType::NodeEditor => "Node Editor", PaneType::PresetBrowser => "Preset Browser", PaneType::AssetLibrary => "Asset Library", + PaneType::ShaderEditor => "Shader Editor", } } @@ -67,6 +70,7 @@ impl PaneType { PaneType::NodeEditor => "node-editor.svg", PaneType::PresetBrowser => "stage.svg", // TODO: needs own icon PaneType::AssetLibrary => "stage.svg", // TODO: needs own icon + PaneType::ShaderEditor => "node-editor.svg", // TODO: needs own icon } } @@ -84,6 +88,7 @@ impl PaneType { "nodeeditor" => Some(PaneType::NodeEditor), "presetbrowser" => Some(PaneType::PresetBrowser), "assetlibrary" => Some(PaneType::AssetLibrary), + "shadereditor" => Some(PaneType::ShaderEditor), _ => None, } } @@ -101,6 +106,7 @@ impl PaneType { PaneType::VirtualPiano, PaneType::PresetBrowser, PaneType::AssetLibrary, + PaneType::ShaderEditor, ] } @@ -117,6 +123,7 @@ impl PaneType { PaneType::NodeEditor => "nodeEditor", PaneType::PresetBrowser => "presetBrowser", PaneType::AssetLibrary => "assetLibrary", + PaneType::ShaderEditor => "shaderEditor", } } } diff --git a/lightningbeam-ui/lightningbeam-core/src/renderer.rs b/lightningbeam-ui/lightningbeam-core/src/renderer.rs index 74ef493..17015d5 100644 --- a/lightningbeam-ui/lightningbeam-core/src/renderer.rs +++ b/lightningbeam-ui/lightningbeam-core/src/renderer.rs @@ -18,12 +18,12 @@ use std::collections::HashMap; use std::sync::Arc; use uuid::Uuid; use vello::kurbo::Rect; -use vello::peniko::{Blob, Fill, Image, ImageFormat}; +use vello::peniko::{Blob, Fill, ImageAlphaType, ImageBrush, ImageData, ImageFormat}; use vello::Scene; /// Cache for decoded image data to avoid re-decoding every frame pub struct ImageCache { - cache: HashMap>, + cache: HashMap>, } impl ImageCache { @@ -35,7 +35,7 @@ impl ImageCache { } /// Get or decode an image, caching the result - pub fn get_or_decode(&mut self, asset: &ImageAsset) -> Option> { + pub fn get_or_decode(&mut self, asset: &ImageAsset) -> Option> { if let Some(cached) = self.cache.get(&asset.id) { return Some(Arc::clone(cached)); } @@ -64,8 +64,8 @@ impl Default for ImageCache { } } -/// Decode an image asset to peniko Image -fn decode_image_asset(asset: &ImageAsset) -> Option { +/// Decode an image asset to peniko ImageBrush +fn decode_image_asset(asset: &ImageAsset) -> Option { // Get the raw file data let data = asset.data.as_ref()?; @@ -73,13 +73,15 @@ fn decode_image_asset(asset: &ImageAsset) -> Option { let img = image::load_from_memory(data).ok()?; let rgba = img.to_rgba8(); - // Create peniko Image - Some(Image::new( - Blob::from(rgba.into_raw()), - ImageFormat::Rgba8, - asset.width, - asset.height, - )) + // Create peniko ImageData then ImageBrush + let image_data = ImageData { + data: Blob::from(rgba.into_raw()), + format: ImageFormat::Rgba8, + width: asset.width, + height: asset.height, + alpha_type: ImageAlphaType::Alpha, + }; + Some(ImageBrush::new(image_data)) } // ============================================================================ @@ -720,15 +722,17 @@ fn render_video_layer( // Cascade opacity: layer_opacity × animated opacity let final_opacity = (layer_opacity * opacity) as f32; - // Create peniko Image from video frame data (zero-copy via Arc clone) + // Create peniko ImageBrush from video frame data (zero-copy via Arc clone) // Coerce Arc> to Arc + Send + Sync> let blob_data: Arc + Send + Sync> = frame.rgba_data.clone(); - let image = Image::new( - vello::peniko::Blob::new(blob_data), - vello::peniko::ImageFormat::Rgba8, - frame.width, - frame.height, - ); + let image_data = ImageData { + data: Blob::new(blob_data), + format: ImageFormat::Rgba8, + width: frame.width, + height: frame.height, + alpha_type: ImageAlphaType::Alpha, + }; + let image = ImageBrush::new(image_data); // Apply opacity let image_with_alpha = image.with_alpha(final_opacity); diff --git a/lightningbeam-ui/lightningbeam-editor/Cargo.toml b/lightningbeam-ui/lightningbeam-editor/Cargo.toml index a8770db..671897c 100644 --- a/lightningbeam-ui/lightningbeam-editor/Cargo.toml +++ b/lightningbeam-ui/lightningbeam-editor/Cargo.toml @@ -14,6 +14,7 @@ ffmpeg-next = { version = "8.0", features = ["static"] } eframe = { workspace = true } egui_extras = { workspace = true } egui-wgpu = { workspace = true } +egui_code_editor = { workspace = true } # GPU wgpu = { workspace = true } diff --git a/lightningbeam-ui/lightningbeam-editor/src/effect_thumbnails.rs b/lightningbeam-ui/lightningbeam-editor/src/effect_thumbnails.rs new file mode 100644 index 0000000..7099ca5 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/effect_thumbnails.rs @@ -0,0 +1,323 @@ +//! GPU-rendered effect thumbnails +//! +//! Generates preview thumbnails for effects by applying them to a source image +//! using the actual WGSL shaders. + +use lightningbeam_core::effect::{EffectDefinition, EffectInstance}; +use lightningbeam_core::gpu::effect_processor::{EffectProcessor, EffectUniforms}; +use std::collections::HashMap; +use uuid::Uuid; + +/// Size of effect thumbnails in pixels +pub const EFFECT_THUMBNAIL_SIZE: u32 = 64; + +/// Embedded still-life image for effect preview thumbnails +const EFFECT_PREVIEW_IMAGE_BYTES: &[u8] = include_bytes!("../../../src/assets/still-life.jpg"); + +/// Generator for GPU-rendered effect thumbnails +pub struct EffectThumbnailGenerator { + /// Effect processor for compiling and applying shaders + effect_processor: EffectProcessor, + /// Source texture (still-life image scaled to thumbnail size) + source_texture: wgpu::Texture, + /// View of the source texture + source_view: wgpu::TextureView, + /// Destination texture for rendered effects + dest_texture: wgpu::Texture, + /// View of the destination texture + dest_view: wgpu::TextureView, + /// Buffer for reading back rendered thumbnails + readback_buffer: wgpu::Buffer, + /// Cached rendered thumbnails (effect_id -> RGBA data) + thumbnail_cache: HashMap>, + /// Effects that need thumbnail generation + pending_effects: Vec, +} + +impl EffectThumbnailGenerator { + /// Create a new effect thumbnail generator + pub fn new(device: &wgpu::Device, queue: &wgpu::Queue) -> Self { + // Load and decode the source image + let source_rgba = Self::load_source_image(); + + // Create effect processor (using Rgba8Unorm for thumbnail output) + let effect_processor = EffectProcessor::new(device, wgpu::TextureFormat::Rgba8Unorm); + + // Create source texture + let source_texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("effect_thumbnail_source"), + size: wgpu::Extent3d { + width: EFFECT_THUMBNAIL_SIZE, + height: EFFECT_THUMBNAIL_SIZE, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + + // Upload source image data + queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture: &source_texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + &source_rgba, + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(EFFECT_THUMBNAIL_SIZE * 4), + rows_per_image: Some(EFFECT_THUMBNAIL_SIZE), + }, + wgpu::Extent3d { + width: EFFECT_THUMBNAIL_SIZE, + height: EFFECT_THUMBNAIL_SIZE, + depth_or_array_layers: 1, + }, + ); + + let source_view = source_texture.create_view(&wgpu::TextureViewDescriptor::default()); + + // Create destination texture + let dest_texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("effect_thumbnail_dest"), + size: wgpu::Extent3d { + width: EFFECT_THUMBNAIL_SIZE, + height: EFFECT_THUMBNAIL_SIZE, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + + let dest_view = dest_texture.create_view(&wgpu::TextureViewDescriptor::default()); + + // Create readback buffer + let buffer_size = (EFFECT_THUMBNAIL_SIZE * EFFECT_THUMBNAIL_SIZE * 4) as u64; + // Align to 256 bytes for wgpu requirements + let aligned_bytes_per_row = ((EFFECT_THUMBNAIL_SIZE * 4 + 255) / 256) * 256; + let readback_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("effect_thumbnail_readback"), + size: (aligned_bytes_per_row * EFFECT_THUMBNAIL_SIZE) as u64, + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + + Self { + effect_processor, + source_texture, + source_view, + dest_texture, + dest_view, + readback_buffer, + thumbnail_cache: HashMap::new(), + pending_effects: Vec::new(), + } + } + + /// Load and resize the source image to thumbnail size + fn load_source_image() -> Vec { + // Try to load the embedded image + if let Ok(img) = image::load_from_memory(EFFECT_PREVIEW_IMAGE_BYTES) { + // Resize to thumbnail size + let resized = img.resize_exact( + EFFECT_THUMBNAIL_SIZE, + EFFECT_THUMBNAIL_SIZE, + image::imageops::FilterType::Lanczos3, + ); + return resized.to_rgba8().into_raw(); + } + + // Fallback: generate a gradient image + let size = EFFECT_THUMBNAIL_SIZE as usize; + let mut rgba = vec![0u8; size * size * 4]; + for y in 0..size { + for x in 0..size { + let idx = (y * size + x) * 4; + // Create a colorful gradient + rgba[idx] = (x * 255 / size) as u8; // R: horizontal gradient + rgba[idx + 1] = (y * 255 / size) as u8; // G: vertical gradient + rgba[idx + 2] = 128; // B: constant + rgba[idx + 3] = 255; // A: opaque + } + } + rgba + } + + /// Request thumbnail generation for an effect + pub fn request_thumbnail(&mut self, effect_id: Uuid) { + if !self.thumbnail_cache.contains_key(&effect_id) && !self.pending_effects.contains(&effect_id) { + self.pending_effects.push(effect_id); + } + } + + /// Get a cached thumbnail, or None if not yet generated + pub fn get_thumbnail(&self, effect_id: &Uuid) -> Option<&Vec> { + self.thumbnail_cache.get(effect_id) + } + + /// Check if a thumbnail is cached + pub fn has_thumbnail(&self, effect_id: &Uuid) -> bool { + self.thumbnail_cache.contains_key(effect_id) + } + + /// Invalidate a cached thumbnail (e.g., when effect shader changes) + pub fn invalidate(&mut self, effect_id: &Uuid) { + self.thumbnail_cache.remove(effect_id); + self.effect_processor.remove_effect(effect_id); + } + + /// Generate thumbnails for pending effects (call once per frame) + /// + /// Returns the number of thumbnails generated this frame. + pub fn generate_pending( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + effect_definitions: &HashMap, + max_per_frame: usize, + ) -> usize { + let mut generated = 0; + + while generated < max_per_frame && !self.pending_effects.is_empty() { + let effect_id = self.pending_effects.remove(0); + + // Get effect definition + let Some(definition) = effect_definitions.get(&effect_id) else { + continue; + }; + + // Try to generate thumbnail + if let Some(rgba) = self.render_effect_thumbnail(device, queue, definition) { + self.thumbnail_cache.insert(effect_id, rgba); + generated += 1; + } + } + + generated + } + + /// Render a single effect thumbnail + fn render_effect_thumbnail( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + definition: &EffectDefinition, + ) -> Option> { + // Compile the effect if not already compiled + if !self.effect_processor.compile_effect(device, definition) { + eprintln!("Failed to compile effect shader: {}", definition.name); + return None; + } + + // Create a default effect instance (default parameter values) + let instance = EffectInstance::new(definition, 0.0, 1.0); + + // Create command encoder + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("effect_thumbnail_encoder"), + }); + + // Apply effect + let success = self.effect_processor.apply_effect( + device, + queue, + &mut encoder, + definition, + &instance, + &self.source_view, + &self.dest_view, + EFFECT_THUMBNAIL_SIZE, + EFFECT_THUMBNAIL_SIZE, + 0.0, // time = 0 + ); + + if !success { + eprintln!("Failed to apply effect: {}", definition.name); + return None; + } + + // Copy result to readback buffer + let aligned_bytes_per_row = ((EFFECT_THUMBNAIL_SIZE * 4 + 255) / 256) * 256; + encoder.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture: &self.dest_texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &self.readback_buffer, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(aligned_bytes_per_row), + rows_per_image: Some(EFFECT_THUMBNAIL_SIZE), + }, + }, + wgpu::Extent3d { + width: EFFECT_THUMBNAIL_SIZE, + height: EFFECT_THUMBNAIL_SIZE, + depth_or_array_layers: 1, + }, + ); + + // Submit commands + queue.submit(std::iter::once(encoder.finish())); + + // Map buffer and read data + let buffer_slice = self.readback_buffer.slice(..); + let (tx, rx) = std::sync::mpsc::channel(); + buffer_slice.map_async(wgpu::MapMode::Read, move |result| { + tx.send(result).unwrap(); + }); + + // Wait for GPU + let _ = device.poll(wgpu::PollType::wait_indefinitely()); + + // Check if mapping succeeded + if rx.recv().ok()?.is_err() { + eprintln!("Failed to map readback buffer"); + return None; + } + + // Copy data from mapped buffer (handling row alignment) + let data = buffer_slice.get_mapped_range(); + let mut rgba = Vec::with_capacity((EFFECT_THUMBNAIL_SIZE * EFFECT_THUMBNAIL_SIZE * 4) as usize); + + for row in 0..EFFECT_THUMBNAIL_SIZE { + let row_start = (row * aligned_bytes_per_row) as usize; + let row_end = row_start + (EFFECT_THUMBNAIL_SIZE * 4) as usize; + rgba.extend_from_slice(&data[row_start..row_end]); + } + + drop(data); + self.readback_buffer.unmap(); + + Some(rgba) + } + + /// Get all effect IDs that have pending thumbnail requests + pub fn pending_count(&self) -> usize { + self.pending_effects.len() + } + + /// Get read-only access to the thumbnail cache + pub fn thumbnail_cache(&self) -> &HashMap> { + &self.thumbnail_cache + } + + /// Add multiple thumbnail requests at once + pub fn request_thumbnails(&mut self, effect_ids: &[Uuid]) { + for id in effect_ids { + self.request_thumbnail(*id); + } + } +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs b/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs index 6030a47..cb19df2 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs @@ -621,7 +621,7 @@ pub fn render_frame_to_rgba( sender.send(result).ok(); }); - device.poll(wgpu::Maintain::Wait); + let _ = device.poll(wgpu::PollType::wait_indefinitely()); receiver .recv() @@ -926,6 +926,7 @@ pub fn render_frame_to_rgba_hdr( load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), store: wgpu::StoreOp::Store, }, + depth_slice: None, })], depth_stencil_attachment: None, occlusion_query_set: None, @@ -990,7 +991,7 @@ pub fn render_frame_to_rgba_hdr( sender.send(result).ok(); }); - device.poll(wgpu::Maintain::Wait); + let _ = device.poll(wgpu::PollType::wait_indefinitely()); receiver .recv() diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index 9085aeb..8726624 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -28,6 +28,9 @@ mod default_instrument; mod export; +mod effect_thumbnails; +use effect_thumbnails::EffectThumbnailGenerator; + /// Lightningbeam Editor - Animation and video editing software #[derive(Parser, Debug)] #[command(name = "Lightningbeam Editor")] @@ -535,6 +538,10 @@ struct EditorApp { is_playing: bool, // Whether playback is currently active (transient - don't save) // Asset drag-and-drop state dragging_asset: Option, // Asset being dragged from Asset Library + // Shader editor inter-pane communication + effect_to_load: Option, // Effect ID to load into shader editor (set by asset library) + // Effect thumbnail invalidation queue (persists across frames until processed) + effect_thumbnails_to_invalidate: Vec, // Import dialog state last_import_filter: ImportFilter, // Last used import filter (remembered across imports) // Tool-specific options (displayed in infopanel) @@ -582,6 +589,8 @@ struct EditorApp { export_progress_dialog: export::dialog::ExportProgressDialog, /// Export orchestrator for background exports export_orchestrator: Option, + /// GPU-rendered effect thumbnail generator + effect_thumbnail_generator: Option, } /// Import filter types for the file dialog @@ -709,6 +718,8 @@ impl EditorApp { playback_time: 0.0, // Start at beginning is_playing: false, // Start paused dragging_asset: None, // No asset being dragged initially + effect_to_load: None, // No effect to load initially + effect_thumbnails_to_invalidate: Vec::new(), // No thumbnails to invalidate initially last_import_filter: ImportFilter::default(), // Default to "All Supported" stroke_width: 3.0, // Default stroke width fill_enabled: true, // Default to filling shapes @@ -729,6 +740,7 @@ impl EditorApp { export_dialog: export::dialog::ExportDialog::default(), export_progress_dialog: export::dialog::ExportProgressDialog::default(), export_orchestrator: None, + effect_thumbnail_generator: None, // Initialized when GPU available } } @@ -2413,6 +2425,51 @@ impl eframe::App for EditorApp { self.fetch_waveform(pool_index); } + // Initialize and update effect thumbnail generator (GPU-based effect previews) + if let Some(render_state) = frame.wgpu_render_state() { + let device = &render_state.device; + let queue = &render_state.queue; + + // Initialize on first GPU access + if self.effect_thumbnail_generator.is_none() { + self.effect_thumbnail_generator = Some(EffectThumbnailGenerator::new(device, queue)); + println!("✅ Effect thumbnail generator initialized"); + } + + // Process effect thumbnail invalidations from previous frame + // This happens BEFORE UI rendering so asset library will see empty GPU cache + // We only invalidate GPU cache here - asset library will see the list during render + // and invalidate its own ThumbnailCache + if !self.effect_thumbnails_to_invalidate.is_empty() { + if let Some(generator) = &mut self.effect_thumbnail_generator { + for effect_id in &self.effect_thumbnails_to_invalidate { + generator.invalidate(effect_id); + } + } + // DON'T clear here - asset library still needs to see these during render + } + + // Generate pending effect thumbnails (up to 2 per frame to avoid stalls) + if let Some(generator) = &mut self.effect_thumbnail_generator { + // Combine built-in effects from registry with custom effects from document + let mut all_effects: HashMap = HashMap::new(); + for def in lightningbeam_core::effect_registry::EffectRegistry::get_all() { + all_effects.insert(def.id, def); + } + for (id, def) in &self.action_executor.document().effect_definitions { + all_effects.insert(*id, def.clone()); + } + + let generated = generator.generate_pending(device, queue, &all_effects, 2); + if generated > 0 { + // Request repaint to continue generating remaining thumbnails + if generator.pending_count() > 0 { + ctx.request_repaint(); + } + } + } + } + // Handle file operation progress if let Some(ref mut operation) = self.file_operation { // Set wait cursor @@ -2810,6 +2867,11 @@ impl eframe::App for EditorApp { // Registry for actions to execute after rendering (two-phase dispatch) let mut pending_actions: Vec> = Vec::new(); + // Queue for effect thumbnail requests (collected during rendering) + let mut effect_thumbnail_requests: Vec = Vec::new(); + // Empty cache fallback if generator not initialized + let empty_thumbnail_cache: HashMap> = HashMap::new(); + // Create render context let mut ctx = RenderContext { tool_icon_cache: &mut self.tool_icon_cache, @@ -2846,6 +2908,12 @@ impl eframe::App for EditorApp { waveform_chunk_cache: &self.waveform_chunk_cache, waveform_image_cache: &mut self.waveform_image_cache, audio_pools_with_new_waveforms: &self.audio_pools_with_new_waveforms, + effect_to_load: &mut self.effect_to_load, + effect_thumbnail_requests: &mut effect_thumbnail_requests, + effect_thumbnail_cache: self.effect_thumbnail_generator.as_ref() + .map(|g| g.thumbnail_cache()) + .unwrap_or(&empty_thumbnail_cache), + effect_thumbnails_to_invalidate: &mut self.effect_thumbnails_to_invalidate, }; render_layout_node( @@ -2861,6 +2929,14 @@ impl eframe::App for EditorApp { &mut ctx, ); + // Process collected effect thumbnail requests + if !effect_thumbnail_requests.is_empty() { + if let Some(generator) = &mut self.effect_thumbnail_generator { + generator.request_thumbnails(&effect_thumbnail_requests); + } + } + + // Execute action on the best handler (two-phase dispatch) if let Some(action) = &self.pending_view_action { if let Some(best_handler) = pending_handlers.iter().min_by_key(|h| h.priority) { @@ -3036,6 +3112,14 @@ struct RenderContext<'a> { waveform_image_cache: &'a mut waveform_image_cache::WaveformImageCache, /// Audio pool indices with new waveform data this frame (for thumbnail invalidation) audio_pools_with_new_waveforms: &'a HashSet, + /// Effect ID to load into shader editor (set by asset library, consumed by shader editor) + effect_to_load: &'a mut Option, + /// Queue for effect thumbnail requests + effect_thumbnail_requests: &'a mut Vec, + /// Cache of generated effect thumbnails + effect_thumbnail_cache: &'a HashMap>, + /// Effect IDs whose thumbnails should be invalidated + effect_thumbnails_to_invalidate: &'a mut Vec, } /// Recursively render a layout node with drag support @@ -3400,47 +3484,41 @@ fn render_pane( ); } - // Show pane type selector menu on left click - let menu_id = ui.id().with(("pane_type_menu", path)); - if icon_response.clicked() { - ui.memory_mut(|mem| mem.toggle_popup(menu_id)); - } + // Show pane type selector menu on left click using new Popup API + egui::containers::Popup::menu(&icon_response) + .show(|ui| { + ui.set_min_width(200.0); + ui.label("Select Pane Type:"); + ui.separator(); - egui::popup::popup_below_widget(ui, menu_id, &icon_response, egui::PopupCloseBehavior::CloseOnClickOutside, |ui| { - ui.set_min_width(200.0); - ui.label("Select Pane Type:"); - ui.separator(); + for pane_type_option in PaneType::all() { + // Load icon for this pane type + if let Some(icon) = ctx.icon_cache.get_or_load(*pane_type_option, ui.ctx()) { + ui.horizontal(|ui| { + // Show icon + let icon_texture_id = icon.id(); + let icon_size = egui::vec2(16.0, 16.0); + ui.add(egui::Image::new((icon_texture_id, icon_size))); - for pane_type_option in PaneType::all() { - // Load icon for this pane type - if let Some(icon) = ctx.icon_cache.get_or_load(*pane_type_option, ui.ctx()) { - ui.horizontal(|ui| { - // Show icon - let icon_texture_id = icon.id(); - let icon_size = egui::vec2(16.0, 16.0); - ui.add(egui::Image::new((icon_texture_id, icon_size))); - - // Show label with selection + // Show label with selection + if ui.selectable_label( + pane_type == Some(*pane_type_option), + pane_type_option.display_name() + ).clicked() { + *pane_name = pane_type_option.to_name().to_string(); + } + }); + } else { + // Fallback if icon fails to load if ui.selectable_label( pane_type == Some(*pane_type_option), pane_type_option.display_name() ).clicked() { *pane_name = pane_type_option.to_name().to_string(); - ui.memory_mut(|mem| mem.close_popup()); } - }); - } else { - // Fallback if icon fails to load - if ui.selectable_label( - pane_type == Some(*pane_type_option), - pane_type_option.display_name() - ).clicked() { - *pane_name = pane_type_option.to_name().to_string(); - ui.memory_mut(|mem| mem.close_popup()); } } - } - }); + }); // Draw pane title in header let title_text = if let Some(pane_type) = pane_type { @@ -3512,6 +3590,10 @@ fn render_pane( waveform_chunk_cache: ctx.waveform_chunk_cache, waveform_image_cache: ctx.waveform_image_cache, audio_pools_with_new_waveforms: ctx.audio_pools_with_new_waveforms, + effect_to_load: ctx.effect_to_load, + effect_thumbnail_requests: ctx.effect_thumbnail_requests, + effect_thumbnail_cache: ctx.effect_thumbnail_cache, + effect_thumbnails_to_invalidate: ctx.effect_thumbnails_to_invalidate, }; pane_instance.render_header(&mut header_ui, &mut shared); } @@ -3571,6 +3653,10 @@ fn render_pane( waveform_chunk_cache: ctx.waveform_chunk_cache, waveform_image_cache: ctx.waveform_image_cache, audio_pools_with_new_waveforms: ctx.audio_pools_with_new_waveforms, + effect_to_load: ctx.effect_to_load, + effect_thumbnail_requests: ctx.effect_thumbnail_requests, + effect_thumbnail_cache: ctx.effect_thumbnail_cache, + effect_thumbnails_to_invalidate: ctx.effect_thumbnails_to_invalidate, }; // Render pane content (header was already rendered above) @@ -3788,6 +3874,7 @@ fn pane_color(pane_type: PaneType) -> egui::Color32 { PaneType::NodeEditor => egui::Color32::from_rgb(30, 45, 50), PaneType::PresetBrowser => egui::Color32::from_rgb(50, 45, 30), PaneType::AssetLibrary => egui::Color32::from_rgb(45, 50, 35), + PaneType::ShaderEditor => egui::Color32::from_rgb(35, 30, 55), } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/asset_library.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/asset_library.rs index 9af24ed..696d98c 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/asset_library.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/asset_library.rs @@ -1173,6 +1173,254 @@ impl AssetLibraryPane { } } + /// Render a section header for effect categories + fn render_section_header(ui: &mut egui::Ui, label: &str, color: egui::Color32) { + ui.add_space(4.0); + let (header_rect, _) = ui.allocate_exact_size( + egui::vec2(ui.available_width(), 20.0), + egui::Sense::hover(), + ); + ui.painter().text( + header_rect.min + egui::vec2(8.0, 2.0), + egui::Align2::LEFT_TOP, + label, + egui::FontId::proportional(11.0), + color, + ); + ui.add_space(2.0); + } + + /// Render a grid of asset items + #[allow(clippy::too_many_arguments)] + fn render_grid_items( + &mut self, + ui: &mut egui::Ui, + assets: &[&AssetEntry], + columns: usize, + item_height: f32, + content_width: f32, + shared: &mut SharedPaneState, + document: &Document, + text_color: egui::Color32, + secondary_text_color: egui::Color32, + ) { + if assets.is_empty() { + return; + } + + let rows = (assets.len() + columns - 1) / columns; + // Grid height: matches the positioning formula used below + // Items are at: GRID_SPACING + row * (item_height + GRID_SPACING) + // Last item bottom: GRID_SPACING + (rows-1) * (item_height + GRID_SPACING) + item_height + // = GRID_SPACING + rows * item_height + (rows-1) * GRID_SPACING + // = rows * (item_height + GRID_SPACING) + GRID_SPACING - GRID_SPACING (for last row) + // Simplified: GRID_SPACING + rows * (item_height + GRID_SPACING) + let grid_height = GRID_SPACING + rows as f32 * (item_height + GRID_SPACING); + + // Reserve space for this grid section + // We need to use allocate_space to properly advance the cursor by the full height, + // then calculate the rect ourselves + let cursor_before = ui.cursor().min; + let _ = ui.allocate_space(egui::vec2(content_width, grid_height)); + let grid_rect = egui::Rect::from_min_size(cursor_before, egui::vec2(content_width, grid_height)); + + for (idx, asset) in assets.iter().enumerate() { + let col = idx % columns; + let row = idx / columns; + + let item_x = grid_rect.min.x + GRID_SPACING + col as f32 * (GRID_ITEM_SIZE + GRID_SPACING); + let item_y = grid_rect.min.y + GRID_SPACING + row as f32 * (item_height + GRID_SPACING); + + let item_rect = egui::Rect::from_min_size( + egui::pos2(item_x, item_y), + egui::vec2(GRID_ITEM_SIZE, item_height), + ); + + // Use interact() instead of allocate_rect() because we've already allocated the + // entire grid space via allocate_exact_size above - allocate_rect would double-count + let response = ui.interact(item_rect, egui::Id::new(("grid_item", asset.id)), egui::Sense::click_and_drag()); + + let is_selected = self.selected_asset == Some(asset.id); + let is_being_dragged = shared.dragging_asset.as_ref().map(|d| d.clip_id == asset.id).unwrap_or(false); + + // Item background + let item_bg = if is_being_dragged { + egui::Color32::from_rgb(80, 100, 120) + } else if is_selected { + egui::Color32::from_rgb(60, 80, 100) + } else if response.hovered() { + egui::Color32::from_rgb(45, 45, 45) + } else { + egui::Color32::from_rgb(35, 35, 35) + }; + ui.painter().rect_filled(item_rect, 4.0, item_bg); + + // Thumbnail area + let thumbnail_rect = egui::Rect::from_min_size( + egui::pos2( + item_rect.min.x + (GRID_ITEM_SIZE - THUMBNAIL_SIZE as f32) / 2.0, + item_rect.min.y + 4.0, + ), + egui::vec2(THUMBNAIL_SIZE as f32, THUMBNAIL_SIZE as f32), + ); + + // Generate and display thumbnail + let asset_id = asset.id; + let asset_category = asset.category; + let ctx = ui.ctx().clone(); + + let prefetched_waveform: Option> = + if asset_category == AssetCategory::Audio && !self.thumbnail_cache.has(&asset_id) { + if let Some(clip) = document.audio_clips.get(&asset_id) { + if let AudioClipType::Sampled { audio_pool_index } = &clip.clip_type { + shared.waveform_cache.get(audio_pool_index) + .map(|peaks| peaks.iter().map(|p| (p.min, p.max)).collect()) + } else { + None + } + } else { + None + } + } else { + None + }; + + let texture = self.thumbnail_cache.get_or_create(&ctx, asset_id, || { + match asset_category { + AssetCategory::Images => document.image_assets.get(&asset_id).and_then(generate_image_thumbnail), + AssetCategory::Vector => { + let bg_color = egui::Color32::from_rgba_unmultiplied(40, 40, 40, 200); + document.vector_clips.get(&asset_id).map(|clip| generate_vector_thumbnail(clip, bg_color)) + } + AssetCategory::Video => generate_video_thumbnail(&asset_id, &shared.video_manager) + .or_else(|| Some(generate_placeholder_thumbnail(AssetCategory::Video, 200))), + AssetCategory::Audio => { + if let Some(clip) = document.audio_clips.get(&asset_id) { + let bg_color = egui::Color32::from_rgba_unmultiplied(40, 40, 40, 200); + match &clip.clip_type { + AudioClipType::Sampled { .. } => { + let wave_color = egui::Color32::from_rgb(100, 200, 100); + if let Some(ref peaks) = prefetched_waveform { + Some(generate_waveform_thumbnail(peaks, bg_color, wave_color)) + } else { + Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200)) + } + } + AudioClipType::Midi { midi_clip_id } => { + let note_color = egui::Color32::from_rgb(100, 200, 100); + if let Some(events) = shared.midi_event_cache.get(midi_clip_id) { + Some(generate_midi_thumbnail(events, clip.duration, bg_color, note_color)) + } else { + Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200)) + } + } + } + } else { + Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200)) + } + } + AssetCategory::Effects => { + // Use GPU-rendered effect thumbnail if available + if let Some(rgba) = shared.effect_thumbnail_cache.get(&asset_id) { + Some(rgba.clone()) + } else { + // Request GPU thumbnail generation + shared.effect_thumbnail_requests.push(asset_id); + // Return None to avoid caching placeholder - will retry next frame + None + } + } + AssetCategory::All => None, + } + }); + + // Either use cached texture or render placeholder directly for effects + // Use painter().image() instead of ui.put() to avoid affecting the cursor + if let Some(texture) = texture { + ui.painter().image( + texture.id(), + thumbnail_rect, + egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), + egui::Color32::WHITE, + ); + } else if asset_category == AssetCategory::Effects { + // Render effect placeholder directly (not cached) until GPU thumbnail ready + let placeholder_rgba = generate_effect_thumbnail(); + let color_image = egui::ColorImage::from_rgba_unmultiplied( + [THUMBNAIL_SIZE as usize, THUMBNAIL_SIZE as usize], + &placeholder_rgba, + ); + let texture = ctx.load_texture( + format!("effect_placeholder_{}", asset_id), + color_image, + egui::TextureOptions::LINEAR, + ); + ui.painter().image( + texture.id(), + thumbnail_rect, + egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), + egui::Color32::WHITE, + ); + } + + // Category color indicator + let indicator_rect = egui::Rect::from_min_size( + egui::pos2(thumbnail_rect.min.x, thumbnail_rect.max.y - 3.0), + egui::vec2(THUMBNAIL_SIZE as f32, 3.0), + ); + ui.painter().rect_filled(indicator_rect, 0.0, asset.category.color()); + + // Asset name + let name_display = ellipsize(&asset.name, 12); + ui.painter().text( + egui::pos2(item_rect.center().x, thumbnail_rect.max.y + 8.0), + egui::Align2::CENTER_TOP, + &name_display, + egui::FontId::proportional(10.0), + text_color, + ); + + // Handle interactions + if response.clicked() { + self.selected_asset = Some(asset.id); + } + + if response.secondary_clicked() { + if let Some(pos) = ui.ctx().pointer_interact_pos() { + self.context_menu = Some(ContextMenuState { asset_id: asset.id, position: pos }); + } + } + + if response.double_clicked() { + if asset.category == AssetCategory::Effects { + *shared.effect_to_load = Some(asset.id); + } else if !asset.is_builtin { + self.rename_state = Some(RenameState { + asset_id: asset.id, + category: asset.category, + edit_text: asset.name.clone(), + }); + } + } + + if response.drag_started() { + let linked_audio_clip_id = if asset.drag_clip_type == DragClipType::Video { + document.video_clips.get(&asset.id).and_then(|video| video.linked_audio_clip_id) + } else { + None + }; + *shared.dragging_asset = Some(DraggingAsset { + clip_id: asset.id, + clip_type: asset.drag_clip_type, + name: asset.name.clone(), + duration: asset.duration, + dimensions: asset.dimensions, + linked_audio_clip_id, + }); + } + } + } + /// Render assets based on current view mode fn render_assets( &mut self, @@ -1244,7 +1492,60 @@ impl AssetLibraryPane { .show(ui, |ui| { ui.set_min_width(scroll_area_rect.width() - 16.0); // Account for scrollbar - for asset in assets { + // For Effects tab, reorder: built-in first, then custom, with headers + let ordered_assets: Vec<&AssetEntry>; + let show_effects_sections = self.selected_category == AssetCategory::Effects; + + let assets_to_render = if show_effects_sections { + let builtin: Vec<_> = assets.iter().filter(|a| a.is_builtin).copied().collect(); + let custom: Vec<_> = assets.iter().filter(|a| !a.is_builtin).copied().collect(); + ordered_assets = builtin.into_iter().chain(custom.into_iter()).collect(); + &ordered_assets[..] + } else { + assets + }; + + // Track whether we need to render section headers + let builtin_count = if show_effects_sections { + assets.iter().filter(|a| a.is_builtin).count() + } else { + 0 + }; + let custom_count = if show_effects_sections { + assets.iter().filter(|a| !a.is_builtin).count() + } else { + 0 + }; + let mut rendered_builtin_header = false; + let mut rendered_custom_header = false; + let mut builtin_rendered = 0; + + for asset in assets_to_render { + // Render section headers for Effects tab + if show_effects_sections { + if asset.is_builtin && !rendered_builtin_header && builtin_count > 0 { + Self::render_section_header(ui, "Built-in Effects", secondary_text_color); + rendered_builtin_header = true; + } + if !asset.is_builtin && !rendered_custom_header && custom_count > 0 { + // Add separator before custom section if there were built-in effects + if builtin_count > 0 { + ui.add_space(8.0); + let separator_rect = ui.allocate_exact_size( + egui::vec2(ui.available_width(), 1.0), + egui::Sense::hover(), + ).0; + ui.painter().rect_filled(separator_rect, 0.0, egui::Color32::from_gray(60)); + ui.add_space(8.0); + } + Self::render_section_header(ui, "Custom Effects", secondary_text_color); + rendered_custom_header = true; + } + if asset.is_builtin { + builtin_rendered += 1; + } + } + let (item_rect, response) = ui.allocate_exact_size( egui::vec2(ui.available_width(), ITEM_HEIGHT), egui::Sense::click_and_drag(), @@ -1413,16 +1714,40 @@ impl AssetLibraryPane { } } AssetCategory::Effects => { - Some(generate_effect_thumbnail()) + // Use GPU-rendered effect thumbnail if available + if let Some(rgba) = shared.effect_thumbnail_cache.get(&asset.id) { + Some(rgba.clone()) + } else { + // Request GPU thumbnail generation + shared.effect_thumbnail_requests.push(asset.id); + // Return None to avoid caching placeholder - will retry next frame + None + } } AssetCategory::All => None, } }); + // Either use cached texture or render placeholder directly for effects if let Some(texture) = texture { let image = egui::Image::new(texture) .fit_to_exact_size(egui::vec2(LIST_THUMBNAIL_SIZE, LIST_THUMBNAIL_SIZE)); ui.put(thumbnail_rect, image); + } else if asset.category == AssetCategory::Effects { + // Render effect placeholder directly (not cached) until GPU thumbnail ready + let placeholder_rgba = generate_effect_thumbnail(); + let color_image = egui::ColorImage::from_rgba_unmultiplied( + [THUMBNAIL_SIZE as usize, THUMBNAIL_SIZE as usize], + &placeholder_rgba, + ); + let texture = ctx.load_texture( + format!("effect_placeholder_{}", asset.id), + color_image, + egui::TextureOptions::LINEAR, + ); + let image = egui::Image::new(&texture) + .fit_to_exact_size(egui::vec2(LIST_THUMBNAIL_SIZE, LIST_THUMBNAIL_SIZE)); + ui.put(thumbnail_rect, image); } // Handle click (selection) @@ -1440,13 +1765,19 @@ impl AssetLibraryPane { } } - // Handle double-click (start rename) + // Handle double-click if response.double_clicked() { - self.rename_state = Some(RenameState { - asset_id: asset.id, - category: asset.category, - edit_text: asset.name.clone(), - }); + // For effects, open in shader editor + if asset.category == AssetCategory::Effects { + *shared.effect_to_load = Some(asset.id); + } else if !asset.is_builtin { + // For other non-builtin assets, start rename + self.rename_state = Some(RenameState { + asset_id: asset.id, + category: asset.category, + edit_text: asset.name.clone(), + }); + } } // Handle drag start @@ -1568,219 +1899,74 @@ impl AssetLibraryPane { .floor() .max(1.0) as usize; let item_height = GRID_ITEM_SIZE + 20.0; // 20 for name below thumbnail - let rows = (assets.len() + columns - 1) / columns; - let total_height = GRID_SPACING + rows as f32 * (item_height + GRID_SPACING); - // Use egui's built-in ScrollArea for scrolling + // For Effects tab, reorder: built-in first, then custom + let ordered_assets: Vec<&AssetEntry>; + let show_effects_sections = self.selected_category == AssetCategory::Effects; + + let assets_to_render: &[&AssetEntry] = if show_effects_sections { + let builtin: Vec<_> = assets.iter().filter(|a| a.is_builtin).copied().collect(); + let custom: Vec<_> = assets.iter().filter(|a| !a.is_builtin).copied().collect(); + ordered_assets = builtin.into_iter().chain(custom.into_iter()).collect(); + &ordered_assets[..] + } else { + assets + }; + + let builtin_count = if show_effects_sections { + assets.iter().filter(|a| a.is_builtin).count() + } else { + 0 + }; + let custom_count = if show_effects_sections { + assets.iter().filter(|a| !a.is_builtin).count() + } else { + 0 + }; + ui.allocate_new_ui(egui::UiBuilder::new().max_rect(rect), |ui| { egui::ScrollArea::vertical() .id_salt(("asset_grid_scroll", path)) .auto_shrink([false, false]) .show(ui, |ui| { - // Reserve space for the entire grid - let (grid_rect, _) = ui.allocate_exact_size( - egui::vec2(content_width, total_height), - egui::Sense::hover(), - ); + ui.set_min_width(content_width); - for (idx, asset) in assets.iter().enumerate() { - let col = idx % columns; - let row = idx / columns; + // Render built-in section header + if show_effects_sections && builtin_count > 0 { + Self::render_section_header(ui, "Built-in Effects", secondary_text_color); + } - // Calculate item position with proper spacing - let item_x = grid_rect.min.x + GRID_SPACING + col as f32 * (GRID_ITEM_SIZE + GRID_SPACING); - let item_y = grid_rect.min.y + GRID_SPACING + row as f32 * (item_height + GRID_SPACING); + // First pass: render built-in items + let builtin_items: Vec<_> = assets_to_render.iter().filter(|a| a.is_builtin).copied().collect(); + if !builtin_items.is_empty() { + self.render_grid_items(ui, &builtin_items, columns, item_height, content_width, shared, document, text_color, secondary_text_color); + } - let item_rect = egui::Rect::from_min_size( - egui::pos2(item_x, item_y), - egui::vec2(GRID_ITEM_SIZE, item_height), - ); + // Separator between sections + if show_effects_sections && builtin_count > 0 && custom_count > 0 { + ui.add_space(8.0); + let separator_rect = ui.allocate_exact_size( + egui::vec2(ui.available_width(), 1.0), + egui::Sense::hover(), + ).0; + ui.painter().rect_filled(separator_rect, 0.0, egui::Color32::from_gray(60)); + ui.add_space(8.0); + } - // Allocate the response for this item - let response = ui.allocate_rect(item_rect, egui::Sense::click_and_drag()); + // Render custom section header + if show_effects_sections && custom_count > 0 { + Self::render_section_header(ui, "Custom Effects", secondary_text_color); + } - let is_selected = self.selected_asset == Some(asset.id); - let is_being_dragged = shared - .dragging_asset - .as_ref() - .map(|d| d.clip_id == asset.id) - .unwrap_or(false); + // Second pass: render custom items + let custom_items: Vec<_> = assets_to_render.iter().filter(|a| !a.is_builtin).copied().collect(); + if !custom_items.is_empty() { + self.render_grid_items(ui, &custom_items, columns, item_height, content_width, shared, document, text_color, secondary_text_color); + } - // Item background - let item_bg = if is_being_dragged { - egui::Color32::from_rgb(80, 100, 120) - } else if is_selected { - egui::Color32::from_rgb(60, 80, 100) - } else if response.hovered() { - egui::Color32::from_rgb(45, 45, 45) - } else { - egui::Color32::from_rgb(35, 35, 35) - }; - ui.painter().rect_filled(item_rect, 4.0, item_bg); - - // Thumbnail area (64x64 centered in 80px width) - let thumbnail_rect = egui::Rect::from_min_size( - egui::pos2( - item_rect.min.x + (GRID_ITEM_SIZE - THUMBNAIL_SIZE as f32) / 2.0, - item_rect.min.y + 4.0, - ), - egui::vec2(THUMBNAIL_SIZE as f32, THUMBNAIL_SIZE as f32), - ); - - // Generate and display thumbnail based on asset type - let asset_id = asset.id; - let asset_category = asset.category; - let ctx = ui.ctx().clone(); - - // Get waveform data from cache if thumbnail not already cached - let prefetched_waveform: Option> = - if asset_category == AssetCategory::Audio && !self.thumbnail_cache.has(&asset_id) { - if let Some(clip) = document.audio_clips.get(&asset_id) { - if let AudioClipType::Sampled { audio_pool_index } = &clip.clip_type { - // Use cached waveform data (populated by fetch_waveform in main.rs) - let waveform = shared.waveform_cache.get(audio_pool_index) - .map(|peaks| peaks.iter().map(|p| (p.min, p.max)).collect()); - if waveform.is_some() { - println!("🎵 Found waveform for pool {} (asset {})", audio_pool_index, asset_id); - } else { - println!("⚠️ No waveform yet for pool {} (asset {})", audio_pool_index, asset_id); - } - waveform - } else { - None - } - } else { - None - } - } else { - None - }; - - let texture = self.thumbnail_cache.get_or_create(&ctx, asset_id, || { - match asset_category { - AssetCategory::Images => { - document.image_assets.get(&asset_id) - .and_then(generate_image_thumbnail) - } - AssetCategory::Vector => { - // Render frame 0 of vector clip using tiny-skia - let bg_color = egui::Color32::from_rgba_unmultiplied(40, 40, 40, 200); - document.vector_clips.get(&asset_id) - .map(|clip| generate_vector_thumbnail(clip, bg_color)) - } - AssetCategory::Video => { - // Generate video thumbnail from first frame - generate_video_thumbnail(&asset_id, &shared.video_manager) - .or_else(|| Some(generate_placeholder_thumbnail(AssetCategory::Video, 200))) - } - AssetCategory::Audio => { - if let Some(clip) = document.audio_clips.get(&asset_id) { - let bg_color = egui::Color32::from_rgba_unmultiplied(40, 40, 40, 200); - match &clip.clip_type { - AudioClipType::Sampled { .. } => { - let wave_color = egui::Color32::from_rgb(100, 200, 100); - if let Some(ref peaks) = prefetched_waveform { - println!("✅ Generating waveform thumbnail with {} peaks for asset {}", peaks.len(), asset_id); - Some(generate_waveform_thumbnail(peaks, bg_color, wave_color)) - } else { - println!("📦 Generating placeholder thumbnail for asset {}", asset_id); - Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200)) - } - } - AudioClipType::Midi { midi_clip_id } => { - let bg_color = egui::Color32::from_rgba_unmultiplied(40, 40, 40, 200); - let note_color = egui::Color32::from_rgb(100, 200, 100); - - if let Some(events) = shared.midi_event_cache.get(midi_clip_id) { - Some(generate_midi_thumbnail(events, clip.duration, bg_color, note_color)) - } else { - Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200)) - } - } - } - } else { - Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200)) - } - } - AssetCategory::Effects => { - Some(generate_effect_thumbnail()) - } - AssetCategory::All => None, - } - }); - - if let Some(texture) = texture { - let image = egui::Image::new(texture) - .fit_to_exact_size(egui::vec2(THUMBNAIL_SIZE as f32, THUMBNAIL_SIZE as f32)); - ui.put(thumbnail_rect, image); - } - - // Category color indicator (small bar at bottom of thumbnail) - let indicator_rect = egui::Rect::from_min_size( - egui::pos2(thumbnail_rect.min.x, thumbnail_rect.max.y - 3.0), - egui::vec2(THUMBNAIL_SIZE as f32, 3.0), - ); - ui.painter().rect_filled(indicator_rect, 0.0, asset.category.color()); - - // Asset name below thumbnail (ellipsized) - let name_display = ellipsize(&asset.name, 12); - let name_pos = egui::pos2( - item_rect.center().x, - thumbnail_rect.max.y + 8.0, - ); - ui.painter().text( - name_pos, - egui::Align2::CENTER_TOP, - &name_display, - egui::FontId::proportional(10.0), - text_color, - ); - - // Handle click (selection) - if response.clicked() { - self.selected_asset = Some(asset.id); - } - - // Handle right-click (context menu) - if response.secondary_clicked() { - if let Some(pos) = ui.ctx().pointer_interact_pos() { - self.context_menu = Some(ContextMenuState { - asset_id: asset.id, - position: pos, - }); - } - } - - // Handle double-click (start rename) - if response.double_clicked() { - self.rename_state = Some(RenameState { - asset_id: asset.id, - category: asset.category, - edit_text: asset.name.clone(), - }); - } - - // Handle drag start - if response.drag_started() { - // For video clips, get the linked audio clip ID - let linked_audio_clip_id = if asset.drag_clip_type == DragClipType::Video { - let result = document.video_clips.get(&asset.id) - .and_then(|video| video.linked_audio_clip_id); - eprintln!("DEBUG DRAG: Video clip {} has linked audio: {:?}", asset.id, result); - result - } else { - None - }; - - *shared.dragging_asset = Some(DraggingAsset { - clip_id: asset.id, - clip_type: asset.drag_clip_type, - name: asset.name.clone(), - duration: asset.duration, - dimensions: asset.dimensions, - linked_audio_clip_id, - }); - } + // For non-Effects tabs, just render all items + if !show_effects_sections { + self.render_grid_items(ui, assets_to_render, columns, item_height, content_width, shared, document, text_color, secondary_text_color); } }); }); @@ -1857,6 +2043,16 @@ impl PaneRenderer for AssetLibraryPane { } } + // Invalidate thumbnails for effects that were edited (shader code changed) + if !shared.effect_thumbnails_to_invalidate.is_empty() { + for effect_id in shared.effect_thumbnails_to_invalidate.iter() { + self.thumbnail_cache.invalidate(effect_id); + } + // Clear after processing - we've handled these + shared.effect_thumbnails_to_invalidate.clear(); + ui.ctx().request_repaint(); + } + // Collect and filter assets let all_assets = self.collect_assets(&document_arc); let filtered_assets = self.filter_assets(&all_assets); @@ -1905,6 +2101,15 @@ impl PaneRenderer for AssetLibraryPane { egui::Frame::popup(ui.style()).show(ui, |ui| { ui.set_min_width(120.0); + // Add "Edit in Shader Editor" for effects + if asset_category == AssetCategory::Effects { + if ui.button("Edit in Shader Editor").clicked() { + *shared.effect_to_load = Some(context_asset_id); + self.context_menu = None; + } + ui.separator(); + } + // Built-in effects cannot be renamed or deleted if asset_is_builtin { ui.label(egui::RichText::new("Built-in effect") diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs index 5a4256b..33006a7 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs @@ -64,6 +64,7 @@ pub mod virtual_piano; pub mod node_editor; pub mod preset_browser; pub mod asset_library; +pub mod shader_editor; /// Which color mode is active for the eyedropper tool #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -191,6 +192,14 @@ pub struct SharedPaneState<'a> { pub waveform_image_cache: &'a mut crate::waveform_image_cache::WaveformImageCache, /// Audio pool indices that got new waveform data this frame (for thumbnail invalidation) pub audio_pools_with_new_waveforms: &'a std::collections::HashSet, + /// Effect ID to load into shader editor (set by asset library, consumed by shader editor) + pub effect_to_load: &'a mut Option, + /// Queue for effect thumbnail requests (effect IDs to generate thumbnails for) + pub effect_thumbnail_requests: &'a mut Vec, + /// Cache of generated effect thumbnails (effect_id -> RGBA data) + 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, } /// Trait for pane rendering @@ -231,6 +240,7 @@ pub enum PaneInstance { NodeEditor(node_editor::NodeEditorPane), PresetBrowser(preset_browser::PresetBrowserPane), AssetLibrary(asset_library::AssetLibraryPane), + ShaderEditor(shader_editor::ShaderEditorPane), } impl PaneInstance { @@ -251,6 +261,9 @@ impl PaneInstance { PaneType::AssetLibrary => { PaneInstance::AssetLibrary(asset_library::AssetLibraryPane::new()) } + PaneType::ShaderEditor => { + PaneInstance::ShaderEditor(shader_editor::ShaderEditorPane::new()) + } } } @@ -267,6 +280,7 @@ impl PaneInstance { PaneInstance::NodeEditor(_) => PaneType::NodeEditor, PaneInstance::PresetBrowser(_) => PaneType::PresetBrowser, PaneInstance::AssetLibrary(_) => PaneType::AssetLibrary, + PaneInstance::ShaderEditor(_) => PaneType::ShaderEditor, } } } @@ -284,6 +298,7 @@ impl PaneRenderer for PaneInstance { PaneInstance::NodeEditor(p) => p.render_header(ui, shared), PaneInstance::PresetBrowser(p) => p.render_header(ui, shared), PaneInstance::AssetLibrary(p) => p.render_header(ui, shared), + PaneInstance::ShaderEditor(p) => p.render_header(ui, shared), } } @@ -305,6 +320,7 @@ impl PaneRenderer for PaneInstance { PaneInstance::NodeEditor(p) => p.render_content(ui, rect, path, shared), PaneInstance::PresetBrowser(p) => p.render_content(ui, rect, path, shared), PaneInstance::AssetLibrary(p) => p.render_content(ui, rect, path, shared), + PaneInstance::ShaderEditor(p) => p.render_content(ui, rect, path, shared), } } @@ -320,6 +336,7 @@ impl PaneRenderer for PaneInstance { PaneInstance::NodeEditor(p) => p.name(), PaneInstance::PresetBrowser(p) => p.name(), PaneInstance::AssetLibrary(p) => p.name(), + PaneInstance::ShaderEditor(p) => p.name(), } } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/shader_editor.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/shader_editor.rs new file mode 100644 index 0000000..4ca29f3 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/shader_editor.rs @@ -0,0 +1,581 @@ +/// Shader Editor pane - WGSL shader code editor with syntax highlighting +/// +/// Provides a code editor for creating and editing custom effect shaders. +/// Features: +/// - Syntax highlighting for WGSL +/// - Line numbers +/// - Basic validation feedback +/// - Template shader insertion + +use eframe::egui::{self, Ui}; +use egui_code_editor::{CodeEditor, ColorTheme, Syntax}; +use lightningbeam_core::effect::{EffectCategory, EffectDefinition}; +use lightningbeam_core::effect_registry::EffectRegistry; +use uuid::Uuid; +use super::{NodePath, PaneRenderer, SharedPaneState}; + +/// Result from the unsaved changes dialog +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum UnsavedDialogResult { + Cancel, + Discard, + SaveAndContinue, +} + +/// Custom syntax definition for WGSL (WebGPU Shading Language) +fn wgsl_syntax() -> Syntax { + Syntax { + language: "WGSL", + case_sensitive: true, + comment: "//", + comment_multiline: ["/*", "*/"], + hyperlinks: std::collections::BTreeSet::new(), + keywords: std::collections::BTreeSet::from([ + // Control flow + "if", "else", "for", "while", "loop", "break", "continue", "return", + "switch", "case", "default", "discard", + // Declarations + "fn", "let", "var", "const", "struct", "alias", "type", + // Storage classes and access modes + "function", "private", "workgroup", "uniform", "storage", + "read", "write", "read_write", + // Shader stages + "vertex", "fragment", "compute", + // Attributes + "location", "builtin", "group", "binding", + // Built-in values + "position", "vertex_index", "instance_index", "front_facing", + "frag_depth", "local_invocation_id", "local_invocation_index", + "global_invocation_id", "workgroup_id", "num_workgroups", + "sample_index", "sample_mask", + ]), + types: std::collections::BTreeSet::from([ + // Scalar types + "bool", "i32", "u32", "f32", "f16", + // Vector types + "vec2", "vec3", "vec4", + "vec2i", "vec3i", "vec4i", + "vec2u", "vec3u", "vec4u", + "vec2f", "vec3f", "vec4f", + "vec2h", "vec3h", "vec4h", + // Matrix types + "mat2x2", "mat2x3", "mat2x4", + "mat3x2", "mat3x3", "mat3x4", + "mat4x2", "mat4x3", "mat4x4", + "mat2x2f", "mat3x3f", "mat4x4f", + // Texture types + "texture_1d", "texture_2d", "texture_2d_array", "texture_3d", + "texture_cube", "texture_cube_array", "texture_multisampled_2d", + "texture_storage_1d", "texture_storage_2d", "texture_storage_2d_array", + "texture_storage_3d", "texture_depth_2d", "texture_depth_2d_array", + "texture_depth_cube", "texture_depth_cube_array", "texture_depth_multisampled_2d", + // Sampler types + "sampler", "sampler_comparison", + // Array and pointer + "array", "ptr", + ]), + special: std::collections::BTreeSet::from([ + // Built-in functions (subset) + "abs", "acos", "all", "any", "asin", "atan", "atan2", + "ceil", "clamp", "cos", "cosh", "cross", + "degrees", "determinant", "distance", "dot", + "exp", "exp2", "faceForward", "floor", "fma", "fract", + "length", "log", "log2", + "max", "min", "mix", "modf", "normalize", + "pow", "radians", "reflect", "refract", "round", + "saturate", "sign", "sin", "sinh", "smoothstep", "sqrt", "step", + "tan", "tanh", "transpose", "trunc", + // Texture functions + "textureSample", "textureSampleLevel", "textureSampleBias", + "textureSampleGrad", "textureSampleCompare", "textureLoad", + "textureStore", "textureDimensions", "textureNumLayers", + "textureNumLevels", "textureNumSamples", + // Atomic functions + "atomicLoad", "atomicStore", "atomicAdd", "atomicSub", + "atomicMax", "atomicMin", "atomicAnd", "atomicOr", "atomicXor", + "atomicExchange", "atomicCompareExchangeWeak", + // Data packing + "pack4x8snorm", "pack4x8unorm", "pack2x16snorm", "pack2x16unorm", + "unpack4x8snorm", "unpack4x8unorm", "unpack2x16snorm", "unpack2x16unorm", + // Synchronization + "storageBarrier", "workgroupBarrier", "workgroupUniformLoad", + // Type constructors + "select", "bitcast", + ]), + } +} + +/// Default WGSL shader template for custom effects +const DEFAULT_SHADER_TEMPLATE: &str = r#"// Custom Effect Shader +// Input: source_tex (the layer content) +// Output: vec4 color at each pixel + +@group(0) @binding(0) var source_tex: texture_2d; +@group(0) @binding(1) var source_sampler: sampler; + +struct VertexOutput { + @builtin(position) position: vec4, + @location(0) uv: vec2, +} + +// Fullscreen triangle strip +@vertex +fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput { + var out: VertexOutput; + let x = f32((vertex_index & 1u) << 1u); + let y = f32(vertex_index & 2u); + out.position = vec4(x * 2.0 - 1.0, 1.0 - y * 2.0, 0.0, 1.0); + out.uv = vec2(x, y); + return out; +} + +@fragment +fn fs_main(in: VertexOutput) -> @location(0) vec4 { + // Sample the source texture + let color = textureSample(source_tex, source_sampler, in.uv); + + // Your effect code here - modify 'color' as desired + // Example: Return the color unchanged (passthrough) + return color; +} +"#; + +/// Grayscale effect shader template +const GRAYSCALE_TEMPLATE: &str = r#"// Grayscale Effect +// Converts the image to grayscale using luminance weights + +@group(0) @binding(0) var source_tex: texture_2d; +@group(0) @binding(1) var source_sampler: sampler; + +struct VertexOutput { + @builtin(position) position: vec4, + @location(0) uv: vec2, +} + +@vertex +fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput { + var out: VertexOutput; + let x = f32((vertex_index & 1u) << 1u); + let y = f32(vertex_index & 2u); + out.position = vec4(x * 2.0 - 1.0, 1.0 - y * 2.0, 0.0, 1.0); + out.uv = vec2(x, y); + return out; +} + +@fragment +fn fs_main(in: VertexOutput) -> @location(0) vec4 { + let color = textureSample(source_tex, source_sampler, in.uv); + + // ITU-R BT.709 luminance coefficients + let luminance = dot(color.rgb, vec3(0.2126, 0.7152, 0.0722)); + + return vec4(luminance, luminance, luminance, color.a); +} +"#; + +/// Vignette effect shader template +const VIGNETTE_TEMPLATE: &str = r#"// Vignette Effect +// Darkens the edges of the image + +@group(0) @binding(0) var source_tex: texture_2d; +@group(0) @binding(1) var source_sampler: sampler; + +struct VertexOutput { + @builtin(position) position: vec4, + @location(0) uv: vec2, +} + +@vertex +fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput { + var out: VertexOutput; + let x = f32((vertex_index & 1u) << 1u); + let y = f32(vertex_index & 2u); + out.position = vec4(x * 2.0 - 1.0, 1.0 - y * 2.0, 0.0, 1.0); + out.uv = vec2(x, y); + return out; +} + +@fragment +fn fs_main(in: VertexOutput) -> @location(0) vec4 { + let color = textureSample(source_tex, source_sampler, in.uv); + + // Calculate distance from center (0.5, 0.5) + let center = vec2(0.5, 0.5); + let dist = distance(in.uv, center); + + // Vignette parameters + let radius = 0.7; // Inner radius (no darkening) + let softness = 0.4; // Transition softness + + // Calculate vignette factor + let vignette = smoothstep(radius + softness, radius, dist); + + return vec4(color.rgb * vignette, color.a); +} +"#; + +/// Shader Editor pane state +pub struct ShaderEditorPane { + /// The shader source code being edited + shader_code: String, + /// Whether to show the template selector + show_templates: bool, + /// Error message from last compilation attempt (if any) + compile_error: Option, + /// Name for the shader/effect + shader_name: String, + /// ID of effect being edited (None = new effect) + editing_effect_id: Option, + /// Original code when effect was loaded (for dirty checking) + original_code: Option, + /// Original name when effect was loaded (for dirty checking) + original_name: Option, + /// Effect awaiting confirmation to load (when there are unsaved changes) + pending_load_effect: Option, + /// Whether to show the unsaved changes confirmation dialog + show_unsaved_dialog: bool, +} + +impl ShaderEditorPane { + pub fn new() -> Self { + Self { + shader_code: DEFAULT_SHADER_TEMPLATE.to_string(), + show_templates: false, + compile_error: None, + shader_name: "Custom Effect".to_string(), + editing_effect_id: None, + original_code: None, + original_name: None, + pending_load_effect: None, + show_unsaved_dialog: false, + } + } + + /// Check if there are unsaved changes + pub fn has_unsaved_changes(&self) -> bool { + match (&self.original_code, &self.original_name) { + (Some(orig_code), Some(orig_name)) => { + self.shader_code != *orig_code || self.shader_name != *orig_name + } + // If no original, check if we've modified from default + (None, None) => { + self.shader_code != DEFAULT_SHADER_TEMPLATE || self.shader_name != "Custom Effect" + } + _ => true, // Inconsistent state, assume dirty + } + } + + /// Load an effect into the editor + pub fn load_effect(&mut self, effect: &EffectDefinition) { + self.shader_name = effect.name.clone(); + self.shader_code = effect.shader_code.clone(); + // For built-in effects, don't set editing_effect_id (editing creates a copy) + if effect.category == EffectCategory::Custom { + self.editing_effect_id = Some(effect.id); + } else { + self.editing_effect_id = None; + } + self.original_code = Some(effect.shader_code.clone()); + self.original_name = Some(effect.name.clone()); + self.compile_error = None; + } + + /// Reset to a new blank effect + pub fn new_effect(&mut self) { + self.shader_name = "Custom Effect".to_string(); + self.shader_code = DEFAULT_SHADER_TEMPLATE.to_string(); + self.editing_effect_id = None; + self.original_code = None; + self.original_name = None; + self.compile_error = None; + } + + /// Mark the current state as saved + pub fn mark_saved(&mut self, effect_id: Uuid) { + self.editing_effect_id = Some(effect_id); + self.original_code = Some(self.shader_code.clone()); + self.original_name = Some(self.shader_name.clone()); + } + + /// Look up an effect by ID (checks document first, then built-in registry) + fn lookup_effect( + &self, + effect_id: Uuid, + document: &lightningbeam_core::document::Document, + ) -> Option { + // First check custom effects in document + if let Some(def) = document.effect_definitions.get(&effect_id) { + return Some(def.clone()); + } + // Then check built-in effects + EffectRegistry::get_by_id(&effect_id) + } + + /// Render the unsaved changes confirmation dialog + fn render_unsaved_dialog(&mut self, ui: &mut egui::Ui) -> Option { + let mut result = None; + + if self.show_unsaved_dialog { + let window_id = egui::Id::new("shader_unsaved_dialog"); + + egui::Window::new("Unsaved Changes") + .id(window_id) + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0)) + .show(ui.ctx(), |ui| { + ui.set_min_width(300.0); + + ui.label("You have unsaved changes to this shader."); + ui.label("What would you like to do?"); + ui.add_space(12.0); + + ui.horizontal(|ui| { + if ui.button("Cancel").clicked() { + result = Some(UnsavedDialogResult::Cancel); + } + if ui.button("Discard Changes").clicked() { + result = Some(UnsavedDialogResult::Discard); + } + if ui.button("Save & Continue").clicked() { + result = Some(UnsavedDialogResult::SaveAndContinue); + } + }); + }); + } + + result + } + + /// Render the toolbar with template selection and actions + /// Returns true if Save was clicked + fn render_toolbar(&mut self, ui: &mut Ui, _path: &NodePath) -> bool { + let mut save_clicked = false; + ui.horizontal(|ui| { + // New button + if ui.button("New").clicked() { + // TODO: Check for unsaved changes first + self.new_effect(); + } + + ui.separator(); + + // Shader name input + ui.label("Name:"); + ui.add(egui::TextEdit::singleline(&mut self.shader_name).desired_width(150.0)); + + ui.separator(); + + // Template dropdown + egui::ComboBox::from_label("Template") + .selected_text("Insert Template") + .show_ui(ui, |ui| { + if ui.selectable_label(false, "Basic (Passthrough)").clicked() { + self.shader_code = DEFAULT_SHADER_TEMPLATE.to_string(); + } + if ui.selectable_label(false, "Grayscale").clicked() { + self.shader_code = GRAYSCALE_TEMPLATE.to_string(); + } + if ui.selectable_label(false, "Vignette").clicked() { + self.shader_code = VIGNETTE_TEMPLATE.to_string(); + } + }); + + ui.separator(); + + // Compile button (placeholder for now) + if ui.button("Validate").clicked() { + // TODO: Integrate with wgpu shader validation + // For now, just clear any previous error + self.compile_error = None; + } + + // Save button + if ui.button("Save").clicked() { + save_clicked = true; + } + + // Show dirty indicator + if self.has_unsaved_changes() { + ui.label(egui::RichText::new("*").color(egui::Color32::YELLOW)); + } + + // Show editing mode + if let Some(_) = self.editing_effect_id { + ui.label(egui::RichText::new("(Editing)").weak()); + } else { + ui.label(egui::RichText::new("(New)").weak()); + } + }); + save_clicked + } + + /// Render the error panel if there's a compile error + fn render_error_panel(&self, ui: &mut Ui) { + if let Some(error) = &self.compile_error { + ui.horizontal(|ui| { + ui.label(egui::RichText::new("Error:").color(egui::Color32::RED)); + ui.label(error); + }); + ui.separator(); + } + } +} + +impl PaneRenderer for ShaderEditorPane { + fn render_content( + &mut self, + ui: &mut egui::Ui, + rect: egui::Rect, + path: &NodePath, + shared: &mut SharedPaneState, + ) { + // Handle effect loading request from asset library + if let Some(effect_id) = shared.effect_to_load.take() { + // Look up the effect + if let Some(effect) = self.lookup_effect(effect_id, shared.action_executor.document()) { + if self.has_unsaved_changes() { + // Store effect to load and show dialog + self.pending_load_effect = Some(effect); + self.show_unsaved_dialog = true; + } else { + // No unsaved changes, load immediately + self.load_effect(&effect); + } + } + } + + // Handle unsaved changes dialog + if let Some(result) = self.render_unsaved_dialog(ui) { + match result { + UnsavedDialogResult::Cancel => { + // Cancel the load, keep current state + self.pending_load_effect = None; + self.show_unsaved_dialog = false; + } + UnsavedDialogResult::Discard => { + // Discard changes and load the new effect + if let Some(effect) = self.pending_load_effect.take() { + self.load_effect(&effect); + } + self.show_unsaved_dialog = false; + } + UnsavedDialogResult::SaveAndContinue => { + // Save current work first + if !self.shader_name.trim().is_empty() { + let effect = if let Some(existing_id) = self.editing_effect_id { + EffectDefinition::with_id( + existing_id, + self.shader_name.clone(), + EffectCategory::Custom, + self.shader_code.clone(), + vec![], + ) + } else { + EffectDefinition::new( + self.shader_name.clone(), + EffectCategory::Custom, + self.shader_code.clone(), + vec![], + ) + }; + let effect_id = effect.id; + shared.action_executor.document_mut().add_effect_definition(effect); + self.mark_saved(effect_id); + // Invalidate thumbnail so it regenerates with new shader + shared.effect_thumbnails_to_invalidate.push(effect_id); + } + // Then load the new effect + if let Some(effect) = self.pending_load_effect.take() { + self.load_effect(&effect); + } + self.show_unsaved_dialog = false; + } + } + } + + // Background + ui.painter().rect_filled( + rect, + 0.0, + egui::Color32::from_rgb(25, 25, 30), + ); + + // Create content area + let content_rect = rect.shrink(8.0); + let mut content_ui = ui.new_child( + egui::UiBuilder::new() + .max_rect(content_rect) + .layout(egui::Layout::top_down(egui::Align::LEFT)), + ); + + content_ui.set_min_width(content_rect.width() - 16.0); + + // Toolbar + let save_clicked = self.render_toolbar(&mut content_ui, path); + content_ui.add_space(4.0); + content_ui.separator(); + content_ui.add_space(4.0); + + // Handle save action + if save_clicked { + if self.shader_name.trim().is_empty() { + self.compile_error = Some("Name cannot be empty".to_string()); + } else { + // Create or update EffectDefinition + let effect = if let Some(existing_id) = self.editing_effect_id { + // Update existing custom effect + EffectDefinition::with_id( + existing_id, + self.shader_name.clone(), + EffectCategory::Custom, + self.shader_code.clone(), + vec![], // No parameters for now + ) + } else { + // Create new custom effect + EffectDefinition::new( + self.shader_name.clone(), + EffectCategory::Custom, + self.shader_code.clone(), + vec![], // No parameters for now + ) + }; + + let effect_id = effect.id; + shared.action_executor.document_mut().add_effect_definition(effect); + self.mark_saved(effect_id); + // Invalidate thumbnail so it regenerates with new shader + shared.effect_thumbnails_to_invalidate.push(effect_id); + self.compile_error = None; + } + } + + // Error panel + self.render_error_panel(&mut content_ui); + + // Calculate remaining height for the code editor + let remaining_rect = content_ui.available_rect_before_wrap(); + + // Code editor + egui::ScrollArea::both() + .id_salt(("shader_editor_scroll", path)) + .auto_shrink([false, false]) + .show(&mut content_ui, |ui| { + ui.set_min_size(remaining_rect.size()); + + CodeEditor::default() + .id_source("shader_code_editor") + .with_rows(50) + .with_fontsize(13.0) + .with_theme(ColorTheme::GRUVBOX_DARK) + .with_syntax(wgsl_syntax()) + .with_numlines(true) + .show(ui, &mut self.shader_code); + }); + } + + fn name(&self) -> &str { + "Shader Editor" + } +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs index c69e81b..910cb4f 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs @@ -1740,6 +1740,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback { load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), store: wgpu::StoreOp::Store, }, + depth_slice: None, })], depth_stencil_attachment: None, timestamp_writes: None, @@ -1828,7 +1829,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback { }); // Poll the device to complete the mapping - device.poll(wgpu::Maintain::Wait); + let _ = device.poll(wgpu::PollType::wait_indefinitely()); // Read the pixel data if receiver.recv().is_ok() { diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/toolbar.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/toolbar.rs index 1edbd5e..8888656 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/toolbar.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/toolbar.rs @@ -151,19 +151,15 @@ impl PaneRenderer for ToolbarPane { // Draw fill color button with checkerboard for alpha draw_color_button(ui, fill_button_rect, *shared.fill_color); - if fill_response.clicked() { - // Open color picker popup - ui.memory_mut(|mem| mem.toggle_popup(fill_button_id)); - } - - // Show fill color picker popup - egui::popup::popup_below_widget(ui, fill_button_id, &fill_response, egui::popup::PopupCloseBehavior::CloseOnClickOutside, |ui: &mut egui::Ui| { - let changed = egui::color_picker::color_picker_color32(ui, shared.fill_color, egui::color_picker::Alpha::OnlyBlend); - // Track that the user interacted with the fill color - if changed { - *shared.active_color_mode = super::ColorMode::Fill; - } - }); + // Show fill color picker popup using new Popup API + egui::containers::Popup::from_toggle_button_response(&fill_response) + .show(|ui| { + let changed = egui::color_picker::color_picker_color32(ui, shared.fill_color, egui::color_picker::Alpha::OnlyBlend); + // Track that the user interacted with the fill color + if changed { + *shared.active_color_mode = super::ColorMode::Fill; + } + }); y += color_button_size + button_spacing; @@ -187,19 +183,15 @@ impl PaneRenderer for ToolbarPane { // Draw stroke color button with checkerboard for alpha draw_color_button(ui, stroke_button_rect, *shared.stroke_color); - if stroke_response.clicked() { - // Open color picker popup - ui.memory_mut(|mem| mem.toggle_popup(stroke_button_id)); - } - - // Show stroke color picker popup - egui::popup::popup_below_widget(ui, stroke_button_id, &stroke_response, egui::popup::PopupCloseBehavior::CloseOnClickOutside, |ui: &mut egui::Ui| { - let changed = egui::color_picker::color_picker_color32(ui, shared.stroke_color, egui::color_picker::Alpha::OnlyBlend); - // Track that the user interacted with the stroke color - if changed { - *shared.active_color_mode = super::ColorMode::Stroke; - } - }); + // Show stroke color picker popup using new Popup API + egui::containers::Popup::from_toggle_button_response(&stroke_response) + .show(|ui| { + let changed = egui::color_picker::color_picker_color32(ui, shared.stroke_color, egui::color_picker::Alpha::OnlyBlend); + // Track that the user interacted with the stroke color + if changed { + *shared.active_color_mode = super::ColorMode::Stroke; + } + }); } fn name(&self) -> &str { diff --git a/src/assets/still-life.jpg b/src/assets/still-life.jpg new file mode 100644 index 0000000000000000000000000000000000000000..431165ec87d9a007cd1c0ffba25afc33fc0b3c99 GIT binary patch literal 65038 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