mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-27 17:51:59 +01:00
* security(engine): bound script, batch, adjustment, import and export work from file input Scripts calling scripts share depth and step budgets; batch inside a played action charges the playback tree; destructive adjustments and mask adjustments visit stored tiles when the content bounds are a huge sparse grid; Copy Layers checks group depth; aux flattening and brush import refuse oversized canvases; video footage frames are budgeted, Render Video names frames after the leaf of the document name and reads no linked files for restricted sessions; ATN names are clipped; profile paths are read as regular files only; prefs.set no longer treats colorSettingsX as colorSettings. * style(engine): rustfmt * engine: address review - refuse unbounded sparse adjustments, carry batch playback charges, charge played steps to scripts, clip ATN targets, stricter colorSettings paths, cap image-sequence frames
513 lines
24 KiB
Rust
513 lines
24 KiB
Rust
//! Destructive pixel operations on layers (Image → Adjustments, flips, fills).
|
|
|
|
use photocraft_compose::{Buffer, adjust};
|
|
use photocraft_doc::{Adjustment, Document, Layer, LayerContent};
|
|
use photocraft_geom::{Rect, TILE_SIZE};
|
|
use photocraft_raster::{Surface, to_rgba};
|
|
|
|
/// Composite `layers` inside their visible composite bounds (effects included) and write the result directly into a
|
|
/// tiled surface. Large merges used to hold both a full-canvas RGBA buffer and a second converted
|
|
/// float buffer at once; banded rendering keeps only one compositor band plus the destination.
|
|
pub fn composite_layers(solo: &Document, format: photocraft_color::PixelFormat, background: Option<[f32; 3]>) -> Surface {
|
|
let canvas = solo.bounds();
|
|
let area = solo
|
|
.layers
|
|
.iter()
|
|
.filter(|layer| layer.visible)
|
|
// Composite bounds include layer effects (a drop shadow reaches past the pixels); `None`
|
|
// means the layer may draw anywhere.
|
|
.map(|layer| photocraft_compose::composite_bounds(layer, canvas).unwrap_or(canvas))
|
|
.filter(|bounds| !bounds.is_empty())
|
|
.reduce(|a, b| a.union(&b))
|
|
.unwrap_or(Rect::EMPTY)
|
|
.intersect(&canvas);
|
|
if area.is_empty() {
|
|
return Surface::new(format);
|
|
}
|
|
// Also keeps a plain CMYK stack in its ink values (#3778).
|
|
photocraft_compose::composite_to_surface(solo, area, format, background)
|
|
}
|
|
|
|
/// [`composite_layers`] without cropping to the canvas: pixels of the layers that lie outside it are kept
|
|
/// (merging layers must not lose them, #5067).
|
|
pub fn composite_layers_uncropped(solo: &Document, format: photocraft_color::PixelFormat) -> Surface {
|
|
let canvas = solo.bounds();
|
|
// The canvas grown to every visible layer's content and effects, so no layer is clipped by `composite_bounds`.
|
|
let reach = solo
|
|
.layers
|
|
.iter()
|
|
.filter(|layer| layer.visible)
|
|
.map(|layer| {
|
|
let margin = if photocraft_compose::effects::has_effects(layer) { photocraft_compose::effects::margin(layer) } else { 0 };
|
|
let bounds = photocraft_compose::layer_bounds(layer, canvas);
|
|
if bounds.is_empty() { bounds } else { bounds.inflate(margin) }
|
|
})
|
|
.filter(|bounds| !bounds.is_empty())
|
|
.fold(canvas, |a, b| a.union(&b));
|
|
let area = solo
|
|
.layers
|
|
.iter()
|
|
.filter(|layer| layer.visible)
|
|
.map(|layer| photocraft_compose::composite_bounds(layer, reach).unwrap_or(canvas))
|
|
.filter(|bounds| !bounds.is_empty())
|
|
.reduce(|a, b| a.union(&b))
|
|
.unwrap_or(Rect::EMPTY);
|
|
if area.is_empty() {
|
|
return Surface::new(format);
|
|
}
|
|
photocraft_compose::composite_to_surface(solo, area, format, None)
|
|
}
|
|
|
|
/// Most bytes of tiles an adjustment may materialize beyond the ones a surface stores: two distant
|
|
/// tiles make the content bounds a grid of millions of tiles that hold nothing. Written tiles are
|
|
/// copies, so the peak is about twice this.
|
|
const MAX_UNSTORED_BYTES: u64 = 1 << 30;
|
|
|
|
/// Tiles in the grid that covers `r` (one extra per axis for a tile straddling its edge).
|
|
fn grid_tiles(r: Rect) -> u64 {
|
|
(u64::from(r.width()).div_ceil(TILE_SIZE as u64) + 1) * (u64::from(r.height()).div_ceil(TILE_SIZE as u64) + 1)
|
|
}
|
|
|
|
/// Bytes the tiles of the grid over `r` take beyond the `stored` ones, in format `fmt`.
|
|
fn unstored_bytes(r: Rect, stored: u64, fmt: &photocraft_color::PixelFormat) -> u64 {
|
|
grid_tiles(r).saturating_sub(stored).saturating_mul((TILE_SIZE as u64).pow(2)).saturating_mul(fmt.bytes_per_pixel() as u64)
|
|
}
|
|
|
|
/// The tile-sized pieces of `r`, each cut to `r`.
|
|
fn tile_rects(r: Rect) -> impl Iterator<Item = Rect> {
|
|
let t = i64::from(TILE_SIZE);
|
|
let cols = i64::from(r.x0).div_euclid(t)..=(i64::from(r.x1) - 1).div_euclid(t);
|
|
let rows = i64::from(r.y0).div_euclid(t)..=(i64::from(r.y1) - 1).div_euclid(t);
|
|
rows.flat_map(move |cy| cols.clone().map(move |cx| Rect::new((cx * t) as i32, (cy * t) as i32, ((cx + 1) * t) as i32, ((cy + 1) * t) as i32).intersect(&r)))
|
|
}
|
|
|
|
const TOO_SPARSE: &str = "the adjustment would have to fill too large an area around the pixels the layer holds";
|
|
|
|
/// Apply an adjustment destructively to a surface, weighted by an optional selection.
|
|
/// Applies `adj` to a surface (any colour model and depth, via straight RGBA) through the
|
|
/// selection; `mode` is the document's, for the tone transfer (e.g. Exposure in Grayscale).
|
|
/// Works tile by tile in parallel, so a large layer needs one tile's buffers per thread rather
|
|
/// than four full-layer copies, and the result is the same as one pass over the whole region.
|
|
pub fn adjust_surface(s: &mut Surface, adj: &Adjustment, selection: Option<&Surface>, mode: photocraft_color::ColorMode) -> Result<(), String> {
|
|
use rayon::prelude::*;
|
|
let r = s.content_bounds();
|
|
if r.is_empty() {
|
|
return Ok(());
|
|
}
|
|
let fmt = s.format();
|
|
// Two distant tiles make the content bounds a grid of millions of tiles that hold nothing.
|
|
// Past a budget a transparent default needs only the stored tiles adjusted (the gaps stay
|
|
// transparent); an opaque default would change under the adjustment everywhere in that grid,
|
|
// so the adjustment is refused rather than left half applied.
|
|
let stored: Vec<_> = s.tiles().map(|(c, _)| *c).filter(|c| !c.rect().intersect(&r).is_empty()).collect();
|
|
let transparent = fmt.alpha && s.default_pixel().last().is_some_and(|a| *a <= 0.0);
|
|
let over = unstored_bytes(r, stored.len() as u64, &fmt) > MAX_UNSTORED_BYTES;
|
|
if over && !transparent {
|
|
return Err(TOO_SPARSE.into());
|
|
}
|
|
let taken = if over { stored.into_iter().flat_map(|c| s.take_tiles(c.rect())).collect() } else { s.take_tiles(r) };
|
|
let done: Vec<_> = taken
|
|
.into_par_iter()
|
|
.flat_map_iter(|(tc, tile)| {
|
|
// A one-tile surface, so `adjusted` reads and writes this tile only.
|
|
let mut one = Surface::new(fmt);
|
|
one.put_tiles([(tc, tile)]);
|
|
let tr = tc.rect().intersect(&r);
|
|
let out = adjusted(&one, tr, adj, selection, mode);
|
|
one.write_region(tr, &out);
|
|
one.take_tiles(tc.rect())
|
|
})
|
|
.collect();
|
|
s.put_tiles(done);
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "flatten_cmyk_tests.rs"]
|
|
mod flatten_cmyk_tests;
|
|
#[cfg(test)]
|
|
#[path = "pixels_tests.rs"]
|
|
mod tests;
|
|
|
|
/// Applies `adj` to a layer mask through the selection. Untouched mask pixels count (they read as
|
|
/// the mask's default value), and without a selection the default changes too, since a mask
|
|
/// reaches past the canvas: Invert turns a reveal-all mask into a hide-all one (#780).
|
|
pub fn adjust_mask(s: &mut Surface, adj: &Adjustment, selection: Option<&Surface>) -> Result<(), String> {
|
|
let mode = photocraft_color::ColorMode::Grayscale;
|
|
if let Some(sel) = selection {
|
|
let r = sel.content_bounds();
|
|
// Tile by tile, and only where the selection stores something (it is unselected, so
|
|
// unchanged, elsewhere): its content bounds can span a grid of mostly nothing.
|
|
let sparse = sel.default_pixel().iter().all(|v| *v == 0.0);
|
|
let tiles: Vec<Rect> = if sparse {
|
|
sel.tiles().map(|(c, _)| c.rect().intersect(&r)).filter(|t| !t.is_empty()).collect()
|
|
} else if r.is_empty() {
|
|
Vec::new()
|
|
} else {
|
|
// Unselected pixels count too, so every tile of the span is adjusted, one at a time
|
|
// (not one read of the whole span), and the span is bounded by what that writes.
|
|
if unstored_bytes(r, 0, &s.format()) > MAX_UNSTORED_BYTES {
|
|
return Err(TOO_SPARSE.into());
|
|
}
|
|
tile_rects(r).collect()
|
|
};
|
|
for t in &tiles {
|
|
let out = adjusted(s, *t, adj, Some(sel), mode);
|
|
s.write_region(*t, &out);
|
|
}
|
|
if !tiles.is_empty() {
|
|
s.prune();
|
|
}
|
|
return Ok(());
|
|
}
|
|
let fmt = s.format();
|
|
let unit = Rect::from_xywh(0, 0, 1, 1);
|
|
let mut out = Surface::with_default(fmt, &adjusted(&Surface::with_default(fmt, &s.default_pixel()), unit, adj, None, mode));
|
|
// Everything the mask doesn't store reads as its default, which `out` already holds adjusted.
|
|
// So only the stored tiles are adjusted, not the (possibly huge) rectangle around them.
|
|
let stored: Vec<Rect> = s.tiles().map(|(c, _)| c.rect()).collect();
|
|
for r in stored {
|
|
out.write_region(r, &adjusted(s, r, adj, None, mode));
|
|
}
|
|
out.prune();
|
|
*s = out;
|
|
Ok(())
|
|
}
|
|
|
|
/// `s`'s pixels over `r` (untouched ones included) with `adj` applied through the selection, encoded.
|
|
fn adjusted(s: &Surface, r: Rect, adj: &Adjustment, selection: Option<&Surface>, mode: photocraft_color::ColorMode) -> Vec<f32> {
|
|
let fmt = s.format();
|
|
if fmt.mode == mode && matches!(mode, photocraft_color::ColorMode::Lab | photocraft_color::ColorMode::Cmyk) && adjust::stored_channel_adjustment(adj) {
|
|
return adjusted_channels(s, r, selection, |ch| adjust::stored_channels(adj, fmt.mode, Some(fmt.sample), ch));
|
|
}
|
|
if fmt.mode == mode && mode == photocraft_color::ColorMode::Cmyk && adjust::cmy_adjustment(adj) {
|
|
return adjusted_cmy(s, r, adj, selection);
|
|
}
|
|
if let Adjustment::BrightnessContrast { brightness, contrast, legacy } = adj
|
|
&& fmt.mode == mode
|
|
&& mode == photocraft_color::ColorMode::Cmyk
|
|
{
|
|
let table = adjust::ink_brightness_contrast_lut(*brightness, *contrast, *legacy);
|
|
return adjusted_channels(s, r, selection, |ch| ch.iter_mut().for_each(|v| *v = adjust::ink_through(&table, *v)));
|
|
}
|
|
let n = fmt.channels();
|
|
let raw = s.read_region(r);
|
|
let mut buf = Buffer { rect: r, px: raw.chunks_exact(n).map(|p| to_rgba(&fmt, p)).collect() };
|
|
let orig = buf.clone();
|
|
// 32-bit documents get the float behaviour of an adjustment layer there (Levels doesn't clip);
|
|
// integer depths keep the unrounded, clipped curves, except that 16-bit Posterize and
|
|
// Threshold use the 16-bit rules an adjustment layer measures against Photoshop, so the
|
|
// command matches its live preview (an adjustment layer) and Photoshop.
|
|
let depth = match (fmt.sample, adj) {
|
|
(photocraft_color::SampleType::F32, _) => Some(fmt.sample),
|
|
(photocraft_color::SampleType::U16, Adjustment::Posterize { .. } | Adjustment::Threshold { .. }) => Some(fmt.sample),
|
|
_ => None,
|
|
};
|
|
adjust::apply_in(adj, &mut buf, adjust::Transfer::for_document(mode, fmt.sample), depth, mode);
|
|
mix_through_selection(&fmt, r, &orig.px, &buf.px, selection)
|
|
}
|
|
|
|
/// [`adjusted`] for an adjustment Photoshop applies to a Lab or CMYK layer's own channels: `op`
|
|
/// changes one pixel's colour channels, exact on the stored values with no trip through RGB, and
|
|
/// the selection mixes those values (alpha untouched).
|
|
fn adjusted_channels(s: &Surface, r: Rect, selection: Option<&Surface>, op: impl Fn(&mut [f32])) -> Vec<f32> {
|
|
let fmt = s.format();
|
|
let (n, colors) = (fmt.channels(), fmt.mode.color_channels());
|
|
let mut raw = s.read_region(r);
|
|
let w = (r.width() as usize).max(1);
|
|
let mut coverage = vec![1.0f32; w];
|
|
for (j, row) in raw.chunks_mut(w * n).enumerate() {
|
|
if let Some(sel) = selection {
|
|
sel.sample_row_strided(r.y0.saturating_add(j as i32), r.x0, 1, 0, &mut coverage);
|
|
}
|
|
for (px, &k) in row.chunks_exact_mut(n).zip(&coverage) {
|
|
if k <= 0.0 {
|
|
continue;
|
|
}
|
|
let Some(ch) = px.get_mut(..colors) else { continue };
|
|
let mut out = [0.0f32; 4];
|
|
let Some(out) = out.get_mut(..colors) else { continue };
|
|
out.copy_from_slice(ch);
|
|
op(out);
|
|
for (v, o) in ch.iter_mut().zip(out.iter()) {
|
|
*v += (o - *v) * k;
|
|
}
|
|
}
|
|
}
|
|
raw
|
|
}
|
|
|
|
/// [`adjusted`] for Hue/Saturation or Color Balance on a CMYK layer (`adjust::apply_cmy`): on its
|
|
/// stored C, M and Y, black kept, the whole region at once; the selection mixes the stored inks.
|
|
fn adjusted_cmy(s: &Surface, r: Rect, adj: &Adjustment, selection: Option<&Surface>) -> Vec<f32> {
|
|
let fmt = s.format();
|
|
let n = fmt.channels();
|
|
let mut raw = s.read_region(r);
|
|
let mut inks: Vec<[f32; 4]> = raw.chunks_exact(n).map(|p| std::array::from_fn(|c| p.get(c).copied().unwrap_or(0.0))).collect();
|
|
adjust::apply_cmy(adj, &mut inks);
|
|
let w = (r.width() as usize).max(1);
|
|
let mut coverage = vec![1.0f32; w];
|
|
for (j, (row, adjusted)) in raw.chunks_mut(w * n).zip(inks.chunks(w)).enumerate() {
|
|
if let Some(sel) = selection {
|
|
sel.sample_row_strided(r.y0.saturating_add(j as i32), r.x0, 1, 0, &mut coverage);
|
|
}
|
|
for ((px, ink), &k) in row.chunks_exact_mut(n).zip(adjusted).zip(&coverage) {
|
|
for (v, a) in px.iter_mut().zip(ink) {
|
|
*v += (a - *v) * k;
|
|
}
|
|
}
|
|
}
|
|
raw
|
|
}
|
|
|
|
/// `edited` mixed back over `orig` (straight RGBA over `r`, row-major) by the selection's
|
|
/// coverage, encoded in `fmt`: what a destructive edit writes back through a selection.
|
|
pub(crate) fn mix_through_selection(
|
|
fmt: &photocraft_color::PixelFormat,
|
|
r: Rect,
|
|
orig: &[[f32; 4]],
|
|
edited: &[[f32; 4]],
|
|
selection: Option<&Surface>,
|
|
) -> Vec<f32> {
|
|
let w = (r.width() as usize).max(1);
|
|
let mut out = Vec::with_capacity(orig.len() * fmt.channels());
|
|
// The selection's coverage (channel 0) one row at a time, each of its tiles looked up once
|
|
// per row rather than once per pixel.
|
|
let mut coverage = vec![1.0f32; w];
|
|
for (j, (edited_row, orig_row)) in edited.chunks(w).zip(orig.chunks(w)).enumerate() {
|
|
if let Some(sel) = selection {
|
|
sel.sample_row_strided(r.y0.saturating_add(j as i32), r.x0, 1, 0, &mut coverage);
|
|
}
|
|
for ((a, o), k) in edited_row.iter().zip(orig_row).zip(&coverage) {
|
|
let mixed: [f32; 4] = std::array::from_fn(|c| o[c] + (a[c] - o[c]) * k);
|
|
let mut enc = [0.0f32; 8];
|
|
let m = photocraft_raster::from_rgba_into(fmt, mixed, &mut enc);
|
|
out.extend_from_slice(&enc[..m]);
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Fill (respecting selection coverage) with a straight RGBA colour.
|
|
/// Works tile by tile in parallel on the encoded pixels; a fully selected tile of an opaque fill
|
|
/// that may set alpha becomes one shared solid tile.
|
|
pub(crate) fn try_fill_surface(s: &mut Surface, area: Rect, color: [f32; 4], selection: Option<&Surface>, lock_transparency: bool) -> crate::Result<()> {
|
|
let fmt = s.format();
|
|
// Coverage 1 of an opaque colour replaces the colour (and the alpha, unless it is kept).
|
|
let opaque = color[3] == 1.0 && (!lock_transparency || !fmt.alpha) && selection.is_none_or(|m| m.format().sample != photocraft_color::SampleType::F32);
|
|
let mut enc = [0.0f32; 8];
|
|
let enc_len = opaque.then(|| photocraft_raster::from_rgba_into(&fmt, [color[0], color[1], color[2], 1.0], &mut enc));
|
|
let solid = enc_len.map(|n| s.solid_tile(enc.get(..n).unwrap_or(&[])));
|
|
let whole = |full: bool| solid.as_ref().filter(|_| full).map(|t| TileEdit::Put(t.clone()));
|
|
edit_tiles(s, area, selection, "filling pixels", whole, |k, px| {
|
|
if k <= 0.0 {
|
|
return false;
|
|
}
|
|
if k >= 1.0
|
|
&& let Some(m) = enc_len
|
|
&& let Some(src) = enc.get(..px.len().min(m))
|
|
{
|
|
px.copy_from_slice(src);
|
|
return true;
|
|
}
|
|
fill_pixel(&fmt, px, color, k, lock_transparency);
|
|
true
|
|
})
|
|
}
|
|
|
|
/// One pixel of [`try_fill_surface`]: `color` over `px` (encoded) at coverage `k`.
|
|
fn fill_pixel(fmt: &photocraft_color::PixelFormat, px: &mut [f32], color: [f32; 4], k: f32, lock_transparency: bool) {
|
|
let d = to_rgba(fmt, px);
|
|
let sa = color[3] * k;
|
|
let oa = sa + d[3] * (1.0 - sa);
|
|
let mut o = [0.0f32; 4];
|
|
if oa > 0.0 {
|
|
for c in 0..3 {
|
|
o[c] = (color[c] * sa + d[c] * d[3] * (1.0 - sa)) / oa;
|
|
}
|
|
}
|
|
o[3] = if lock_transparency { d[3] } else { oa };
|
|
let mut enc = [0.0f32; 8];
|
|
let m = photocraft_raster::from_rgba_into(fmt, o, &mut enc);
|
|
let n = px.len().min(m);
|
|
if let (Some(dst), Some(src)) = (px.get_mut(..n), enc.get(..n)) {
|
|
dst.copy_from_slice(src);
|
|
}
|
|
}
|
|
|
|
/// Clear pixels (make transparent) within the selection.
|
|
/// Works tile by tile in parallel on the encoded pixels; a fully selected tile is dropped (it
|
|
/// would only hold empty pixels), and tiles the selection leaves alone stay shared with history.
|
|
pub(crate) fn try_clear_surface(s: &mut Surface, area: Rect, selection: Option<&Surface>) -> crate::Result<()> {
|
|
let fmt = s.format();
|
|
let n = fmt.channels();
|
|
if !fmt.alpha {
|
|
return Ok(());
|
|
}
|
|
// Cleared pixels become all zeros; when that is also the default pixel, a cleared tile is no tile.
|
|
let empty_default = s.default_bytes().iter().all(|&b| b == 0);
|
|
let whole = |full: bool| (full && empty_default).then_some(TileEdit::Drop);
|
|
edit_tiles(s, area, selection, "erasing pixels", whole, |k, px| {
|
|
let Some(a) = px.get_mut(n - 1) else { return false };
|
|
*a *= 1.0 - k;
|
|
// Fully cleared pixels become the empty pixel, so bounds and tile pruning see them as gone.
|
|
if *a <= 0.0 {
|
|
px.fill(0.0);
|
|
}
|
|
true
|
|
})
|
|
}
|
|
|
|
/// Take the coverage a floating selection lifts out of `s` over `area`: each pixel loses its
|
|
/// selection coverage k of alpha, down to none, so a selection made from the layer's own
|
|
/// transparency leaves nothing behind (#7655), where [`try_clear_surface`] would leave a ghost.
|
|
pub(crate) fn try_lift_coverage(s: &mut Surface, area: Rect, selection: &Surface) -> crate::Result<()> {
|
|
let fmt = s.format();
|
|
let n = fmt.channels();
|
|
if !fmt.alpha {
|
|
return Ok(());
|
|
}
|
|
let empty_default = s.default_bytes().iter().all(|&b| b == 0);
|
|
let whole = |full: bool| (full && empty_default).then_some(TileEdit::Drop);
|
|
edit_tiles(s, area, Some(selection), "lifting pixels", whole, |k, px| {
|
|
if k <= 0.0 {
|
|
return false;
|
|
}
|
|
let Some(a) = px.get_mut(n - 1) else { return false };
|
|
*a = (*a - k).max(0.0);
|
|
if *a <= 0.0 {
|
|
px.fill(0.0);
|
|
}
|
|
true
|
|
})
|
|
}
|
|
|
|
/// A whole-tile shortcut of [`edit_tiles`].
|
|
enum TileEdit {
|
|
/// Replace the tile with this (shared) one.
|
|
Put(std::sync::Arc<photocraft_raster::Tile>),
|
|
/// Remove the tile (it reads as the default pixel again).
|
|
Drop,
|
|
}
|
|
|
|
/// Edit `s` over `area` tile by tile in parallel. `whole` may handle a tile lying wholly inside
|
|
/// `area` and fully selected (`true`) in one go; otherwise `pixel` edits each pixel of the area
|
|
/// with its selection coverage (decoded channel values in and out; `false` = left untouched).
|
|
/// A tile is copied only when one of its pixels actually changes (tiles are shared with undo
|
|
/// states), and pixels are decoded and re-encoded exactly as a whole-region float round trip
|
|
/// would, so the result is the same as one pass over `area`.
|
|
fn edit_tiles(
|
|
s: &mut Surface,
|
|
area: Rect,
|
|
selection: Option<&Surface>,
|
|
purpose: &str,
|
|
whole: impl Fn(bool) -> Option<TileEdit> + Sync,
|
|
pixel: impl Fn(f32, &mut [f32]) -> bool + Sync,
|
|
) -> crate::Result<()> {
|
|
use photocraft_raster::Tile;
|
|
use rayon::prelude::*;
|
|
use std::sync::Arc;
|
|
let fmt = s.format();
|
|
let (n, bpp) = (fmt.channels(), fmt.bytes_per_pixel());
|
|
let oom = |e| crate::EngineError::Other(format!("not enough memory while {purpose} ({e}); the document was not changed"));
|
|
let mut coords = crate::allocation::capacity(area.tiles().count(), purpose)?;
|
|
coords.extend(area.tiles());
|
|
// Untouched pixels read as the default where the surface has no tile.
|
|
let blank = if coords.iter().any(|&tc| s.tile(tc).is_none()) { Some(s.try_default_tile().map_err(oom)?) } else { None };
|
|
let dp = s.default_bytes();
|
|
let surf = &*s;
|
|
let edits = coords
|
|
.into_par_iter()
|
|
.map(|tc| -> crate::Result<Option<(photocraft_geom::TileCoord, Option<Arc<Tile>>)>> {
|
|
let tr = tc.rect().intersect(&area);
|
|
let mask = selection.map(|m| crate::fill_cmds::Mask::of(m, tc));
|
|
let full = tr == tc.rect() && mask.as_ref().is_none_or(|m| m.full(tr));
|
|
let current = surf.tile(tc);
|
|
match whole(full) {
|
|
Some(TileEdit::Put(t)) => return Ok(Some((tc, Some(t)))),
|
|
Some(TileEdit::Drop) => return Ok(current.map(|_| (tc, None))),
|
|
None => {}
|
|
}
|
|
let Some(src) = current.map(|t| &**t).or(blank.as_ref()) else { return Ok(None) };
|
|
let mut out: Option<Tile> = None;
|
|
let (mut px, mut enc) = ([0.0f32; 8], [0u8; 32]);
|
|
let (Some(px), Some(enc)) = (px.get_mut(..n), enc.get_mut(..bpp)) else { return Ok(None) };
|
|
for y in tr.y0..tr.y1 {
|
|
let row = (y - tc.ty * TILE_SIZE) as usize * TILE_SIZE as usize;
|
|
for x in tr.x0..tr.x1 {
|
|
let i = (row + (x - tc.tx * TILE_SIZE) as usize) * bpp;
|
|
let Some(old) = src.bytes().get(i..i + bpp) else { continue };
|
|
for (c, v) in px.iter_mut().enumerate() {
|
|
*v = photocraft_color::read_sample(old, fmt.sample, c);
|
|
}
|
|
if !pixel(mask.as_ref().map_or(1.0, |m| m.at(x, y)), px) {
|
|
continue;
|
|
}
|
|
photocraft_raster::encode_pixel(&fmt, px, enc);
|
|
if enc == old {
|
|
continue;
|
|
}
|
|
if out.is_none() {
|
|
out = Some(src.try_clone().map_err(oom)?);
|
|
}
|
|
if let Some(dst) = out.as_mut().and_then(|t| t.bytes_mut().get_mut(i..i + bpp)) {
|
|
dst.copy_from_slice(enc);
|
|
}
|
|
}
|
|
}
|
|
Ok(match out {
|
|
Some(t) if bpp > 0 && t.bytes().chunks_exact(bpp).all(|p| p == dp) => current.map(|_| (tc, None)),
|
|
Some(t) => Some((tc, Some(Arc::new(t)))),
|
|
None => None,
|
|
})
|
|
})
|
|
.collect::<crate::Result<Vec<_>>>()?;
|
|
for (tc, tile) in edits.into_iter().flatten() {
|
|
match tile {
|
|
Some(t) => s.put_tiles([(tc, t)]),
|
|
None => drop(s.take_tiles(tc.rect())),
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Remap a surface through a pixel-coordinate mapping (used for flips/rotations).
|
|
pub fn remap_surface(s: &Surface, map: impl Fn(i32, i32) -> (i32, i32)) -> Surface {
|
|
let r = s.content_bounds();
|
|
let mut out = Surface::with_default(s.format(), &s.default_pixel());
|
|
let n = s.channels();
|
|
let raw = s.read_region(r);
|
|
let w = r.width() as usize;
|
|
for (i, px) in raw.chunks_exact(n).enumerate() {
|
|
let (x, y) = (r.x0 + (i % w) as i32, r.y0 + (i / w) as i32);
|
|
let (nx, ny) = map(x, y);
|
|
out.write_pixel(nx, ny, px);
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Merge `upper` onto `lower` producing a raster layer (Layer → Merge Down).
|
|
pub fn merge_down(doc: &Document, lower: &Layer, upper: &Layer) -> Layer {
|
|
let format = doc.pixel_format();
|
|
let stack = vec![lower.clone(), upper.clone()];
|
|
let mut tmp = Document::new("merge", doc.size, format.mode, format.sample);
|
|
// The pair is read (and a CMYK one separated again) through the document's own profile.
|
|
tmp.icc_profile = doc.icc_profile.clone();
|
|
tmp.layers = stack;
|
|
// A Background stays canvas-sized; any other layer keeps the pixels outside the canvas (#5067).
|
|
let s = if doc.is_background(lower.id) { composite_layers(&tmp, format, None) } else { composite_layers_uncropped(&tmp, format) };
|
|
let mut merged = Layer::new(lower.name.clone(), LayerContent::Raster(s));
|
|
merged.id = lower.id;
|
|
merged.blend = lower.blend;
|
|
merged.opacity = 1.0;
|
|
// Merge Down replaces the lower layer, so keep its editing restrictions.
|
|
// In particular, a merged Background must remain opaque and position-locked.
|
|
merged.locks = lower.locks;
|
|
// And its place: two layers clipped in one chain merge into a layer still clipped to the base,
|
|
// with the lower layer's colour label and link.
|
|
merged.clipped = lower.clipped && upper.clipped;
|
|
merged.label = lower.label;
|
|
merged.link_group = lower.link_group;
|
|
merged
|
|
}
|