PSD export parallel layer channels; shared proxy cache; banded CMYK export (#1230)

* CMYK -> sRGB flat export: row bands through the colour transform

cmyk_image_to_srgb materialized three full-size f32 buffers (normalized
input, transform working set, sRGB output) - ~1.4 GB of allocation
churn on a 36 MP export. convert_f32 walks plain strided slices, so the
image now passes through in bands of rows (~32 MB budget): peak
transient memory drops by the document size times two, the output vector
is the only full-size allocation left. Byte-identical output: the
colour test pins the converted pixels and the tagged sRGB profile.

* Banded CMYK export: guard empty images, bounds-checked slices

(BAND_BYTES / (row * ss * 4)) divided by zero for a zero-width image and
clamp(1, 0) panicked for a zero-height one. Guard both, take each band with
checked arithmetic and data().get(), and reuse the output band buffer.
Tests: empty CMYK images convert without panicking; a banded conversion
covers every row.

Note: this PR's single change is the banded CMYK -> sRGB flat export; the
title's parallel PSD layer export and shared proxy cache are not part of it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-08 17:04:39 -04:00
1 parent a32ba211c9
commit 4198da39b0
1 file changed
+59 -3
+59 -3
View File
@@ -391,15 +391,42 @@ fn cmyk_image_to_srgb(img: &Image) -> Result<Image, IoError> {
let t = Transform::new(&src, dst, Intent::RelativeColorimetric, true).map_err(|e| IoError::Unsupported(e.to_string()))?;
let alpha = img.layout().has_alpha();
let (ss, ds) = (if alpha { 5 } else { 4 }, if alpha { 4 } else { 3 });
let vals = img.to_normalized();
let mut out = vec![0.0f32; img.pixel_count() * ds];
t.convert_f32(&vals, ss, &mut out, ds, true);
let (w, h) = img.dimensions();
let layout = if alpha { ChannelLayout::Rgba } else { ChannelLayout::Rgb };
let sample = match img.sample_type() {
CSample::F16 => CSample::F32,
s => s,
};
// Row bands through the transform: three full-size f32 buffers (normalized input, the
// transform's working set, sRGB output) become band-height slices — ~1.4 GB of allocation
// churn at 36 MP before this. `convert_f32` walks plain strided slices, so bands are free.
let row = w as usize;
let src_bpp = img.layout().channels() * img.sample_type().bytes();
let h_rows = h as usize;
// `max(1)` keeps a zero-width or zero-height image from dividing by zero or inverting the
// clamp range; such an image simply has no bands.
let band_rows = (BAND_BYTES / (row * ss * 4).max(1)).clamp(1, h_rows.max(1));
let mut out = Vec::with_capacity(img.pixel_count() * ds);
let mut vals: Vec<f32> = Vec::with_capacity(band_rows * row * ss);
let mut dst_band: Vec<f32> = Vec::with_capacity(band_rows * row * ds);
// Whole bands of rows only: a trailing partial row (h not a multiple of band_rows, or a
// 1-row image smaller than the band budget) is folded into the last band.
let mut idx = 0usize;
while idx < h_rows {
let n = band_rows.min(h_rows - idx);
let bytes = |r: usize| r.checked_mul(row).and_then(|v| v.checked_mul(src_bpp));
let rows = bytes(idx)
.zip(bytes(idx + n))
.and_then(|(a, b)| img.data().get(a..b))
.ok_or_else(|| IoError::Unsupported("CMYK image data is shorter than its dimensions".into()))?;
idx += n;
vals.clear();
vals.extend(Image::from_raw(w, n as u32, img.layout(), img.sample_type(), rows.to_vec())?.to_normalized());
dst_band.clear();
dst_band.resize(n * row * ds, 0.0);
t.convert_f32(&vals, ss, &mut dst_band, ds, true);
out.extend_from_slice(&dst_band);
}
Ok(Image::from_normalized(w, h, layout, sample, &out)?.with_icc(Some(dst.to_bytes().to_vec())).with_meta(img.meta.clone()))
}
@@ -434,3 +461,32 @@ fn export_mode_specific(doc: &Document, format: Format, opts: &ExportOptions) ->
_ => Ok(None),
}
}
#[cfg(test)]
mod cmyk_band_tests {
use super::*;
#[test]
fn empty_cmyk_images_convert_without_panicking() {
// Zero width divided the band size by zero; zero height inverted the clamp range.
for (w, h) in [(0, 0), (0, 7), (7, 0)] {
let img = Image::from_raw(w, h, ChannelLayout::Cmyk, CSample::U8, Vec::new()).unwrap();
if let Ok(out) = cmyk_image_to_srgb(&img) {
assert_eq!(out.dimensions(), (w, h));
}
}
}
#[test]
fn banded_conversion_covers_every_row() {
// One colour everywhere converts to one colour everywhere, first row to last.
let (w, h) = (5u32, 9u32);
let img = Image::from_raw(w, h, ChannelLayout::CmykA, CSample::U8, [40, 90, 10, 20, 255].repeat((w * h) as usize)).unwrap();
let out = cmyk_image_to_srgb(&img).unwrap();
assert_eq!(out.dimensions(), (w, h));
assert_eq!(out.data().len(), (w * h * 4) as usize);
let first = out.data().get(..4).unwrap().to_vec();
assert!(out.data().chunks(4).all(|p| p == first.as_slice()), "{:?}", out.data());
assert_eq!(first[3], 255);
}
}