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:
ljkxandClaude Opus 5.5 committed 2026-11-02 19:59:27 +01:00
commit 7eb54124ea
112 files changed
+2240 -406

No files matched your search

Generated
+11
View File
@@ -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",
+1
View File
@@ -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"
+2
View File
@@ -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());
+2
View File
@@ -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:?}");
}
+4 -1
View File
@@ -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"))]
+4
View File
@@ -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
View File
@@ -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
}
+3 -2
View File
@@ -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
+25 -4
View File
@@ -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]
+5 -1
View File
@@ -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"))]
+6 -1
View File
@@ -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)] {
+1
View File
@@ -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`
+2
View File
@@ -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())
}
+4 -2
View File
@@ -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
+1
View File
@@ -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};
+33 -5
View File
@@ -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,
+222
View File
@@ -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]);
}
}
+20 -5
View File
@@ -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),
}
}
+1
View File
@@ -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;
+259
View File
@@ -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}");
}
}
+13 -2
View File
@@ -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;
+4 -4
View File
@@ -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 {
+4
View File
@@ -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;
}
+26 -10
View File
@@ -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
+15 -48
View File
@@ -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
}
+10 -2
View File
@@ -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])
+24 -3
View File
@@ -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] {
+2 -3
View File
@@ -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();
+1 -1
View File
@@ -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
View File
@@ -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));
}
+27
View File
@@ -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]));
}
+1 -1
View File
@@ -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 {
+3 -3
View File
@@ -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();
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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);
+1 -1
View File
@@ -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();
+1 -1
View File
@@ -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.
+2 -2
View File
@@ -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}");
+1 -1
View File
@@ -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()
}
}
+1 -1
View File
@@ -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() {
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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)
}
+1 -1
View File
@@ -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");
}
+1 -1
View File
@@ -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 };
+3 -3
View File
@@ -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));
+2
View File
@@ -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);
+50 -9
View File
@@ -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
+1
View File
@@ -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;
+81
View File
@@ -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")])));
}
}
+10 -3
View File
@@ -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];
+3 -1
View File
@@ -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);
}
}
+2 -2
View File
@@ -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.
+1 -1
View File
@@ -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) {
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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();
+114
View File
@@ -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);
}
+1
View File
@@ -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 }
+1 -1
View File
@@ -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);
+2 -4
View File
@@ -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> {
+19 -4
View File
@@ -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,
+4 -24
View File
@@ -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,
+15 -29
View File
@@ -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
View File
@@ -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());
+23
View File
@@ -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""));
+10 -4
View File
@@ -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
View File
@@ -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`.
+2
View File
@@ -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).
+1 -1
View File
@@ -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);
+2 -2
View File
@@ -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) {
+8 -1
View File
@@ -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)
+2 -2
View File
@@ -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)]
+2 -2
View File
@@ -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) {
+3 -1
View File
@@ -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;
}
+3 -4
View File
@@ -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)
}
+2
View File
@@ -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);
+8
View File
@@ -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));
+2
View File
@@ -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)
}
+1 -1
View File
@@ -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}");
+1 -1
View File
@@ -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");
+2 -1
View File
@@ -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();
+4 -4
View File
@@ -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:?}");
+3 -3
View File
@@ -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");
}
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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)
}
+1 -1
View File
@@ -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);
+2 -2
View File
@@ -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:?}");
+1 -1
View File
@@ -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 {
+2 -2
View File
@@ -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.
+2 -2
View File
@@ -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}");
+1 -1
View File
@@ -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);
+1 -1
View File
@@ -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);
}
+2 -2
View File
@@ -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}"));
+2 -2
View File
@@ -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