match smart object re-renders to the document's mode and depth

This commit is contained in:
Omar Saidahmed committed 2026-10-16 00:27:10 -04:00
1 parent 6d3443c098
commit cac01f79b8
3 files changed
+105 -22

No files matched your search

+24 -19
View File
@@ -698,30 +698,35 @@ fn convert_depth(s: &mut Session, depth: SampleType) -> Result<Value> {
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<Value> {
let d = s.active().ok_or(EngineError::NoDocument)?;
+27 -3
View File
@@ -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<Surface> {
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<SourceImage> {
@@ -224,7 +248,7 @@ pub fn source_image(file_name: &str, bytes: &[u8], fmt: PixelFormat) -> Result<S
}
let doc = decode_source(file_name, bytes)?;
let buf = photocraft_compose::flatten(&doc);
let img = SourceImage { surface: Arc::new(buffer_to_surface(&buf, fmt)), bounds: doc.bounds() };
let img = SourceImage { surface: Arc::new(source_surface(&doc, &buf, fmt)?), bounds: doc.bounds() };
cache_put(key, img.clone());
Ok(img)
}
@@ -271,7 +295,7 @@ pub fn stack_image(file_name: &str, bytes: &[u8], fmt: PixelFormat, mode: photoc
photocraft_doc::StackMode::Variance => 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)
}
+54
View File
@@ -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"
);
}
}