Files
photocraft/crates/plugins/src/apply.rs
T
40f14d25e8 Plug-ins: keep the originating error across parallel bands (#1654)
When one band failed, its siblings stopped with a generic "another band failed"
error, which could win the collection order and hide the real cause. The abort
flag now carries the first error, so every band reports it; tests cover traps,
error codes and the instruction budget across parallel bands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:11:00 -04:00

236 lines
9.9 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Running a filter plug-in over a surface, band by band.
use photocraft_color::{ColorMode, PixelFormat, SampleType};
use photocraft_geom::{Rect, TILE_SIZE, TileCoord};
use photocraft_raster::Surface;
use serde_json::{Value, json};
use crate::manifest::Area;
use crate::runtime::{Deadline, Plugin};
use crate::{Error, Result};
/// The `format` argument of `pc_filter`: bits 0–7 the source bit depth (8, 16 or 32), bit 8 set
/// when the last channel is (straight) alpha, bits 16–23 the colour mode as numbered in the PSD
/// file format (1 Grayscale, 2 Indexed, 3 RGB, 4 CMYK, 7 Multichannel, 8 Duotone, 9 Lab;
/// 0 Bitmap).
pub fn format_code(f: PixelFormat) -> u32 {
let depth = match f.sample {
SampleType::U8 => 8,
SampleType::U16 => 16,
SampleType::F32 => 32,
};
let mode = match f.mode {
ColorMode::Bitmap => 0,
ColorMode::Grayscale => 1,
ColorMode::Indexed => 2,
ColorMode::Rgb => 3,
ColorMode::Cmyk => 4,
ColorMode::Multichannel => 7,
ColorMode::Duotone => 8,
ColorMode::Lab => 9,
};
depth | (u32::from(f.alpha) << 8) | (mode << 16)
}
fn mode_name(m: ColorMode) -> &'static str {
match m {
ColorMode::Bitmap => "bitmap",
ColorMode::Grayscale => "grayscale",
ColorMode::Indexed => "indexed",
ColorMode::Rgb => "rgb",
ColorMode::Cmyk => "cmyk",
ColorMode::Lab => "lab",
ColorMode::Multichannel => "multichannel",
ColorMode::Duotone => "duotone",
}
}
/// Reads `rect`, repeating the edge pixels of `extent` outside it (like built-in neighbourhood
/// filters at the canvas edge).
fn read_clamped(s: &Surface, rect: Rect, extent: Rect) -> Vec<f32> {
let inner = rect.intersect(&extent);
if inner.is_empty() || inner == rect {
return s.read_region(rect);
}
let src = s.read_region(inner);
let n = s.channels();
let (w, iw) = (rect.width() as usize, inner.width() as usize);
let mut out = vec![0.0f32; w * rect.height() as usize * n];
for (row, y) in (rect.y0..rect.y1).enumerate() {
let sy = (y.clamp(inner.y0, inner.y1 - 1) - inner.y0) as usize;
for (col, x) in (rect.x0..rect.x1).enumerate() {
let sx = (x.clamp(inner.x0, inner.x1 - 1) - inner.x0) as usize;
let s = (sy * iw + sx) * n;
let d = (row * w + col) * n;
if let (Some(dst), Some(srcpx)) = (out.get_mut(d..d + n), src.get(s..s + n)) {
dst.copy_from_slice(srcpx);
}
}
}
out
}
impl Plugin {
/// The rectangle a run writes: the layer's content (grown by the plug-in's overlap) or the
/// canvas, limited to the selection's bounds.
pub fn output_area(&self, surface: &Surface, canvas: Rect, selection: Option<&Surface>) -> Rect {
let content = surface.content_bounds();
let extent = canvas.union(&content);
let mut area = match self.manifest().area {
Area::Content if content.is_empty() => Rect::EMPTY,
Area::Content => content.inflate(self.manifest().overlap as i32).intersect(&extent),
Area::Canvas => canvas,
};
if let Some(sel) = selection {
area = area.intersect(&sel.content_bounds());
}
area
}
/// Runs the filter on `surface` with user `params` (validated against the manifest) and
/// returns the new surface. `canvas` is the document bounds; `selection` (coverage in
/// channel 0) limits and feathers the result, as for built-in filters.
pub fn apply(&self, surface: &Surface, canvas: Rect, selection: Option<&Surface>, params: &Value) -> Result<Surface> {
let resolved = self.manifest().resolve_params(params)?;
let area = self.output_area(surface, canvas, selection);
let mut out = surface.clone();
if area.is_empty() {
return Ok(out);
}
let fmt = surface.format();
let n = fmt.channels();
let ov = self.manifest().overlap as i32;
let extent = canvas.union(&surface.content_bounds());
// Bands are tile-aligned blocks, one tile row tall and as many tiles wide as fit in
// `band_bytes`, so each worker can encode its own tiles and the merge is a tile copy.
let tile_bytes = (TILE_SIZE as usize).pow(2) * n * 4;
// Half the plug-in's memory cap is for the band (the module needs room of its own).
let room = self.limits().max_memory_bytes / 2;
let across = (self.limits().band_bytes.min(room) / tile_bytes).clamp(1, 1024) as i32;
let in_bytes = ((across * TILE_SIZE + 2 * ov) as usize) * ((TILE_SIZE + 2 * ov) as usize) * n * 4;
if in_bytes > room {
return Err(Error::Limit("memory budget (a band does not fit in the plug-in's memory)".into()));
}
let mut bands = Vec::new();
let (t0, t1) = (TileCoord::containing(area.x0, area.y0), TileCoord::containing(area.x1 - 1, area.y1 - 1));
for ty in t0.ty..=t1.ty {
let mut tx = t0.tx;
while tx <= t1.tx {
let r = Rect::new(tx * TILE_SIZE, ty * TILE_SIZE, (tx + across).min(t1.tx + 1) * TILE_SIZE, (ty + 1) * TILE_SIZE);
bands.push(r.intersect(&area));
tx += across;
}
}
let deadline = Deadline::new(self.limits());
let ctx = BandCtx { surface, extent, canvas, selection, fmt, ov, params: Value::Object(resolved) };
let run = |b: &Rect| {
let r = self.run_band(&ctx, *b, &deadline).map(|data| encode_band(surface, *b, &data));
if let Err(error) = &r {
deadline.abort(error.clone());
}
r
};
// A few bands at a time (one per worker), so peak memory stays near one band per thread.
#[cfg(not(target_arch = "wasm32"))]
let group = rayon::current_num_threads().max(1);
#[cfg(target_arch = "wasm32")]
let group = 1;
for chunk in bands.chunks(group) {
#[cfg(not(target_arch = "wasm32"))]
let results: Vec<Result<Surface>> = {
use rayon::prelude::*;
chunk.par_iter().map(run).collect()
};
#[cfg(target_arch = "wasm32")]
let results: Vec<Result<Surface>> = chunk.iter().map(run).collect();
for r in results {
for (c, t) in r?.tiles() {
out.tile_mut(*c).bytes_mut().copy_from_slice(t.bytes());
}
}
}
out.prune();
Ok(out)
}
fn run_band(&self, c: &BandCtx, band: Rect, deadline: &Deadline) -> Result<Vec<f32>> {
let n = c.fmt.channels();
let input = band.inflate(c.ov);
let mut data = read_clamped(c.surface, input, c.extent);
let (iw, ih) = (input.width(), input.height());
let mut params = c.params.clone();
if let Value::Object(m) = &mut params {
m.insert(
"_image".into(),
json!({
"x": input.x0, "y": input.y0, "width": iw, "height": ih, "overlap": c.ov,
"canvasWidth": c.canvas.width(), "canvasHeight": c.canvas.height(),
"mode": mode_name(c.fmt.mode), "depth": format_code(c.fmt) & 0xff, "alpha": c.fmt.alpha, "channels": n,
}),
);
}
let pbytes = serde_json::to_vec(&params).map_err(|e| Error::Params(e.to_string()))?;
let orig_full = data.clone();
let run =
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.filter_buffer(&mut data, iw, ih, n as u32, format_code(c.fmt), &pbytes, deadline)));
run.map_err(|_| Error::Trap("the runtime panicked".into()))??;
// Keep the band (drop the overlap margins), sanitise and blend by the selection.
let (bw, bh) = (band.width() as usize, band.height() as usize);
let ov = c.ov as usize;
let mut out = vec![0.0f32; bw * bh * n];
let sel_row = c.selection.map(|s| s.format().channels());
let mut sel = Vec::new();
let int_depth = c.fmt.sample != SampleType::F32;
for row in 0..bh {
if let (Some(s), Some(sn)) = (c.selection, sel_row) {
let r = Rect::new(band.x0, band.y0 + row as i32, band.x1, band.y0 + row as i32 + 1);
sel = s.read_region(r).chunks_exact(sn.max(1)).map(|p| p.first().copied().unwrap_or(0.0).clamp(0.0, 1.0)).collect();
}
for col in 0..bw {
let si = ((row + ov) * (bw + 2 * ov) + col + ov) * n;
let di = (row * bw + col) * n;
let k = if c.selection.is_some() { sel.get(col).copied().unwrap_or(0.0) } else { 1.0 };
for ch in 0..n {
let o = orig_full.get(si + ch).copied().unwrap_or(0.0);
let mut v = data.get(si + ch).copied().unwrap_or(o);
if !v.is_finite() {
v = o;
}
if int_depth || (c.fmt.alpha && ch == n - 1) {
v = v.clamp(0.0, 1.0);
}
if let Some(d) = out.get_mut(di + ch) {
*d = o + (v - o) * k;
}
}
}
}
Ok(out)
}
}
/// The tiles under `band` with the band's new pixels written in (the rest of each tile is the
/// original), encoded in the surface's format: built on the worker thread.
fn encode_band(surface: &Surface, band: Rect, data: &[f32]) -> Surface {
let mut s = Surface::with_default(surface.format(), &surface.default_pixel());
for c in band.tiles() {
if let Some(t) = surface.tile(c) {
s.tile_mut(c).bytes_mut().copy_from_slice(t.bytes());
} else {
s.tile_mut(c);
}
}
s.write_region(band, data);
s
}
struct BandCtx<'a> {
surface: &'a Surface,
extent: Rect,
canvas: Rect,
selection: Option<&'a Surface>,
fmt: PixelFormat,
ov: i32,
params: Value,
}