Add raster layers
This commit is contained in:
parent
4b638b882f
commit
1c7256a12e
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<u8>,
|
||||
/// Raw RGBA pixels *after* the stroke (for execute / redo)
|
||||
buffer_after: Vec<u8>,
|
||||
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<u8>,
|
||||
buffer_after: Vec<u8>,
|
||||
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))
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<u8>, 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<Vec<u8>, 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<RgbaImage, String> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Self, String> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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/<keyframe-uuid>.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<LoadedProject, String> {
|
|||
|
||||
// 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<LoadedProject, String> {
|
|||
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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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/<uuid>.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<StrokePoint>,
|
||||
}
|
||||
|
||||
/// 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/<uuid>.png"`
|
||||
pub media_path: String,
|
||||
/// Stroke history (for potential replay / future non-destructive editing)
|
||||
pub stroke_log: Vec<StrokeRecord>,
|
||||
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<u8>,
|
||||
}
|
||||
|
||||
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<RasterKeyframe>,
|
||||
}
|
||||
|
||||
impl RasterLayer {
|
||||
/// Create a new raster layer with the given name
|
||||
pub fn new(name: impl Into<String>) -> 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<usize> {
|
||||
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; }
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<crate::raster_layer::StrokePoint>,
|
||||
},
|
||||
|
||||
/// 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",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<MenuAction> 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<AppAction> 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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -2136,6 +2136,10 @@ pub struct StagePane {
|
|||
dcel_editing_cache: Option<DcelEditingCache>,
|
||||
// Current snap result (for visual feedback rendering)
|
||||
current_snap: Option<lightningbeam_core::snap::SnapResult>,
|
||||
// Raster stroke in progress: (layer_id, time, brush_state, buffer_before)
|
||||
raster_stroke_state: Option<(uuid::Uuid, f64, lightningbeam_core::brush_engine::StrokeState, Vec<u8>)>,
|
||||
// Last raster stroke point (for incremental segment painting)
|
||||
raster_last_point: Option<lightningbeam_core::raster_layer::StrokePoint>,
|
||||
/// Synthetic drag/click override for test mode replay (debug builds only)
|
||||
#[cfg(debug_assertions)]
|
||||
replay_override: Option<ReplayDragState>,
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
|
||||
<!-- Eraser body (angled rectangle) -->
|
||||
<rect x="3" y="11" width="14" height="7" rx="1.5"
|
||||
transform="rotate(-30 10 14.5)"
|
||||
fill="#bebebe" fill-opacity="1"/>
|
||||
<!-- Pink eraser tip highlight -->
|
||||
<rect x="3" y="11" width="5" height="7" rx="1.5"
|
||||
transform="rotate(-30 10 14.5)"
|
||||
fill="#e8a0a0" fill-opacity="0.8"/>
|
||||
<!-- Baseline -->
|
||||
<line x1="2" y1="22" x2="22" y2="22" stroke="#bebebe" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 559 B |
|
|
@ -0,0 +1,13 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="80" height="80" viewBox="0 0 100 100">
|
||||
<!-- Brush handle -->
|
||||
<rect x="48" y="10" width="8" height="40" rx="3" fill="currentColor" opacity="0.7"/>
|
||||
<!-- Brush ferrule -->
|
||||
<rect x="46" y="46" width="12" height="6" rx="1" fill="currentColor"/>
|
||||
<!-- Brush tip -->
|
||||
<ellipse cx="52" cy="58" rx="6" ry="10" fill="currentColor"/>
|
||||
<!-- Paint strokes -->
|
||||
<path d="M15 72 Q25 62 38 68 Q50 74 60 65" stroke="currentColor" stroke-width="5"
|
||||
stroke-linecap="round" fill="none" opacity="0.9"/>
|
||||
<path d="M12 85 Q30 78 50 83 Q65 87 80 80" stroke="currentColor" stroke-width="4"
|
||||
stroke-linecap="round" fill="none" opacity="0.6"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 698 B |
|
|
@ -0,0 +1,12 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
|
||||
<!-- Finger tip -->
|
||||
<ellipse cx="14" cy="5" rx="3" ry="4" fill="#bebebe" fill-opacity="1"/>
|
||||
<!-- Finger body -->
|
||||
<rect x="11" y="5" width="6" height="10" rx="3" fill="#bebebe" fill-opacity="1"/>
|
||||
<!-- Smudge streak 1 -->
|
||||
<path d="M 6 14 Q 10 16 15 15" stroke="#bebebe" stroke-width="2.5" stroke-linecap="round" fill="none" opacity="0.7"/>
|
||||
<!-- Smudge streak 2 -->
|
||||
<path d="M 4 17 Q 9 19 14 18" stroke="#bebebe" stroke-width="2" stroke-linecap="round" fill="none" opacity="0.5"/>
|
||||
<!-- Smudge streak 3 -->
|
||||
<path d="M 5 20 Q 10 21.5 15 21" stroke="#bebebe" stroke-width="1.5" stroke-linecap="round" fill="none" opacity="0.3"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 734 B |
Loading…
Reference in New Issue