raster: info-panel size + "Layer to document size" (scale/expand-crop)

Resizing the document leaves raster layers as-is (canvas keeps its old pixel size,
anchored top-left). To reconcile, the info panel now shows the active raster
layer's canvas size — driven by the *active* layer, not selection focus, since
painting doesn't focus the layer — and, when it differs from the document, a
Scale / Expand-Crop toggle + a "Layer to document size" button.

- RasterKeyframe::resize_to(w, h, mode): always applies the new declared size;
  resamples (Lanczos3) when Scale, top-left pad/trim when Canvas, and only touches
  the buffer when pixels are resident (a blank canvas just takes the new size).
  Sets texture_dirty so the stage's dirty-scan refreshes the GPU texture.
- ResizeRasterLayerAction resizes every keyframe with undo, holding a (read-only)
  RasterStore so paged-out keyframes are loaded one at a time rather than bulk.

Resized keyframes stay resident + dirty and persist on the next full save (no
incremental store write to page them back out). Scale is lossy/compounds;
Expand-Crop is lossless.
This commit is contained in:
Skyler Lehmkuhl 2026-06-26 15:40:16 -04:00
parent 69939c066d
commit 5869e3ced1
4 changed files with 199 additions and 0 deletions

View File

@ -40,6 +40,7 @@ pub mod raster_diff;
pub mod raster_stroke;
pub mod raster_fill;
pub mod add_raster_keyframe;
pub mod resize_raster_layer;
pub mod move_layer;
pub mod set_fill_paint;
pub mod set_image_fill;
@ -77,6 +78,7 @@ pub use group_layers::GroupLayersAction;
pub use raster_stroke::RasterStrokeAction;
pub use raster_fill::RasterFillAction;
pub use add_raster_keyframe::AddRasterKeyframeAction;
pub use resize_raster_layer::ResizeRasterLayerAction;
pub use move_layer::MoveLayerAction;
pub use set_fill_paint::SetFillPaintAction;
pub use set_image_fill::SetImageFillAction;

View File

@ -0,0 +1,90 @@
//! Resize every keyframe of a raster layer to a new canvas size (Scale or Canvas mode), with undo.
//!
//! Used by the info panel's "Layer to document size" button. Pixels must be resident (the editor
//! faults them in first) so the resample/copy is exact and a later page-in won't mismatch the size.
use crate::action::Action;
use crate::document::Document;
use crate::layer::AnyLayer;
use crate::raster_layer::RasterResizeMode;
use crate::raster_store::RasterStore;
use uuid::Uuid;
/// Per-keyframe state captured for undo.
struct OldKeyframe {
id: Uuid,
width: u32,
height: u32,
pixels: Vec<u8>,
}
pub struct ResizeRasterLayerAction {
layer_id: Uuid,
new_w: u32,
new_h: u32,
mode: RasterResizeMode,
/// Read-only page-in for keyframes whose pixels aren't resident. (No incremental write exists, so
/// resized keyframes stay resident + dirty and persist on the next full save.)
store: RasterStore,
/// Captured on first execute for rollback.
old: Option<Vec<OldKeyframe>>,
}
impl ResizeRasterLayerAction {
pub fn new(layer_id: Uuid, new_w: u32, new_h: u32, mode: RasterResizeMode, store: RasterStore) -> Self {
Self { layer_id, new_w, new_h, mode, store, old: None }
}
}
impl Action for ResizeRasterLayerAction {
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
let Some(AnyLayer::Raster(rl)) = document.get_layer_mut(&self.layer_id) else {
return Err("ResizeRasterLayerAction: layer is not a raster layer".into());
};
let capture = self.old.is_none();
let mut old = Vec::new();
for kf in rl.keyframes.iter_mut() {
// Page the keyframe in one at a time so we never hold the whole layer in memory at once.
if kf.raw_pixels.is_empty() {
if let Some(px) = self.store.load_pixels(kf.id) {
kf.raw_pixels = px;
kf.needs_fault_in = false;
}
}
if capture {
old.push(OldKeyframe { id: kf.id, width: kf.width, height: kf.height, pixels: kf.raw_pixels.clone() });
}
kf.resize_to(self.new_w, self.new_h, self.mode);
}
if capture {
self.old = Some(old);
}
Ok(())
}
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
let Some(AnyLayer::Raster(rl)) = document.get_layer_mut(&self.layer_id) else {
return Err("ResizeRasterLayerAction: layer is not a raster layer".into());
};
if let Some(old) = &self.old {
for o in old {
if let Some(kf) = rl.keyframes.iter_mut().find(|kf| kf.id == o.id) {
kf.width = o.width;
kf.height = o.height;
kf.raw_pixels = o.pixels.clone();
kf.proxy = None;
kf.texture_dirty = true;
kf.dirty = true;
}
}
}
Ok(())
}
fn description(&self) -> String {
match self.mode {
RasterResizeMode::Scale => "Scale raster layer".into(),
RasterResizeMode::Canvas => "Resize raster canvas".into(),
}
}
}

View File

@ -169,6 +169,57 @@ impl RasterKeyframe {
proxy: None,
}
}
/// Change the canvas to `(new_w, new_h)`. `Scale` resamples the content (Lanczos3) to fill the
/// new size; `Canvas` keeps the content at native resolution anchored top-left, padding with
/// transparent (expand) or trimming (crop). No-op if the size is unchanged. If pixels aren't
/// resident the buffer is left empty and only the declared size changes — the caller must fault
/// pixels in first (a later load would otherwise mismatch the new size).
pub fn resize_to(&mut self, new_w: u32, new_h: u32, mode: RasterResizeMode) {
if new_w == 0 || new_h == 0 || (self.width == new_w && self.height == new_h) {
return;
}
// Resample/recanvas the buffer only when pixels are resident. A blank keyframe (no content
// and no store row) just takes the new declared size — there's nothing to corrupt. Paged-out
// keyframes are loaded by the caller (ResizeRasterLayerAction) before this runs.
if !self.raw_pixels.is_empty() {
let old = std::mem::take(&mut self.raw_pixels);
self.raw_pixels = match mode {
RasterResizeMode::Scale => match image::RgbaImage::from_raw(self.width, self.height, old) {
Some(img) => image::imageops::resize(&img, new_w, new_h, image::imageops::FilterType::Lanczos3).into_raw(),
None => vec![0u8; (new_w as usize) * (new_h as usize) * 4],
},
RasterResizeMode::Canvas => {
// Copy the old pixels into a transparent new buffer, anchored top-left (matching
// the raster's (0,0) document anchor); right/bottom is padded or trimmed.
let mut buf = vec![0u8; (new_w as usize) * (new_h as usize) * 4];
let copy_w = self.width.min(new_w) as usize;
let copy_h = self.height.min(new_h) as usize;
let (ow, nw) = (self.width as usize, new_w as usize);
for y in 0..copy_h {
let src = y * ow * 4;
let dst = y * nw * 4;
buf[dst..dst + copy_w * 4].copy_from_slice(&old[src..src + copy_w * 4]);
}
buf
}
};
self.proxy = None; // invalidate any downsampled proxy
}
self.width = new_w;
self.height = new_h;
self.texture_dirty = true;
self.dirty = true;
}
}
/// How a raster canvas resize treats existing pixels.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum RasterResizeMode {
/// Resample the content to fill the new size (changes pixel resolution).
Scale,
/// Keep content at native resolution, anchored top-left; pad/trim the canvas.
Canvas,
}
/// A pixel-buffer painting layer

View File

@ -44,6 +44,8 @@ pub struct InfopanelPane {
selected_tool_gradient_stop: Option<usize>,
/// FPS value captured when a drag/focus-in starts (for single-undo-action on commit)
fps_drag_start: Option<f64>,
/// Resize mode for the active raster layer's "to document size" action (scale vs canvas).
raster_resize_mode: lightningbeam_core::raster_layer::RasterResizeMode,
}
impl InfopanelPane {
@ -61,6 +63,7 @@ impl InfopanelPane {
selected_shape_gradient_stop: None,
selected_tool_gradient_stop: None,
fps_drag_start: None,
raster_resize_mode: lightningbeam_core::raster_layer::RasterResizeMode::Scale,
}
}
}
@ -1197,6 +1200,53 @@ impl InfopanelPane {
});
}
/// Render a raster-layer section: shows the active keyframe's canvas dimensions and, when they
/// differ from the document, a Scale/Expand-Crop mode toggle + a "Layer to document size" button.
/// Driven by the *active* layer (not selection focus), since painting doesn't focus the layer.
fn render_raster_layer_section(&mut self, ui: &mut Ui, path: &NodePath, shared: &mut SharedPaneState, layer_id: Uuid) {
use lightningbeam_core::raster_layer::RasterResizeMode;
// Pull the values, then drop the document borrow before mutating `shared`.
let time = *shared.playback_time;
let dims = {
let document = shared.action_executor.document();
match document.get_layer(&layer_id) {
Some(AnyLayer::Raster(rl)) => rl
.keyframe_at(time)
.map(|kf| (kf.width, kf.height, document.width as u32, document.height as u32)),
_ => None,
}
};
let Some((kf_w, kf_h, doc_w, doc_h)) = dims else { return };
egui::CollapsingHeader::new("Raster Layer")
.id_salt(("raster_layer", path))
.default_open(true)
.show(ui, |ui| {
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.label("Size:");
ui.label(format!("{} × {}", kf_w, kf_h));
});
if kf_w != doc_w || kf_h != doc_h {
ui.horizontal(|ui| {
ui.label("Mode:");
ui.selectable_value(&mut self.raster_resize_mode, RasterResizeMode::Scale, "Scale");
ui.selectable_value(&mut self.raster_resize_mode, RasterResizeMode::Canvas, "Expand/Crop");
});
if ui.button(format!("Layer to document size ({} × {})", doc_w, doc_h)).clicked() {
let store = lightningbeam_core::raster_store::RasterStore::new(shared.container_path.clone());
let action = lightningbeam_core::actions::ResizeRasterLayerAction::new(
layer_id, doc_w, doc_h, self.raster_resize_mode, store,
);
shared.pending_actions.push(Box::new(action));
}
}
ui.add_space(4.0);
});
}
/// Render clip instance info section
fn render_clip_instance_section(&self, ui: &mut Ui, path: &NodePath, shared: &SharedPaneState, clip_ids: &[Uuid]) {
let document = shared.action_executor.document();
@ -1547,6 +1597,12 @@ impl PaneRenderer for InfopanelPane {
}
}
// Active raster layer's size + "to document size" — shown whenever a raster layer is
// active (independent of selection focus, since painting doesn't focus the layer).
if let Some(active_id) = *shared.active_layer_id {
self.render_raster_layer_section(ui, path, shared, active_id);
}
// Onion-skinning view settings — always available, regardless of selection.
ui.add_space(8.0);
ui.separator();