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 f37c67b..32aa3c6 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/add_clip_instance.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/add_clip_instance.rs @@ -104,6 +104,9 @@ impl Action for AddClipInstanceAction { AnyLayer::Group(_) => { return Err("Cannot add clip instances directly to group layers".to_string()); } + AnyLayer::Raster(_) => { + return Err("Cannot add clip instances directly to group layers".to_string()); + } } self.executed = true; @@ -142,6 +145,9 @@ impl Action for AddClipInstanceAction { AnyLayer::Group(_) => { // Group layers don't have clip instances, nothing to rollback } + AnyLayer::Raster(_) => { + // Raster layers don't have clip instances, nothing to rollback + } } self.executed = false; diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/add_layer.rs b/lightningbeam-ui/lightningbeam-core/src/actions/add_layer.rs index 28fa30d..7eac030 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/add_layer.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/add_layer.rs @@ -137,6 +137,7 @@ impl Action for AddLayerAction { AnyLayer::Video(_) => "Add video layer", AnyLayer::Effect(_) => "Add effect layer", AnyLayer::Group(_) => "Add group layer", + AnyLayer::Raster(_) => "Add raster layer", } .to_string() } diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/loop_clip_instances.rs b/lightningbeam-ui/lightningbeam-core/src/actions/loop_clip_instances.rs index 8ae3683..9d8d79e 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/loop_clip_instances.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/loop_clip_instances.rs @@ -36,6 +36,7 @@ impl Action for LoopClipInstancesAction { AnyLayer::Video(vl) => &mut vl.clip_instances, AnyLayer::Effect(el) => &mut el.clip_instances, AnyLayer::Group(_) => continue, + AnyLayer::Raster(_) => continue, }; for (instance_id, _old_dur, new_dur, _old_lb, new_lb) in loops { @@ -59,6 +60,7 @@ impl Action for LoopClipInstancesAction { AnyLayer::Video(vl) => &mut vl.clip_instances, AnyLayer::Effect(el) => &mut el.clip_instances, AnyLayer::Group(_) => continue, + AnyLayer::Raster(_) => continue, }; for (instance_id, old_dur, _new_dur, old_lb, _new_lb) in loops { diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs b/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs index 66cb4ff..9c33906 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs @@ -31,6 +31,7 @@ pub mod convert_to_movie_clip; pub mod region_split; pub mod toggle_group_expansion; pub mod group_layers; +pub mod raster_stroke; pub use add_clip_instance::AddClipInstanceAction; pub use add_effect::AddEffectAction; @@ -60,3 +61,4 @@ pub use convert_to_movie_clip::ConvertToMovieClipAction; pub use region_split::RegionSplitAction; pub use toggle_group_expansion::ToggleGroupExpansionAction; pub use group_layers::GroupLayersAction; +pub use raster_stroke::RasterStrokeAction; 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 f2b5f82..b14be04 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/move_clip_instances.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/move_clip_instances.rs @@ -57,6 +57,7 @@ impl Action for MoveClipInstancesAction { AnyLayer::Video(vl) => &vl.clip_instances, AnyLayer::Effect(el) => &el.clip_instances, AnyLayer::Group(_) => &[], + AnyLayer::Raster(_) => &[], }; if let Some(instance) = clip_instances.iter().find(|ci| ci.id == *member_instance_id) { @@ -95,6 +96,7 @@ impl Action for MoveClipInstancesAction { AnyLayer::Vector(vl) => &vl.clip_instances, AnyLayer::Effect(el) => &el.clip_instances, AnyLayer::Group(_) => &[], + AnyLayer::Raster(_) => &[], }; let group: Vec<(Uuid, f64, f64)> = moves.iter().filter_map(|(id, old_start, _)| { @@ -129,6 +131,7 @@ impl Action for MoveClipInstancesAction { AnyLayer::Video(vl) => &mut vl.clip_instances, AnyLayer::Effect(el) => &mut el.clip_instances, AnyLayer::Group(_) => continue, + AnyLayer::Raster(_) => continue, }; // Update timeline_start for each clip instance @@ -155,6 +158,7 @@ impl Action for MoveClipInstancesAction { AnyLayer::Video(vl) => &mut vl.clip_instances, AnyLayer::Effect(el) => &mut el.clip_instances, AnyLayer::Group(_) => continue, + AnyLayer::Raster(_) => continue, }; // Restore original timeline_start for each clip instance diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/raster_stroke.rs b/lightningbeam-ui/lightningbeam-core/src/actions/raster_stroke.rs new file mode 100644 index 0000000..547809b --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/actions/raster_stroke.rs @@ -0,0 +1,81 @@ +//! Raster stroke action — records and undoes a brush stroke on a RasterLayer. +//! +//! The brush engine paints directly into `RasterKeyframe::raw_pixels` during the +//! drag (via `document_mut()`). This action captures the pixel buffer state +//! *before* and *after* the stroke so it can be undone / redone without +//! re-running the brush engine. +//! +//! `execute` → swap in `buffer_after` +//! `rollback` → swap in `buffer_before` + +use crate::action::Action; +use crate::document::Document; +use crate::layer::AnyLayer; +use uuid::Uuid; + +/// Action that records a single brush stroke for undo/redo. +/// +/// The stroke must already be painted into the document's `raw_pixels` before +/// this action is executed for the first time. +pub struct RasterStrokeAction { + layer_id: Uuid, + time: f64, + /// Raw RGBA pixels *before* the stroke (for rollback / undo) + buffer_before: Vec, + /// Raw RGBA pixels *after* the stroke (for execute / redo) + buffer_after: Vec, + width: u32, + height: u32, +} + +impl RasterStrokeAction { + /// Create the action. + /// + /// * `buffer_before` – raw RGBA pixels captured just before the stroke began. + /// * `buffer_after` – raw RGBA pixels captured just after the stroke finished. + pub fn new( + layer_id: Uuid, + time: f64, + buffer_before: Vec, + buffer_after: Vec, + width: u32, + height: u32, + ) -> Self { + Self { layer_id, time, buffer_before, buffer_after, width, height } + } +} + +impl Action for RasterStrokeAction { + fn execute(&mut self, document: &mut Document) -> Result<(), String> { + let kf = get_keyframe_mut(document, &self.layer_id, self.time, self.width, self.height)?; + kf.raw_pixels = self.buffer_after.clone(); + Ok(()) + } + + fn rollback(&mut self, document: &mut Document) -> Result<(), String> { + let kf = get_keyframe_mut(document, &self.layer_id, self.time, self.width, self.height)?; + kf.raw_pixels = self.buffer_before.clone(); + Ok(()) + } + + fn description(&self) -> String { + "Paint stroke".to_string() + } +} + +fn get_keyframe_mut<'a>( + document: &'a mut Document, + layer_id: &Uuid, + time: f64, + width: u32, + height: u32, +) -> Result<&'a mut crate::raster_layer::RasterKeyframe, String> { + let layer = document + .get_layer_mut(layer_id) + .ok_or_else(|| format!("Layer {} not found", layer_id))?; + let raster = match layer { + AnyLayer::Raster(rl) => rl, + _ => return Err("Not a raster layer".to_string()), + }; + Ok(raster.ensure_keyframe_at(time, width, height)) +} diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/remove_clip_instances.rs b/lightningbeam-ui/lightningbeam-core/src/actions/remove_clip_instances.rs index 95c7b2e..d24fb99 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/remove_clip_instances.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/remove_clip_instances.rs @@ -45,6 +45,7 @@ impl Action for RemoveClipInstancesAction { AnyLayer::Video(vl) => &mut vl.clip_instances, AnyLayer::Effect(el) => &mut el.clip_instances, AnyLayer::Group(_) => continue, + AnyLayer::Raster(_) => continue, }; // Find and remove the instance, saving it for rollback @@ -70,6 +71,7 @@ impl Action for RemoveClipInstancesAction { AnyLayer::Video(vl) => &mut vl.clip_instances, AnyLayer::Effect(el) => &mut el.clip_instances, AnyLayer::Group(_) => continue, + AnyLayer::Raster(_) => continue, }; clip_instances.push(instance); diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/split_clip_instance.rs b/lightningbeam-ui/lightningbeam-core/src/actions/split_clip_instance.rs index dd00152..b393803 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/split_clip_instance.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/split_clip_instance.rs @@ -113,6 +113,7 @@ impl Action for SplitClipInstanceAction { AnyLayer::Video(vl) => &vl.clip_instances, AnyLayer::Effect(el) => &el.clip_instances, AnyLayer::Group(_) => return Err("Cannot split clip instances on group layers".to_string()), + AnyLayer::Raster(_) => return Err("Cannot split clip instances on group layers".to_string()), }; let instance = clip_instances @@ -232,6 +233,9 @@ impl Action for SplitClipInstanceAction { AnyLayer::Group(_) => { return Err("Cannot split clip instances on group layers".to_string()); } + AnyLayer::Raster(_) => { + return Err("Cannot split clip instances on group layers".to_string()); + } } self.executed = true; @@ -290,6 +294,9 @@ impl Action for SplitClipInstanceAction { AnyLayer::Group(_) => { // Group layers don't have clip instances, nothing to rollback } + AnyLayer::Raster(_) => { + // Raster layers don't have clip instances, nothing to rollback + } } self.executed = false; 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 7395a60..433e6dc 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/transform_clip_instances.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/transform_clip_instances.rs @@ -100,6 +100,7 @@ impl Action for TransformClipInstancesAction { } AnyLayer::Effect(_) => {} AnyLayer::Group(_) => {} + AnyLayer::Raster(_) => {} } Ok(()) } @@ -138,6 +139,7 @@ impl Action for TransformClipInstancesAction { } AnyLayer::Effect(_) => {} AnyLayer::Group(_) => {} + AnyLayer::Raster(_) => {} } Ok(()) } 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 368e71b..efa828b 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/trim_clip_instances.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/trim_clip_instances.rs @@ -100,6 +100,7 @@ impl Action for TrimClipInstancesAction { AnyLayer::Video(vl) => &vl.clip_instances, AnyLayer::Effect(el) => &el.clip_instances, AnyLayer::Group(_) => &[], + AnyLayer::Raster(_) => &[], }; if let Some(instance) = clip_instances.iter().find(|ci| ci.id == *member_instance_id) { @@ -136,6 +137,7 @@ impl Action for TrimClipInstancesAction { AnyLayer::Video(vl) => &vl.clip_instances, AnyLayer::Effect(el) => &el.clip_instances, AnyLayer::Group(_) => &[], + AnyLayer::Raster(_) => &[], }; if let Some(instance) = clip_instances.iter().find(|ci| ci.id == *member_instance_id) { @@ -179,6 +181,7 @@ impl Action for TrimClipInstancesAction { AnyLayer::Vector(vl) => &vl.clip_instances, AnyLayer::Effect(el) => &el.clip_instances, AnyLayer::Group(_) => &[], + AnyLayer::Raster(_) => &[], }; let instance = clip_instances.iter() @@ -271,6 +274,7 @@ impl Action for TrimClipInstancesAction { AnyLayer::Video(vl) => &mut vl.clip_instances, AnyLayer::Effect(el) => &mut el.clip_instances, AnyLayer::Group(_) => continue, + AnyLayer::Raster(_) => continue, }; // Apply trims @@ -310,6 +314,7 @@ impl Action for TrimClipInstancesAction { AnyLayer::Video(vl) => &mut vl.clip_instances, AnyLayer::Effect(el) => &mut el.clip_instances, AnyLayer::Group(_) => continue, + AnyLayer::Raster(_) => continue, }; // Restore original trim values diff --git a/lightningbeam-ui/lightningbeam-core/src/brush_engine.rs b/lightningbeam-ui/lightningbeam-core/src/brush_engine.rs new file mode 100644 index 0000000..22241f2 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/brush_engine.rs @@ -0,0 +1,365 @@ +//! Raster brush engine — pure-Rust MyPaint-style Gaussian dab renderer +//! +//! ## Algorithm +//! +//! Based on the libmypaint brush engine (ISC license, Martin Renold et al.). +//! +//! ### Dab shape +//! For each pixel at normalised squared distance `rr = (dist / radius)²` from the +//! dab centre, the opacity weight is calculated using two linear segments: +//! +//! ```text +//! opa +//! ^ +//! * . +//! | * +//! | . +//! +-----------*> rr +//! 0 hardness 1 +//! ``` +//! +//! - segment 1 (rr ≤ hardness): `opa = 1 + rr * (-(1/hardness - 1))` +//! - segment 2 (hardness < rr ≤ 1): `opa = hardness/(1-hardness) - rr * hardness/(1-hardness)` +//! - rr > 1: opa = 0 +//! +//! ### Dab placement +//! Dabs are placed along the stroke polyline at intervals of +//! `spacing = radius * dabs_per_radius`. Fractional remainder is tracked across +//! consecutive `apply_stroke` calls via `StrokeState`. +//! +//! ### Blending +//! Normal mode uses the standard "over" operator on premultiplied RGBA: +//! ```text +//! result_a = opa_a + (1 - opa_a) * bottom_a +//! result_rgb = opa_a * top_rgb + (1 - opa_a) * bottom_rgb +//! ``` +//! Erase mode: subtract `opa_a` from the destination alpha and premultiply. + +use image::RgbaImage; +use crate::raster_layer::{RasterBlendMode, StrokeRecord}; + +/// Transient brush stroke state (tracks partial dab position between segments) +pub struct StrokeState { + /// Distance along the path already "consumed" toward the next dab (in pixels) + pub distance_since_last_dab: f32, + /// Accumulated canvas color for smudge mode (RGBA linear, updated each dab) + pub smudge_color: [f32; 4], +} + +impl StrokeState { + pub fn new() -> Self { + Self { distance_since_last_dab: 0.0, smudge_color: [0.0; 4] } + } +} + +impl Default for StrokeState { + fn default() -> Self { Self::new() } +} + +/// Pure-Rust MyPaint-style Gaussian dab brush engine +pub struct BrushEngine; + +impl BrushEngine { + /// Apply a complete stroke to a pixel buffer. + /// + /// A fresh [`StrokeState`] is created for each stroke (starts with full dab + /// placement spacing so the first dab lands at the very first point). + pub fn apply_stroke(buffer: &mut RgbaImage, stroke: &StrokeRecord) { + let mut state = StrokeState::new(); + // Ensure the very first point always gets a dab + state.distance_since_last_dab = f32::MAX; + Self::apply_stroke_with_state(buffer, stroke, &mut state); + } + + /// Apply a stroke segment to a buffer while preserving dab-placement state. + /// + /// Use this when building up a stroke incrementally (e.g. live drawing) so + /// that dab spacing is consistent across motion events. + pub fn apply_stroke_with_state( + buffer: &mut RgbaImage, + stroke: &StrokeRecord, + state: &mut StrokeState, + ) { + if stroke.points.len() < 2 { + // Single-point "tap": draw one dab at the given pressure + if let Some(pt) = stroke.points.first() { + let r = stroke.brush_settings.radius_at_pressure(pt.pressure); + let o = stroke.brush_settings.opacity_at_pressure(pt.pressure); + if matches!(stroke.blend_mode, RasterBlendMode::Smudge) { + // Seed smudge color from canvas at the tap position + state.smudge_color = Self::sample_average(buffer, pt.x, pt.y, r); + Self::render_dab(buffer, pt.x, pt.y, r, stroke.brush_settings.hardness, + o, state.smudge_color, RasterBlendMode::Normal); + } else { + Self::render_dab(buffer, pt.x, pt.y, r, stroke.brush_settings.hardness, + o, stroke.color, stroke.blend_mode); + } + state.distance_since_last_dab = 0.0; + } + return; + } + + for window in stroke.points.windows(2) { + let p0 = &window[0]; + let p1 = &window[1]; + + let dx = p1.x - p0.x; + let dy = p1.y - p0.y; + let seg_len = (dx * dx + dy * dy).sqrt(); + if seg_len < 1e-4 { + continue; + } + + // Interpolate across this segment + let mut t = 0.0f32; + while t < 1.0 { + let pressure = p0.pressure + t * (p1.pressure - p0.pressure); + + let radius = stroke.brush_settings.radius_at_pressure(pressure); + let spacing = radius * stroke.brush_settings.dabs_per_radius; + let spacing = spacing.max(0.5); // at least half a pixel + + let dist_to_next = spacing - state.distance_since_last_dab; + let seg_t_to_next = (dist_to_next / seg_len).max(0.0); + + if seg_t_to_next > 1.0 - t { + // Not enough distance left in this segment for another dab + state.distance_since_last_dab += seg_len * (1.0 - t); + break; + } + + t += seg_t_to_next; + let x2 = p0.x + t * dx; + let y2 = p0.y + t * dy; + let pressure2 = p0.pressure + t * (p1.pressure - p0.pressure); + + let radius2 = stroke.brush_settings.radius_at_pressure(pressure2); + let opacity2 = stroke.brush_settings.opacity_at_pressure(pressure2); + + if matches!(stroke.blend_mode, RasterBlendMode::Smudge) { + // Sample canvas under dab, blend into running smudge color + let sampled = Self::sample_average(buffer, x2, y2, radius2); + const PICK_UP: f32 = 0.15; + for i in 0..4 { + state.smudge_color[i] = state.smudge_color[i] * (1.0 - PICK_UP) + + sampled[i] * PICK_UP; + } + Self::render_dab(buffer, x2, y2, radius2, + stroke.brush_settings.hardness, + opacity2, state.smudge_color, RasterBlendMode::Normal); + } else { + Self::render_dab(buffer, x2, y2, radius2, + stroke.brush_settings.hardness, + opacity2, stroke.color, stroke.blend_mode); + } + + state.distance_since_last_dab = 0.0; + } + } + } + + /// Render a single Gaussian dab at pixel position (x, y). + /// + /// Uses the two-segment linear falloff from MyPaint/libmypaint for the + /// opacity mask, then blends using the requested `blend_mode`. + pub fn render_dab( + buffer: &mut RgbaImage, + x: f32, + y: f32, + radius: f32, + hardness: f32, + opacity: f32, + color: [f32; 4], + blend_mode: RasterBlendMode, + ) { + if radius < 0.5 || opacity <= 0.0 { + return; + } + + let hardness = hardness.clamp(1e-3, 1.0); + + // Pre-compute the two linear-segment coefficients (from libmypaint render_dab_mask) + let seg1_offset = 1.0f32; + let seg1_slope = -(1.0 / hardness - 1.0); + let seg2_offset = hardness / (1.0 - hardness); + let seg2_slope = -hardness / (1.0 - hardness); + + let r_fringe = radius + 1.0; + let x0 = ((x - r_fringe).floor() as i32).max(0) as u32; + let y0 = ((y - r_fringe).floor() as i32).max(0) as u32; + let x1 = ((x + r_fringe).ceil() as i32).min(buffer.width() as i32 - 1).max(0) as u32; + let y1 = ((y + r_fringe).ceil() as i32).min(buffer.height() as i32 - 1).max(0) as u32; + + let one_over_r2 = 1.0 / (radius * radius); + + for py in y0..=y1 { + for px in x0..=x1 { + let dx = px as f32 + 0.5 - x; + let dy = py as f32 + 0.5 - y; + let rr = (dx * dx + dy * dy) * one_over_r2; + + if rr > 1.0 { + continue; + } + + // Two-segment opacity (identical to libmypaint calculate_opa) + let opa_weight = if rr <= hardness { + seg1_offset + rr * seg1_slope + } else { + seg2_offset + rr * seg2_slope + } + .clamp(0.0, 1.0); + + let dab_alpha = opa_weight * opacity * color[3]; + if dab_alpha <= 0.0 { + continue; + } + + let pixel = buffer.get_pixel_mut(px, py); + let dst = [ + pixel[0] as f32 / 255.0, + pixel[1] as f32 / 255.0, + pixel[2] as f32 / 255.0, + pixel[3] as f32 / 255.0, + ]; + + let (out_r, out_g, out_b, out_a) = match blend_mode { + RasterBlendMode::Normal | RasterBlendMode::Smudge => { + // Standard "over" operator (smudge pre-computes its color upstream) + let oa = dab_alpha; + let ba = 1.0 - oa; + let out_a = oa + ba * dst[3]; + let out_r = oa * color[0] + ba * dst[0]; + let out_g = oa * color[1] + ba * dst[1]; + let out_b = oa * color[2] + ba * dst[2]; + (out_r, out_g, out_b, out_a) + } + RasterBlendMode::Erase => { + // Reduce destination alpha by dab_alpha + let new_a = (dst[3] - dab_alpha).max(0.0); + let scale = if dst[3] > 1e-6 { new_a / dst[3] } else { 0.0 }; + (dst[0] * scale, dst[1] * scale, dst[2] * scale, new_a) + } + }; + + pixel[0] = (out_r.clamp(0.0, 1.0) * 255.0) as u8; + pixel[1] = (out_g.clamp(0.0, 1.0) * 255.0) as u8; + pixel[2] = (out_b.clamp(0.0, 1.0) * 255.0) as u8; + pixel[3] = (out_a.clamp(0.0, 1.0) * 255.0) as u8; + } + } + } + + /// Sample the average RGBA color in a circular region of `radius` around (x, y). + /// + /// Used by smudge to pick up canvas color before painting each dab. + fn sample_average(buffer: &RgbaImage, x: f32, y: f32, radius: f32) -> [f32; 4] { + let sample_r = (radius * 0.5).max(1.0); + let x0 = ((x - sample_r).floor() as i32).max(0) as u32; + let y0 = ((y - sample_r).floor() as i32).max(0) as u32; + let x1 = ((x + sample_r).ceil() as i32).min(buffer.width() as i32 - 1).max(0) as u32; + let y1 = ((y + sample_r).ceil() as i32).min(buffer.height() as i32 - 1).max(0) as u32; + + let mut sum = [0.0f32; 4]; + let mut count = 0u32; + for py in y0..=y1 { + for px in x0..=x1 { + let p = buffer.get_pixel(px, py); + sum[0] += p[0] as f32 / 255.0; + sum[1] += p[1] as f32 / 255.0; + sum[2] += p[2] as f32 / 255.0; + sum[3] += p[3] as f32 / 255.0; + count += 1; + } + } + if count > 0 { + let n = count as f32; + [sum[0] / n, sum[1] / n, sum[2] / n, sum[3] / n] + } else { + [0.0; 4] + } + } +} + +/// Create an `RgbaImage` from a raw RGBA pixel buffer. +/// +/// If `raw` is empty a blank (transparent) image of the given dimensions is returned. +/// Panics if `raw.len() != width * height * 4` (and `raw` is non-empty). +pub fn image_from_raw(raw: Vec, width: u32, height: u32) -> RgbaImage { + if raw.is_empty() { + RgbaImage::new(width, height) + } else { + RgbaImage::from_raw(width, height, raw) + .expect("raw_pixels length mismatch") + } +} + +/// Encode an `RgbaImage` as a PNG byte vector +pub fn encode_png(img: &RgbaImage) -> Result, String> { + let mut buf = std::io::Cursor::new(Vec::new()); + img.write_to(&mut buf, image::ImageFormat::Png) + .map_err(|e| format!("PNG encode error: {e}"))?; + Ok(buf.into_inner()) +} + +/// Decode PNG bytes into an `RgbaImage` +pub fn decode_png(data: &[u8]) -> Result { + image::load_from_memory(data) + .map(|img| img.to_rgba8()) + .map_err(|e| format!("PNG decode error: {e}")) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::raster_layer::{StrokePoint, StrokeRecord, RasterBlendMode}; + use crate::brush_settings::BrushSettings; + + fn make_stroke(color: [f32; 4]) -> StrokeRecord { + StrokeRecord { + brush_settings: BrushSettings::default_round_hard(), + color, + blend_mode: RasterBlendMode::Normal, + points: vec![ + StrokePoint { x: 10.0, y: 10.0, pressure: 0.8, tilt_x: 0.0, tilt_y: 0.0, timestamp: 0.0 }, + StrokePoint { x: 50.0, y: 10.0, pressure: 0.8, tilt_x: 0.0, tilt_y: 0.0, timestamp: 0.1 }, + ], + } + } + + #[test] + fn test_stroke_modifies_buffer() { + let mut img = RgbaImage::new(100, 100); + let stroke = make_stroke([1.0, 0.0, 0.0, 1.0]); // red + BrushEngine::apply_stroke(&mut img, &stroke); + // The center pixel should have some red + let px = img.get_pixel(30, 10); + assert!(px[0] > 0, "expected red paint"); + } + + #[test] + fn test_erase_reduces_alpha() { + let mut img = RgbaImage::from_pixel(100, 100, image::Rgba([200, 100, 50, 255])); + let stroke = StrokeRecord { + brush_settings: BrushSettings::default_round_hard(), + color: [0.0, 0.0, 0.0, 1.0], + blend_mode: RasterBlendMode::Erase, + points: vec![ + StrokePoint { x: 50.0, y: 50.0, pressure: 1.0, tilt_x: 0.0, tilt_y: 0.0, timestamp: 0.0 }, + ], + }; + BrushEngine::apply_stroke(&mut img, &stroke); + let px = img.get_pixel(50, 50); + assert!(px[3] < 255, "alpha should be reduced by erase"); + } + + #[test] + fn test_png_roundtrip() { + let mut img = RgbaImage::new(64, 64); + let px = img.get_pixel_mut(10, 10); + *px = image::Rgba([255, 128, 0, 255]); + let png = encode_png(&img).unwrap(); + let decoded = decode_png(&png).unwrap(); + assert_eq!(decoded.get_pixel(10, 10), img.get_pixel(10, 10)); + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/brush_settings.rs b/lightningbeam-ui/lightningbeam-core/src/brush_settings.rs new file mode 100644 index 0000000..2f3f96e --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/brush_settings.rs @@ -0,0 +1,148 @@ +//! Brush settings for the raster paint engine +//! +//! Settings that describe the appearance and behavior of a paint brush. +//! Compatible with MyPaint .myb brush file format (subset). + +use serde::{Deserialize, Serialize}; + +/// Settings for a paint brush +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct BrushSettings { + /// log(radius) base value; actual radius = exp(radius_log) + pub radius_log: f32, + /// Edge hardness 0.0 (fully soft/gaussian) to 1.0 (hard edge) + pub hardness: f32, + /// Base opacity 0.0–1.0 + pub opaque: f32, + /// Dab spacing as fraction of radius (smaller = denser strokes) + pub dabs_per_radius: f32, + /// HSV hue (0.0–1.0); usually overridden by stroke color + pub color_h: f32, + /// HSV saturation (0.0–1.0) + pub color_s: f32, + /// HSV value (0.0–1.0) + pub color_v: f32, + /// How much pressure increases/decreases radius + /// Final radius = exp(radius_log + pressure_radius_gain * pressure) + pub pressure_radius_gain: f32, + /// How much pressure increases/decreases opacity + /// Final opacity = opaque * (1 + pressure_opacity_gain * (pressure - 0.5)) + pub pressure_opacity_gain: f32, +} + +impl BrushSettings { + /// Default soft round brush (smooth Gaussian falloff) + pub fn default_round_soft() -> Self { + Self { + radius_log: 2.0, // radius ≈ 7.4 px + hardness: 0.1, + opaque: 0.8, + dabs_per_radius: 0.25, + color_h: 0.0, + color_s: 0.0, + color_v: 0.0, + pressure_radius_gain: 0.5, + pressure_opacity_gain: 1.0, + } + } + + /// Default hard round brush (sharp edge) + pub fn default_round_hard() -> Self { + Self { + radius_log: 2.0, + hardness: 0.9, + opaque: 1.0, + dabs_per_radius: 0.2, + color_h: 0.0, + color_s: 0.0, + color_v: 0.0, + pressure_radius_gain: 0.3, + pressure_opacity_gain: 0.8, + } + } + + /// Compute actual radius at a given pressure level + pub fn radius_at_pressure(&self, pressure: f32) -> f32 { + let r = self.radius_log + self.pressure_radius_gain * (pressure - 0.5); + r.exp().clamp(0.5, 500.0) + } + + /// Compute actual opacity at a given pressure level + pub fn opacity_at_pressure(&self, pressure: f32) -> f32 { + let o = self.opaque * (1.0 + self.pressure_opacity_gain * (pressure - 0.5)); + o.clamp(0.0, 1.0) + } + + /// Parse a MyPaint .myb JSON brush file (subset). + /// + /// Reads `radius_logarithmic`, `hardness`, `opaque`, `dabs_per_basic_radius`, + /// `color_h`, `color_s`, `color_v` from the `settings` key's `base_value` fields. + pub fn from_myb(json: &str) -> Result { + let v: serde_json::Value = + serde_json::from_str(json).map_err(|e| format!("JSON parse error: {e}"))?; + + let settings = v.get("settings").ok_or("Missing 'settings' key")?; + + let read_base = |name: &str, default: f32| -> f32 { + settings + .get(name) + .and_then(|s| s.get("base_value")) + .and_then(|bv| bv.as_f64()) + .map(|f| f as f32) + .unwrap_or(default) + }; + + // Pressure dynamics: read from the "inputs" mapping of radius/opacity + // For simplicity, look for the pressure input point in radius_logarithmic + let pressure_radius_gain = settings + .get("radius_logarithmic") + .and_then(|s| s.get("inputs")) + .and_then(|inp| inp.get("pressure")) + .and_then(|pts| pts.as_array()) + .and_then(|arr| { + // arr = [[x0,y0],[x1,y1],...] – approximate as linear gain at x=1.0 + if arr.len() >= 2 { + let y0 = arr[0].get(1)?.as_f64()? as f32; + let y1 = arr[arr.len() - 1].get(1)?.as_f64()? as f32; + Some((y1 - y0) * 0.5) + } else { + None + } + }) + .unwrap_or(0.5); + + let pressure_opacity_gain = settings + .get("opaque") + .and_then(|s| s.get("inputs")) + .and_then(|inp| inp.get("pressure")) + .and_then(|pts| pts.as_array()) + .and_then(|arr| { + if arr.len() >= 2 { + let y0 = arr[0].get(1)?.as_f64()? as f32; + let y1 = arr[arr.len() - 1].get(1)?.as_f64()? as f32; + Some(y1 - y0) + } else { + None + } + }) + .unwrap_or(1.0); + + Ok(Self { + radius_log: read_base("radius_logarithmic", 2.0), + hardness: read_base("hardness", 0.5).clamp(0.0, 1.0), + opaque: read_base("opaque", 1.0).clamp(0.0, 1.0), + dabs_per_radius: read_base("dabs_per_basic_radius", 0.25).clamp(0.01, 10.0), + color_h: read_base("color_h", 0.0), + color_s: read_base("color_s", 0.0), + color_v: read_base("color_v", 0.0), + pressure_radius_gain, + pressure_opacity_gain, + }) + } +} + +impl Default for BrushSettings { + fn default() -> Self { + Self::default_round_soft() + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/clip.rs b/lightningbeam-ui/lightningbeam-core/src/clip.rs index 17901cb..4fa3fc1 100644 --- a/lightningbeam-ui/lightningbeam-core/src/clip.rs +++ b/lightningbeam-ui/lightningbeam-core/src/clip.rs @@ -116,6 +116,7 @@ impl VectorClip { AnyLayer::Video(vl) => &vl.clip_instances, AnyLayer::Effect(el) => &el.clip_instances, AnyLayer::Group(_) => &[], + AnyLayer::Raster(_) => &[], }; for ci in clip_instances { let end = if let Some(td) = ci.timeline_duration { diff --git a/lightningbeam-ui/lightningbeam-core/src/clipboard.rs b/lightningbeam-ui/lightningbeam-core/src/clipboard.rs index 3789abd..86134ea 100644 --- a/lightningbeam-ui/lightningbeam-core/src/clipboard.rs +++ b/lightningbeam-ui/lightningbeam-core/src/clipboard.rs @@ -32,6 +32,7 @@ impl ClipboardLayerType { }, AnyLayer::Effect(_) => ClipboardLayerType::Effect, AnyLayer::Group(_) => ClipboardLayerType::Vector, // Groups don't have a direct clipboard type; treat as vector + AnyLayer::Raster(_) => ClipboardLayerType::Vector, // Raster layers treated as vector for clipboard purposes } } diff --git a/lightningbeam-ui/lightningbeam-core/src/document.rs b/lightningbeam-ui/lightningbeam-core/src/document.rs index ce73e0a..8749c4e 100644 --- a/lightningbeam-ui/lightningbeam-core/src/document.rs +++ b/lightningbeam-ui/lightningbeam-core/src/document.rs @@ -419,6 +419,9 @@ impl Document { } } } + crate::layer::AnyLayer::Raster(_) => { + // Raster layers don't have clip instances + } crate::layer::AnyLayer::Group(group) => { // Recurse into group children to find their clip instance endpoints fn process_group_children( @@ -457,6 +460,9 @@ impl Document { } } } + crate::layer::AnyLayer::Raster(_) => { + // Raster layers don't have clip instances + } crate::layer::AnyLayer::Group(g) => { process_group_children(&g.children, doc, max_end, calc_end); } @@ -822,6 +828,7 @@ impl Document { AnyLayer::Vector(vector) => &vector.clip_instances, AnyLayer::Effect(effect) => &effect.clip_instances, AnyLayer::Group(_) => &[], + AnyLayer::Raster(_) => &[], }; let instance = instances.iter().find(|inst| &inst.id == instance_id)?; @@ -861,6 +868,7 @@ impl Document { AnyLayer::Vector(vector) => &vector.clip_instances, AnyLayer::Effect(effect) => &effect.clip_instances, AnyLayer::Group(_) => &[], + AnyLayer::Raster(_) => &[], }; for instance in instances { @@ -922,6 +930,7 @@ impl Document { AnyLayer::Effect(effect) => &effect.clip_instances, AnyLayer::Vector(_) => return Some(desired_start), // Shouldn't reach here AnyLayer::Group(_) => return Some(desired_start), // Groups don't have own clips + AnyLayer::Raster(_) => return Some(desired_start), // Raster layers don't have own clips }; let mut occupied_ranges: Vec<(f64, f64, Uuid)> = Vec::new(); @@ -1016,6 +1025,7 @@ impl Document { AnyLayer::Effect(e) => &e.clip_instances, AnyLayer::Vector(v) => &v.clip_instances, AnyLayer::Group(_) => &[], + AnyLayer::Raster(_) => &[], }; // Collect non-group clip ranges @@ -1086,6 +1096,7 @@ impl Document { AnyLayer::Effect(effect) => &effect.clip_instances, AnyLayer::Vector(vector) => &vector.clip_instances, AnyLayer::Group(_) => &[], + AnyLayer::Raster(_) => &[], }; for other in instances { @@ -1133,6 +1144,7 @@ impl Document { AnyLayer::Effect(effect) => &effect.clip_instances, AnyLayer::Vector(vector) => &vector.clip_instances, AnyLayer::Group(_) => &[], + AnyLayer::Raster(_) => &[], }; let mut nearest_start = f64::MAX; @@ -1179,6 +1191,7 @@ impl Document { AnyLayer::Effect(effect) => &effect.clip_instances, AnyLayer::Vector(vector) => &vector.clip_instances, AnyLayer::Group(_) => &[], + AnyLayer::Raster(_) => &[], }; let mut nearest_end = 0.0; diff --git a/lightningbeam-ui/lightningbeam-core/src/file_io.rs b/lightningbeam-ui/lightningbeam-core/src/file_io.rs index 13c72b0..e8a501e 100644 --- a/lightningbeam-ui/lightningbeam-core/src/file_io.rs +++ b/lightningbeam-ui/lightningbeam-core/src/file_io.rs @@ -370,6 +370,37 @@ pub fn save_beam( eprintln!("📊 [SAVE_BEAM] - ZIP writing: {:.2}ms", zip_write_time); } + // 4b. Write raster layer PNG buffers to ZIP (media/raster/.png) + let step4b_start = std::time::Instant::now(); + let raster_file_options = FileOptions::default() + .compression_method(CompressionMethod::Stored); // PNG is already compressed + let mut raster_count = 0usize; + for layer in &document.root.children { + if let crate::layer::AnyLayer::Raster(rl) = layer { + for kf in &rl.keyframes { + if !kf.raw_pixels.is_empty() { + // Encode raw RGBA to PNG for storage + let img = crate::brush_engine::image_from_raw( + kf.raw_pixels.clone(), kf.width, kf.height, + ); + match crate::brush_engine::encode_png(&img) { + Ok(png_bytes) => { + let zip_path = kf.media_path.clone(); + zip.start_file(&zip_path, raster_file_options) + .map_err(|e| format!("Failed to create {} in ZIP: {}", zip_path, e))?; + zip.write_all(&png_bytes) + .map_err(|e| format!("Failed to write {}: {}", zip_path, e))?; + raster_count += 1; + } + Err(e) => eprintln!("⚠️ [SAVE_BEAM] Failed to encode raster PNG {}: {}", kf.media_path, e), + } + } + } + } + } + eprintln!("📊 [SAVE_BEAM] Step 4b: Write {} raster PNG buffers took {:.2}ms", + raster_count, step4b_start.elapsed().as_secs_f64() * 1000.0); + // 5. Build BeamProject structure with modified entries let step5_start = std::time::Instant::now(); let now = chrono::Utc::now().to_rfc3339(); @@ -467,7 +498,7 @@ pub fn load_beam(path: &Path) -> Result { // 5. Extract document and audio backend state let step5_start = std::time::Instant::now(); - let document = beam_project.ui_state; + let mut document = beam_project.ui_state; let mut audio_project = beam_project.audio_backend.project; let audio_pool_entries = beam_project.audio_backend.audio_pool_entries; let layer_to_track_map = beam_project.audio_backend.layer_to_track_map; @@ -584,6 +615,37 @@ pub fn load_beam(path: &Path) -> Result { eprintln!("📊 [LOAD_BEAM] - FLAC decoding: {:.2}ms", flac_decode_time); } + // 7b. Load raster layer PNG buffers from ZIP + let step7b_start = std::time::Instant::now(); + let mut raster_load_count = 0usize; + for layer in document.root.children.iter_mut() { + if let crate::layer::AnyLayer::Raster(rl) = layer { + for kf in &mut rl.keyframes { + if !kf.media_path.is_empty() { + match zip.by_name(&kf.media_path) { + Ok(mut png_file) => { + let mut png_bytes = Vec::new(); + let _ = png_file.read_to_end(&mut png_bytes); + // Decode PNG into raw RGBA pixels for fast in-memory access + match crate::brush_engine::decode_png(&png_bytes) { + Ok(rgba) => { + kf.raw_pixels = rgba.into_raw(); + raster_load_count += 1; + } + Err(e) => eprintln!("⚠️ [LOAD_BEAM] Failed to decode raster PNG {}: {}", kf.media_path, e), + } + } + Err(_) => { + // Keyframe PNG not in ZIP yet (new keyframe); leave raw_pixels empty + } + } + } + } + } + } + eprintln!("📊 [LOAD_BEAM] Step 7b: Load {} raster PNG buffers took {:.2}ms", + raster_load_count, step7b_start.elapsed().as_secs_f64() * 1000.0); + // 8. Check for missing external files // An entry is missing if it has a relative_path (external reference) // but no embedded_data and the file doesn't exist diff --git a/lightningbeam-ui/lightningbeam-core/src/layer.rs b/lightningbeam-ui/lightningbeam-core/src/layer.rs index 157132f..3d4501e 100644 --- a/lightningbeam-ui/lightningbeam-core/src/layer.rs +++ b/lightningbeam-ui/lightningbeam-core/src/layer.rs @@ -7,6 +7,7 @@ use crate::clip::ClipInstance; use crate::dcel::Dcel; use crate::effect_layer::EffectLayer; use crate::object::ShapeInstance; +use crate::raster_layer::RasterLayer; use crate::shape::Shape; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -27,6 +28,8 @@ pub enum LayerType { Effect, /// Group layer containing child layers (e.g. video + audio) Group, + /// Raster pixel-buffer painting layer + Raster, } /// Common trait for all layer types @@ -761,6 +764,7 @@ impl GroupLayer { AnyLayer::Vector(l) => &l.clip_instances, AnyLayer::Effect(l) => &l.clip_instances, AnyLayer::Group(_) => &[], // no nested groups + AnyLayer::Raster(_) => &[], // raster layers have no clip instances }; for ci in instances { result.push((child_id, ci)); @@ -778,6 +782,7 @@ pub enum AnyLayer { Video(VideoLayer), Effect(EffectLayer), Group(GroupLayer), + Raster(RasterLayer), } impl LayerTrait for AnyLayer { @@ -788,6 +793,7 @@ impl LayerTrait for AnyLayer { AnyLayer::Video(l) => l.id(), AnyLayer::Effect(l) => l.id(), AnyLayer::Group(l) => l.id(), + AnyLayer::Raster(l) => l.id(), } } @@ -798,6 +804,7 @@ impl LayerTrait for AnyLayer { AnyLayer::Video(l) => l.name(), AnyLayer::Effect(l) => l.name(), AnyLayer::Group(l) => l.name(), + AnyLayer::Raster(l) => l.name(), } } @@ -808,6 +815,7 @@ impl LayerTrait for AnyLayer { AnyLayer::Video(l) => l.set_name(name), AnyLayer::Effect(l) => l.set_name(name), AnyLayer::Group(l) => l.set_name(name), + AnyLayer::Raster(l) => l.set_name(name), } } @@ -818,6 +826,7 @@ impl LayerTrait for AnyLayer { AnyLayer::Video(l) => l.has_custom_name(), AnyLayer::Effect(l) => l.has_custom_name(), AnyLayer::Group(l) => l.has_custom_name(), + AnyLayer::Raster(l) => l.has_custom_name(), } } @@ -828,6 +837,7 @@ impl LayerTrait for AnyLayer { AnyLayer::Video(l) => l.set_has_custom_name(custom), AnyLayer::Effect(l) => l.set_has_custom_name(custom), AnyLayer::Group(l) => l.set_has_custom_name(custom), + AnyLayer::Raster(l) => l.set_has_custom_name(custom), } } @@ -838,6 +848,7 @@ impl LayerTrait for AnyLayer { AnyLayer::Video(l) => l.visible(), AnyLayer::Effect(l) => l.visible(), AnyLayer::Group(l) => l.visible(), + AnyLayer::Raster(l) => l.visible(), } } @@ -848,6 +859,7 @@ impl LayerTrait for AnyLayer { AnyLayer::Video(l) => l.set_visible(visible), AnyLayer::Effect(l) => l.set_visible(visible), AnyLayer::Group(l) => l.set_visible(visible), + AnyLayer::Raster(l) => l.set_visible(visible), } } @@ -858,6 +870,7 @@ impl LayerTrait for AnyLayer { AnyLayer::Video(l) => l.opacity(), AnyLayer::Effect(l) => l.opacity(), AnyLayer::Group(l) => l.opacity(), + AnyLayer::Raster(l) => l.opacity(), } } @@ -868,6 +881,7 @@ impl LayerTrait for AnyLayer { AnyLayer::Video(l) => l.set_opacity(opacity), AnyLayer::Effect(l) => l.set_opacity(opacity), AnyLayer::Group(l) => l.set_opacity(opacity), + AnyLayer::Raster(l) => l.set_opacity(opacity), } } @@ -878,6 +892,7 @@ impl LayerTrait for AnyLayer { AnyLayer::Video(l) => l.volume(), AnyLayer::Effect(l) => l.volume(), AnyLayer::Group(l) => l.volume(), + AnyLayer::Raster(l) => l.volume(), } } @@ -888,6 +903,7 @@ impl LayerTrait for AnyLayer { AnyLayer::Video(l) => l.set_volume(volume), AnyLayer::Effect(l) => l.set_volume(volume), AnyLayer::Group(l) => l.set_volume(volume), + AnyLayer::Raster(l) => l.set_volume(volume), } } @@ -898,6 +914,7 @@ impl LayerTrait for AnyLayer { AnyLayer::Video(l) => l.muted(), AnyLayer::Effect(l) => l.muted(), AnyLayer::Group(l) => l.muted(), + AnyLayer::Raster(l) => l.muted(), } } @@ -908,6 +925,7 @@ impl LayerTrait for AnyLayer { AnyLayer::Video(l) => l.set_muted(muted), AnyLayer::Effect(l) => l.set_muted(muted), AnyLayer::Group(l) => l.set_muted(muted), + AnyLayer::Raster(l) => l.set_muted(muted), } } @@ -918,6 +936,7 @@ impl LayerTrait for AnyLayer { AnyLayer::Video(l) => l.soloed(), AnyLayer::Effect(l) => l.soloed(), AnyLayer::Group(l) => l.soloed(), + AnyLayer::Raster(l) => l.soloed(), } } @@ -928,6 +947,7 @@ impl LayerTrait for AnyLayer { AnyLayer::Video(l) => l.set_soloed(soloed), AnyLayer::Effect(l) => l.set_soloed(soloed), AnyLayer::Group(l) => l.set_soloed(soloed), + AnyLayer::Raster(l) => l.set_soloed(soloed), } } @@ -938,6 +958,7 @@ impl LayerTrait for AnyLayer { AnyLayer::Video(l) => l.locked(), AnyLayer::Effect(l) => l.locked(), AnyLayer::Group(l) => l.locked(), + AnyLayer::Raster(l) => l.locked(), } } @@ -948,6 +969,7 @@ impl LayerTrait for AnyLayer { AnyLayer::Video(l) => l.set_locked(locked), AnyLayer::Effect(l) => l.set_locked(locked), AnyLayer::Group(l) => l.set_locked(locked), + AnyLayer::Raster(l) => l.set_locked(locked), } } } @@ -961,6 +983,7 @@ impl AnyLayer { AnyLayer::Video(l) => &l.layer, AnyLayer::Effect(l) => &l.layer, AnyLayer::Group(l) => &l.layer, + AnyLayer::Raster(l) => &l.layer, } } @@ -972,6 +995,7 @@ impl AnyLayer { AnyLayer::Video(l) => &mut l.layer, AnyLayer::Effect(l) => &mut l.layer, AnyLayer::Group(l) => &mut l.layer, + AnyLayer::Raster(l) => &mut l.layer, } } diff --git a/lightningbeam-ui/lightningbeam-core/src/lib.rs b/lightningbeam-ui/lightningbeam-core/src/lib.rs index c0138a1..40e705b 100644 --- a/lightningbeam-ui/lightningbeam-core/src/lib.rs +++ b/lightningbeam-ui/lightningbeam-core/src/lib.rs @@ -48,6 +48,9 @@ pub mod dcel2; pub use dcel2 as dcel; pub mod snap; pub mod webcam; +pub mod raster_layer; +pub mod brush_settings; +pub mod brush_engine; #[cfg(debug_assertions)] pub mod test_mode; diff --git a/lightningbeam-ui/lightningbeam-core/src/raster_layer.rs b/lightningbeam-ui/lightningbeam-core/src/raster_layer.rs new file mode 100644 index 0000000..9f6f293 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/raster_layer.rs @@ -0,0 +1,197 @@ +//! Raster (pixel-buffer) layer for Lightningbeam +//! +//! Each keyframe holds a PNG-compressed pixel buffer stored in the .beam ZIP +//! under `media/raster/.png`. A brush engine renders dabs along strokes +//! and the resulting RGBA image is composited into the Vello scene. + +use crate::brush_settings::BrushSettings; +use crate::layer::{Layer, LayerTrait, LayerType}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// How a raster stroke blends onto the layer buffer +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum RasterBlendMode { + /// Normal alpha-compositing (paint over) + Normal, + /// Erase pixels (reduce alpha) + Erase, + /// Smudge / blend surrounding pixels + Smudge, +} + +impl Default for RasterBlendMode { + fn default() -> Self { + Self::Normal + } +} + +/// A single point along a stroke +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct StrokePoint { + pub x: f32, + pub y: f32, + /// Pen/tablet pressure 0.0–1.0 (mouse uses 0.5) + pub pressure: f32, + /// Pen tilt X in radians + pub tilt_x: f32, + /// Pen tilt Y in radians + pub tilt_y: f32, + /// Seconds elapsed since start of this stroke + pub timestamp: f64, +} + +/// Record of a single brush stroke applied to a keyframe +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct StrokeRecord { + pub brush_settings: BrushSettings, + /// RGBA linear color [r, g, b, a] + pub color: [f32; 4], + pub blend_mode: RasterBlendMode, + pub points: Vec, +} + +/// Specifies how the raster content transitions to the next keyframe +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum TweenType { + /// Hold the pixel buffer until the next keyframe + Hold, +} + +impl Default for TweenType { + fn default() -> Self { + Self::Hold + } +} + +/// A single keyframe of a raster layer +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct RasterKeyframe { + /// Unique ID for this keyframe (used as pixel-cache key) + pub id: Uuid, + /// Time position in seconds + pub time: f64, + pub width: u32, + pub height: u32, + /// ZIP-relative path: `"media/raster/.png"` + pub media_path: String, + /// Stroke history (for potential replay / future non-destructive editing) + pub stroke_log: Vec, + pub tween_after: TweenType, + /// Raw RGBA pixel buffer (width × height × 4 bytes). + /// + /// This is the working in-memory representation used by the brush engine and renderer. + /// NOT serialized to the document JSON — populated from the ZIP's PNG on load, + /// and encoded back to PNG on save. An empty Vec means the canvas is blank (transparent). + #[serde(skip)] + pub raw_pixels: Vec, +} + +impl RasterKeyframe { + /// Returns true when the pixel buffer has been initialised (non-blank). + pub fn has_pixels(&self) -> bool { + !self.raw_pixels.is_empty() + } + + pub fn new(time: f64, width: u32, height: u32) -> Self { + let id = Uuid::new_v4(); + let media_path = format!("media/raster/{}.png", id); + Self { + id, + time, + width, + height, + media_path, + stroke_log: Vec::new(), + tween_after: TweenType::Hold, + raw_pixels: Vec::new(), + } + } +} + +/// A pixel-buffer painting layer +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct RasterLayer { + /// Base layer properties (id, name, opacity, visibility, …) + pub layer: Layer, + /// Keyframes sorted by time + pub keyframes: Vec, +} + +impl RasterLayer { + /// Create a new raster layer with the given name + pub fn new(name: impl Into) -> Self { + Self { + layer: Layer::new(LayerType::Raster, name), + keyframes: Vec::new(), + } + } + + // === Keyframe accessors === + + /// Get the active keyframe at-or-before `time` + pub fn keyframe_at(&self, time: f64) -> Option<&RasterKeyframe> { + let idx = self.keyframes.partition_point(|kf| kf.time <= time); + if idx > 0 { Some(&self.keyframes[idx - 1]) } else { None } + } + + /// Get a mutable reference to the active keyframe at-or-before `time` + pub fn keyframe_at_mut(&mut self, time: f64) -> Option<&mut RasterKeyframe> { + let idx = self.keyframes.partition_point(|kf| kf.time <= time); + if idx > 0 { Some(&mut self.keyframes[idx - 1]) } else { None } + } + + /// Find the index of a keyframe at exactly `time` (within tolerance) + fn keyframe_index_at_exact(&self, time: f64, tolerance: f64) -> Option { + self.keyframes.iter().position(|kf| (kf.time - time).abs() < tolerance) + } + + /// Ensure a keyframe exists at `time`; create one (with given dimensions) if not. + /// + /// If `width`/`height` are 0 the new keyframe inherits dimensions from the + /// previous active keyframe, falling back to 1920×1080. + pub fn ensure_keyframe_at(&mut self, time: f64, width: u32, height: u32) -> &mut RasterKeyframe { + let tolerance = 0.001; + if let Some(idx) = self.keyframe_index_at_exact(time, tolerance) { + return &mut self.keyframes[idx]; + } + + let (w, h) = if width == 0 || height == 0 { + self.keyframe_at(time) + .map(|kf| (kf.width, kf.height)) + .unwrap_or((1920, 1080)) + } else { + (width, height) + }; + + let insert_idx = self.keyframes.partition_point(|kf| kf.time < time); + self.keyframes.insert(insert_idx, RasterKeyframe::new(time, w, h)); + &mut self.keyframes[insert_idx] + } + + /// Return the ZIP-relative PNG path for the active keyframe at `time`, or `None`. + pub fn buffer_path_at_time(&self, time: f64) -> Option<&str> { + self.keyframe_at(time).map(|kf| kf.media_path.as_str()) + } +} + +// Delegate all LayerTrait methods to self.layer +impl LayerTrait for RasterLayer { + 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; } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/renderer.rs b/lightningbeam-ui/lightningbeam-core/src/renderer.rs index c7dd1b5..649535f 100644 --- a/lightningbeam-ui/lightningbeam-core/src/renderer.rs +++ b/lightningbeam-ui/lightningbeam-core/src/renderer.rs @@ -306,6 +306,11 @@ pub fn render_layer_isolated( } rendered.has_content = !group_layer.children.is_empty(); } + AnyLayer::Raster(raster_layer) => { + render_raster_layer_to_scene(raster_layer, time, &mut rendered.scene, base_transform); + rendered.has_content = raster_layer.keyframe_at(time) + .map_or(false, |kf| kf.has_pixels()); + } } rendered @@ -334,6 +339,32 @@ fn render_vector_layer_to_scene( ); } +/// Render a raster layer's active keyframe to a Vello scene using an ImageBrush. +/// +/// Uses `raw_pixels` directly — no PNG decode needed. +fn render_raster_layer_to_scene( + layer: &crate::raster_layer::RasterLayer, + time: f64, + scene: &mut Scene, + base_transform: Affine, +) { + let Some(kf) = layer.keyframe_at(time) else { return }; + if kf.raw_pixels.is_empty() { + return; + } + + let image_data = ImageData { + data: Blob::from(kf.raw_pixels.clone()), + format: ImageFormat::Rgba8, + width: kf.width, + height: kf.height, + alpha_type: ImageAlphaType::Alpha, + }; + let brush = ImageBrush::new(image_data); + let canvas_rect = Rect::new(0.0, 0.0, kf.width as f64, kf.height as f64); + scene.fill(Fill::NonZero, base_transform, &brush, None, &canvas_rect); +} + /// Render a video layer to an isolated scene (for compositing pipeline) fn render_video_layer_to_scene( document: &Document, @@ -451,6 +482,9 @@ fn render_layer( render_layer(document, time, child, scene, base_transform, parent_opacity, image_cache, video_manager, camera_frame); } } + AnyLayer::Raster(raster_layer) => { + render_raster_layer_to_scene(raster_layer, time, scene, base_transform); + } } } diff --git a/lightningbeam-ui/lightningbeam-core/src/tool.rs b/lightningbeam-ui/lightningbeam-core/src/tool.rs index 67b77f5..931946c 100644 --- a/lightningbeam-ui/lightningbeam-core/src/tool.rs +++ b/lightningbeam-ui/lightningbeam-core/src/tool.rs @@ -37,6 +37,10 @@ pub enum Tool { RegionSelect, /// Split tool - split audio/video clips at a point Split, + /// Erase tool - erase raster pixels + Erase, + /// Smudge tool - smudge/blend raster pixels + Smudge, } /// Region select mode @@ -66,6 +70,11 @@ pub enum ToolState { simplify_mode: SimplifyMode, }, + /// Drawing a raster paint stroke + DrawingRasterStroke { + points: Vec, + }, + /// Dragging selected objects DraggingSelection { start_pos: Point, @@ -213,6 +222,8 @@ impl Tool { Tool::Text => "Text", Tool::RegionSelect => "Region Select", Tool::Split => "Split", + Tool::Erase => "Erase", + Tool::Smudge => "Smudge", } } @@ -232,6 +243,8 @@ impl Tool { Tool::Text => "text.svg", Tool::RegionSelect => "region_select.svg", Tool::Split => "split.svg", + Tool::Erase => "erase.svg", + Tool::Smudge => "smudge.svg", } } @@ -259,6 +272,7 @@ impl Tool { match layer_type { None | Some(LayerType::Vector) => Tool::all(), Some(LayerType::Audio) | Some(LayerType::Video) => &[Tool::Select, Tool::Split], + Some(LayerType::Raster) => &[Tool::Select, Tool::Draw, Tool::Erase, Tool::Smudge, Tool::Eyedropper], _ => &[Tool::Select], } } @@ -279,6 +293,8 @@ impl Tool { Tool::Text => "T", Tool::RegionSelect => "S", Tool::Split => "C", + Tool::Erase => "X", + Tool::Smudge => "U", } } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/custom_cursor.rs b/lightningbeam-ui/lightningbeam-editor/src/custom_cursor.rs index 19999e0..029c57b 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/custom_cursor.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/custom_cursor.rs @@ -45,6 +45,8 @@ impl CustomCursor { Tool::Text => CustomCursor::Text, Tool::RegionSelect => CustomCursor::Select, // Reuse select cursor for now Tool::Split => CustomCursor::Select, // Reuse select cursor for now + Tool::Erase => CustomCursor::Draw, // Reuse draw cursor for raster erase + Tool::Smudge => CustomCursor::Draw, // Reuse draw cursor for raster smudge } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/keymap.rs b/lightningbeam-ui/lightningbeam-editor/src/keymap.rs index 3273d20..f3bcbe4 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/keymap.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/keymap.rs @@ -50,6 +50,7 @@ pub enum AppAction { AddVideoLayer, AddAudioTrack, AddMidiTrack, + AddRasterLayer, AddTestClip, DeleteLayer, ToggleLayerVisibility, @@ -124,7 +125,7 @@ impl AppAction { Self::BringToFront | Self::SplitClip | Self::DuplicateClip => "Modify", Self::AddLayer | Self::AddVideoLayer | Self::AddAudioTrack | - Self::AddMidiTrack | Self::AddTestClip | Self::DeleteLayer | + Self::AddMidiTrack | Self::AddRasterLayer | Self::AddTestClip | Self::DeleteLayer | Self::ToggleLayerVisibility => "Layer", Self::NewKeyframe | Self::NewBlankKeyframe | Self::DeleteFrame | @@ -199,6 +200,7 @@ impl AppAction { Self::AddVideoLayer => "Add Video Layer", Self::AddAudioTrack => "Add Audio Track", Self::AddMidiTrack => "Add MIDI Track", + Self::AddRasterLayer => "Add Raster Layer", Self::AddTestClip => "Add Test Clip", Self::DeleteLayer => "Delete Layer", Self::ToggleLayerVisibility => "Toggle Layer Visibility", @@ -314,6 +316,7 @@ impl From for AppAction { MenuAction::AddVideoLayer => Self::AddVideoLayer, MenuAction::AddAudioTrack => Self::AddAudioTrack, MenuAction::AddMidiTrack => Self::AddMidiTrack, + MenuAction::AddRasterLayer => Self::AddRasterLayer, MenuAction::AddTestClip => Self::AddTestClip, MenuAction::DeleteLayer => Self::DeleteLayer, MenuAction::ToggleLayerVisibility => Self::ToggleLayerVisibility, @@ -373,6 +376,7 @@ impl TryFrom for MenuAction { AppAction::AddVideoLayer => MenuAction::AddVideoLayer, AppAction::AddAudioTrack => MenuAction::AddAudioTrack, AppAction::AddMidiTrack => MenuAction::AddMidiTrack, + AppAction::AddRasterLayer => MenuAction::AddRasterLayer, AppAction::AddTestClip => MenuAction::AddTestClip, AppAction::DeleteLayer => MenuAction::DeleteLayer, AppAction::ToggleLayerVisibility => MenuAction::ToggleLayerVisibility, diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index 33744cb..482b3e6 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -321,6 +321,8 @@ mod tool_icons { pub static BEZIER_EDIT: &[u8] = include_bytes!("../../../src/assets/bezier_edit.svg"); pub static TEXT: &[u8] = include_bytes!("../../../src/assets/text.svg"); pub static SPLIT: &[u8] = include_bytes!("../../../src/assets/split.svg"); + pub static ERASE: &[u8] = include_bytes!("../../../src/assets/erase.svg"); + pub static SMUDGE: &[u8] = include_bytes!("../../../src/assets/smudge.svg"); } /// Embedded focus icon SVGs @@ -328,6 +330,7 @@ mod focus_icons { pub static ANIMATION: &[u8] = include_bytes!("../../../src/assets/focus-animation.svg"); pub static MUSIC: &[u8] = include_bytes!("../../../src/assets/focus-music.svg"); pub static VIDEO: &[u8] = include_bytes!("../../../src/assets/focus-video.svg"); + pub static PAINTING: &[u8] = include_bytes!("../../../src/assets/focus-painting.svg"); } /// Icon cache for pane type icons @@ -389,6 +392,8 @@ impl ToolIconCache { Tool::Text => tool_icons::TEXT, Tool::RegionSelect => tool_icons::SELECT, // Reuse select icon for now Tool::Split => tool_icons::SPLIT, + Tool::Erase => tool_icons::ERASE, + Tool::Smudge => tool_icons::SMUDGE, }; if let Some(texture) = rasterize_svg(svg_data, tool.icon_file(), 180, ctx) { self.icons.insert(tool, texture); @@ -416,6 +421,7 @@ impl FocusIconCache { FocusIcon::Animation => (focus_icons::ANIMATION, "focus-animation.svg"), FocusIcon::Music => (focus_icons::MUSIC, "focus-music.svg"), FocusIcon::Video => (focus_icons::VIDEO, "focus-video.svg"), + FocusIcon::Painting => (focus_icons::PAINTING, "focus-painting.svg"), }; // Replace currentColor with the actual color @@ -661,6 +667,7 @@ enum FocusIcon { Animation, Music, Video, + Painting, } /// Recording arm mode - determines how tracks are armed for recording @@ -1218,6 +1225,18 @@ impl EditorApp { if response.clicked() { self.create_new_project_with_focus(1); } + + ui.add_space(card_spacing); + + // Painting + let (rect, response) = ui.allocate_exact_size( + egui::vec2(card_size, card_size + 40.0), + egui::Sense::click(), + ); + self.render_focus_card_with_icon(ui, rect, response.hovered(), "Painting", FocusIcon::Painting); + if response.clicked() { + self.create_new_project_with_focus(5); + } }); }); }); @@ -1355,7 +1374,7 @@ impl EditorApp { .with_framerate(self.config.framerate as f64); // Add default layer based on focus type - // Layout indices: 0 = Animation, 1 = Video editing, 2 = Music + // Layout indices: 0 = Animation, 1 = Video editing, 2 = Music, 5 = Drawing/Painting let layer_id = match layout_index { 0 => { // Animation focus -> VectorLayer @@ -1373,6 +1392,12 @@ impl EditorApp { let layer = AudioLayer::new_midi("MIDI 1"); document.root.add_child(AnyLayer::Audio(layer)) } + 5 => { + // Painting focus -> RasterLayer + use lightningbeam_core::raster_layer::RasterLayer; + let layer = RasterLayer::new("Raster 1"); + document.root.add_child(AnyLayer::Raster(layer)) + } _ => { // Fallback to VectorLayer let layer = VectorLayer::new("Layer 1"); @@ -1662,7 +1687,7 @@ impl EditorApp { AnyLayer::Audio(al) => find_splittable_clips(&al.clip_instances, split_time, document), AnyLayer::Video(vl) => find_splittable_clips(&vl.clip_instances, split_time, document), AnyLayer::Effect(el) => find_splittable_clips(&el.clip_instances, split_time, document), - AnyLayer::Group(_) => vec![], + AnyLayer::Group(_) | AnyLayer::Raster(_) => vec![], }; for instance_id in active_layer_clips { @@ -1680,7 +1705,7 @@ impl EditorApp { AnyLayer::Audio(al) => find_splittable_clips(&al.clip_instances, split_time, document), AnyLayer::Video(vl) => find_splittable_clips(&vl.clip_instances, split_time, document), AnyLayer::Effect(el) => find_splittable_clips(&el.clip_instances, split_time, document), - AnyLayer::Group(_) => vec![], + AnyLayer::Group(_) | AnyLayer::Raster(_) => vec![], }; if member_splittable.contains(member_instance_id) { clips_to_split.push((*member_layer_id, *member_instance_id)); @@ -1791,7 +1816,7 @@ impl EditorApp { AnyLayer::Audio(al) => &al.clip_instances, AnyLayer::Video(vl) => &vl.clip_instances, AnyLayer::Effect(el) => &el.clip_instances, - AnyLayer::Group(_) => &[], + AnyLayer::Group(_) | AnyLayer::Raster(_) => &[], }; let instances: Vec<_> = clip_slice .iter() @@ -2089,7 +2114,7 @@ impl EditorApp { AnyLayer::Audio(al) => &al.clip_instances, AnyLayer::Video(vl) => &vl.clip_instances, AnyLayer::Effect(el) => &el.clip_instances, - AnyLayer::Group(_) => &[], + AnyLayer::Group(_) | AnyLayer::Raster(_) => &[], }; instances.iter() .filter(|ci| selection.contains_clip_instance(&ci.id)) @@ -2788,6 +2813,24 @@ impl EditorApp { } } } + MenuAction::AddRasterLayer => { + use lightningbeam_core::raster_layer::RasterLayer; + let editing_clip_id = self.editing_context.current_clip_id(); + let context_layers = self.action_executor.document().context_layers(editing_clip_id.as_ref()); + let layer_number = context_layers.len() + 1; + let layer_name = format!("Raster {}", layer_number); + + let layer = RasterLayer::new(layer_name); + let action = lightningbeam_core::actions::AddLayerAction::new(AnyLayer::Raster(layer)) + .with_target_clip(editing_clip_id); + let _ = self.action_executor.execute(Box::new(action)); + + // Set newly created layer as active + let context_layers = self.action_executor.document().context_layers(editing_clip_id.as_ref()); + if let Some(last_layer) = context_layers.last() { + self.active_layer_id = Some(last_layer.id()); + } + } MenuAction::AddTestClip => { // Create a test vector clip and add it to the library (not to timeline) use lightningbeam_core::clip::VectorClip; diff --git a/lightningbeam-ui/lightningbeam-editor/src/menu.rs b/lightningbeam-ui/lightningbeam-editor/src/menu.rs index ebb8b8d..2a3f105 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/menu.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/menu.rs @@ -320,6 +320,7 @@ pub enum MenuAction { AddVideoLayer, AddAudioTrack, AddMidiTrack, + AddRasterLayer, AddTestClip, // For testing: adds a test clip to the asset library DeleteLayer, ToggleLayerVisibility, @@ -417,6 +418,7 @@ impl MenuItemDef { const ADD_VIDEO_LAYER: Self = Self { label: "Add Video Layer", action: MenuAction::AddVideoLayer, shortcut: None }; const ADD_AUDIO_TRACK: Self = Self { label: "Add Audio Track", action: MenuAction::AddAudioTrack, shortcut: None }; const ADD_MIDI_TRACK: Self = Self { label: "Add MIDI Track", action: MenuAction::AddMidiTrack, shortcut: None }; + const ADD_RASTER_LAYER: Self = Self { label: "Add Raster Layer", action: MenuAction::AddRasterLayer, shortcut: None }; const ADD_TEST_CLIP: Self = Self { label: "Add Test Clip to Library", action: MenuAction::AddTestClip, shortcut: None }; const DELETE_LAYER: Self = Self { label: "Delete Layer", action: MenuAction::DeleteLayer, shortcut: None }; const TOGGLE_LAYER_VISIBILITY: Self = Self { label: "Hide/Show Layer", action: MenuAction::ToggleLayerVisibility, shortcut: None }; @@ -534,6 +536,7 @@ impl MenuItemDef { MenuDef::Item(&Self::ADD_VIDEO_LAYER), MenuDef::Item(&Self::ADD_AUDIO_TRACK), MenuDef::Item(&Self::ADD_MIDI_TRACK), + MenuDef::Item(&Self::ADD_RASTER_LAYER), MenuDef::Separator, MenuDef::Item(&Self::ADD_TEST_CLIP), MenuDef::Separator, diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/asset_library.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/asset_library.rs index 32b47b4..fa21d7c 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/asset_library.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/asset_library.rs @@ -1265,6 +1265,9 @@ impl AssetLibraryPane { lightningbeam_core::layer::AnyLayer::Group(_) => { // Group layers don't have their own clip instances } + lightningbeam_core::layer::AnyLayer::Raster(_) => { + // Raster layers don't have their own clip instances + } } } false diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs index f280918..a54f9f6 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs @@ -535,6 +535,7 @@ impl InfopanelPane { AnyLayer::Video(_) => "Video", AnyLayer::Effect(_) => "Effect", AnyLayer::Group(_) => "Group", + AnyLayer::Raster(_) => "Raster", }; ui.horizontal(|ui| { ui.label("Type:"); @@ -590,6 +591,7 @@ impl InfopanelPane { AnyLayer::Video(l) => &l.clip_instances, AnyLayer::Effect(l) => &l.clip_instances, AnyLayer::Group(_) => &[], + AnyLayer::Raster(_) => &[], }; if let Some(ci) = instances.iter().find(|c| c.id == ci_id) { found = true; diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs index dca5bd3..af1f8d0 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs @@ -2136,6 +2136,10 @@ pub struct StagePane { dcel_editing_cache: Option, // Current snap result (for visual feedback rendering) current_snap: Option, + // Raster stroke in progress: (layer_id, time, brush_state, buffer_before) + raster_stroke_state: Option<(uuid::Uuid, f64, lightningbeam_core::brush_engine::StrokeState, Vec)>, + // Last raster stroke point (for incremental segment painting) + raster_last_point: Option, /// Synthetic drag/click override for test mode replay (debug builds only) #[cfg(debug_assertions)] replay_override: Option, @@ -2211,6 +2215,8 @@ impl StagePane { last_viewport_rect: None, dcel_editing_cache: None, current_snap: None, + raster_stroke_state: None, + raster_last_point: None, #[cfg(debug_assertions)] replay_override: None, } @@ -4175,6 +4181,155 @@ impl StagePane { } } + /// Handle raster stroke tool input (Draw/Erase/Smudge on a raster layer). + /// + /// Paints incrementally into `document_mut()` on every drag event so the + /// result is visible immediately. On mouse-up the pre/post raw-pixel + /// buffers are wrapped in a `RasterStrokeAction` for undo/redo. + fn handle_raster_stroke_tool( + &mut self, + ui: &mut egui::Ui, + response: &egui::Response, + world_pos: egui::Vec2, + blend_mode: lightningbeam_core::raster_layer::RasterBlendMode, + shared: &mut SharedPaneState, + ) { + use lightningbeam_core::tool::ToolState; + use lightningbeam_core::layer::AnyLayer; + use lightningbeam_core::raster_layer::StrokePoint; + use lightningbeam_core::brush_engine::{BrushEngine, StrokeState, image_from_raw}; + use lightningbeam_core::raster_layer::StrokeRecord; + + let active_layer_id = match *shared.active_layer_id { + Some(id) => id, + None => return, + }; + + // Only operate on raster layers + let is_raster = shared.action_executor.document() + .get_layer(&active_layer_id) + .map_or(false, |l| matches!(l, AnyLayer::Raster(_))); + if !is_raster { + return; + } + + let brush = lightningbeam_core::brush_settings::BrushSettings::default_round_soft(); + + let color = if matches!(blend_mode, lightningbeam_core::raster_layer::RasterBlendMode::Erase) { + [1.0f32, 1.0, 1.0, 1.0] + } else { + let c = *shared.stroke_color; + [c.r() as f32 / 255.0, c.g() as f32 / 255.0, c.b() as f32 / 255.0, c.a() as f32 / 255.0] + }; + + // Mouse down: snapshot buffer_before, init stroke state, paint first dab + if self.rsp_drag_started(response) || self.rsp_clicked(response) { + let (doc_width, doc_height, buffer_before) = { + let doc = shared.action_executor.document(); + let buf = doc.get_layer(&active_layer_id) + .and_then(|l| if let AnyLayer::Raster(rl) = l { + rl.keyframe_at(*shared.playback_time).map(|kf| kf.raw_pixels.clone()) + } else { None }) + .unwrap_or_default(); + (doc.width as u32, doc.height as u32, buf) + }; + + // Start a fresh stroke state; MAX distance ensures first point gets a dab + let mut stroke_state = StrokeState::new(); + stroke_state.distance_since_last_dab = f32::MAX; + + let first_pt = StrokePoint { x: world_pos.x, y: world_pos.y, pressure: 1.0, tilt_x: 0.0, tilt_y: 0.0, timestamp: 0.0 }; + + // Paint the first dab directly into the document + { + let doc = shared.action_executor.document_mut(); + if let Some(AnyLayer::Raster(rl)) = doc.get_layer_mut(&active_layer_id) { + let kf = rl.ensure_keyframe_at(*shared.playback_time, doc_width, doc_height); + let mut img = image_from_raw(std::mem::take(&mut kf.raw_pixels), kf.width, kf.height); + let single = StrokeRecord { + brush_settings: brush.clone(), + color, + blend_mode, + points: vec![first_pt.clone()], + }; + BrushEngine::apply_stroke_with_state(&mut img, &single, &mut stroke_state); + kf.raw_pixels = img.into_raw(); + } + } + + self.raster_stroke_state = Some((active_layer_id, *shared.playback_time, stroke_state, buffer_before)); + self.raster_last_point = Some(first_pt); + *shared.tool_state = ToolState::DrawingRasterStroke { points: vec![] }; + } + + // Mouse drag: paint each new segment immediately + if self.rsp_dragged(response) { + if let Some((layer_id, time, ref mut stroke_state, _)) = self.raster_stroke_state { + if let Some(prev_pt) = self.raster_last_point.take() { + let curr_pt = StrokePoint { x: world_pos.x, y: world_pos.y, pressure: 1.0, tilt_x: 0.0, tilt_y: 0.0, timestamp: 0.0 }; + + // Skip if not moved enough + const MIN_DIST_SQ: f32 = 1.5 * 1.5; + let dx = curr_pt.x - prev_pt.x; + let dy = curr_pt.y - prev_pt.y; + let moved_pt = if dx * dx + dy * dy >= MIN_DIST_SQ { curr_pt.clone() } else { prev_pt.clone() }; + + if dx * dx + dy * dy >= MIN_DIST_SQ { + let doc = shared.action_executor.document_mut(); + if let Some(AnyLayer::Raster(rl)) = doc.get_layer_mut(&layer_id) { + if let Some(kf) = rl.keyframe_at_mut(time) { + let mut img = image_from_raw(std::mem::take(&mut kf.raw_pixels), kf.width, kf.height); + let seg = StrokeRecord { + brush_settings: brush.clone(), + color, + blend_mode, + points: vec![prev_pt, curr_pt], + }; + BrushEngine::apply_stroke_with_state(&mut img, &seg, stroke_state); + kf.raw_pixels = img.into_raw(); + } + } + } + + self.raster_last_point = Some(moved_pt); + } + } + } + + // Mouse up: wrap the pre/post buffers in an undo action + if self.rsp_drag_stopped(response) + || (self.rsp_any_released(ui) && matches!(*shared.tool_state, ToolState::DrawingRasterStroke { .. })) + { + if let Some((layer_id, time, _, buffer_before)) = self.raster_stroke_state.take() { + use lightningbeam_core::actions::RasterStrokeAction; + + let (doc_width, doc_height, buffer_after) = { + let doc = shared.action_executor.document(); + let buf = doc.get_layer(&layer_id) + .and_then(|l| if let AnyLayer::Raster(rl) = l { + rl.keyframe_at(time).map(|kf| kf.raw_pixels.clone()) + } else { None }) + .unwrap_or_default(); + (doc.width as u32, doc.height as u32, buf) + }; + + let action = RasterStrokeAction::new( + layer_id, + time, + buffer_before, + buffer_after, + doc_width, + doc_height, + ); + // execute is a no-op for the first call (pixels already in document), + // but registers the action in the undo stack + let _ = shared.action_executor.execute(Box::new(action)); + } + self.raster_last_point = None; + *shared.tool_state = ToolState::Idle; + } + } + fn handle_paint_bucket_tool( &mut self, response: &egui::Response, @@ -6187,7 +6342,21 @@ impl StagePane { self.handle_ellipse_tool(ui, &response, world_pos, shift_held, ctrl_held, shared); } Tool::Draw => { - self.handle_draw_tool(ui, &response, world_pos, shared); + // Dispatch to raster or vector draw handler based on active layer type + let is_raster = shared.active_layer_id.and_then(|id| { + shared.action_executor.document().get_layer(&id) + }).map_or(false, |l| matches!(l, lightningbeam_core::layer::AnyLayer::Raster(_))); + if is_raster { + self.handle_raster_stroke_tool(ui, &response, world_pos, lightningbeam_core::raster_layer::RasterBlendMode::Normal, shared); + } else { + self.handle_draw_tool(ui, &response, world_pos, shared); + } + } + Tool::Erase => { + self.handle_raster_stroke_tool(ui, &response, world_pos, lightningbeam_core::raster_layer::RasterBlendMode::Erase, shared); + } + Tool::Smudge => { + self.handle_raster_stroke_tool(ui, &response, world_pos, lightningbeam_core::raster_layer::RasterBlendMode::Smudge, shared); } Tool::Transform => { self.handle_transform_tool(ui, &response, world_pos, shared); diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs index fd143d9..66a950a 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs @@ -118,6 +118,7 @@ fn effective_clip_duration( AnyLayer::Video(_) => document.get_video_clip(&clip_instance.clip_id).map(|c| c.duration), AnyLayer::Effect(_) => Some(lightningbeam_core::effect::EFFECT_DURATION), AnyLayer::Group(_) => None, + AnyLayer::Raster(_) => None, } } @@ -372,6 +373,7 @@ fn collect_clip_instances<'a>(layer: &'a AnyLayer, result: &mut Vec<(&'a AnyLaye collect_clip_instances(child, result); } } + AnyLayer::Raster(_) => {} } } @@ -684,6 +686,7 @@ impl TimelinePane { lightningbeam_core::layer::AnyLayer::Video(vl) => &vl.clip_instances, lightningbeam_core::layer::AnyLayer::Effect(el) => &el.clip_instances, lightningbeam_core::layer::AnyLayer::Group(_) => &[], + lightningbeam_core::layer::AnyLayer::Raster(_) => &[], }; // Check each clip instance @@ -1196,6 +1199,7 @@ impl TimelinePane { AnyLayer::Video(_) => ("Video", egui::Color32::from_rgb(180, 100, 255)), AnyLayer::Effect(_) => ("Effect", egui::Color32::from_rgb(255, 100, 180)), AnyLayer::Group(_) => ("Group", egui::Color32::from_rgb(0, 180, 180)), + AnyLayer::Raster(_) => ("Raster", egui::Color32::from_rgb(160, 100, 200)), }; (layer.id(), data.name.clone(), lt, tc) } @@ -1213,6 +1217,7 @@ impl TimelinePane { AnyLayer::Video(_) => ("Video", egui::Color32::from_rgb(180, 100, 255)), AnyLayer::Effect(_) => ("Effect", egui::Color32::from_rgb(255, 100, 180)), AnyLayer::Group(_) => ("Group", egui::Color32::from_rgb(0, 180, 180)), + AnyLayer::Raster(_) => ("Raster", egui::Color32::from_rgb(160, 100, 200)), }; (child.id(), data.name.clone(), lt, tc) } @@ -1972,6 +1977,7 @@ impl TimelinePane { lightningbeam_core::layer::AnyLayer::Video(vl) => &vl.clip_instances, lightningbeam_core::layer::AnyLayer::Effect(el) => &el.clip_instances, lightningbeam_core::layer::AnyLayer::Group(_) => &[], + lightningbeam_core::layer::AnyLayer::Raster(_) => &[], }; // For moves, precompute the clamped offset so all selected clips move uniformly @@ -2278,6 +2284,10 @@ impl TimelinePane { egui::Color32::from_rgb(0, 150, 150), // Teal egui::Color32::from_rgb(100, 220, 220), // Bright teal ), + lightningbeam_core::layer::AnyLayer::Raster(_) => ( + egui::Color32::from_rgb(160, 100, 200), // Purple/violet + egui::Color32::from_rgb(200, 160, 240), // Bright purple/violet + ), }; let (row, total_rows) = clip_stacking[clip_instance_index]; @@ -2868,6 +2878,7 @@ impl TimelinePane { lightningbeam_core::layer::AnyLayer::Video(vl) => &vl.clip_instances, lightningbeam_core::layer::AnyLayer::Effect(el) => &el.clip_instances, lightningbeam_core::layer::AnyLayer::Group(_) => &[], + lightningbeam_core::layer::AnyLayer::Raster(_) => &[], }; // Check if click is within any clip instance @@ -3722,6 +3733,7 @@ impl PaneRenderer for TimelinePane { lightningbeam_core::layer::AnyLayer::Video(vl) => &vl.clip_instances, lightningbeam_core::layer::AnyLayer::Effect(el) => &el.clip_instances, lightningbeam_core::layer::AnyLayer::Group(_) => &[], + lightningbeam_core::layer::AnyLayer::Raster(_) => &[], }; for clip_instance in clip_instances { @@ -3860,6 +3872,7 @@ impl PaneRenderer for TimelinePane { AnyLayer::Video(vl) => &vl.clip_instances, AnyLayer::Effect(el) => &el.clip_instances, AnyLayer::Group(_) => &[], + AnyLayer::Raster(_) => &[], }; for inst in instances { if !shared.selection.contains_clip_instance(&inst.id) { continue; } @@ -3890,6 +3903,7 @@ impl PaneRenderer for TimelinePane { AnyLayer::Video(vl) => &vl.clip_instances, AnyLayer::Effect(el) => &el.clip_instances, AnyLayer::Group(_) => &[], + AnyLayer::Raster(_) => &[], }; // Check each selected clip enabled = instances.iter() diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/toolbar.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/toolbar.rs index e652c63..becc4c3 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/toolbar.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/toolbar.rs @@ -40,6 +40,7 @@ impl PaneRenderer for ToolbarPane { AnyLayer::Video(_) => LayerType::Video, AnyLayer::Effect(_) => LayerType::Effect, AnyLayer::Group(_) => LayerType::Group, + AnyLayer::Raster(_) => LayerType::Raster, }); // Auto-switch to Select if the current tool isn't available for this layer type diff --git a/src/assets/erase.svg b/src/assets/erase.svg new file mode 100644 index 0000000..598fb8f --- /dev/null +++ b/src/assets/erase.svg @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/src/assets/focus-painting.svg b/src/assets/focus-painting.svg new file mode 100644 index 0000000..97574e1 --- /dev/null +++ b/src/assets/focus-painting.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + diff --git a/src/assets/smudge.svg b/src/assets/smudge.svg new file mode 100644 index 0000000..4469401 --- /dev/null +++ b/src/assets/smudge.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + +