diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/add_clip_instance.rs b/lightningbeam-ui/lightningbeam-core/src/actions/add_clip_instance.rs index 5bcaa41..f149662 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/add_clip_instance.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/add_clip_instance.rs @@ -98,6 +98,9 @@ impl Action for AddClipInstanceAction { AnyLayer::Video(video_layer) => { video_layer.clip_instances.push(self.clip_instance.clone()); } + AnyLayer::Effect(_) => { + return Err("Cannot add clip instances to effect layers".to_string()); + } } self.executed = true; @@ -130,6 +133,9 @@ impl Action for AddClipInstanceAction { .clip_instances .retain(|ci| ci.id != instance_id); } + AnyLayer::Effect(_) => { + // Effect layers don't have clip instances, nothing to rollback + } } self.executed = false; diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/add_effect.rs b/lightningbeam-ui/lightningbeam-core/src/actions/add_effect.rs new file mode 100644 index 0000000..9df7fca --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/actions/add_effect.rs @@ -0,0 +1,232 @@ +//! Add effect action +//! +//! Handles adding a new effect instance (as a ClipInstance) to an effect layer. + +use crate::action::Action; +use crate::clip::ClipInstance; +use crate::document::Document; +use crate::layer::AnyLayer; +use uuid::Uuid; + +/// Action that adds an effect instance to an effect layer +/// +/// Effect instances are represented as ClipInstance objects where clip_id +/// references an EffectDefinition. +pub struct AddEffectAction { + /// ID of the layer to add the effect to + layer_id: Uuid, + /// The clip instance (effect) to add + instance: Option, + /// Index to insert at (None = append to end) + insert_index: Option, + /// ID of the created effect (set after execution) + created_effect_id: Option, +} + +impl AddEffectAction { + /// Create a new add effect action + /// + /// # Arguments + /// + /// * `layer_id` - ID of the effect layer to add the effect to + /// * `instance` - The clip instance (referencing an effect definition) to add + pub fn new(layer_id: Uuid, instance: ClipInstance) -> Self { + Self { + layer_id, + instance: Some(instance), + insert_index: None, + created_effect_id: None, + } + } + + /// Create a new add effect action that inserts at a specific index + /// + /// # Arguments + /// + /// * `layer_id` - ID of the effect layer to add the effect to + /// * `instance` - The clip instance (referencing an effect definition) to add + /// * `index` - Index to insert at + pub fn at_index(layer_id: Uuid, instance: ClipInstance, index: usize) -> Self { + Self { + layer_id, + instance: Some(instance), + insert_index: Some(index), + created_effect_id: None, + } + } + + /// Get the ID of the created effect (after execution) + pub fn created_effect_id(&self) -> Option { + self.created_effect_id + } + + /// Get the layer ID this effect was added to + pub fn layer_id(&self) -> Uuid { + self.layer_id + } +} + +impl Action for AddEffectAction { + fn execute(&mut self, document: &mut Document) -> Result<(), String> { + // Take the instance (can only execute once without rollback) + let instance = self.instance.take() + .ok_or_else(|| "Effect already added (call rollback first)".to_string())?; + + // Store the instance ID + let instance_id = instance.id; + + // Find the effect layer + let layer = document.get_layer_mut(&self.layer_id) + .ok_or_else(|| format!("Layer {} not found", self.layer_id))?; + + // Ensure it's an effect layer + let effect_layer = match layer { + AnyLayer::Effect(ref mut el) => el, + _ => return Err("Layer is not an effect layer".to_string()), + }; + + // Add or insert the effect + match self.insert_index { + Some(index) => { + effect_layer.insert_clip_instance(index, instance); + } + None => { + effect_layer.add_clip_instance(instance); + } + } + + self.created_effect_id = Some(instance_id); + Ok(()) + } + + fn rollback(&mut self, document: &mut Document) -> Result<(), String> { + let instance_id = self.created_effect_id + .ok_or_else(|| "No effect to remove (not executed yet)".to_string())?; + + // Find the effect layer + let layer = document.get_layer_mut(&self.layer_id) + .ok_or_else(|| format!("Layer {} not found", self.layer_id))?; + + // Ensure it's an effect layer + let effect_layer = match layer { + AnyLayer::Effect(ref mut el) => el, + _ => return Err("Layer is not an effect layer".to_string()), + }; + + // Remove the instance and store it for potential re-execution + let removed = effect_layer.remove_clip_instance(&instance_id) + .ok_or_else(|| format!("Effect instance {} not found", instance_id))?; + + // Store the instance back for potential redo + self.instance = Some(removed); + self.created_effect_id = None; + + Ok(()) + } + + fn description(&self) -> String { + "Add effect".to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::effect::{EffectCategory, EffectDefinition, EffectParameterDef}; + use crate::effect_layer::EffectLayer; + use crate::layer::AnyLayer; + + fn create_test_setup() -> (Document, Uuid, EffectDefinition) { + let mut document = Document::new("Test"); + + // Create effect layer + let effect_layer = EffectLayer::new("Effects"); + let layer_id = effect_layer.layer.id; + document.root_mut().add_child(AnyLayer::Effect(effect_layer)); + + // Create effect definition + let def = EffectDefinition::new( + "Test Effect", + EffectCategory::Color, + "// shader code", + vec![EffectParameterDef::float_range("intensity", "Intensity", 1.0, 0.0, 2.0)], + ); + + (document, layer_id, def) + } + + #[test] + fn test_add_effect() { + let (mut document, layer_id, def) = create_test_setup(); + + let instance = def.create_instance(0.0, 10.0); + let instance_id = instance.id; + + let mut action = AddEffectAction::new(layer_id, instance); + action.execute(&mut document).unwrap(); + + // Verify effect was added + assert_eq!(action.created_effect_id(), Some(instance_id)); + + let layer = document.get_layer(&layer_id).unwrap(); + if let AnyLayer::Effect(el) = layer { + assert_eq!(el.clip_instances.len(), 1); + assert_eq!(el.clip_instances[0].id, instance_id); + } else { + panic!("Expected effect layer"); + } + } + + #[test] + fn test_add_effect_rollback() { + let (mut document, layer_id, def) = create_test_setup(); + + let instance = def.create_instance(0.0, 10.0); + + let mut action = AddEffectAction::new(layer_id, instance); + action.execute(&mut document).unwrap(); + action.rollback(&mut document).unwrap(); + + // Verify effect was removed + let layer = document.get_layer(&layer_id).unwrap(); + if let AnyLayer::Effect(el) = layer { + assert_eq!(el.clip_instances.len(), 0); + } else { + panic!("Expected effect layer"); + } + } + + #[test] + fn test_add_effect_at_index() { + let (mut document, layer_id, def) = create_test_setup(); + + // Add first effect + let instance1 = def.create_instance(0.0, 10.0); + let id1 = instance1.id; + let mut action1 = AddEffectAction::new(layer_id, instance1); + action1.execute(&mut document).unwrap(); + + // Add second effect + let instance2 = def.create_instance(0.0, 10.0); + let id2 = instance2.id; + let mut action2 = AddEffectAction::new(layer_id, instance2); + action2.execute(&mut document).unwrap(); + + // Insert third effect at index 1 (between first and second) + let instance3 = def.create_instance(0.0, 10.0); + let id3 = instance3.id; + let mut action3 = AddEffectAction::at_index(layer_id, instance3, 1); + action3.execute(&mut document).unwrap(); + + // Verify order: [id1, id3, id2] + let layer = document.get_layer(&layer_id).unwrap(); + if let AnyLayer::Effect(el) = layer { + assert_eq!(el.clip_instances.len(), 3); + assert_eq!(el.clip_instances[0].id, id1); + assert_eq!(el.clip_instances[1].id, id3); + assert_eq!(el.clip_instances[2].id, id2); + } else { + panic!("Expected effect layer"); + } + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/add_layer.rs b/lightningbeam-ui/lightningbeam-core/src/actions/add_layer.rs index 296713c..e018c23 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/add_layer.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/add_layer.rs @@ -76,6 +76,7 @@ impl Action for AddLayerAction { AnyLayer::Vector(_) => "Add vector layer", AnyLayer::Audio(_) => "Add audio layer", AnyLayer::Video(_) => "Add video layer", + AnyLayer::Effect(_) => "Add effect layer", } .to_string() } diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs b/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs index 5dccc31..6c4aee6 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs @@ -4,11 +4,13 @@ //! through the action system. pub mod add_clip_instance; +pub mod add_effect; pub mod add_layer; pub mod add_shape; pub mod move_clip_instances; pub mod move_objects; pub mod paint_bucket; +pub mod remove_effect; pub mod set_document_properties; pub mod set_instance_properties; pub mod set_layer_properties; @@ -18,11 +20,13 @@ pub mod transform_objects; pub mod trim_clip_instances; pub use add_clip_instance::AddClipInstanceAction; +pub use add_effect::AddEffectAction; pub use add_layer::AddLayerAction; pub use add_shape::AddShapeAction; pub use move_clip_instances::MoveClipInstancesAction; pub use move_objects::MoveShapeInstancesAction; pub use paint_bucket::PaintBucketAction; +pub use remove_effect::RemoveEffectAction; pub use set_document_properties::SetDocumentPropertiesAction; pub use set_instance_properties::{InstancePropertyChange, SetInstancePropertiesAction}; pub use set_layer_properties::{LayerProperty, SetLayerPropertiesAction}; diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/move_clip_instances.rs b/lightningbeam-ui/lightningbeam-core/src/actions/move_clip_instances.rs index 57cead7..c61ea96 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/move_clip_instances.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/move_clip_instances.rs @@ -3,6 +3,7 @@ //! Handles moving one or more clip instances along the timeline. use crate::action::Action; +use crate::clip::ClipInstance; use crate::document::Document; use crate::layer::AnyLayer; use std::collections::HashMap; @@ -50,10 +51,11 @@ impl Action for MoveClipInstancesAction { // Find member's current position if let Some(layer) = document.get_layer(member_layer_id) { - let clip_instances = match layer { + let clip_instances: &[ClipInstance] = match layer { AnyLayer::Vector(vl) => &vl.clip_instances, AnyLayer::Audio(al) => &al.clip_instances, AnyLayer::Video(vl) => &vl.clip_instances, + AnyLayer::Effect(_) => continue, // Effect layers don't have clip instances }; if let Some(instance) = clip_instances.iter().find(|ci| ci.id == *member_instance_id) { @@ -88,10 +90,11 @@ impl Action for MoveClipInstancesAction { for (instance_id, old_start, new_start) in moves { // Get the instance to calculate its duration - let clip_instances = match layer { + let clip_instances: &[ClipInstance] = match layer { AnyLayer::Audio(al) => &al.clip_instances, AnyLayer::Video(vl) => &vl.clip_instances, AnyLayer::Vector(vl) => &vl.clip_instances, + AnyLayer::Effect(_) => continue, // Effect layers don't have clip instances }; let instance = clip_instances.iter() @@ -138,6 +141,7 @@ impl Action for MoveClipInstancesAction { AnyLayer::Vector(vl) => &mut vl.clip_instances, AnyLayer::Audio(al) => &mut al.clip_instances, AnyLayer::Video(vl) => &mut vl.clip_instances, + AnyLayer::Effect(_) => continue, // Effect layers don't have clip instances }; // Update timeline_start for each clip instance @@ -162,6 +166,7 @@ impl Action for MoveClipInstancesAction { AnyLayer::Vector(vl) => &mut vl.clip_instances, AnyLayer::Audio(al) => &mut al.clip_instances, AnyLayer::Video(vl) => &mut vl.clip_instances, + AnyLayer::Effect(_) => continue, // Effect layers don't have clip instances }; // Restore original timeline_start for each clip instance diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/remove_effect.rs b/lightningbeam-ui/lightningbeam-core/src/actions/remove_effect.rs new file mode 100644 index 0000000..4aeb5fb --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/actions/remove_effect.rs @@ -0,0 +1,223 @@ +//! Remove effect action +//! +//! Handles removing an effect instance (ClipInstance) from an effect layer. + +use crate::action::Action; +use crate::clip::ClipInstance; +use crate::document::Document; +use crate::layer::AnyLayer; +use uuid::Uuid; + +/// Action that removes an effect instance from an effect layer +pub struct RemoveEffectAction { + /// ID of the layer containing the effect + layer_id: Uuid, + /// ID of the effect instance to remove + instance_id: Uuid, + /// The removed instance (stored for undo) + removed_instance: Option, + /// Index where the instance was (for proper undo position) + removed_index: Option, +} + +impl RemoveEffectAction { + /// Create a new remove effect action + /// + /// # Arguments + /// + /// * `layer_id` - ID of the effect layer containing the effect + /// * `instance_id` - ID of the clip instance to remove + pub fn new(layer_id: Uuid, instance_id: Uuid) -> Self { + Self { + layer_id, + instance_id, + removed_instance: None, + removed_index: None, + } + } + + /// Get the layer ID + pub fn layer_id(&self) -> Uuid { + self.layer_id + } + + /// Get the instance ID that was/will be removed + pub fn instance_id(&self) -> Uuid { + self.instance_id + } +} + +impl Action for RemoveEffectAction { + fn execute(&mut self, document: &mut Document) -> Result<(), String> { + // Find the effect layer + let layer = document.get_layer_mut(&self.layer_id) + .ok_or_else(|| format!("Layer {} not found", self.layer_id))?; + + // Ensure it's an effect layer + let effect_layer = match layer { + AnyLayer::Effect(ref mut el) => el, + _ => return Err("Layer is not an effect layer".to_string()), + }; + + // Find the index before removing + let index = effect_layer.clip_instance_index(&self.instance_id) + .ok_or_else(|| format!("Effect instance {} not found", self.instance_id))?; + + // Remove the instance + let removed = effect_layer.remove_clip_instance(&self.instance_id) + .ok_or_else(|| format!("Effect instance {} not found", self.instance_id))?; + + // Store for undo + self.removed_instance = Some(removed); + self.removed_index = Some(index); + + Ok(()) + } + + fn rollback(&mut self, document: &mut Document) -> Result<(), String> { + let instance = self.removed_instance.take() + .ok_or_else(|| "No instance to restore (not executed yet)".to_string())?; + let index = self.removed_index + .ok_or_else(|| "No index stored (not executed yet)".to_string())?; + + // Find the effect layer + let layer = document.get_layer_mut(&self.layer_id) + .ok_or_else(|| format!("Layer {} not found", self.layer_id))?; + + // Ensure it's an effect layer + let effect_layer = match layer { + AnyLayer::Effect(ref mut el) => el, + _ => return Err("Layer is not an effect layer".to_string()), + }; + + // Insert the instance back at its original position + effect_layer.insert_clip_instance(index, instance); + + self.removed_index = None; + + Ok(()) + } + + fn description(&self) -> String { + "Remove effect".to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::effect::{EffectCategory, EffectDefinition, EffectParameterDef}; + use crate::effect_layer::EffectLayer; + use crate::layer::AnyLayer; + + fn create_test_setup() -> (Document, Uuid, EffectDefinition) { + let mut document = Document::new("Test"); + + // Create effect layer + let effect_layer = EffectLayer::new("Effects"); + let layer_id = effect_layer.layer.id; + document.root_mut().add_child(AnyLayer::Effect(effect_layer)); + + // Create effect definition + let def = EffectDefinition::new( + "Test Effect", + EffectCategory::Color, + "// shader code", + vec![EffectParameterDef::float_range("intensity", "Intensity", 1.0, 0.0, 2.0)], + ); + + (document, layer_id, def) + } + + #[test] + fn test_remove_effect() { + let (mut document, layer_id, def) = create_test_setup(); + + // Add an effect first + let instance = def.create_instance(0.0, 10.0); + let instance_id = instance.id; + + if let Some(AnyLayer::Effect(el)) = document.get_layer_mut(&layer_id) { + el.add_clip_instance(instance); + } + + // Verify effect exists + if let Some(AnyLayer::Effect(el)) = document.get_layer(&layer_id) { + assert_eq!(el.clip_instances.len(), 1); + } + + // Remove the effect + let mut action = RemoveEffectAction::new(layer_id, instance_id); + action.execute(&mut document).unwrap(); + + // Verify effect was removed + if let Some(AnyLayer::Effect(el)) = document.get_layer(&layer_id) { + assert_eq!(el.clip_instances.len(), 0); + } + } + + #[test] + fn test_remove_effect_rollback() { + let (mut document, layer_id, def) = create_test_setup(); + + // Add an effect first + let instance = def.create_instance(0.0, 10.0); + let instance_id = instance.id; + + if let Some(AnyLayer::Effect(el)) = document.get_layer_mut(&layer_id) { + el.add_clip_instance(instance); + } + + // Remove and rollback + let mut action = RemoveEffectAction::new(layer_id, instance_id); + action.execute(&mut document).unwrap(); + action.rollback(&mut document).unwrap(); + + // Verify effect was restored + if let Some(AnyLayer::Effect(el)) = document.get_layer(&layer_id) { + assert_eq!(el.clip_instances.len(), 1); + assert_eq!(el.clip_instances[0].id, instance_id); + } + } + + #[test] + fn test_remove_preserves_order() { + let (mut document, layer_id, def) = create_test_setup(); + + // Add three effects + let instance1 = def.create_instance(0.0, 10.0); + let id1 = instance1.id; + let instance2 = def.create_instance(0.0, 10.0); + let id2 = instance2.id; + let instance3 = def.create_instance(0.0, 10.0); + let id3 = instance3.id; + + if let Some(AnyLayer::Effect(el)) = document.get_layer_mut(&layer_id) { + el.add_clip_instance(instance1); + el.add_clip_instance(instance2); + el.add_clip_instance(instance3); + } + + // Remove middle effect + let mut action = RemoveEffectAction::new(layer_id, id2); + action.execute(&mut document).unwrap(); + + // Verify order: [id1, id3] + if let Some(AnyLayer::Effect(el)) = document.get_layer(&layer_id) { + assert_eq!(el.clip_instances.len(), 2); + assert_eq!(el.clip_instances[0].id, id1); + assert_eq!(el.clip_instances[1].id, id3); + } + + // Rollback - effect should be restored at index 1 + action.rollback(&mut document).unwrap(); + + // Verify order: [id1, id2, id3] + if let Some(AnyLayer::Effect(el)) = document.get_layer(&layer_id) { + assert_eq!(el.clip_instances.len(), 3); + assert_eq!(el.clip_instances[0].id, id1); + assert_eq!(el.clip_instances[1].id, id2); + assert_eq!(el.clip_instances[2].id, id3); + } + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/transform_clip_instances.rs b/lightningbeam-ui/lightningbeam-core/src/actions/transform_clip_instances.rs index 5974589..19b7d36 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/transform_clip_instances.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/transform_clip_instances.rs @@ -40,6 +40,7 @@ impl Action for TransformClipInstancesAction { AnyLayer::Vector(vl) => &mut vl.clip_instances, AnyLayer::Audio(al) => &mut al.clip_instances, AnyLayer::Video(vl) => &mut vl.clip_instances, + AnyLayer::Effect(_) => return Ok(()), // Effect layers don't have clip instances }; // Apply new transforms @@ -62,6 +63,7 @@ impl Action for TransformClipInstancesAction { AnyLayer::Vector(vl) => &mut vl.clip_instances, AnyLayer::Audio(al) => &mut al.clip_instances, AnyLayer::Video(vl) => &mut vl.clip_instances, + AnyLayer::Effect(_) => return Ok(()), // Effect layers don't have clip instances }; // Restore old transforms diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/trim_clip_instances.rs b/lightningbeam-ui/lightningbeam-core/src/actions/trim_clip_instances.rs index 79ba17e..f750e68 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/trim_clip_instances.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/trim_clip_instances.rs @@ -3,6 +3,7 @@ //! Handles trimming one or more clip instances by adjusting trim_start and/or trim_end. use crate::action::Action; +use crate::clip::ClipInstance; use crate::document::Document; use crate::layer::AnyLayer; use std::collections::HashMap; @@ -93,10 +94,11 @@ impl Action for TrimClipInstancesAction { // Find member's current values if let Some(layer) = document.get_layer(member_layer_id) { - let clip_instances = match layer { + let clip_instances: &[ClipInstance] = match layer { AnyLayer::Vector(vl) => &vl.clip_instances, AnyLayer::Audio(al) => &al.clip_instances, AnyLayer::Video(vl) => &vl.clip_instances, + AnyLayer::Effect(_) => continue, }; if let Some(instance) = clip_instances.iter().find(|ci| ci.id == *member_instance_id) { @@ -127,10 +129,11 @@ impl Action for TrimClipInstancesAction { // Find member's current trim_end if let Some(layer) = document.get_layer(member_layer_id) { - let clip_instances = match layer { + let clip_instances: &[ClipInstance] = match layer { AnyLayer::Vector(vl) => &vl.clip_instances, AnyLayer::Audio(al) => &al.clip_instances, AnyLayer::Video(vl) => &vl.clip_instances, + AnyLayer::Effect(_) => continue, }; if let Some(instance) = clip_instances.iter().find(|ci| ci.id == *member_instance_id) { @@ -168,10 +171,11 @@ impl Action for TrimClipInstancesAction { let mut clamped_layer_trims = Vec::new(); for (instance_id, trim_type, old, new) in trims { - let clip_instances = match layer { + let clip_instances: &[ClipInstance] = match layer { AnyLayer::Audio(al) => &al.clip_instances, AnyLayer::Video(vl) => &vl.clip_instances, AnyLayer::Vector(vl) => &vl.clip_instances, + AnyLayer::Effect(_) => continue, // Effect layers don't have clip instances }; let instance = clip_instances.iter() @@ -262,6 +266,7 @@ impl Action for TrimClipInstancesAction { AnyLayer::Vector(vl) => &mut vl.clip_instances, AnyLayer::Audio(al) => &mut al.clip_instances, AnyLayer::Video(vl) => &mut vl.clip_instances, + AnyLayer::Effect(_) => continue, // Effect layers don't have clip instances }; // Apply trims @@ -299,6 +304,7 @@ impl Action for TrimClipInstancesAction { AnyLayer::Vector(vl) => &mut vl.clip_instances, AnyLayer::Audio(al) => &mut al.clip_instances, AnyLayer::Video(vl) => &mut vl.clip_instances, + AnyLayer::Effect(_) => continue, // Effect layers don't have clip instances }; // Restore original trim values diff --git a/lightningbeam-ui/lightningbeam-core/src/clip.rs b/lightningbeam-ui/lightningbeam-core/src/clip.rs index 407ffc7..70aaf5a 100644 --- a/lightningbeam-ui/lightningbeam-core/src/clip.rs +++ b/lightningbeam-ui/lightningbeam-core/src/clip.rs @@ -600,6 +600,15 @@ impl ClipInstance { self } + /// Set explicit timeline duration by setting trim_end + /// + /// For effect instances, this effectively sets the duration since + /// effects have infinite internal duration (trim_start defaults to 0). + pub fn with_timeline_duration(mut self, duration: f64) -> Self { + self.trim_end = Some(self.trim_start + duration); + self + } + /// Get the effective duration of this instance (accounting for trimming and looping) /// If timeline_duration is set, returns that (enabling content looping) /// Otherwise returns the trimmed content duration diff --git a/lightningbeam-ui/lightningbeam-core/src/document.rs b/lightningbeam-ui/lightningbeam-core/src/document.rs index 3a17d36..ffcb8c8 100644 --- a/lightningbeam-ui/lightningbeam-core/src/document.rs +++ b/lightningbeam-ui/lightningbeam-core/src/document.rs @@ -4,6 +4,7 @@ //! and a root graphics object containing the scene graph. use crate::clip::{AudioClip, ClipInstance, ImageAsset, VideoClip, VectorClip}; +use crate::effect::EffectDefinition; use crate::layer::AnyLayer; use crate::layout::LayoutNode; use crate::shape::ShapeColor; @@ -110,6 +111,10 @@ pub struct Document { /// Instance groups for linked clip instances pub instance_groups: HashMap, + /// Effect definitions (all effects are embedded in the document) + #[serde(default)] + pub effect_definitions: HashMap, + /// Current UI layout state (serialized for save/load) #[serde(default, skip_serializing_if = "Option::is_none")] pub ui_layout: Option, @@ -139,6 +144,7 @@ impl Default for Document { audio_clips: HashMap::new(), image_assets: HashMap::new(), instance_groups: HashMap::new(), + effect_definitions: HashMap::new(), ui_layout: None, ui_layout_base: None, current_time: 0.0, @@ -236,6 +242,14 @@ impl Document { } } } + crate::layer::AnyLayer::Effect(effect_layer) => { + for instance in &effect_layer.clip_instances { + if let Some(clip_duration) = self.get_clip_duration(&instance.clip_id) { + let end_time = calculate_instance_end(instance, clip_duration); + max_end_time = max_end_time.max(end_time); + } + } + } } } @@ -393,11 +407,42 @@ impl Document { self.image_assets.remove(id) } + // === EFFECT DEFINITION METHODS === + + /// Add an effect definition to the document + pub fn add_effect_definition(&mut self, definition: EffectDefinition) -> Uuid { + let id = definition.id; + self.effect_definitions.insert(id, definition); + id + } + + /// Get an effect definition by ID + pub fn get_effect_definition(&self, id: &Uuid) -> Option<&EffectDefinition> { + self.effect_definitions.get(id) + } + + /// Get a mutable effect definition by ID + pub fn get_effect_definition_mut(&mut self, id: &Uuid) -> Option<&mut EffectDefinition> { + self.effect_definitions.get_mut(id) + } + + /// Remove an effect definition from the document + pub fn remove_effect_definition(&mut self, id: &Uuid) -> Option { + self.effect_definitions.remove(id) + } + + /// Get all effect definitions + pub fn effect_definitions(&self) -> impl Iterator { + self.effect_definitions.values() + } + // === CLIP OVERLAP DETECTION METHODS === /// Get the duration of any clip type by ID /// - /// Searches through all clip libraries to find the clip and return its duration + /// Searches through all clip libraries to find the clip and return its duration. + /// For effect definitions, returns `EFFECT_DURATION` (f64::MAX) since effects + /// have infinite internal duration. pub fn get_clip_duration(&self, clip_id: &Uuid) -> Option { if let Some(clip) = self.vector_clips.get(clip_id) { Some(clip.duration) @@ -405,6 +450,10 @@ impl Document { Some(clip.duration) } else if let Some(clip) = self.audio_clips.get(clip_id) { Some(clip.duration) + } else if self.effect_definitions.contains_key(clip_id) { + // Effects have infinite internal duration - their timeline length + // is controlled by ClipInstance.trim_end + Some(crate::effect::EFFECT_DURATION) } else { None } @@ -415,10 +464,11 @@ impl Document { let layer = self.get_layer(layer_id)?; // Find the clip instance - let instances = match layer { + let instances: &[ClipInstance] = match layer { AnyLayer::Audio(audio) => &audio.clip_instances, AnyLayer::Video(video) => &video.clip_instances, AnyLayer::Vector(vector) => &vector.clip_instances, + AnyLayer::Effect(effect) => &effect.clip_instances, }; let instance = instances.iter().find(|inst| &inst.id == instance_id)?; @@ -435,7 +485,7 @@ impl Document { /// /// Returns (overlaps, conflicting_instance_id) /// - /// Only checks audio and video layers - vector/MIDI layers return false + /// Only checks audio, video, and effect layers - vector/MIDI layers return false pub fn check_overlap_on_layer( &self, layer_id: &Uuid, @@ -447,15 +497,16 @@ impl Document { return (false, None); }; - // Only check audio and video layers - if !matches!(layer, AnyLayer::Audio(_) | AnyLayer::Video(_)) { + // Check audio, video, and effect layers (effects cannot overlap on same layer) + if !matches!(layer, AnyLayer::Audio(_) | AnyLayer::Video(_) | AnyLayer::Effect(_)) { return (false, None); } - let instances = match layer { + let instances: &[ClipInstance] = match layer { AnyLayer::Audio(audio) => &audio.clip_instances, AnyLayer::Video(video) => &video.clip_instances, AnyLayer::Vector(vector) => &vector.clip_instances, + AnyLayer::Effect(effect) => &effect.clip_instances, }; for instance in instances { @@ -502,8 +553,8 @@ impl Document { // Clamp to timeline start (can't go before 0) let desired_start = desired_start.max(0.0); - // Vector/MIDI layers don't need overlap adjustment, but still respect timeline start - if !matches!(layer, AnyLayer::Audio(_) | AnyLayer::Video(_)) { + // Vector layers don't need overlap adjustment, but still respect timeline start + if matches!(layer, AnyLayer::Vector(_)) { return Some(desired_start); } @@ -515,10 +566,11 @@ impl Document { } // Collect all existing clip time ranges on this layer - let instances = match layer { + let instances: &[ClipInstance] = match layer { AnyLayer::Audio(audio) => &audio.clip_instances, AnyLayer::Video(video) => &video.clip_instances, - _ => return Some(desired_start), // Shouldn't reach here + AnyLayer::Effect(effect) => &effect.clip_instances, + AnyLayer::Vector(_) => return Some(desired_start), // Shouldn't reach here }; let mut occupied_ranges: Vec<(f64, f64, Uuid)> = Vec::new(); @@ -599,17 +651,18 @@ impl Document { return current_timeline_start; // No limit if layer not found }; - // Only check audio and video layers - if !matches!(layer, AnyLayer::Audio(_) | AnyLayer::Video(_)) { + // Only check audio, video, and effect layers + if matches!(layer, AnyLayer::Vector(_)) { return current_timeline_start; // No limit for vector layers }; // Find the nearest clip to the left let mut nearest_end = 0.0; // Can extend to timeline start by default - let instances = match layer { + let instances: &[ClipInstance] = match layer { AnyLayer::Audio(audio) => &audio.clip_instances, AnyLayer::Video(video) => &video.clip_instances, + AnyLayer::Effect(effect) => &effect.clip_instances, AnyLayer::Vector(vector) => &vector.clip_instances, }; @@ -648,14 +701,15 @@ impl Document { return f64::MAX; // No limit if layer not found }; - // Only check audio and video layers - if !matches!(layer, AnyLayer::Audio(_) | AnyLayer::Video(_)) { + // Only check audio, video, and effect layers + if matches!(layer, AnyLayer::Vector(_)) { return f64::MAX; // No limit for vector layers } - let instances = match layer { + let instances: &[ClipInstance] = match layer { AnyLayer::Audio(audio) => &audio.clip_instances, AnyLayer::Video(video) => &video.clip_instances, + AnyLayer::Effect(effect) => &effect.clip_instances, AnyLayer::Vector(vector) => &vector.clip_instances, }; diff --git a/lightningbeam-ui/lightningbeam-core/src/effect.rs b/lightningbeam-ui/lightningbeam-core/src/effect.rs new file mode 100644 index 0000000..84c81a3 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/effect.rs @@ -0,0 +1,570 @@ +//! Effect system for Lightningbeam +//! +//! Provides GPU-accelerated visual effects with animatable parameters. +//! Effects are defined by WGSL shaders embedded directly in the document. +//! +//! Effect instances are represented as `ClipInstance` objects where `clip_id` +//! references an `EffectDefinition`. Effects are treated as having infinite +//! internal duration (`EFFECT_DURATION`), with timeline duration controlled +//! solely by `timeline_start` and `timeline_duration`. + +use crate::animation::AnimationCurve; +use crate::clip::ClipInstance; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use uuid::Uuid; + +/// Constant representing "infinite" effect duration for clip lookups. +/// Effects don't have an inherent duration like video/audio clips. +/// Their timeline duration is controlled by `ClipInstance.timeline_duration`. +pub const EFFECT_DURATION: f64 = f64::MAX; + +/// Category of effect for UI organization +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum EffectCategory { + /// Color adjustments (brightness, contrast, hue, saturation) + Color, + /// Blur effects (gaussian, motion, radial) + Blur, + /// Distortion effects (warp, ripple, twirl) + Distort, + /// Stylize effects (glow, sharpen, posterize) + Stylize, + /// Generate effects (noise, gradients, patterns) + Generate, + /// Keying effects (chroma key, luma key) + Keying, + /// Transition effects (wipe, dissolve, etc.) + Transition, + /// Time-based effects (echo, frame hold) + Time, + /// Custom user-defined effect + Custom, +} + +/// Type of effect parameter +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum ParameterType { + /// Floating point value + Float, + /// Integer value + Int, + /// Boolean toggle + Bool, + /// RGBA color + Color, + /// 2D point/vector + Point2D, + /// Angle in degrees + Angle, + /// Enum with named options + Enum, +} + +/// Value of an effect parameter +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub enum ParameterValue { + Float(f64), + Int(i64), + Bool(bool), + Color { r: f64, g: f64, b: f64, a: f64 }, + Point2D { x: f64, y: f64 }, + Angle(f64), + Enum(u32), +} + +impl ParameterValue { + /// Get as f64 for shader uniform packing (returns 0.0 for non-float types) + pub fn as_f32(&self) -> f32 { + match self { + ParameterValue::Float(v) => *v as f32, + ParameterValue::Int(v) => *v as f32, + ParameterValue::Bool(v) => if *v { 1.0 } else { 0.0 }, + ParameterValue::Angle(v) => *v as f32, + ParameterValue::Enum(v) => *v as f32, + ParameterValue::Color { r, .. } => *r as f32, + ParameterValue::Point2D { x, .. } => *x as f32, + } + } + + /// Pack color value into 4 f32s [r, g, b, a] + pub fn as_color_f32(&self) -> [f32; 4] { + match self { + ParameterValue::Color { r, g, b, a } => [*r as f32, *g as f32, *b as f32, *a as f32], + _ => [0.0, 0.0, 0.0, 1.0], + } + } + + /// Pack point value into 2 f32s [x, y] + pub fn as_point_f32(&self) -> [f32; 2] { + match self { + ParameterValue::Point2D { x, y } => [*x as f32, *y as f32], + _ => [0.0, 0.0], + } + } +} + +impl Default for ParameterValue { + fn default() -> Self { + ParameterValue::Float(0.0) + } +} + +/// Definition of a single effect parameter +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct EffectParameterDef { + /// Internal parameter name (used in shader) + pub name: String, + /// Display label for UI + pub label: String, + /// Parameter data type + pub param_type: ParameterType, + /// Default value + pub default_value: ParameterValue, + /// Minimum allowed value (for numeric types) + pub min_value: Option, + /// Maximum allowed value (for numeric types) + pub max_value: Option, + /// Whether this parameter can be animated + pub animatable: bool, + /// Enum option names (for ParameterType::Enum) + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub enum_options: Vec, +} + +impl EffectParameterDef { + /// Create a new float parameter definition + pub fn float(name: impl Into, label: impl Into, default: f64) -> Self { + Self { + name: name.into(), + label: label.into(), + param_type: ParameterType::Float, + default_value: ParameterValue::Float(default), + min_value: None, + max_value: None, + animatable: true, + enum_options: Vec::new(), + } + } + + /// Create a float parameter with range constraints + pub fn float_range( + name: impl Into, + label: impl Into, + default: f64, + min: f64, + max: f64, + ) -> Self { + Self { + name: name.into(), + label: label.into(), + param_type: ParameterType::Float, + default_value: ParameterValue::Float(default), + min_value: Some(ParameterValue::Float(min)), + max_value: Some(ParameterValue::Float(max)), + animatable: true, + enum_options: Vec::new(), + } + } + + /// Create a boolean parameter definition + pub fn boolean(name: impl Into, label: impl Into, default: bool) -> Self { + Self { + name: name.into(), + label: label.into(), + param_type: ParameterType::Bool, + default_value: ParameterValue::Bool(default), + min_value: None, + max_value: None, + animatable: false, + enum_options: Vec::new(), + } + } + + /// Create a color parameter definition + pub fn color(name: impl Into, label: impl Into, r: f64, g: f64, b: f64, a: f64) -> Self { + Self { + name: name.into(), + label: label.into(), + param_type: ParameterType::Color, + default_value: ParameterValue::Color { r, g, b, a }, + min_value: None, + max_value: None, + animatable: true, + enum_options: Vec::new(), + } + } + + /// Create an angle parameter definition (in degrees) + pub fn angle(name: impl Into, label: impl Into, default: f64) -> Self { + Self { + name: name.into(), + label: label.into(), + param_type: ParameterType::Angle, + default_value: ParameterValue::Angle(default), + min_value: Some(ParameterValue::Angle(0.0)), + max_value: Some(ParameterValue::Angle(360.0)), + animatable: true, + enum_options: Vec::new(), + } + } + + /// Create a point parameter definition + pub fn point(name: impl Into, label: impl Into, x: f64, y: f64) -> Self { + Self { + name: name.into(), + label: label.into(), + param_type: ParameterType::Point2D, + default_value: ParameterValue::Point2D { x, y }, + min_value: None, + max_value: None, + animatable: true, + enum_options: Vec::new(), + } + } +} + +/// Type of input an effect can accept +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum EffectInputType { + /// Input from a specific layer + Layer, + /// Input from the composition (all layers below, already composited) + Composition, + /// Input from another effect in the chain + Effect, +} + +/// Definition of an effect input slot +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct EffectInput { + /// Name of this input + pub name: String, + /// Type of input expected + pub input_type: EffectInputType, + /// Whether this input is required + pub required: bool, +} + +impl EffectInput { + /// Create a required composition input (most common case) + pub fn composition(name: impl Into) -> Self { + Self { + name: name.into(), + input_type: EffectInputType::Composition, + required: true, + } + } + + /// Create an optional layer input + pub fn layer(name: impl Into, required: bool) -> Self { + Self { + name: name.into(), + input_type: EffectInputType::Layer, + required, + } + } +} + +/// Complete definition of an effect (embedded shader + metadata) +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct EffectDefinition { + /// Unique identifier for this effect definition + pub id: Uuid, + /// Display name + pub name: String, + /// Optional description + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + /// Effect category for UI organization + pub category: EffectCategory, + /// WGSL shader source code (embedded directly) + pub shader_code: String, + /// Input slots for this effect + pub inputs: Vec, + /// Parameter definitions + pub parameters: Vec, +} + +impl EffectDefinition { + /// Create a new effect definition with a single composition input + pub fn new( + name: impl Into, + category: EffectCategory, + shader_code: impl Into, + parameters: Vec, + ) -> Self { + Self { + id: Uuid::new_v4(), + name: name.into(), + description: None, + category, + shader_code: shader_code.into(), + inputs: vec![EffectInput::composition("source")], + parameters, + } + } + + /// Create with a specific ID (for built-in effects with stable IDs) + pub fn with_id(id: Uuid, name: impl Into, category: EffectCategory, shader_code: impl Into, parameters: Vec) -> Self { + Self { + id, + name: name.into(), + description: None, + category, + shader_code: shader_code.into(), + inputs: vec![EffectInput::composition("source")], + parameters, + } + } + + /// Add a description + pub fn with_description(mut self, description: impl Into) -> Self { + self.description = Some(description.into()); + self + } + + /// Add custom inputs + pub fn with_inputs(mut self, inputs: Vec) -> Self { + self.inputs = inputs; + self + } + + /// Get a parameter definition by name + pub fn get_parameter(&self, name: &str) -> Option<&EffectParameterDef> { + self.parameters.iter().find(|p| p.name == name) + } + + /// Create a ClipInstance for this effect definition + /// + /// The returned ClipInstance references this effect definition via `clip_id`. + /// Effects use `timeline_duration` to control their length since they have + /// infinite internal duration. + /// + /// # Arguments + /// + /// * `timeline_start` - When the effect starts on the timeline (seconds) + /// * `duration` - How long the effect appears on the timeline (seconds) + pub fn create_instance(&self, timeline_start: f64, duration: f64) -> ClipInstance { + ClipInstance::new(self.id) + .with_timeline_start(timeline_start) + .with_timeline_duration(duration) + } +} + +/// Connection to an input source for an effect +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum InputConnection { + /// Connect to a specific layer (by ID) + Layer(Uuid), + /// Connect to the composited result of all layers below + Composition, + /// Connect to the output of another effect instance + Effect(Uuid), +} + +/// Animated parameter value for an effect instance +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct AnimatedParameter { + /// Parameter name (matches EffectParameterDef.name) + pub name: String, + /// Current/base value + pub value: ParameterValue, + /// Optional animation curve (for animatable parameters) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub animation: Option, +} + +impl AnimatedParameter { + /// Create a new non-animated parameter + pub fn new(name: impl Into, value: ParameterValue) -> Self { + Self { + name: name.into(), + value, + animation: None, + } + } + + /// Create with animation + pub fn with_animation(name: impl Into, value: ParameterValue, curve: AnimationCurve) -> Self { + Self { + name: name.into(), + value, + animation: Some(curve), + } + } + + /// Get the value at a specific time + pub fn value_at(&self, time: f64) -> ParameterValue { + if let Some(ref curve) = self.animation { + // Apply animation curve to get animated value + let animated_value = curve.eval(time); + // Convert based on parameter type + match &self.value { + ParameterValue::Float(_) => ParameterValue::Float(animated_value), + ParameterValue::Int(_) => ParameterValue::Int(animated_value.round() as i64), + ParameterValue::Bool(_) => ParameterValue::Bool(animated_value > 0.5), + ParameterValue::Angle(_) => ParameterValue::Angle(animated_value), + ParameterValue::Enum(_) => ParameterValue::Enum(animated_value.round() as u32), + // Color and Point2D would need multiple curves, so just use base value + ParameterValue::Color { .. } => self.value.clone(), + ParameterValue::Point2D { .. } => self.value.clone(), + } + } else { + self.value.clone() + } + } +} + +/// Instance of an effect applied to a layer +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct EffectInstance { + /// Unique identifier for this instance + pub id: Uuid, + /// ID of the effect definition this is an instance of + pub effect_id: Uuid, + /// Start time on the timeline (when effect becomes active) + pub timeline_start: f64, + /// End time on the timeline (when effect stops) + pub timeline_end: f64, + /// Input connections (parallel to EffectDefinition.inputs) + pub input_connections: Vec>, + /// Parameter values (name -> animated value) + pub parameters: HashMap, + /// Whether the effect is enabled + pub enabled: bool, + /// Mix/blend amount (0.0 = original, 1.0 = full effect) + pub mix: f64, +} + +impl EffectInstance { + /// Create a new effect instance from a definition + pub fn new(definition: &EffectDefinition, timeline_start: f64, timeline_end: f64) -> Self { + // Initialize parameters from definition defaults + let mut parameters = HashMap::new(); + for param_def in &definition.parameters { + parameters.insert( + param_def.name.clone(), + AnimatedParameter::new(param_def.name.clone(), param_def.default_value.clone()), + ); + } + + // Initialize input connections (Composition for required, None for optional) + let input_connections = definition.inputs.iter() + .map(|input| { + if input.required && input.input_type == EffectInputType::Composition { + Some(InputConnection::Composition) + } else { + None + } + }) + .collect(); + + Self { + id: Uuid::new_v4(), + effect_id: definition.id, + timeline_start, + timeline_end, + input_connections, + parameters, + enabled: true, + mix: 1.0, + } + } + + /// Check if the effect is active at a given time + pub fn is_active_at(&self, time: f64) -> bool { + self.enabled && time >= self.timeline_start && time < self.timeline_end + } + + /// Get a parameter value at a specific time + pub fn get_parameter_at(&self, name: &str, time: f64) -> Option { + self.parameters.get(name).map(|p| p.value_at(time)) + } + + /// Set a parameter value (non-animated) + pub fn set_parameter(&mut self, name: &str, value: ParameterValue) { + if let Some(param) = self.parameters.get_mut(name) { + param.value = value; + param.animation = None; + } + } + + /// Get all parameter values at a specific time as f32 array for shader uniform + pub fn get_uniform_params(&self, time: f64, definitions: &[EffectParameterDef]) -> Vec { + let mut params = Vec::with_capacity(16); + for def in definitions { + if let Some(param) = self.parameters.get(&def.name) { + let value = param.value_at(time); + match def.param_type { + ParameterType::Float | ParameterType::Int | ParameterType::Bool | + ParameterType::Angle | ParameterType::Enum => { + params.push(value.as_f32()); + } + ParameterType::Color => { + let color = value.as_color_f32(); + params.extend_from_slice(&color); + } + ParameterType::Point2D => { + let point = value.as_point_f32(); + params.extend_from_slice(&point); + } + } + } + } + // Pad to 16 floats for uniform alignment + while params.len() < 16 { + params.push(0.0); + } + params + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_effect_definition_creation() { + let def = EffectDefinition::new( + "Test Effect", + EffectCategory::Color, + "// shader code", + vec![EffectParameterDef::float_range("intensity", "Intensity", 1.0, 0.0, 2.0)], + ); + + assert_eq!(def.name, "Test Effect"); + assert_eq!(def.category, EffectCategory::Color); + assert_eq!(def.parameters.len(), 1); + assert_eq!(def.inputs.len(), 1); + } + + #[test] + fn test_effect_instance_creation() { + let def = EffectDefinition::new( + "Blur", + EffectCategory::Blur, + "// blur shader", + vec![ + EffectParameterDef::float_range("radius", "Radius", 10.0, 0.0, 100.0), + EffectParameterDef::float_range("quality", "Quality", 1.0, 0.0, 1.0), + ], + ); + + let instance = EffectInstance::new(&def, 0.0, 10.0); + + assert_eq!(instance.effect_id, def.id); + assert!(instance.is_active_at(5.0)); + assert!(!instance.is_active_at(15.0)); + assert_eq!(instance.parameters.len(), 2); + } + + #[test] + fn test_parameter_value_as_f32() { + assert_eq!(ParameterValue::Float(1.5).as_f32(), 1.5); + assert_eq!(ParameterValue::Int(42).as_f32(), 42.0); + assert_eq!(ParameterValue::Bool(true).as_f32(), 1.0); + assert_eq!(ParameterValue::Bool(false).as_f32(), 0.0); + assert_eq!(ParameterValue::Angle(90.0).as_f32(), 90.0); + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/effect_layer.rs b/lightningbeam-ui/lightningbeam-core/src/effect_layer.rs new file mode 100644 index 0000000..9d4d658 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/effect_layer.rs @@ -0,0 +1,289 @@ +//! Effect layer type for Lightningbeam +//! +//! An EffectLayer applies visual effects to the composition below it. +//! Effect instances are stored as `ClipInstance` objects where `clip_id` +//! references an `EffectDefinition`. + +use crate::clip::ClipInstance; +use crate::layer::{Layer, LayerTrait, LayerType}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// Layer type that applies visual effects to the composition +/// +/// Effect instances are represented as `ClipInstance` objects. +/// The `clip_id` field references an `EffectDefinition` rather than a clip. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct EffectLayer { + /// Base layer properties + pub layer: Layer, + /// Effect instances (as ClipInstances referencing EffectDefinitions) + pub clip_instances: Vec, +} + +impl LayerTrait for EffectLayer { + fn id(&self) -> Uuid { + self.layer.id + } + + fn name(&self) -> &str { + &self.layer.name + } + + fn set_name(&mut self, name: String) { + self.layer.name = name; + } + + fn has_custom_name(&self) -> bool { + self.layer.has_custom_name + } + + fn set_has_custom_name(&mut self, custom: bool) { + self.layer.has_custom_name = custom; + } + + fn visible(&self) -> bool { + self.layer.visible + } + + fn set_visible(&mut self, visible: bool) { + self.layer.visible = visible; + } + + fn opacity(&self) -> f64 { + self.layer.opacity + } + + fn set_opacity(&mut self, opacity: f64) { + self.layer.opacity = opacity; + } + + fn volume(&self) -> f64 { + self.layer.volume + } + + fn set_volume(&mut self, volume: f64) { + self.layer.volume = volume; + } + + fn muted(&self) -> bool { + self.layer.muted + } + + fn set_muted(&mut self, muted: bool) { + self.layer.muted = muted; + } + + fn soloed(&self) -> bool { + self.layer.soloed + } + + fn set_soloed(&mut self, soloed: bool) { + self.layer.soloed = soloed; + } + + fn locked(&self) -> bool { + self.layer.locked + } + + fn set_locked(&mut self, locked: bool) { + self.layer.locked = locked; + } +} + +impl EffectLayer { + /// Create a new effect layer + pub fn new(name: impl Into) -> Self { + Self { + layer: Layer::new(LayerType::Effect, name), + clip_instances: Vec::new(), + } + } + + /// Create with a specific ID + pub fn with_id(id: Uuid, name: impl Into) -> Self { + Self { + layer: Layer::with_id(id, LayerType::Effect, name), + clip_instances: Vec::new(), + } + } + + /// Add a clip instance (effect) to this layer + pub fn add_clip_instance(&mut self, instance: ClipInstance) -> Uuid { + let id = instance.id; + self.clip_instances.push(instance); + id + } + + /// Insert a clip instance at a specific index + pub fn insert_clip_instance(&mut self, index: usize, instance: ClipInstance) -> Uuid { + let id = instance.id; + let index = index.min(self.clip_instances.len()); + self.clip_instances.insert(index, instance); + id + } + + /// Remove a clip instance by ID + pub fn remove_clip_instance(&mut self, id: &Uuid) -> Option { + if let Some(index) = self.clip_instances.iter().position(|e| &e.id == id) { + Some(self.clip_instances.remove(index)) + } else { + None + } + } + + /// Get a clip instance by ID + pub fn get_clip_instance(&self, id: &Uuid) -> Option<&ClipInstance> { + self.clip_instances.iter().find(|e| &e.id == id) + } + + /// Get a mutable clip instance by ID + pub fn get_clip_instance_mut(&mut self, id: &Uuid) -> Option<&mut ClipInstance> { + self.clip_instances.iter_mut().find(|e| &e.id == id) + } + + /// Get all clip instances (effects) that are active at a given time + /// + /// Uses `EFFECT_DURATION` to calculate effective duration for each instance. + pub fn active_clip_instances_at(&self, time: f64) -> Vec<&ClipInstance> { + use crate::effect::EFFECT_DURATION; + self.clip_instances + .iter() + .filter(|e| { + let end = e.timeline_start + e.effective_duration(EFFECT_DURATION); + time >= e.timeline_start && time < end + }) + .collect() + } + + /// Get the index of a clip instance + pub fn clip_instance_index(&self, id: &Uuid) -> Option { + self.clip_instances.iter().position(|e| &e.id == id) + } + + /// Move a clip instance to a new position in the layer + pub fn move_clip_instance(&mut self, id: &Uuid, new_index: usize) -> bool { + if let Some(current_index) = self.clip_instance_index(id) { + let instance = self.clip_instances.remove(current_index); + let new_index = new_index.min(self.clip_instances.len()); + self.clip_instances.insert(new_index, instance); + true + } else { + false + } + } + + /// Reorder clip instances by providing a list of IDs in desired order + pub fn reorder_clip_instances(&mut self, order: &[Uuid]) { + let mut new_order = Vec::with_capacity(self.clip_instances.len()); + + // Add instances in the specified order + for id in order { + if let Some(index) = self.clip_instances.iter().position(|e| &e.id == id) { + new_order.push(self.clip_instances.remove(index)); + } + } + + // Append any instances not in the order list + new_order.append(&mut self.clip_instances); + self.clip_instances = new_order; + } + + // === MUTATION METHODS (pub(crate) - only accessible to action module) === + + /// Add a clip instance (internal, for actions only) + pub(crate) fn add_clip_instance_internal(&mut self, instance: ClipInstance) -> Uuid { + self.add_clip_instance(instance) + } + + /// Remove a clip instance (internal, for actions only) + pub(crate) fn remove_clip_instance_internal(&mut self, id: &Uuid) -> Option { + self.remove_clip_instance(id) + } + + /// Insert a clip instance at a specific index (internal, for actions only) + pub(crate) fn insert_clip_instance_internal(&mut self, index: usize, instance: ClipInstance) -> Uuid { + self.insert_clip_instance(index, instance) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::effect::{EffectCategory, EffectDefinition, EffectParameterDef}; + + fn create_test_effect_def() -> EffectDefinition { + EffectDefinition::new( + "Test Effect", + EffectCategory::Color, + "// shader code", + vec![EffectParameterDef::float_range("intensity", "Intensity", 1.0, 0.0, 2.0)], + ) + } + + #[test] + fn test_effect_layer_creation() { + let layer = EffectLayer::new("Effects"); + assert_eq!(layer.name(), "Effects"); + assert_eq!(layer.clip_instances.len(), 0); + } + + #[test] + fn test_add_effect() { + let mut layer = EffectLayer::new("Effects"); + let def = create_test_effect_def(); + let effect = def.create_instance(0.0, 10.0); + let effect_id = effect.id; + + let id = layer.add_clip_instance(effect); + assert_eq!(id, effect_id); + assert_eq!(layer.clip_instances.len(), 1); + assert!(layer.get_clip_instance(&effect_id).is_some()); + } + + #[test] + fn test_active_effects() { + let mut layer = EffectLayer::new("Effects"); + let def = create_test_effect_def(); + + // Effect 1: active from 0 to 5 + let effect1 = def.create_instance(0.0, 5.0); + layer.add_clip_instance(effect1); + + // Effect 2: active from 3 to 10 + let effect2 = def.create_instance(3.0, 7.0); // 3.0 + 7.0 = 10.0 end + layer.add_clip_instance(effect2); + + // At time 2: only effect1 active + assert_eq!(layer.active_clip_instances_at(2.0).len(), 1); + + // At time 4: both effects active + assert_eq!(layer.active_clip_instances_at(4.0).len(), 2); + + // At time 7: only effect2 active + assert_eq!(layer.active_clip_instances_at(7.0).len(), 1); + } + + #[test] + fn test_effect_reordering() { + let mut layer = EffectLayer::new("Effects"); + let def = create_test_effect_def(); + + let effect1 = def.create_instance(0.0, 10.0); + let id1 = effect1.id; + layer.add_clip_instance(effect1); + + let effect2 = def.create_instance(0.0, 10.0); + let id2 = effect2.id; + layer.add_clip_instance(effect2); + + // Initially: [id1, id2] + assert_eq!(layer.clip_instance_index(&id1), Some(0)); + assert_eq!(layer.clip_instance_index(&id2), Some(1)); + + // Move id1 to index 1: [id2, id1] + layer.move_clip_instance(&id1, 1); + assert_eq!(layer.clip_instance_index(&id1), Some(1)); + assert_eq!(layer.clip_instance_index(&id2), Some(0)); + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/effect_registry.rs b/lightningbeam-ui/lightningbeam-core/src/effect_registry.rs new file mode 100644 index 0000000..bdb2ec9 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/effect_registry.rs @@ -0,0 +1,191 @@ +//! Default effect definitions registry +//! +//! Provides default effect definitions with embedded WGSL shaders. +//! These are copied into documents when used - no runtime dependency on registry. +//! +//! Built-in effects use stable UUIDs so they can be reliably looked up by ID. + +use crate::effect::{EffectCategory, EffectDefinition, EffectParameterDef}; +use uuid::Uuid; + +// Stable UUIDs for built-in effects (randomly generated, never change) +const GRAYSCALE_ID: Uuid = Uuid::from_u128(0xac2cd8ce_4ea3_4c84_8c70_5cfc4dae22fb); +const INVERT_ID: Uuid = Uuid::from_u128(0x9ff36aef_5f40_45b2_bf42_cbe7fa52bd3a); +const BRIGHTNESS_CONTRAST_ID: Uuid = Uuid::from_u128(0x6cd772c9_ea8a_4b1e_93fb_2aa1d3306f62); +const HUE_SATURATION_ID: Uuid = Uuid::from_u128(0x3f210ac2_4eb5_436a_8337_c583d19dcbe1); +const COLOR_TINT_ID: Uuid = Uuid::from_u128(0x7b85ea51_22d6_4506_8689_85bdcd9ca6db); +const GAUSSIAN_BLUR_ID: Uuid = Uuid::from_u128(0x3e36bc88_3495_4f8b_ad07_8a5cdcc4c05b); +const VIGNETTE_ID: Uuid = Uuid::from_u128(0xf21873da_df9e_4ba2_ba5d_46a276e6485c); +const SHARPEN_ID: Uuid = Uuid::from_u128(0x217f644a_c4a1_46ed_b9b7_86b820792b29); + +/// Registry of default built-in effects +pub struct EffectRegistry; + +impl EffectRegistry { + /// Get all available default effect definitions + pub fn get_all() -> Vec { + vec![ + Self::grayscale(), + Self::invert(), + Self::brightness_contrast(), + Self::hue_saturation(), + Self::color_tint(), + Self::gaussian_blur(), + Self::vignette(), + Self::sharpen(), + ] + } + + /// Get a specific effect by name + pub fn get_by_name(name: &str) -> Option { + match name.to_lowercase().as_str() { + "grayscale" => Some(Self::grayscale()), + "invert" => Some(Self::invert()), + "brightness/contrast" | "brightness_contrast" => Some(Self::brightness_contrast()), + "hue/saturation" | "hue_saturation" => Some(Self::hue_saturation()), + "color tint" | "color_tint" => Some(Self::color_tint()), + "gaussian blur" | "gaussian_blur" => Some(Self::gaussian_blur()), + "vignette" => Some(Self::vignette()), + "sharpen" => Some(Self::sharpen()), + _ => None, + } + } + + /// Get a specific effect by its UUID + pub fn get_by_id(id: &Uuid) -> Option { + Self::get_all().into_iter().find(|def| def.id == *id) + } + + /// Grayscale effect - converts to black and white + pub fn grayscale() -> EffectDefinition { + EffectDefinition::with_id( + GRAYSCALE_ID, + "Grayscale", + EffectCategory::Color, + include_str!("shaders/effect_grayscale.wgsl"), + vec![ + EffectParameterDef::float_range("amount", "Amount", 1.0, 0.0, 1.0), + ], + ).with_description("Convert image to grayscale") + } + + /// Invert effect - inverts colors + pub fn invert() -> EffectDefinition { + EffectDefinition::with_id( + INVERT_ID, + "Invert", + EffectCategory::Color, + include_str!("shaders/effect_invert.wgsl"), + vec![ + EffectParameterDef::float_range("amount", "Amount", 1.0, 0.0, 1.0), + ], + ).with_description("Invert image colors") + } + + /// Brightness/Contrast adjustment + pub fn brightness_contrast() -> EffectDefinition { + EffectDefinition::with_id( + BRIGHTNESS_CONTRAST_ID, + "Brightness/Contrast", + EffectCategory::Color, + include_str!("shaders/effect_brightness_contrast.wgsl"), + vec![ + EffectParameterDef::float_range("brightness", "Brightness", 0.0, -1.0, 1.0), + EffectParameterDef::float_range("contrast", "Contrast", 1.0, 0.0, 3.0), + ], + ).with_description("Adjust brightness and contrast") + } + + /// Hue/Saturation adjustment + pub fn hue_saturation() -> EffectDefinition { + EffectDefinition::with_id( + HUE_SATURATION_ID, + "Hue/Saturation", + EffectCategory::Color, + include_str!("shaders/effect_hue_saturation.wgsl"), + vec![ + EffectParameterDef::angle("hue", "Hue Shift", 0.0), + EffectParameterDef::float_range("saturation", "Saturation", 1.0, 0.0, 3.0), + EffectParameterDef::float_range("lightness", "Lightness", 0.0, -1.0, 1.0), + ], + ).with_description("Adjust hue, saturation, and lightness") + } + + /// Color tint effect + pub fn color_tint() -> EffectDefinition { + EffectDefinition::with_id( + COLOR_TINT_ID, + "Color Tint", + EffectCategory::Color, + include_str!("shaders/effect_color_tint.wgsl"), + vec![ + EffectParameterDef::color("tint_color", "Tint Color", 1.0, 0.5, 0.0, 1.0), + EffectParameterDef::float_range("amount", "Amount", 0.5, 0.0, 1.0), + ], + ).with_description("Apply a color tint overlay") + } + + /// Gaussian blur effect + pub fn gaussian_blur() -> EffectDefinition { + EffectDefinition::with_id( + GAUSSIAN_BLUR_ID, + "Gaussian Blur", + EffectCategory::Blur, + include_str!("shaders/effect_blur.wgsl"), + vec![ + EffectParameterDef::float_range("radius", "Radius", 5.0, 0.0, 50.0), + EffectParameterDef::float_range("quality", "Quality", 1.0, 0.0, 1.0), + ], + ).with_description("Gaussian blur effect") + } + + /// Vignette effect - darkens edges + pub fn vignette() -> EffectDefinition { + EffectDefinition::with_id( + VIGNETTE_ID, + "Vignette", + EffectCategory::Stylize, + include_str!("shaders/effect_vignette.wgsl"), + vec![ + EffectParameterDef::float_range("radius", "Radius", 0.5, 0.0, 1.5), + EffectParameterDef::float_range("softness", "Softness", 0.5, 0.0, 1.0), + EffectParameterDef::float_range("amount", "Amount", 0.5, 0.0, 1.0), + ], + ).with_description("Add a vignette darkening effect to edges") + } + + /// Sharpen effect + pub fn sharpen() -> EffectDefinition { + EffectDefinition::with_id( + SHARPEN_ID, + "Sharpen", + EffectCategory::Stylize, + include_str!("shaders/effect_sharpen.wgsl"), + vec![ + EffectParameterDef::float_range("amount", "Amount", 1.0, 0.0, 3.0), + EffectParameterDef::float_range("radius", "Radius", 1.0, 0.5, 5.0), + ], + ).with_description("Sharpen image details") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_get_all_effects() { + let effects = EffectRegistry::get_all(); + assert!(effects.len() >= 8); + } + + #[test] + fn test_get_by_name() { + let grayscale = EffectRegistry::get_by_name("grayscale"); + assert!(grayscale.is_some()); + assert_eq!(grayscale.unwrap().name, "Grayscale"); + + let unknown = EffectRegistry::get_by_name("unknown_effect"); + assert!(unknown.is_none()); + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/gpu/effect_processor.rs b/lightningbeam-ui/lightningbeam-core/src/gpu/effect_processor.rs new file mode 100644 index 0000000..3e8ff61 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/gpu/effect_processor.rs @@ -0,0 +1,440 @@ +//! GPU effect processor for shader-based visual effects +//! +//! Compiles effect shaders and applies them to textures in the compositing pipeline. + +use crate::effect::{EffectDefinition, EffectInstance}; +use std::collections::HashMap; +use uuid::Uuid; +use super::buffer_pool::{BufferHandle, BufferPool, BufferSpec, BufferFormat}; + +/// Uniform data for effect shaders +/// +/// Parameters are packed as vec4s (4 floats each) for proper GPU alignment. +/// - params0: parameters 0-3 +/// - params1: parameters 4-7 +/// - params2: parameters 8-11 +/// - params3: parameters 12-15 +#[repr(C)] +#[derive(Clone, Copy, Debug, bytemuck::Pod, bytemuck::Zeroable)] +pub struct EffectUniforms { + /// Parameters 0-3 (packed as vec4 for 16-byte alignment) + pub params0: [f32; 4], + /// Parameters 4-7 + pub params1: [f32; 4], + /// Parameters 8-11 + pub params2: [f32; 4], + /// Parameters 12-15 + pub params3: [f32; 4], + /// Source texture width + pub texture_width: f32, + /// Source texture height + pub texture_height: f32, + /// Current time in seconds + pub time: f32, + /// Mix/blend amount (0.0 = original, 1.0 = full effect) + pub mix: f32, +} + +impl Default for EffectUniforms { + fn default() -> Self { + Self { + params0: [0.0; 4], + params1: [0.0; 4], + params2: [0.0; 4], + params3: [0.0; 4], + texture_width: 1.0, + texture_height: 1.0, + time: 0.0, + mix: 1.0, + } + } +} + +impl EffectUniforms { + /// Set parameters from a flat array of up to 16 floats + pub fn set_params(&mut self, params: &[f32]) { + for (i, &val) in params.iter().take(16).enumerate() { + match i / 4 { + 0 => self.params0[i % 4] = val, + 1 => self.params1[i % 4] = val, + 2 => self.params2[i % 4] = val, + 3 => self.params3[i % 4] = val, + _ => {} + } + } + } +} + +/// A compiled effect ready for GPU execution +struct CompiledEffect { + /// The render pipeline for this effect + pipeline: wgpu::RenderPipeline, +} + +/// GPU processor for visual effects +/// +/// Manages shader compilation and execution for effect layers. +/// Effects are applied as fullscreen passes that read from a source texture +/// and write to a destination texture. +pub struct EffectProcessor { + /// Compiled effect pipelines keyed by effect definition ID + compiled_effects: HashMap, + /// Bind group layout for effect shaders (shared across all effects) + bind_group_layout: wgpu::BindGroupLayout, + /// Sampler for texture sampling + sampler: wgpu::Sampler, + /// Output texture format + output_format: wgpu::TextureFormat, +} + +impl EffectProcessor { + /// Create a new effect processor + pub fn new(device: &wgpu::Device, output_format: wgpu::TextureFormat) -> Self { + // Create bind group layout matching effect shader expectations + let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("effect_bind_group_layout"), + entries: &[ + // Source texture (binding 0) + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + // Sampler (binding 1) + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + // Uniforms (binding 2) + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + ], + }); + + // Create sampler for effect textures + let sampler = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("effect_sampler"), + address_mode_u: wgpu::AddressMode::ClampToEdge, + address_mode_v: wgpu::AddressMode::ClampToEdge, + address_mode_w: wgpu::AddressMode::ClampToEdge, + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + mipmap_filter: wgpu::FilterMode::Nearest, + ..Default::default() + }); + + Self { + compiled_effects: HashMap::new(), + bind_group_layout, + sampler, + output_format, + } + } + + /// Compile an effect definition into a GPU pipeline + /// + /// Returns true if compilation was successful, false if the shader failed to compile. + pub fn compile_effect(&mut self, device: &wgpu::Device, definition: &EffectDefinition) -> bool { + // Check if already compiled + if self.compiled_effects.contains_key(&definition.id) { + return true; + } + + // Create pipeline layout + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some(&format!("effect_pipeline_layout_{}", definition.name)), + bind_group_layouts: &[&self.bind_group_layout], + push_constant_ranges: &[], + }); + + // Create shader module from embedded WGSL + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some(&format!("effect_shader_{}", definition.name)), + source: wgpu::ShaderSource::Wgsl(definition.shader_code.as_str().into()), + }); + + // Create render pipeline + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some(&format!("effect_pipeline_{}", definition.name)), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + buffers: &[], + compilation_options: wgpu::PipelineCompilationOptions::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_main"), + targets: &[Some(wgpu::ColorTargetState { + format: self.output_format, + // No blending - effect completely replaces the pixel + blend: None, + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: wgpu::PipelineCompilationOptions::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleStrip, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: None, + polygon_mode: wgpu::PolygonMode::Fill, + unclipped_depth: false, + conservative: false, + }, + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + multiview: None, + cache: None, + }); + + self.compiled_effects.insert(definition.id, CompiledEffect { + pipeline, + }); + + true + } + + /// Remove a compiled effect (e.g., when an effect definition is removed from the document) + pub fn remove_effect(&mut self, effect_id: &Uuid) { + self.compiled_effects.remove(effect_id); + } + + /// Check if an effect is compiled + pub fn is_compiled(&self, effect_id: &Uuid) -> bool { + self.compiled_effects.contains_key(effect_id) + } + + /// Apply an effect instance + /// + /// Renders from source_view to dest_view using the effect shader. + /// Parameters are evaluated at the given time. + pub fn apply_effect( + &self, + device: &wgpu::Device, + queue: &wgpu::Queue, + encoder: &mut wgpu::CommandEncoder, + definition: &EffectDefinition, + instance: &EffectInstance, + source_view: &wgpu::TextureView, + dest_view: &wgpu::TextureView, + width: u32, + height: u32, + time: f64, + ) -> bool { + // Get compiled effect + let Some(compiled) = self.compiled_effects.get(&definition.id) else { + return false; + }; + + // Build uniforms from instance parameters + let param_values = instance.get_uniform_params(time, &definition.parameters); + let mut uniforms = EffectUniforms { + texture_width: width as f32, + texture_height: height as f32, + time: time as f32, + mix: instance.mix as f32, + ..Default::default() + }; + uniforms.set_params(¶m_values); + + // Create uniform buffer + let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("effect_uniforms"), + size: std::mem::size_of::() as u64, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + queue.write_buffer(&uniform_buffer, 0, bytemuck::bytes_of(&uniforms)); + + // Create bind group + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("effect_bind_group"), + layout: &self.bind_group_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(source_view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&self.sampler), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: uniform_buffer.as_entire_binding(), + }, + ], + }); + + // Render pass + let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some(&format!("effect_pass_{}", definition.name)), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: dest_view, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + occlusion_query_set: None, + timestamp_writes: None, + }); + + render_pass.set_pipeline(&compiled.pipeline); + render_pass.set_bind_group(0, &bind_group, &[]); + render_pass.draw(0..4, 0..1); + + true + } + + /// Apply a chain of effects, ping-ponging between buffers + /// + /// This is the main entry point for applying multiple effects to a composition. + /// Effects are applied in order, with the output of each becoming the input of the next. + pub fn apply_effect_chain( + &self, + device: &wgpu::Device, + queue: &wgpu::Queue, + encoder: &mut wgpu::CommandEncoder, + buffer_pool: &mut BufferPool, + definitions: &HashMap, + instances: &[&EffectInstance], + source: BufferHandle, + width: u32, + height: u32, + time: f64, + ) -> Option { + if instances.is_empty() { + return Some(source); + } + + // We need two buffers for ping-ponging + let spec = BufferSpec::new(width, height, BufferFormat::Rgba16Float); + let mut current_source = source; + let mut temp_buffer: Option = None; + + for instance in instances.iter() { + // Skip disabled effects + if !instance.enabled { + continue; + } + + // Get effect definition + let Some(definition) = definitions.get(&instance.effect_id) else { + continue; + }; + + // Acquire destination buffer (reuse temp buffer if available) + let dest = if let Some(buf) = temp_buffer.take() { + buf + } else { + buffer_pool.acquire(device, spec) + }; + + // Get views + let Some(source_view) = buffer_pool.get_view(current_source) else { + continue; + }; + let Some(dest_view) = buffer_pool.get_view(dest) else { + continue; + }; + + // Apply effect + if self.apply_effect( + device, + queue, + encoder, + definition, + instance, + source_view, + dest_view, + width, + height, + time, + ) { + // Swap buffers for next iteration + // Previous source becomes temp (can be reused) + if current_source != source { + temp_buffer = Some(current_source); + } + current_source = dest; + } else { + // Effect failed, release the dest buffer + buffer_pool.release(dest); + } + } + + // Release temp buffer if we still have one + if let Some(buf) = temp_buffer { + buffer_pool.release(buf); + } + + // Return final result (if we processed any effects, it's different from source) + Some(current_source) + } + + /// Get the bind group layout (for external use if needed) + pub fn bind_group_layout(&self) -> &wgpu::BindGroupLayout { + &self.bind_group_layout + } + + /// Get the number of compiled effects + pub fn compiled_count(&self) -> usize { + self.compiled_effects.len() + } + + /// Clear all compiled effects (e.g., on device loss) + pub fn clear(&mut self) { + self.compiled_effects.clear(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_effect_uniforms_size() { + // Verify uniform struct is properly sized for GPU alignment + let size = std::mem::size_of::(); + // 16 floats (64 bytes) + 4 floats (16 bytes) = 80 bytes + assert_eq!(size, 80); + } + + #[test] + fn test_effect_uniforms_default() { + let uniforms = EffectUniforms::default(); + assert_eq!(uniforms.params0, [0.0; 4]); + assert_eq!(uniforms.params1, [0.0; 4]); + assert_eq!(uniforms.params2, [0.0; 4]); + assert_eq!(uniforms.params3, [0.0; 4]); + assert_eq!(uniforms.mix, 1.0); + } + + #[test] + fn test_effect_uniforms_set_params() { + let mut uniforms = EffectUniforms::default(); + uniforms.set_params(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]); + assert_eq!(uniforms.params0, [1.0, 2.0, 3.0, 4.0]); + assert_eq!(uniforms.params1, [5.0, 6.0, 0.0, 0.0]); + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/gpu/mod.rs b/lightningbeam-ui/lightningbeam-core/src/gpu/mod.rs index 3e4cd69..419b741 100644 --- a/lightningbeam-ui/lightningbeam-core/src/gpu/mod.rs +++ b/lightningbeam-ui/lightningbeam-core/src/gpu/mod.rs @@ -7,10 +7,12 @@ pub mod buffer_pool; pub mod compositor; +pub mod effect_processor; // Re-export commonly used types pub use buffer_pool::{BufferHandle, BufferPool, BufferSpec, BufferFormat}; pub use compositor::{Compositor, CompositorLayer, BlendMode}; +pub use effect_processor::{EffectProcessor, EffectUniforms}; /// Standard HDR internal texture format (16-bit float per channel) pub const HDR_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float; diff --git a/lightningbeam-ui/lightningbeam-core/src/layer.rs b/lightningbeam-ui/lightningbeam-core/src/layer.rs index 02bbae1..8f5c745 100644 --- a/lightningbeam-ui/lightningbeam-core/src/layer.rs +++ b/lightningbeam-ui/lightningbeam-core/src/layer.rs @@ -4,6 +4,7 @@ use crate::animation::AnimationData; use crate::clip::ClipInstance; +use crate::effect_layer::EffectLayer; use crate::object::ShapeInstance; use crate::shape::Shape; use serde::{Deserialize, Serialize}; @@ -21,6 +22,8 @@ pub enum LayerType { Video, /// Generic automation layer Automation, + /// Visual effects layer + Effect, } /// Common trait for all layer types @@ -546,6 +549,7 @@ pub enum AnyLayer { Vector(VectorLayer), Audio(AudioLayer), Video(VideoLayer), + Effect(EffectLayer), } impl LayerTrait for AnyLayer { @@ -554,6 +558,7 @@ impl LayerTrait for AnyLayer { AnyLayer::Vector(l) => l.id(), AnyLayer::Audio(l) => l.id(), AnyLayer::Video(l) => l.id(), + AnyLayer::Effect(l) => l.id(), } } @@ -562,6 +567,7 @@ impl LayerTrait for AnyLayer { AnyLayer::Vector(l) => l.name(), AnyLayer::Audio(l) => l.name(), AnyLayer::Video(l) => l.name(), + AnyLayer::Effect(l) => l.name(), } } @@ -570,6 +576,7 @@ impl LayerTrait for AnyLayer { AnyLayer::Vector(l) => l.set_name(name), AnyLayer::Audio(l) => l.set_name(name), AnyLayer::Video(l) => l.set_name(name), + AnyLayer::Effect(l) => l.set_name(name), } } @@ -578,6 +585,7 @@ impl LayerTrait for AnyLayer { AnyLayer::Vector(l) => l.has_custom_name(), AnyLayer::Audio(l) => l.has_custom_name(), AnyLayer::Video(l) => l.has_custom_name(), + AnyLayer::Effect(l) => l.has_custom_name(), } } @@ -586,6 +594,7 @@ impl LayerTrait for AnyLayer { AnyLayer::Vector(l) => l.set_has_custom_name(custom), AnyLayer::Audio(l) => l.set_has_custom_name(custom), AnyLayer::Video(l) => l.set_has_custom_name(custom), + AnyLayer::Effect(l) => l.set_has_custom_name(custom), } } @@ -594,6 +603,7 @@ impl LayerTrait for AnyLayer { AnyLayer::Vector(l) => l.visible(), AnyLayer::Audio(l) => l.visible(), AnyLayer::Video(l) => l.visible(), + AnyLayer::Effect(l) => l.visible(), } } @@ -602,6 +612,7 @@ impl LayerTrait for AnyLayer { AnyLayer::Vector(l) => l.set_visible(visible), AnyLayer::Audio(l) => l.set_visible(visible), AnyLayer::Video(l) => l.set_visible(visible), + AnyLayer::Effect(l) => l.set_visible(visible), } } @@ -610,6 +621,7 @@ impl LayerTrait for AnyLayer { AnyLayer::Vector(l) => l.opacity(), AnyLayer::Audio(l) => l.opacity(), AnyLayer::Video(l) => l.opacity(), + AnyLayer::Effect(l) => l.opacity(), } } @@ -618,6 +630,7 @@ impl LayerTrait for AnyLayer { AnyLayer::Vector(l) => l.set_opacity(opacity), AnyLayer::Audio(l) => l.set_opacity(opacity), AnyLayer::Video(l) => l.set_opacity(opacity), + AnyLayer::Effect(l) => l.set_opacity(opacity), } } @@ -626,6 +639,7 @@ impl LayerTrait for AnyLayer { AnyLayer::Vector(l) => l.volume(), AnyLayer::Audio(l) => l.volume(), AnyLayer::Video(l) => l.volume(), + AnyLayer::Effect(l) => l.volume(), } } @@ -634,6 +648,7 @@ impl LayerTrait for AnyLayer { AnyLayer::Vector(l) => l.set_volume(volume), AnyLayer::Audio(l) => l.set_volume(volume), AnyLayer::Video(l) => l.set_volume(volume), + AnyLayer::Effect(l) => l.set_volume(volume), } } @@ -642,6 +657,7 @@ impl LayerTrait for AnyLayer { AnyLayer::Vector(l) => l.muted(), AnyLayer::Audio(l) => l.muted(), AnyLayer::Video(l) => l.muted(), + AnyLayer::Effect(l) => l.muted(), } } @@ -650,6 +666,7 @@ impl LayerTrait for AnyLayer { AnyLayer::Vector(l) => l.set_muted(muted), AnyLayer::Audio(l) => l.set_muted(muted), AnyLayer::Video(l) => l.set_muted(muted), + AnyLayer::Effect(l) => l.set_muted(muted), } } @@ -658,6 +675,7 @@ impl LayerTrait for AnyLayer { AnyLayer::Vector(l) => l.soloed(), AnyLayer::Audio(l) => l.soloed(), AnyLayer::Video(l) => l.soloed(), + AnyLayer::Effect(l) => l.soloed(), } } @@ -666,6 +684,7 @@ impl LayerTrait for AnyLayer { AnyLayer::Vector(l) => l.set_soloed(soloed), AnyLayer::Audio(l) => l.set_soloed(soloed), AnyLayer::Video(l) => l.set_soloed(soloed), + AnyLayer::Effect(l) => l.set_soloed(soloed), } } @@ -674,6 +693,7 @@ impl LayerTrait for AnyLayer { AnyLayer::Vector(l) => l.locked(), AnyLayer::Audio(l) => l.locked(), AnyLayer::Video(l) => l.locked(), + AnyLayer::Effect(l) => l.locked(), } } @@ -682,6 +702,7 @@ impl LayerTrait for AnyLayer { AnyLayer::Vector(l) => l.set_locked(locked), AnyLayer::Audio(l) => l.set_locked(locked), AnyLayer::Video(l) => l.set_locked(locked), + AnyLayer::Effect(l) => l.set_locked(locked), } } } @@ -693,6 +714,7 @@ impl AnyLayer { AnyLayer::Vector(l) => &l.layer, AnyLayer::Audio(l) => &l.layer, AnyLayer::Video(l) => &l.layer, + AnyLayer::Effect(l) => &l.layer, } } @@ -702,6 +724,7 @@ impl AnyLayer { AnyLayer::Vector(l) => &mut l.layer, AnyLayer::Audio(l) => &mut l.layer, AnyLayer::Video(l) => &mut l.layer, + AnyLayer::Effect(l) => &mut l.layer, } } diff --git a/lightningbeam-ui/lightningbeam-core/src/lib.rs b/lightningbeam-ui/lightningbeam-core/src/lib.rs index 6754105..8559668 100644 --- a/lightningbeam-ui/lightningbeam-core/src/lib.rs +++ b/lightningbeam-ui/lightningbeam-core/src/lib.rs @@ -14,6 +14,9 @@ pub mod layer; pub mod layer_tree; pub mod clip; pub mod instance_group; +pub mod effect; +pub mod effect_layer; +pub mod effect_registry; pub mod document; pub mod renderer; pub mod video; diff --git a/lightningbeam-ui/lightningbeam-core/src/renderer.rs b/lightningbeam-ui/lightningbeam-core/src/renderer.rs index cb02b44..74ef493 100644 --- a/lightningbeam-ui/lightningbeam-core/src/renderer.rs +++ b/lightningbeam-ui/lightningbeam-core/src/renderer.rs @@ -9,7 +9,7 @@ //! The compositing mode enables proper per-layer opacity, blend modes, and effects. use crate::animation::TransformProperty; -use crate::clip::ImageAsset; +use crate::clip::{ClipInstance, ImageAsset}; use crate::document::Document; use crate::gpu::BlendMode; use crate::layer::{AnyLayer, LayerTrait, VectorLayer}; @@ -86,6 +86,18 @@ fn decode_image_asset(asset: &ImageAsset) -> Option { // Per-Layer Rendering for HDR Compositing Pipeline // ============================================================================ +/// Type of rendered layer for compositor handling +#[derive(Clone, Debug)] +pub enum RenderedLayerType { + /// Regular content layer (vector, video) - composite its scene + Content, + /// Effect layer - apply effects to current composite state + Effect { + /// Active effect instances at the current time + effect_instances: Vec, + }, +} + /// Metadata for a rendered layer, used for compositing pub struct RenderedLayer { /// The layer's unique identifier @@ -98,6 +110,8 @@ pub struct RenderedLayer { pub blend_mode: BlendMode, /// Whether this layer has any visible content pub has_content: bool, + /// Type of layer for compositor (content vs effect) + pub layer_type: RenderedLayerType, } impl RenderedLayer { @@ -109,6 +123,7 @@ impl RenderedLayer { opacity: 1.0, blend_mode: BlendMode::Normal, has_content: false, + layer_type: RenderedLayerType::Content, } } @@ -120,6 +135,20 @@ impl RenderedLayer { opacity, blend_mode, has_content: false, + layer_type: RenderedLayerType::Content, + } + } + + /// Create an effect layer with active effect instances + pub fn effect_layer(layer_id: Uuid, opacity: f32, effect_instances: Vec) -> Self { + let has_content = !effect_instances.is_empty(); + Self { + layer_id, + scene: Scene::new(), + opacity, + blend_mode: BlendMode::Normal, + has_content, + layer_type: RenderedLayerType::Effect { effect_instances }, } } } @@ -246,6 +275,16 @@ pub fn render_layer_isolated( ); rendered.has_content = !video_layer.clip_instances.is_empty(); } + AnyLayer::Effect(effect_layer) => { + // Effect layers are processed during compositing, not rendered to scene + // Return early with a dedicated effect layer type + let active_effects: Vec = effect_layer + .active_clip_instances_at(time) + .into_iter() + .cloned() + .collect(); + return RenderedLayer::effect_layer(layer_id, opacity, active_effects); + } } rendered @@ -395,6 +434,9 @@ fn render_layer( let mut video_mgr = video_manager.lock().unwrap(); render_video_layer(document, time, video_layer, scene, base_transform, parent_opacity, &mut video_mgr); } + AnyLayer::Effect(_) => { + // Effect layers are processed during GPU compositing, not rendered to scene + } } } diff --git a/lightningbeam-ui/lightningbeam-core/src/shaders/effect_blur.wgsl b/lightningbeam-ui/lightningbeam-core/src/shaders/effect_blur.wgsl new file mode 100644 index 0000000..367c85d --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/shaders/effect_blur.wgsl @@ -0,0 +1,73 @@ +// Gaussian Blur Effect Shader +// Simple box blur approximation (real Gaussian would need multiple passes) + +struct Uniforms { + // params packed as vec4s for proper 16-byte alignment + params0: vec4, + params1: vec4, + params2: vec4, + params3: vec4, + texture_width: f32, + texture_height: f32, + time: f32, + mix: f32, +} + +struct VertexOutput { + @builtin(position) position: vec4, + @location(0) uv: vec2, +} + +@group(0) @binding(0) var source_tex: texture_2d; +@group(0) @binding(1) var source_sampler: sampler; +@group(0) @binding(2) var uniforms: Uniforms; + +@vertex +fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput { + var out: VertexOutput; + let x = f32((vertex_index & 1u) << 1u); + let y = f32(vertex_index & 2u); + out.position = vec4(x * 2.0 - 1.0, 1.0 - y * 2.0, 0.0, 1.0); + out.uv = vec2(x, y); + return out; +} + +@fragment +fn fs_main(in: VertexOutput) -> @location(0) vec4 { + let src = textureSample(source_tex, source_sampler, in.uv); + let radius = uniforms.params0.x; // Blur radius in pixels + let quality = uniforms.params0.y; // Quality (0-1, affects sample count) + + if (radius < 0.5) { + return vec4(mix(src.rgb, src.rgb, uniforms.mix), src.a); + } + + let pixel_size = vec2(1.0 / uniforms.texture_width, 1.0 / uniforms.texture_height); + + // Sample count based on quality (5-13 samples per direction) + let samples = i32(5.0 + quality * 8.0); + let half_samples = samples / 2; + + var color = vec3(0.0); + var total_weight = 0.0; + + // Simple box blur with gaussian-like weighting + for (var y = -half_samples; y <= half_samples; y++) { + for (var x = -half_samples; x <= half_samples; x++) { + let offset = vec2(f32(x), f32(y)) * pixel_size * radius / f32(half_samples); + let sample_pos = in.uv + offset; + + // Gaussian-like weight based on distance + let dist = length(vec2(f32(x), f32(y))) / f32(half_samples); + let weight = exp(-dist * dist * 2.0); + + color += textureSample(source_tex, source_sampler, sample_pos).rgb * weight; + total_weight += weight; + } + } + + color /= total_weight; + + let result = mix(src.rgb, color, uniforms.mix); + return vec4(result, src.a); +} diff --git a/lightningbeam-ui/lightningbeam-core/src/shaders/effect_brightness_contrast.wgsl b/lightningbeam-ui/lightningbeam-core/src/shaders/effect_brightness_contrast.wgsl new file mode 100644 index 0000000..92c2350 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/shaders/effect_brightness_contrast.wgsl @@ -0,0 +1,51 @@ +// Brightness/Contrast Effect Shader + +struct Uniforms { + // params packed as vec4s for proper 16-byte alignment + params0: vec4, + params1: vec4, + params2: vec4, + params3: vec4, + texture_width: f32, + texture_height: f32, + time: f32, + mix: f32, +} + +struct VertexOutput { + @builtin(position) position: vec4, + @location(0) uv: vec2, +} + +@group(0) @binding(0) var source_tex: texture_2d; +@group(0) @binding(1) var source_sampler: sampler; +@group(0) @binding(2) var uniforms: Uniforms; + +@vertex +fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput { + var out: VertexOutput; + let x = f32((vertex_index & 1u) << 1u); + let y = f32(vertex_index & 2u); + out.position = vec4(x * 2.0 - 1.0, 1.0 - y * 2.0, 0.0, 1.0); + out.uv = vec2(x, y); + return out; +} + +@fragment +fn fs_main(in: VertexOutput) -> @location(0) vec4 { + let src = textureSample(source_tex, source_sampler, in.uv); + let brightness = uniforms.params0.x; // -1 to 1 + let contrast = uniforms.params0.y; // 0 to 3 + + // Apply brightness (additive) + var color = src.rgb + vec3(brightness); + + // Apply contrast (multiply around midpoint 0.5) + color = (color - vec3(0.5)) * contrast + vec3(0.5); + + // Clamp to valid range + color = clamp(color, vec3(0.0), vec3(1.0)); + + let result = mix(src.rgb, color, uniforms.mix); + return vec4(result, src.a); +} diff --git a/lightningbeam-ui/lightningbeam-core/src/shaders/effect_color_tint.wgsl b/lightningbeam-ui/lightningbeam-core/src/shaders/effect_color_tint.wgsl new file mode 100644 index 0000000..bd3c6b9 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/shaders/effect_color_tint.wgsl @@ -0,0 +1,47 @@ +// Color Tint Effect Shader + +struct Uniforms { + // params packed as vec4s for proper 16-byte alignment + params0: vec4, + params1: vec4, + params2: vec4, + params3: vec4, + texture_width: f32, + texture_height: f32, + time: f32, + mix: f32, +} + +struct VertexOutput { + @builtin(position) position: vec4, + @location(0) uv: vec2, +} + +@group(0) @binding(0) var source_tex: texture_2d; +@group(0) @binding(1) var source_sampler: sampler; +@group(0) @binding(2) var uniforms: Uniforms; + +@vertex +fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput { + var out: VertexOutput; + let x = f32((vertex_index & 1u) << 1u); + let y = f32(vertex_index & 2u); + out.position = vec4(x * 2.0 - 1.0, 1.0 - y * 2.0, 0.0, 1.0); + out.uv = vec2(x, y); + return out; +} + +@fragment +fn fs_main(in: VertexOutput) -> @location(0) vec4 { + let src = textureSample(source_tex, source_sampler, in.uv); + + // Tint color from params (RGBA in params0) + let tint = uniforms.params0.xyz; + let amount = uniforms.params1.x; // Amount parameter (params[4]) + + // Apply tint as multiplicative blend + let tinted = src.rgb * mix(vec3(1.0), tint, amount); + + let result = mix(src.rgb, tinted, uniforms.mix); + return vec4(result, src.a); +} diff --git a/lightningbeam-ui/lightningbeam-core/src/shaders/effect_grayscale.wgsl b/lightningbeam-ui/lightningbeam-core/src/shaders/effect_grayscale.wgsl new file mode 100644 index 0000000..08cdd76 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/shaders/effect_grayscale.wgsl @@ -0,0 +1,47 @@ +// Grayscale Effect Shader +// Converts image to grayscale using luminance weights + +struct Uniforms { + // params packed as vec4s for proper 16-byte alignment + params0: vec4, + params1: vec4, + params2: vec4, + params3: vec4, + texture_width: f32, + texture_height: f32, + time: f32, + mix: f32, +} + +struct VertexOutput { + @builtin(position) position: vec4, + @location(0) uv: vec2, +} + +@group(0) @binding(0) var source_tex: texture_2d; +@group(0) @binding(1) var source_sampler: sampler; +@group(0) @binding(2) var uniforms: Uniforms; + +// Fullscreen triangle vertex shader +@vertex +fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput { + var out: VertexOutput; + let x = f32((vertex_index & 1u) << 1u); + let y = f32(vertex_index & 2u); + out.position = vec4(x * 2.0 - 1.0, 1.0 - y * 2.0, 0.0, 1.0); + out.uv = vec2(x, y); + return out; +} + +@fragment +fn fs_main(in: VertexOutput) -> @location(0) vec4 { + let src = textureSample(source_tex, source_sampler, in.uv); + let amount = uniforms.params0.x; // grayscale amount + + // ITU-R BT.709 luminance coefficients + let luminance = dot(src.rgb, vec3(0.2126, 0.7152, 0.0722)); + let gray = vec3(luminance, luminance, luminance); + + let result = mix(src.rgb, gray, amount * uniforms.mix); + return vec4(result, src.a); +} diff --git a/lightningbeam-ui/lightningbeam-core/src/shaders/effect_hue_saturation.wgsl b/lightningbeam-ui/lightningbeam-core/src/shaders/effect_hue_saturation.wgsl new file mode 100644 index 0000000..3553417 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/shaders/effect_hue_saturation.wgsl @@ -0,0 +1,107 @@ +// Hue/Saturation/Lightness Effect Shader + +struct Uniforms { + // params packed as vec4s for proper 16-byte alignment + params0: vec4, + params1: vec4, + params2: vec4, + params3: vec4, + texture_width: f32, + texture_height: f32, + time: f32, + mix: f32, +} + +struct VertexOutput { + @builtin(position) position: vec4, + @location(0) uv: vec2, +} + +@group(0) @binding(0) var source_tex: texture_2d; +@group(0) @binding(1) var source_sampler: sampler; +@group(0) @binding(2) var uniforms: Uniforms; + +@vertex +fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput { + var out: VertexOutput; + let x = f32((vertex_index & 1u) << 1u); + let y = f32(vertex_index & 2u); + out.position = vec4(x * 2.0 - 1.0, 1.0 - y * 2.0, 0.0, 1.0); + out.uv = vec2(x, y); + return out; +} + +// Convert RGB to HSL +fn rgb_to_hsl(c: vec3) -> vec3 { + let cmax = max(max(c.r, c.g), c.b); + let cmin = min(min(c.r, c.g), c.b); + let delta = cmax - cmin; + + var h = 0.0; + var s = 0.0; + let l = (cmax + cmin) / 2.0; + + if (delta > 0.0) { + s = select(delta / (2.0 - cmax - cmin), delta / (cmax + cmin), l < 0.5); + + if (cmax == c.r) { + h = (c.g - c.b) / delta + select(6.0, 0.0, c.g >= c.b); + } else if (cmax == c.g) { + h = (c.b - c.r) / delta + 2.0; + } else { + h = (c.r - c.g) / delta + 4.0; + } + h /= 6.0; + } + + return vec3(h, s, l); +} + +// Helper function for HSL to RGB +fn hue_to_rgb(p: f32, q: f32, t: f32) -> f32 { + var tt = t; + if (tt < 0.0) { tt += 1.0; } + if (tt > 1.0) { tt -= 1.0; } + if (tt < 1.0/6.0) { return p + (q - p) * 6.0 * tt; } + if (tt < 1.0/2.0) { return q; } + if (tt < 2.0/3.0) { return p + (q - p) * (2.0/3.0 - tt) * 6.0; } + return p; +} + +// Convert HSL to RGB +fn hsl_to_rgb(hsl: vec3) -> vec3 { + if (hsl.y == 0.0) { + return vec3(hsl.z, hsl.z, hsl.z); + } + + let q = select(hsl.z + hsl.y - hsl.z * hsl.y, hsl.z * (1.0 + hsl.y), hsl.z < 0.5); + let p = 2.0 * hsl.z - q; + + return vec3( + hue_to_rgb(p, q, hsl.x + 1.0/3.0), + hue_to_rgb(p, q, hsl.x), + hue_to_rgb(p, q, hsl.x - 1.0/3.0) + ); +} + +@fragment +fn fs_main(in: VertexOutput) -> @location(0) vec4 { + let src = textureSample(source_tex, source_sampler, in.uv); + let hue_shift = uniforms.params0.x / 360.0; // Convert degrees to 0-1 range + let saturation = uniforms.params0.y; // Multiplier (1.0 = no change) + let lightness = uniforms.params0.z; // Additive (-1 to 1) + + // Convert to HSL + var hsl = rgb_to_hsl(src.rgb); + + // Apply adjustments + hsl.x = fract(hsl.x + hue_shift); // Shift hue (wrapping) + hsl.y = clamp(hsl.y * saturation, 0.0, 1.0); // Multiply saturation + hsl.z = clamp(hsl.z + lightness, 0.0, 1.0); // Add lightness + + // Convert back to RGB + let adjusted = hsl_to_rgb(hsl); + + let result = mix(src.rgb, adjusted, uniforms.mix); + return vec4(result, src.a); +} diff --git a/lightningbeam-ui/lightningbeam-core/src/shaders/effect_invert.wgsl b/lightningbeam-ui/lightningbeam-core/src/shaders/effect_invert.wgsl new file mode 100644 index 0000000..8bb5b28 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/shaders/effect_invert.wgsl @@ -0,0 +1,45 @@ +// Invert Effect Shader +// Inverts color values + +struct Uniforms { + // params packed as vec4s for proper 16-byte alignment + // params[0-3] in params0, params[4-7] in params1, etc. + params0: vec4, + params1: vec4, + params2: vec4, + params3: vec4, + texture_width: f32, + texture_height: f32, + time: f32, + mix: f32, +} + +struct VertexOutput { + @builtin(position) position: vec4, + @location(0) uv: vec2, +} + +@group(0) @binding(0) var source_tex: texture_2d; +@group(0) @binding(1) var source_sampler: sampler; +@group(0) @binding(2) var uniforms: Uniforms; + +@vertex +fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput { + var out: VertexOutput; + let x = f32((vertex_index & 1u) << 1u); + let y = f32(vertex_index & 2u); + out.position = vec4(x * 2.0 - 1.0, 1.0 - y * 2.0, 0.0, 1.0); + out.uv = vec2(x, y); + return out; +} + +@fragment +fn fs_main(in: VertexOutput) -> @location(0) vec4 { + let src = textureSample(source_tex, source_sampler, in.uv); + let amount = uniforms.params0.x; // params[0] + + let inverted = vec3(1.0) - src.rgb; + let result = mix(src.rgb, inverted, amount * uniforms.mix); + + return vec4(result, src.a); +} diff --git a/lightningbeam-ui/lightningbeam-core/src/shaders/effect_sharpen.wgsl b/lightningbeam-ui/lightningbeam-core/src/shaders/effect_sharpen.wgsl new file mode 100644 index 0000000..09588a3 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/shaders/effect_sharpen.wgsl @@ -0,0 +1,60 @@ +// Sharpen Effect Shader +// Unsharp mask style sharpening + +struct Uniforms { + // params packed as vec4s for proper 16-byte alignment + params0: vec4, + params1: vec4, + params2: vec4, + params3: vec4, + texture_width: f32, + texture_height: f32, + time: f32, + mix: f32, +} + +struct VertexOutput { + @builtin(position) position: vec4, + @location(0) uv: vec2, +} + +@group(0) @binding(0) var source_tex: texture_2d; +@group(0) @binding(1) var source_sampler: sampler; +@group(0) @binding(2) var uniforms: Uniforms; + +@vertex +fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput { + var out: VertexOutput; + let x = f32((vertex_index & 1u) << 1u); + let y = f32(vertex_index & 2u); + out.position = vec4(x * 2.0 - 1.0, 1.0 - y * 2.0, 0.0, 1.0); + out.uv = vec2(x, y); + return out; +} + +@fragment +fn fs_main(in: VertexOutput) -> @location(0) vec4 { + let src = textureSample(source_tex, source_sampler, in.uv); + let amount = uniforms.params0.x; // Sharpen amount (0-3) + let radius = uniforms.params0.y; // Sample radius (0.5-5) + + let pixel_size = vec2(1.0 / uniforms.texture_width, 1.0 / uniforms.texture_height) * radius; + + // Sample neighbors for edge detection + let left = textureSample(source_tex, source_sampler, in.uv - vec2(pixel_size.x, 0.0)).rgb; + let right = textureSample(source_tex, source_sampler, in.uv + vec2(pixel_size.x, 0.0)).rgb; + let top = textureSample(source_tex, source_sampler, in.uv - vec2(0.0, pixel_size.y)).rgb; + let bottom = textureSample(source_tex, source_sampler, in.uv + vec2(0.0, pixel_size.y)).rgb; + + // Average of neighbors (blur) + let blur = (left + right + top + bottom) * 0.25; + + // Unsharp mask: original + (original - blur) * amount + let sharpened = src.rgb + (src.rgb - blur) * amount; + + // Clamp to valid range + let clamped = clamp(sharpened, vec3(0.0), vec3(1.0)); + + let result = mix(src.rgb, clamped, uniforms.mix); + return vec4(result, src.a); +} diff --git a/lightningbeam-ui/lightningbeam-core/src/shaders/effect_vignette.wgsl b/lightningbeam-ui/lightningbeam-core/src/shaders/effect_vignette.wgsl new file mode 100644 index 0000000..60f5df2 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/shaders/effect_vignette.wgsl @@ -0,0 +1,55 @@ +// Vignette Effect Shader +// Darkens edges of the image + +struct Uniforms { + // params packed as vec4s for proper 16-byte alignment + params0: vec4, + params1: vec4, + params2: vec4, + params3: vec4, + texture_width: f32, + texture_height: f32, + time: f32, + mix: f32, +} + +struct VertexOutput { + @builtin(position) position: vec4, + @location(0) uv: vec2, +} + +@group(0) @binding(0) var source_tex: texture_2d; +@group(0) @binding(1) var source_sampler: sampler; +@group(0) @binding(2) var uniforms: Uniforms; + +@vertex +fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput { + var out: VertexOutput; + let x = f32((vertex_index & 1u) << 1u); + let y = f32(vertex_index & 2u); + out.position = vec4(x * 2.0 - 1.0, 1.0 - y * 2.0, 0.0, 1.0); + out.uv = vec2(x, y); + return out; +} + +@fragment +fn fs_main(in: VertexOutput) -> @location(0) vec4 { + let src = textureSample(source_tex, source_sampler, in.uv); + let radius = uniforms.params0.x; // Vignette radius (0.5 = normal) + let softness = uniforms.params0.y; // Edge softness (0-1) + let amount = uniforms.params0.z; // Darkness amount (0-1) + + // Calculate distance from center (normalized to -1 to 1) + let center = vec2(0.5, 0.5); + let dist = distance(in.uv, center); + + // Create vignette factor with smooth falloff + let inner = radius; + let outer = radius + softness; + let vignette = 1.0 - smoothstep(inner, outer, dist) * amount; + + let vignetted = src.rgb * vignette; + + let result = mix(src.rgb, vignetted, uniforms.mix); + return vec4(result, src.a); +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index 5fb74d8..9085aeb 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -2055,6 +2055,7 @@ impl EditorApp { panes::DragClipType::AudioSampled => "Audio", panes::DragClipType::AudioMidi => "MIDI", panes::DragClipType::Image => "Image", + panes::DragClipType::Effect => "Effect", }); let new_layer = panes::create_layer_for_clip_type(asset_info.clip_type, &layer_name); diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/asset_library.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/asset_library.rs index fab0967..9af24ed 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/asset_library.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/asset_library.rs @@ -514,6 +514,82 @@ fn shape_color_to_tiny_skia(color: &ShapeColor) -> tiny_skia::Color { tiny_skia::Color::from_rgba8(color.r, color.g, color.b, color.a) } +/// Generate a simple effect thumbnail with a pink gradient +fn generate_effect_thumbnail() -> Vec { + let size = THUMBNAIL_SIZE as usize; + let mut rgba = vec![0u8; size * size * 4]; + + // Pink gradient background with "FX" visual indicator + for y in 0..size { + for x in 0..size { + let brightness = 1.0 - (y as f32 / size as f32) * 0.3; + let idx = (y * size + x) * 4; + rgba[idx] = (220.0 * brightness) as u8; // R + rgba[idx + 1] = (80.0 * brightness) as u8; // G + rgba[idx + 2] = (160.0 * brightness) as u8; // B + rgba[idx + 3] = 200; // A + } + } + + // Draw a simple "FX" pattern in the center using darker pixels + let center = size / 2; + let letter_size = size / 4; + + // Draw "F" - vertical bar + for y in (center - letter_size)..(center + letter_size) { + let x = center - letter_size; + let idx = (y * size + x) * 4; + rgba[idx] = 255; + rgba[idx + 1] = 255; + rgba[idx + 2] = 255; + rgba[idx + 3] = 255; + } + // Draw "F" - top horizontal + for x in (center - letter_size)..(center - 2) { + let y = center - letter_size; + let idx = (y * size + x) * 4; + rgba[idx] = 255; + rgba[idx + 1] = 255; + rgba[idx + 2] = 255; + rgba[idx + 3] = 255; + } + // Draw "F" - middle horizontal + for x in (center - letter_size)..(center - 4) { + let y = center; + let idx = (y * size + x) * 4; + rgba[idx] = 255; + rgba[idx + 1] = 255; + rgba[idx + 2] = 255; + rgba[idx + 3] = 255; + } + + // Draw "X" - diagonal lines + for i in 0..letter_size { + // Top-left to bottom-right + let x1 = center + 2 + i; + let y1 = center - letter_size + i * 2; + if x1 < size && y1 < size { + let idx = (y1 * size + x1) * 4; + rgba[idx] = 255; + rgba[idx + 1] = 255; + rgba[idx + 2] = 255; + rgba[idx + 3] = 255; + } + // Top-right to bottom-left + let x2 = center + letter_size - i; + let y2 = center - letter_size + i * 2; + if x2 < size && y2 < size { + let idx = (y2 * size + x2) * 4; + rgba[idx] = 255; + rgba[idx + 1] = 255; + rgba[idx + 2] = 255; + rgba[idx + 3] = 255; + } + } + + rgba +} + /// Ellipsize a string to fit within a maximum character count fn ellipsize(s: &str, max_chars: usize) -> String { if s.chars().count() <= max_chars { @@ -532,6 +608,7 @@ pub enum AssetCategory { Video, Audio, Images, + Effects, } impl AssetCategory { @@ -542,6 +619,7 @@ impl AssetCategory { AssetCategory::Video => "Video", AssetCategory::Audio => "Audio", AssetCategory::Images => "Images", + AssetCategory::Effects => "Effects", } } @@ -552,6 +630,7 @@ impl AssetCategory { AssetCategory::Video, AssetCategory::Audio, AssetCategory::Images, + AssetCategory::Effects, ] } @@ -563,6 +642,7 @@ impl AssetCategory { AssetCategory::Video => egui::Color32::from_rgb(255, 150, 100), // Orange AssetCategory::Audio => egui::Color32::from_rgb(100, 255, 150), // Green AssetCategory::Images => egui::Color32::from_rgb(255, 200, 100), // Yellow/Gold + AssetCategory::Effects => egui::Color32::from_rgb(220, 80, 160), // Pink } } } @@ -578,6 +658,8 @@ pub struct AssetEntry { pub duration: f64, pub dimensions: Option<(f64, f64)>, pub extra_info: String, + /// True for built-in effects from the registry (not editable/deletable) + pub is_builtin: bool, } /// Pending delete confirmation state @@ -658,6 +740,7 @@ impl AssetLibraryPane { duration: clip.duration, dimensions: Some((clip.width, clip.height)), extra_info: format!("{}x{}", clip.width as u32, clip.height as u32), + is_builtin: false, }); } @@ -671,6 +754,7 @@ impl AssetLibraryPane { duration: clip.duration, dimensions: Some((clip.width, clip.height)), extra_info: format!("{:.0}fps", clip.frame_rate), + is_builtin: false, }); } @@ -699,6 +783,7 @@ impl AssetLibraryPane { duration: clip.duration, dimensions: None, extra_info, + is_builtin: false, }); } @@ -712,9 +797,46 @@ impl AssetLibraryPane { duration: 0.0, // Images don't have duration dimensions: Some((asset.width as f64, asset.height as f64)), extra_info: format!("{}x{}", asset.width, asset.height), + is_builtin: false, }); } + // Collect built-in effects from registry + for effect_def in lightningbeam_core::effect_registry::EffectRegistry::get_all() { + assets.push(AssetEntry { + id: effect_def.id, + name: effect_def.name.clone(), + category: AssetCategory::Effects, + drag_clip_type: DragClipType::Effect, + duration: 5.0, // Default duration when dropped + dimensions: None, + extra_info: format!("{:?}", effect_def.category), + is_builtin: true, // Built-in from registry + }); + } + + // Collect user-edited effects from document (that aren't in registry) + let registry_ids: HashSet = lightningbeam_core::effect_registry::EffectRegistry::get_all() + .iter() + .map(|e| e.id) + .collect(); + + for effect_def in document.effect_definitions.values() { + if !registry_ids.contains(&effect_def.id) { + // User-created/modified effect + assets.push(AssetEntry { + id: effect_def.id, + name: effect_def.name.clone(), + category: AssetCategory::Effects, + drag_clip_type: DragClipType::Effect, + duration: 5.0, + dimensions: None, + extra_info: format!("{:?}", effect_def.category), + is_builtin: false, // User effect + }); + } + } + // Sort alphabetically by name assets.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); @@ -729,8 +851,13 @@ impl AssetLibraryPane { .iter() .filter(|asset| { // Category filter - let category_matches = self.selected_category == AssetCategory::All - || asset.category == self.selected_category; + let category_matches = if self.selected_category == AssetCategory::All { + // "All" tab: show everything EXCEPT built-in effects + // (built-in effects only appear in the Effects tab) + !(asset.category == AssetCategory::Effects && asset.is_builtin) + } else { + asset.category == self.selected_category + }; // Search filter let search_matches = @@ -773,6 +900,15 @@ impl AssetLibraryPane { } } } + lightningbeam_core::layer::AnyLayer::Effect(el) => { + if category == AssetCategory::Effects { + for instance in &el.clip_instances { + if instance.clip_id == asset_id { + return true; + } + } + } + } } } false @@ -793,6 +929,9 @@ impl AssetLibraryPane { AssetCategory::Images => { document.remove_image_asset(&asset_id); } + AssetCategory::Effects => { + document.effect_definitions.remove(&asset_id); + } AssetCategory::All => {} // Not a real category for deletion } } @@ -820,6 +959,11 @@ impl AssetLibraryPane { asset.name = new_name.to_string(); } } + AssetCategory::Effects => { + if let Some(effect) = document.effect_definitions.get_mut(&asset_id) { + effect.name = new_name.to_string(); + } + } AssetCategory::All => {} // Not a real category for renaming } } @@ -1268,6 +1412,9 @@ impl AssetLibraryPane { Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200)) } } + AssetCategory::Effects => { + Some(generate_effect_thumbnail()) + } AssetCategory::All => None, } }); @@ -1555,6 +1702,9 @@ impl AssetLibraryPane { Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200)) } } + AssetCategory::Effects => { + Some(generate_effect_thumbnail()) + } AssetCategory::All => None, } }); @@ -1739,6 +1889,7 @@ impl PaneRenderer for AssetLibraryPane { if let Some(asset) = all_assets.iter().find(|a| a.id == context_asset_id) { let asset_name = asset.name.clone(); let asset_category = asset.category; + let asset_is_builtin = asset.is_builtin; let in_use = Self::is_asset_in_use( shared.action_executor.document(), context_asset_id, @@ -1754,25 +1905,32 @@ impl PaneRenderer for AssetLibraryPane { egui::Frame::popup(ui.style()).show(ui, |ui| { ui.set_min_width(120.0); - if ui.button("Rename").clicked() { - // Start inline rename - self.rename_state = Some(RenameState { - asset_id: context_asset_id, - category: asset_category, - edit_text: asset_name.clone(), - }); - self.context_menu = None; - } + // Built-in effects cannot be renamed or deleted + if asset_is_builtin { + ui.label(egui::RichText::new("Built-in effect") + .color(egui::Color32::from_gray(120)) + .italics()); + } else { + if ui.button("Rename").clicked() { + // Start inline rename + self.rename_state = Some(RenameState { + asset_id: context_asset_id, + category: asset_category, + edit_text: asset_name.clone(), + }); + self.context_menu = None; + } - if ui.button("Delete").clicked() { - // Set up pending delete confirmation - self.pending_delete = Some(PendingDelete { - asset_id: context_asset_id, - asset_name: asset_name.clone(), - category: asset_category, - in_use, - }); - self.context_menu = None; + if ui.button("Delete").clicked() { + // Set up pending delete confirmation + self.pending_delete = Some(PendingDelete { + asset_id: context_asset_id, + asset_name: asset_name.clone(), + category: asset_category, + in_use, + }); + self.context_menu = None; + } } }); }); diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs index 645f495..5a4256b 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs @@ -33,6 +33,8 @@ pub enum DragClipType { AudioMidi, /// Static image asset Image, + /// Effect (shader-based visual effect) + Effect, } /// Information about an asset being dragged from the Asset Library @@ -91,6 +93,7 @@ pub fn layer_matches_clip_type(layer: &lightningbeam_core::layer::AnyLayer, clip (AnyLayer::Audio(audio), DragClipType::AudioMidi) => { audio.audio_layer_type == AudioLayerType::Midi } + (AnyLayer::Effect(_), DragClipType::Effect) => true, _ => false, } } @@ -98,6 +101,7 @@ pub fn layer_matches_clip_type(layer: &lightningbeam_core::layer::AnyLayer, clip /// Create a new layer of the appropriate type for a clip pub fn create_layer_for_clip_type(clip_type: DragClipType, name: &str) -> lightningbeam_core::layer::AnyLayer { use lightningbeam_core::layer::*; + use lightningbeam_core::effect_layer::EffectLayer; match clip_type { DragClipType::Vector => AnyLayer::Vector(VectorLayer::new(name)), DragClipType::Video => AnyLayer::Video(VideoLayer::new(name)), @@ -105,6 +109,7 @@ pub fn create_layer_for_clip_type(clip_type: DragClipType, name: &str) -> lightn DragClipType::AudioMidi => AnyLayer::Audio(AudioLayer::new_midi(name)), // Images are placed as shapes on vector layers, not their own layer type DragClipType::Image => AnyLayer::Vector(VectorLayer::new(name)), + DragClipType::Effect => AnyLayer::Effect(EffectLayer::new(name)), } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs index 3deb7db..df6f205 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs @@ -6,8 +6,9 @@ use eframe::egui; use lightningbeam_core::action::Action; use lightningbeam_core::clip::ClipInstance; -use lightningbeam_core::gpu::{BufferPool, Compositor}; +use lightningbeam_core::gpu::{BufferPool, BufferFormat, BufferSpec, Compositor, EffectProcessor, HDR_FORMAT}; use lightningbeam_core::layer::{AnyLayer, AudioLayer, AudioLayerType, VideoLayer, VectorLayer}; +use lightningbeam_core::renderer::RenderedLayerType; use super::{DragClipType, NodePath, PaneRenderer, SharedPaneState}; use std::sync::{Arc, Mutex, OnceLock}; use vello::kurbo::Shape; @@ -32,6 +33,8 @@ struct SharedVelloResources { buffer_pool: Mutex, /// Compositor for layer blending compositor: Compositor, + /// Effect processor for GPU shader effects + effect_processor: Mutex, } /// Per-instance Vello resources (created for each Stage pane) @@ -196,7 +199,10 @@ impl SharedVelloResources { // Use HDR format for internal compositing let compositor = Compositor::new(device, lightningbeam_core::gpu::HDR_FORMAT); - println!("✅ Vello shared resources initialized (renderer, shaders, and HDR compositor)"); + // Initialize effect processor for GPU shader effects + let effect_processor = EffectProcessor::new(device, lightningbeam_core::gpu::HDR_FORMAT); + + println!("✅ Vello shared resources initialized (renderer, shaders, HDR compositor, and effect processor)"); Ok(Self { renderer: Arc::new(Mutex::new(renderer)), @@ -208,6 +214,7 @@ impl SharedVelloResources { video_manager, buffer_pool: Mutex::new(buffer_pool), compositor, + effect_processor: Mutex::new(effect_processor), }) } } @@ -490,47 +497,144 @@ impl egui_wgpu::CallbackTrait for VelloCallback { } buffer_pool.release(bg_handle); + // HDR buffer spec for effect processing + let hdr_spec = BufferSpec::new(width, height, BufferFormat::Rgba16Float); + + // Lock effect processor + let mut effect_processor = shared.effect_processor.lock().unwrap(); + // Now render and composite each layer incrementally for rendered_layer in &composite_result.layers { if !rendered_layer.has_content { continue; } - // Acquire a buffer for this layer - let layer_handle = buffer_pool.acquire(device, layer_spec); + match &rendered_layer.layer_type { + RenderedLayerType::Content => { + // Regular content layer - render and composite as before + let layer_handle = buffer_pool.acquire(device, layer_spec); - if let (Some(layer_view), Some(hdr_view)) = (buffer_pool.get_view(layer_handle), &instance_resources.hdr_texture_view) { - // Render layer scene to buffer - if let Ok(mut renderer) = shared.renderer.lock() { - renderer.render_to_texture(device, queue, &rendered_layer.scene, layer_view, &layer_render_params).ok(); + if let (Some(layer_view), Some(hdr_view)) = (buffer_pool.get_view(layer_handle), &instance_resources.hdr_texture_view) { + // Render layer scene to buffer + if let Ok(mut renderer) = shared.renderer.lock() { + renderer.render_to_texture(device, queue, &rendered_layer.scene, layer_view, &layer_render_params).ok(); + } + + // Composite this layer onto the HDR accumulator with its opacity + let compositor_layer = lightningbeam_core::gpu::CompositorLayer::new( + layer_handle, + rendered_layer.opacity, + rendered_layer.blend_mode, + ); + + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("layer_composite_encoder"), + }); + shared.compositor.composite( + device, + queue, + &mut encoder, + &[compositor_layer], + &buffer_pool, + hdr_view, + None, // Don't clear - blend onto existing content + ); + queue.submit(Some(encoder.finish())); + } + + buffer_pool.release(layer_handle); } + RenderedLayerType::Effect { effect_instances } => { + // Effect layer - apply effects to the current HDR accumulator + let current_time = self.document.current_time; - // Composite this layer onto the HDR accumulator with its opacity - let compositor_layer = lightningbeam_core::gpu::CompositorLayer::new( - layer_handle, - rendered_layer.opacity, - rendered_layer.blend_mode, - ); + for effect_instance in effect_instances { + // Get effect definition from document + let Some(effect_def) = self.document.get_effect_definition(&effect_instance.clip_id) else { + println!("Effect definition not found for clip_id: {:?}", effect_instance.clip_id); + continue; + }; - let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { - label: Some("layer_composite_encoder"), - }); - shared.compositor.composite( - device, - queue, - &mut encoder, - &[compositor_layer], - &buffer_pool, - hdr_view, - None, // Don't clear - blend onto existing content - ); - queue.submit(Some(encoder.finish())); + // Compile effect if needed + if !effect_processor.is_compiled(&effect_def.id) { + let success = effect_processor.compile_effect(device, effect_def); + if !success { + eprintln!("Failed to compile effect: {}", effect_def.name); + continue; + } + println!("Compiled effect: {}", effect_def.name); + } + + // Create EffectInstance from ClipInstance for the processor + // For now, create a simple effect instance with default parameters + let effect_inst = lightningbeam_core::effect::EffectInstance::new( + effect_def, + effect_instance.timeline_start, + effect_instance.timeline_start + effect_instance.effective_duration(lightningbeam_core::effect::EFFECT_DURATION), + ); + + // Acquire temp buffer for effect output (HDR format) + let effect_output_handle = buffer_pool.acquire(device, hdr_spec); + + if let (Some(hdr_view), Some(effect_output_view)) = ( + &instance_resources.hdr_texture_view, + buffer_pool.get_view(effect_output_handle), + ) { + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("effect_encoder"), + }); + + // Apply effect: HDR accumulator → effect output buffer + let applied = effect_processor.apply_effect( + device, + queue, + &mut encoder, + effect_def, + &effect_inst, + hdr_view, + effect_output_view, + width, + height, + current_time, + ); + + if applied { + queue.submit(Some(encoder.finish())); + + // Copy effect output back to HDR accumulator + // We need to blit the effect result back to the HDR texture + let mut copy_encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("effect_copy_encoder"), + }); + + // Use compositor to copy (with opacity 1.0, replacing content) + let effect_layer = lightningbeam_core::gpu::CompositorLayer::normal( + effect_output_handle, + rendered_layer.opacity, // Apply effect layer opacity + ); + shared.compositor.composite( + device, + queue, + &mut copy_encoder, + &[effect_layer], + &buffer_pool, + hdr_view, + Some([0.0, 0.0, 0.0, 0.0]), // Clear with transparent (we're replacing) + ); + queue.submit(Some(copy_encoder.finish())); + } else { + eprintln!("Effect {} failed to apply", effect_def.name); + } + } + + buffer_pool.release(effect_output_handle); + } + } } - - // Release buffer immediately - it can be reused for next layer - buffer_pool.release(layer_handle); } + drop(effect_processor); + // Advance frame counter for buffer cleanup buffer_pool.next_frame(); drop(buffer_pool); @@ -4982,6 +5086,7 @@ impl PaneRenderer for StagePane { DragClipType::AudioSampled => "Audio", DragClipType::AudioMidi => "MIDI", DragClipType::Image => "Image", + DragClipType::Effect => "Effect", }); let new_layer = super::create_layer_for_clip_type(dragging.clip_type, &layer_name); @@ -5030,6 +5135,30 @@ impl PaneRenderer for StagePane { shape_instance, ); shared.pending_actions.push(Box::new(action)); + } else if dragging.clip_type == DragClipType::Effect { + // Handle effect drops specially + // Get effect definition from registry or document + let effect_def = lightningbeam_core::effect_registry::EffectRegistry::get_by_id(&dragging.clip_id) + .or_else(|| shared.action_executor.document().get_effect_definition(&dragging.clip_id).cloned()); + + if let Some(def) = effect_def { + // Ensure effect definition is in document (copy from registry if built-in) + if shared.action_executor.document().get_effect_definition(&def.id).is_none() { + shared.action_executor.document_mut().add_effect_definition(def.clone()); + } + + // Create clip instance for effect with 5 second default duration + let clip_instance = ClipInstance::new(def.id) + .with_timeline_start(drop_time) + .with_timeline_duration(5.0); + + // Use AddEffectAction for effect layers + let action = lightningbeam_core::actions::AddEffectAction::new( + layer_id, + clip_instance, + ); + shared.pending_actions.push(Box::new(action)); + } } else { // For clips, create a clip instance let mut clip_instance = ClipInstance::new(dragging.clip_id) diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs index d8c382e..e16a406 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs @@ -68,6 +68,7 @@ fn can_drop_on_layer(layer: &AnyLayer, clip_type: DragClipType) -> bool { (AnyLayer::Audio(audio), DragClipType::AudioMidi) => { audio.audio_layer_type == AudioLayerType::Midi } + (AnyLayer::Effect(_), DragClipType::Effect) => true, _ => false, } } @@ -171,6 +172,7 @@ impl TimelinePane { lightningbeam_core::layer::AnyLayer::Vector(vl) => &vl.clip_instances, lightningbeam_core::layer::AnyLayer::Audio(al) => &al.clip_instances, lightningbeam_core::layer::AnyLayer::Video(vl) => &vl.clip_instances, + lightningbeam_core::layer::AnyLayer::Effect(el) => &el.clip_instances, }; // Check each clip instance @@ -185,6 +187,9 @@ impl TimelinePane { lightningbeam_core::layer::AnyLayer::Video(_) => { document.get_video_clip(&clip_instance.clip_id).map(|c| c.duration) } + lightningbeam_core::layer::AnyLayer::Effect(_) => { + Some(lightningbeam_core::effect::EFFECT_DURATION) + } }?; let instance_duration = clip_instance.effective_duration(clip_duration); @@ -860,6 +865,7 @@ impl TimelinePane { } } lightningbeam_core::layer::AnyLayer::Video(_) => ("Video", egui::Color32::from_rgb(180, 100, 255)), // Purple + lightningbeam_core::layer::AnyLayer::Effect(_) => ("Effect", egui::Color32::from_rgb(255, 100, 180)), // Pink }; // Color indicator bar on the left edge @@ -1154,6 +1160,7 @@ impl TimelinePane { lightningbeam_core::layer::AnyLayer::Vector(vl) => &vl.clip_instances, lightningbeam_core::layer::AnyLayer::Audio(al) => &al.clip_instances, lightningbeam_core::layer::AnyLayer::Video(vl) => &vl.clip_instances, + lightningbeam_core::layer::AnyLayer::Effect(el) => &el.clip_instances, }; for clip_instance in clip_instances { @@ -1171,6 +1178,9 @@ impl TimelinePane { document.get_video_clip(&clip_instance.clip_id) .map(|c| c.duration) } + lightningbeam_core::layer::AnyLayer::Effect(_) => { + Some(lightningbeam_core::effect::EFFECT_DURATION) + } }; if let Some(clip_duration) = clip_duration { @@ -1329,6 +1339,10 @@ impl TimelinePane { egui::Color32::from_rgb(150, 80, 220), // Purple egui::Color32::from_rgb(200, 150, 255), // Bright purple ), + lightningbeam_core::layer::AnyLayer::Effect(_) => ( + egui::Color32::from_rgb(220, 80, 160), // Pink + egui::Color32::from_rgb(255, 120, 200), // Bright pink + ), }; let clip_rect = egui::Rect::from_min_max( @@ -1552,6 +1566,7 @@ impl TimelinePane { lightningbeam_core::layer::AnyLayer::Vector(vl) => &vl.clip_instances, lightningbeam_core::layer::AnyLayer::Audio(al) => &al.clip_instances, lightningbeam_core::layer::AnyLayer::Video(vl) => &vl.clip_instances, + lightningbeam_core::layer::AnyLayer::Effect(el) => &el.clip_instances, }; // Check if click is within any clip instance @@ -1570,6 +1585,9 @@ impl TimelinePane { document.get_video_clip(&clip_instance.clip_id) .map(|c| c.duration) } + lightningbeam_core::layer::AnyLayer::Effect(_) => { + Some(lightningbeam_core::effect::EFFECT_DURATION) + } }; if let Some(clip_duration) = clip_duration { @@ -1678,6 +1696,7 @@ impl TimelinePane { lightningbeam_core::layer::AnyLayer::Vector(vl) => &vl.clip_instances, lightningbeam_core::layer::AnyLayer::Audio(al) => &al.clip_instances, lightningbeam_core::layer::AnyLayer::Video(vl) => &vl.clip_instances, + lightningbeam_core::layer::AnyLayer::Effect(el) => &el.clip_instances, }; // Find selected clip instances in this layer @@ -1734,6 +1753,9 @@ impl TimelinePane { lightningbeam_core::layer::AnyLayer::Video(vl) => { &vl.clip_instances } + lightningbeam_core::layer::AnyLayer::Effect(el) => { + &el.clip_instances + } }; // Find selected clip instances in this layer @@ -1756,6 +1778,9 @@ impl TimelinePane { .get_video_clip(&clip_instance.clip_id) .map(|c| c.duration) } + lightningbeam_core::layer::AnyLayer::Effect(_) => { + Some(lightningbeam_core::effect::EFFECT_DURATION) + } }; if let Some(clip_duration) = clip_duration { @@ -2113,6 +2138,7 @@ impl PaneRenderer for TimelinePane { lightningbeam_core::layer::AnyLayer::Vector(vl) => &vl.clip_instances, lightningbeam_core::layer::AnyLayer::Audio(al) => &al.clip_instances, lightningbeam_core::layer::AnyLayer::Video(vl) => &vl.clip_instances, + lightningbeam_core::layer::AnyLayer::Effect(el) => &el.clip_instances, }; for clip_instance in clip_instances { @@ -2130,6 +2156,9 @@ impl PaneRenderer for TimelinePane { document.get_video_clip(&clip_instance.clip_id) .map(|c| c.duration) } + lightningbeam_core::layer::AnyLayer::Effect(_) => { + Some(lightningbeam_core::effect::EFFECT_DURATION) + } }; if let Some(clip_duration) = clip_duration { @@ -2357,103 +2386,216 @@ impl PaneRenderer for TimelinePane { let layer_id = layer.id(); let drop_time = self.x_to_time(pointer_pos.x - content_rect.min.x).max(0.0); - // Get document dimensions for centering and create clip instance - let (center_x, center_y, mut clip_instance) = { - let doc = shared.action_executor.document(); - let center_x = doc.width / 2.0; - let center_y = doc.height / 2.0; + // Handle effect drops specially + if dragging.clip_type == DragClipType::Effect { + // Get effect definition from registry or document + let effect_def = lightningbeam_core::effect_registry::EffectRegistry::get_by_id(&dragging.clip_id) + .or_else(|| shared.action_executor.document().get_effect_definition(&dragging.clip_id).cloned()); - let mut clip_instance = ClipInstance::new(dragging.clip_id) - .with_timeline_start(drop_time); + if let Some(def) = effect_def { + // Ensure effect definition is in document (copy from registry if built-in) + if shared.action_executor.document().get_effect_definition(&def.id).is_none() { + shared.action_executor.document_mut().add_effect_definition(def.clone()); + } - // For video clips, scale to fill document dimensions - if dragging.clip_type == DragClipType::Video { - if let Some((video_width, video_height)) = dragging.dimensions { - // Calculate scale to fill document - let scale_x = doc.width / video_width; - let scale_y = doc.height / video_height; + // Create clip instance for effect with 5 second default duration + let clip_instance = ClipInstance::new(def.id) + .with_timeline_start(drop_time) + .with_timeline_duration(5.0); - clip_instance.transform.scale_x = scale_x; - clip_instance.transform.scale_y = scale_y; + // Use AddEffectAction for effect layers + let action = lightningbeam_core::actions::AddEffectAction::new( + layer_id, + clip_instance, + ); + shared.pending_actions.push(Box::new(action)); + } - // Position at (0, 0) to center the scaled video - // (scaled dimensions = document dimensions, so top-left at origin centers it) - clip_instance.transform.x = 0.0; - clip_instance.transform.y = 0.0; + // Clear drag state + *shared.dragging_asset = None; + } else { + // Get document dimensions for centering and create clip instance + let (center_x, center_y, mut clip_instance) = { + let doc = shared.action_executor.document(); + let center_x = doc.width / 2.0; + let center_y = doc.height / 2.0; + + let mut clip_instance = ClipInstance::new(dragging.clip_id) + .with_timeline_start(drop_time); + + // For video clips, scale to fill document dimensions + if dragging.clip_type == DragClipType::Video { + if let Some((video_width, video_height)) = dragging.dimensions { + // Calculate scale to fill document + let scale_x = doc.width / video_width; + let scale_y = doc.height / video_height; + + clip_instance.transform.scale_x = scale_x; + clip_instance.transform.scale_y = scale_y; + + // Position at (0, 0) to center the scaled video + // (scaled dimensions = document dimensions, so top-left at origin centers it) + clip_instance.transform.x = 0.0; + clip_instance.transform.y = 0.0; + } else { + // No dimensions available, use document center + clip_instance.transform.x = center_x; + clip_instance.transform.y = center_y; + } } else { - // No dimensions available, use document center + // Non-video clips: center at document center clip_instance.transform.x = center_x; clip_instance.transform.y = center_y; } + + (center_x, center_y, clip_instance) + }; // doc is dropped here + + // Save instance ID for potential grouping + let video_instance_id = clip_instance.id; + + // Create and queue action for video + let action = lightningbeam_core::actions::AddClipInstanceAction::new( + layer_id, + clip_instance, + ); + shared.pending_actions.push(Box::new(action)); + + // If video has linked audio, auto-place it and create group + if let Some(linked_audio_clip_id) = dragging.linked_audio_clip_id { + eprintln!("DEBUG: Video has linked audio clip: {}", linked_audio_clip_id); + + // Find or create sampled audio track where the audio won't overlap + let audio_layer_id = { + let doc = shared.action_executor.document(); + let result = find_sampled_audio_track_for_clip(doc, linked_audio_clip_id, drop_time); + if let Some(id) = result { + eprintln!("DEBUG: Found existing audio track without overlap: {}", id); + } else { + eprintln!("DEBUG: No suitable audio track found, will create new one"); + } + result + }.unwrap_or_else(|| { + eprintln!("DEBUG: Creating new audio track"); + // Create new sampled audio layer + let audio_layer = lightningbeam_core::layer::AudioLayer::new_sampled("Audio Track"); + let layer_id = shared.action_executor.document_mut().root.add_child( + lightningbeam_core::layer::AnyLayer::Audio(audio_layer) + ); + eprintln!("DEBUG: Created audio layer with ID: {}", layer_id); + layer_id + }); + + eprintln!("DEBUG: Using audio layer ID: {}", audio_layer_id); + + // Create audio clip instance at same timeline position + let audio_instance = ClipInstance::new(linked_audio_clip_id) + .with_timeline_start(drop_time); + let audio_instance_id = audio_instance.id; + + eprintln!("DEBUG: Created audio instance: {} for clip: {}", audio_instance_id, linked_audio_clip_id); + + // Queue audio action + let audio_action = lightningbeam_core::actions::AddClipInstanceAction::new( + audio_layer_id, + audio_instance, + ); + shared.pending_actions.push(Box::new(audio_action)); + eprintln!("DEBUG: Queued audio action, total pending: {}", shared.pending_actions.len()); + + // Create instance group linking video and audio + let mut group = lightningbeam_core::instance_group::InstanceGroup::new(); + group.add_member(layer_id, video_instance_id); + group.add_member(audio_layer_id, audio_instance_id); + shared.action_executor.document_mut().add_instance_group(group); + eprintln!("DEBUG: Created instance group"); } else { - // Non-video clips: center at document center - clip_instance.transform.x = center_x; - clip_instance.transform.y = center_y; + eprintln!("DEBUG: Video has NO linked audio clip!"); } - (center_x, center_y, clip_instance) - }; // doc is dropped here + // Clear drag state + *shared.dragging_asset = None; + } + } + } else { + // No existing layer at this position - show "create new layer" indicator + // and handle drop to create a new layer + let layer_y = content_rect.min.y + hovered_layer_index as f32 * LAYER_HEIGHT - self.viewport_scroll_y; + let highlight_rect = egui::Rect::from_min_size( + egui::pos2(content_rect.min.x, layer_y), + egui::vec2(content_rect.width(), LAYER_HEIGHT), + ); - // Save instance ID for potential grouping - let video_instance_id = clip_instance.id; + // Blue highlight for "will create new layer" + ui.painter().rect_filled( + highlight_rect, + 0.0, + egui::Color32::from_rgba_unmultiplied(100, 150, 255, 40), + ); - // Create and queue action for video - let action = lightningbeam_core::actions::AddClipInstanceAction::new( - layer_id, - clip_instance, + // Show drop time indicator + let drop_time = self.x_to_time(pointer_pos.x - content_rect.min.x).max(0.0); + let drop_x = self.time_to_x(drop_time); + if drop_x >= 0.0 && drop_x <= content_rect.width() { + ui.painter().line_segment( + [ + egui::pos2(content_rect.min.x + drop_x, layer_y), + egui::pos2(content_rect.min.x + drop_x, layer_y + LAYER_HEIGHT), + ], + egui::Stroke::new(2.0, egui::Color32::WHITE), ); - shared.pending_actions.push(Box::new(action)); + } - // If video has linked audio, auto-place it and create group - if let Some(linked_audio_clip_id) = dragging.linked_audio_clip_id { - eprintln!("DEBUG: Video has linked audio clip: {}", linked_audio_clip_id); + // Handle drop on mouse release - create new layer + if ui.input(|i| i.pointer.any_released()) { + let drop_time = self.x_to_time(pointer_pos.x - content_rect.min.x).max(0.0); - // Find or create sampled audio track where the audio won't overlap - let audio_layer_id = { - let doc = shared.action_executor.document(); - let result = find_sampled_audio_track_for_clip(doc, linked_audio_clip_id, drop_time); - if let Some(id) = result { - eprintln!("DEBUG: Found existing audio track without overlap: {}", id); - } else { - eprintln!("DEBUG: No suitable audio track found, will create new one"); + // Create the appropriate layer type + let layer_name = format!("{} Layer", match dragging.clip_type { + DragClipType::Vector => "Vector", + DragClipType::Video => "Video", + DragClipType::AudioSampled => "Audio", + DragClipType::AudioMidi => "MIDI", + DragClipType::Image => "Image", + DragClipType::Effect => "Effect", + }); + let new_layer = super::create_layer_for_clip_type(dragging.clip_type, &layer_name); + let new_layer_id = new_layer.id(); + + // Add the layer + shared.action_executor.document_mut().root.add_child(new_layer); + + // Now add the clip to the new layer + if dragging.clip_type == DragClipType::Effect { + // Handle effect drops + let effect_def = lightningbeam_core::effect_registry::EffectRegistry::get_by_id(&dragging.clip_id) + .or_else(|| shared.action_executor.document().get_effect_definition(&dragging.clip_id).cloned()); + + if let Some(def) = effect_def { + if shared.action_executor.document().get_effect_definition(&def.id).is_none() { + shared.action_executor.document_mut().add_effect_definition(def.clone()); } - result - }.unwrap_or_else(|| { - eprintln!("DEBUG: Creating new audio track"); - // Create new sampled audio layer - let audio_layer = lightningbeam_core::layer::AudioLayer::new_sampled("Audio Track"); - let layer_id = shared.action_executor.document_mut().root.add_child( - lightningbeam_core::layer::AnyLayer::Audio(audio_layer) + + let clip_instance = ClipInstance::new(def.id) + .with_timeline_start(drop_time) + .with_timeline_duration(5.0); + + let action = lightningbeam_core::actions::AddEffectAction::new( + new_layer_id, + clip_instance, ); - eprintln!("DEBUG: Created audio layer with ID: {}", layer_id); - layer_id - }); - - eprintln!("DEBUG: Using audio layer ID: {}", audio_layer_id); - - // Create audio clip instance at same timeline position - let audio_instance = ClipInstance::new(linked_audio_clip_id) - .with_timeline_start(drop_time); - let audio_instance_id = audio_instance.id; - - eprintln!("DEBUG: Created audio instance: {} for clip: {}", audio_instance_id, linked_audio_clip_id); - - // Queue audio action - let audio_action = lightningbeam_core::actions::AddClipInstanceAction::new( - audio_layer_id, - audio_instance, - ); - shared.pending_actions.push(Box::new(audio_action)); - eprintln!("DEBUG: Queued audio action, total pending: {}", shared.pending_actions.len()); - - // Create instance group linking video and audio - let mut group = lightningbeam_core::instance_group::InstanceGroup::new(); - group.add_member(layer_id, video_instance_id); - group.add_member(audio_layer_id, audio_instance_id); - shared.action_executor.document_mut().add_instance_group(group); - eprintln!("DEBUG: Created instance group"); + shared.pending_actions.push(Box::new(action)); + } } else { - eprintln!("DEBUG: Video has NO linked audio clip!"); + // Handle other clip types + let clip_instance = ClipInstance::new(dragging.clip_id) + .with_timeline_start(drop_time); + + let action = lightningbeam_core::actions::AddClipInstanceAction::new( + new_layer_id, + clip_instance, + ); + shared.pending_actions.push(Box::new(action)); } // Clear drag state