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:
parent
69939c066d
commit
5869e3ced1
|
|
@ -40,6 +40,7 @@ pub mod raster_diff;
|
||||||
pub mod raster_stroke;
|
pub mod raster_stroke;
|
||||||
pub mod raster_fill;
|
pub mod raster_fill;
|
||||||
pub mod add_raster_keyframe;
|
pub mod add_raster_keyframe;
|
||||||
|
pub mod resize_raster_layer;
|
||||||
pub mod move_layer;
|
pub mod move_layer;
|
||||||
pub mod set_fill_paint;
|
pub mod set_fill_paint;
|
||||||
pub mod set_image_fill;
|
pub mod set_image_fill;
|
||||||
|
|
@ -77,6 +78,7 @@ pub use group_layers::GroupLayersAction;
|
||||||
pub use raster_stroke::RasterStrokeAction;
|
pub use raster_stroke::RasterStrokeAction;
|
||||||
pub use raster_fill::RasterFillAction;
|
pub use raster_fill::RasterFillAction;
|
||||||
pub use add_raster_keyframe::AddRasterKeyframeAction;
|
pub use add_raster_keyframe::AddRasterKeyframeAction;
|
||||||
|
pub use resize_raster_layer::ResizeRasterLayerAction;
|
||||||
pub use move_layer::MoveLayerAction;
|
pub use move_layer::MoveLayerAction;
|
||||||
pub use set_fill_paint::SetFillPaintAction;
|
pub use set_fill_paint::SetFillPaintAction;
|
||||||
pub use set_image_fill::SetImageFillAction;
|
pub use set_image_fill::SetImageFillAction;
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -169,6 +169,57 @@ impl RasterKeyframe {
|
||||||
proxy: None,
|
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
|
/// A pixel-buffer painting layer
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,8 @@ pub struct InfopanelPane {
|
||||||
selected_tool_gradient_stop: Option<usize>,
|
selected_tool_gradient_stop: Option<usize>,
|
||||||
/// FPS value captured when a drag/focus-in starts (for single-undo-action on commit)
|
/// FPS value captured when a drag/focus-in starts (for single-undo-action on commit)
|
||||||
fps_drag_start: Option<f64>,
|
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 {
|
impl InfopanelPane {
|
||||||
|
|
@ -61,6 +63,7 @@ impl InfopanelPane {
|
||||||
selected_shape_gradient_stop: None,
|
selected_shape_gradient_stop: None,
|
||||||
selected_tool_gradient_stop: None,
|
selected_tool_gradient_stop: None,
|
||||||
fps_drag_start: 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
|
/// Render clip instance info section
|
||||||
fn render_clip_instance_section(&self, ui: &mut Ui, path: &NodePath, shared: &SharedPaneState, clip_ids: &[Uuid]) {
|
fn render_clip_instance_section(&self, ui: &mut Ui, path: &NodePath, shared: &SharedPaneState, clip_ids: &[Uuid]) {
|
||||||
let document = shared.action_executor.document();
|
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.
|
// Onion-skinning view settings — always available, regardless of selection.
|
||||||
ui.add_space(8.0);
|
ui.add_space(8.0);
|
||||||
ui.separator();
|
ui.separator();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue