From cac01f79b80d2a28bb75e03cdfd0757206846c9e Mon Sep 17 00:00:00 2001 From: Omar Saidahmed Date: Fri, 16 Oct 2026 00:27:10 -0400 Subject: [PATCH] match smart object re-renders to the document's mode and depth --- crates/engine/src/image_cmds.rs | 43 ++++++++++-------- crates/engine/src/smart_cmds.rs | 30 +++++++++++-- crates/engine/tests/smart_object_tones.rs | 54 +++++++++++++++++++++++ 3 files changed, 105 insertions(+), 22 deletions(-) create mode 100644 crates/engine/tests/smart_object_tones.rs diff --git a/crates/engine/src/image_cmds.rs b/crates/engine/src/image_cmds.rs index 3e870aa686..6fc2700762 100644 --- a/crates/engine/src/image_cmds.rs +++ b/crates/engine/src/image_cmds.rs @@ -698,30 +698,35 @@ fn convert_depth(s: &mut Session, depth: SampleType) -> Result { return Ok(Value::Null); } s.edit("Bit Depth", |doc, _| { - // 32-bit documents hold linear light (as Photoshop's do: grey 128 becomes 0.2159, measured - // in Photoshop 27.11), so the colour samples cross the document's tone curve when the - // depth crosses 32 bits: decoded after widening, encoded before narrowing (so no linear - // value is quantised to 8 or 16 bits). Masks, alpha and channels are not colour. - let crosses = (current == SampleType::F32) != (depth == SampleType::F32) && matches!(doc.mode, ColorMode::Rgb | ColorMode::Grayscale); - let to_linear = depth == SampleType::F32; - let transfer = photocraft_compose::adjust::Transfer::for_document(doc.mode, if to_linear { current } else { depth }); - if crosses && !to_linear { - for_each_surface(&mut doc.layers, false, &mut |surf, _| surf.map_color(|v| transfer.encode(v).clamp(0.0, 1.0))); - } - convert_layers_depth(&mut doc.layers, depth); - if crosses && to_linear { - for_each_surface(&mut doc.layers, false, &mut |surf, _| surf.map_color(|v| transfer.decode(v))); - } - for ch in doc.channels.iter_mut().chain(doc.quick_mask.as_mut()) { - let f = ch.surface.format().with_sample(depth); - ch.surface = ch.surface.convert(f); - } - doc.depth = depth; + change_depth(doc, depth); Ok(()) })?; Ok(Value::Null) } +pub(crate) fn change_depth(doc: &mut Document, depth: SampleType) { + let current = doc.depth; + // 32-bit documents hold linear light (as Photoshop's do: grey 128 becomes 0.2159, measured + // in Photoshop 27.11), so the colour samples cross the document's tone curve when the + // depth crosses 32 bits: decoded after widening, encoded before narrowing (so no linear + // value is quantised to 8 or 16 bits). Masks, alpha and channels are not colour. + let crosses = (current == SampleType::F32) != (depth == SampleType::F32) && matches!(doc.mode, ColorMode::Rgb | ColorMode::Grayscale); + let to_linear = depth == SampleType::F32; + let transfer = photocraft_compose::adjust::Transfer::for_document(doc.mode, if to_linear { current } else { depth }); + if crosses && !to_linear { + for_each_surface(&mut doc.layers, false, &mut |surf, _| surf.map_color(|v| transfer.encode(v).clamp(0.0, 1.0))); + } + convert_layers_depth(&mut doc.layers, depth); + if crosses && to_linear { + for_each_surface(&mut doc.layers, false, &mut |surf, _| surf.map_color(|v| transfer.decode(v))); + } + for ch in doc.channels.iter_mut().chain(doc.quick_mask.as_mut()) { + let f = ch.surface.format().with_sample(depth); + ch.surface = ch.surface.convert(f); + } + doc.depth = depth; +} + /// Image → Duplicate. fn duplicate(s: &mut Session, p: &Value) -> Result { let d = s.active().ok_or(EngineError::NoDocument)?; diff --git a/crates/engine/src/smart_cmds.rs b/crates/engine/src/smart_cmds.rs index 6ffc89e67d..ec3e55f8ca 100644 --- a/crates/engine/src/smart_cmds.rs +++ b/crates/engine/src/smart_cmds.rs @@ -27,7 +27,7 @@ use std::sync::{Arc, Mutex}; use photocraft_algo::resample::translate_surface; use photocraft_algo::transform::Homography; -use photocraft_color::{BlendMode, PixelFormat}; +use photocraft_color::{BlendMode, ColorMode, PixelFormat, SampleType}; use photocraft_doc::{DocId, Document, Layer, LayerContent, LayerId, LayerMask, Metadata, SmartContentsId, SmartFilter, SmartObject, SmartSource}; use photocraft_geom::{Affine, Rect, Size}; use photocraft_raster::Surface; @@ -189,6 +189,30 @@ fn buffer_to_surface(buf: &photocraft_compose::Buffer, fmt: PixelFormat) -> Surf s } +fn source_surface(src: &Document, buf: &photocraft_compose::Buffer, fmt: PixelFormat) -> Result { + let gray = fmt.mode == ColorMode::Grayscale && src.pixel_format().mode != ColorMode::Grayscale; + let linear = fmt.sample == SampleType::F32 && src.depth != SampleType::F32; + if !gray && !linear { + return Ok(buffer_to_surface(buf, fmt)); + } + let mut doc = Document::new(src.name.clone(), src.size, src.mode, src.depth); + doc.icc_profile = src.icc_profile.clone(); + doc.layers = vec![Layer::new("source", LayerContent::Raster(buffer_to_surface(buf, src.pixel_format())))]; + if gray { + let gray_profile = crate::color_cmds::working_profile(ColorMode::Grayscale); + crate::color_cmds::convert_document(&mut doc, &gray_profile, photocraft_cms::Intent::RelativeColorimetric, true)?; + } + if linear { + crate::image_cmds::change_depth(&mut doc, SampleType::F32); + } + let surface = doc.layers.pop().and_then(|l| match l.content { + LayerContent::Raster(s) => Some(s), + _ => None, + }); + let surface = surface.ok_or_else(|| other("the smart object's source could not be converted"))?; + Ok(if surface.format() == fmt { surface } else { surface.convert(fmt) }) +} + /// An SVG source rendered at `scale` times its own size (cached per scale), so a vector smart /// object stays sharp however it is placed. pub fn svg_source_image(bytes: &[u8], fmt: PixelFormat, scale: f64) -> Result { @@ -224,7 +248,7 @@ pub fn source_image(file_name: &str, bytes: &[u8], fmt: PixelFormat) -> Result Stat::Variance, }; let buf = photocraft_compose::Buffer { rect: bounds, px: photocraft_algo::stack::combine(&frames, stat) }; - let img = SourceImage { surface: Arc::new(buffer_to_surface(&buf, fmt)), bounds }; + let img = SourceImage { surface: Arc::new(source_surface(&doc, &buf, fmt)?), bounds }; cache_put(key, img.clone()); Ok(img) } diff --git a/crates/engine/tests/smart_object_tones.rs b/crates/engine/tests/smart_object_tones.rs new file mode 100644 index 0000000000..3c57526bad --- /dev/null +++ b/crates/engine/tests/smart_object_tones.rs @@ -0,0 +1,54 @@ +use photocraft_doc::{LayerContent, LayerId}; +use photocraft_engine::{Session, smart_cmds}; +use photocraft_raster::Surface; +use serde_json::json; + +fn mean_difference(a: &Surface, b: &Surface) -> f64 { + let r = a.content_bounds().intersect(&b.content_bounds()); + let (mut sum, mut n) = (0f64, 0u32); + for y in r.y0..r.y1 { + for x in r.x0..r.x1 { + let (p, q) = (a.rgba(x, y), b.rgba(x, y)); + for c in 0..3 { + sum += f64::from((p[c] - q[c]).abs()); + n += 1; + } + } + } + sum / f64::from(n.max(1)) * 255.0 +} + +fn converted(cmd: &str) -> (Session, LayerId, LayerId) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("paint.stroke", json!({"points": [[5, 10], [60, 40]], "size": 20, "hardness": 0.3, "color": "#e04a20"})).unwrap(); + s.execute("paint.stroke", json!({"points": [[5, 40], [60, 5]], "size": 14, "hardness": 0.6, "color": "#2a7fd0"})).unwrap(); + let pixel = s.active().unwrap().active_layer.unwrap(); + s.execute("layer.duplicate", json!({})).unwrap(); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + let smart = s.active().unwrap().active_layer.unwrap(); + s.execute(cmd, json!({})).unwrap(); + (s, pixel, smart) +} + +#[test] +fn smart_objects_re_render_with_the_tones_of_the_converted_document() { + for cmd in ["image.mode.bits32", "image.mode.grayscale"] { + let (s, pixel, smart) = converted(cmd); + let st = s.active().unwrap(); + let layer = st.doc.layer(smart).unwrap().clone(); + let mut rerendered = layer.clone(); + smart_cmds::refresh_layer(&st.doc, &mut rerendered).unwrap(); + let (LayerContent::Smart(stored), LayerContent::Smart(fresh)) = (&layer.content, &rerendered.content) else { panic!("not a smart object") }; + let (stored, fresh) = (stored.cache.as_ref().unwrap(), fresh.cache.as_ref().unwrap()); + let reference = st.doc.layer(pixel).unwrap().surface().unwrap(); + let drift = mean_difference(stored, fresh); + assert!(drift < 1.0, "{cmd}: a re-render drifts {drift:.2}/255 from the converted rendering"); + let (off, baseline) = (mean_difference(fresh, reference), mean_difference(stored, reference)); + assert!( + off <= baseline + 0.5, + "{cmd}: the re-render is {off:.2}/255 from a pixel layer converted the same way, the converted rendering {baseline:.2}/255" + ); + } +}