show previews for effects
This commit is contained in:
parent
c8a5cbfc89
commit
efca9da2c9
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -355,6 +355,7 @@ impl Compositor {
|
|||
load: load_op,
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
depth_slice: None,
|
||||
})],
|
||||
depth_stencil_attachment: None,
|
||||
occlusion_query_set: None,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Uuid, Arc<Image>>,
|
||||
cache: HashMap<Uuid, Arc<ImageBrush>>,
|
||||
}
|
||||
|
||||
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<Arc<Image>> {
|
||||
pub fn get_or_decode(&mut self, asset: &ImageAsset) -> Option<Arc<ImageBrush>> {
|
||||
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<Image> {
|
||||
/// Decode an image asset to peniko ImageBrush
|
||||
fn decode_image_asset(asset: &ImageAsset) -> Option<ImageBrush> {
|
||||
// Get the raw file data
|
||||
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 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<Vec<u8>> to Arc<dyn AsRef<[u8]> + Send + Sync>
|
||||
let blob_data: Arc<dyn AsRef<[u8]> + 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);
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
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<export::ExportOrchestrator>,
|
||||
/// GPU-rendered effect thumbnail generator
|
||||
effect_thumbnail_generator: Option<EffectThumbnailGenerator>,
|
||||
}
|
||||
|
||||
/// 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<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
|
||||
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<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
|
||||
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<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
|
||||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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<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)
|
||||
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")
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
Loading…
Reference in New Issue