Lightningbeam/lightningbeam-ui/lightningbeam-core/src/effect.rs

577 lines
19 KiB
Rust

//! 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<ParameterValue>,
/// Maximum allowed value (for numeric types)
pub max_value: Option<ParameterValue>,
/// 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<String>,
}
impl EffectParameterDef {
/// Create a new float parameter definition
pub fn float(name: impl Into<String>, label: impl Into<String>, 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<String>,
label: impl Into<String>,
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<String>, label: impl Into<String>, 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<String>, label: impl Into<String>, 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<String>, label: impl Into<String>, 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<String>, label: impl Into<String>, 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<String>) -> Self {
Self {
name: name.into(),
input_type: EffectInputType::Composition,
required: true,
}
}
/// Create an optional layer input
pub fn layer(name: impl Into<String>, 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<String>,
/// 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<EffectInput>,
/// Parameter definitions
pub parameters: Vec<EffectParameterDef>,
/// Folder this effect belongs to (None = root of category)
#[serde(default)]
pub folder_id: Option<Uuid>,
}
impl EffectDefinition {
/// Create a new effect definition with a single composition input
pub fn new(
name: impl Into<String>,
category: EffectCategory,
shader_code: impl Into<String>,
parameters: Vec<EffectParameterDef>,
) -> Self {
Self {
id: Uuid::new_v4(),
name: name.into(),
description: None,
category,
shader_code: shader_code.into(),
inputs: vec![EffectInput::composition("source")],
parameters,
folder_id: None,
}
}
/// Create with a specific ID (for built-in effects with stable IDs)
pub fn with_id(id: Uuid, name: impl Into<String>, category: EffectCategory, shader_code: impl Into<String>, parameters: Vec<EffectParameterDef>) -> Self {
Self {
id,
name: name.into(),
description: None,
category,
shader_code: shader_code.into(),
inputs: vec![EffectInput::composition("source")],
parameters,
folder_id: None,
}
}
/// Add a description
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
/// Add custom inputs
pub fn with_inputs(mut self, inputs: Vec<EffectInput>) -> 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<AnimationCurve>,
}
impl AnimatedParameter {
/// Create a new non-animated parameter
pub fn new(name: impl Into<String>, value: ParameterValue) -> Self {
Self {
name: name.into(),
value,
animation: None,
}
}
/// Create with animation
pub fn with_animation(name: impl Into<String>, 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<Option<InputConnection>>,
/// Parameter values (name -> animated value)
pub parameters: HashMap<String, AnimatedParameter>,
/// 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<ParameterValue> {
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<f32> {
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);
}
}