mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-10 20:32:31 +00:00
Merge current main into Batch branch
This commit is contained in:
commit
180cbe651a
94 files changed
+1575
-239
No files matched your search
@@ -1,6 +1,8 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
fn main() {
|
||||
// May re-execute this process with glibc tuned to return freed document memory (#8743).
|
||||
photocraft_engine::malloc_tuning::apply();
|
||||
// `args_os`, not `args`: a non-Unicode argument (a Latin-1 file name on Linux) must reach clap,
|
||||
// not panic before anything is printed (issue #1108).
|
||||
let code = photocraft_cli::run(std::env::args_os().skip(1), &mut std::io::stdout(), &mut std::io::stderr());
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
//! The crate's integration tests, linked into one test binary.
|
||||
|
||||
mod cli;
|
||||
#[cfg(all(target_os = "linux", target_env = "gnu"))]
|
||||
mod malloc_tuning;
|
||||
mod man_and_completions;
|
||||
mod orientation;
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
//! The binary re-executes itself with glibc's trim threshold set (#8743), keeping its arguments
|
||||
//! and a value the user set.
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use photocraft_engine::malloc_tuning::{TRIM_THRESHOLD, TRIM_THRESHOLD_VAR};
|
||||
|
||||
/// The environment of a running `serve` once it answered a request, as `NAME=value` entries.
|
||||
fn serve_environ(set: Option<&str>) -> Vec<String> {
|
||||
let mut cmd = Command::new(crate::binary());
|
||||
for var in [TRIM_THRESHOLD_VAR, "MALLOC_MMAP_THRESHOLD_", "MALLOC_TOP_PAD_", "MALLOC_MMAP_MAX_", "MALLOC_ARENA_MAX", "GLIBC_TUNABLES"] {
|
||||
cmd.env_remove(var);
|
||||
}
|
||||
if let Some(v) = set {
|
||||
cmd.env(TRIM_THRESHOLD_VAR, v);
|
||||
}
|
||||
let mut child = cmd.arg("serve").stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().unwrap();
|
||||
let mut stdin = child.stdin.take().unwrap();
|
||||
writeln!(stdin, r#"{{"id":1,"method":"session.list"}}"#).unwrap();
|
||||
let mut reply = String::new();
|
||||
BufReader::new(child.stdout.take().unwrap()).read_line(&mut reply).unwrap();
|
||||
assert!(reply.contains(r#""id":1"#), "serve answered after the re-exec: {reply}");
|
||||
// The same process: `exec` keeps the PID, so this is the re-executed program's environment.
|
||||
let environ = std::fs::read(format!("/proc/{}/environ", child.id())).unwrap();
|
||||
drop(stdin);
|
||||
child.wait().unwrap();
|
||||
environ.split(|&b| b == 0).map(|e| String::from_utf8_lossy(e).into_owned()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sets_the_trim_threshold() {
|
||||
let env = serve_environ(None);
|
||||
assert!(env.contains(&format!("{TRIM_THRESHOLD_VAR}={TRIM_THRESHOLD}")), "{env:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_a_value_the_user_set() {
|
||||
let env = serve_environ(Some("999999"));
|
||||
assert!(env.contains(&format!("{TRIM_THRESHOLD_VAR}=999999")), "{env:?}");
|
||||
}
|
||||
@@ -340,7 +340,10 @@ fn write_start_marker(sentinel: &gpu_startup::SharedSentinel, plan: &gpu_startup
|
||||
}
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
// First, so the panic hook and every start-up warning are recorded (`logging`).
|
||||
// Before anything starts a thread: may re-execute this process with glibc tuned to return
|
||||
// the memory of closed documents (#8743).
|
||||
photocraft_engine::malloc_tuning::apply();
|
||||
// Then, so the panic hook and every start-up warning are recorded (`logging`).
|
||||
let logger = logging::install();
|
||||
crash_guard::install_hook();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
+32
-15
@@ -1,7 +1,7 @@
|
||||
//! Blurs: Gaussian, box, motion, radial, surface.
|
||||
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_raster::Interrupt;
|
||||
use photocraft_raster::{Interrupt, scratch};
|
||||
|
||||
use crate::image::{Edge, Image, premultiply, unpremultiply};
|
||||
use crate::photo_util::{par_map, par_rows};
|
||||
@@ -78,7 +78,7 @@ pub(crate) fn conv_sep(src: &Image, out: Rect, kx: &[f32], ky: &[f32], alpha: bo
|
||||
// Premultiplied copy of the needed source window.
|
||||
let win = Rect::new(out.x0 - rx, out.y0 - ry, out.x1 + rx, out.y1 + ry);
|
||||
let ww = win.width() as usize;
|
||||
let mut p = vec![0.0f32; ww * win.height() as usize * n];
|
||||
let mut p = scratch::Buf::new(ww * win.height() as usize * n, 0.0f32);
|
||||
for y in win.y0..win.y1 {
|
||||
for x in win.x0..win.x1 {
|
||||
let o = ((y - win.y0) as usize * ww + (x - win.x0) as usize) * n;
|
||||
@@ -89,7 +89,7 @@ pub(crate) fn conv_sep(src: &Image, out: Rect, kx: &[f32], ky: &[f32], alpha: bo
|
||||
}
|
||||
premultiply(&mut p, n, alpha);
|
||||
// Horizontal pass: (ow × th).
|
||||
let mut tmp = vec![0.0f32; ow * th * n];
|
||||
let mut tmp = scratch::Buf::new(ow * th * n, 0.0f32);
|
||||
for ty in 0..th {
|
||||
let row = &p[ty * ww * n..(ty + 1) * ww * n];
|
||||
let dst = &mut tmp[ty * ow * n..(ty + 1) * ow * n];
|
||||
@@ -113,7 +113,7 @@ pub(crate) fn conv_sep(src: &Image, out: Rect, kx: &[f32], ky: &[f32], alpha: bo
|
||||
}
|
||||
}
|
||||
// Vertical pass.
|
||||
let mut res = vec![0.0f32; ow * oh * n];
|
||||
let mut res = scratch::take(ow * oh * n, 0.0f32);
|
||||
for oy in 0..oh {
|
||||
let d = &mut res[oy * ow * n..(oy + 1) * ow * n];
|
||||
for (i, kv) in ky.iter().enumerate() {
|
||||
@@ -182,11 +182,27 @@ pub(crate) fn box_widths(params: &FilterParams) -> Option<Vec<usize>> {
|
||||
}
|
||||
|
||||
/// Scratch buffers of one worker for [`box_blur`].
|
||||
#[derive(Default)]
|
||||
struct Scratch {
|
||||
line: Vec<f32>,
|
||||
next: Vec<f32>,
|
||||
acc: Vec<f32>,
|
||||
/// The source row being read (horizontal passes only).
|
||||
row: Vec<f32>,
|
||||
}
|
||||
|
||||
impl Scratch {
|
||||
/// Buffers with room for lines of `line` floats (and the shorter ones after the first pass).
|
||||
fn new(line: usize, row: usize) -> Self {
|
||||
Scratch { line: scratch::take_cap(line), next: scratch::take_cap(line), acc: Vec::new(), row: scratch::take_cap(row) }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Scratch {
|
||||
fn drop(&mut self) {
|
||||
scratch::give(std::mem::take(&mut self.line));
|
||||
scratch::give(std::mem::take(&mut self.next));
|
||||
scratch::give(std::mem::take(&mut self.row));
|
||||
}
|
||||
}
|
||||
|
||||
/// One running-sum box pass of radius `r` along a line of elements of `w` floats each (a pixel's
|
||||
@@ -252,7 +268,7 @@ fn box_pass_px<const N: usize>(src: &[f32], dst: &mut [f32], r: usize) {
|
||||
/// `dst` unfinished) when `ctl` is cancelled before a pass: a column strip's passes take tens of
|
||||
/// milliseconds on a tall image, too long to finish once cancelled (#6205).
|
||||
fn line_passes(dst: &mut [f32], w: usize, radii: &[usize], s: &mut Scratch, ctl: &Interrupt) {
|
||||
let Scratch { line, next, acc } = s;
|
||||
let Scratch { line, next, acc, .. } = s;
|
||||
let Some(len) = dst.len().checked_div(w) else { return };
|
||||
let mut tail: usize = radii.iter().sum();
|
||||
for (k, &r) in radii.iter().enumerate() {
|
||||
@@ -306,21 +322,21 @@ pub(crate) fn box_blur(
|
||||
// Horizontal passes over `used`'s rows only (the repeated rows above and below it would just
|
||||
// repeat their results), for the output columns. Each row is extended to the passes' reach by
|
||||
// repeating its end pixels.
|
||||
let mut h = vec![0.0f32; used.height() as usize * ow * n];
|
||||
let mut h = scratch::Buf::new(used.height() as usize * ow * n, 0.0f32);
|
||||
let (left, right) = ((used.x0 - out.x0.saturating_sub(reach)) as usize, (out.x1.saturating_add(reach) - used.x1) as usize);
|
||||
par_rows(&mut h, ow * ROWS_PER_TASK, n, |task, chunk| {
|
||||
let (mut row, mut s) = (Vec::new(), Scratch::default());
|
||||
let mut s = Scratch::new((used.width() as usize + left + right) * n, used.width() as usize * n);
|
||||
for (k, dst) in chunk.chunks_exact_mut(ow * n).enumerate() {
|
||||
if ctl.cancelled() {
|
||||
return;
|
||||
}
|
||||
let y = used.y0 + (task * ROWS_PER_TASK + k) as i32;
|
||||
read(Rect::new(used.x0, y, used.x1, y + 1), &mut row);
|
||||
premultiply(&mut row, n, alpha);
|
||||
let (Some(first), Some(last)) = (row.get(..n), row.get(row.len().saturating_sub(n)..)) else { return };
|
||||
read(Rect::new(used.x0, y, used.x1, y + 1), &mut s.row);
|
||||
premultiply(&mut s.row, n, alpha);
|
||||
let (Some(first), Some(last)) = (s.row.get(..n), s.row.get(s.row.len().saturating_sub(n)..)) else { return };
|
||||
s.line.clear();
|
||||
(0..left).for_each(|_| s.line.extend_from_slice(first));
|
||||
s.line.extend_from_slice(&row);
|
||||
s.line.extend_from_slice(&s.row);
|
||||
(0..right).for_each(|_| s.line.extend_from_slice(last));
|
||||
line_passes(dst, n, &radii, &mut s, ctl);
|
||||
}
|
||||
@@ -336,11 +352,11 @@ pub(crate) fn box_blur(
|
||||
}
|
||||
let (sx, w) = ((t.x0 - out.x0) as usize * n, t.width() as usize * n);
|
||||
let row = |y: usize| &h[y * stride + sx..y * stride + sx + w];
|
||||
let mut s = Scratch::default();
|
||||
let mut s = Scratch::new((rows + top + bottom) * w, 0);
|
||||
(0..top).for_each(|_| s.line.extend_from_slice(row(0)));
|
||||
(0..rows).for_each(|y| s.line.extend_from_slice(row(y)));
|
||||
(0..bottom).for_each(|_| s.line.extend_from_slice(row(rows - 1)));
|
||||
let mut res = vec![0.0f32; oh * w];
|
||||
let mut res = scratch::take(oh * w, 0.0f32);
|
||||
line_passes(&mut res, w, &radii, &mut s, ctl);
|
||||
if ctl.cancelled() {
|
||||
return (t, Vec::new());
|
||||
@@ -367,7 +383,7 @@ fn box_passes(src: &Image, out: Rect, alpha: bool, boxes: &[usize]) -> Vec<f32>
|
||||
buf.extend_from_slice(src.row(r));
|
||||
};
|
||||
let ow = out.width() as usize * n;
|
||||
let mut res = vec![0.0f32; ow * out.height() as usize];
|
||||
let mut res = scratch::take(ow * out.height() as usize, 0.0f32);
|
||||
if res.is_empty() {
|
||||
return res;
|
||||
}
|
||||
@@ -376,6 +392,7 @@ fn box_passes(src: &Image, out: Rect, alpha: bool, boxes: &[usize]) -> Vec<f32>
|
||||
for (dst, s) in res.chunks_exact_mut(ow).zip(data.chunks_exact(w)) {
|
||||
dst[x0..x0 + w].copy_from_slice(s);
|
||||
}
|
||||
scratch::give(data);
|
||||
}
|
||||
res
|
||||
}
|
||||
|
||||
@@ -204,7 +204,7 @@ fn direct_boxes(img: &Image, out: Rect, boxes: &[usize], alpha: bool) -> Vec<f32
|
||||
let rect = out.inflate(left);
|
||||
cur = Image { rect, ch: img.ch, data: conv_sep(&cur, rect, &k, &k, alpha) };
|
||||
}
|
||||
cur.data
|
||||
cur.into_data()
|
||||
}
|
||||
|
||||
/// Noisy pixels over `r` (alpha varying, some fully transparent).
|
||||
@@ -481,7 +481,8 @@ fn line_passes_stop_between_passes_when_cancelled() {
|
||||
let reach: usize = radii.iter().sum();
|
||||
let line: Vec<f32> = (0..(len + 2 * reach) * w).map(|i| (i % 7) as f32 + 1.0).collect();
|
||||
let run = |ctl: &Interrupt| {
|
||||
let mut s = Scratch { line: line.clone(), ..Scratch::default() };
|
||||
let mut s = Scratch::new(0, 0);
|
||||
s.line.extend_from_slice(&line);
|
||||
let mut dst = vec![0.0f32; len * w];
|
||||
line_passes(&mut dst, w, &radii, &mut s, ctl);
|
||||
dst
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Interleaved `f32` image regions and sampling helpers.
|
||||
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_raster::Surface;
|
||||
use photocraft_raster::{Surface, scratch};
|
||||
|
||||
/// How samples outside an image are read.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -14,8 +14,9 @@ pub enum Edge {
|
||||
Wrap,
|
||||
}
|
||||
|
||||
/// A rectangle of interleaved, normalized samples.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
/// A rectangle of interleaved, normalized samples. The samples come from, and go back to, the
|
||||
/// scratch pool.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct Image {
|
||||
pub rect: Rect,
|
||||
/// Channels per pixel (alpha last when the surface has alpha).
|
||||
@@ -23,6 +24,20 @@ pub struct Image {
|
||||
pub data: Vec<f32>,
|
||||
}
|
||||
|
||||
impl Clone for Image {
|
||||
fn clone(&self) -> Self {
|
||||
let mut data = scratch::take(self.data.len(), 0.0f32);
|
||||
data.copy_from_slice(&self.data);
|
||||
Image { rect: self.rect, ch: self.ch, data }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Image {
|
||||
fn drop(&mut self) {
|
||||
scratch::give(std::mem::take(&mut self.data));
|
||||
}
|
||||
}
|
||||
|
||||
impl Image {
|
||||
/// Reads `rect` from a surface.
|
||||
pub fn read(s: &Surface, rect: Rect) -> Self {
|
||||
@@ -53,9 +68,15 @@ impl Image {
|
||||
img
|
||||
}
|
||||
|
||||
/// The samples, taken out of the image (it can't be destructured: it returns them to the
|
||||
/// pool on drop).
|
||||
pub fn into_data(mut self) -> Vec<f32> {
|
||||
std::mem::take(&mut self.data)
|
||||
}
|
||||
|
||||
/// New zeroed image.
|
||||
pub fn new(rect: Rect, ch: usize) -> Self {
|
||||
Image { rect, ch, data: vec![0.0; rect.width() as usize * rect.height() as usize * ch] }
|
||||
Image { rect, ch, data: scratch::take(rect.width() as usize * rect.height() as usize * ch, 0.0) }
|
||||
}
|
||||
|
||||
#[inline]
|
||||
|
||||
@@ -1269,7 +1269,10 @@ fn apply_tiled_with_impl(
|
||||
}
|
||||
for (t, data) in results {
|
||||
match data {
|
||||
TileData::Samples(data) => out.write_region(t, &data),
|
||||
TileData::Samples(data) => {
|
||||
out.write_region(t, &data);
|
||||
photocraft_raster::scratch::give(data);
|
||||
}
|
||||
TileData::Encoded(data) => out.write_interleaved(t, &data),
|
||||
}
|
||||
}
|
||||
@@ -1324,6 +1327,7 @@ fn apply_box_blur(
|
||||
mix_selection(data, *t, sel, &Image::read(surface, *t), fmt.alpha);
|
||||
}
|
||||
s.write_region(*t, data);
|
||||
photocraft_raster::scratch::give(std::mem::take(data));
|
||||
}
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
@@ -267,7 +267,12 @@ fn malformed_inputs_and_allocations_stop_gracefully() {
|
||||
assert!(crate::apply_in_with(&s, &p, rect, rect, None, rect, &Interrupt::NONE).is_none());
|
||||
assert_eq!(crate::apply_in(&s, &p, rect, rect, None, rect), s);
|
||||
}
|
||||
for bad in [Image { ch: 0, ..src.clone() }, Image { data: vec![0.0], ..src.clone() }, Image { rect: Rect::new(5, 0, 0, 2), ..src.clone() }] {
|
||||
let with = |edit: &dyn Fn(&mut Image)| {
|
||||
let mut bad = src.clone();
|
||||
edit(&mut bad);
|
||||
bad
|
||||
};
|
||||
for bad in [with(&|i| i.ch = 0), with(&|i| i.data = vec![0.0]), with(&|i| i.rect = Rect::new(5, 0, 0, 2))] {
|
||||
assert!(exact::run(&bad, rect, &ctx, 100.0, 1.0, 0.0, &Interrupt::NONE).is_none());
|
||||
}
|
||||
for rect in [Rect::new(i32::MIN, 0, i32::MAX, 2), Rect::new(0, 0, 300_000, 300_000), Rect::new(2, 0, 0, 2)] {
|
||||
|
||||
@@ -521,6 +521,7 @@ fn warp(
|
||||
outp[o + a] = al as f32;
|
||||
}
|
||||
}
|
||||
photocraft_raster::scratch::give(px);
|
||||
Ok(any.then_some((*t, outp)))
|
||||
};
|
||||
// Each tile is written as soon as it is warped, so only the tiles in flight hold their `f32`
|
||||
|
||||
@@ -358,6 +358,8 @@ impl Headless {
|
||||
if let Some(d) = self.session.close(i) {
|
||||
self.writers.remove(&d.doc.id.0);
|
||||
}
|
||||
// The closed document's buffers went to the scratch pool: hand them back to the OS.
|
||||
photocraft_raster::scratch::release();
|
||||
Ok(self.session_list())
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ use std::borrow::Cow;
|
||||
use photocraft_doc::{Knockout, Layer, LayerContent};
|
||||
use photocraft_geom::Rect;
|
||||
|
||||
use crate::extents::recycle_mask;
|
||||
use crate::{Buffer, Ctx, composite_atop, composite_layer, effects, mask_vals, render_content};
|
||||
|
||||
/// Where a layer stack sits, for knockouts: at the document's top level, and/or inside an
|
||||
@@ -157,6 +158,7 @@ pub(crate) fn knockout(layer: &Layer, backdrop: &mut Buffer, target: &Buffer, cl
|
||||
if target.rect != backdrop.rect || shape.len() != backdrop.px.len() {
|
||||
// Never reached (targets are built over the backdrop's rect); compositing without the
|
||||
// knockout beats a wrong pixel or a panic.
|
||||
recycle_mask(Some(shape));
|
||||
draw(backdrop);
|
||||
return;
|
||||
}
|
||||
@@ -172,7 +174,10 @@ pub(crate) fn knockout(layer: &Layer, backdrop: &mut Buffer, target: &Buffer, cl
|
||||
}
|
||||
let knocked = (layer.opacity < 1.0).then(|| backdrop.clone());
|
||||
draw(backdrop);
|
||||
let Some(knocked) = knocked else { return };
|
||||
let Some(knocked) = knocked else {
|
||||
recycle_mask(Some(shape));
|
||||
return;
|
||||
};
|
||||
let rest = 1.0 - layer.opacity.clamp(0.0, 1.0);
|
||||
for (((p, b), k), s) in backdrop.px.iter_mut().zip(&before.px).zip(&knocked.px).zip(&shape) {
|
||||
if *s <= 0.0 {
|
||||
@@ -186,6 +191,7 @@ pub(crate) fn knockout(layer: &Layer, backdrop: &mut Buffer, target: &Buffer, cl
|
||||
let a = pm[3].clamp(0.0, 1.0);
|
||||
*p = if a > 0.0 { [(pm[0] / a).clamp(0.0, 1.0), (pm[1] / a).clamp(0.0, 1.0), (pm[2] / a).clamp(0.0, 1.0), a] } else { [0.0; 4] };
|
||||
}
|
||||
recycle_mask(Some(shape));
|
||||
}
|
||||
|
||||
/// Composite a clipping group's clipped layers atop `base` (the base's isolated content, Blend
|
||||
@@ -220,7 +226,12 @@ pub(crate) fn composite_clipped_individually(layer: &Layer, clipped: &[Layer], b
|
||||
let op = layer.opacity.clamp(0.0, 1.0);
|
||||
let clip: Vec<f32> = match render_content(layer, rect, cx) {
|
||||
Some(b) => b.px.iter().map(|p| p[3] * op).collect(),
|
||||
None => mask_vals(layer, rect, cx).unwrap_or_else(|| vec![1.0; n]).iter().map(|m| m * op).collect(),
|
||||
None => {
|
||||
let mv = mask_vals(layer, rect, cx);
|
||||
let clip = mv.as_ref().map_or_else(|| vec![op; n], |m| m.iter().map(|m| m * op).collect());
|
||||
recycle_mask(mv);
|
||||
clip
|
||||
}
|
||||
};
|
||||
if clip.len() != n {
|
||||
return;
|
||||
|
||||
@@ -395,7 +395,7 @@ fn shapeless_stroke_tile_skips_match_full_region_across_blending_settings() {
|
||||
.unwrap();
|
||||
let mut band_pixels = Vec::with_capacity(whole.px.len());
|
||||
for band in bands {
|
||||
band_pixels.extend(band.px);
|
||||
band_pixels.extend(band.into_px());
|
||||
}
|
||||
assert_eq!(whole.px, band_pixels, "banded: {depth:?}, {position:?}, {blend:?}, fill={fill}, blend_if={blend_if}");
|
||||
}
|
||||
@@ -639,7 +639,7 @@ fn transparency_shapes_off_ignores_masks_and_reaches_groups() {
|
||||
l.mask = Some(LayerMask { surface: photocraft_raster::Surface::with_default(PixelFormat::GRAY8, &[77.0 / 255.0]), ..LayerMask::reveal_all() });
|
||||
l.advanced.transparency_shapes = tsl;
|
||||
d.layers.push(l);
|
||||
render(&d, d.bounds()).px
|
||||
render(&d, d.bounds()).into_px()
|
||||
};
|
||||
let (off, on) = (render_with(false), render_with(true));
|
||||
assert!(off.iter().zip(&on).all(|(a, b)| close(*a, *b)), "{mode:?} ({depth:?}): a mask isn't the layer's transparency");
|
||||
@@ -691,13 +691,13 @@ fn transparency_shapes_off_keeps_the_base_alpha_under_a_clipped_knockout() {
|
||||
base.advanced.transparency_shapes = false;
|
||||
base.vector_mask = Some(photocraft_doc::vector::VectorMask { feather: 1.5, ..photocraft_doc::vector::VectorMask::new(square.clone()) });
|
||||
d.layers.push(base);
|
||||
let alone = render(&d, d.bounds()).px;
|
||||
let alone = render(&d, d.bounds()).into_px();
|
||||
let mut red = solid(&d, "red", full(&d), [1.0, 0.0, 0.0, 0.5]);
|
||||
red.clipped = true;
|
||||
let mut knock = knocker(&d, k);
|
||||
knock.clipped = true;
|
||||
d.layers.extend([red, knock]);
|
||||
let got = render(&d, d.bounds()).px;
|
||||
let got = render(&d, d.bounds()).into_px();
|
||||
let what = format!("{k:?} ({depth:?})");
|
||||
for (i, (g, a)) in got.iter().zip(&alone).enumerate() {
|
||||
if i % 12 < 6 {
|
||||
|
||||
@@ -27,6 +27,7 @@ use photocraft_doc::adjust::ToneSpace;
|
||||
use photocraft_doc::{Adjustment, Document, Knockout, Layer, LayerContent};
|
||||
use photocraft_geom::Rect;
|
||||
|
||||
use super::extents::recycle_mask;
|
||||
use super::{Ctx, RENDER_TILE, advanced, empty_in, mask_vals, pattern};
|
||||
|
||||
#[path = "cmyk_split.rs"]
|
||||
@@ -355,6 +356,7 @@ fn apply_mask(l: &Layer, buf: &mut CmykBuffer, cx: &Ctx) {
|
||||
for (p, k) in buf.px.iter_mut().zip(&m) {
|
||||
p[4] *= clean(*k).max(0.0);
|
||||
}
|
||||
recycle_mask(Some(m));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -425,6 +427,7 @@ fn mix_adjusted(l: &Layer, base: &mut CmykBuffer, adjusted: &CmykBuffer, cx: &Ct
|
||||
*v = if k >= 1.0 { b } else { *v + (b - *v) * k };
|
||||
}
|
||||
}
|
||||
recycle_mask(mv);
|
||||
}
|
||||
|
||||
/// Rounds to the document's levels after an adjustment layer, as the compositor does (on the
|
||||
@@ -534,6 +537,7 @@ fn composite_layer(l: &Layer, clipped: &[Layer], backdrop: &mut CmykBuffer, cx:
|
||||
[0.0; 5]
|
||||
};
|
||||
}
|
||||
recycle_mask(mv);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::content::{channel_weights, empty_in, recycle_buffer, render_content, restore_channels, scratch_buffer, surface_to_buffer};
|
||||
use super::content::{channel_weights, empty_in, render_content, restore_channels, surface_to_buffer};
|
||||
use super::ctx::Ctx;
|
||||
use super::extents::{effects_frame, mask_k, mask_vals, shapeless_stroke_bounds};
|
||||
use super::extents::{effects_frame, mask_k, mask_vals, recycle_mask, shapeless_stroke_bounds};
|
||||
use super::fx_cache::effect_maps;
|
||||
use super::{BlendMode, Buffer, Fill, Layer, LayerContent, Rect, Surface, advanced, blend, effects, psblend, shape_split};
|
||||
|
||||
@@ -339,6 +339,7 @@ pub(crate) fn composite_pixel_layer(layer: &Layer, surf: &Surface, backdrop: &mu
|
||||
}
|
||||
blend_span_fill(dst, &row, rect.x0, y, layer.blend, layer.opacity, layer.fill_opacity, gamma);
|
||||
}
|
||||
recycle_mask(mv);
|
||||
}
|
||||
|
||||
pub(crate) fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer, cx: &Ctx, scope: advanced::Scope) {
|
||||
@@ -389,6 +390,7 @@ pub(crate) fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop:
|
||||
[0.0; 4]
|
||||
};
|
||||
}
|
||||
recycle_mask(mv);
|
||||
}
|
||||
// A Frame's stroke over the frame's content, at the group's opacity.
|
||||
if let Some(stroke) = shape_split::frame_stroke(layer, cx.canvas, cx.depth) {
|
||||
@@ -425,9 +427,10 @@ pub(crate) fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop:
|
||||
if let LayerContent::Adjustment(adj) = &layer.content {
|
||||
let mv = mask_vals(layer, rect, cx);
|
||||
if mv.as_ref().is_some_and(|values| values.iter().all(|&value| opacity * value <= 0.0)) {
|
||||
recycle_mask(mv);
|
||||
return;
|
||||
}
|
||||
let mut adjusted = scratch_buffer(rect);
|
||||
let mut adjusted = Buffer::transparent(rect);
|
||||
adjusted.px.copy_from_slice(&backdrop.px);
|
||||
cx.apply_adjustment(adj, &mut adjusted);
|
||||
// Clipped layers onto an adjustment are uncommon; they composite atop the adjusted result.
|
||||
@@ -455,7 +458,7 @@ pub(crate) fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop:
|
||||
None => blend_back(&mut backdrop.px, &adjusted.px, std::iter::repeat(k), layer.blend, fill),
|
||||
}
|
||||
}
|
||||
recycle_buffer(adjusted);
|
||||
recycle_mask(mv);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -469,6 +472,7 @@ pub(crate) fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop:
|
||||
if let (Some(m), Some(b)) = (&hide, &before) {
|
||||
advanced::apply_hiding_mask(backdrop, b, m);
|
||||
}
|
||||
recycle_mask(hide);
|
||||
return;
|
||||
}
|
||||
if let Some((mut content, stroke, mask)) = shape_parts(layer, clipped, rect, cx) {
|
||||
@@ -478,6 +482,7 @@ pub(crate) fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop:
|
||||
p[3] *= mask_k(&mask, i);
|
||||
}
|
||||
blend_layer_into(backdrop, &content, layer, cx, 1.0);
|
||||
recycle_mask(mask);
|
||||
return;
|
||||
}
|
||||
if !matches!(layer.content, LayerContent::Group(_) | LayerContent::Fill(_) | LayerContent::Adjustment(_))
|
||||
@@ -512,7 +517,6 @@ pub(crate) fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop:
|
||||
let Some(mut content) = render_content(layer, rect, cx) else { return };
|
||||
advanced::composite_clipped(clipped, &mut content, cx);
|
||||
blend_layer_into(backdrop, &content, layer, cx, text_gamma(layer));
|
||||
recycle_buffer(content);
|
||||
}
|
||||
|
||||
/// A layer with effects (and its clipping group) onto `backdrop`.
|
||||
@@ -521,7 +525,7 @@ fn composite_layer_effects(layer: &Layer, clipped: &[Layer], backdrop: &mut Buff
|
||||
// Neighbourhoods are already captured by the full-region effect maps;
|
||||
// content and effect application only need the output rectangle.
|
||||
// A stroked shape's vector stroke stays above its clipped layers and interior effects.
|
||||
let (mut content, vstroke) = match split_parts(layer, rect, cx) {
|
||||
let (mut content, parts) = match split_parts(layer, rect, cx) {
|
||||
Some((fill, stroke, mask)) => (fill, Some((stroke, mask))),
|
||||
None => {
|
||||
let Some(content) = render_content(layer, rect, cx) else { return };
|
||||
@@ -529,9 +533,12 @@ fn composite_layer_effects(layer: &Layer, clipped: &[Layer], backdrop: &mut Buff
|
||||
}
|
||||
};
|
||||
advanced::composite_clipped(clipped, &mut content, cx);
|
||||
let vstroke = vstroke.as_ref().map(|(s, m)| effects::VectorStroke { stroke: s, mask: m.as_deref() });
|
||||
let vstroke = parts.as_ref().map(|(s, m)| effects::VectorStroke { stroke: s, mask: m.as_deref() });
|
||||
let maps = effect_maps(layer, rect, cx);
|
||||
effects::composite_with_effects_prepared(layer, &content, backdrop, &maps, effects_frame(layer, cx.canvas), cx.patterns, vstroke);
|
||||
if let Some((_, mask)) = parts {
|
||||
recycle_mask(mask);
|
||||
}
|
||||
}
|
||||
|
||||
/// The coverage-mixing gamma of a layer: the text blending gamma
|
||||
@@ -605,6 +612,7 @@ fn composite_atop_any(layer: &Layer, base: &mut Buffer, cx: &Ctx) {
|
||||
p[c] += (bl[c] - p[c]) * k;
|
||||
}
|
||||
}
|
||||
recycle_mask(mv);
|
||||
return;
|
||||
}
|
||||
if effects::has_effects(layer) {
|
||||
@@ -615,21 +623,28 @@ fn composite_atop_any(layer: &Layer, base: &mut Buffer, cx: &Ctx) {
|
||||
let hide = advanced::hiding_mask(layer, rect, cx);
|
||||
let src = advanced::effects_source(layer);
|
||||
let layer = src.as_ref();
|
||||
let (content, vstroke) = match split_parts(layer, rect, cx) {
|
||||
let (content, parts) = match split_parts(layer, rect, cx) {
|
||||
Some((fill, stroke, mask)) => (fill, Some((stroke, mask))),
|
||||
None => {
|
||||
let Some(content) = render_content(layer, rect, cx) else { return };
|
||||
let Some(content) = render_content(layer, rect, cx) else {
|
||||
recycle_mask(hide);
|
||||
return;
|
||||
};
|
||||
(content, None)
|
||||
}
|
||||
};
|
||||
let vstroke = vstroke.as_ref().map(|(s, m)| effects::VectorStroke { stroke: s, mask: m.as_deref() });
|
||||
let vstroke = parts.as_ref().map(|(s, m)| effects::VectorStroke { stroke: s, mask: m.as_deref() });
|
||||
let mut opaque = Buffer { rect, px: base.px.iter().map(|p| [p[0], p[1], p[2], 1.0]).collect() };
|
||||
let maps = effect_maps(layer, rect, cx);
|
||||
effects::composite_with_effects_prepared(layer, &content, &mut opaque, &maps, effects_frame(layer, cx.canvas), cx.patterns, vstroke);
|
||||
if let Some((_, mask)) = parts {
|
||||
recycle_mask(mask);
|
||||
}
|
||||
if let Some(m) = &hide {
|
||||
let plain = Buffer { rect, px: base.px.iter().map(|p| [p[0], p[1], p[2], 1.0]).collect() };
|
||||
advanced::apply_hiding_mask(&mut opaque, &plain, m);
|
||||
}
|
||||
recycle_mask(hide);
|
||||
for (p, o) in base.px.iter_mut().zip(&opaque.px) {
|
||||
if p[3] > 0.0 {
|
||||
*p = [o[0], o[1], o[2], p[3]];
|
||||
@@ -689,6 +704,7 @@ fn blend_layer_into(backdrop: &mut Buffer, src: &Buffer, layer: &Layer, cx: &Ctx
|
||||
let alpha = (s[3] / m).clamp(0.0, 1.0);
|
||||
*b = psblend::composite_fill(mode, *b, [s[0], s[1], s[2], m], opacity, fill * alpha, gamma);
|
||||
}
|
||||
recycle_mask(mv);
|
||||
}
|
||||
|
||||
/// [`blend_into_g`] for a layer's own `opacity` and `fill`: Fill is part of the blend for
|
||||
|
||||
@@ -1,63 +1,29 @@
|
||||
use super::composite::{adjustment_quantum, blend_into, composite_stack};
|
||||
use super::ctx::Ctx;
|
||||
use super::extents::{effect_shape, effect_source_bounds, mask_vals, shapeless_stroke_bounds, transparent_outside};
|
||||
use super::extents::{effect_shape, effect_source_bounds, mask_vals, recycle_mask, shapeless_stroke_bounds, transparent_outside};
|
||||
use super::fx_cache::effect_maps;
|
||||
use super::{BlendMode, Buffer, Document, Fill, Layer, LayerContent, Pattern, Rect, Surface, bounds, effects, gradient_fill, pattern, shape_split};
|
||||
|
||||
/// Tile size for parallel rendering (tile results are independent).
|
||||
pub const RENDER_TILE: i32 = 256;
|
||||
|
||||
// One tile of scratch per worker; nested rendering takes its own buffer.
|
||||
thread_local! {
|
||||
static SCRATCH: std::cell::RefCell<Vec<[f32; 4]>> = const { std::cell::RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
pub(crate) fn scratch_buffer(rect: Rect) -> Buffer {
|
||||
if rect.width() as usize * rect.height() as usize > (RENDER_TILE * RENDER_TILE) as usize {
|
||||
return Buffer::transparent(rect);
|
||||
}
|
||||
let mut px = SCRATCH.with(|slot| std::mem::take(&mut *slot.borrow_mut()));
|
||||
px.resize(rect.width() as usize * rect.height() as usize, [0.0; 4]);
|
||||
Buffer { rect, px }
|
||||
}
|
||||
|
||||
pub(crate) fn recycle_buffer(buf: Buffer) {
|
||||
if buf.px.capacity() <= (RENDER_TILE * RENDER_TILE) as usize {
|
||||
SCRATCH.with(|slot| {
|
||||
let mut slot = slot.borrow_mut();
|
||||
if slot.capacity() < buf.px.capacity() {
|
||||
*slot = buf.px;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[test]
|
||||
fn scratch_reuse_preserves_pixels_and_bounds_retention() {
|
||||
SCRATCH.with(|slot| slot.borrow_mut().clear());
|
||||
let rect = Rect::new(-2, -3, 18, 11);
|
||||
fn scratch_reuse_preserves_pixels() {
|
||||
// 256 × 256 pixels of [f32; 4] (1 MiB): over `MIN_POOLED_BYTES`, so the pool keeps it.
|
||||
let rect = Rect::new(-2, -3, 254, 253);
|
||||
for fmt in [photocraft_color::PixelFormat::RGBA8, photocraft_color::PixelFormat::RGBA16, photocraft_color::PixelFormat::RGBA32F] {
|
||||
let s = Surface::with_default(fmt, &[0.2, 0.4, 0.6, 0.8]);
|
||||
let buf = surface_to_scratch(&s, rect);
|
||||
let mut buf = surface_to_scratch(&s, rect);
|
||||
assert_eq!(buf, surface_to_buffer(&s, rect));
|
||||
let ptr = buf.px.as_ptr();
|
||||
recycle_buffer(buf);
|
||||
let reused = surface_to_scratch(&Surface::new(fmt), rect);
|
||||
assert_eq!(reused.px.as_ptr(), ptr);
|
||||
buf.px.fill([1.0; 4]);
|
||||
// A dropped buffer goes back to the pool; the next one must come out cleared.
|
||||
drop(buf);
|
||||
let reused = Buffer::transparent(rect);
|
||||
assert!(reused.px.iter().all(|p| p == &[0.0; 4]));
|
||||
let nested = scratch_buffer(rect);
|
||||
let nested = Buffer::transparent(rect);
|
||||
assert_ne!(nested.px.as_ptr(), reused.px.as_ptr());
|
||||
recycle_buffer(nested);
|
||||
recycle_buffer(reused);
|
||||
}
|
||||
let cached = SCRATCH.with(|slot| slot.borrow().as_ptr());
|
||||
let huge = scratch_buffer(Rect::new(0, 0, RENDER_TILE + 1, RENDER_TILE));
|
||||
recycle_buffer(huge);
|
||||
SCRATCH.with(|slot| {
|
||||
assert_eq!(slot.borrow().as_ptr(), cached);
|
||||
assert!(slot.borrow().capacity() <= (RENDER_TILE * RENDER_TILE) as usize);
|
||||
});
|
||||
}
|
||||
|
||||
/// Render a layer's own content (no blending into the backdrop yet) into an isolated buffer.
|
||||
@@ -93,6 +59,7 @@ pub(crate) fn render_content(layer: &Layer, rect: Rect, cx: &Ctx) -> Option<Buff
|
||||
for (p, k) in buf.px.iter_mut().zip(&m) {
|
||||
p[3] *= k;
|
||||
}
|
||||
recycle_mask(Some(m));
|
||||
}
|
||||
// A Frame's stroke lies over its masked content; the frame doesn't clip it.
|
||||
if let Some(stroke) = shape_split::frame_stroke(layer, cx.canvas, cx.depth) {
|
||||
@@ -125,13 +92,13 @@ pub fn stroke_frame(doc: &Document, layer: &Layer, st: &photocraft_doc::StrokeFx
|
||||
}
|
||||
|
||||
pub fn surface_to_buffer(s: &Surface, rect: Rect) -> Buffer {
|
||||
let mut px = vec![[0.0f32; 4]; rect.width() as usize * rect.height() as usize];
|
||||
s.read_rgba_into(rect, &mut px);
|
||||
Buffer { rect, px }
|
||||
let mut buf = Buffer::transparent(rect);
|
||||
s.read_rgba_into(rect, &mut buf.px);
|
||||
buf
|
||||
}
|
||||
|
||||
fn surface_to_scratch(s: &Surface, rect: Rect) -> Buffer {
|
||||
let mut buf = scratch_buffer(rect);
|
||||
let mut buf = Buffer::transparent(rect);
|
||||
s.read_rgba_into(rect, &mut buf.px);
|
||||
buf
|
||||
}
|
||||
|
||||
@@ -134,6 +134,7 @@ pub(crate) fn effect_shape(layer: &Layer, rect: Rect, cx: &Ctx) -> Vec<f32> {
|
||||
out[i] = v * mask_k(&mv, i);
|
||||
}
|
||||
}
|
||||
recycle_mask(mv);
|
||||
out
|
||||
}
|
||||
|
||||
@@ -283,13 +284,13 @@ pub(crate) fn mask_vals(layer: &Layer, rect: Rect, cx: &Ctx) -> Option<Vec<f32>>
|
||||
if masks::has_feather(layer)
|
||||
&& let Some(s) = masks::combined_mask(layer, cx.canvas)
|
||||
{
|
||||
let mut v = Vec::new();
|
||||
let mut v = photocraft_raster::scratch::take_cap(rect.width() as usize * rect.height() as usize);
|
||||
s.read_region_into(rect, &mut v);
|
||||
return Some(v);
|
||||
}
|
||||
let vector = layer.vector_mask.as_ref().map(|vm| cx.vector_masks.values(vm, rect));
|
||||
let Some(m) = layer.mask.as_ref() else { return vector };
|
||||
let mut v = Vec::new();
|
||||
let mut v = photocraft_raster::scratch::take_cap(rect.width() as usize * rect.height() as usize);
|
||||
m.values_into(rect, &mut v);
|
||||
if let Some(vm) = vector {
|
||||
for (a, b) in v.iter_mut().zip(vm) {
|
||||
@@ -299,6 +300,13 @@ pub(crate) fn mask_vals(layer: &Layer, rect: Rect, cx: &Ctx) -> Option<Vec<f32>>
|
||||
Some(v)
|
||||
}
|
||||
|
||||
/// Hand a [`mask_vals`] result back to the scratch pool once its tile is done.
|
||||
pub(crate) fn recycle_mask(m: Option<Vec<f32>>) {
|
||||
if let Some(v) = m {
|
||||
photocraft_raster::scratch::give(v);
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn mask_k(m: &Option<Vec<f32>>, i: usize) -> f32 {
|
||||
m.as_ref().map_or(1.0, |v| v[i])
|
||||
|
||||
@@ -84,18 +84,39 @@ pub use render::{
|
||||
};
|
||||
|
||||
/// Straight-alpha RGBA float buffer covering a rectangle.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
///
|
||||
/// The pixel storage comes from, and goes back to, the scratch pool.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct Buffer {
|
||||
pub rect: Rect,
|
||||
pub px: Vec<[f32; 4]>,
|
||||
}
|
||||
|
||||
impl Clone for Buffer {
|
||||
fn clone(&self) -> Self {
|
||||
let mut px = photocraft_raster::scratch::take(self.px.len(), [0.0; 4]);
|
||||
px.copy_from_slice(&self.px);
|
||||
Self { rect: self.rect, px }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Buffer {
|
||||
fn drop(&mut self) {
|
||||
photocraft_raster::scratch::give(std::mem::take(&mut self.px));
|
||||
}
|
||||
}
|
||||
|
||||
impl Buffer {
|
||||
pub fn transparent(rect: Rect) -> Self {
|
||||
Self { rect, px: vec![[0.0; 4]; rect.width() as usize * rect.height() as usize] }
|
||||
Self::filled(rect, [0.0; 4])
|
||||
}
|
||||
pub fn filled(rect: Rect, c: [f32; 4]) -> Self {
|
||||
Self { rect, px: vec![c; rect.width() as usize * rect.height() as usize] }
|
||||
Self { rect, px: photocraft_raster::scratch::take(rect.width() as usize * rect.height() as usize, c) }
|
||||
}
|
||||
/// The pixels, taken out of the buffer (it can't be destructured: it returns them to the
|
||||
/// pool on drop).
|
||||
pub fn into_px(mut self) -> Vec<[f32; 4]> {
|
||||
std::mem::take(&mut self.px)
|
||||
}
|
||||
#[inline]
|
||||
pub fn get(&self, x: i32, y: i32) -> [f32; 4] {
|
||||
|
||||
@@ -229,14 +229,12 @@ pub fn composite_to_surface(doc: &Document, rect: Rect, fmt: photocraft_color::P
|
||||
let mut s = Surface::new(fmt);
|
||||
let n = fmt.channels();
|
||||
let empty = s.default_pixel();
|
||||
let mut data = Vec::new();
|
||||
let _ = render_bands(doc, rect, 0, |band| -> Result<(), ()> {
|
||||
let band = match background {
|
||||
Some(bg) => band.over_background(bg),
|
||||
None => band,
|
||||
};
|
||||
data.clear();
|
||||
data.resize(band.px.len() * n, 0.0);
|
||||
let mut data = photocraft_raster::scratch::take(band.px.len() * n, 0.0f32);
|
||||
photocraft_color::convert::with_cmyk_space(space.as_ref(), || {
|
||||
for (p, out) in band.px.iter().zip(data.chunks_exact_mut(n)) {
|
||||
if fmt.alpha && (p[3] <= 0.0 || p[3].is_nan()) && empty.len() == n {
|
||||
@@ -247,6 +245,7 @@ pub fn composite_to_surface(doc: &Document, rect: Rect, fmt: photocraft_color::P
|
||||
}
|
||||
});
|
||||
s.write_region(band.rect, &data);
|
||||
photocraft_raster::scratch::give(data);
|
||||
Ok(())
|
||||
});
|
||||
s.prune();
|
||||
|
||||
@@ -115,7 +115,7 @@ fn soft_shape_matches_an_equal_vector_mask() {
|
||||
fn soft_shape_keeps_its_stroke_crisp() {
|
||||
let stroke = ShapeStroke { width: 4.0, paint: Fill::Solid(Color::rgb(1.0, 0.0, 0.0)), ..ShapeStroke::default() };
|
||||
let d = shape_doc(SampleType::U8, 0.5, 10.0, Some(stroke));
|
||||
let px = render(&d, Rect::new(0, 100, 200, 101)).px;
|
||||
let px = render(&d, Rect::new(0, 100, 200, 101)).into_px();
|
||||
// Centred 4 px stroke over x = 48..52: opaque red.
|
||||
for x in [48, 49, 50, 51] {
|
||||
let p = px[x];
|
||||
|
||||
+10
-10
@@ -3001,7 +3001,7 @@ fn levels_steps_match_photoshop() {
|
||||
let px = xs.iter().map(|&x| f32::from(x) / 255.0).map(|v| [v, v, v, 1.0]).collect();
|
||||
let mut b = Buffer { rect: Rect::new(0, 0, xs.len() as i32, 1), px };
|
||||
adjust::apply_depth(adj, &mut b, adjust::Transfer::Srgb, Some(SampleType::U8));
|
||||
b.px
|
||||
b.into_px()
|
||||
};
|
||||
let rgb = levels(ch(0.0, 213.0, 1.22, 48.0, 255.0), [ch(0.0, 255.0, 0.47, 0.0, 194.0), id.clone(), ch(61.0, 255.0, 0.47, 0.0, 213.0)]);
|
||||
let xs = [2, 8, 9, 15, 16, 43, 45, 48, 55, 61, 72, 79, 84, 89, 90, 101, 106, 120, 150, 180, 200];
|
||||
@@ -3364,7 +3364,7 @@ fn render_bands_cover_the_rect_in_order() {
|
||||
render_bands(&d, r, 100, |b| -> Result<(), ()> {
|
||||
assert_eq!((b.rect.x0, b.rect.x1), (r.x0, r.x1));
|
||||
rows.push((b.rect.y0, b.rect.y1));
|
||||
px.extend(b.px);
|
||||
px.extend(b.into_px());
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
@@ -4469,13 +4469,13 @@ fn dissolve_adjustment_resolves_opacity_to_binary_coverage() {
|
||||
layer.blend = BlendMode::Dissolve;
|
||||
layer.opacity = 0.5;
|
||||
d.layers.push(layer);
|
||||
let pixels = render(&d, d.bounds()).px;
|
||||
let pixels = render(&d, d.bounds()).into_px();
|
||||
let black = pixels.iter().filter(|p| p[0] < 0.01).count();
|
||||
let white = pixels.iter().filter(|p| p[0] > 0.99).count();
|
||||
assert!(black > 0 && white > 0, "Dissolve must select both original and adjusted pixels");
|
||||
assert_eq!(black + white, pixels.len(), "no intermediate gray pixels");
|
||||
d.layers.last_mut().unwrap().blend = BlendMode::Normal;
|
||||
let continuous = render(&d, d.bounds()).px;
|
||||
let continuous = render(&d, d.bounds()).into_px();
|
||||
assert!(continuous.iter().all(|p| (p[0] - 0.5).abs() < E), "Normal remains continuously interpolated");
|
||||
}
|
||||
|
||||
@@ -4486,14 +4486,14 @@ fn clipped_dissolve_fill_matches_opaque_unclipped_coverage() {
|
||||
fill.blend = BlendMode::Dissolve;
|
||||
fill.opacity = 0.5;
|
||||
d.layers.push(fill);
|
||||
let control = render(&d, d.bounds()).px;
|
||||
let control = render(&d, d.bounds()).into_px();
|
||||
d.layers.last_mut().unwrap().clipped = true;
|
||||
let clipped = render(&d, d.bounds()).px;
|
||||
let clipped = render(&d, d.bounds()).into_px();
|
||||
assert_eq!(clipped, control, "clipping over a fully opaque base must preserve dissolve pixels");
|
||||
let black = clipped.iter().filter(|p| p[0] < 0.01).count();
|
||||
assert!(black > 0 && black < clipped.len());
|
||||
d.layers.last_mut().unwrap().blend = BlendMode::Normal;
|
||||
let continuous = render(&d, d.bounds()).px;
|
||||
let continuous = render(&d, d.bounds()).into_px();
|
||||
assert!(continuous.iter().all(|p| (p[0] - 0.5).abs() < E));
|
||||
}
|
||||
|
||||
@@ -4504,17 +4504,17 @@ fn dissolve_color_overlay_preserves_binary_layer_coverage() {
|
||||
layer.blend = BlendMode::Dissolve;
|
||||
layer.opacity = 0.5;
|
||||
d.layers.push(layer);
|
||||
let control = render(&d, d.bounds()).px;
|
||||
let control = render(&d, d.bounds()).into_px();
|
||||
d.layers
|
||||
.last_mut()
|
||||
.unwrap()
|
||||
.effects
|
||||
.items
|
||||
.push(photocraft_doc::Effect::ColorOverlay { common: photocraft_doc::FxCommon::new(BlendMode::Normal, 1.0), color: Color::BLACK });
|
||||
let styled = render(&d, d.bounds()).px;
|
||||
let styled = render(&d, d.bounds()).into_px();
|
||||
assert_eq!(styled, control, "an identity overlay should not turn dissolve pixels gray");
|
||||
d.layers.last_mut().unwrap().blend = BlendMode::Normal;
|
||||
let continuous = render(&d, d.bounds()).px;
|
||||
let continuous = render(&d, d.bounds()).into_px();
|
||||
assert!(continuous.iter().all(|p| (p[0] - 0.5).abs() < E));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
use photocraft_compose::Buffer;
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_raster::scratch;
|
||||
|
||||
/// Restores idle release even when an assertion fails.
|
||||
struct IdleReleaseOff;
|
||||
impl Drop for IdleReleaseOff {
|
||||
fn drop(&mut self) {
|
||||
scratch::release();
|
||||
scratch::set_idle_release(true);
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs in its own binary so other compositor tests cannot consume the pooled allocation.
|
||||
#[test]
|
||||
fn dropped_buffer_storage_is_reused() {
|
||||
scratch::set_idle_release(false);
|
||||
let _idle = IdleReleaseOff;
|
||||
scratch::release();
|
||||
let rect = Rect::from_xywh(0, 0, 256, 256);
|
||||
let buf = Buffer::transparent(rect);
|
||||
let ptr = buf.px.as_ptr();
|
||||
drop(buf);
|
||||
let reused = Buffer::transparent(rect);
|
||||
assert_eq!(reused.px.as_ptr(), ptr);
|
||||
assert!(reused.px.iter().all(|p| *p == [0.0; 4]));
|
||||
}
|
||||
@@ -25,7 +25,7 @@ fn main() -> Result<(), Box<dyn Error>> {
|
||||
let path = std::env::args().nth(1).ok_or("provide a local image path")?;
|
||||
let bytes = photocraft_format::read::read_file(std::path::Path::new(&path))?;
|
||||
let doc = photocraft_io::import(&path, &bytes)?.document;
|
||||
let px = photocraft_compose::flatten(&doc).px;
|
||||
let px = photocraft_compose::flatten(&doc).into_px();
|
||||
println!("{}", json!({"width":doc.size.width,"height":doc.size.height,"preview_factor":1}));
|
||||
for count in [8usize, 16, 32, 64, 128, 256] {
|
||||
for run in 0..4 {
|
||||
|
||||
@@ -459,9 +459,9 @@ fn stats_pixels(doc: &Document, layer: Option<u64>, use_selection: bool) -> Resu
|
||||
let px: Vec<[f32; 4]> = match layer {
|
||||
Some(id) => {
|
||||
let l = doc.layer(photocraft_doc::LayerId(id)).ok_or(EngineError::NoLayer(photocraft_doc::LayerId(id)))?;
|
||||
photocraft_compose::render_layer(l, area).px
|
||||
photocraft_compose::render_layer(l, area).into_px()
|
||||
}
|
||||
None => photocraft_compose::flatten(doc).px,
|
||||
None => photocraft_compose::flatten(doc).into_px(),
|
||||
};
|
||||
let mask = doc.selection.as_ref().filter(|_| use_selection).map(|sel| photocraft_algo::selection::mask_from_surface(Some(sel), area));
|
||||
Ok((px, mask))
|
||||
@@ -521,7 +521,7 @@ fn hdr_toning(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.edit("HDR Toning", |doc, active| {
|
||||
let buf = photocraft_compose::flatten(doc).over_background([1.0, 1.0, 1.0]);
|
||||
let (w, hgt) = (buf.rect.width() as usize, buf.rect.height() as usize);
|
||||
let mut px = buf.px;
|
||||
let mut px = buf.into_px();
|
||||
tone::hdr_toning(&mut px, w, hgt, &h);
|
||||
let fmt = doc.pixel_format();
|
||||
let data: Vec<f32> = px.iter().flat_map(|q| photocraft_raster::from_rgba(&fmt, *q)).collect();
|
||||
|
||||
@@ -480,7 +480,7 @@ fn smart_session() -> Session {
|
||||
}
|
||||
|
||||
fn flatten(s: &Session) -> Vec<[f32; 4]> {
|
||||
photocraft_compose::flatten(doc(s)).px
|
||||
photocraft_compose::flatten(doc(s)).into_px()
|
||||
}
|
||||
|
||||
fn max_diff(a: &[[f32; 4]], b: &[[f32; 4]]) -> f32 {
|
||||
|
||||
@@ -36,7 +36,7 @@ pub fn faces_in(doc: &Document, active: Option<LayerId>, all_layers: bool, min_s
|
||||
surf.read_rgba_into(area, &mut px);
|
||||
px
|
||||
}
|
||||
None => photocraft_compose::render(doc, area).px,
|
||||
None => photocraft_compose::render(doc, area).into_px(),
|
||||
};
|
||||
let found = detect(&px, w, h, min_score)?;
|
||||
let (x0, y0) = (area.x0 as f32, area.y0 as f32);
|
||||
|
||||
@@ -230,7 +230,7 @@ fn mask_all_objects(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
struct Composite<'a>(&'a Document);
|
||||
impl photocraft_algo::segment::Sampler for Composite<'_> {
|
||||
fn rgba(&self, r: Rect) -> Vec<[f32; 4]> {
|
||||
photocraft_compose::render(self.0, r).px
|
||||
photocraft_compose::render(self.0, r).into_px()
|
||||
}
|
||||
}
|
||||
photocraft_algo::segment::subject::select_subject(&Composite(&doc), doc.bounds())
|
||||
|
||||
@@ -757,11 +757,11 @@ fn applying_a_feathered_mask_preserves_its_soft_composite() {
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let before = photocraft_compose::flatten(doc(&s)).px;
|
||||
let before = photocraft_compose::flatten(doc(&s)).into_px();
|
||||
assert!(before[15 * 40 + 19][3] > 0.0 && before[15 * 40 + 19][3] < 1.0, "mask must start with a soft edge");
|
||||
s.execute("layer.layerMask.apply", json!({})).unwrap();
|
||||
assert!(active(&s).mask.is_none(), "Apply removes the pixel mask");
|
||||
let after = photocraft_compose::flatten(doc(&s)).px;
|
||||
let after = photocraft_compose::flatten(doc(&s)).into_px();
|
||||
for (a, b) in before.iter().zip(&after) {
|
||||
assert!((a[3] - b[3]).abs() <= 1.0 / 255.0 + 1e-5, "depth {depth}: alpha {a:?} became {b:?}");
|
||||
}
|
||||
@@ -791,13 +791,13 @@ fn from_transparency_preserves_mask_coverage_outside_the_canvas() {
|
||||
for depth in DEPTHS {
|
||||
let mut s = session(depth, "rgb");
|
||||
paint(&mut s, |_, _| [1.0, 0.0, 0.0, 0.5]);
|
||||
let before = photocraft_compose::flatten(doc(&s)).px;
|
||||
let before = photocraft_compose::flatten(doc(&s)).into_px();
|
||||
s.execute("layer.translate", json!({"dx": 20})).unwrap();
|
||||
s.execute("layer.layerMask.fromTransparency", json!({})).unwrap();
|
||||
let mask = active(&s).mask.as_ref().unwrap();
|
||||
assert!((mask.value(50, 10) - 0.5).abs() < 1.0 / 255.0 + 1e-6, "off-canvas mask at depth {depth}");
|
||||
s.execute("layer.translate", json!({"dx": -20})).unwrap();
|
||||
let after = photocraft_compose::flatten(doc(&s)).px;
|
||||
let after = photocraft_compose::flatten(doc(&s)).into_px();
|
||||
for (a, b) in before.iter().zip(&after) {
|
||||
assert!((a[3] - b[3]).abs() <= 1.0 / 255.0 + 1e-6, "alpha changed at depth {depth}: {a:?} -> {b:?}");
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ fn modes_toggle_and_show_in_inspect_without_history() {
|
||||
fn a_mask_view_never_alters_pixels() {
|
||||
let (mut s, masked, _) = session();
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
let px = |s: &Session| photocraft_compose::render(&s.active().unwrap().doc, Rect::new(0, 0, 40, 20)).px;
|
||||
let px = |s: &Session| photocraft_compose::render(&s.active().unwrap().doc, Rect::new(0, 0, 40, 20)).into_px();
|
||||
let composite = px(&s);
|
||||
for mode in ["gray", "overlay", "toggleOverlay", "toggleGray", "off"] {
|
||||
s.execute(ID, json!({"layer": masked, "mode": mode})).unwrap();
|
||||
|
||||
@@ -186,7 +186,7 @@ fn with_released_movement_locks<R>(l: &mut Layer, f: impl FnOnce(&mut Layer) ->
|
||||
/// The flattened image as straight RGBA (over white when `opaque`).
|
||||
fn composite(doc: &Document, opaque: bool) -> Vec<[f32; 4]> {
|
||||
let buf = photocraft_compose::flatten(doc);
|
||||
if opaque { buf.over_background([1.0, 1.0, 1.0]).px } else { buf.px }
|
||||
if opaque { buf.over_background([1.0, 1.0, 1.0]).into_px() } else { buf.into_px() }
|
||||
}
|
||||
|
||||
/// Replace the document's layers by one 8-bit layer holding `px` in `mode`'s storage format.
|
||||
|
||||
@@ -568,7 +568,7 @@ fn bitmap_budgets_a_conversion_at_the_document_resolution_too() {
|
||||
|
||||
/// The Bitmap conversion of `d` the pre-#8477 way, from one full-canvas composite over white.
|
||||
fn bitmap_oracle(d: &Document, method: Option<BitmapMethod>, custom: Option<(&[f32], usize, usize)>) -> Vec<f32> {
|
||||
let px = photocraft_compose::flatten(d).over_background([1.0; 3]).px;
|
||||
let px = photocraft_compose::flatten(d).over_background([1.0; 3]).into_px();
|
||||
let mut gray: Vec<f32> = px.iter().map(|q| photocraft_color::convert::rgb_to_gray([q[0], q[1], q[2]])).collect();
|
||||
let w = d.size.width as usize;
|
||||
match (method, custom) {
|
||||
@@ -730,7 +730,7 @@ fn duotone_to_colour_modes_keeps_the_ink_colours() {
|
||||
s.execute("image.mode.duotone", cfg.clone()).unwrap();
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
let gray: Vec<f32> = (0..16).map(|x| before.layers[0].surface().unwrap().pixel(x, 1)[0]).collect();
|
||||
let shown = photocraft_compose::render(&display_document(&before).unwrap(), Rect::from_xywh(0, 1, 16, 1)).px;
|
||||
let shown = photocraft_compose::render(&display_document(&before).unwrap(), Rect::from_xywh(0, 1, 16, 1)).into_px();
|
||||
s.execute(&format!("image.mode.{mode}"), json!({})).unwrap();
|
||||
let d = doc(&s);
|
||||
assert!(d.duotone.is_none(), "{ctx}");
|
||||
|
||||
@@ -40,7 +40,7 @@ struct CompositeSampler<'a>(&'a Document);
|
||||
|
||||
impl Sampler for CompositeSampler<'_> {
|
||||
fn rgba(&self, r: Rect) -> Vec<[f32; 4]> {
|
||||
photocraft_compose::render(self.0, r).px
|
||||
photocraft_compose::render(self.0, r).into_px()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ fn sample_pixels(s: &Session, all_layers: bool, window: Option<Rect>) -> Result<
|
||||
surf.read_rgba_into(area, &mut px);
|
||||
Ok((area, px))
|
||||
}
|
||||
_ => Ok((area, photocraft_compose::render(&d.doc, area).px)),
|
||||
_ => Ok((area, photocraft_compose::render(&d.doc, area).into_px())),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ pub fn stack_image(file_name: &str, bytes: &[u8], fmt: PixelFormat, mode: photoc
|
||||
let mut one = l.clone();
|
||||
one.blend = BlendMode::Normal;
|
||||
one.clipped = false;
|
||||
photocraft_compose::render_layer(&one, bounds).px
|
||||
photocraft_compose::render_layer(&one, bounds).into_px()
|
||||
})
|
||||
.collect();
|
||||
if frames.is_empty() {
|
||||
|
||||
@@ -110,7 +110,7 @@ fn paint(s: &mut Session) {
|
||||
}
|
||||
|
||||
fn flat(s: &Session) -> Vec<[f32; 4]> {
|
||||
photocraft_compose::flatten(&s.active().unwrap().doc).px
|
||||
photocraft_compose::flatten(&s.active().unwrap().doc).into_px()
|
||||
}
|
||||
|
||||
fn max_diff(a: &[[f32; 4]], b: &[[f32; 4]]) -> f32 {
|
||||
|
||||
@@ -49,7 +49,7 @@ struct CompositeSampler<'a>(&'a Document);
|
||||
|
||||
impl Sampler for CompositeSampler<'_> {
|
||||
fn rgba(&self, r: Rect) -> Vec<[f32; 4]> {
|
||||
photocraft_compose::render(self.0, r).px
|
||||
photocraft_compose::render(self.0, r).into_px()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -366,7 +366,7 @@ fn off_from_fresh(s: &Session) -> f32 {
|
||||
let st = s.active().unwrap();
|
||||
let mut fresh: Document = (*st.doc).clone();
|
||||
fresh.layer_mut(st.active_layer.unwrap()).unwrap().fill_cache = None;
|
||||
let (a, b) = (photocraft_compose::render(&st.doc, st.doc.bounds()).px, photocraft_compose::render(&fresh, fresh.bounds()).px);
|
||||
let (a, b) = (photocraft_compose::render(&st.doc, st.doc.bounds()).into_px(), photocraft_compose::render(&fresh, fresh.bounds()).into_px());
|
||||
a.iter().zip(&b).flat_map(|(p, q)| p.iter().zip(q).map(|(x, y)| (x - y).abs())).fold(0.0, f32::max)
|
||||
}
|
||||
|
||||
|
||||
@@ -229,7 +229,7 @@ fn animated_gif_keeps_transparency() {
|
||||
// Each frame has a transparent index and is cleared before the next (disposal 2).
|
||||
let packed: Vec<u8> = gif.windows(4).filter(|w| w[..3] == [0x21, 0xF9, 0x04]).map(|w| w[3]).collect();
|
||||
assert_eq!(packed, [0x09, 0x09]);
|
||||
let px = photocraft_compose::flatten(&photocraft_io::import("t.gif", &gif).unwrap().document).px;
|
||||
let px = photocraft_compose::flatten(&photocraft_io::import("t.gif", &gif).unwrap().document).into_px();
|
||||
assert_eq!(px[0][3], 0.0, "the corner stays transparent");
|
||||
assert_eq!(px[3 * 8 + 3], [1.0, 0.0, 0.0, 1.0], "the square stays red");
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ pub(super) fn adjust(surface: &mut Surface, colors: ColorChannels, adjustment: &
|
||||
.collect(),
|
||||
};
|
||||
adjust::apply_in(&scalar, &mut buf, transfer, depth, mode);
|
||||
Arc::new(IntegerTable { adjusted: buf.px.into_iter().map(|p| p[0]).collect(), encoded: Vec::new() })
|
||||
Arc::new(IntegerTable { adjusted: buf.px.iter().map(|p| p[0]).collect(), encoded: Vec::new() })
|
||||
})
|
||||
});
|
||||
let mut component = Component { index, scalar, table, lab_invert };
|
||||
|
||||
@@ -157,7 +157,7 @@ fn replayed(s: &mut Session, id: LayerId, params: serde_json::Value) -> Vec<[f32
|
||||
})
|
||||
.unwrap();
|
||||
let doc = &s.active().unwrap().doc;
|
||||
photocraft_compose::render(doc, doc.bounds()).px
|
||||
photocraft_compose::render(doc, doc.bounds()).into_px()
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -202,12 +202,12 @@ fn smart_displace_keeps_a_relative_map_path() {
|
||||
png(&d.join("map.png"), |_| [255, 255, 255]);
|
||||
let (mut s, id) = document(&d, &d.join("ramp.png"));
|
||||
let path = d.join("map.png").to_string_lossy().into_owned();
|
||||
let unfiltered = photocraft_compose::render(&s.active().unwrap().doc, s.active().unwrap().doc.bounds()).px;
|
||||
let unfiltered = photocraft_compose::render(&s.active().unwrap().doc, s.active().unwrap().doc.bounds()).into_px();
|
||||
s.execute("filter.distort.displace", json!({"layer": id.0, "mapPath": path, "horizontal": 50, "vertical": 0})).unwrap();
|
||||
let state = |s: &Session| {
|
||||
let doc = &s.active().unwrap().doc;
|
||||
let Some(LayerContent::Smart(sm)) = doc.layer(id).map(|l| &l.content) else { panic!("a smart object") };
|
||||
(sm.smart_filters[0].params.clone(), photocraft_compose::render(doc, doc.bounds()).px)
|
||||
(sm.smart_filters[0].params.clone(), photocraft_compose::render(doc, doc.bounds()).into_px())
|
||||
};
|
||||
let (params, displaced) = state(&s);
|
||||
assert_eq!(params["mapPath"], json!(path));
|
||||
|
||||
@@ -397,6 +397,8 @@ pub(crate) fn keep_alpha(old: &photocraft_raster::Surface, new: &mut photocraft_
|
||||
}
|
||||
}
|
||||
s.write_region(*r, &v);
|
||||
photocraft_raster::scratch::give(o);
|
||||
photocraft_raster::scratch::give(v);
|
||||
true
|
||||
})
|
||||
.reduce(|| true, |a, b| a && b);
|
||||
|
||||
@@ -294,6 +294,16 @@ impl Running {
|
||||
}
|
||||
}
|
||||
|
||||
/// A cancelled job's worker that may still be unwinding.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
struct Draining {
|
||||
handle: std::thread::JoinHandle<()>,
|
||||
doc: Option<DocId>,
|
||||
/// Its document closed: the worker still holds that document's snapshot, so the scratch
|
||||
/// pool is released again once it exits (the release at close came too early, #10479).
|
||||
release: bool,
|
||||
}
|
||||
|
||||
/// The session's job table (see the module docs).
|
||||
#[derive(Default)]
|
||||
pub struct Jobs {
|
||||
@@ -309,7 +319,7 @@ pub struct Jobs {
|
||||
recent: Vec<JobInfo>,
|
||||
/// Workers of cancelled jobs that may still be unwinding (native).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
draining: Vec<std::thread::JoinHandle<()>>,
|
||||
draining: Vec<Draining>,
|
||||
/// The context inline job work runs under (see [`Session::set_inline_job_ctx`]).
|
||||
inline: Option<JobCtx>,
|
||||
}
|
||||
@@ -691,7 +701,17 @@ impl Session {
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
self.jobs.draining.retain(|h| !h.is_finished());
|
||||
{
|
||||
let mut release = false;
|
||||
self.jobs.draining.retain(|d| {
|
||||
let done = d.handle.is_finished();
|
||||
release |= done && d.release;
|
||||
!done
|
||||
});
|
||||
if release {
|
||||
photocraft_raster::scratch::release();
|
||||
}
|
||||
}
|
||||
std::mem::take(&mut self.jobs.events)
|
||||
}
|
||||
|
||||
@@ -730,8 +750,8 @@ impl Session {
|
||||
job.ctx.cancel();
|
||||
self.end_job(&job, JobOutcome::Cancelled);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(h) = job.handle {
|
||||
self.jobs.draining.push(h);
|
||||
if let Some(handle) = job.handle {
|
||||
self.jobs.draining.push(Draining { handle, doc: job.doc, release: false });
|
||||
}
|
||||
true
|
||||
}
|
||||
@@ -778,8 +798,15 @@ impl Session {
|
||||
/// cancel latency with it). Native only; returns at once on wasm.
|
||||
pub fn join_cancelled_jobs(&mut self) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
for h in self.jobs.draining.drain(..) {
|
||||
let _ = h.join();
|
||||
{
|
||||
let mut release = false;
|
||||
for d in self.jobs.draining.drain(..) {
|
||||
let _ = d.handle.join();
|
||||
release |= d.release;
|
||||
}
|
||||
if release {
|
||||
photocraft_raster::scratch::release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -798,6 +825,11 @@ impl Session {
|
||||
for id in ids {
|
||||
self.cancel_job(id);
|
||||
}
|
||||
// Workers cancelled now, or earlier and still unwinding, hold the closing document.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
for d in self.jobs.draining.iter_mut().filter(|d| d.doc == Some(doc)) {
|
||||
d.release = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the command `id`: inline, or (with `background`) as a job when it is one.
|
||||
@@ -881,6 +913,7 @@ impl Session {
|
||||
// being recorded, nor move the recording mark past the entries it is about to replace.
|
||||
let recording = if spec.journal && crate::actions_cmds::replayable(id) { self.actions.recording.take() } else { None };
|
||||
let before = self.active().map(|st| st.doc.clone());
|
||||
let docs_before: Vec<_> = self.docs.iter().map(|st| st.doc.id).collect();
|
||||
// Last-resort guard (AGENTS.md, Never crash): a command that panics anyway fails with an
|
||||
// error instead of taking the app down. `edit` only commits a document after its closure
|
||||
// returns, so the documents are unchanged.
|
||||
@@ -900,7 +933,7 @@ impl Session {
|
||||
self.hint_before = None;
|
||||
}
|
||||
let pending = self.jobs.pending.take();
|
||||
match (r, pending) {
|
||||
let out = match (r, pending) {
|
||||
(Err(e), Some(job)) => {
|
||||
job.ctx.cancel();
|
||||
Err(e)
|
||||
@@ -917,8 +950,8 @@ impl Session {
|
||||
Ok(Started::Job(jid))
|
||||
}
|
||||
(Ok(v), None) => {
|
||||
if let Some(before) = before {
|
||||
self.add_relayout_damage(&before);
|
||||
if let Some(before) = &before {
|
||||
self.add_relayout_damage(before);
|
||||
}
|
||||
if let Some((doc_id, layer_id)) = new_fx_target
|
||||
&& let Some(st) = self.active_mut()
|
||||
@@ -934,7 +967,15 @@ impl Session {
|
||||
self.after_command(id, params, spec.journal, Some(nested_from), pre_selected.as_deref());
|
||||
Ok(Started::Done(v))
|
||||
}
|
||||
};
|
||||
// A closed document's freed buffers went to the scratch pool (`before` kept the active
|
||||
// one alive until here): hand them back to the OS (#10479). Compared by id, since Split
|
||||
// and Merge Channels close documents while opening others.
|
||||
if docs_before.iter().any(|id| !self.docs.iter().any(|st| st.doc.id == *id)) {
|
||||
drop(before);
|
||||
photocraft_raster::scratch::release();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Grows the damage a command reported for the pixels it changed by what effects laid out
|
||||
|
||||
@@ -29,6 +29,7 @@ pub mod kys;
|
||||
pub mod layer_place;
|
||||
pub mod layer_style;
|
||||
pub mod link_watch;
|
||||
pub mod malloc_tuning;
|
||||
pub mod new_layer;
|
||||
mod param_check;
|
||||
mod pixels;
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
//! glibc malloc tuning for the native binaries (#8743).
|
||||
//!
|
||||
//! With glibc's defaults, the memory of a closed document stays with the process: freeing a large
|
||||
//! buffer raises glibc's dynamic mmap threshold (up to 32 MiB), so later tile buffers come from the
|
||||
//! heap, whose freed pages glibc keeps for reuse instead of giving them back. A session that opened
|
||||
//! and closed a 5400 × 7200 document held ~540 MiB afterwards; with `MALLOC_TRIM_THRESHOLD_` set it
|
||||
//! went back to ~20 MiB, and the peak was lower too. Setting the threshold explicitly turns the
|
||||
//! dynamic adjustment off (mallopt(3)), so large buffers are mapped and unmapped on their own and
|
||||
//! the heap top is trimmed as it is freed. Mapping fresh pages on every call is slow, so the large
|
||||
//! short-lived buffers come from `photocraft_raster::scratch` instead, which reuses them and
|
||||
//! releases them when a document closes or the pool goes idle (#10479).
|
||||
//!
|
||||
//! glibc reads its tunables once, when the process starts, and changing the environment from Rust
|
||||
//! needs `unsafe` (and comes too late), so [`apply`] re-executes the binary with the variable set:
|
||||
//! the same process (PID), arguments, `argv[0]` and environment, on Linux with glibc only. A value
|
||||
//! the user or a package already set, for this or another malloc tunable, is left alone, so setting
|
||||
//! `MALLOC_TRIM_THRESHOLD_` to anything opts out. Programs PhotoCraft starts inherit the variable.
|
||||
|
||||
use std::ffi::OsString;
|
||||
|
||||
/// The glibc environment variable [`apply`] sets.
|
||||
pub const TRIM_THRESHOLD_VAR: &str = "MALLOC_TRIM_THRESHOLD_";
|
||||
|
||||
/// Its value: glibc's own default trim threshold (128 KiB), now fixed instead of dynamic.
|
||||
pub const TRIM_THRESHOLD: &str = "131072";
|
||||
|
||||
/// glibc malloc variables that mean someone tuned malloc themselves.
|
||||
const MALLOC_VARS: [&str; 5] = [TRIM_THRESHOLD_VAR, "MALLOC_MMAP_THRESHOLD_", "MALLOC_TOP_PAD_", "MALLOC_MMAP_MAX_", "MALLOC_ARENA_MAX"];
|
||||
|
||||
/// Whether a start-up should re-execute with [`TRIM_THRESHOLD_VAR`] set, given the environment:
|
||||
/// only when no malloc variable or `glibc.malloc.*` tunable is set (which also ends the re-exec).
|
||||
pub fn needs_restart(var: impl Fn(&str) -> Option<OsString>) -> bool {
|
||||
if MALLOC_VARS.iter().any(|v| var(v).is_some()) {
|
||||
return false;
|
||||
}
|
||||
!var("GLIBC_TUNABLES").is_some_and(|t| t.to_string_lossy().contains("glibc.malloc."))
|
||||
}
|
||||
|
||||
/// Re-execute this binary with [`TRIM_THRESHOLD_VAR`] set when [`needs_restart`] says so. Call it
|
||||
/// first in `main`, before any thread starts. It returns only when nothing needed to change or the
|
||||
/// re-exec failed, and then the process carries on with glibc's defaults.
|
||||
#[cfg(all(target_os = "linux", target_env = "gnu"))]
|
||||
pub fn apply() {
|
||||
use std::os::unix::process::CommandExt;
|
||||
if !needs_restart(|k| std::env::var_os(k)) {
|
||||
return;
|
||||
}
|
||||
let Ok(exe) = std::env::current_exe() else { return };
|
||||
let mut args = std::env::args_os();
|
||||
let mut cmd = std::process::Command::new(exe);
|
||||
if let Some(argv0) = args.next() {
|
||||
cmd.arg0(argv0);
|
||||
}
|
||||
// `exec` replaces the process and only returns on failure.
|
||||
let err = cmd.args(args).env(TRIM_THRESHOLD_VAR, TRIM_THRESHOLD).exec();
|
||||
eprintln!("PhotoCraft: could not restart with {TRIM_THRESHOLD_VAR} set ({err}); memory of closed documents may not be returned to the system");
|
||||
}
|
||||
|
||||
/// Nothing to tune off glibc.
|
||||
#[cfg(not(all(target_os = "linux", target_env = "gnu")))]
|
||||
pub fn apply() {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn env<'a>(vars: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<OsString> + 'a {
|
||||
move |k| vars.iter().find(|(n, _)| *n == k).map(|(_, v)| OsString::from(v))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restarts_only_without_malloc_tuning() {
|
||||
assert!(needs_restart(env(&[])));
|
||||
assert!(needs_restart(env(&[("HOME", "/home/a"), ("GLIBC_TUNABLES", "glibc.cpu.hwcaps=-AVX2")])));
|
||||
assert!(!needs_restart(env(&[(TRIM_THRESHOLD_VAR, TRIM_THRESHOLD)])), "the re-executed process stops");
|
||||
assert!(!needs_restart(env(&[(TRIM_THRESHOLD_VAR, "")])), "any value opts out");
|
||||
assert!(!needs_restart(env(&[("MALLOC_MMAP_THRESHOLD_", "1048576")])));
|
||||
assert!(!needs_restart(env(&[("MALLOC_ARENA_MAX", "2")])));
|
||||
assert!(!needs_restart(env(&[("GLIBC_TUNABLES", "glibc.malloc.trim_threshold=0")])));
|
||||
}
|
||||
}
|
||||
@@ -115,6 +115,7 @@ pub fn adjust_surface(s: &mut Surface, adj: &Adjustment, selection: Option<&Surf
|
||||
let tr = tc.rect().intersect(&r);
|
||||
let out = adjusted(&one, tr, adj, selection, mode);
|
||||
one.write_region(tr, &out);
|
||||
photocraft_raster::scratch::give(out);
|
||||
one.take_tiles(tc.rect())
|
||||
})
|
||||
.collect();
|
||||
@@ -184,6 +185,7 @@ pub(crate) fn adjust_mask_with_transfer(
|
||||
for t in &tiles {
|
||||
let out = adjusted_with_transfer(s, *t, adj, Some(sel), mode, transfer);
|
||||
s.write_region(*t, &out);
|
||||
photocraft_raster::scratch::give(out);
|
||||
}
|
||||
if !tiles.is_empty() {
|
||||
s.prune();
|
||||
@@ -197,7 +199,9 @@ pub(crate) fn adjust_mask_with_transfer(
|
||||
// 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_with_transfer(s, r, adj, None, mode, transfer));
|
||||
let data = adjusted_with_transfer(s, r, adj, None, mode, transfer);
|
||||
out.write_region(r, &data);
|
||||
photocraft_raster::scratch::give(data);
|
||||
}
|
||||
out.prune();
|
||||
*s = out;
|
||||
@@ -233,7 +237,10 @@ fn adjusted_with_transfer(
|
||||
}
|
||||
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 mut px = photocraft_raster::scratch::take_cap(raw.len() / n.max(1));
|
||||
px.extend(raw.chunks_exact(n).map(|p| to_rgba(&fmt, p)));
|
||||
photocraft_raster::scratch::give(raw);
|
||||
let mut buf = Buffer { rect: r, px };
|
||||
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
|
||||
@@ -311,7 +318,7 @@ pub(crate) fn mix_through_selection(
|
||||
selection: Option<&Surface>,
|
||||
) -> Vec<f32> {
|
||||
let w = (r.width() as usize).max(1);
|
||||
let mut out = Vec::with_capacity(orig.len() * fmt.channels());
|
||||
let mut out = photocraft_raster::scratch::take_cap(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];
|
||||
|
||||
@@ -239,7 +239,9 @@ fn invert(base: Option<&Surface>, canvas: Rect) -> Option<Surface> {
|
||||
if r.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let inverted: Vec<f32> = b.read_region(r).chunks_exact(ch).map(|px| 1.0 - px.first().copied().unwrap_or(0.0).clamp(0.0, 1.0)).collect();
|
||||
let read = b.read_region(r);
|
||||
let inverted: Vec<f32> = read.chunks_exact(ch).map(|px| 1.0 - px.first().copied().unwrap_or(0.0).clamp(0.0, 1.0)).collect();
|
||||
photocraft_raster::scratch::give(read);
|
||||
s.write_region(r, &inverted);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -814,7 +814,7 @@ fn fill_layer_pixel_rewrites_refresh_the_effect_maps() {
|
||||
.unwrap();
|
||||
let render = |s: &Session| {
|
||||
let d = &s.active().unwrap().doc;
|
||||
photocraft_compose::render(d, d.bounds()).px
|
||||
photocraft_compose::render(d, d.bounds()).into_px()
|
||||
};
|
||||
// Give the layer the cached-pixels state a PSD import produces (the cache is what Image
|
||||
// › Transform & friends rewrite in place, without touching the Fill spec).
|
||||
@@ -901,7 +901,7 @@ fn group_effect_maps_follow_child_visibility_and_opacity() {
|
||||
.unwrap();
|
||||
let render = |s: &Session| {
|
||||
let d = &s.active().unwrap().doc;
|
||||
photocraft_compose::render(d, d.bounds()).px
|
||||
photocraft_compose::render(d, d.bounds()).into_px()
|
||||
};
|
||||
let mut prev = render(&s); // populate the effect-map cache
|
||||
type Edit = (&'static str, fn(&mut photocraft_doc::Layer));
|
||||
|
||||
@@ -24,7 +24,7 @@ fn fixture(depth: u32) -> Session {
|
||||
|
||||
/// Compare the actual flattened document rather than just the imported parameters.
|
||||
fn pixels(s: &Session) -> Vec<[f32; 4]> {
|
||||
photocraft_compose::flatten(&s.active().unwrap().doc).px
|
||||
photocraft_compose::flatten(&s.active().unwrap().doc).into_px()
|
||||
}
|
||||
|
||||
/// Fractions remain valid native edits; strict integer ATN conversion must refuse them.
|
||||
|
||||
@@ -24,7 +24,7 @@ fn fixture(depth: u32) -> Session {
|
||||
|
||||
/// Flatten the active document to compare direct, native and ATN playback pixels.
|
||||
fn pixels(s: &Session) -> Vec<[f32; 4]> {
|
||||
photocraft_compose::flatten(&s.active().unwrap().doc).px
|
||||
photocraft_compose::flatten(&s.active().unwrap().doc).into_px()
|
||||
}
|
||||
|
||||
/// Fractions are valid native edits, but the existing integer event cannot carry them.
|
||||
|
||||
@@ -37,7 +37,7 @@ fn doc_bounds() -> photocraft_doc::Rect {
|
||||
}
|
||||
|
||||
fn pixels(s: &Session) -> Vec<[f32; 4]> {
|
||||
photocraft_compose::flatten(&s.active().unwrap().doc).px
|
||||
photocraft_compose::flatten(&s.active().unwrap().doc).into_px()
|
||||
}
|
||||
|
||||
fn check_cases(id: &str, cases: &[Value], require_effect: bool) {
|
||||
|
||||
@@ -23,7 +23,7 @@ fn fixture(depth: u32) -> Session {
|
||||
|
||||
/// Compare the flattened document, including alpha, rather than only imported JSON.
|
||||
fn pixels(s: &Session) -> Vec<[f32; 4]> {
|
||||
photocraft_compose::flatten(&s.active().unwrap().doc).px
|
||||
photocraft_compose::flatten(&s.active().unwrap().doc).into_px()
|
||||
}
|
||||
|
||||
/// Direct execution, native playback and strict binary ATN playback must agree exactly.
|
||||
|
||||
@@ -124,7 +124,7 @@ fn photoshop_oracles_rerendered() {
|
||||
}
|
||||
};
|
||||
let Ok(merged) = photocraft_io::merged_composite(&file) else { continue };
|
||||
let ours = photocraft_compose::flatten(&doc).px;
|
||||
let ours = photocraft_compose::flatten(&doc).into_px();
|
||||
let (m, bad) = max_diff(&ours, &merged);
|
||||
let group = name.split(['/', '\\']).next().unwrap_or(".").to_string();
|
||||
let g = groups.entry(group).or_default();
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
//! One integration-test process keeps the process-wide scratch pool isolated from parallel unit
|
||||
//! tests (#10479).
|
||||
use photocraft_engine::Session;
|
||||
use photocraft_engine::jobs::Started;
|
||||
use photocraft_raster::scratch;
|
||||
use serde_json::json;
|
||||
use std::sync::{Mutex, PoisonError};
|
||||
|
||||
static SERIAL: Mutex<()> = Mutex::new(());
|
||||
|
||||
/// Holds the test lock with the pool's idle release off, so a slow runner can't empty the pool
|
||||
/// between a command and the check after it; the release comes back on when dropped.
|
||||
struct Quiet {
|
||||
_serial: std::sync::MutexGuard<'static, ()>,
|
||||
}
|
||||
|
||||
impl Quiet {
|
||||
/// Locks the test process and disables idle pool release for this guard's lifetime.
|
||||
fn new() -> Self {
|
||||
let _serial = SERIAL.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
scratch::set_idle_release(false);
|
||||
Quiet { _serial }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Quiet {
|
||||
fn drop(&mut self) {
|
||||
scratch::set_idle_release(true);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Verifies closing one or all documents releases their scratch buffers.
|
||||
fn closing_a_document_leaves_none_of_its_buffers_in_the_pool() {
|
||||
let _q = Quiet::new();
|
||||
for close in ["file.close", "file.closeAll"] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 1024, "height": 1024})).unwrap();
|
||||
s.execute("edit.fill", json!({"contents": "color", "color": "#808080"})).unwrap();
|
||||
s.execute("filter.blur.gaussianBlur", json!({"radius": 3})).unwrap();
|
||||
// Dispatch holds the active document across the command; its tiles must not land in
|
||||
// the pool after the release.
|
||||
s.execute(close, json!({})).unwrap();
|
||||
assert_eq!(scratch::retained_bytes(), 0, "{close}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Verifies a command that keeps its document does not release unrelated pooled buffers.
|
||||
fn commands_that_keep_their_documents_keep_the_pool() {
|
||||
let _q = Quiet::new();
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 1024, "height": 1024})).unwrap();
|
||||
scratch::give(vec![0u8; scratch::MIN_POOLED_BYTES]);
|
||||
s.execute("edit.fill", json!({"contents": "color", "color": "#808080"})).unwrap();
|
||||
assert!(scratch::retained_bytes() > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Verifies a cancelled worker releases its document buffers after it exits.
|
||||
fn a_cancelled_job_of_a_closed_document_returns_its_buffers_once_it_exits() {
|
||||
let _q = Quiet::new();
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 2048, "height": 2048})).unwrap();
|
||||
s.execute("edit.fill", json!({"contents": "color", "color": "#808080"})).unwrap();
|
||||
let Started::Job(_) = s.start("filter.blur.gaussianBlur", json!({"radius": 60})).unwrap() else {
|
||||
panic!("Gaussian Blur runs as a background job");
|
||||
};
|
||||
// Closing cancels the job, but its worker still holds the document until it exits.
|
||||
s.close(0);
|
||||
scratch::give(vec![0u8; scratch::MIN_POOLED_BYTES]);
|
||||
s.join_cancelled_jobs();
|
||||
assert_eq!(scratch::retained_bytes(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Verifies cancelling a job before document close still releases its buffers.
|
||||
fn a_job_cancelled_before_its_document_closes_still_returns_its_buffers() {
|
||||
let _q = Quiet::new();
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 2048, "height": 2048})).unwrap();
|
||||
s.execute("edit.fill", json!({"contents": "color", "color": "#808080"})).unwrap();
|
||||
let Started::Job(id) = s.start("filter.blur.gaussianBlur", json!({"radius": 60})).unwrap() else {
|
||||
panic!("Gaussian Blur runs as a background job");
|
||||
};
|
||||
// Cancelled first: the worker is already off the running list when the document closes.
|
||||
assert!(s.cancel_job(id));
|
||||
s.close(0);
|
||||
scratch::give(vec![0u8; scratch::MIN_POOLED_BYTES]);
|
||||
s.join_cancelled_jobs();
|
||||
assert_eq!(scratch::retained_bytes(), 0);
|
||||
}
|
||||
|
||||
/// Verifies polling alone releases buffers after a closed document's cancelled worker exits.
|
||||
#[test]
|
||||
fn a_cancelled_job_of_a_closed_document_is_released_by_polling() {
|
||||
let _q = Quiet::new();
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 2048, "height": 2048})).unwrap();
|
||||
s.execute("edit.fill", json!({"contents": "color", "color": "#808080"})).unwrap();
|
||||
let Started::Job(_) = s.start("filter.blur.gaussianBlur", json!({"radius": 60})).unwrap() else {
|
||||
panic!("Gaussian Blur runs as a background job");
|
||||
};
|
||||
s.close(0);
|
||||
// Seed after close so the close path cannot satisfy the assertion.
|
||||
scratch::give(vec![0u8; scratch::MIN_POOLED_BYTES]);
|
||||
assert!(scratch::retained_bytes() > 0);
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
|
||||
while scratch::retained_bytes() > 0 && std::time::Instant::now() < deadline {
|
||||
s.poll_jobs();
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
assert_eq!(scratch::retained_bytes(), 0);
|
||||
}
|
||||
+124
-9
@@ -30,8 +30,87 @@ fn blob_path(h: &str) -> String {
|
||||
format!("blobs/{h}.zst")
|
||||
}
|
||||
|
||||
fn compress(data: &[u8]) -> Vec<u8> {
|
||||
ruzstd::encoding::compress_to_vec(data, ruzstd::encoding::CompressionLevel::Fastest)
|
||||
/// The bytes an [`Object`] stores, owned by (or shared with) the compressor's source so one
|
||||
/// [`Compressor`] can be reused for objects of any lifetime.
|
||||
enum Source {
|
||||
Tile(Arc<Tile>),
|
||||
Blob(Arc<Vec<u8>>),
|
||||
/// A little-endian copy of a tile (big-endian hosts only).
|
||||
Owned(Vec<u8>),
|
||||
}
|
||||
|
||||
struct SourceReader {
|
||||
source: Source,
|
||||
pos: usize,
|
||||
}
|
||||
|
||||
impl SourceReader {
|
||||
fn all(&self) -> &[u8] {
|
||||
match &self.source {
|
||||
Source::Tile(t) => t.bytes(),
|
||||
Source::Blob(b) => b.as_slice(),
|
||||
Source::Owned(v) => v.as_slice(),
|
||||
}
|
||||
}
|
||||
|
||||
fn len(&self) -> usize {
|
||||
self.all().len()
|
||||
}
|
||||
}
|
||||
|
||||
impl Read for SourceReader {
|
||||
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||
let all = self.all();
|
||||
let rest = all.get(self.pos..).unwrap_or(&[]);
|
||||
let n = rest.len().min(buf.len());
|
||||
if let (Some(dst), Some(src)) = (buf.get_mut(..n), rest.get(..n)) {
|
||||
dst.copy_from_slice(src);
|
||||
}
|
||||
self.pos += n;
|
||||
Ok(n)
|
||||
}
|
||||
}
|
||||
|
||||
/// A reusable zstd compressor. `ruzstd` allocates a block buffer, a ~1 MiB suffix table and a
|
||||
/// block output buffer per frame; keeping the [`FrameCompressor`](ruzstd::encoding::FrameCompressor)
|
||||
/// between objects reuses the first two (its matcher resets itself on every `compress`).
|
||||
/// Output is byte-identical to `compress_to_vec` at the same level.
|
||||
struct Compressor {
|
||||
inner: ruzstd::encoding::FrameCompressor<SourceReader, Vec<u8>, ruzstd::encoding::MatchGeneratorDriver>,
|
||||
}
|
||||
|
||||
impl Compressor {
|
||||
fn new() -> Self {
|
||||
Self { inner: ruzstd::encoding::FrameCompressor::new(ruzstd::encoding::CompressionLevel::Fastest) }
|
||||
}
|
||||
|
||||
fn run(&mut self, source: Source) -> Vec<u8> {
|
||||
let reader = SourceReader { source, pos: 0 };
|
||||
// Room for the worst case (raw blocks: 3 bytes per 128 KiB, plus frame header and
|
||||
// checksum), so the output never regrows; shrunk afterwards to what it used. Untouched
|
||||
// pages cost nothing.
|
||||
let len = reader.len();
|
||||
let mut drain = Vec::new();
|
||||
if drain.try_reserve_exact(len.saturating_add(len / 16384).saturating_add(64)).is_err() {
|
||||
drain = Vec::new();
|
||||
}
|
||||
self.inner.set_source(reader);
|
||||
self.inner.set_drain(drain);
|
||||
self.inner.compress();
|
||||
self.inner.take_source();
|
||||
let mut out = self.inner.take_drain().unwrap_or_default();
|
||||
out.shrink_to_fit();
|
||||
out
|
||||
}
|
||||
|
||||
fn object(&mut self, obj: &Object) -> Vec<u8> {
|
||||
let source = match obj {
|
||||
Object::Tile(tile, _) if cfg!(target_endian = "little") => Source::Tile(Arc::clone(tile)),
|
||||
Object::Tile(tile, sample) => Source::Owned(tile_le(tile, *sample).into_owned()),
|
||||
Object::Blob(blob) => Source::Blob(Arc::clone(blob)),
|
||||
};
|
||||
self.run(source)
|
||||
}
|
||||
}
|
||||
|
||||
/// Decodes a zstd frame of at most `max` bytes (`LimitExceeded` beyond). With `exact`, the
|
||||
@@ -528,8 +607,8 @@ enum Object {
|
||||
}
|
||||
|
||||
impl Object {
|
||||
fn compressed(&self) -> Vec<u8> {
|
||||
compress(&self.content())
|
||||
fn compressed(&self, compressor: &mut Compressor) -> Vec<u8> {
|
||||
compressor.object(self)
|
||||
}
|
||||
|
||||
/// The bytes this object stores (tiles little-endian).
|
||||
@@ -860,7 +939,8 @@ type Compressed<'a> = (&'a String, Arc<Vec<u8>>);
|
||||
fn par_compress<'a>(objects: &[(&'a String, &'a Object)]) -> Result<Vec<Compressed<'a>>> {
|
||||
let threads = if cfg!(target_arch = "wasm32") { 1 } else { std::thread::available_parallelism().map_or(1, |n| n.get()).clamp(1, 32) };
|
||||
if threads < 2 || objects.len() < 2 {
|
||||
return Ok(objects.iter().map(|(p, o)| (*p, Arc::new(o.compressed()))).collect());
|
||||
let mut compressor = Compressor::new();
|
||||
return Ok(objects.iter().map(|(p, o)| (*p, Arc::new(o.compressed(&mut compressor)))).collect());
|
||||
}
|
||||
let next = std::sync::atomic::AtomicUsize::new(0);
|
||||
std::thread::scope(|sc| {
|
||||
@@ -868,10 +948,11 @@ fn par_compress<'a>(objects: &[(&'a String, &'a Object)]) -> Result<Vec<Compress
|
||||
.map(|_| {
|
||||
sc.spawn(|| {
|
||||
let mut out = Vec::new();
|
||||
let mut compressor = Compressor::new();
|
||||
loop {
|
||||
let i = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let Some((p, o)) = objects.get(i) else { break };
|
||||
out.push((*p, Arc::new(o.compressed())));
|
||||
out.push((*p, Arc::new(o.compressed(&mut compressor))));
|
||||
}
|
||||
out
|
||||
})
|
||||
@@ -976,6 +1057,7 @@ impl PcraftWriter {
|
||||
for (name, data) in &previews {
|
||||
z.add(name, data)?;
|
||||
}
|
||||
let mut compressor: Option<Compressor> = None;
|
||||
for (path, obj) in &p.objects {
|
||||
let (key, data) = match self.compressed.remove_entry(path) {
|
||||
Some((key, data)) => {
|
||||
@@ -989,7 +1071,7 @@ impl PcraftWriter {
|
||||
Object::Tile(..) => stats.tiles_written += 1,
|
||||
Object::Blob(_) => stats.blobs_written += 1,
|
||||
}
|
||||
(None, fresh.remove(path).unwrap_or_else(|| Arc::new(obj.compressed())))
|
||||
(None, fresh.remove(path).unwrap_or_else(|| Arc::new(obj.compressed(compressor.get_or_insert_with(Compressor::new)))))
|
||||
}
|
||||
};
|
||||
z.add(path, &data)?;
|
||||
@@ -1036,6 +1118,7 @@ impl PcraftWriter {
|
||||
None => 0,
|
||||
};
|
||||
let recheck = self.rolling_recheck(&p.objects);
|
||||
let mut compressor: Option<Compressor> = None;
|
||||
for (path, obj) in &p.objects {
|
||||
let object_path = dir.join(path);
|
||||
// bundle_root and list_objects validated the fixed parent directories once. Check
|
||||
@@ -1070,7 +1153,7 @@ impl PcraftWriter {
|
||||
Object::Tile(..) => stats.tiles_written += 1,
|
||||
Object::Blob(_) => stats.blobs_written += 1,
|
||||
}
|
||||
write_bundle_file(dir, &object_path, &obj.compressed(), fs)?;
|
||||
write_bundle_file(dir, &object_path, &obj.compressed(compressor.get_or_insert_with(Compressor::new)), fs)?;
|
||||
let written = path_signature(&object_path)?;
|
||||
if let Some(cache) = &mut self.verified_directory {
|
||||
match written {
|
||||
@@ -1421,6 +1504,38 @@ pub(crate) fn write_atomic(path: &Path, data: &[u8]) -> Result<()> {
|
||||
#[cfg(all(test, windows))]
|
||||
mod durability_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[test]
|
||||
fn reused_compressor_matches_compress_to_vec_and_round_trips() {
|
||||
let mut state = 0x9e37_79b9_u32;
|
||||
let mut noise = |n: usize| -> Vec<u8> {
|
||||
(0..n)
|
||||
.map(|_| {
|
||||
state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
|
||||
(state >> 24) as u8
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let inputs: Vec<Vec<u8>> = vec![
|
||||
Vec::new(),
|
||||
vec![7],
|
||||
b"hello hello hello hello hello".to_vec(),
|
||||
noise(1000),
|
||||
(0..300_000u32).map(|i| (i / 7 % 251) as u8).collect(),
|
||||
noise(300_000),
|
||||
vec![0; 262_144],
|
||||
// A repeat after a big one proves the matcher state is reset between objects.
|
||||
b"hello hello hello hello hello".to_vec(),
|
||||
];
|
||||
let mut compressor = Compressor::new();
|
||||
for data in inputs {
|
||||
let expected = ruzstd::encoding::compress_to_vec(data.as_slice(), ruzstd::encoding::CompressionLevel::Fastest);
|
||||
let got = compressor.object(&Object::Blob(Arc::new(data.clone())));
|
||||
assert_eq!(got, expected, "len {}", data.len());
|
||||
assert_eq!(decompress(&got, data.len(), "test").unwrap(), data);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[test]
|
||||
fn an_incompressible_blob_at_its_limit_still_loads() {
|
||||
@@ -1442,7 +1557,7 @@ fn an_incompressible_blob_at_its_limit_still_loads() {
|
||||
x as u8
|
||||
})
|
||||
.collect();
|
||||
let z = compress(&data);
|
||||
let z = Compressor::new().object(&Object::Blob(Arc::new(data.clone())));
|
||||
assert!(z.len() > data.len(), "the fixture must not compress");
|
||||
let hash = hash_bytes(&data);
|
||||
let src = One(blob_path(&hash), z);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use super::batch::write_tile;
|
||||
use super::textures::{Resident, TexKind};
|
||||
use super::{Arc, F, HashMap, I, PixelFormat, Rect, SampleType, Surface, TILE_SIZE, Tile, TileCoord, U, fx};
|
||||
use photocraft_raster::scratch;
|
||||
|
||||
/// Distinct tiles converted per parallel batch: bounds the converted bytes held at once (64 tiles
|
||||
/// of a 16-bit page are 32 MiB).
|
||||
@@ -141,6 +142,8 @@ pub(crate) fn convert_tiles(
|
||||
_ => put(t, cs, &bytes.concat()),
|
||||
}
|
||||
}
|
||||
// The texels are on their way to the GPU: the buffers serve the next batch.
|
||||
parts.into_iter().for_each(scratch::give);
|
||||
}
|
||||
groups.len()
|
||||
}
|
||||
@@ -157,7 +160,9 @@ pub(crate) fn colour_managed(fmt: &PixelFormat, kind: TexKind) -> bool {
|
||||
/// Tile pixels in the texture's format. `tile = None` gives the default pixel everywhere.
|
||||
pub(crate) fn convert_tile(surface: &Surface, tile: Option<&Arc<Tile>>, kind: TexKind, c: TileCoord) -> Vec<u8> {
|
||||
if let (Some(t), TexKind::Rgba8Direct | TexKind::R8Direct) = (tile, kind) {
|
||||
return t.bytes().to_vec();
|
||||
let mut out = scratch::take_cap(t.bytes().len());
|
||||
out.extend_from_slice(t.bytes());
|
||||
return out;
|
||||
}
|
||||
let fmt = surface.format();
|
||||
if let (Some(t), TexKind::Rgba16F) = (tile, kind)
|
||||
@@ -179,7 +184,7 @@ fn convert_rows(surface: &Surface, tile: Option<&Arc<Tile>>, kind: TexKind, rect
|
||||
Some(_) => surface.read_region(rect),
|
||||
None => surface.default_pixel().repeat(n),
|
||||
};
|
||||
let mut out = Vec::with_capacity(n * kind.bytes_per_pixel());
|
||||
let mut out = scratch::take_cap(n * kind.bytes_per_pixel());
|
||||
// The previous pixel and its colour.
|
||||
let mut last: Option<(usize, [f32; 4])> = None;
|
||||
for (i, px) in raw.chunks_exact(ch).enumerate() {
|
||||
@@ -209,6 +214,7 @@ fn convert_rows(surface: &Surface, tile: Option<&Arc<Tile>>, kind: TexKind, rect
|
||||
TexKind::R32F => out.extend(px[0].to_le_bytes()),
|
||||
}
|
||||
}
|
||||
scratch::give(raw);
|
||||
out
|
||||
}
|
||||
|
||||
@@ -229,7 +235,7 @@ pub(crate) fn rgb_gray16_to_f16(fmt: &PixelFormat, bytes: &[u8]) -> Option<Vec<u
|
||||
let half = HALF.get_or_init(|| (0..=u16::MAX).map(|v| f32_to_f16(v as f32 / 65535.0)).collect());
|
||||
let one = f32_to_f16(1.0).to_le_bytes();
|
||||
let ch = fmt.channels();
|
||||
let mut out = Vec::with_capacity((TILE_SIZE * TILE_SIZE) as usize * 8);
|
||||
let mut out = scratch::take_cap((TILE_SIZE * TILE_SIZE) as usize * 8);
|
||||
for px in bytes.chunks_exact(ch * 2) {
|
||||
let h = |i: usize| -> [u8; 2] {
|
||||
let v = px.get(i * 2..i * 2 + 2).map_or(0, |b| u16::from_ne_bytes([b[0], b[1]]));
|
||||
@@ -261,7 +267,7 @@ pub(crate) fn rgb_gray32_to_f16(fmt: &PixelFormat, bytes: &[u8]) -> Option<Vec<u
|
||||
};
|
||||
let one = f32_to_f16(1.0).to_le_bytes();
|
||||
let ch = fmt.channels();
|
||||
let mut out = Vec::with_capacity((TILE_SIZE * TILE_SIZE) as usize * 8);
|
||||
let mut out = scratch::take_cap((TILE_SIZE * TILE_SIZE) as usize * 8);
|
||||
for px in bytes.chunks_exact(ch * 4) {
|
||||
let h = |i: usize| -> [u8; 2] {
|
||||
let v = px.get(i * 4..i * 4 + 4).map_or(0.0, |b| f32::from_ne_bytes([b[0], b[1], b[2], b[3]]));
|
||||
|
||||
+10
-2
@@ -427,12 +427,20 @@ fn fields_in_windows<const N: usize>(
|
||||
})
|
||||
}
|
||||
|
||||
/// The layer's shape (`compose::layer_shape`) over `r`, in parallel bands.
|
||||
/// The layer's shape (`compose::layer_shape`) over `r`, in parallel bands. Callers hand the
|
||||
/// result back with `photocraft_raster::scratch::give` once they have read it.
|
||||
pub(crate) fn shape(doc: &photocraft_doc::Document, layer: &Layer, r: Rect) -> Vec<f32> {
|
||||
if r.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
par_map(bands(r, BAND), |band| photocraft_compose::layer_shape(doc, layer, band)).concat()
|
||||
let parts = par_map(bands(r, BAND), |band| photocraft_compose::layer_shape(doc, layer, band));
|
||||
let mut shape = photocraft_raster::scratch::take_cap(parts.iter().map(Vec::len).sum());
|
||||
// A band can be a pooled mask (Transparency Shapes Layer off): hand each back once copied.
|
||||
for part in parts {
|
||||
shape.extend_from_slice(&part);
|
||||
photocraft_raster::scratch::give(part);
|
||||
}
|
||||
shape
|
||||
}
|
||||
|
||||
/// Write `v` (row-major over `r`) into the region-sized `shape`.
|
||||
|
||||
@@ -60,6 +60,7 @@ impl Compositor {
|
||||
e.region = region;
|
||||
rebuild = false;
|
||||
}
|
||||
photocraft_raster::scratch::give(v);
|
||||
}
|
||||
if rebuild {
|
||||
let shape = Tex::map(device, "pc_fx_shape", region, MAP32);
|
||||
@@ -108,6 +109,7 @@ impl Compositor {
|
||||
fx::paste(&mut e.shape_cpu, region, damage, &v);
|
||||
e.shape.write_r32(queue, region, damage, &v);
|
||||
staged += v.len() as u64 * 4;
|
||||
photocraft_raster::scratch::give(v);
|
||||
}
|
||||
|
||||
// Programs, and the distance fields they read (max reach per field).
|
||||
|
||||
@@ -957,7 +957,7 @@ fn hard_stop_gradients_match_cpu_or_fall_back() {
|
||||
Ok(out) => out,
|
||||
Err(e) => {
|
||||
assert!(e.0.contains("coincident gradient stops"), "{e}");
|
||||
photocraft_compose::render(&d, d.bounds()).px
|
||||
photocraft_compose::render(&d, d.bounds()).into_px()
|
||||
}
|
||||
};
|
||||
let worst = worst_diff(&cpu.px, &out);
|
||||
|
||||
@@ -34,7 +34,7 @@ fn run_file(path: &str) {
|
||||
println!("mode {:?} depth {:?} layers {} warnings {:?} light {:?}", doc.mode, doc.depth, doc.layer_count(), imp.warnings, doc.global_light);
|
||||
print_layer_details(doc);
|
||||
dump_filter_effects(&file);
|
||||
let ours = photocraft_compose::flatten(doc).px;
|
||||
let ours = photocraft_compose::flatten(doc).into_px();
|
||||
let merged = photocraft_io::merged_composite(&file).unwrap();
|
||||
compare_modes(doc, &ours, &merged);
|
||||
}
|
||||
@@ -487,7 +487,7 @@ fn corpus_summary(root: &std::path::Path) {
|
||||
return (name, None);
|
||||
}
|
||||
let Ok(merged) = photocraft_io::merged_composite(&file) else { return (name, None) };
|
||||
let ours = photocraft_compose::flatten(&imp.document).px;
|
||||
let ours = photocraft_compose::flatten(&imp.document).into_px();
|
||||
let mut m = 0.0f32;
|
||||
let mut bad = 0usize;
|
||||
for (a, b) in ours.iter().zip(merged) {
|
||||
|
||||
@@ -452,9 +452,16 @@ pub(crate) fn image_surface(img: &Image, target_layout: ChannelLayout, csample:
|
||||
let band = (BAND_BYTES / row.max(1)).max(1);
|
||||
for (i, rows) in img.data().chunks(row.max(1) * band).enumerate() {
|
||||
let n = (rows.len() / row.max(1)) as u32;
|
||||
let part = Image::from_raw(w, n, img.layout(), img.sample_type(), rows.to_vec())?.convert(target_layout, csample);
|
||||
// Band buffers cycle through the scratch pool: at this size each fresh one is an
|
||||
// mmap that gets page-faulted in.
|
||||
let mut src = photocraft_raster::scratch::take_cap::<u8>(rows.len());
|
||||
src.extend_from_slice(rows);
|
||||
let src = Image::from_raw(w, n, img.layout(), img.sample_type(), src)?;
|
||||
let part = src.convert(target_layout, csample);
|
||||
let y0 = (i * band) as i32;
|
||||
s.write_interleaved(Rect::new(0, y0, w as i32, y0 + n as i32), part.data());
|
||||
photocraft_raster::scratch::give(src.into_data());
|
||||
photocraft_raster::scratch::give(part.into_data());
|
||||
}
|
||||
}
|
||||
Ok(s)
|
||||
|
||||
@@ -765,14 +765,14 @@ pub fn merged_composite(file: &PsdFile) -> Result<Vec<[f32; 4]>, IoError> {
|
||||
let (doc, _) = psd_to_document(&PsdFile { layer_info, ..file.clone() });
|
||||
// Multichannel documents keep their channels apart (no layer): composite them.
|
||||
if doc.layers.is_empty() && doc.mode == photocraft_color::ColorMode::Multichannel {
|
||||
return Ok(photocraft_compose::flatten(&doc).px);
|
||||
return Ok(photocraft_compose::flatten(&doc).into_px());
|
||||
}
|
||||
let l = doc.layers.first().ok_or_else(|| IoError::Unsupported("no merged image".into()))?;
|
||||
let s = l.surface().ok_or_else(|| IoError::Unsupported("no merged image".into()))?;
|
||||
// CMYK reads through the file's own profile, as the compositor reads its layers, so a tagged
|
||||
// file's merged image and its composite are compared in the same colours.
|
||||
let cmyk = photocraft_compose::cmyk_space(&doc);
|
||||
Ok(photocraft_color::convert::with_cmyk_space(cmyk.as_ref(), || photocraft_compose::surface_to_buffer(s, doc.bounds()).px))
|
||||
Ok(photocraft_color::convert::with_cmyk_space(cmyk.as_ref(), || photocraft_compose::surface_to_buffer(s, doc.bounds()).into_px()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -629,7 +629,7 @@ impl Writer<'_> {
|
||||
LayerContent::Raster(s) => {
|
||||
let r = s.content_bounds();
|
||||
admit_layer(r)?;
|
||||
(r, photocraft_compose::surface_to_buffer(s, r).px)
|
||||
(r, photocraft_compose::surface_to_buffer(s, r).into_px())
|
||||
}
|
||||
_ => {
|
||||
// Rendered as it shows on its own, before the layer's blending options.
|
||||
@@ -643,7 +643,7 @@ impl Writer<'_> {
|
||||
solo.effects = Default::default();
|
||||
let r = photocraft_compose::composite_bounds(&solo, canvas).unwrap_or(canvas).intersect(&canvas);
|
||||
admit_layer(r)?;
|
||||
(r, photocraft_compose::render_layer(&solo, r).px)
|
||||
(r, photocraft_compose::render_layer(&solo, r).into_px())
|
||||
}
|
||||
};
|
||||
if let Some(mask) = layer.mask.as_ref().filter(|m| m.enabled) {
|
||||
|
||||
@@ -120,7 +120,9 @@ fn pattern_from_psd(p: &PsdPattern, oom: &mut Option<String>) -> Option<Pattern>
|
||||
crate::pixels::lab16_chroma(&mut bytes, refs.len(), true);
|
||||
}
|
||||
let mut surface = Surface::new(fmt);
|
||||
if let Err(error) = surface.try_write_interleaved(Rect::new(0, 0, w as i32, h as i32), &bytes) {
|
||||
let result = surface.try_write_interleaved(Rect::new(0, 0, w as i32, h as i32), &bytes);
|
||||
photocraft_raster::scratch::give(bytes);
|
||||
if let Err(error) = result {
|
||||
*oom = Some(error.to_string());
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -47,7 +47,8 @@ fn invert_sample(bytes: &mut [u8], s: SampleType) {
|
||||
/// are filled with `fill[c]` (a native-endian encoded sample). Channels whose
|
||||
/// `invert[c]` is set are inverted (PSD stores CMYK as `max - ink`).
|
||||
pub fn interleave(planes: &[Option<&[u8]>], fill: &[Vec<u8>], n: usize, s: SampleType, invert: &[bool]) -> Vec<u8> {
|
||||
let mut out = vec![0u8; n * planes.len() * s.bytes()];
|
||||
// Large results come from (and callers may return them to) the scratch pool.
|
||||
let mut out = photocraft_raster::scratch::take(n * planes.len() * s.bytes(), 0u8);
|
||||
interleave_into(planes, fill, n, s, invert, &mut out);
|
||||
out
|
||||
}
|
||||
@@ -69,9 +70,7 @@ pub fn try_interleave(planes: &[Option<&[u8]>], fill: &[Vec<u8>], n: usize, s: S
|
||||
return Err(PsdError::Invalid("interleave fill has the wrong sample size".into()));
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
out.try_reserve_exact(len).map_err(|_| PsdError::OutOfMemory("not enough memory to interleave PSD pixels"))?;
|
||||
out.resize(len, 0);
|
||||
let mut out = photocraft_raster::scratch::try_take_exact(len, 0u8).ok_or(PsdError::OutOfMemory("not enough memory to interleave PSD pixels"))?;
|
||||
interleave_into(planes, fill, n, s, invert, &mut out);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
@@ -223,6 +223,8 @@ impl Ex {
|
||||
let mut bytes = Vec::with_capacity(row_len * h_of(r) * s.format().bytes_per_pixel());
|
||||
for part in parts {
|
||||
bytes.extend_from_slice(&part);
|
||||
// Free gathered bands before allocating channel planes below; pooling them here
|
||||
// retains another layer-sized allocation throughout deinterleave.
|
||||
}
|
||||
if self.fmt.mode == ColorMode::Lab && self.fmt.sample == SampleType::U16 {
|
||||
crate::pixels::lab16_chroma(&mut bytes, self.cc + 1, false);
|
||||
|
||||
@@ -328,6 +328,12 @@ impl Ctx<'_> {
|
||||
self.allocation_failed(format!("layer \"{name}\": {error}"));
|
||||
return Surface::new(self.fmt);
|
||||
}
|
||||
// The interleaved band goes back to the scratch pool for the next band (and the next
|
||||
// import): it is a fresh page-faulted mmap otherwise. The decoded planes don't: the
|
||||
// decoder allocates its own, so pooled ones would only sit there.
|
||||
drop(refs);
|
||||
photocraft_raster::scratch::give(bytes);
|
||||
drop(planes);
|
||||
}
|
||||
surf.prune();
|
||||
// A white background, a solid fill's cache: one tile per value, shared across layers.
|
||||
@@ -1408,6 +1414,7 @@ fn import_layered(import: DecodedImport<'_>, doc: &mut Document, cx: &mut Ctx<'_
|
||||
if let Err(error) = surface.try_write_interleaved(canvas, &bytes) {
|
||||
return cx.allocation_failed(format!("merged image: {error}"));
|
||||
}
|
||||
photocraft_raster::scratch::give(bytes);
|
||||
if alpha_idx.is_some() {
|
||||
let white = photocraft_raster::from_rgba(&fmt, [1.0, 1.0, 1.0, 1.0]);
|
||||
let mut vals = Vec::new();
|
||||
@@ -1547,6 +1554,7 @@ fn import_extra_channels(import: DecodedImport<'_>, doc: &mut Document, cx: &mut
|
||||
if let Err(error) = s.try_write_interleaved(canvas, &raw) {
|
||||
return cx.allocation_failed(format!("extra channel {idx}: {error}"));
|
||||
}
|
||||
photocraft_raster::scratch::give(raw);
|
||||
s.prune();
|
||||
let name = names.get(k).cloned().unwrap_or_else(|| format!("Alpha {}", k + 1));
|
||||
doc.channels.push(AlphaChannel::new(name, s));
|
||||
|
||||
@@ -173,6 +173,7 @@ pub fn mask_from_item(item: &FilterEffectsItem, sample: SampleType, stack: &Filt
|
||||
let n = w.checked_mul(h).ok_or("the smart filter mask is too large")?;
|
||||
let bytes = crate::pixels::interleave(&[Some(&plane)], &[crate::pixels::zero_sample(sample)], n, sample, &[false]);
|
||||
surface.write_interleaved(GeomRect::new(rect.left, rect.top, rect.right, rect.bottom), &bytes);
|
||||
photocraft_raster::scratch::give(bytes);
|
||||
surface.prune();
|
||||
if surface.content_bounds().is_empty() {
|
||||
return Ok(None);
|
||||
@@ -215,6 +216,7 @@ pub fn unfiltered_from_item(item: &FilterEffectsItem, fmt: PixelFormat) -> Optio
|
||||
}
|
||||
let mut surface = Surface::new(fmt);
|
||||
surface.write_interleaved(GeomRect::new(item.rect.left, item.rect.top, item.rect.right, item.rect.bottom), &bytes);
|
||||
photocraft_raster::scratch::give(bytes);
|
||||
surface.prune();
|
||||
Some(surface)
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ fn psd_merged_image_matches_composite_and_drops_near_opaque_alpha() {
|
||||
let file = document_to_psd(&d);
|
||||
assert_eq!(file.merged_has_alpha(), alpha < 0.99, "alpha {alpha}");
|
||||
let merged = merged_composite(&file).unwrap();
|
||||
let want = photocraft_compose::flatten(&d).px;
|
||||
let want = photocraft_compose::flatten(&d).into_px();
|
||||
let m = merged.iter().zip(&want).flat_map(|(a, b)| (0..4).map(move |i| (a[i] - b[i]).abs())).fold(0.0f32, f32::max);
|
||||
// Un-matting divides the 8-bit rounding error by alpha.
|
||||
assert!(m <= 1.0 / (255.0 * alpha) + 1e-4, "alpha {alpha}: max diff {m}");
|
||||
|
||||
@@ -18,7 +18,7 @@ fn cell_error(mode: BlendMode) -> (f32, f32) {
|
||||
let doc = import("rgb-blend-modes.psd", &bytes).expect("opens").document;
|
||||
let file = PsdFile::from_bytes(&bytes).expect("parses");
|
||||
let merged = merged_composite(&file).expect("merged image");
|
||||
let ours = photocraft_compose::flatten(&doc).px;
|
||||
let ours = photocraft_compose::flatten(&doc).into_px();
|
||||
let width = doc.size.width as i32;
|
||||
let duck = doc.walk().into_iter().map(|(_, _, l)| l).find(|l| l.name == "rubber_duck_PNG1" && l.blend == mode).expect("the mode's duck");
|
||||
let surface = duck.surface().expect("duck pixels");
|
||||
|
||||
@@ -313,7 +313,8 @@ fn rgb_only_export_uses_direct_composite_not_reseparated_cmyk() {
|
||||
let doc = document(SampleType::U16, 0.5);
|
||||
let rgb = photocraft_compose::flatten(&doc);
|
||||
let expected =
|
||||
photocraft_codecs::Image::from_normalized(8, 8, ChannelLayout::Rgba, CodecSample::U16, &rgb.px.into_iter().flatten().collect::<Vec<_>>()).unwrap();
|
||||
photocraft_codecs::Image::from_normalized(8, 8, ChannelLayout::Rgba, CodecSample::U16, &rgb.into_px().into_iter().flatten().collect::<Vec<_>>())
|
||||
.unwrap();
|
||||
|
||||
let result = export(&doc, "png", &ExportOptions::default()).unwrap();
|
||||
|
||||
|
||||
@@ -116,11 +116,11 @@ fn cmyk_fills_survive_psd_roundtrip_at_every_depth() {
|
||||
let mut doc = Document::new("CMYK fill", photocraft_geom::Size::new(9, 6), ColorMode::Cmyk, depth);
|
||||
doc.icc_profile = profile.clone();
|
||||
doc.layers.push(Layer::new("Fill", LayerContent::Fill(fill)));
|
||||
let expected = photocraft_compose::flatten(&doc).px;
|
||||
let expected = photocraft_compose::flatten(&doc).into_px();
|
||||
assert!(max_diff(&expected, &photocraft_compose::render_tiled(&doc, doc.bounds(), 3).px) <= 1e-6);
|
||||
let bytes = export(&doc, "x.psd", &ExportOptions::default()).unwrap().bytes;
|
||||
let back = import("x.psd", &bytes).unwrap().document;
|
||||
let actual = photocraft_compose::flatten(&back).px;
|
||||
let actual = photocraft_compose::flatten(&back).into_px();
|
||||
let diff = max_diff(&expected, &actual);
|
||||
assert!(diff <= 1e-6, "{depth:?} {style:?}: round-trip error {diff}");
|
||||
assert_eq!(back.icc_profile, doc.icc_profile);
|
||||
@@ -144,7 +144,7 @@ fn uncached_cmyk_gradient_corpus_roundtrip() {
|
||||
let bytes = std::fs::read(path).expect("run cargo xtask corpus --all");
|
||||
let doc = import("x.psd", &bytes).unwrap().document;
|
||||
assert!(doc.layers.iter().any(|l| matches!(l.content, photocraft_doc::LayerContent::Fill(_)) && l.fill_cache.is_none()));
|
||||
let expected = photocraft_compose::flatten(&doc).px;
|
||||
let expected = photocraft_compose::flatten(&doc).into_px();
|
||||
let saved = export(&doc, "x.psd", &ExportOptions::default()).unwrap().bytes;
|
||||
let back = import("x.psd", &saved).unwrap().document;
|
||||
let diff = max_diff(&expected, &photocraft_compose::flatten(&back).px);
|
||||
@@ -305,7 +305,7 @@ fn gradient_maps_with_cmyk_lab_gray_stops_import_and_keep_their_space() {
|
||||
_ => assert_eq!((first, last), ([1.0; 3], [0.0; 3])),
|
||||
}
|
||||
// The layer now renders: the composite differs from the background alone.
|
||||
let with_map = photocraft_compose::flatten(&back).px;
|
||||
let with_map = photocraft_compose::flatten(&back).into_px();
|
||||
let mut hidden = back.clone();
|
||||
hidden.layers[1].visible = false;
|
||||
assert!(max_diff(&with_map, &photocraft_compose::flatten(&hidden).px) > 0.01, "{mode:?} {depth:?}");
|
||||
|
||||
@@ -15,7 +15,7 @@ fn check_export_oracle(d: &photocraft_doc::Document, tol: f32) {
|
||||
let f = PsdFile::from_bytes(&r.bytes).unwrap();
|
||||
let merged = merged_composite(&f).unwrap();
|
||||
let d2 = import("x.psd", &r.bytes).unwrap().document;
|
||||
let mut ours = photocraft_compose::flatten(&d2).px;
|
||||
let mut ours = photocraft_compose::flatten(&d2).into_px();
|
||||
if d.mode == ColorMode::Cmyk {
|
||||
// The merged CMYK image is the RGB composite separated through the CMYK profile, which
|
||||
// gamut-maps colours that blend modes push outside CMYK: project ours the same way.
|
||||
@@ -107,7 +107,7 @@ fn builder_oracle(depth: u16, mode: ColorMode) {
|
||||
let f = PsdFile::from_bytes(&bytes).unwrap();
|
||||
let merged = merged_composite(&f).unwrap();
|
||||
let d2 = import("b.psd", &bytes).unwrap().document;
|
||||
let ours = photocraft_compose::flatten(&d2).px;
|
||||
let ours = photocraft_compose::flatten(&d2).into_px();
|
||||
let m = max_diff(&ours, &merged);
|
||||
// Density/feather of masks are not written by the builder; the generated
|
||||
// doc uses them only on a disabled mask, so results must match.
|
||||
@@ -157,7 +157,7 @@ fn hidden_and_clipped_affect_composite() {
|
||||
for l in &mut d2.layers {
|
||||
l.visible = true;
|
||||
}
|
||||
let other = photocraft_compose::flatten(&d2).px;
|
||||
let other = photocraft_compose::flatten(&d2).into_px();
|
||||
assert!(max_diff(&other, &merged) > 0.0, "unhiding a layer must change the composite");
|
||||
}
|
||||
|
||||
|
||||
@@ -316,9 +316,9 @@ fn check_file(name: &str, bytes: &[u8]) -> (Outcome, Option<f32>) {
|
||||
return (Outcome::Error, None);
|
||||
}
|
||||
};
|
||||
let ours = photocraft_compose::flatten(doc).px;
|
||||
let ours = photocraft_compose::flatten(doc).into_px();
|
||||
// Our own export must not change how the document renders.
|
||||
let again = photocraft_compose::flatten(&reimported).px;
|
||||
let again = photocraft_compose::flatten(&reimported).into_px();
|
||||
let rt = if again.len() == ours.len() { common::max_diff(&ours, &again) } else { f32::INFINITY };
|
||||
let layers = doc.layer_count();
|
||||
// Oracle choice: the merged image (Photoshop's composite; for files without layers it is the
|
||||
|
||||
@@ -17,7 +17,7 @@ fn roundtrip(doc: &Document) -> Document {
|
||||
}
|
||||
|
||||
fn max_diff(a: &Document, b: &Document) -> f32 {
|
||||
let (x, y) = (photocraft_compose::flatten(a).px, photocraft_compose::flatten(b).px);
|
||||
let (x, y) = (photocraft_compose::flatten(a).into_px(), photocraft_compose::flatten(b).into_px());
|
||||
assert_eq!(x.len(), y.len());
|
||||
x.iter().zip(&y).flat_map(|(p, q)| (0..4).map(move |c| (p[c] * p[3] - q[c] * q[3]).abs())).fold(0.0, f32::max)
|
||||
}
|
||||
|
||||
@@ -350,7 +350,7 @@ fn layered_to_png_warns_flatten() {
|
||||
assert!(r.warnings.iter().any(|w| w.contains("flattened")), "{:?}", r.warnings);
|
||||
let back = import("out.png", &r.bytes).unwrap().document;
|
||||
// Composite equals our flatten.
|
||||
let flat = photocraft_compose::flatten(&d).px;
|
||||
let flat = photocraft_compose::flatten(&d).into_px();
|
||||
let s = back.layers[0].surface().unwrap();
|
||||
let px: Vec<[f32; 4]> = s.read_region(back.bounds()).as_chunks::<4>().0.iter().map(|p| [p[0], p[1], p[2], p[3]]).collect();
|
||||
assert!(max_diff(&px, &flat) <= 1.0 / 255.0 + 1e-4);
|
||||
|
||||
@@ -60,8 +60,8 @@ fn lab_round_trips_at_every_depth() {
|
||||
let p = back.layers[0].surface().unwrap().pixel(2, 0);
|
||||
let tol = if depth == SampleType::U8 { 0.5 / 255.0 + 1e-6 } else { 2e-5 };
|
||||
assert!((p[1] - A).abs() <= tol && (p[2] - B).abs() <= tol, "{depth:?}: {p:?}");
|
||||
let a = photocraft_compose::flatten(&doc).px;
|
||||
let b = photocraft_compose::flatten(&back).px;
|
||||
let a = photocraft_compose::flatten(&doc).into_px();
|
||||
let b = photocraft_compose::flatten(&back).into_px();
|
||||
for (x, y) in a.iter().zip(&b) {
|
||||
for c in 0..4 {
|
||||
assert!((x[c] - y[c]).abs() <= 1.0 / 255.0, "{depth:?}: {x:?} vs {y:?}");
|
||||
|
||||
@@ -42,7 +42,7 @@ fn check_grid(g: &Grid, doc: &Document, what: &str) {
|
||||
];
|
||||
assert_eq!(census(doc), want, "{what}: [raster, groups, masked, with effects, hidden]");
|
||||
for (x, y) in g.sample_points() {
|
||||
let px = photocraft_compose::render(doc, Rect::from_xywh(x as i32, y as i32, 1, 1)).px;
|
||||
let px = photocraft_compose::render(doc, Rect::from_xywh(x as i32, y as i32, 1, 1)).into_px();
|
||||
let got = px.first().copied().unwrap_or_default();
|
||||
let want = g.expected(x, y);
|
||||
for c in 0..3 {
|
||||
|
||||
@@ -56,7 +56,7 @@ fn alpha_type_channels_import_as_alpha_and_round_trip() {
|
||||
as_ink.channels[1].surface = s;
|
||||
as_ink.channels[1].spot = Some((photocraft_color::Color::BLACK, 0.0));
|
||||
// Within f32 rounding: the inverted copy is re-quantised to the channel depth.
|
||||
let (shown, inked) = (photocraft_compose::flatten(&d).px, photocraft_compose::flatten(&as_ink).px);
|
||||
let (shown, inked) = (photocraft_compose::flatten(&d).into_px(), photocraft_compose::flatten(&as_ink).into_px());
|
||||
assert_eq!(shown.len(), inked.len());
|
||||
for (a, b) in shown.iter().zip(&inked) {
|
||||
assert!(a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-5), "{a:?} vs {b:?} ({depth}-bit)");
|
||||
@@ -115,7 +115,7 @@ fn alpha_type_channels_are_stored_as_is_not_as_ink() {
|
||||
assert_eq!(values(&d.channels[0]), [1.0, 0.0, 1.0, 0.0], "spot channel ({depth}-bit)");
|
||||
|
||||
// The composite is what Photoshop shows: the alpha prints as black ink where unselected.
|
||||
let px = photocraft_compose::flatten(&d).px;
|
||||
let px = photocraft_compose::flatten(&d).into_px();
|
||||
assert_eq!(px.iter().map(|p| [p[0], p[1], p[2]]).collect::<Vec<_>>(), [[0.0, 1.0, 1.0], [1.0, 1.0, 1.0], [0.0, 0.0, 0.0], [0.0, 0.0, 0.0]]);
|
||||
|
||||
// Saved back byte for byte.
|
||||
|
||||
@@ -118,7 +118,7 @@ fn layered_32bit_export_reimports_render_equal() {
|
||||
for mode in [ColorMode::Rgb, ColorMode::Grayscale] {
|
||||
let doc = common::gen_doc(mode, SampleType::F32, Features::ALL);
|
||||
let back = import("t.psd", &export_psd(&doc)).unwrap().document;
|
||||
let (a, b) = (photocraft_compose::flatten(&doc).px, photocraft_compose::flatten(&back).px);
|
||||
let (a, b) = (photocraft_compose::flatten(&doc).into_px(), photocraft_compose::flatten(&back).into_px());
|
||||
assert_eq!(a.len(), b.len());
|
||||
let d = common::max_diff(&a, &b);
|
||||
assert!(d <= 1e-4, "{mode:?}: render differs by {d}");
|
||||
@@ -154,7 +154,7 @@ fn photoshop_32bit_corpus_round_trip() {
|
||||
assert_32bit_layout(&name, &out);
|
||||
let back = import(&name, &out).unwrap().document;
|
||||
assert_eq!(doc.layer_count(), back.layer_count(), "{name}: layer count");
|
||||
let (a, b) = (photocraft_compose::flatten(&doc).px, photocraft_compose::flatten(&back).px);
|
||||
let (a, b) = (photocraft_compose::flatten(&doc).into_px(), photocraft_compose::flatten(&back).into_px());
|
||||
assert_eq!(a.len(), b.len(), "{name}");
|
||||
let d = common::max_diff(&a, &b);
|
||||
assert!(d <= 1e-4, "{name}: render differs by {d}");
|
||||
|
||||
@@ -32,7 +32,7 @@ fn truncated_merged_image_keeps_layer_pixels_at_every_depth_and_compression() {
|
||||
for compression in Compression::ALL {
|
||||
let mut file = layered_in_mode(mode, depth, extension);
|
||||
let intact = import(extension, &file.to_bytes().unwrap()).unwrap();
|
||||
let expected = photocraft_compose::flatten(&intact.document).px;
|
||||
let expected = photocraft_compose::flatten(&intact.document).into_px();
|
||||
let pixels = file.decode_merged().unwrap();
|
||||
file.image_data = photocraft_psd::ImageData::encode(compression, &pixels, &file.header).unwrap();
|
||||
file.image_data.data.truncate(file.image_data.data.len() / 2);
|
||||
|
||||
@@ -283,7 +283,7 @@ fn imported_type_layers_keep_their_full_bounds() {
|
||||
let o = iou(&ours.surface, cache);
|
||||
assert!(o > 0.97, "{}: re-render overlap {o}", c.name);
|
||||
// And the document composite is the file's merged composite.
|
||||
let px = photocraft_compose::flatten(&doc).px;
|
||||
let px = photocraft_compose::flatten(&doc).into_px();
|
||||
let worst = common::max_diff(&px, &merged);
|
||||
assert!(worst <= 2.0 / 255.0, "{}: composite differs from the merged image by {worst}", c.name);
|
||||
}
|
||||
|
||||
@@ -124,8 +124,8 @@ fn corpus_round_trips_through_layered_tiff() {
|
||||
}
|
||||
d
|
||||
};
|
||||
let x = photocraft_compose::flatten(&expected).px;
|
||||
let y = photocraft_compose::flatten(&back).px;
|
||||
let x = photocraft_compose::flatten(&expected).into_px();
|
||||
let y = photocraft_compose::flatten(&back).into_px();
|
||||
let diff = max_diff(&x, &y);
|
||||
if diff > 1.0 / 255.0 + 1e-5 {
|
||||
return Err(format!("render differs by {diff}"));
|
||||
|
||||
@@ -87,8 +87,8 @@ fn tiff_and_psd_open_as_the_same_document() {
|
||||
)
|
||||
.unwrap()
|
||||
.document;
|
||||
let a = photocraft_compose::flatten(&d).px;
|
||||
let b = photocraft_compose::flatten(&flat).px;
|
||||
let a = photocraft_compose::flatten(&d).into_px();
|
||||
let b = photocraft_compose::flatten(&flat).into_px();
|
||||
assert!(max_diff(&a, &b) <= 2.0 / 255.0, "{mode:?} {depth:?}: composite differs by {}", max_diff(&a, &b));
|
||||
}
|
||||
}
|
||||
|
||||
+24
-14
@@ -9,6 +9,7 @@
|
||||
|
||||
mod interrupt;
|
||||
pub use interrupt::{Cancelled, Interrupt};
|
||||
pub mod scratch;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt;
|
||||
@@ -62,18 +63,30 @@ impl TileInterner {
|
||||
}
|
||||
|
||||
/// Pixel storage for one tile: `TILE_SIZE² × bytes_per_pixel`, row-major, interleaved channels.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct Tile {
|
||||
data: Box<[u8]>,
|
||||
}
|
||||
|
||||
impl Clone for Tile {
|
||||
fn clone(&self) -> Self {
|
||||
let mut data = scratch::take_exact(self.data.len(), 0u8);
|
||||
data.copy_from_slice(&self.data);
|
||||
Tile { data: data.into_boxed_slice() }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Tile {
|
||||
fn drop(&mut self) {
|
||||
scratch::give(std::mem::take(&mut self.data).into_vec());
|
||||
}
|
||||
}
|
||||
|
||||
impl Tile {
|
||||
fn try_filled(format: &PixelFormat, pixel: &[u8]) -> Result<Self, AllocationError> {
|
||||
let n = (TILE_SIZE * TILE_SIZE) as usize;
|
||||
let bytes = n.checked_mul(format.bytes_per_pixel()).ok_or(AllocationError::SizeOverflow)?;
|
||||
let mut data = Vec::new();
|
||||
data.try_reserve_exact(bytes).map_err(|_| AllocationError::NotEnoughMemory { bytes })?;
|
||||
data.resize(bytes, 0);
|
||||
let mut data = scratch::try_take_exact(bytes, 0u8).ok_or(AllocationError::NotEnoughMemory { bytes })?;
|
||||
if pixel.iter().any(|&b| b != 0) {
|
||||
for px in data.chunks_exact_mut(pixel.len()) {
|
||||
px.copy_from_slice(pixel);
|
||||
@@ -85,15 +98,14 @@ impl Tile {
|
||||
/// Clone this tile's pixel buffer with a fallible reservation.
|
||||
pub fn try_clone(&self) -> Result<Self, AllocationError> {
|
||||
let bytes = self.data.len();
|
||||
let mut data = Vec::new();
|
||||
data.try_reserve_exact(bytes).map_err(|_| AllocationError::NotEnoughMemory { bytes })?;
|
||||
data.extend_from_slice(&self.data);
|
||||
let mut data = scratch::try_take_exact(bytes, 0u8).ok_or(AllocationError::NotEnoughMemory { bytes })?;
|
||||
data.copy_from_slice(&self.data);
|
||||
Ok(Tile { data: data.into_boxed_slice() })
|
||||
}
|
||||
|
||||
fn filled(format: &PixelFormat, pixel: &[u8]) -> Self {
|
||||
let row_len = TILE_SIZE as usize * format.bytes_per_pixel();
|
||||
let mut data = vec![0u8; row_len * TILE_SIZE as usize];
|
||||
let mut data = scratch::take_exact(row_len * TILE_SIZE as usize, 0u8);
|
||||
if pixel.iter().any(|&b| b != 0) {
|
||||
// One prebuilt row copied per tile row: 256 bulk copies instead of one
|
||||
// copy_from_slice per pixel.
|
||||
@@ -358,9 +370,7 @@ impl Surface {
|
||||
pub fn try_read_region(&self, r: Rect) -> Result<Vec<f32>, AllocationError> {
|
||||
let len = (r.width() as usize).checked_mul(r.height() as usize).and_then(|n| n.checked_mul(self.channels())).ok_or(AllocationError::SizeOverflow)?;
|
||||
let bytes = len.checked_mul(std::mem::size_of::<f32>()).ok_or(AllocationError::SizeOverflow)?;
|
||||
let mut out = Vec::new();
|
||||
out.try_reserve_exact(len).map_err(|_| AllocationError::NotEnoughMemory { bytes })?;
|
||||
out.resize(len, 0.0);
|
||||
let mut out = scratch::try_take_exact(len, 0.0f32).ok_or(AllocationError::NotEnoughMemory { bytes })?;
|
||||
self.read_region_into(r, &mut out);
|
||||
Ok(out)
|
||||
}
|
||||
@@ -761,7 +771,7 @@ impl Surface {
|
||||
pub fn read_region(&self, r: Rect) -> Vec<f32> {
|
||||
let n = self.channels();
|
||||
let w = r.width() as usize;
|
||||
let mut out = vec![0.0f32; w * r.height() as usize * n];
|
||||
let mut out = scratch::take(w * r.height() as usize * n, 0.0f32);
|
||||
self.read_samples_into(r, &mut out);
|
||||
out
|
||||
}
|
||||
@@ -935,7 +945,7 @@ impl Surface {
|
||||
pub fn to_interleaved(&self, r: Rect) -> Vec<u8> {
|
||||
let bpp = self.format.bytes_per_pixel();
|
||||
let w = r.width() as usize;
|
||||
let mut out = vec![0u8; w * r.height() as usize * bpp];
|
||||
let mut out = scratch::take(w * r.height() as usize * bpp, 0u8);
|
||||
for tc in r.tiles() {
|
||||
let tr = tc.rect().intersect(&r);
|
||||
let span = tr.width() as usize * bpp;
|
||||
@@ -1022,7 +1032,7 @@ impl Surface {
|
||||
return hit.clone().map(|t| (dc, t));
|
||||
}
|
||||
|
||||
let mut tile = Tile { data: vec![0u8; (TILE_SIZE * TILE_SIZE) as usize * bpp].into_boxed_slice() };
|
||||
let mut tile = Tile { data: scratch::take_exact((TILE_SIZE * TILE_SIZE) as usize * bpp, 0u8).into_boxed_slice() };
|
||||
let (tx0, ty0) = (i64::from(dc.tx) * i64::from(TILE_SIZE), i64::from(dc.ty) * i64::from(TILE_SIZE));
|
||||
let row_len = TILE_SIZE as usize * bpp;
|
||||
// Per stored row: copy the source span that falls inside the content in as few
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
//! Process-wide, capped pool of large scratch buffers.
|
||||
//!
|
||||
//! With glibc's mmap threshold pinned (`MALLOC_TRIM_THRESHOLD_`, which is how PhotoCraft returns
|
||||
//! memory to the OS), every allocation over 128 KiB is a fresh `mmap` that is page-faulted in and
|
||||
//! `munmap`ed on free. Tiles, compositor bands and filter scratch are all that size, so a single
|
||||
//! viewport render took 350× the page faults. This pool keeps freed buffers (at least
|
||||
//! [`MIN_POOLED_BYTES`] of capacity) on per-type free lists and hands them back out, so the
|
||||
//! steady state allocates nothing.
|
||||
//!
|
||||
//! Retention is capped by [`set_budget`] (default [`DEFAULT_BUDGET`]); [`release`] drops
|
||||
//! everything, which is what returns the memory to the OS. The engine releases when a document
|
||||
//! closes; off wasm a background thread also releases once the pool has gone unused for
|
||||
//! [`IDLE_RELEASE_SECS`], so every host (app, CLI, tests) gives memory back the same way.
|
||||
|
||||
use std::collections::{BTreeMap, VecDeque};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
|
||||
use std::sync::{Mutex, PoisonError};
|
||||
|
||||
/// Buffers with less capacity than this are cheap from the heap and are never retained.
|
||||
pub const MIN_POOLED_BYTES: usize = 64 * 1024;
|
||||
/// Default cap on the bytes the pool retains: about the working set of a 36 MP filter (tiles,
|
||||
/// halo images and result bands); a browser's wasm heap never shrinks, so there it is small.
|
||||
pub const DEFAULT_BUDGET: usize = if cfg!(target_arch = "wasm32") { 256 << 20 } else { 2 << 30 };
|
||||
|
||||
/// Pooled bytes a miss never evicts. A filter's output tiles are all misses (the old tiles stay
|
||||
/// in the undo history); evicting every byte they need also dropped the scratch planes the next
|
||||
/// tile reuses. On a 36 MP Gaussian Blur with the trim threshold set, 256 MiB keeps the timings
|
||||
/// at glibc's defaults (128 MiB: r 40 ~17% slower) for ~10% more peak memory (0: ~20% slower).
|
||||
pub const DEFAULT_MISS_FLOOR: usize = if cfg!(target_arch = "wasm32") { 64 << 20 } else { 256 << 20 };
|
||||
|
||||
static BUDGET: AtomicUsize = AtomicUsize::new(DEFAULT_BUDGET);
|
||||
static MISS_FLOOR: AtomicUsize = AtomicUsize::new(DEFAULT_MISS_FLOOR);
|
||||
static RETAINED: AtomicUsize = AtomicUsize::new(0);
|
||||
static USES: AtomicU64 = AtomicU64::new(0);
|
||||
static IDLE_RELEASE: AtomicBool = AtomicBool::new(true);
|
||||
|
||||
/// Seconds without a [`take`] or [`give`] after which the idle thread releases the pool.
|
||||
pub const IDLE_RELEASE_SECS: u64 = 2;
|
||||
|
||||
/// Starts the idle-release thread once (off wasm, where there are threads). If the thread
|
||||
/// can't be spawned the pool still works; it is then released only by [`release`].
|
||||
fn start_idle_release() {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
static STARTED: std::sync::Once = std::sync::Once::new();
|
||||
STARTED.call_once(|| {
|
||||
let _ = std::thread::Builder::new().name("scratch-idle".into()).spawn(|| {
|
||||
let mut seen = uses();
|
||||
loop {
|
||||
std::thread::sleep(std::time::Duration::from_secs(IDLE_RELEASE_SECS));
|
||||
let now = uses();
|
||||
if now == seen {
|
||||
// Decided under the lock, so `set_idle_release(false)` can't return
|
||||
// while a release it should have stopped is about to start.
|
||||
let old = {
|
||||
let mut pool = lock();
|
||||
// A `give` or `take` since `now` was read means the pool is in use.
|
||||
(uses() == now && retained_bytes() > 0 && IDLE_RELEASE.load(Ordering::Relaxed)).then(|| empty(&mut pool))
|
||||
};
|
||||
drop(old);
|
||||
}
|
||||
seen = now;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// One free list: buffers keyed by capacity (in elements), each with the sequence number it
|
||||
/// was returned at (oldest first within a capacity).
|
||||
#[doc(hidden)]
|
||||
pub type FreeList<T> = BTreeMap<usize, VecDeque<(u64, Vec<T>)>>;
|
||||
|
||||
/// All the free lists behind one lock, so the oldest buffer of any type can be evicted.
|
||||
/// `order` maps every pooled buffer's sequence number to its type and capacity, so the oldest
|
||||
/// is found in O(log n); best-fit lookups are a range query on the capacity key.
|
||||
#[doc(hidden)]
|
||||
pub struct Pool {
|
||||
seq: u64,
|
||||
order: BTreeMap<u64, (Tag, usize)>,
|
||||
u8s: FreeList<u8>,
|
||||
u16s: FreeList<u16>,
|
||||
f32s: FreeList<f32>,
|
||||
px4: FreeList<[f32; 4]>,
|
||||
}
|
||||
|
||||
static POOL: Mutex<Pool> =
|
||||
Mutex::new(Pool { seq: 0, order: BTreeMap::new(), u8s: BTreeMap::new(), u16s: BTreeMap::new(), f32s: BTreeMap::new(), px4: BTreeMap::new() });
|
||||
|
||||
/// Which free list a buffer lives on.
|
||||
#[doc(hidden)]
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum Tag {
|
||||
U8,
|
||||
U16,
|
||||
F32,
|
||||
Px4,
|
||||
}
|
||||
|
||||
/// A buffer evicted from the pool, dropped by the caller once the lock is released.
|
||||
#[allow(dead_code)]
|
||||
enum Evicted {
|
||||
U8(Vec<u8>),
|
||||
U16(Vec<u16>),
|
||||
F32(Vec<f32>),
|
||||
Px4(Vec<[f32; 4]>),
|
||||
}
|
||||
|
||||
/// Element types that have a free list. Implemented for the buffers the hot paths use.
|
||||
pub trait Pooled: Copy + Send + 'static {
|
||||
#[doc(hidden)]
|
||||
const TAG: Tag;
|
||||
#[doc(hidden)]
|
||||
fn list(pool: &mut Pool) -> &mut FreeList<Self>;
|
||||
}
|
||||
|
||||
macro_rules! pooled {
|
||||
($($t:ty => $field:ident, $tag:ident),*) => {$(
|
||||
impl Pooled for $t {
|
||||
const TAG: Tag = Tag::$tag;
|
||||
fn list(pool: &mut Pool) -> &mut FreeList<Self> {
|
||||
&mut pool.$field
|
||||
}
|
||||
}
|
||||
)*};
|
||||
}
|
||||
pooled!(u8 => u8s, U8, u16 => u16s, U16, f32 => f32s, F32, [f32; 4] => px4, Px4);
|
||||
|
||||
fn bytes_of<T>(v: &Vec<T>) -> usize {
|
||||
v.capacity().saturating_mul(std::mem::size_of::<T>())
|
||||
}
|
||||
|
||||
fn lock() -> std::sync::MutexGuard<'static, Pool> {
|
||||
POOL.lock().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// Remove the oldest buffer of capacity `cap` from `list`.
|
||||
fn drop_oldest<T>(list: &mut FreeList<T>, cap: usize) -> Option<Vec<T>> {
|
||||
let queue = list.get_mut(&cap)?;
|
||||
let v = queue.pop_front()?.1;
|
||||
if queue.is_empty() {
|
||||
list.remove(&cap);
|
||||
}
|
||||
Some(v)
|
||||
}
|
||||
|
||||
impl Pool {
|
||||
/// Remove the buffer returned longest ago, of any type. `None` when the pool is empty.
|
||||
fn evict_oldest(&mut self) -> Option<Evicted> {
|
||||
let (_, (tag, cap)) = self.order.pop_first()?;
|
||||
let evicted = match tag {
|
||||
Tag::U8 => drop_oldest(&mut self.u8s, cap).map(Evicted::U8),
|
||||
Tag::U16 => drop_oldest(&mut self.u16s, cap).map(Evicted::U16),
|
||||
Tag::F32 => drop_oldest(&mut self.f32s, cap).map(Evicted::F32),
|
||||
Tag::Px4 => drop_oldest(&mut self.px4, cap).map(Evicted::Px4),
|
||||
};
|
||||
if let Some(e) = &evicted {
|
||||
let freed = match e {
|
||||
Evicted::U8(v) => bytes_of(v),
|
||||
Evicted::U16(v) => bytes_of(v),
|
||||
Evicted::F32(v) => bytes_of(v),
|
||||
Evicted::Px4(v) => bytes_of(v),
|
||||
};
|
||||
RETAINED.fetch_sub(freed, Ordering::Relaxed);
|
||||
}
|
||||
evicted
|
||||
}
|
||||
}
|
||||
|
||||
/// A `Vec` of `len` copies of `fill`, reusing a pooled buffer when one fits: the one whose
|
||||
/// capacity equals `len`, else the smallest with capacity in `len..=2*len`.
|
||||
pub fn take<T: Pooled>(len: usize, fill: T) -> Vec<T> {
|
||||
take_with(len, fill, 2)
|
||||
}
|
||||
|
||||
/// An empty `Vec` with room for at least `cap` elements, reusing a pooled buffer when one fits.
|
||||
pub fn take_cap<T: Pooled>(cap: usize) -> Vec<T> {
|
||||
USES.fetch_add(1, Ordering::Relaxed);
|
||||
match pop::<T>(cap, 2) {
|
||||
Some(mut v) => {
|
||||
v.clear();
|
||||
v
|
||||
}
|
||||
None => Vec::with_capacity(cap),
|
||||
}
|
||||
}
|
||||
|
||||
/// Like [`take`] but only reuses a buffer whose capacity is exactly `len`, so
|
||||
/// `into_boxed_slice` never reallocates.
|
||||
pub fn take_exact<T: Pooled>(len: usize, fill: T) -> Vec<T> {
|
||||
take_with(len, fill, 1)
|
||||
}
|
||||
|
||||
/// Pop the best-fitting free buffer: capacity `len`, else the smallest up to `len * slack`.
|
||||
///
|
||||
/// On a miss the caller allocates `len` fresh, so the oldest free buffers of at least that many
|
||||
/// bytes are dropped first, down to [`set_miss_floor`]: memory the pool can't reuse for this request
|
||||
/// goes back instead of piling up beside the new buffer, so pool plus live buffers exceed what
|
||||
/// the program would hold without the pool by at most the floor (#10479).
|
||||
fn pop<T: Pooled>(len: usize, slack: usize) -> Option<Vec<T>> {
|
||||
let need = len.saturating_mul(std::mem::size_of::<T>());
|
||||
if need < MIN_POOLED_BYTES {
|
||||
return None;
|
||||
}
|
||||
let mut evicted = Vec::new();
|
||||
let hit = {
|
||||
let mut pool = lock();
|
||||
let max = len.saturating_mul(slack);
|
||||
let list = T::list(&mut pool);
|
||||
let cap = list.range(len..=max).next().map(|(&cap, _)| cap);
|
||||
// The newest buffer of that capacity is the one most likely still in cache.
|
||||
let found = cap.and_then(|cap| {
|
||||
let queue = list.get_mut(&cap)?;
|
||||
let entry = queue.pop_back();
|
||||
if queue.is_empty() {
|
||||
list.remove(&cap);
|
||||
}
|
||||
entry
|
||||
});
|
||||
match found {
|
||||
Some((seq, v)) => {
|
||||
pool.order.remove(&seq);
|
||||
RETAINED.fetch_sub(bytes_of(&v), Ordering::Relaxed);
|
||||
Some(v)
|
||||
}
|
||||
None => {
|
||||
let target = RETAINED.load(Ordering::Relaxed).saturating_sub(need).max(MISS_FLOOR.load(Ordering::Relaxed));
|
||||
while RETAINED.load(Ordering::Relaxed) > target {
|
||||
match pool.evict_oldest() {
|
||||
Some(e) => evicted.push(e),
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
// Freed outside the lock.
|
||||
drop(evicted);
|
||||
hit
|
||||
}
|
||||
|
||||
fn take_with<T: Pooled>(len: usize, fill: T, slack: usize) -> Vec<T> {
|
||||
USES.fetch_add(1, Ordering::Relaxed);
|
||||
if len == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
match pop::<T>(len, slack) {
|
||||
Some(mut v) => {
|
||||
v.clear();
|
||||
v.resize(len, fill);
|
||||
v
|
||||
}
|
||||
None => vec![fill; len],
|
||||
}
|
||||
}
|
||||
|
||||
/// Fallible [`take_exact`]: on a pool miss the heap allocation is `try_reserve_exact`, so
|
||||
/// `None` means the allocation failed rather than aborting.
|
||||
pub fn try_take_exact<T: Pooled>(len: usize, fill: T) -> Option<Vec<T>> {
|
||||
USES.fetch_add(1, Ordering::Relaxed);
|
||||
let mut v = match pop::<T>(len, 1) {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
let mut v = Vec::new();
|
||||
if v.try_reserve_exact(len).is_err() {
|
||||
// The pool may hold up to the miss floor of buffers this request couldn't use:
|
||||
// give them back, then try once more before failing the caller.
|
||||
release();
|
||||
v.try_reserve_exact(len).ok()?;
|
||||
}
|
||||
v
|
||||
}
|
||||
};
|
||||
v.clear();
|
||||
v.resize(len, fill);
|
||||
Some(v)
|
||||
}
|
||||
|
||||
/// Return a buffer to the pool. Small buffers, and buffers bigger than the whole budget, are
|
||||
/// dropped; otherwise the buffers returned longest ago (of any type) make room for it.
|
||||
pub fn give<T: Pooled>(v: Vec<T>) {
|
||||
USES.fetch_add(1, Ordering::Relaxed);
|
||||
let bytes = bytes_of(&v);
|
||||
if bytes < MIN_POOLED_BYTES {
|
||||
return;
|
||||
}
|
||||
let mut evicted = Vec::new();
|
||||
{
|
||||
let mut pool = lock();
|
||||
// Read under the lock, which `set_budget` also takes, so no insert uses a stale budget.
|
||||
let budget = BUDGET.load(Ordering::Relaxed);
|
||||
if bytes > budget {
|
||||
return;
|
||||
}
|
||||
start_idle_release();
|
||||
while RETAINED.load(Ordering::Relaxed).saturating_add(bytes) > budget {
|
||||
match pool.evict_oldest() {
|
||||
Some(e) => evicted.push(e),
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
RETAINED.fetch_add(bytes, Ordering::Relaxed);
|
||||
pool.seq += 1;
|
||||
let seq = pool.seq;
|
||||
let cap = v.capacity();
|
||||
pool.order.insert(seq, (T::TAG, cap));
|
||||
T::list(&mut pool).entry(cap).or_default().push_back((seq, v));
|
||||
}
|
||||
// Freed outside the lock.
|
||||
drop(evicted);
|
||||
}
|
||||
|
||||
/// Set the cap on retained bytes (existing entries beyond it go on the next [`give`]).
|
||||
pub fn set_budget(bytes: usize) {
|
||||
let _pool = lock();
|
||||
BUDGET.store(bytes, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Set the pooled bytes a miss never evicts (default [`DEFAULT_MISS_FLOOR`]).
|
||||
pub fn set_miss_floor(bytes: usize) {
|
||||
MISS_FLOOR.store(bytes, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Let the idle thread release the pool (the default) or not. Tests that check
|
||||
/// [`retained_bytes`] turn it off, so a slow runner can't have the pool emptied under them.
|
||||
pub fn set_idle_release(on: bool) {
|
||||
// Under the lock: once this returns, the idle thread can't be midway into a release.
|
||||
let _pool = lock();
|
||||
IDLE_RELEASE.store(on, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Bytes currently held by the pool.
|
||||
pub fn retained_bytes() -> usize {
|
||||
RETAINED.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Number of [`take`]/[`give`] calls so far; a changing value means the pool is in use.
|
||||
pub fn uses() -> u64 {
|
||||
USES.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Drop every pooled buffer, handing the memory back to the OS.
|
||||
pub fn release() {
|
||||
let old = empty(&mut lock());
|
||||
// Freed outside the lock.
|
||||
drop(old);
|
||||
}
|
||||
|
||||
/// Everything `pool` holds, swapped out of it (to be freed once the lock is dropped).
|
||||
fn empty(pool: &mut Pool) -> Pool {
|
||||
RETAINED.store(0, Ordering::Relaxed);
|
||||
let seq = pool.seq;
|
||||
std::mem::replace(pool, Pool { seq, order: BTreeMap::new(), u8s: BTreeMap::new(), u16s: BTreeMap::new(), f32s: BTreeMap::new(), px4: BTreeMap::new() })
|
||||
}
|
||||
|
||||
/// A pooled buffer that derefs to a slice and returns itself to the pool on drop.
|
||||
pub struct Buf<T: Pooled>(Vec<T>);
|
||||
|
||||
impl<T: Pooled> Buf<T> {
|
||||
/// `len` copies of `fill` from the pool.
|
||||
pub fn new(len: usize, fill: T) -> Self {
|
||||
Buf(take(len, fill))
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Pooled> std::ops::Deref for Buf<T> {
|
||||
type Target = [T];
|
||||
fn deref(&self) -> &[T] {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Pooled> std::ops::DerefMut for Buf<T> {
|
||||
fn deref_mut(&mut self) -> &mut [T] {
|
||||
&mut self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Pooled> Drop for Buf<T> {
|
||||
fn drop(&mut self) {
|
||||
give(std::mem::take(&mut self.0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
//! The scratch pool is process-wide, so these tests live in their own binary (nothing else
|
||||
//! allocates tiles here) and take a lock to run one at a time.
|
||||
|
||||
use photocraft_raster::scratch::*;
|
||||
use std::sync::{Mutex, PoisonError};
|
||||
|
||||
static SERIAL: Mutex<()> = Mutex::new(());
|
||||
|
||||
/// Lets a miss evict every pooled byte for the test's lifetime, so sizes can be counted.
|
||||
struct NoFloor;
|
||||
impl NoFloor {
|
||||
/// Sets the miss floor to zero for this guard's lifetime.
|
||||
fn new() -> Self {
|
||||
set_miss_floor(0);
|
||||
NoFloor
|
||||
}
|
||||
}
|
||||
impl Drop for NoFloor {
|
||||
fn drop(&mut self) {
|
||||
set_miss_floor(DEFAULT_MISS_FLOOR);
|
||||
}
|
||||
}
|
||||
/// Locks the process-wide pool tests against concurrent access.
|
||||
fn lock_serial() -> std::sync::MutexGuard<'static, ()> {
|
||||
SERIAL.lock().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// Runs a test alone with idle release off, so the idle thread can't empty the pool between a
|
||||
/// `give` and the checks after it; idle release comes back on when dropped.
|
||||
struct Serial {
|
||||
_lock: std::sync::MutexGuard<'static, ()>,
|
||||
}
|
||||
impl Drop for Serial {
|
||||
fn drop(&mut self) {
|
||||
set_idle_release(true);
|
||||
}
|
||||
}
|
||||
/// Serializes a test and disables idle release until the guard is dropped.
|
||||
fn serial() -> Serial {
|
||||
let _lock = lock_serial();
|
||||
set_idle_release(false);
|
||||
Serial { _lock }
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Verifies pooled buffers are reused by pointer, retain capacity, and are refilled to the requested length.
|
||||
fn reuse_returns_same_capacity_and_refills() {
|
||||
let _g = serial();
|
||||
release();
|
||||
let mut v = take(100_000, 0.0f32);
|
||||
v.iter_mut().for_each(|x| *x = 7.0);
|
||||
let ptr = v.as_ptr();
|
||||
give(v);
|
||||
assert_eq!(retained_bytes(), 400_000);
|
||||
let w = take(100_000, 1.5f32);
|
||||
assert_eq!(w.as_ptr(), ptr);
|
||||
assert_eq!(w.len(), 100_000);
|
||||
assert!(w.iter().all(|&x| x == 1.5));
|
||||
assert_eq!(retained_bytes(), 0);
|
||||
// A smaller request within 2x reuses it too, and is sized exactly.
|
||||
give(w);
|
||||
let s = take(60_000, 0.0f32);
|
||||
assert_eq!(s.as_ptr(), ptr);
|
||||
assert_eq!(s.len(), 60_000);
|
||||
release();
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Verifies oversized or non-exact buffers are discarded instead of reused.
|
||||
fn too_big_a_buffer_is_not_reused() {
|
||||
let _g = serial();
|
||||
release();
|
||||
let _floor = NoFloor::new();
|
||||
give(vec![0u8; 1_000_000]);
|
||||
assert_eq!(take_exact(1_000_000, 0u8).len(), 1_000_000);
|
||||
assert_eq!(retained_bytes(), 0);
|
||||
give(vec![0u8; 1_000_000]);
|
||||
// More than 2x (or not exact): allocated fresh, and the miss drops the unusable buffer.
|
||||
assert_eq!(take(100_000, 0u8).capacity(), 100_000);
|
||||
assert_eq!(retained_bytes(), 0);
|
||||
give(vec![0u8; 1_000_000]);
|
||||
assert_eq!(take_exact(999_999, 0u8).capacity(), 999_999);
|
||||
assert_eq!(retained_bytes(), 0);
|
||||
release();
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Verifies exact try-takes reuse a matching buffer and succeed on a miss.
|
||||
fn try_take_exact_reuses_and_allocates() {
|
||||
let _g = serial();
|
||||
release();
|
||||
let buf = vec![0u8; 200_000];
|
||||
let ptr = buf.as_ptr();
|
||||
give(buf);
|
||||
let v = try_take_exact(200_000, 9u8);
|
||||
assert!(v.as_ref().is_some_and(|v| v.len() == 200_000 && v.iter().all(|&b| b == 9)));
|
||||
assert_eq!(v.as_ref().map(|v| v.as_ptr()), Some(ptr));
|
||||
assert_eq!(retained_bytes(), 0);
|
||||
assert!(try_take_exact(300_000, 0u8).is_some());
|
||||
release();
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Verifies buffers below the pooling threshold are not retained.
|
||||
fn small_buffers_are_not_retained() {
|
||||
let _g = serial();
|
||||
release();
|
||||
give(vec![0u8; MIN_POOLED_BYTES - 1]);
|
||||
give(vec![0.0f32; 100]);
|
||||
assert_eq!(retained_bytes(), 0);
|
||||
assert_eq!(take(10, 3u8), vec![3u8; 10]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Verifies zero-length take and give operations remain empty.
|
||||
fn zero_length() {
|
||||
let _g = serial();
|
||||
assert!(take(0, 0u8).is_empty());
|
||||
give(Vec::<u8>::new());
|
||||
assert!(take_exact(0, [0.0f32; 4]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Verifies the byte budget evicts oldest buffers and rejects buffers larger than the budget.
|
||||
fn budget_evicts_oldest() {
|
||||
let _g = serial();
|
||||
release();
|
||||
set_budget(250_000);
|
||||
let (a, b) = (vec![0u8; 100_000], vec![0u8; 100_000]);
|
||||
let pb = b.as_ptr();
|
||||
give(a);
|
||||
give(b);
|
||||
give(vec![0u8; 100_000]);
|
||||
assert_eq!(retained_bytes(), 200_000);
|
||||
// The oldest was evicted; the survivors include b.
|
||||
let first = take_exact(100_000, 0u8);
|
||||
let second = take_exact(100_000, 0u8);
|
||||
assert!(first.as_ptr() == pb || second.as_ptr() == pb);
|
||||
// Larger than the whole budget: dropped.
|
||||
give(vec![0u8; 300_000]);
|
||||
assert_eq!(retained_bytes(), 0);
|
||||
set_budget(DEFAULT_BUDGET);
|
||||
release();
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Verifies release empties pooled buffers of several element types.
|
||||
fn release_empties() {
|
||||
let _g = serial();
|
||||
give(vec![0u8; 200_000]);
|
||||
give(vec![0.0f32; 200_000]);
|
||||
give(vec![[0.0f32; 4]; 100_000]);
|
||||
assert!(retained_bytes() > 0);
|
||||
release();
|
||||
assert_eq!(retained_bytes(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Verifies the usage counter advances for both take and give.
|
||||
fn uses_counts_calls() {
|
||||
let _g = serial();
|
||||
let before = uses();
|
||||
give(take(10, 0u8));
|
||||
assert_eq!(uses(), before + 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Verifies dropping a `Buf` returns its storage to the pool.
|
||||
fn buf_returns_on_drop() {
|
||||
let _g = serial();
|
||||
release();
|
||||
{
|
||||
let mut b = Buf::new(150_000, 0.0f32);
|
||||
b[3] = 1.0;
|
||||
assert_eq!(b.len(), 150_000);
|
||||
}
|
||||
assert_eq!(retained_bytes(), 600_000);
|
||||
release();
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Verifies concurrent take and give operations preserve initialized values and finish cleanly.
|
||||
fn concurrent_smoke() {
|
||||
let _g = serial();
|
||||
release();
|
||||
let handles: Vec<_> = (0..8u8)
|
||||
.map(|t| {
|
||||
std::thread::spawn(move || {
|
||||
for i in 0..200 {
|
||||
let v = take(70_000 + i % 5, t);
|
||||
assert!(v.iter().all(|&x| x == t));
|
||||
give(v);
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
for h in handles {
|
||||
assert!(h.join().is_ok());
|
||||
}
|
||||
release();
|
||||
assert_eq!(retained_bytes(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Verifies budget eviction uses insertion age across different element types.
|
||||
fn eviction_is_oldest_first_across_types() {
|
||||
let _g = serial();
|
||||
release();
|
||||
set_budget(500_000);
|
||||
give(vec![0u8; 300_000]);
|
||||
give(vec![0.0f32; 50_000]);
|
||||
assert_eq!(retained_bytes(), 500_000);
|
||||
// Needs 200 000 more: the older u8 buffer goes, the f32 one stays.
|
||||
give(vec![[0.0f32; 4]; 12_500]);
|
||||
assert_eq!(retained_bytes(), 400_000);
|
||||
assert_eq!(take_exact(50_000, 1.0f32).len(), 50_000);
|
||||
assert_eq!(retained_bytes(), 200_000);
|
||||
set_budget(DEFAULT_BUDGET);
|
||||
release();
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Verifies reuse chooses the smallest eligible capacity and exact takes require an exact match.
|
||||
fn best_fit_picks_the_smallest_fitting_capacity() {
|
||||
let _g = serial();
|
||||
release();
|
||||
let _floor = NoFloor::new();
|
||||
for n in [180_000usize, 120_000, 150_000, 300_000] {
|
||||
give(vec![0u8; n]);
|
||||
}
|
||||
// Within 2x of 100 000: 120 000 is the tightest fit, then 150 000, then 180 000.
|
||||
assert_eq!(take(100_000, 0u8).capacity(), 120_000);
|
||||
assert_eq!(take(100_000, 0u8).capacity(), 150_000);
|
||||
assert_eq!(take(100_000, 0u8).capacity(), 180_000);
|
||||
// 300 000 is more than 2x: allocated fresh, and the miss drops the pooled one.
|
||||
assert_eq!(take(100_000, 0u8).capacity(), 100_000);
|
||||
assert_eq!(retained_bytes(), 0);
|
||||
// An exact capacity beats a smaller-slack scan.
|
||||
give(vec![0u8; 100_000]);
|
||||
assert_eq!(take_exact(100_000, 0u8).capacity(), 100_000);
|
||||
assert_eq!(take_exact(250_000, 0u8).capacity(), 250_000);
|
||||
release();
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Verifies repeated budget overflow evicts oldest entries across mixed types and capacities.
|
||||
fn eviction_walks_oldest_first_through_mixed_types_and_capacities() {
|
||||
let _g = serial();
|
||||
release();
|
||||
let _floor = NoFloor::new();
|
||||
set_budget(700_000);
|
||||
// Oldest to newest, 100 000 bytes each, interleaved types and two u8 capacities.
|
||||
give(vec![0u8; 100_000]);
|
||||
let f = vec![0.0f32; 25_000];
|
||||
give(f);
|
||||
give(vec![0u8; 100_000]);
|
||||
give(vec![0u16; 50_000]);
|
||||
let newest = vec![0u8; 150_000];
|
||||
let np = newest.as_ptr();
|
||||
give(newest);
|
||||
assert_eq!(retained_bytes(), 550_000);
|
||||
// 200 000 more overflows by 50 000: the oldest (u8) goes.
|
||||
give(vec![[0.0f32; 4]; 12_500]);
|
||||
assert_eq!(retained_bytes(), 650_000);
|
||||
// 100 000 more: the next oldest (the f32 buffer) goes.
|
||||
give(vec![0u8; 100_000]);
|
||||
assert_eq!(retained_bytes(), 650_000);
|
||||
// 200 000 more: the u8 then the u16 buffer go.
|
||||
give(vec![0u8; 200_000]);
|
||||
assert_eq!(retained_bytes(), 650_000);
|
||||
// The newest u8 buffer of the old batch is still pooled; the f32 one was evicted.
|
||||
let again = take_exact(150_000, 0u8);
|
||||
assert_eq!(again.as_ptr(), np);
|
||||
assert_eq!(retained_bytes(), 500_000);
|
||||
// The evicted f32 buffer is a miss: the oldest buffers, at least its 100 000 bytes, leave
|
||||
// (the 200 000-byte [f32; 4] one).
|
||||
assert_eq!(take_exact(25_000, 0.0f32).len(), 25_000);
|
||||
assert_eq!(retained_bytes(), 300_000);
|
||||
set_budget(DEFAULT_BUDGET);
|
||||
release();
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Verifies the idle-release worker clears an unused pooled buffer.
|
||||
fn idle_pool_is_released_by_the_background_thread() {
|
||||
// Idle release stays on here: it is what this test checks.
|
||||
let _g = lock_serial();
|
||||
release();
|
||||
give(vec![0u8; MIN_POOLED_BYTES]);
|
||||
assert_eq!(retained_bytes(), MIN_POOLED_BYTES);
|
||||
// Released after one full idle interval without use (at most two from the give).
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3 * IDLE_RELEASE_SECS + 1);
|
||||
while retained_bytes() > 0 && std::time::Instant::now() < deadline {
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
}
|
||||
assert_eq!(retained_bytes(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Verifies a miss discards pooled bytes in proportion to its allocation and a hit preserves the pool.
|
||||
fn a_miss_drops_as_many_pooled_bytes_as_it_allocates() {
|
||||
let _g = serial();
|
||||
release();
|
||||
let _floor = NoFloor::new();
|
||||
// Three 64 KiB f32 buffers a byte request can't use.
|
||||
for _ in 0..3 {
|
||||
give(vec![0.0f32; MIN_POOLED_BYTES / 4]);
|
||||
}
|
||||
assert_eq!(retained_bytes(), 3 * MIN_POOLED_BYTES);
|
||||
// A 128 KiB miss drops the two oldest, so pool plus live buffers stay at 192 KiB.
|
||||
let v = take(2 * MIN_POOLED_BYTES, 0u8);
|
||||
assert_eq!(retained_bytes(), MIN_POOLED_BYTES);
|
||||
give(v);
|
||||
// A hit drops nothing.
|
||||
let w = take(MIN_POOLED_BYTES / 4, 0.0f32);
|
||||
assert_eq!(retained_bytes(), 2 * MIN_POOLED_BYTES);
|
||||
drop(w);
|
||||
release();
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Verifies miss eviction stops when the configured retained-byte floor is reached.
|
||||
fn a_miss_keeps_the_floor() {
|
||||
let _g = serial();
|
||||
release();
|
||||
set_miss_floor(2 * MIN_POOLED_BYTES);
|
||||
for _ in 0..3 {
|
||||
give(vec![0.0f32; MIN_POOLED_BYTES / 4]);
|
||||
}
|
||||
// A 192 KiB miss would drop all three; only one goes, down to the floor.
|
||||
assert_eq!(take(3 * MIN_POOLED_BYTES, 0u8).len(), 3 * MIN_POOLED_BYTES);
|
||||
assert_eq!(retained_bytes(), 2 * MIN_POOLED_BYTES);
|
||||
set_miss_floor(DEFAULT_MISS_FLOOR);
|
||||
release();
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Verifies a failed oversized reservation leaves the pool empty.
|
||||
fn a_failed_reservation_releases_the_pool() {
|
||||
let _g = serial();
|
||||
release();
|
||||
// Unmatched buffers under the miss floor stay pooled on a miss.
|
||||
give(vec![0u8; 4 * MIN_POOLED_BYTES]);
|
||||
assert!(retained_bytes() > 0);
|
||||
// No allocator can reserve this: the call fails, but not before the pool was emptied. (The
|
||||
// retry after the release isn't observable here: it needs an allocator that fails once.)
|
||||
assert!(try_take_exact(isize::MAX as usize, 0u8).is_none());
|
||||
assert_eq!(retained_bytes(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Verifies a successful miss preserves unmatched buffers within the miss floor.
|
||||
fn a_successful_miss_keeps_unmatched_pooled_buffers() {
|
||||
let _g = serial();
|
||||
release();
|
||||
give(vec![0u8; 4 * MIN_POOLED_BYTES]);
|
||||
let v = try_take_exact(2 * MIN_POOLED_BYTES, 0u8);
|
||||
assert!(v.as_ref().is_some_and(|v| v.len() == 2 * MIN_POOLED_BYTES && v.iter().all(|&b| b == 0)));
|
||||
assert_eq!(retained_bytes(), 4 * MIN_POOLED_BYTES, "within the miss floor, nothing is dropped");
|
||||
release();
|
||||
}
|
||||
@@ -27,6 +27,19 @@ pub fn process_peak_rss_bytes() -> Option<u64> {
|
||||
parse_vm_hwm(&status)
|
||||
}
|
||||
|
||||
/// Minor page faults this process has taken so far (`/proc/self/stat` field 10), `None` off
|
||||
/// Linux. A scenario that maps fresh memory on every run shows up here (#10479).
|
||||
pub fn minor_faults() -> Option<u64> {
|
||||
let stat = std::fs::read_to_string("/proc/self/stat").ok()?;
|
||||
parse_minflt(&stat)
|
||||
}
|
||||
|
||||
fn parse_minflt(stat: &str) -> Option<u64> {
|
||||
// `comm` (field 2) may contain spaces; the fields after its closing `)` start at field 3.
|
||||
let rest = stat.get(stat.rfind(')')?.checked_add(1)?..)?;
|
||||
rest.split_whitespace().nth(7)?.parse().ok()
|
||||
}
|
||||
|
||||
fn parse_vm_hwm(status: &str) -> Option<u64> {
|
||||
let line = status.lines().find(|l| l.starts_with("VmHWM:"))?;
|
||||
let kb: u64 = line.split_whitespace().nth(1)?.parse().ok()?;
|
||||
@@ -149,6 +162,14 @@ pub fn write_report(path: &str, report: &Value) -> Result<(), String> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn minflt_is_field_ten_even_with_spaces_in_comm() {
|
||||
assert_eq!(parse_minflt("42 (cat) R 1 2 3 0 -1 4194304 122 0 0 0"), Some(122));
|
||||
assert_eq!(parse_minflt("42 (a b) c) S 1 2 3 0 -1 0 7 0"), Some(7));
|
||||
assert_eq!(parse_minflt("42 (cat) R 1"), None);
|
||||
assert_eq!(parse_minflt("garbage"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_nearest_rank() {
|
||||
let v: Vec<f64> = (1..=20).map(f64::from).collect();
|
||||
|
||||
@@ -83,9 +83,9 @@ struct Bench {
|
||||
/// state later rows need).
|
||||
only: Option<String>,
|
||||
rows: Vec<(String, f64, f64, f64, f64)>,
|
||||
/// Per row (same order as `rows`): the timed samples and the peak RSS while they ran, for
|
||||
/// `--json` (`cargo xtask perf`).
|
||||
extra: Vec<(Vec<f64>, Option<u64>)>,
|
||||
/// Per row (same order as `rows`): the timed samples, the peak RSS while they ran and the
|
||||
/// minor page faults per warm run (all of it, untimed setup included), for `--json` (`cargo xtask perf`).
|
||||
extra: Vec<(Vec<f64>, Option<u64>, Option<u64>)>,
|
||||
rss: photocraft_testkit::perf::RssSampler,
|
||||
}
|
||||
|
||||
@@ -127,11 +127,17 @@ impl Bench {
|
||||
}
|
||||
self.rss.reset();
|
||||
let cold = f(self);
|
||||
let faults0 = photocraft_testkit::perf::minor_faults();
|
||||
let mut v: Vec<f64> = (0..self.reps).map(|_| f(self)).collect();
|
||||
self.extra.push((v.clone(), self.rss.peak()));
|
||||
// Minor page faults per warm run: memory mapped fresh from the kernel each time (#10479).
|
||||
// Counted over the whole of `f`, so it includes untimed setup and cleanup (an undo, an
|
||||
// extra refresh), not only the work the milliseconds measure.
|
||||
let faults = faults0.zip(photocraft_testkit::perf::minor_faults()).map(|(a, b)| b.saturating_sub(a) / self.reps as u64);
|
||||
self.extra.push((v.clone(), self.rss.peak(), faults));
|
||||
v.sort_by(f64::total_cmp);
|
||||
let med = v[v.len() / 2];
|
||||
println!("{name:<44} {med:>9.1} ms (min {:>8.1}, max {:>8.1}, cold {:>8.1})", v[0], v[v.len() - 1], cold);
|
||||
let fl = faults.map_or(String::new(), |f| format!(", {f} faults/run incl. setup"));
|
||||
println!("{name:<44} {med:>9.1} ms (min {:>8.1}, max {:>8.1}, cold {:>8.1}{fl})", v[0], v[v.len() - 1], cold);
|
||||
self.rows.push((name.to_string(), med, v[0], v[v.len() - 1], cold));
|
||||
}
|
||||
}
|
||||
@@ -473,11 +479,12 @@ fn main() {
|
||||
.rows
|
||||
.iter()
|
||||
.zip(&b.extra)
|
||||
.map(|((n, med, min, max, cold), (samples, rss))| {
|
||||
.map(|((n, med, min, max, cold), (samples, rss, faults))| {
|
||||
let mut r = json!({"name": n, "median_ms": med, "min_ms": min, "max_ms": max, "cold_ms": cold});
|
||||
// For `cargo xtask perf`: raw samples (it computes p50 / p95) and peak RSS.
|
||||
r["samples_ms"] = json!(samples);
|
||||
r["peak_rss_bytes"] = json!(rss);
|
||||
r["minor_faults_per_run"] = json!(faults);
|
||||
r
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -236,7 +236,7 @@ fn proxy_mask(app: &PhotocraftApp, proxy: &Document, k: u32, params: &Value) ->
|
||||
surf.read_rgba_into(area, &mut px);
|
||||
px
|
||||
}
|
||||
_ => photocraft_compose::render(proxy, area).px,
|
||||
_ => photocraft_compose::render(proxy, area).into_px(),
|
||||
};
|
||||
let invert = params.get("invert").and_then(Value::as_bool).unwrap_or(false);
|
||||
let mut mask: Vec<f32> = query
|
||||
|
||||
@@ -119,7 +119,7 @@ fn web_pane(app: &PhotocraftApp, ui: &mut egui::Ui, size: egui::Vec2, p: Option<
|
||||
};
|
||||
let buf = photocraft_compose::flatten(&wd);
|
||||
let v = (
|
||||
Arc::new(buf.px),
|
||||
Arc::new(buf.into_px()),
|
||||
wd.size.width,
|
||||
wd.size.height,
|
||||
(fw * fh) / (f64::from(wd.size.width) * f64::from(wd.size.height)).max(1.0),
|
||||
|
||||
@@ -628,7 +628,7 @@ fn smart_displace_preview_uses_the_map_document() {
|
||||
applied.add_document((**d).clone(), None);
|
||||
}
|
||||
applied.execute(SMART_FILTER_SET_PARAMS, p.clone()).unwrap();
|
||||
let want = photocraft_compose::render(&applied.active().unwrap().doc, doc.bounds()).px;
|
||||
let want = photocraft_compose::render(&applied.active().unwrap().doc, doc.bounds()).into_px();
|
||||
let preview = preview_document_for_target(
|
||||
&doc,
|
||||
layer,
|
||||
|
||||
@@ -180,7 +180,7 @@ fn overlapping_stamp_preview_matches_commit_across_batches() {
|
||||
s.strokes[0].points.extend_from_slice(&points[2..]);
|
||||
assert!(s.paint_preview(|| 2));
|
||||
let (shown, _) = s.shown().unwrap();
|
||||
let expected = photocraft_compose::flatten(&shown).px;
|
||||
let expected = photocraft_compose::flatten(&shown).into_px();
|
||||
assert_ne!(expected, photocraft_compose::flatten(&app.session.active().unwrap().doc).px);
|
||||
commit(&mut app).unwrap();
|
||||
assert_eq!(photocraft_compose::flatten(&app.session.active().unwrap().doc).px, expected, "newLayer={new_layer}");
|
||||
@@ -217,7 +217,7 @@ fn stamp_preview_paints_over_the_paste_like_ok() {
|
||||
let s = app.distort.vanishing.as_ref().unwrap();
|
||||
assert!(s.preview_has_paste(), "the preview shows the paste, so the overlay isn't drawn over the strokes");
|
||||
let (shown, _) = s.shown().unwrap();
|
||||
let expected = photocraft_compose::flatten(&shown).px;
|
||||
let expected = photocraft_compose::flatten(&shown).into_px();
|
||||
commit(&mut app).unwrap();
|
||||
assert_eq!(photocraft_compose::flatten(&app.session.active().unwrap().doc).px, expected, "newLayer={new_layer}");
|
||||
}
|
||||
@@ -349,7 +349,7 @@ fn ok_paints_the_layer_the_mode_was_opened_on() {
|
||||
app.session.execute("layer.new.layer", json!({"name": "other"})).unwrap();
|
||||
let other = app.session.active().unwrap().active_layer.unwrap();
|
||||
sync_preview(&mut app);
|
||||
let expected = photocraft_compose::flatten(&crate::distort_ui::display_doc(&app, 0).unwrap().0).px;
|
||||
let expected = photocraft_compose::flatten(&crate::distort_ui::display_doc(&app, 0).unwrap().0).into_px();
|
||||
commit(&mut app).unwrap();
|
||||
let d = &app.session.active().unwrap().doc;
|
||||
assert!(d.layer(other).unwrap().surface().unwrap().content_bounds().is_empty(), "newLayer={new_layer}");
|
||||
@@ -389,7 +389,7 @@ fn an_edit_under_the_mode_shows_on_the_canvas() {
|
||||
let px = shown.layer(layer).unwrap().surface().unwrap();
|
||||
assert!(px.rgba(210, 30)[1] > 0.9, "the edit shows: {:?}", px.rgba(210, 30));
|
||||
assert!(px.rgba(120, 80) != st.doc.layer(layer).unwrap().surface().unwrap().rgba(120, 80), "and so does the stroke");
|
||||
let expected = photocraft_compose::flatten(&shown).px;
|
||||
let expected = photocraft_compose::flatten(&shown).into_px();
|
||||
commit(&mut app).unwrap();
|
||||
assert_eq!(photocraft_compose::flatten(&app.session.active().unwrap().doc).px, expected);
|
||||
}
|
||||
|
||||
@@ -256,7 +256,8 @@ pub fn compose(doc: &Document, work: &Layer, plan: &Plan, source: &Source) -> Op
|
||||
let band = Rect::new(shown.x0, y0, shown.x1, y0.saturating_add((px.len() / width.max(1)) as i32));
|
||||
let mut over = Buffer::transparent(band);
|
||||
effects::composite_with_effects(work, &crop(&content, band), &mut over, &maps, plan.bounds, &doc.patterns);
|
||||
for (o, p) in px.iter_mut().zip(over.px) {
|
||||
// Copied, not moved out, so `over` hands its band back to the scratch pool on drop.
|
||||
for (o, p) in px.iter_mut().zip(over.px.iter().copied()) {
|
||||
*o = p;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -152,14 +152,14 @@ fn preview_layer_composites_on_the_gpu_like_the_command() {
|
||||
// may flip a few pixels sitting right at a step (1 % allowed).
|
||||
let skip = if matches!(kind, "threshold" | "posterize") && depth != SampleType::U8 { (b.width() * b.height()) as usize / 100 } else { 0 };
|
||||
let on_gpu = render_to_vec(&mut comp, &device, &queue, &preview, b).unwrap_or_else(|e| panic!("{what}: {e:?}"));
|
||||
let on_cpu = photocraft_compose::render(&preview, b).px;
|
||||
let on_cpu = photocraft_compose::render(&preview, b).into_px();
|
||||
let d = max_diff(&on_gpu, &on_cpu, skip);
|
||||
assert!(d <= tol_gpu, "{what}: GPU vs CPU {:.2}/255", d * 255.0);
|
||||
let mut s = photocraft_engine::Session::new();
|
||||
s.add_document(doc.clone(), None);
|
||||
s.select_layer(target).unwrap();
|
||||
s.execute(&format!("image.adjustments.{kind}"), params.clone()).unwrap();
|
||||
let cmd = photocraft_compose::render(&s.active().unwrap().doc, b).px;
|
||||
let cmd = photocraft_compose::render(&s.active().unwrap().doc, b).into_px();
|
||||
let d = max_diff(&on_gpu, &cmd, skip);
|
||||
worst = worst.max(d);
|
||||
assert!(d <= tol_cmd, "{what}: GPU preview vs command {:.2}/255", d * 255.0);
|
||||
@@ -184,7 +184,7 @@ fn proxy_preview_composites_on_the_gpu() {
|
||||
for kind in photocraft_ui_egui::adjust_editors::KINDS {
|
||||
let p = proxy_with_settings(&proxy, kind, &sample(kind)).unwrap();
|
||||
let on_gpu = render_to_vec(&mut comp, &device, &queue, &p, p.bounds()).unwrap();
|
||||
let on_cpu = photocraft_compose::render(&p, p.bounds()).px;
|
||||
let on_cpu = photocraft_compose::render(&p, p.bounds()).into_px();
|
||||
let d = max_diff(&on_gpu, &on_cpu, 0);
|
||||
assert!(d <= 2.0 / 255.0, "{kind} selection {selection}: {:.2}/255", d * 255.0);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user