mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-15 06:34:51 +00:00
Merge origin/main into feat/raw-xmp-crop
Conflict only in docs/file-format-parity.md: main's status line with this PR's Camera Raw crop entry first (Last updated 2026-11-02), and both revision-history rows, this PR's redated to 2026-11-02 above main's 2026-10-30 YCbCr TIFF row. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
7eb54124ea
112 files changed
+2240
-406
No files matched your search
Generated
+11
@@ -1388,6 +1388,16 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-pcraft-format"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=b8f7d93b00dfb8f172938f3d2d796ae62dcfe123#b8f7d93b00dfb8f172938f3d2d796ae62dcfe123"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-print"
|
||||
version = "0.1.0"
|
||||
@@ -5199,6 +5209,7 @@ name = "photocraft-format"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"craft-pcraft-format",
|
||||
"crc32fast",
|
||||
"exacl",
|
||||
"flate2",
|
||||
|
||||
@@ -42,6 +42,7 @@ photocraft-ipp = { path = "crates/ipp" }
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
|
||||
craft-jpeg = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
|
||||
craft-pcraft-format = { git = "https://github.com/storytold/craft-libs", rev = "b8f7d93b00dfb8f172938f3d2d796ae62dcfe123" }
|
||||
# craft-update: app-specific pretend versions apply to startup and manual checks.
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] }
|
||||
thiserror = "2"
|
||||
|
||||
@@ -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"))]
|
||||
|
||||
@@ -291,6 +291,10 @@ sync_github_name = true
|
||||
[people.dylanmtaylor]
|
||||
sync_github_name = true
|
||||
|
||||
[people.Yuki-init]
|
||||
display_name = "DC@yuki.himejima"
|
||||
sync_github_name = false
|
||||
|
||||
[people.sn-o-w]
|
||||
real_name = "Cristian Silaghi"
|
||||
sync_github_name = false
|
||||
|
||||
+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())
|
||||
}
|
||||
|
||||
|
||||
@@ -254,8 +254,10 @@ embedded table (and a PNG's transparent entry) so `photocraft-io` can open them
|
||||
ms in release). Image data cut off by the end of the file decodes with
|
||||
`DecodeWarning::Truncated`; a file far too small for its declared size (more than any
|
||||
compression could expand) is refused before allocating.
|
||||
* YCbCr, CIE Lab, signed integers and compressions that the `tiff` crate lacks (CCITT,
|
||||
JPEG-in-TIFF) return `Unsupported`.
|
||||
* YCbCr with three 8- or 16-bit samples decodes to RGB. YCbCr with other sample counts or
|
||||
bit depths returns `Unsupported`. JPEG-compressed YCbCr uses the full-range JFIF conversion.
|
||||
* 8/16-bit CIELab and ICCLab decode to the Lab channel layout.
|
||||
* Signed integer samples and unsupported compression methods return `Unsupported`.
|
||||
* **TIFF encode** writes classic TIFF, or BigTIFF when `EncodeOptions::tiff_bigtiff` is set or
|
||||
the file could pass 4 GiB. `Metadata::extra_samples` (up to 16) are written after the colour
|
||||
and alpha samples, with their `kind` in the ExtraSamples tag (a reserved `kind` above 2 is
|
||||
|
||||
@@ -168,6 +168,7 @@ mod bands;
|
||||
mod decode;
|
||||
mod encode;
|
||||
mod tags;
|
||||
mod ycbcr;
|
||||
|
||||
pub use decode::TiffBands;
|
||||
pub(crate) use decode::{decode, decode_page, orientation};
|
||||
|
||||
@@ -2,6 +2,7 @@ use std::borrow::Cow;
|
||||
|
||||
use super::bands::{Tally, convert_ab, decode_bands, extra_sample_indices, refill_zeroed, split_extra_samples, zeroed};
|
||||
use super::tags::{F, TAG_IMAGE_SOURCE_DATA, TAG_PHOTOSHOP, TAG_XMP, TEXT_TAGS, photoshop_tags};
|
||||
use super::ycbcr::YCbCr;
|
||||
use super::{
|
||||
ChannelLayout, ChunkType, CodecError, DecodeWarning, Decoder, ExifDirs, File, Ifd, Image, Limits, Metadata, Read, SampleType, Seek, SeekFrom, Tag,
|
||||
camera_raw, err, ifd, io, map_err, tiff_exif,
|
||||
@@ -185,6 +186,9 @@ impl<'a> TiffBands<'a> {
|
||||
// Missing chunks leave their rows as they are: they must start empty in every group.
|
||||
buf = refill_zeroed(buf, len)?;
|
||||
tally = tally + decode_bands(p, self.b, &mut buf, band, first, &mut [], 0)?;
|
||||
if let Some(c) = p.ycbcr {
|
||||
c.convert(&mut buf, p.lay.out_channels(), p.lay.out);
|
||||
}
|
||||
if let Some(signed) = p.lab {
|
||||
convert_ab(&mut buf, p.lay.out_channels(), p.lay.out, signed, false);
|
||||
}
|
||||
@@ -520,6 +524,20 @@ fn header_patches(
|
||||
Ok(patches)
|
||||
}
|
||||
|
||||
/// Lab and YCbCr are converted after decoding, from 8 or 16-bit samples only: Lab needs at least
|
||||
/// its three colour samples, YCbCr exactly three.
|
||||
fn check_converted_model(photometric: u64, spp: u64, bits: u64, src: Src) -> Result<(), CodecError> {
|
||||
let (name, samples_ok) = match photometric {
|
||||
8 | 9 => ("Lab", spp >= 3),
|
||||
6 => ("YCbCr", spp == 3),
|
||||
_ => return Ok(()),
|
||||
};
|
||||
if samples_ok && matches!(src, Src::U8 | Src::U16) {
|
||||
return Ok(());
|
||||
}
|
||||
Err(CodecError::unsupported(F, format!("{name} image with {spp} samples of {bits} bits")))
|
||||
}
|
||||
|
||||
/// A directory checked and laid out for decoding, before any pixel is read.
|
||||
pub(super) struct Prepared {
|
||||
w: u32,
|
||||
@@ -531,6 +549,8 @@ pub(super) struct Prepared {
|
||||
tl: tiff::decoder::Limits,
|
||||
/// Lab samples: `Some(true)` for CIELab (a* and b* stored signed), `Some(false)` for ICCLab.
|
||||
lab: Option<bool>,
|
||||
/// YCbCr samples: the conversion to RGB (#10316).
|
||||
ycbcr: Option<YCbCr>,
|
||||
/// The colour samples of a pixel in the file's colour model.
|
||||
color: usize,
|
||||
/// The ExtraSamples tag (0 unspecified, 1 associated alpha, 2 unassociated alpha).
|
||||
@@ -567,6 +587,9 @@ fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result<Image, CodecEr
|
||||
if tally.decoded == 0 && tally.missing > 0 {
|
||||
return Err(err("the image data is missing (cut off or past the end of the file)"));
|
||||
}
|
||||
if let Some(c) = p.ycbcr {
|
||||
c.convert(&mut data, p.lay.out_channels(), p.lay.out);
|
||||
}
|
||||
if let Some(signed) = p.lab {
|
||||
convert_ab(&mut data, p.lay.out_channels(), p.lay.out, signed, false);
|
||||
}
|
||||
@@ -642,15 +665,17 @@ fn prepare(f: &File<'_>, dir: &Ifd, limits: &Limits, whole: bool) -> Result<Prep
|
||||
// `tiff` crate as RGB, then labelled Lab with a* and b* in PhotoCraft's encoding
|
||||
// (`convert_ab`).
|
||||
let lab = matches!(photometric, 8 | 9);
|
||||
if lab && (spp < 3 || !matches!(src, Src::U8 | Src::U16)) {
|
||||
return Err(CodecError::unsupported(F, format!("Lab image with {spp} samples of {bits} bits")));
|
||||
}
|
||||
// YCbCr (6), as scanners and cameras write it (usually JPEG-compressed with subsampled
|
||||
// chroma): the `tiff` crate decodes and upsamples the samples, and they are turned into RGB
|
||||
// after decoding (#10316).
|
||||
let ycbcr = photometric == 6;
|
||||
check_converted_model(photometric, spp, bits, src)?;
|
||||
// TIFF 6.0 lets any extra sample be the alpha (#1349). For RGB and CMYK the `tiff` crate
|
||||
// returns the alpha only when it is the first extra sample: otherwise it is asked for every
|
||||
// sample (as multiband gray) and the colour and alpha samples are picked here. An alpha
|
||||
// position past the samples the file has is ignored.
|
||||
let color: usize = match photometric {
|
||||
2 | 8 | 9 => 3,
|
||||
2 | 6 | 8 | 9 => 3,
|
||||
5 => 4,
|
||||
_ => 1,
|
||||
};
|
||||
@@ -686,6 +711,7 @@ fn prepare(f: &File<'_>, dir: &Ifd, limits: &Limits, whole: bool) -> Result<Prep
|
||||
(if gray_alpha { ChannelLayout::GrayA } else { ChannelLayout::Gray }, usize::from(num_samples))
|
||||
}
|
||||
tiff::ColorType::RGB(_) => (ChannelLayout::Rgb, 3),
|
||||
tiff::ColorType::YCbCr(_) if ycbcr => (ChannelLayout::Rgb, 3),
|
||||
tiff::ColorType::RGBA(_) => (ChannelLayout::Rgba, 4),
|
||||
tiff::ColorType::CMYK(_) => (ChannelLayout::Cmyk, 4),
|
||||
tiff::ColorType::CMYKA(_) => (ChannelLayout::CmykA, 5),
|
||||
@@ -729,8 +755,9 @@ fn prepare(f: &File<'_>, dir: &Ifd, limits: &Limits, whole: bool) -> Result<Prep
|
||||
// strip or tile tables point at inside the file count (and never more than the file), so
|
||||
// padding the file while pointing the chunks past its end does not raise the bound: the
|
||||
// banded decoder has no whole-image `max_alloc` to fall back on.
|
||||
let compression = f.tag_uint(dir, ifd::TAG_COMPRESSION).unwrap_or(1);
|
||||
let stored = u64::from(w).saturating_mul(u64::from(h)).saturating_mul(spp).saturating_mul(bits).div_ceil(8);
|
||||
let ratio: u64 = match f.tag_uint(dir, ifd::TAG_COMPRESSION).unwrap_or(1) {
|
||||
let ratio: u64 = match compression {
|
||||
1 => 4,
|
||||
32773 => 130,
|
||||
4 => cw.clamp(4096, 65_535),
|
||||
@@ -814,6 +841,7 @@ fn prepare(f: &File<'_>, dir: &Ifd, limits: &Limits, whole: bool) -> Result<Prep
|
||||
patches,
|
||||
tl,
|
||||
lab: lab.then_some(photometric == 8),
|
||||
ycbcr: ycbcr.then(|| YCbCr::of(f, dir, compression, if out == SampleType::U16 { 65535.0 } else { 255.0 })),
|
||||
color,
|
||||
extra_kinds: extra,
|
||||
extras_over_limit,
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
//! YCbCr (PhotometricInterpretation 6) to RGB, after the `tiff` crate has decoded the samples.
|
||||
//!
|
||||
//! The crate decompresses JPEG data and upsamples subsampled chroma, but hands back the stored
|
||||
//! Y, Cb and Cr values. This module turns them into RGB in place: ReferenceBlackWhite scales the
|
||||
//! codes and YCbCrCoefficients gives the luma weights. JPEG-compressed data (compression 7) uses
|
||||
//! the JPEG (JFIF) conversion, full range with the BT.601 weights, and ignores both tags, as
|
||||
//! libtiff does when it asks the JPEG library for RGB.
|
||||
//!
|
||||
//! # Sources
|
||||
//!
|
||||
//! - TIFF Revision 6.0 (Final, June 3, 1992), Section 21 "YCbCr Images", pp. 89–92; the
|
||||
//! YCbCrCoefficients field and its inverse equations are on pp. 90–91:
|
||||
//! `R = Cr·(2 - 2·LumaRed) + Y`, `B = Cb·(2 - 2·LumaBlue) + Y`,
|
||||
//! `G = (Y - LumaBlue·B - LumaRed·R) / LumaGreen`.
|
||||
//! <https://www.itu.int/itudoc/itu-t/com16/tiff-fx/docs/tiff6.pdf>
|
||||
//! - libtiff, `libtiff/tif_color.c`: `TIFFYCbCrToRGBInit` (tables) and `TIFFYCbCrtoRGB` (per pixel).
|
||||
//! <https://gitlab.com/libtiff/libtiff/-/blob/master/libtiff/tif_color.c>
|
||||
//!
|
||||
//! # Green: a compatibility choice
|
||||
//!
|
||||
//! Red and blue follow the spec. Green follows libtiff, which rewrites the spec's equation as
|
||||
//! `G = Y - (LumaRed·(2 - 2·LumaRed) / LumaGreen)·Cr - (LumaBlue·(2 - 2·LumaBlue) / LumaGreen)·Cb`,
|
||||
//! adding Y unscaled. The two agree only when the weights sum to 1. Every standard set does
|
||||
//! (the spec's own examples, BT.601 and BT.709), but the spec only calls the weights "the
|
||||
//! proportions of red, green, and blue respectively in luminance" and does not require the sum.
|
||||
//! For other weights the spec's equation scales Y in green by `(1 - LumaRed - LumaBlue) / LumaGreen`,
|
||||
//! which tints neutral grays.
|
||||
//!
|
||||
//! PhotoCraft deliberately follows libtiff here (decided in PR #10319, for issue #10316), so such
|
||||
//! files open as they do in libtiff-based tools (ImageMagick, GIMP) and grays stay neutral. This is
|
||||
//! a contributor choice, not a requirement of the spec; the test
|
||||
//! `weights_that_do_not_sum_to_one_keep_grays_neutral` in `tests/it/tiff_ycbcr.rs` pins it.
|
||||
|
||||
use super::{File, Ifd, SampleType};
|
||||
|
||||
const TAG_YCBCR_COEFFICIENTS: u16 = 529;
|
||||
const TAG_REFERENCE_BLACK_WHITE: u16 = 532;
|
||||
|
||||
/// BT.601 luma weights: the TIFF default for YCbCrCoefficients.
|
||||
const BT601: [f64; 3] = [0.299, 0.587, 0.114];
|
||||
|
||||
/// How one file's YCbCr codes map to RGB.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(super) struct YCbCr {
|
||||
/// Luma weights of red, green and blue.
|
||||
luma: [f64; 3],
|
||||
/// Black and white codes of Y, Cb and Cr.
|
||||
ref_bw: [f64; 6],
|
||||
}
|
||||
|
||||
impl YCbCr {
|
||||
/// The conversion for directory `dir`, whose samples have `max` as their largest code
|
||||
/// (255 or 65535). Missing or unusable tags fall back to the TIFF defaults.
|
||||
pub(super) fn of(f: &File<'_>, dir: &Ifd, compression: u64, max: f64) -> Self {
|
||||
let defaults = Self::defaults(max);
|
||||
if compression == 7 {
|
||||
return defaults;
|
||||
}
|
||||
let luma = f
|
||||
.tag_f64s(dir, TAG_YCBCR_COEFFICIENTS, 3)
|
||||
.and_then(|v| <[f64; 3]>::try_from(v).ok())
|
||||
.filter(|l| l.iter().all(|c| c.is_finite() && *c > 0.0 && *c < 1.0))
|
||||
.unwrap_or(defaults.luma);
|
||||
let ref_bw = f
|
||||
.tag_f64s(dir, TAG_REFERENCE_BLACK_WHITE, 6)
|
||||
.and_then(|v| <[f64; 6]>::try_from(v).ok())
|
||||
.filter(|r| r.iter().all(|v| v.is_finite()) && r.as_chunks::<2>().0.iter().all(|[black, white]| (white - black).abs() > f64::EPSILON))
|
||||
.unwrap_or(defaults.ref_bw);
|
||||
YCbCr { luma, ref_bw }
|
||||
}
|
||||
|
||||
/// TIFF's defaults: BT.601 weights, full-range luma, chroma centred on the middle code.
|
||||
fn defaults(max: f64) -> Self {
|
||||
let mid = (max + 1.0) / 2.0;
|
||||
YCbCr { luma: BT601, ref_bw: [0.0, max, mid, max, mid, max] }
|
||||
}
|
||||
|
||||
/// Converts interleaved Y, Cb, Cr pixels of `channels` samples (the first three are the
|
||||
/// colour) to R, G, B in place. Other sample types are left as they are.
|
||||
pub(super) fn convert(self, data: &mut [u8], channels: usize, sample: SampleType) {
|
||||
match sample {
|
||||
SampleType::U8 => {
|
||||
let t = Tables::new(&self, 255);
|
||||
for_each_pixel(data, channels, |px| {
|
||||
if let [y, cb, cr, ..] = px {
|
||||
let [r, g, b] = t.rgb(usize::from(*y), usize::from(*cb), usize::from(*cr));
|
||||
(*y, *cb, *cr) = (r as u8, g as u8, b as u8);
|
||||
}
|
||||
});
|
||||
}
|
||||
SampleType::U16 => {
|
||||
let t = Tables::new(&self, 65535);
|
||||
for_each_pixel(data, channels * 2, |px| {
|
||||
if let [y0, y1, b0, b1, r0, r1, ..] = px {
|
||||
let get = |a: u8, b: u8| usize::from(u16::from_ne_bytes([a, b]));
|
||||
let [r, g, b] = t.rgb(get(*y0, *y1), get(*b0, *b1), get(*r0, *r1));
|
||||
[*y0, *y1] = (r as u16).to_ne_bytes();
|
||||
[*b0, *b1] = (g as u16).to_ne_bytes();
|
||||
[*r0, *r1] = (b as u16).to_ne_bytes();
|
||||
}
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `f` on every `stride`-byte pixel, in parallel on native targets.
|
||||
fn for_each_pixel(data: &mut [u8], stride: usize, f: impl Fn(&mut [u8]) + Sync + Send) {
|
||||
if stride == 0 {
|
||||
return;
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use rayon::prelude::*;
|
||||
// Bands of whole pixels, so each task has a useful amount of work.
|
||||
let band = stride.saturating_mul(1 << 14);
|
||||
data.par_chunks_mut(band).for_each(|b| b.chunks_exact_mut(stride).for_each(&f));
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
data.chunks_exact_mut(stride).for_each(f);
|
||||
}
|
||||
|
||||
/// Per-code contributions, so a pixel costs a few additions.
|
||||
struct Tables {
|
||||
max: f64,
|
||||
/// Scaled luma of each Y code.
|
||||
y: Vec<f64>,
|
||||
/// Red and green contributions of each Cr code.
|
||||
cr_r: Vec<f64>,
|
||||
cr_g: Vec<f64>,
|
||||
/// Green and blue contributions of each Cb code.
|
||||
cb_g: Vec<f64>,
|
||||
cb_b: Vec<f64>,
|
||||
}
|
||||
|
||||
impl Tables {
|
||||
fn new(c: &YCbCr, max: u32) -> Self {
|
||||
let m = f64::from(max);
|
||||
let [lr, lg, lb] = c.luma;
|
||||
let [y_black, y_white, cb_black, cb_white, cr_black, cr_white] = c.ref_bw;
|
||||
// TIFF 6.0: chroma codes span ±(max + 1) / 2 - 1 around the black code; with the
|
||||
// default ReferenceBlackWhite that is the code minus the middle code.
|
||||
let half = (m + 1.0) / 2.0 - 1.0;
|
||||
let codes = || (0..=max).map(f64::from);
|
||||
let y = codes().map(|v| (v - y_black) * m / (y_white - y_black)).collect();
|
||||
let cb: Vec<f64> = codes().map(|v| (v - cb_black) * half / (cb_white - cb_black)).collect();
|
||||
let cr: Vec<f64> = codes().map(|v| (v - cr_black) * half / (cr_white - cr_black)).collect();
|
||||
// R = Y + (2 - 2·Lr)·Cr and B = Y + (2 - 2·Lb)·Cb (TIFF 6.0 p. 91); green as libtiff:
|
||||
// G = Y - (Lr·(2 - 2·Lr) / Lg)·Cr - (Lb·(2 - 2·Lb) / Lg)·Cb (see the module notes).
|
||||
let (kr, kb) = (2.0 - 2.0 * lr, 2.0 - 2.0 * lb);
|
||||
Tables {
|
||||
max: m,
|
||||
y,
|
||||
cr_r: cr.iter().map(|v| kr * v).collect(),
|
||||
cr_g: cr.iter().map(|v| -lr * kr * v / lg).collect(),
|
||||
cb_g: cb.iter().map(|v| -lb * kb * v / lg).collect(),
|
||||
cb_b: cb.iter().map(|v| kb * v).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// R, G and B of one pixel, rounded and clamped to the code range.
|
||||
fn rgb(&self, y: usize, cb: usize, cr: usize) -> [u32; 3] {
|
||||
let at = |t: &[f64], i: usize| t.get(i).copied().unwrap_or(0.0);
|
||||
let yv = at(&self.y, y);
|
||||
// Green adds Y unscaled, as libtiff does (see "Green: a compatibility choice" above).
|
||||
let r = yv + at(&self.cr_r, cr);
|
||||
let g = yv + at(&self.cr_g, cr) + at(&self.cb_g, cb);
|
||||
let b = yv + at(&self.cb_b, cb);
|
||||
[r, g, b].map(|v| v.round().clamp(0.0, self.max) as u32)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn convert(c: &YCbCr, px: [u8; 3]) -> [u8; 3] {
|
||||
let mut d = px.to_vec();
|
||||
c.convert(&mut d, 3, SampleType::U8);
|
||||
[d[0], d[1], d[2]]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jfif_conversion_matches_known_colours() {
|
||||
let c = YCbCr::defaults(255.0);
|
||||
assert_eq!(convert(&c, [0, 128, 128]), [0, 0, 0]);
|
||||
assert_eq!(convert(&c, [255, 128, 128]), [255, 255, 255]);
|
||||
assert_eq!(convert(&c, [128, 128, 128]), [128, 128, 128]);
|
||||
// Pure red, green and blue as JFIF encodes them.
|
||||
assert_eq!(convert(&c, [76, 85, 255]), [254, 0, 0]);
|
||||
assert_eq!(convert(&c, [150, 44, 21]), [0, 255, 1]);
|
||||
assert_eq!(convert(&c, [29, 255, 107]), [0, 0, 254]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sixteen_bit_and_extra_samples() {
|
||||
let c = YCbCr::defaults(65535.0);
|
||||
let mut d: Vec<u8> = [65535u16, 32768, 32768, 1234].iter().flat_map(|v| v.to_ne_bytes()).collect();
|
||||
c.convert(&mut d, 4, SampleType::U16);
|
||||
let v: Vec<u16> = d.as_chunks::<2>().0.iter().map(|b| u16::from_ne_bytes(*b)).collect();
|
||||
assert_eq!(v, [65535, 65535, 65535, 1234]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn studio_range_reference_black_white() {
|
||||
// Y 16–235, chroma 128 ± 112 (ITU-R BT.601 studio range).
|
||||
let c = YCbCr { luma: BT601, ref_bw: [16.0, 235.0, 128.0, 240.0, 128.0, 240.0] };
|
||||
assert_eq!(convert(&c, [16, 128, 128]), [0, 0, 0]);
|
||||
assert_eq!(convert(&c, [235, 128, 128]), [255, 255, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn odd_buffers_do_not_panic() {
|
||||
let c = YCbCr::defaults(255.0);
|
||||
let mut d = vec![1u8, 2];
|
||||
c.convert(&mut d, 3, SampleType::U8);
|
||||
c.convert(&mut d, 0, SampleType::U8);
|
||||
c.convert(&mut d, 0, SampleType::U16);
|
||||
assert_eq!(d, [1, 2]);
|
||||
}
|
||||
}
|
||||
@@ -236,18 +236,33 @@ impl<'a> File<'a> {
|
||||
|
||||
/// The first value of a numeric tag as a float: RATIONAL, FLOAT, DOUBLE or an integer.
|
||||
pub fn tag_f64(&self, ifd: &Ifd, tag: u16) -> Option<f64> {
|
||||
self.float(ifd.get(tag)?, 0)
|
||||
}
|
||||
|
||||
/// The values of a numeric tag as floats, when it holds exactly `n` (`None` for any other
|
||||
/// count or an unreadable value).
|
||||
pub fn tag_f64s(&self, ifd: &Ifd, tag: u16, n: u64) -> Option<Vec<f64>> {
|
||||
let e = ifd.get(tag)?;
|
||||
if e.count == 0 {
|
||||
if e.count != n {
|
||||
return None;
|
||||
}
|
||||
(0..n).map(|i| self.float(e, i)).collect()
|
||||
}
|
||||
|
||||
/// Value `i` of a numeric entry as a float: RATIONAL, FLOAT, DOUBLE or an integer.
|
||||
fn float(&self, e: &Entry, i: u64) -> Option<f64> {
|
||||
if i >= e.count {
|
||||
return None;
|
||||
}
|
||||
let at = e.value_at.checked_add(i.checked_mul(type_size(e.ty)?)?)?;
|
||||
match e.ty {
|
||||
5 => {
|
||||
let (n, d) = (self.u32_at(e.value_at)?, self.u32_at(e.value_at.checked_add(4)?)?);
|
||||
let (n, d) = (self.u32_at(at)?, self.u32_at(at.checked_add(4)?)?);
|
||||
(d != 0).then(|| f64::from(n) / f64::from(d))
|
||||
}
|
||||
11 => Some(f64::from(f32::from_bits(self.u32_at(e.value_at)?))),
|
||||
12 => Some(f64::from_bits(self.u64_at(e.value_at)?)),
|
||||
_ => self.uint(e, 0).map(|v| v as f64),
|
||||
11 => Some(f64::from(f32::from_bits(self.u32_at(at)?))),
|
||||
12 => Some(f64::from_bits(self.u64_at(at)?)),
|
||||
_ => self.uint(e, i).map(|v| v as f64),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ mod tiff_lab;
|
||||
mod tiff_pages;
|
||||
mod tiff_photoshop;
|
||||
mod tiff_regressions;
|
||||
mod tiff_ycbcr;
|
||||
mod webp_frames;
|
||||
mod webp_lossy;
|
||||
mod xpm;
|
||||
@@ -0,0 +1,259 @@
|
||||
//! YCbCr TIFFs (PhotometricInterpretation 6), as scanners write them: JPEG-compressed with 2×2
|
||||
//! chroma subsampling, and uncompressed with ReferenceBlackWhite and YCbCrCoefficients (#10316).
|
||||
//! Every file here is hand-built by `common::tiffgen`.
|
||||
|
||||
use crate::common::sample_icc;
|
||||
use crate::common::tiffgen::*;
|
||||
use photocraft_codecs::*;
|
||||
|
||||
const COMPRESSION: u16 = 259;
|
||||
const SUBSAMPLING: u16 = 530;
|
||||
const YCBCR_COEFFICIENTS: u16 = 529;
|
||||
const REFERENCE_BLACK_WHITE: u16 = 532;
|
||||
const ICC_PROFILE: u16 = 34675;
|
||||
|
||||
/// A directory whose Compression is `c`.
|
||||
fn compressed(mut d: Dir, c: u64) -> Dir {
|
||||
d.entries.retain(|e| e.0 != COMPRESSION);
|
||||
d.tag(COMPRESSION, SHORT, &[c])
|
||||
}
|
||||
|
||||
/// A little-endian RATIONAL value for `tiffgen`.
|
||||
fn rational(num: u32, den: u32) -> u64 {
|
||||
u64::from(num) | (u64::from(den) << 32)
|
||||
}
|
||||
|
||||
/// The image's pixels as RGB bytes.
|
||||
fn rgb(img: &Image) -> Vec<u8> {
|
||||
img.to_rgba8().as_chunks::<4>().0.iter().flat_map(|[r, g, b, _]| [*r, *g, *b]).collect()
|
||||
}
|
||||
|
||||
/// A smooth colour test card, `w`×`h` RGB.
|
||||
fn card(w: u32, h: u32) -> Vec<u8> {
|
||||
let mut v = Vec::new();
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
v.extend([(x * 255 / (w - 1)) as u8, (y * 255 / (h - 1)) as u8, (255 - x * 255 / (w - 1)) as u8]);
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// A 4:2:0 baseline JPEG stream of `rgb`.
|
||||
fn jpeg(rgb: &[u8], w: u32, h: u32) -> Vec<u8> {
|
||||
let img = Image::from_raw(w, h, ChannelLayout::Rgb, SampleType::U8, rgb.to_vec()).unwrap();
|
||||
encode(&img, Format::Jpeg, &EncodeOptions { jpeg_chroma_subsampling: true, ..Default::default() }).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jpeg_compressed_ycbcr_with_subsampled_chroma_reads_as_rgb() {
|
||||
let (w, h) = (32, 24);
|
||||
let stream = jpeg(&card(w, h), w, h);
|
||||
// The stream really is subsampled: the luma component samples 2×2 in the frame header.
|
||||
let sof = stream.windows(2).position(|m| m == [0xFF, 0xC0]).expect("baseline frame header");
|
||||
assert_eq!(stream[sof + 11], 0x22, "luma sampling factors");
|
||||
let dir = compressed(strips(w, h, 8, 3, 6, h, &stream), 7).tag(SUBSAMPLING, SHORT, &[2, 2]);
|
||||
let file = build(true, false, &[dir], &[0]).bytes;
|
||||
|
||||
let img = decode(&file).unwrap();
|
||||
assert_eq!((img.width(), img.height(), img.layout(), img.sample_type()), (w, h, ChannelLayout::Rgb, SampleType::U8));
|
||||
// The oracle: the same JPEG stream decoded to RGB by an independent decoder.
|
||||
let want = image::load_from_memory_with_format(&stream, image::ImageFormat::Jpeg).unwrap().to_rgb8().into_raw();
|
||||
let worst = rgb(&img).iter().zip(&want).map(|(g, o)| g.abs_diff(*o)).max().unwrap();
|
||||
assert!(worst <= 2, "largest difference from the oracle: {worst}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uncompressed_ycbcr_uses_the_default_conversion() {
|
||||
// Black, white, mid gray and JFIF's pure red, without subsampling.
|
||||
let px = [0, 128, 128, 255, 128, 128, 128, 128, 128, 76, 85, 255];
|
||||
let dir = strips(4, 1, 8, 3, 6, 1, &px).tag(SUBSAMPLING, SHORT, &[1, 1]);
|
||||
let img = decode(&build(true, false, &[dir], &[0]).bytes).unwrap();
|
||||
assert_eq!(img.layout(), ChannelLayout::Rgb);
|
||||
assert_eq!(rgb(&img), [0, 0, 0, 255, 255, 255, 128, 128, 128, 254, 0, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_black_white_sets_the_code_range() {
|
||||
// Studio range: Y 16–235, chroma 128 ± 112.
|
||||
let rbw = [rational(16, 1), rational(235, 1), rational(128, 1), rational(240, 1), rational(128, 1), rational(240, 1)];
|
||||
let dir = strips(2, 1, 8, 3, 6, 1, &[16, 128, 128, 235, 128, 128]).tag(SUBSAMPLING, SHORT, &[1, 1]).tag(REFERENCE_BLACK_WHITE, RATIONAL, &rbw);
|
||||
let img = decode(&build(true, false, &[dir], &[0]).bytes).unwrap();
|
||||
assert_eq!(rgb(&img), [0, 0, 0, 255, 255, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weights_that_do_not_sum_to_one_keep_grays_neutral() {
|
||||
// 0.25 each (sum 0.75): TIFF 6.0's full inverse would scale green's Y by (1 − Lr − Lb) / Lg
|
||||
// = 2 and give mid gray G 255 (clamped). Like libtiff, green keeps Y, so gray stays gray, and
|
||||
// the weights still set the chroma terms: R = Y + 1.5·Cr, B = Y + 1.5·Cb.
|
||||
let weights = [rational(1, 4), rational(1, 4), rational(1, 4)];
|
||||
let dir = strips(2, 1, 8, 3, 6, 1, &[128, 128, 128, 128, 64, 192]).tag(SUBSAMPLING, SHORT, &[1, 1]).tag(YCBCR_COEFFICIENTS, RATIONAL, &weights);
|
||||
let img = decode(&build(true, false, &[dir], &[0]).bytes).unwrap();
|
||||
// Green for the second pixel: 128 − (Lb·1.5/Lg)·Cb − (Lr·1.5/Lg)·Cr = 128 + 96 − 96.
|
||||
assert_eq!(rgb(&img), [128, 128, 128, 224, 128, 32]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ycbcr_coefficients_set_the_luma_weights() {
|
||||
// Weights 0.25, 0.5, 0.25 with Cb −64 and Cr +64: R = Y + 1.5·Cr, B = Y + 1.5·Cb, and G keeps Y
|
||||
// (BT.601 would give R 218 and B 15).
|
||||
let weights = [rational(1, 4), rational(1, 2), rational(1, 4)];
|
||||
let dir = strips(1, 1, 8, 3, 6, 1, &[128, 64, 192]).tag(SUBSAMPLING, SHORT, &[1, 1]).tag(YCBCR_COEFFICIENTS, RATIONAL, &weights);
|
||||
let img = decode(&build(true, false, &[dir], &[0]).bytes).unwrap();
|
||||
assert_eq!(rgb(&img), [224, 128, 32]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ycbcr_tags_with_the_wrong_count_use_the_defaults() {
|
||||
// Four weights and seven black/white codes: both tags are malformed, so BT.601 and full range
|
||||
// apply (the weights alone would give 224, 128, 32).
|
||||
let weights = [rational(1, 4), rational(1, 2), rational(1, 4), rational(1, 4)];
|
||||
let rbw = [rational(16, 1), rational(235, 1), rational(128, 1), rational(240, 1), rational(128, 1), rational(240, 1), rational(0, 1)];
|
||||
let dir = strips(1, 1, 8, 3, 6, 1, &[128, 64, 192]).tag(SUBSAMPLING, SHORT, &[1, 1]).tag(YCBCR_COEFFICIENTS, RATIONAL, &weights).tag(
|
||||
REFERENCE_BLACK_WHITE,
|
||||
RATIONAL,
|
||||
&rbw,
|
||||
);
|
||||
let img = decode(&build(true, false, &[dir], &[0]).bytes).unwrap();
|
||||
assert_eq!(rgb(&img), [218, 104, 15]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sixteen_bit_ycbcr_reads_as_rgb() {
|
||||
let px: Vec<u8> = [65535u16, 32768, 32768, 0, 32768, 32768].iter().flat_map(|v| v.to_le_bytes()).collect();
|
||||
let dir = strips(2, 1, 16, 3, 6, 1, &px).tag(SUBSAMPLING, SHORT, &[1, 1]);
|
||||
let img = decode(&build(true, false, &[dir], &[0]).bytes).unwrap();
|
||||
assert_eq!((img.layout(), img.sample_type()), (ChannelLayout::Rgb, SampleType::U16));
|
||||
let n = img.to_normalized();
|
||||
assert!(n[..3].iter().all(|&v| v == 1.0) && n[3..].iter().all(|&v| v == 0.0), "{n:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(target_pointer_width = "64")]
|
||||
fn sixteen_bit_ycbcr_bands_match_whole_decode_with_a_missing_last_strip() {
|
||||
// On 32-bit targets max_alloc also bounds the whole image, so a small allocation cap
|
||||
// cannot force these tiny strips into separate output groups there.
|
||||
let (w, h, rows) = (4u32, 5u32, 2u32);
|
||||
let pixels = [[0u16, 32768, 32768], [65535, 32768, 32768], [19595, 21710, 65535], [32768, 40960, 24576]];
|
||||
let samples: Vec<u16> = (0..w * h).flat_map(|i| pixels[i as usize % pixels.len()]).collect();
|
||||
let weights = [rational(1, 4), rational(1, 2), rational(1, 4)];
|
||||
let icc = sample_icc(512);
|
||||
for little in [true, false] {
|
||||
for missing in [false, true] {
|
||||
let dir = strips(w, h, 16, 3, 6, rows, &u16s(&samples, little)).tag(SUBSAMPLING, SHORT, &[1, 1]).tag(YCBCR_COEFFICIENTS, RATIONAL, &weights).bytes(
|
||||
ICC_PROFILE,
|
||||
UNDEFINED,
|
||||
&icc,
|
||||
);
|
||||
let mut file = build(little, false, &[dir], &[0]);
|
||||
if missing {
|
||||
let past = file.bytes.len() as u64 + 4096;
|
||||
patch_value(&mut file, little, false, 0, 273, 2, past);
|
||||
}
|
||||
let want = decode_with(&file.bytes, &DecodeOptions { keep_orientation: true, ..Default::default() }).unwrap();
|
||||
let limits = Limits { max_alloc: u64::from(w * rows * 3 * 2), ..Limits::banded() };
|
||||
let mut bands = TiffBands::open(&file.bytes, None, &limits).unwrap();
|
||||
assert_eq!((bands.dimensions(), bands.layout(), bands.sample_type()), ((w, h), ChannelLayout::Rgb, SampleType::U16));
|
||||
let mut got = Image::new(w, h, bands.layout(), bands.sample_type()).unwrap();
|
||||
let mut positions = Vec::new();
|
||||
bands
|
||||
.decode(|x, y, group: &Image| -> Result<(), CodecError> {
|
||||
positions.push((x, y, group.height()));
|
||||
assert_eq!((x, group.width(), group.layout(), group.sample_type()), (0, w, ChannelLayout::Rgb, SampleType::U16));
|
||||
let at = y as usize * w as usize * 3 * 2;
|
||||
got.data_mut()[at..at + group.data().len()].copy_from_slice(group.data());
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(positions, [(0, 0, 2), (0, 2, 2), (0, 4, 1)], "one output group per strip, including the short last strip");
|
||||
assert_eq!(got.data(), want.data(), "little endian: {little}, missing last strip: {missing}");
|
||||
assert_eq!(bands.icc, want.icc);
|
||||
assert_eq!(bands.meta, want.meta);
|
||||
assert_eq!(bands.warnings, want.warnings);
|
||||
assert_eq!(bands.warnings.iter().any(|w| matches!(w, DecodeWarning::Truncated { .. })), missing);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_ycbcr_files_fail_cleanly() {
|
||||
// Four samples (an extra sample next to YCbCr).
|
||||
let four = strips(1, 1, 8, 4, 6, 1, &[128, 128, 128, 255]).tag(SUBSAMPLING, SHORT, &[1, 1]);
|
||||
assert!(decode(&build(true, false, &[four], &[0]).bytes).is_err());
|
||||
// Subsampled chroma without JPEG compression (the default subsampling is 2×2).
|
||||
let sub = strips(2, 2, 8, 3, 6, 2, &[128; 12]);
|
||||
assert!(decode(&build(true, false, &[sub], &[0]).bytes).is_err());
|
||||
// A JPEG strip that is not JPEG data.
|
||||
let junk = compressed(strips(16, 16, 8, 3, 6, 16, &[0x5A; 64]), 7).tag(SUBSAMPLING, SHORT, &[2, 2]);
|
||||
assert!(decode(&build(true, false, &[junk], &[0]).bytes).is_err());
|
||||
// Coefficients and ReferenceBlackWhite that would divide by zero fall back to the defaults.
|
||||
let bad = strips(1, 1, 8, 3, 6, 1, &[255, 128, 128])
|
||||
.tag(SUBSAMPLING, SHORT, &[1, 1])
|
||||
.tag(YCBCR_COEFFICIENTS, RATIONAL, &[rational(0, 1), rational(0, 0), rational(5, 1)])
|
||||
.tag(REFERENCE_BLACK_WHITE, RATIONAL, &[rational(7, 1); 6]);
|
||||
let img = decode(&build(true, false, &[bad], &[0]).bytes).unwrap();
|
||||
assert_eq!(&img.to_rgba8()[..3], &[255, 255, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiled_jpeg_ycbcr_keeps_its_icc_profile() {
|
||||
// Two 16×16 tiles, each its own 4:2:0 JPEG stream, and an embedded profile, like a scanner's
|
||||
// tiled output.
|
||||
let (w, h, t) = (32u32, 16u32, 16u32);
|
||||
let px = card(w, h);
|
||||
let tile = |tx: u32| -> Vec<u8> {
|
||||
(0..t)
|
||||
.flat_map(|y| {
|
||||
let at = ((y * w + tx * t) * 3) as usize;
|
||||
px[at..at + (t * 3) as usize].to_vec()
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let streams = [jpeg(&tile(0), t, t), jpeg(&tile(1), t, t)];
|
||||
let icc = sample_icc(512);
|
||||
let dir = Dir { entries: base(w, h, 8, 3, 6), chunks: streams.to_vec(), tiled: true };
|
||||
let dir = compressed(dir, 7).tag(322, SHORT, &[u64::from(t)]).tag(323, SHORT, &[u64::from(t)]).tag(SUBSAMPLING, SHORT, &[2, 2]).bytes(
|
||||
ICC_PROFILE,
|
||||
UNDEFINED,
|
||||
&icc,
|
||||
);
|
||||
let img = decode(&build(true, false, &[dir], &[0]).bytes).unwrap();
|
||||
assert_eq!((img.width(), img.height(), img.layout()), (w, h, ChannelLayout::Rgb));
|
||||
assert_eq!(img.icc.as_deref(), Some(icc.as_slice()), "the embedded profile is kept");
|
||||
// The oracle: each tile's stream decoded by an independent decoder, placed side by side.
|
||||
let tiles: Vec<Vec<u8>> = streams.iter().map(|s| image::load_from_memory_with_format(s, image::ImageFormat::Jpeg).unwrap().to_rgb8().into_raw()).collect();
|
||||
let row = (t * 3) as usize;
|
||||
let want: Vec<u8> = (0..t as usize).flat_map(|y| [&tiles[0][y * row..(y + 1) * row], &tiles[1][y * row..(y + 1) * row]].concat()).collect();
|
||||
let worst = rgb(&img).iter().zip(&want).map(|(g, o)| g.abs_diff(*o)).max().unwrap();
|
||||
assert!(worst <= 2, "largest difference from the oracle: {worst}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn big_endian_sixteen_bit_ycbcr_reads_as_rgb() {
|
||||
// White, black, and red as full-range 16-bit YCbCr (Y 0.299·65535, Cb 32768 − 0.1687·65535, Cr max).
|
||||
let px = [65535u16, 32768, 32768, 0, 32768, 32768, 19595, 21710, 65535];
|
||||
let dir = strips(3, 1, 16, 3, 6, 1, &u16s(&px, false)).tag(SUBSAMPLING, SHORT, &[1, 1]);
|
||||
let file = build(false, false, &[dir], &[0]).bytes;
|
||||
assert_eq!(&file[..2], b"MM");
|
||||
let img = decode(&file).unwrap();
|
||||
assert_eq!((img.layout(), img.sample_type()), (ChannelLayout::Rgb, SampleType::U16));
|
||||
let n = img.to_normalized();
|
||||
let want = [1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0];
|
||||
assert!(n.iter().zip(want).all(|(g, w)| (g - w).abs() < 2.0 / 255.0), "{n:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn planar_ycbcr_reads_as_rgb_in_both_byte_orders() {
|
||||
// The pixels of the default-conversion test, one plane per component.
|
||||
let px = [0u8, 128, 128, 255, 128, 128, 128, 128, 128, 76, 85, 255];
|
||||
for little in [true, false] {
|
||||
let dir = Dir { entries: base(4, 1, 8, 3, 6), chunks: planar_strips(&px, 4, 1, 3, 1, 1), tiled: false }.tag(278, LONG, &[1]).tag(284, SHORT, &[2]).tag(
|
||||
SUBSAMPLING,
|
||||
SHORT,
|
||||
&[1, 1],
|
||||
);
|
||||
let img = decode(&build(little, false, &[dir], &[0]).bytes).unwrap();
|
||||
assert_eq!(rgb(&img), [0, 0, 0, 255, 255, 255, 128, 128, 128, 254, 0, 0], "little endian: {little}");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -277,6 +277,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 {
|
||||
@@ -292,7 +302,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>,
|
||||
}
|
||||
@@ -673,7 +683,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)
|
||||
}
|
||||
|
||||
@@ -706,8 +726,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
|
||||
}
|
||||
@@ -754,8 +774,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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -774,6 +801,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.
|
||||
@@ -857,6 +889,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.
|
||||
@@ -876,7 +909,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)
|
||||
@@ -893,8 +926,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()
|
||||
@@ -910,7 +943,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);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ photocraft-color = { workspace = true }
|
||||
photocraft-raster = { workspace = true }
|
||||
photocraft-doc = { workspace = true }
|
||||
photocraft-codecs = { workspace = true }
|
||||
craft-pcraft-format = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true, features = ["unbounded_depth", "float_roundtrip"] }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
@@ -451,7 +451,7 @@ fn sweep_orphans(recovery_dir: &Path, min_age: std::time::Duration) {
|
||||
}
|
||||
// Anything at the sidecar's path (even a broken one) keeps the bundle.
|
||||
let has_sidecar = std::fs::symlink_metadata(recovery_dir.join(format!("{key}.json"))).is_ok();
|
||||
let parts = [path.clone(), path.join("tiles"), path.join("blobs")];
|
||||
let parts = [path.clone(), path.join(craft_pcraft_format::TILES_DIR), path.join(craft_pcraft_format::BLOBS_DIR)];
|
||||
// A committed bundle is recoverable work: keep exactly what `list_recovery` lists.
|
||||
if !has_sidecar && sidecarless_entry(recovery_dir, &path).is_none() && parts.iter().all(|p| untouched_for(p, min_age, true)) {
|
||||
let _ = std::fs::remove_dir_all(&path);
|
||||
|
||||
@@ -81,9 +81,7 @@ fn key4(s: &str) -> Result<[u8; 4]> {
|
||||
unhex(s)?.try_into().map_err(|_| FormatError::corrupt(format!("bad 4-byte key `{s}`")))
|
||||
}
|
||||
|
||||
pub(crate) fn is_valid_hash(h: &str) -> bool {
|
||||
h.len() == 64 && h.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
|
||||
}
|
||||
pub(crate) use craft_pcraft_format::is_valid_hash;
|
||||
|
||||
/// Convert native-endian sample bytes to little-endian in place (and back:
|
||||
/// the swap is an involution).
|
||||
@@ -110,7 +108,7 @@ fn surface_m(s: &Surface, sink: &mut dyn Sink) -> SurfaceM {
|
||||
let mut dp = vec![0u8; f.bytes_per_pixel()];
|
||||
encode_pixel(&f, &s.default_pixel(), &mut dp);
|
||||
swap_to_le(&mut dp, f.sample);
|
||||
SurfaceM { format: f, default: hex(&dp), tiles: s.tiles().map(|(c, t)| TileRef { tx: c.tx, ty: c.ty, hash: sink.tile(f, t) }).collect() }
|
||||
SurfaceM { format: f, default: hex(&dp), tiles: s.tiles().map(|(c, t)| TileRef::new(c.tx, c.ty, sink.tile(f, t))).collect() }
|
||||
}
|
||||
|
||||
fn opt_blob(b: &Option<Arc<Vec<u8>>>, sink: &mut dyn Sink) -> Option<Hash> {
|
||||
|
||||
@@ -50,7 +50,7 @@ pub use read::read_file;
|
||||
pub use store::{ByteSource, DEFAULT_REVERIFY_BUDGET, PcraftWriter, SaveStats, read_entry};
|
||||
|
||||
/// File extension of the native format.
|
||||
pub const EXTENSION: &str = "pcraft";
|
||||
pub use craft_pcraft_format::EXTENSION;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum FormatError {
|
||||
@@ -76,6 +76,20 @@ impl FormatError {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<craft_pcraft_format::Error> for FormatError {
|
||||
fn from(e: craft_pcraft_format::Error) -> Self {
|
||||
use craft_pcraft_format::Error;
|
||||
match e {
|
||||
Error::Corrupt(m) => FormatError::Corrupt(m),
|
||||
Error::Unsupported(m) => FormatError::Unsupported(m),
|
||||
Error::TooNew { found, supported, .. } => FormatError::TooNew { found, supported },
|
||||
Error::LimitExceeded(m) => FormatError::LimitExceeded(m),
|
||||
Error::Json(e) => FormatError::Json(e),
|
||||
other => FormatError::Corrupt(other.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, FormatError>;
|
||||
|
||||
/// Previews to embed. The format crate cannot render (it sits beside the
|
||||
@@ -119,10 +133,11 @@ pub const MAX_CANVAS_PIXELS: u64 = MAX_CANVAS_SIDE as u64 * MAX_CANVAS_SIDE as u
|
||||
|
||||
impl Default for LoadOptions {
|
||||
fn default() -> Self {
|
||||
let limits = craft_pcraft_format::Limits::default();
|
||||
LoadOptions {
|
||||
max_manifest_bytes: 256 << 20,
|
||||
max_blob_bytes: 1 << 30,
|
||||
max_total_bytes: 16 << 30,
|
||||
max_manifest_bytes: limits.max_manifest_bytes,
|
||||
max_blob_bytes: limits.max_blob_bytes,
|
||||
max_total_bytes: limits.max_total_bytes,
|
||||
preserve_ids: true,
|
||||
max_canvas_side: MAX_CANVAS_SIDE,
|
||||
max_canvas_pixels: MAX_CANVAS_PIXELS,
|
||||
|
||||
@@ -14,24 +14,11 @@ use photocraft_doc::{
|
||||
use photocraft_geom::{Affine, Size};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Current manifest version written by this build.
|
||||
pub const FORMAT_VERSION: u32 = 1;
|
||||
pub use craft_pcraft_format::{FORMAT_VERSION, Hash, TileRef};
|
||||
|
||||
/// Hex-encoded BLAKE3 hash (64 chars).
|
||||
pub type Hash = String;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Manifest {
|
||||
pub format_version: u32,
|
||||
/// Free-form writer identification, e.g. `photocraft-format 0.1.0`.
|
||||
pub generator: String,
|
||||
pub document: DocM,
|
||||
/// Optional previews present in the bundle.
|
||||
#[serde(default)]
|
||||
pub thumbnail: Option<String>,
|
||||
#[serde(default)]
|
||||
pub composite: Option<String>,
|
||||
}
|
||||
/// The manifest: craft-pcraft-format's envelope (`format_version`, `generator`, the optional
|
||||
/// `thumbnail` and `composite` previews) around the document [`DocM`].
|
||||
pub type Manifest = craft_pcraft_format::Manifest<DocM>;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct DocM {
|
||||
@@ -154,13 +141,6 @@ pub struct NamedPathM {
|
||||
pub psd_raw: Option<Hash>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct TileRef {
|
||||
pub tx: i32,
|
||||
pub ty: i32,
|
||||
pub hash: Hash,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SurfaceM {
|
||||
pub format: PixelFormat,
|
||||
|
||||
@@ -1,43 +1,29 @@
|
||||
//! Manifest migrations. Each step upgrades the raw JSON from version `n` to
|
||||
//! `n + 1`, so old bundles keep loading as the schema evolves.
|
||||
|
||||
use serde_json::Value;
|
||||
use crate::FormatError;
|
||||
|
||||
use crate::manifest::FORMAT_VERSION;
|
||||
use crate::{FormatError, Result};
|
||||
|
||||
pub(crate) type Step = fn(&mut Value) -> Result<()>;
|
||||
pub(crate) type Step = craft_pcraft_format::Step<FormatError>;
|
||||
|
||||
/// `STEPS[i]` upgrades version `i + 1` to `i + 2`. Empty while version 1 is
|
||||
/// the only version.
|
||||
/// the only version. `craft_pcraft_format::read_manifest_with` runs them on load.
|
||||
pub(crate) const STEPS: &[Step] = &[];
|
||||
|
||||
/// Upgrade `v` in place to `target` using `steps`.
|
||||
pub(crate) fn migrate_with(v: &mut Value, steps: &[Step], target: u32) -> Result<u32> {
|
||||
let found = v.get("format_version").and_then(Value::as_u64).ok_or_else(|| FormatError::corrupt("manifest has no format_version"))?;
|
||||
let found = u32::try_from(found).map_err(|_| FormatError::corrupt("format_version out of range"))?;
|
||||
if found == 0 {
|
||||
return Err(FormatError::corrupt("format_version 0 is invalid"));
|
||||
}
|
||||
if found > target {
|
||||
return Err(FormatError::TooNew { found, supported: target });
|
||||
}
|
||||
for ver in found..target {
|
||||
let step = steps.get(ver as usize - 1).ok_or_else(|| FormatError::Unsupported(format!("no migration from format {ver}")))?;
|
||||
step(v)?;
|
||||
v["format_version"] = Value::from(ver + 1);
|
||||
}
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
pub(crate) fn migrate(v: &mut Value) -> Result<u32> {
|
||||
migrate_with(v, STEPS, FORMAT_VERSION)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use crate::Result;
|
||||
use crate::manifest::FORMAT_VERSION;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Upgrade `v` in place to `target` using `steps`, with craft-pcraft-format's runner.
|
||||
fn migrate_with(v: &mut Value, steps: &[Step], target: u32) -> Result<u32> {
|
||||
craft_pcraft_format::migrate(v, steps, target)
|
||||
}
|
||||
|
||||
fn migrate(v: &mut Value) -> Result<u32> {
|
||||
migrate_with(v, STEPS, FORMAT_VERSION)
|
||||
}
|
||||
|
||||
fn rename_title(v: &mut Value) -> Result<()> {
|
||||
let t = v["document"]["title"].take();
|
||||
|
||||
+164
-99
@@ -15,23 +15,93 @@ use crate::manifest::{ContentM, FORMAT_VERSION, Hash, LayerM, Manifest};
|
||||
use crate::zip::{ZipReader, ZipWriter};
|
||||
use crate::{FormatError, LoadOptions, Result, SaveOptions};
|
||||
|
||||
pub(crate) const MANIFEST: &str = "manifest.json";
|
||||
pub(crate) const THUMB: &str = "thumb.png";
|
||||
pub(crate) const COMPOSITE: &str = "composite/preview.png";
|
||||
/// The file a directory save holds an exclusive lock on for its whole transaction (#4957).
|
||||
pub(crate) const SAVE_LOCK: &str = "save.lock";
|
||||
use craft_pcraft_format::{BLOBS_DIR, COMPOSITE_DIR, TILES_DIR, blob_path, tile_path};
|
||||
// `SAVE_LOCK` is the file a directory save holds an exclusive lock on for its whole transaction (#4957).
|
||||
pub(crate) use craft_pcraft_format::{COMPOSITE, MANIFEST, SAVE_LOCK, THUMB};
|
||||
/// How long a directory save waits for another save of the same bundle to finish.
|
||||
const SAVE_LOCK_WAIT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
|
||||
fn tile_path(h: &str) -> String {
|
||||
format!("tiles/{h}.zst")
|
||||
}
|
||||
fn blob_path(h: &str) -> String {
|
||||
format!("blobs/{h}.zst")
|
||||
/// 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>),
|
||||
}
|
||||
|
||||
fn compress(data: &[u8]) -> Vec<u8> {
|
||||
ruzstd::encoding::compress_to_vec(data, ruzstd::encoding::CompressionLevel::Fastest)
|
||||
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
|
||||
@@ -149,64 +219,27 @@ pub fn read_entry(file: &mut impl std::io::Read, len: u64, max: usize, path: &st
|
||||
// Loading
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Deepest JSON nesting a manifest may have: each group level adds three (the layer object, its
|
||||
/// content object and the `children` array), with headroom for the document and leaf layer fields.
|
||||
/// The saver checks the same bound, so every bundle it writes loads again.
|
||||
const MAX_MANIFEST_DEPTH: usize = 3 * (convert::MAX_GROUP_DEPTH + 1) + 64;
|
||||
|
||||
/// Most JSON items (arrays and objects opened, plus elements and members after a comma) a
|
||||
/// manifest may hold. Parsing builds a `serde_json::Value` tree of tens of bytes per item, so
|
||||
/// `[0,0,…]` costs about 16× its size in memory; a typical manifest spends 20 bytes or more per
|
||||
/// item, so this still admits manifests of about 80 MiB.
|
||||
const MAX_MANIFEST_ITEMS: usize = 4 << 20;
|
||||
|
||||
/// Rejects JSON nested deeper than [`MAX_MANIFEST_DEPTH`] or holding more than
|
||||
/// [`MAX_MANIFEST_ITEMS`] items with a linear scan (no recursion), so parsing with serde_json's
|
||||
/// recursion limit disabled can't exhaust the stack, nor tiny values the memory, on hostile input.
|
||||
fn check_manifest_depth(json: &[u8]) -> Result<()> {
|
||||
let (mut depth, mut items, mut in_str, mut escaped) = (0usize, 0usize, false, false);
|
||||
for &b in json {
|
||||
if in_str {
|
||||
match b {
|
||||
_ if escaped => escaped = false,
|
||||
b'\\' => escaped = true,
|
||||
b'"' => in_str = false,
|
||||
_ => {}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match b {
|
||||
b'"' => in_str = true,
|
||||
b'[' | b'{' => {
|
||||
depth += 1;
|
||||
if depth > MAX_MANIFEST_DEPTH {
|
||||
return Err(FormatError::LimitExceeded(format!("manifest JSON nested deeper than {MAX_MANIFEST_DEPTH} levels")));
|
||||
}
|
||||
}
|
||||
b']' | b'}' => depth = depth.saturating_sub(1),
|
||||
_ => {}
|
||||
}
|
||||
if matches!(b, b'[' | b'{' | b',') {
|
||||
items += 1;
|
||||
if items > MAX_MANIFEST_ITEMS {
|
||||
return Err(FormatError::LimitExceeded(format!("manifest JSON holds more than {MAX_MANIFEST_ITEMS} values")));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
/// The limits a manifest is checked against: craft-pcraft-format's item limit, the JSON nesting
|
||||
/// of [`convert::MAX_GROUP_DEPTH`] nested groups ([`craft_pcraft_format::manifest_depth`]) and at
|
||||
/// most `max_manifest_bytes` bytes. The saver checks the same nesting and items, so every bundle
|
||||
/// it writes loads again.
|
||||
fn manifest_limits(max_manifest_bytes: usize) -> craft_pcraft_format::Limits {
|
||||
let mut limits = craft_pcraft_format::Limits::default();
|
||||
limits.max_manifest_bytes = max_manifest_bytes;
|
||||
limits.max_depth = craft_pcraft_format::manifest_depth(convert::MAX_GROUP_DEPTH);
|
||||
limits
|
||||
}
|
||||
|
||||
pub(crate) fn read_manifest(src: &dyn ByteSource, opts: &LoadOptions) -> Result<Manifest> {
|
||||
let raw = src.get(MANIFEST, opts.max_manifest_bytes)?;
|
||||
// serde_json stops at 128 levels (about 40 nested groups); the bounded check replaces that limit.
|
||||
check_manifest_depth(&raw)?;
|
||||
let mut de = serde_json::Deserializer::from_slice(&raw);
|
||||
de.disable_recursion_limit();
|
||||
let mut v = <serde_json::Value as serde::Deserialize>::deserialize(&mut de)?;
|
||||
de.end()?;
|
||||
crate::migrate::migrate(&mut v)?;
|
||||
let layers = take_array(&mut v, "/document/layers");
|
||||
let mut m: Manifest = serde_json::from_value(v)?;
|
||||
// serde_json stops at 128 levels (about 40 nested groups); `read_manifest_with` checks the
|
||||
// nesting and the items in one pass, then parses without that limit.
|
||||
let limits = manifest_limits(opts.max_manifest_bytes);
|
||||
let mut layers = Vec::new();
|
||||
let mut m: Manifest = craft_pcraft_format::read_manifest_with(&raw, &limits, crate::migrate::STEPS, FORMAT_VERSION, |mut document| {
|
||||
layers = take_array(&mut document, "/layers");
|
||||
Ok::<_, FormatError>(serde_json::from_value(document)?)
|
||||
})?;
|
||||
m.document.layers = layers_from_json(layers)?;
|
||||
// A number past f32's range decodes as infinity, which no save could write back (#6862).
|
||||
if let Err(crate::finite::Problem::NonFinite { path }) = crate::finite::check(&m) {
|
||||
@@ -263,11 +296,10 @@ impl LoadFetch<'_> {
|
||||
|
||||
impl Fetch for LoadFetch<'_> {
|
||||
fn tile(&mut self, hash: &str, len: usize) -> Result<Vec<u8>> {
|
||||
if !is_valid_hash(hash) {
|
||||
let Some(path) = tile_path(hash) else {
|
||||
return Err(FormatError::corrupt(format!("invalid tile hash `{hash}`")));
|
||||
}
|
||||
};
|
||||
self.account(len)?;
|
||||
let path = tile_path(hash);
|
||||
let z = self.src.get(&path, len + len / 8 + 4096)?;
|
||||
let data = decompress(&z, len, &path)?;
|
||||
if hash_bytes(&data) != hash {
|
||||
@@ -288,10 +320,9 @@ impl Fetch for LoadFetch<'_> {
|
||||
if let Some(b) = self.blobs.get(hash) {
|
||||
return if b.len() <= limit { Ok(b.clone()) } else { Err(FormatError::LimitExceeded("optional preview blob exceeds its limit".into())) };
|
||||
}
|
||||
if !is_valid_hash(hash) {
|
||||
let Some(path) = blob_path(hash) else {
|
||||
return Err(FormatError::corrupt(format!("invalid blob hash `{hash}`")));
|
||||
}
|
||||
let path = blob_path(hash);
|
||||
};
|
||||
// The stored zstd frame of incompressible data is a little larger than the data, so a
|
||||
// blob right at `limit` must still be read; `decompress_bounded` enforces `limit`.
|
||||
let z = self.src.get(&path, limit.saturating_add(limit / 8).saturating_add(4096))?;
|
||||
@@ -528,8 +559,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).
|
||||
@@ -607,7 +638,7 @@ fn bundle_root(dir: &Path) -> Result<PathBuf> {
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
let root = std::fs::canonicalize(&dir)?;
|
||||
plain_directory_or_missing(&root)?;
|
||||
for sub in ["tiles", "blobs", "composite"] {
|
||||
for sub in [TILES_DIR, BLOBS_DIR, COMPOSITE_DIR] {
|
||||
let path = root.join(sub);
|
||||
plain_directory_or_missing(&path)?;
|
||||
if !path.exists() {
|
||||
@@ -627,7 +658,7 @@ fn bundle_root(dir: &Path) -> Result<PathBuf> {
|
||||
|
||||
fn validate_bundle_file(root: &Path, path: &Path) -> Result<()> {
|
||||
let parent = path.parent().ok_or_else(|| unsafe_bundle_entry(path))?;
|
||||
if parent != root && parent != root.join("tiles") && parent != root.join("blobs") && parent != root.join("composite") {
|
||||
if parent != root && parent != root.join(TILES_DIR) && parent != root.join(BLOBS_DIR) && parent != root.join(COMPOSITE_DIR) {
|
||||
return Err(unsafe_bundle_entry(path));
|
||||
}
|
||||
plain_directory_or_missing(parent)?;
|
||||
@@ -784,9 +815,9 @@ impl BundleGc {
|
||||
|
||||
let flags = gc_directory_flags();
|
||||
let root = open(root, flags, Mode::empty()).map_err(std::io::Error::from)?;
|
||||
let tiles = openat(&root, "tiles", flags, Mode::empty()).map_err(std::io::Error::from)?;
|
||||
let blobs = openat(&root, "blobs", flags, Mode::empty()).map_err(std::io::Error::from)?;
|
||||
let composite = openat(&root, "composite", flags, Mode::empty()).map_err(std::io::Error::from)?;
|
||||
let tiles = openat(&root, TILES_DIR, flags, Mode::empty()).map_err(std::io::Error::from)?;
|
||||
let blobs = openat(&root, BLOBS_DIR, flags, Mode::empty()).map_err(std::io::Error::from)?;
|
||||
let composite = openat(&root, COMPOSITE_DIR, flags, Mode::empty()).map_err(std::io::Error::from)?;
|
||||
Ok(Self { root, tiles, blobs, composite })
|
||||
}
|
||||
|
||||
@@ -796,8 +827,8 @@ impl BundleGc {
|
||||
THUMB => (&self.root, THUMB),
|
||||
COMPOSITE => (&self.composite, "preview.png"),
|
||||
_ => match relative.split_once('/') {
|
||||
Some(("tiles", name)) if name.strip_suffix(".zst").is_some_and(is_valid_hash) => (&self.tiles, name),
|
||||
Some(("blobs", name)) if name.strip_suffix(".zst").is_some_and(is_valid_hash) => (&self.blobs, name),
|
||||
Some((TILES_DIR, name)) if name.strip_suffix(craft_pcraft_format::OBJECT_SUFFIX).is_some_and(is_valid_hash) => (&self.tiles, name),
|
||||
Some((BLOBS_DIR, name)) if name.strip_suffix(craft_pcraft_format::OBJECT_SUFFIX).is_some_and(is_valid_hash) => (&self.blobs, name),
|
||||
_ => return Err(FormatError::Unsupported(format!("invalid bundle cleanup target: {relative}"))),
|
||||
},
|
||||
};
|
||||
@@ -860,7 +891,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 +900,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
|
||||
})
|
||||
@@ -919,13 +952,9 @@ impl PcraftWriter {
|
||||
convert::check_nesting(&doc.layers)?;
|
||||
let mut c = Collect { hash_cache: Some(&mut self.hash_cache), blob_hash_cache: Some(&mut self.blob_hash_cache), ..Default::default() };
|
||||
let document = convert::doc_m(doc, &mut c);
|
||||
let manifest = Manifest {
|
||||
format_version: FORMAT_VERSION,
|
||||
generator: format!("photocraft-format {}", env!("CARGO_PKG_VERSION")),
|
||||
document,
|
||||
thumbnail: opts.thumbnail.as_ref().map(|_| THUMB.to_owned()),
|
||||
composite: opts.composite.as_ref().map(|_| COMPOSITE.to_owned()),
|
||||
};
|
||||
let mut manifest = Manifest::new(format!("photocraft-format {}", env!("CARGO_PKG_VERSION")), document);
|
||||
manifest.thumbnail = opts.thumbnail.as_ref().map(|_| THUMB.to_owned());
|
||||
manifest.composite = opts.composite.as_ref().map(|_| COMPOSITE.to_owned());
|
||||
// serde_json writes NaN and the infinities as `null`, which the manifest's float fields
|
||||
// refuse on load (issue #1101): refuse the save instead, naming the value.
|
||||
crate::finite::check(&manifest).map_err(|e| match e {
|
||||
@@ -933,14 +962,16 @@ impl PcraftWriter {
|
||||
crate::finite::Problem::Other(m) => FormatError::Unsupported(format!("manifest: {m}")),
|
||||
})?;
|
||||
let manifest = serde_json::to_vec_pretty(&manifest)?;
|
||||
check_manifest_depth(&manifest)?;
|
||||
// The nesting and the items only: a save checks no manifest size.
|
||||
craft_pcraft_format::check_manifest(&manifest, &manifest_limits(usize::MAX))?;
|
||||
let stats = SaveStats { tiles_total: c.tiles.len(), blobs_total: c.blobs.len(), manifest_bytes: manifest.len(), ..Default::default() };
|
||||
let invalid = |h: &str| FormatError::corrupt(format!("invalid object hash `{h}`"));
|
||||
let mut objects = BTreeMap::new();
|
||||
for (h, (t, s)) in c.tiles {
|
||||
objects.insert(tile_path(&h), Object::Tile(t, s));
|
||||
objects.insert(tile_path(&h).ok_or_else(|| invalid(&h))?, Object::Tile(t, s));
|
||||
}
|
||||
for (h, b) in c.blobs {
|
||||
objects.insert(blob_path(&h), Object::Blob(b));
|
||||
objects.insert(blob_path(&h).ok_or_else(|| invalid(&h))?, Object::Blob(b));
|
||||
}
|
||||
Ok(Prepared { manifest, objects, stats })
|
||||
}
|
||||
@@ -976,6 +1007,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 +1021,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 +1068,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 +1103,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 {
|
||||
@@ -1095,12 +1128,12 @@ impl PcraftWriter {
|
||||
// flush. The retry can reuse those valid bytes (including cached ones), so flush
|
||||
// both object directories again before publishing a manifest that references them.
|
||||
// This also persists the fixed subdirectories of a newly created bundle.
|
||||
for sub in ["tiles", "blobs"] {
|
||||
for sub in [TILES_DIR, BLOBS_DIR] {
|
||||
fs.sync_dir(&dir.join(sub)).map_err(|e| crate::atomic::publication_error(e, &dir.join(sub)))?;
|
||||
}
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
for sub in ["tiles", "blobs"] {
|
||||
for sub in [TILES_DIR, BLOBS_DIR] {
|
||||
let folder = dir.join(sub);
|
||||
if folder.is_dir() {
|
||||
confirm_flushed(&folder, &folder)?;
|
||||
@@ -1421,6 +1454,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,10 +1507,10 @@ 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);
|
||||
let src = One(blob_path(&hash).unwrap(), z);
|
||||
let mut fetch = LoadFetch { src: &src, opts: crate::LoadOptions::default(), total: 0, blobs: HashMap::new() };
|
||||
assert_eq!(fetch.blob_bounded(&hash, data.len()).unwrap().as_slice(), data.as_slice());
|
||||
assert!(LoadFetch { src: &src, opts: crate::LoadOptions::default(), total: 0, blobs: HashMap::new() }.blob_bounded(&hash, data.len() - 1).is_err());
|
||||
|
||||
@@ -216,6 +216,13 @@ fn nesting_beyond_the_load_limit_is_refused_at_save() {
|
||||
assert!(matches!(e, FormatError::LimitExceeded(_)), "{e}");
|
||||
}
|
||||
|
||||
/// craft-pcraft-format's default manifest nesting limit is computed from its own copy of the
|
||||
/// group depth, so the two must stay equal.
|
||||
#[test]
|
||||
fn group_depth_matches_craft_pcraft_format() {
|
||||
assert_eq!(MAX_GROUP_DEPTH, craft_pcraft_format::MAX_GROUP_DEPTH);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_document_roundtrips() {
|
||||
let doc = Document::new("empty", photocraft_doc::Size::new(1, 1), ColorMode::Rgb, SampleType::U8);
|
||||
@@ -524,6 +531,22 @@ fn save_is_deterministic() {
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
|
||||
/// A bundle that is read and saved again gives the same bytes, previews included.
|
||||
#[test]
|
||||
fn save_load_save_gives_the_same_bytes() {
|
||||
let mut preview = Rgba8Image::new(4, 3);
|
||||
preview.pixels.iter_mut().enumerate().for_each(|(i, p)| *p = i as u8);
|
||||
for opts in [SaveOptions::default(), SaveOptions { thumbnail: Some(preview.clone()), composite: Some(preview.clone()) }] {
|
||||
for mode in MODES {
|
||||
for depth in SampleType::ALL {
|
||||
let first = save_to_bytes(&rich_doc(mode, depth), &opts).unwrap();
|
||||
let again = save_to_bytes(&load_from_bytes(&first).unwrap(), &opts).unwrap();
|
||||
assert!(first == again, "{mode:?} {depth:?}: the second save differs");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_pcraft() {
|
||||
assert!(!is_pcraft(b""));
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Loaded 100 of 112 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user