mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-14 19:26:51 -04:00
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:
1 file changed
+59
-3
+59
-3
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user