show previews for effects

This commit is contained in:
Skyler Lehmkuhl 2025-12-08 13:32:11 -05:00
parent c8a5cbfc89
commit efca9da2c9
16 changed files with 1511 additions and 289 deletions

View File

@ -8,7 +8,7 @@ serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
# UI framework (for Color32 conversion) # UI framework (for Color32 conversion)
egui = "0.31" egui = { workspace = true }
# GPU rendering infrastructure # GPU rendering infrastructure
wgpu = { workspace = true } wgpu = { workspace = true }

View File

@ -147,6 +147,7 @@ impl SrgbToLinearConverter {
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
store: wgpu::StoreOp::Store, store: wgpu::StoreOp::Store,
}, },
depth_slice: None,
})], })],
depth_stencil_attachment: None, depth_stencil_attachment: None,
occlusion_query_set: None, occlusion_query_set: None,

View File

@ -355,6 +355,7 @@ impl Compositor {
load: load_op, load: load_op,
store: wgpu::StoreOp::Store, store: wgpu::StoreOp::Store,
}, },
depth_slice: None,
})], })],
depth_stencil_attachment: None, depth_stencil_attachment: None,
occlusion_query_set: None, occlusion_query_set: None,

View File

@ -293,6 +293,7 @@ impl EffectProcessor {
load: wgpu::LoadOp::Load, load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store, store: wgpu::StoreOp::Store,
}, },
depth_slice: None,
})], })],
depth_stencil_attachment: None, depth_stencil_attachment: None,
occlusion_query_set: None, occlusion_query_set: None,

View File

@ -33,6 +33,8 @@ pub enum PaneType {
PresetBrowser, PresetBrowser,
/// Asset library for browsing clips /// Asset library for browsing clips
AssetLibrary, AssetLibrary,
/// WGSL shader code editor for custom effects
ShaderEditor,
} }
impl PaneType { impl PaneType {
@ -49,6 +51,7 @@ impl PaneType {
PaneType::NodeEditor => "Node Editor", PaneType::NodeEditor => "Node Editor",
PaneType::PresetBrowser => "Preset Browser", PaneType::PresetBrowser => "Preset Browser",
PaneType::AssetLibrary => "Asset Library", PaneType::AssetLibrary => "Asset Library",
PaneType::ShaderEditor => "Shader Editor",
} }
} }
@ -67,6 +70,7 @@ impl PaneType {
PaneType::NodeEditor => "node-editor.svg", PaneType::NodeEditor => "node-editor.svg",
PaneType::PresetBrowser => "stage.svg", // TODO: needs own icon PaneType::PresetBrowser => "stage.svg", // TODO: needs own icon
PaneType::AssetLibrary => "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), "nodeeditor" => Some(PaneType::NodeEditor),
"presetbrowser" => Some(PaneType::PresetBrowser), "presetbrowser" => Some(PaneType::PresetBrowser),
"assetlibrary" => Some(PaneType::AssetLibrary), "assetlibrary" => Some(PaneType::AssetLibrary),
"shadereditor" => Some(PaneType::ShaderEditor),
_ => None, _ => None,
} }
} }
@ -101,6 +106,7 @@ impl PaneType {
PaneType::VirtualPiano, PaneType::VirtualPiano,
PaneType::PresetBrowser, PaneType::PresetBrowser,
PaneType::AssetLibrary, PaneType::AssetLibrary,
PaneType::ShaderEditor,
] ]
} }
@ -117,6 +123,7 @@ impl PaneType {
PaneType::NodeEditor => "nodeEditor", PaneType::NodeEditor => "nodeEditor",
PaneType::PresetBrowser => "presetBrowser", PaneType::PresetBrowser => "presetBrowser",
PaneType::AssetLibrary => "assetLibrary", PaneType::AssetLibrary => "assetLibrary",
PaneType::ShaderEditor => "shaderEditor",
} }
} }
} }

View File

@ -18,12 +18,12 @@ use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use uuid::Uuid; use uuid::Uuid;
use vello::kurbo::Rect; use vello::kurbo::Rect;
use vello::peniko::{Blob, Fill, Image, ImageFormat}; use vello::peniko::{Blob, Fill, ImageAlphaType, ImageBrush, ImageData, ImageFormat};
use vello::Scene; use vello::Scene;
/// Cache for decoded image data to avoid re-decoding every frame /// Cache for decoded image data to avoid re-decoding every frame
pub struct ImageCache { pub struct ImageCache {
cache: HashMap<Uuid, Arc<Image>>, cache: HashMap<Uuid, Arc<ImageBrush>>,
} }
impl ImageCache { impl ImageCache {
@ -35,7 +35,7 @@ impl ImageCache {
} }
/// Get or decode an image, caching the result /// Get or decode an image, caching the result
pub fn get_or_decode(&mut self, asset: &ImageAsset) -> Option<Arc<Image>> { pub fn get_or_decode(&mut self, asset: &ImageAsset) -> Option<Arc<ImageBrush>> {
if let Some(cached) = self.cache.get(&asset.id) { if let Some(cached) = self.cache.get(&asset.id) {
return Some(Arc::clone(cached)); return Some(Arc::clone(cached));
} }
@ -64,8 +64,8 @@ impl Default for ImageCache {
} }
} }
/// Decode an image asset to peniko Image /// Decode an image asset to peniko ImageBrush
fn decode_image_asset(asset: &ImageAsset) -> Option<Image> { fn decode_image_asset(asset: &ImageAsset) -> Option<ImageBrush> {
// Get the raw file data // Get the raw file data
let data = asset.data.as_ref()?; let data = asset.data.as_ref()?;
@ -73,13 +73,15 @@ fn decode_image_asset(asset: &ImageAsset) -> Option<Image> {
let img = image::load_from_memory(data).ok()?; let img = image::load_from_memory(data).ok()?;
let rgba = img.to_rgba8(); let rgba = img.to_rgba8();
// Create peniko Image // Create peniko ImageData then ImageBrush
Some(Image::new( let image_data = ImageData {
Blob::from(rgba.into_raw()), data: Blob::from(rgba.into_raw()),
ImageFormat::Rgba8, format: ImageFormat::Rgba8,
asset.width, width: asset.width,
asset.height, 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 // Cascade opacity: layer_opacity × animated opacity
let final_opacity = (layer_opacity * opacity) as f32; 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<Vec<u8>> to Arc<dyn AsRef<[u8]> + Send + Sync> // Coerce Arc<Vec<u8>> to Arc<dyn AsRef<[u8]> + Send + Sync>
let blob_data: Arc<dyn AsRef<[u8]> + Send + Sync> = frame.rgba_data.clone(); let blob_data: Arc<dyn AsRef<[u8]> + Send + Sync> = frame.rgba_data.clone();
let image = Image::new( let image_data = ImageData {
vello::peniko::Blob::new(blob_data), data: Blob::new(blob_data),
vello::peniko::ImageFormat::Rgba8, format: ImageFormat::Rgba8,
frame.width, width: frame.width,
frame.height, height: frame.height,
); alpha_type: ImageAlphaType::Alpha,
};
let image = ImageBrush::new(image_data);
// Apply opacity // Apply opacity
let image_with_alpha = image.with_alpha(final_opacity); let image_with_alpha = image.with_alpha(final_opacity);

View File

@ -14,6 +14,7 @@ ffmpeg-next = { version = "8.0", features = ["static"] }
eframe = { workspace = true } eframe = { workspace = true }
egui_extras = { workspace = true } egui_extras = { workspace = true }
egui-wgpu = { workspace = true } egui-wgpu = { workspace = true }
egui_code_editor = { workspace = true }
# GPU # GPU
wgpu = { workspace = true } wgpu = { workspace = true }

View File

@ -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<Uuid, Vec<u8>>,
/// Effects that need thumbnail generation
pending_effects: Vec<Uuid>,
}
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<u8> {
// 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<u8>> {
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<Uuid, EffectDefinition>,
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<Vec<u8>> {
// 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<Uuid, Vec<u8>> {
&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);
}
}
}

View File

@ -621,7 +621,7 @@ pub fn render_frame_to_rgba(
sender.send(result).ok(); sender.send(result).ok();
}); });
device.poll(wgpu::Maintain::Wait); let _ = device.poll(wgpu::PollType::wait_indefinitely());
receiver receiver
.recv() .recv()
@ -926,6 +926,7 @@ pub fn render_frame_to_rgba_hdr(
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store, store: wgpu::StoreOp::Store,
}, },
depth_slice: None,
})], })],
depth_stencil_attachment: None, depth_stencil_attachment: None,
occlusion_query_set: None, occlusion_query_set: None,
@ -990,7 +991,7 @@ pub fn render_frame_to_rgba_hdr(
sender.send(result).ok(); sender.send(result).ok();
}); });
device.poll(wgpu::Maintain::Wait); let _ = device.poll(wgpu::PollType::wait_indefinitely());
receiver receiver
.recv() .recv()

View File

@ -28,6 +28,9 @@ mod default_instrument;
mod export; mod export;
mod effect_thumbnails;
use effect_thumbnails::EffectThumbnailGenerator;
/// Lightningbeam Editor - Animation and video editing software /// Lightningbeam Editor - Animation and video editing software
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
#[command(name = "Lightningbeam Editor")] #[command(name = "Lightningbeam Editor")]
@ -535,6 +538,10 @@ struct EditorApp {
is_playing: bool, // Whether playback is currently active (transient - don't save) is_playing: bool, // Whether playback is currently active (transient - don't save)
// Asset drag-and-drop state // Asset drag-and-drop state
dragging_asset: Option<panes::DraggingAsset>, // Asset being dragged from Asset Library dragging_asset: Option<panes::DraggingAsset>, // Asset being dragged from Asset Library
// Shader editor inter-pane communication
effect_to_load: Option<Uuid>, // 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<Uuid>,
// Import dialog state // Import dialog state
last_import_filter: ImportFilter, // Last used import filter (remembered across imports) last_import_filter: ImportFilter, // Last used import filter (remembered across imports)
// Tool-specific options (displayed in infopanel) // Tool-specific options (displayed in infopanel)
@ -582,6 +589,8 @@ struct EditorApp {
export_progress_dialog: export::dialog::ExportProgressDialog, export_progress_dialog: export::dialog::ExportProgressDialog,
/// Export orchestrator for background exports /// Export orchestrator for background exports
export_orchestrator: Option<export::ExportOrchestrator>, export_orchestrator: Option<export::ExportOrchestrator>,
/// GPU-rendered effect thumbnail generator
effect_thumbnail_generator: Option<EffectThumbnailGenerator>,
} }
/// Import filter types for the file dialog /// Import filter types for the file dialog
@ -709,6 +718,8 @@ impl EditorApp {
playback_time: 0.0, // Start at beginning playback_time: 0.0, // Start at beginning
is_playing: false, // Start paused is_playing: false, // Start paused
dragging_asset: None, // No asset being dragged initially 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" last_import_filter: ImportFilter::default(), // Default to "All Supported"
stroke_width: 3.0, // Default stroke width stroke_width: 3.0, // Default stroke width
fill_enabled: true, // Default to filling shapes fill_enabled: true, // Default to filling shapes
@ -729,6 +740,7 @@ impl EditorApp {
export_dialog: export::dialog::ExportDialog::default(), export_dialog: export::dialog::ExportDialog::default(),
export_progress_dialog: export::dialog::ExportProgressDialog::default(), export_progress_dialog: export::dialog::ExportProgressDialog::default(),
export_orchestrator: None, 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); 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<Uuid, lightningbeam_core::effect::EffectDefinition> = 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 // Handle file operation progress
if let Some(ref mut operation) = self.file_operation { if let Some(ref mut operation) = self.file_operation {
// Set wait cursor // Set wait cursor
@ -2810,6 +2867,11 @@ impl eframe::App for EditorApp {
// Registry for actions to execute after rendering (two-phase dispatch) // Registry for actions to execute after rendering (two-phase dispatch)
let mut pending_actions: Vec<Box<dyn lightningbeam_core::action::Action>> = Vec::new(); let mut pending_actions: Vec<Box<dyn lightningbeam_core::action::Action>> = Vec::new();
// Queue for effect thumbnail requests (collected during rendering)
let mut effect_thumbnail_requests: Vec<Uuid> = Vec::new();
// Empty cache fallback if generator not initialized
let empty_thumbnail_cache: HashMap<Uuid, Vec<u8>> = HashMap::new();
// Create render context // Create render context
let mut ctx = RenderContext { let mut ctx = RenderContext {
tool_icon_cache: &mut self.tool_icon_cache, 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_chunk_cache: &self.waveform_chunk_cache,
waveform_image_cache: &mut self.waveform_image_cache, waveform_image_cache: &mut self.waveform_image_cache,
audio_pools_with_new_waveforms: &self.audio_pools_with_new_waveforms, 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( render_layout_node(
@ -2861,6 +2929,14 @@ impl eframe::App for EditorApp {
&mut ctx, &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) // Execute action on the best handler (two-phase dispatch)
if let Some(action) = &self.pending_view_action { if let Some(action) = &self.pending_view_action {
if let Some(best_handler) = pending_handlers.iter().min_by_key(|h| h.priority) { 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, waveform_image_cache: &'a mut waveform_image_cache::WaveformImageCache,
/// Audio pool indices with new waveform data this frame (for thumbnail invalidation) /// Audio pool indices with new waveform data this frame (for thumbnail invalidation)
audio_pools_with_new_waveforms: &'a HashSet<usize>, audio_pools_with_new_waveforms: &'a HashSet<usize>,
/// Effect ID to load into shader editor (set by asset library, consumed by shader editor)
effect_to_load: &'a mut Option<Uuid>,
/// Queue for effect thumbnail requests
effect_thumbnail_requests: &'a mut Vec<Uuid>,
/// Cache of generated effect thumbnails
effect_thumbnail_cache: &'a HashMap<Uuid, Vec<u8>>,
/// Effect IDs whose thumbnails should be invalidated
effect_thumbnails_to_invalidate: &'a mut Vec<Uuid>,
} }
/// Recursively render a layout node with drag support /// Recursively render a layout node with drag support
@ -3400,13 +3484,9 @@ fn render_pane(
); );
} }
// Show pane type selector menu on left click // Show pane type selector menu on left click using new Popup API
let menu_id = ui.id().with(("pane_type_menu", path)); egui::containers::Popup::menu(&icon_response)
if icon_response.clicked() { .show(|ui| {
ui.memory_mut(|mem| mem.toggle_popup(menu_id));
}
egui::popup::popup_below_widget(ui, menu_id, &icon_response, egui::PopupCloseBehavior::CloseOnClickOutside, |ui| {
ui.set_min_width(200.0); ui.set_min_width(200.0);
ui.label("Select Pane Type:"); ui.label("Select Pane Type:");
ui.separator(); ui.separator();
@ -3426,7 +3506,6 @@ fn render_pane(
pane_type_option.display_name() pane_type_option.display_name()
).clicked() { ).clicked() {
*pane_name = pane_type_option.to_name().to_string(); *pane_name = pane_type_option.to_name().to_string();
ui.memory_mut(|mem| mem.close_popup());
} }
}); });
} else { } else {
@ -3436,7 +3515,6 @@ fn render_pane(
pane_type_option.display_name() pane_type_option.display_name()
).clicked() { ).clicked() {
*pane_name = pane_type_option.to_name().to_string(); *pane_name = pane_type_option.to_name().to_string();
ui.memory_mut(|mem| mem.close_popup());
} }
} }
} }
@ -3512,6 +3590,10 @@ fn render_pane(
waveform_chunk_cache: ctx.waveform_chunk_cache, waveform_chunk_cache: ctx.waveform_chunk_cache,
waveform_image_cache: ctx.waveform_image_cache, waveform_image_cache: ctx.waveform_image_cache,
audio_pools_with_new_waveforms: ctx.audio_pools_with_new_waveforms, 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); pane_instance.render_header(&mut header_ui, &mut shared);
} }
@ -3571,6 +3653,10 @@ fn render_pane(
waveform_chunk_cache: ctx.waveform_chunk_cache, waveform_chunk_cache: ctx.waveform_chunk_cache,
waveform_image_cache: ctx.waveform_image_cache, waveform_image_cache: ctx.waveform_image_cache,
audio_pools_with_new_waveforms: ctx.audio_pools_with_new_waveforms, 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) // 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::NodeEditor => egui::Color32::from_rgb(30, 45, 50),
PaneType::PresetBrowser => egui::Color32::from_rgb(50, 45, 30), PaneType::PresetBrowser => egui::Color32::from_rgb(50, 45, 30),
PaneType::AssetLibrary => egui::Color32::from_rgb(45, 50, 35), PaneType::AssetLibrary => egui::Color32::from_rgb(45, 50, 35),
PaneType::ShaderEditor => egui::Color32::from_rgb(35, 30, 55),
} }
} }

View File

@ -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<Vec<(f32, f32)>> =
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 /// Render assets based on current view mode
fn render_assets( fn render_assets(
&mut self, &mut self,
@ -1244,7 +1492,60 @@ impl AssetLibraryPane {
.show(ui, |ui| { .show(ui, |ui| {
ui.set_min_width(scroll_area_rect.width() - 16.0); // Account for scrollbar 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( let (item_rect, response) = ui.allocate_exact_size(
egui::vec2(ui.available_width(), ITEM_HEIGHT), egui::vec2(ui.available_width(), ITEM_HEIGHT),
egui::Sense::click_and_drag(), egui::Sense::click_and_drag(),
@ -1413,16 +1714,40 @@ impl AssetLibraryPane {
} }
} }
AssetCategory::Effects => { 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, AssetCategory::All => None,
} }
}); });
// Either use cached texture or render placeholder directly for effects
if let Some(texture) = texture { if let Some(texture) = texture {
let image = egui::Image::new(texture) let image = egui::Image::new(texture)
.fit_to_exact_size(egui::vec2(LIST_THUMBNAIL_SIZE, LIST_THUMBNAIL_SIZE)); .fit_to_exact_size(egui::vec2(LIST_THUMBNAIL_SIZE, LIST_THUMBNAIL_SIZE));
ui.put(thumbnail_rect, image); 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) // Handle click (selection)
@ -1440,14 +1765,20 @@ impl AssetLibraryPane {
} }
} }
// Handle double-click (start rename) // Handle double-click
if response.double_clicked() { if response.double_clicked() {
// 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 { self.rename_state = Some(RenameState {
asset_id: asset.id, asset_id: asset.id,
category: asset.category, category: asset.category,
edit_text: asset.name.clone(), edit_text: asset.name.clone(),
}); });
} }
}
// Handle drag start // Handle drag start
if response.drag_started() { if response.drag_started() {
@ -1568,219 +1899,74 @@ impl AssetLibraryPane {
.floor() .floor()
.max(1.0) as usize; .max(1.0) as usize;
let item_height = GRID_ITEM_SIZE + 20.0; // 20 for name below thumbnail 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| { ui.allocate_new_ui(egui::UiBuilder::new().max_rect(rect), |ui| {
egui::ScrollArea::vertical() egui::ScrollArea::vertical()
.id_salt(("asset_grid_scroll", path)) .id_salt(("asset_grid_scroll", path))
.auto_shrink([false, false]) .auto_shrink([false, false])
.show(ui, |ui| { .show(ui, |ui| {
// Reserve space for the entire grid ui.set_min_width(content_width);
let (grid_rect, _) = ui.allocate_exact_size(
egui::vec2(content_width, total_height), // Render built-in section header
if show_effects_sections && builtin_count > 0 {
Self::render_section_header(ui, "Built-in Effects", secondary_text_color);
}
// 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);
}
// 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(), egui::Sense::hover(),
); ).0;
ui.painter().rect_filled(separator_rect, 0.0, egui::Color32::from_gray(60));
for (idx, asset) in assets.iter().enumerate() { ui.add_space(8.0);
let col = idx % columns;
let row = idx / columns;
// 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);
let item_rect = egui::Rect::from_min_size(
egui::pos2(item_x, item_y),
egui::vec2(GRID_ITEM_SIZE, item_height),
);
// Allocate the response for this item
let response = ui.allocate_rect(item_rect, 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 (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<Vec<(f32, f32)>> =
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) // Render custom section header
let indicator_rect = egui::Rect::from_min_size( if show_effects_sections && custom_count > 0 {
egui::pos2(thumbnail_rect.min.x, thumbnail_rect.max.y - 3.0), Self::render_section_header(ui, "Custom Effects", secondary_text_color);
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) // Second pass: render custom items
if response.secondary_clicked() { let custom_items: Vec<_> = assets_to_render.iter().filter(|a| !a.is_builtin).copied().collect();
if let Some(pos) = ui.ctx().pointer_interact_pos() { if !custom_items.is_empty() {
self.context_menu = Some(ContextMenuState { self.render_grid_items(ui, &custom_items, columns, item_height, content_width, shared, document, text_color, secondary_text_color);
asset_id: asset.id,
position: pos,
});
}
} }
// Handle double-click (start rename) // For non-Effects tabs, just render all items
if response.double_clicked() { if !show_effects_sections {
self.rename_state = Some(RenameState { self.render_grid_items(ui, assets_to_render, columns, item_height, content_width, shared, document, text_color, secondary_text_color);
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,
});
}
} }
}); });
}); });
@ -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 // Collect and filter assets
let all_assets = self.collect_assets(&document_arc); let all_assets = self.collect_assets(&document_arc);
let filtered_assets = self.filter_assets(&all_assets); let filtered_assets = self.filter_assets(&all_assets);
@ -1905,6 +2101,15 @@ impl PaneRenderer for AssetLibraryPane {
egui::Frame::popup(ui.style()).show(ui, |ui| { egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.set_min_width(120.0); 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 // Built-in effects cannot be renamed or deleted
if asset_is_builtin { if asset_is_builtin {
ui.label(egui::RichText::new("Built-in effect") ui.label(egui::RichText::new("Built-in effect")

View File

@ -64,6 +64,7 @@ pub mod virtual_piano;
pub mod node_editor; pub mod node_editor;
pub mod preset_browser; pub mod preset_browser;
pub mod asset_library; pub mod asset_library;
pub mod shader_editor;
/// Which color mode is active for the eyedropper tool /// Which color mode is active for the eyedropper tool
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[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, pub waveform_image_cache: &'a mut crate::waveform_image_cache::WaveformImageCache,
/// Audio pool indices that got new waveform data this frame (for thumbnail invalidation) /// Audio pool indices that got new waveform data this frame (for thumbnail invalidation)
pub audio_pools_with_new_waveforms: &'a std::collections::HashSet<usize>, pub audio_pools_with_new_waveforms: &'a std::collections::HashSet<usize>,
/// Effect ID to load into shader editor (set by asset library, consumed by shader editor)
pub effect_to_load: &'a mut Option<Uuid>,
/// Queue for effect thumbnail requests (effect IDs to generate thumbnails for)
pub effect_thumbnail_requests: &'a mut Vec<Uuid>,
/// Cache of generated effect thumbnails (effect_id -> RGBA data)
pub effect_thumbnail_cache: &'a std::collections::HashMap<Uuid, Vec<u8>>,
/// Effect IDs whose thumbnails should be invalidated (e.g., after shader edit)
pub effect_thumbnails_to_invalidate: &'a mut Vec<Uuid>,
} }
/// Trait for pane rendering /// Trait for pane rendering
@ -231,6 +240,7 @@ pub enum PaneInstance {
NodeEditor(node_editor::NodeEditorPane), NodeEditor(node_editor::NodeEditorPane),
PresetBrowser(preset_browser::PresetBrowserPane), PresetBrowser(preset_browser::PresetBrowserPane),
AssetLibrary(asset_library::AssetLibraryPane), AssetLibrary(asset_library::AssetLibraryPane),
ShaderEditor(shader_editor::ShaderEditorPane),
} }
impl PaneInstance { impl PaneInstance {
@ -251,6 +261,9 @@ impl PaneInstance {
PaneType::AssetLibrary => { PaneType::AssetLibrary => {
PaneInstance::AssetLibrary(asset_library::AssetLibraryPane::new()) 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::NodeEditor(_) => PaneType::NodeEditor,
PaneInstance::PresetBrowser(_) => PaneType::PresetBrowser, PaneInstance::PresetBrowser(_) => PaneType::PresetBrowser,
PaneInstance::AssetLibrary(_) => PaneType::AssetLibrary, 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::NodeEditor(p) => p.render_header(ui, shared),
PaneInstance::PresetBrowser(p) => p.render_header(ui, shared), PaneInstance::PresetBrowser(p) => p.render_header(ui, shared),
PaneInstance::AssetLibrary(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::NodeEditor(p) => p.render_content(ui, rect, path, shared),
PaneInstance::PresetBrowser(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::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::NodeEditor(p) => p.name(),
PaneInstance::PresetBrowser(p) => p.name(), PaneInstance::PresetBrowser(p) => p.name(),
PaneInstance::AssetLibrary(p) => p.name(), PaneInstance::AssetLibrary(p) => p.name(),
PaneInstance::ShaderEditor(p) => p.name(),
} }
} }
} }

View File

@ -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<f32> color at each pixel
@group(0) @binding(0) var source_tex: texture_2d<f32>;
@group(0) @binding(1) var source_sampler: sampler;
struct VertexOutput {
@builtin(position) position: vec4<f32>,
@location(0) uv: vec2<f32>,
}
// 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<f32>(x * 2.0 - 1.0, 1.0 - y * 2.0, 0.0, 1.0);
out.uv = vec2<f32>(x, y);
return out;
}
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
// 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<f32>;
@group(0) @binding(1) var source_sampler: sampler;
struct VertexOutput {
@builtin(position) position: vec4<f32>,
@location(0) uv: vec2<f32>,
}
@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<f32>(x * 2.0 - 1.0, 1.0 - y * 2.0, 0.0, 1.0);
out.uv = vec2<f32>(x, y);
return out;
}
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
let color = textureSample(source_tex, source_sampler, in.uv);
// ITU-R BT.709 luminance coefficients
let luminance = dot(color.rgb, vec3<f32>(0.2126, 0.7152, 0.0722));
return vec4<f32>(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<f32>;
@group(0) @binding(1) var source_sampler: sampler;
struct VertexOutput {
@builtin(position) position: vec4<f32>,
@location(0) uv: vec2<f32>,
}
@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<f32>(x * 2.0 - 1.0, 1.0 - y * 2.0, 0.0, 1.0);
out.uv = vec2<f32>(x, y);
return out;
}
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
let color = textureSample(source_tex, source_sampler, in.uv);
// Calculate distance from center (0.5, 0.5)
let center = vec2<f32>(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<f32>(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<String>,
/// Name for the shader/effect
shader_name: String,
/// ID of effect being edited (None = new effect)
editing_effect_id: Option<Uuid>,
/// Original code when effect was loaded (for dirty checking)
original_code: Option<String>,
/// Original name when effect was loaded (for dirty checking)
original_name: Option<String>,
/// Effect awaiting confirmation to load (when there are unsaved changes)
pending_load_effect: Option<EffectDefinition>,
/// 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<EffectDefinition> {
// 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<UnsavedDialogResult> {
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"
}
}

View File

@ -1740,6 +1740,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store, store: wgpu::StoreOp::Store,
}, },
depth_slice: None,
})], })],
depth_stencil_attachment: None, depth_stencil_attachment: None,
timestamp_writes: None, timestamp_writes: None,
@ -1828,7 +1829,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
}); });
// Poll the device to complete the mapping // Poll the device to complete the mapping
device.poll(wgpu::Maintain::Wait); let _ = device.poll(wgpu::PollType::wait_indefinitely());
// Read the pixel data // Read the pixel data
if receiver.recv().is_ok() { if receiver.recv().is_ok() {

View File

@ -151,13 +151,9 @@ impl PaneRenderer for ToolbarPane {
// Draw fill color button with checkerboard for alpha // Draw fill color button with checkerboard for alpha
draw_color_button(ui, fill_button_rect, *shared.fill_color); draw_color_button(ui, fill_button_rect, *shared.fill_color);
if fill_response.clicked() { // Show fill color picker popup using new Popup API
// Open color picker popup egui::containers::Popup::from_toggle_button_response(&fill_response)
ui.memory_mut(|mem| mem.toggle_popup(fill_button_id)); .show(|ui| {
}
// 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); 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 // Track that the user interacted with the fill color
if changed { if changed {
@ -187,13 +183,9 @@ impl PaneRenderer for ToolbarPane {
// Draw stroke color button with checkerboard for alpha // Draw stroke color button with checkerboard for alpha
draw_color_button(ui, stroke_button_rect, *shared.stroke_color); draw_color_button(ui, stroke_button_rect, *shared.stroke_color);
if stroke_response.clicked() { // Show stroke color picker popup using new Popup API
// Open color picker popup egui::containers::Popup::from_toggle_button_response(&stroke_response)
ui.memory_mut(|mem| mem.toggle_popup(stroke_button_id)); .show(|ui| {
}
// 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); 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 // Track that the user interacted with the stroke color
if changed { if changed {

BIN
src/assets/still-life.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB