mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-02 22:30:20 -06:00
Merge main
# Conflicts: # crates/format/src/store.rs
This commit is contained in:
+99
-20
@@ -64,6 +64,34 @@ fn png(img: &Rgba8Image) -> Result<Vec<u8>> {
|
||||
photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).map_err(|e| FormatError::Unsupported(format!("preview: {e}")))
|
||||
}
|
||||
|
||||
fn previews(opts: &SaveOptions) -> Result<Vec<(&'static str, Vec<u8>)>> {
|
||||
let (composite, thumbnail) = beside(
|
||||
opts.thumbnail.is_some() && opts.composite.is_some(),
|
||||
|| opts.composite.as_ref().map(png).transpose(),
|
||||
|| opts.thumbnail.as_ref().map(png).transpose(),
|
||||
);
|
||||
let mut out = Vec::new();
|
||||
if let Some(t) = thumbnail? {
|
||||
out.push((THUMB, t));
|
||||
}
|
||||
if let Some(t) = composite? {
|
||||
out.push((COMPOSITE, t));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn beside<A: Send, B>(parallel: bool, a: impl FnOnce() -> Result<A> + Send, b: impl FnOnce() -> B) -> (Result<A>, B) {
|
||||
if !parallel || cfg!(target_arch = "wasm32") {
|
||||
let a = a();
|
||||
return (a, b());
|
||||
}
|
||||
std::thread::scope(|sc| {
|
||||
let h = sc.spawn(a);
|
||||
let b = b();
|
||||
(h.join().unwrap_or_else(|_| Err(FormatError::Io(std::io::Error::other("preview worker panicked")))), b)
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sources
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -481,7 +509,6 @@ struct FileSignature {
|
||||
struct Prepared {
|
||||
manifest: Vec<u8>,
|
||||
objects: BTreeMap<String, Object>,
|
||||
previews: Vec<(&'static str, Vec<u8>)>,
|
||||
stats: SaveStats,
|
||||
}
|
||||
|
||||
@@ -882,13 +909,6 @@ 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 mut previews = Vec::new();
|
||||
if let Some(t) = &opts.thumbnail {
|
||||
previews.push((THUMB, png(t)?));
|
||||
}
|
||||
if let Some(t) = &opts.composite {
|
||||
previews.push((COMPOSITE, png(t)?));
|
||||
}
|
||||
let manifest = Manifest {
|
||||
format_version: FORMAT_VERSION,
|
||||
generator: format!("photocraft-format {}", env!("CARGO_PKG_VERSION")),
|
||||
@@ -912,27 +932,38 @@ impl PcraftWriter {
|
||||
for (h, b) in c.blobs {
|
||||
objects.insert(blob_path(&h), Object::Blob(b));
|
||||
}
|
||||
Ok(Prepared { manifest, objects, previews, stats })
|
||||
Ok(Prepared { manifest, objects, stats })
|
||||
}
|
||||
|
||||
/// Save as a ZIP bundle in memory.
|
||||
pub fn save_zip(&mut self, doc: &Document, opts: &SaveOptions) -> Result<(Vec<u8>, SaveStats)> {
|
||||
let p = self.prepare(doc, opts)?;
|
||||
// Another thread encodes the previews while the document is collected and its new
|
||||
// objects are compressed on all cores (zstd dominates a full save).
|
||||
let (previews, prepared) = beside(
|
||||
opts.thumbnail.is_some() || opts.composite.is_some(),
|
||||
|| previews(opts),
|
||||
|| -> Result<_> {
|
||||
let p = self.prepare(doc, opts)?;
|
||||
let todo: Vec<(&String, &Object)> = p.objects.iter().filter(|(path, _)| !self.compressed.contains_key(*path)).collect();
|
||||
let fresh: HashMap<String, Arc<Vec<u8>>> = par_compress(&todo)?.into_iter().map(|(path, data)| (path.clone(), data)).collect();
|
||||
Ok((p, fresh))
|
||||
},
|
||||
);
|
||||
let (p, mut fresh) = prepared?;
|
||||
let previews = previews?;
|
||||
let mut stats = p.stats;
|
||||
let mut next = HashMap::with_capacity(p.objects.len());
|
||||
let mut cached_bytes = 0usize;
|
||||
// New objects are compressed on all cores first (zstd dominates a full save).
|
||||
let todo: Vec<(&String, &Object)> = p.objects.iter().filter(|(path, _)| !self.compressed.contains_key(*path)).collect();
|
||||
let mut fresh: HashMap<&String, Arc<Vec<u8>>> = par_compress(&todo)?.into_iter().collect();
|
||||
let entries = std::iter::once((MANIFEST, p.manifest.len())).chain(p.previews.iter().map(|(name, data)| (*name, data.len()))).chain(
|
||||
p.objects.keys().map(|path| {
|
||||
// The archive is sized for every entry up front (#7689): previews, then reused and fresh
|
||||
// objects at their compressed sizes.
|
||||
let entries =
|
||||
std::iter::once((MANIFEST, p.manifest.len())).chain(previews.iter().map(|(name, data)| (*name, data.len()))).chain(p.objects.keys().map(|path| {
|
||||
let size = self.compressed.get(path).or_else(|| fresh.get(path)).map_or(0, |data| data.len());
|
||||
(path.as_str(), size)
|
||||
}),
|
||||
);
|
||||
}));
|
||||
let mut z = ZipWriter::with_entries(entries)?;
|
||||
z.add(MANIFEST, &p.manifest)?;
|
||||
for (name, data) in &p.previews {
|
||||
for (name, data) in &previews {
|
||||
z.add(name, data)?;
|
||||
}
|
||||
for (path, obj) in &p.objects {
|
||||
@@ -982,6 +1013,7 @@ impl PcraftWriter {
|
||||
validate_bundle_file(dir, &dir.join(path))?;
|
||||
}
|
||||
let p = self.prepare(doc, opts)?;
|
||||
let previews = previews(opts)?;
|
||||
let mut stats = p.stats;
|
||||
if self.verified_directory.as_ref().is_none_or(|cache| cache.directory != canonical_dir) {
|
||||
self.verified_directory = Some(DirectoryVerificationCache { directory: canonical_dir.clone(), ..Default::default() });
|
||||
@@ -1041,7 +1073,7 @@ impl PcraftWriter {
|
||||
}
|
||||
}
|
||||
}
|
||||
for (name, data) in &p.previews {
|
||||
for (name, data) in &previews {
|
||||
write_bundle_file(dir, &dir.join(name), data, fs)?;
|
||||
}
|
||||
// Pin all GC directories before removing stale previews or objects.
|
||||
@@ -1068,7 +1100,7 @@ impl PcraftWriter {
|
||||
// Omitted previews belong to the previous manifest until the new one commits.
|
||||
// After commit, a locked leftover is only housekeeping, just like unused objects.
|
||||
for stale in [THUMB, COMPOSITE] {
|
||||
if !p.previews.iter().any(|(n, _)| *n == stale) {
|
||||
if !previews.iter().any(|(n, _)| *n == stale) {
|
||||
let _ = remove_bundle_file(dir, &dir.join(stale), &gc);
|
||||
}
|
||||
}
|
||||
@@ -1196,6 +1228,53 @@ fn compressed_cache_budget_limits_retained_capacity_without_changing_bytes() {
|
||||
assert!(writer.compressed.is_empty());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[test]
|
||||
fn previews_follow_the_manifest_with_the_bytes_of_a_serial_encode() {
|
||||
use photocraft_doc::{Layer, LayerContent};
|
||||
use photocraft_geom::Size;
|
||||
use photocraft_raster::Surface;
|
||||
|
||||
let mut doc = Document::new("previews", Size::new(600, 3), photocraft_color::ColorMode::Rgb, SampleType::U8);
|
||||
let mut surface = Surface::new(doc.pixel_format());
|
||||
for x in 0..600 {
|
||||
surface.write_pixel(x, 1, &[x as f32 / 600.0, 0.5, 0.25, 1.0]);
|
||||
}
|
||||
doc.layers.push(Layer::new("pixels", LayerContent::Raster(surface)));
|
||||
let image = |w: u32, h: u32, k: u8| {
|
||||
let mut i = Rgba8Image::new(w, h);
|
||||
i.pixels.iter_mut().enumerate().for_each(|(j, p)| *p = (j as u8).wrapping_mul(k));
|
||||
i
|
||||
};
|
||||
let (thumb, composite) = (image(4, 3, 7), image(9, 5, 13));
|
||||
for (t, c) in [(true, true), (true, false), (false, true), (false, false)] {
|
||||
let opts = SaveOptions { thumbnail: t.then(|| thumb.clone()), composite: c.then(|| composite.clone()) };
|
||||
let (bytes, _) = PcraftWriter::new().save_zip(&doc, &opts).unwrap();
|
||||
let zip = ZipReader::new(&bytes).unwrap();
|
||||
let names: Vec<&str> = zip.entries.iter().map(|e| e.name.as_str()).collect();
|
||||
let mut want = vec![MANIFEST];
|
||||
want.extend(t.then_some(THUMB));
|
||||
want.extend(c.then_some(COMPOSITE));
|
||||
assert_eq!(names[..want.len()], want[..]);
|
||||
assert!(names[want.len()..].iter().all(|n| n.starts_with("tiles/") || n.starts_with("blobs/")), "{names:?}");
|
||||
for (name, img) in [(THUMB, opts.thumbnail.as_ref()), (COMPOSITE, opts.composite.as_ref())] {
|
||||
assert_eq!(zip.read_by_name(name, usize::MAX).ok(), img.map(|i| png(i).unwrap()), "{name}");
|
||||
}
|
||||
}
|
||||
let bad = SaveOptions { thumbnail: Some(Rgba8Image { width: 4, height: 3, pixels: vec![0; 5] }), composite: Some(composite.clone()) };
|
||||
assert!(matches!(PcraftWriter::new().save_zip(&doc, &bad), Err(FormatError::Unsupported(m)) if m.starts_with("preview")));
|
||||
let bad = SaveOptions { thumbnail: Some(thumb.clone()), composite: Some(Rgba8Image { width: 9, height: 5, pixels: vec![0; 5] }) };
|
||||
assert!(matches!(PcraftWriter::new().save_zip(&doc, &bad), Err(FormatError::Unsupported(m)) if m.starts_with("preview")));
|
||||
|
||||
let dir = std::env::temp_dir().join(format!("pcraft-previews-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let opts = SaveOptions { thumbnail: Some(thumb.clone()), composite: Some(composite.clone()) };
|
||||
PcraftWriter::new().save_dir(&doc, &dir, &opts).unwrap();
|
||||
assert_eq!(std::fs::read(dir.join(THUMB)).unwrap(), png(&thumb).unwrap());
|
||||
assert_eq!(std::fs::read(dir.join(COMPOSITE)).unwrap(), png(&composite).unwrap());
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
#[test]
|
||||
fn gc_pinned_directory_handles_resist_subdirectory_swap() {
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
//! Export timings on a realistic photo document (default 7360×4912 = 36 MP): the opened photo,
|
||||
//! a painted layer covering part of the canvas, and a soft half-transparent layer.
|
||||
//!
|
||||
//! ```sh
|
||||
//! cargo run --release -p photocraft-io --example bench_export -- [--size 7360x4912] [--reps 7] [--only pcraft] [--out DIR]
|
||||
//! ```
|
||||
//!
|
||||
//! Each row is the median (and min) of `--reps` runs. `--out DIR` writes each format's bytes to
|
||||
//! `DIR/out.<ext>` so two builds' output can be compared.
|
||||
use std::time::Instant;
|
||||
|
||||
use photocraft_doc::Layer;
|
||||
use photocraft_geom::Rect;
|
||||
|
||||
fn arg(args: &[String], name: &str) -> Option<String> {
|
||||
args.iter().position(|a| a == name).and_then(|i| args.get(i + 1).cloned())
|
||||
}
|
||||
|
||||
fn photo(w: u32, h: u32) -> photocraft_codecs::Image {
|
||||
let mut px = vec![0u8; w as usize * h as usize * 3];
|
||||
for (y, row) in px.chunks_mut(w as usize * 3).enumerate() {
|
||||
for x in 0..w as usize {
|
||||
let (fx, fy) = (x as f32 / w as f32, y as f32 / h as f32);
|
||||
let n = ((x.wrapping_mul(73_856_093) ^ y.wrapping_mul(19_349_663)) % 997) as f32 / 997.0 - 0.5;
|
||||
let blob = (-(((fx - 0.4) * 3.0).powi(2) + ((fy - 0.55) * 4.0).powi(2))).exp();
|
||||
let r = 0.55 + 0.35 * (fx * 6.0).sin() * 0.5 + 0.3 * blob + n * 0.06;
|
||||
let g = 0.45 + 0.25 * (fy * 9.0 + fx * 2.0).cos() * 0.5 + 0.2 * blob + n * 0.06;
|
||||
let b = 0.35 + 0.4 * fy - 0.2 * blob + n * 0.06;
|
||||
for (c, v) in [r, g, b].into_iter().enumerate() {
|
||||
row[x * 3 + c] = (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
photocraft_codecs::Image::from_raw(w, h, photocraft_codecs::ChannelLayout::Rgb, photocraft_codecs::SampleType::U8, px).expect("image")
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let (w, h) = arg(&args, "--size").and_then(|s| s.split_once('x').and_then(|(a, b)| Some((a.parse().ok()?, b.parse().ok()?)))).unwrap_or((7360u32, 4912u32));
|
||||
let reps: usize = arg(&args, "--reps").and_then(|s| s.parse().ok()).unwrap_or(7);
|
||||
let only = arg(&args, "--only");
|
||||
let out = arg(&args, "--out");
|
||||
|
||||
let png = photocraft_codecs::encode(&photo(w, h), photocraft_codecs::Format::Png, &Default::default()).expect("png");
|
||||
let mut doc = photocraft_io::import("photo.png", &png).expect("import").document;
|
||||
let fmt = doc.pixel_format();
|
||||
let mut paint = Layer::raster("paint", fmt);
|
||||
if let Some(s) = paint.surface_mut() {
|
||||
let (pw, ph) = (w / 2, h / 3);
|
||||
let data: Vec<f32> = (0..pw * ph)
|
||||
.flat_map(|i| {
|
||||
let (x, y) = (i % pw, i / pw);
|
||||
let a = if (x / 64 + y / 64) % 3 == 0 { 0.0 } else { 0.85 };
|
||||
[0.75, (x % 256) as f32 / 255.0, (y % 256) as f32 / 255.0, a]
|
||||
})
|
||||
.collect();
|
||||
s.write_region(Rect::from_xywh(w as i32 / 8, h as i32 / 4, pw, ph), &data);
|
||||
}
|
||||
doc.layers.push(paint);
|
||||
let mut soft = Layer::raster("soft", fmt);
|
||||
soft.opacity = 0.6;
|
||||
if let Some(s) = soft.surface_mut() {
|
||||
s.fill_rect(Rect::from_xywh(w as i32 / 2, 0, w / 2, h), &[0.1, 0.2, 0.9, 0.5]);
|
||||
}
|
||||
doc.layers.push(soft);
|
||||
|
||||
println!("document {w}×{h}, {} layers, {reps} reps (median / min ms)", doc.layers.len());
|
||||
// "pcraft+cache" is the app's Save: one export cache per document, so the first save
|
||||
// compresses every tile and later ones reuse what fits the cache (timed after a warm-up save).
|
||||
for ext in ["psd", "pcraft", "pcraft+cache", "tif", "png", "jpg"] {
|
||||
if only.as_deref().is_some_and(|o| o != ext) {
|
||||
continue;
|
||||
}
|
||||
let cached = ext == "pcraft+cache";
|
||||
let opts = photocraft_io::ExportOptions { pcraft_cache: cached.then(photocraft_io::PcraftExportCache::default), ..Default::default() };
|
||||
let name = format!("x.{}", ext.trim_end_matches("+cache"));
|
||||
if cached {
|
||||
photocraft_io::export(&doc, &name, &opts).expect("warm-up");
|
||||
}
|
||||
let mut times = Vec::with_capacity(reps);
|
||||
let mut bytes = Vec::new();
|
||||
for _ in 0..reps {
|
||||
let t = Instant::now();
|
||||
bytes = photocraft_io::export(&doc, &name, &opts).expect("export").bytes;
|
||||
times.push(t.elapsed().as_secs_f64() * 1000.0);
|
||||
}
|
||||
times.sort_by(f64::total_cmp);
|
||||
println!("{ext:>7}: {:9.1} / {:9.1} ({} bytes)", times[times.len() / 2], times[0], bytes.len());
|
||||
if let Some(dir) = &out {
|
||||
std::fs::write(format!("{dir}/out.{}", ext.replace('+', "-")), &bytes).expect("write");
|
||||
}
|
||||
}
|
||||
}
|
||||
+23
-4
@@ -93,11 +93,30 @@ pub fn deinterleave(bytes: &[u8], ch: usize, s: SampleType, invert: &[bool]) ->
|
||||
let bps = s.bytes();
|
||||
let n = bytes.len() / (ch * bps).max(1);
|
||||
if s == SampleType::U8 {
|
||||
// Fast path: one plane per thread, no per-sample copies.
|
||||
return par_map((0..ch).collect(), |c| {
|
||||
let it = bytes.iter().skip(c).step_by(ch).take(n);
|
||||
if invert[c] { it.map(|v| 255 - v).collect() } else { it.copied().collect() }
|
||||
// Fast path: one band of pixels per core, written straight into every plane.
|
||||
let mut planes = vec![vec![0u8; n]; ch];
|
||||
let bs = bands(n);
|
||||
let mut slots: Vec<Vec<&mut [u8]>> = bs.iter().map(|_| Vec::with_capacity(ch)).collect();
|
||||
for plane in &mut planes {
|
||||
let mut rest = plane.as_mut_slice();
|
||||
for (slot, band) in slots.iter_mut().zip(&bs) {
|
||||
let Some((part, tail)) = rest.split_at_mut_checked(band.len()) else { break };
|
||||
slot.push(part);
|
||||
rest = tail;
|
||||
}
|
||||
}
|
||||
par_map(bs.into_iter().zip(slots).collect(), |(band, mut outs)| {
|
||||
let src = bytes.get(band.start * ch..band.end * ch).unwrap_or(&[]);
|
||||
for (c, out) in outs.iter_mut().enumerate() {
|
||||
let px = src.chunks_exact(ch).filter_map(|p| p.get(c).copied());
|
||||
if invert.get(c).copied().unwrap_or(false) {
|
||||
out.iter_mut().zip(px).for_each(|(o, v)| *o = 255 - v);
|
||||
} else {
|
||||
out.iter_mut().zip(px).for_each(|(o, v)| *o = v);
|
||||
}
|
||||
}
|
||||
});
|
||||
return planes;
|
||||
}
|
||||
// One band of rows per core, appended in band order into pre-grown planes.
|
||||
let row = ch * bps;
|
||||
|
||||
@@ -29,39 +29,58 @@ pub(super) fn merged_planes(doc: &Document, fmt: &PixelFormat, cmyk: bool, matte
|
||||
if ctl.cancelled() {
|
||||
return Err(());
|
||||
}
|
||||
// Retain alpha whenever it differs from opaque at the stored precision.
|
||||
has_alpha |= band.px.iter().any(|p| match sample {
|
||||
SampleType::U8 => q255(p[3]) < 255,
|
||||
SampleType::U16 => (p[3].clamp(0.0, 1.0) * 65535.0 + 0.5) as u16 != u16::MAX,
|
||||
SampleType::F32 => p[3] != 1.0,
|
||||
});
|
||||
translucent |= band.px.iter().any(|p| p[3] < 1.0);
|
||||
let start = (band.rect.y0 - canvas.y0) as usize * w;
|
||||
// Converted and encoded on all cores, then copied into each plane.
|
||||
let parts = crate::pixels::par_map(crate::pixels::bands(band.px.len()), |range| {
|
||||
// The composite came through the document's CMYK profile: convert back through it too.
|
||||
photocraft_color::convert::with_cmyk_space(space.as_ref(), || {
|
||||
let mut out: Vec<Vec<u8>> = vec![Vec::with_capacity(range.len() * bps); cc + 1];
|
||||
let mut v = [0.0f32; 5];
|
||||
for p in &band.px[range.clone()] {
|
||||
photocraft_raster::from_rgba_into(fmt, *p, &mut v);
|
||||
for c in 0..=cc {
|
||||
// Matte against white like Photoshop (see `pixels::matte`).
|
||||
let m = if c < cc && matte { crate::pixels::matte(v[c], v[cc], white[c]) } else { v[c] };
|
||||
let x = if cmyk && c < cc { 1.0 - m } else { m };
|
||||
// 16-bit Lab a*/b* use Photoshop's 0..65280 scale (`pixels::lab16_chroma`).
|
||||
let x = if lab16 && (c == 1 || c == 2) { x * (crate::pixels::LAB16_CHROMA_MAX / 65535.0) } else { x };
|
||||
encode_be(x, sample, &mut out[c]);
|
||||
}
|
||||
}
|
||||
(range.start, out)
|
||||
let ranges = crate::pixels::bands(band.px.len());
|
||||
let mut slots: Vec<Vec<&mut [u8]>> = ranges.iter().map(|_| Vec::with_capacity(cc + 1)).collect();
|
||||
for chunk in planes.chunks_mut(plane.max(1)).take(cc + 1) {
|
||||
let Some(mut rest) = chunk.get_mut(start * bps..(start + band.px.len()) * bps) else { continue };
|
||||
for (slot, range) in slots.iter_mut().zip(&ranges) {
|
||||
let Some((part, tail)) = rest.split_at_mut_checked(range.len() * bps) else { break };
|
||||
slot.push(part);
|
||||
rest = tail;
|
||||
}
|
||||
}
|
||||
// Converted and encoded on all cores. The composite came through the document's CMYK
|
||||
// profile: convert back through it too.
|
||||
let flags = crate::pixels::par_map(ranges.into_iter().zip(slots).collect(), |(range, mut out)| {
|
||||
let px = band.px.get(range).unwrap_or(&[]);
|
||||
let k = Encode { fmt, cc, matte, cmyk, lab16, white: &white };
|
||||
photocraft_color::convert::with_cmyk_space(space.as_ref(), || match sample {
|
||||
SampleType::U8 => k.run(
|
||||
px,
|
||||
&mut out,
|
||||
|a| q255(a) < 255,
|
||||
|x, d, i| {
|
||||
if let Some(b) = d.get_mut(i) {
|
||||
*b = (x.clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
|
||||
}
|
||||
},
|
||||
),
|
||||
SampleType::U16 => k.run(
|
||||
px,
|
||||
&mut out,
|
||||
|a| (a.clamp(0.0, 1.0) * 65535.0 + 0.5) as u16 != u16::MAX,
|
||||
|x, d, i| {
|
||||
if let Some(b) = d.get_mut(i * 2..i * 2 + 2) {
|
||||
b.copy_from_slice(&((x.clamp(0.0, 1.0) * 65535.0 + 0.5) as u16).to_be_bytes());
|
||||
}
|
||||
},
|
||||
),
|
||||
SampleType::F32 => k.run(
|
||||
px,
|
||||
&mut out,
|
||||
|a| a != 1.0,
|
||||
|x, d, i| {
|
||||
if let Some(b) = d.get_mut(i * 4..i * 4 + 4) {
|
||||
b.copy_from_slice(&x.to_be_bytes());
|
||||
}
|
||||
},
|
||||
),
|
||||
})
|
||||
});
|
||||
for (at, part) in parts {
|
||||
for (c, src) in part.iter().enumerate() {
|
||||
let o = c * plane + (start + at) * bps;
|
||||
planes[o..o + src.len()].copy_from_slice(src);
|
||||
}
|
||||
for (a, t) in flags {
|
||||
has_alpha |= a;
|
||||
translucent |= t;
|
||||
}
|
||||
ctl.progress((band.rect.y1 - canvas.y0) as f32 / rows);
|
||||
Ok(())
|
||||
@@ -69,6 +88,38 @@ pub(super) fn merged_planes(doc: &Document, fmt: &PixelFormat, cmyk: bool, matte
|
||||
(planes, has_alpha, translucent)
|
||||
}
|
||||
|
||||
struct Encode<'a> {
|
||||
fmt: &'a PixelFormat,
|
||||
cc: usize,
|
||||
matte: bool,
|
||||
cmyk: bool,
|
||||
lab16: bool,
|
||||
white: &'a [f32],
|
||||
}
|
||||
|
||||
impl Encode<'_> {
|
||||
fn run(&self, px: &[[f32; 4]], out: &mut [&mut [u8]], stored_translucent: impl Fn(f32) -> bool, put: impl Fn(f32, &mut [u8], usize)) -> (bool, bool) {
|
||||
let Self { fmt, cc, matte, cmyk, lab16, white } = *self;
|
||||
let (mut has_alpha, mut translucent) = (false, false);
|
||||
let mut v = [0.0f32; 5];
|
||||
for (i, p) in px.iter().enumerate() {
|
||||
// Retain alpha whenever it differs from opaque at the stored precision.
|
||||
has_alpha |= stored_translucent(p[3]);
|
||||
translucent |= p[3] < 1.0;
|
||||
photocraft_raster::from_rgba_into(fmt, *p, &mut v);
|
||||
for (c, dst) in out.iter_mut().enumerate() {
|
||||
// Matte against white like Photoshop (see `pixels::matte`).
|
||||
let m = if c < cc && matte { crate::pixels::matte(v[c], v[cc], white.get(c).copied().unwrap_or(1.0)) } else { v[c] };
|
||||
let x = if cmyk && c < cc { 1.0 - m } else { m };
|
||||
// 16-bit Lab a*/b* use Photoshop's 0..65280 scale (`pixels::lab16_chroma`).
|
||||
let x = if lab16 && (c == 1 || c == 2) { x * (crate::pixels::LAB16_CHROMA_MAX / 65535.0) } else { x };
|
||||
put(x, dst, i);
|
||||
}
|
||||
}
|
||||
(has_alpha, translucent)
|
||||
}
|
||||
}
|
||||
|
||||
/// [`merged_planes`] of a CMYK document whose layers are mixed on their ink values
|
||||
/// (`photocraft_compose::cmyk`), so opaque pixels keep their exact inks; `None` for documents
|
||||
/// that composite in RGB. Reports progress and stops on cancel like [`merged_planes`].
|
||||
@@ -141,3 +192,80 @@ pub(super) fn placeholder_planes(doc: &Document, fmt: &PixelFormat) -> Vec<u8> {
|
||||
}
|
||||
planes
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn previous_merged_planes(doc: &Document, fmt: &PixelFormat, cmyk: bool, matte: bool) -> (Vec<u8>, bool, bool) {
|
||||
if cmyk && let Some(planes) = merged_ink_planes(doc, fmt.sample, matte, &Interrupt::NONE) {
|
||||
return planes;
|
||||
}
|
||||
let sample = fmt.sample;
|
||||
let cc = fmt.mode.color_channels();
|
||||
let bps = sample.bytes();
|
||||
let plane = doc.size.area() as usize * bps;
|
||||
let mut planes = vec![0u8; plane * (cc + 1)];
|
||||
let (mut has_alpha, mut translucent) = (false, false);
|
||||
let white = photocraft_raster::from_rgba(fmt, [1.0, 1.0, 1.0, 1.0]);
|
||||
let canvas = doc.bounds();
|
||||
let w = canvas.width() as usize;
|
||||
let space = photocraft_compose::cmyk_space(doc);
|
||||
let lab16 = fmt.mode == ColorMode::Lab && sample == SampleType::U16;
|
||||
let _ = photocraft_compose::render_bands(doc, canvas, 0, |band| -> Result<(), ()> {
|
||||
has_alpha |= band.px.iter().any(|p| match sample {
|
||||
SampleType::U8 => q255(p[3]) < 255,
|
||||
SampleType::U16 => (p[3].clamp(0.0, 1.0) * 65535.0 + 0.5) as u16 != u16::MAX,
|
||||
SampleType::F32 => p[3] != 1.0,
|
||||
});
|
||||
translucent |= band.px.iter().any(|p| p[3] < 1.0);
|
||||
let start = (band.rect.y0 - canvas.y0) as usize * w;
|
||||
photocraft_color::convert::with_cmyk_space(space.as_ref(), || {
|
||||
let mut v = [0.0f32; 5];
|
||||
for (i, p) in band.px.iter().enumerate() {
|
||||
photocraft_raster::from_rgba_into(fmt, *p, &mut v);
|
||||
for c in 0..=cc {
|
||||
let m = if c < cc && matte { crate::pixels::matte(v[c], v[cc], white[c]) } else { v[c] };
|
||||
let x = if cmyk && c < cc { 1.0 - m } else { m };
|
||||
let x = if lab16 && (c == 1 || c == 2) { x * (crate::pixels::LAB16_CHROMA_MAX / 65535.0) } else { x };
|
||||
let mut b = Vec::new();
|
||||
encode_be(x, sample, &mut b);
|
||||
let o = c * plane + (start + i) * bps;
|
||||
planes[o..o + bps].copy_from_slice(&b);
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
});
|
||||
(planes, has_alpha, translucent)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merged_planes_match_the_per_band_reference() {
|
||||
let size = photocraft_geom::Size::new(37, 23);
|
||||
for mode in [ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Lab, ColorMode::Cmyk] {
|
||||
for sample in SampleType::ALL {
|
||||
for alpha_floor in [0.0f32, 0.999_9, 1.0] {
|
||||
let mut doc = Document::new("merged", size, mode, sample);
|
||||
let fmt = doc.pixel_format();
|
||||
let mut s = Surface::new(fmt);
|
||||
for y in 0..23 {
|
||||
for x in 0..37 {
|
||||
let a = (alpha_floor + (1.0 - alpha_floor) * ((x * 7 + y * 3) % 11) as f32 / 10.0).min(1.0);
|
||||
let rgba = [x as f32 / 36.0, y as f32 / 22.0, ((x + y) % 5) as f32 / 4.0, a];
|
||||
let mut v = [0.0f32; 5];
|
||||
let n = photocraft_raster::from_rgba_into(&fmt, rgba, &mut v);
|
||||
s.write_pixel(x, y, &v[..n]);
|
||||
}
|
||||
}
|
||||
doc.layers.push(Layer::new("pixels", LayerContent::Raster(s)));
|
||||
for matte in [false, true] {
|
||||
let got = merged_planes(&doc, &fmt, cmyk_of(&fmt), matte, &Interrupt::NONE);
|
||||
let want = previous_merged_planes(&doc, &fmt, cmyk_of(&fmt), matte);
|
||||
assert_eq!(got, want, "{mode:?} {sample:?} alpha >= {alpha_floor} matte {matte}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user