Merge pull request #9077 from ArjenS-LA/fix/average-smart-filters

Execute Average on Smart Objects and imported PSD smart filters
This commit is contained in:
David Mann authored and GitHub committed 2026-11-09 14:23:56 +01:00
commit 1372bae3ea
12 files changed
+488 -36

No files matched your search

+135
View File
@@ -0,0 +1,135 @@
//! Average changes colour, never coverage. Keep the existing RGB conversion and selection
//! weighting; Photoshop RGB8 observations are documented in docs/average-filter.md.
use photocraft_color::{PixelFormat, SampleType};
use photocraft_geom::Rect;
use photocraft_raster::{Interrupt, Surface, from_rgba_into, to_rgba};
use crate::{Ctx, Image};
#[derive(Default)]
struct Mean {
sum: [f64; 3],
weight: f64,
}
impl Mean {
fn add(&mut self, fmt: &PixelFormat, px: &[f32], coverage: f32) {
let rgba = to_rgba(fmt, px);
let w = f64::from(coverage);
for (sum, value) in self.sum.iter_mut().zip(rgba) {
*sum += f64::from(value) * w;
}
self.weight += w;
}
fn colour(&self) -> Option<[f32; 3]> {
(self.weight > 0.0).then(|| self.sum.map(|s| (s / self.weight) as f32))
}
}
fn mix(fmt: &PixelFormat, px: &mut [f32], colour: [f32; 3], coverage: f32) {
if coverage <= 0.0 {
return;
}
let mut rgba = to_rgba(fmt, px);
for (value, mean) in rgba.iter_mut().zip(colour) {
*value += (mean - *value) * coverage;
}
from_rgba_into(fmt, rgba, px);
}
// Rect's public coordinates can span more than i32::MAX. Reject these before raster helpers
// do their signed coordinate arithmetic, and cap a single operation's sample count.
fn valid_area(area: Rect, channels: usize) -> bool {
area.width() <= i32::MAX as u32
&& area.height() <= i32::MAX as u32
&& u64::from(area.width()).saturating_mul(u64::from(area.height())).saturating_mul(channels as u64) <= 512 * 1024 * 1024
}
pub(crate) fn apply(surface: &Surface, area: Rect, selection: Option<&Surface>, ctl: &Interrupt<'_>) -> Option<Surface> {
let fmt = surface.format();
let n = fmt.channels();
if !valid_area(area, n) || ctl.cancelled() {
return None;
}
if area.is_empty() {
return Some(surface.clone());
}
let mut raw = surface.try_read_region(area).ok()?;
let width = area.width() as usize;
let coverage = |i: usize| selection.map_or(1.0, |s| s.sample_channel(area.x0 + (i % width) as i32, area.y0 + (i / width) as i32, 0));
let mut mean = Mean::default();
for (i, px) in raw.chunks_exact(n).enumerate() {
if i % width == 0 && ctl.cancelled() {
return None;
}
mean.add(&fmt, px, coverage(i));
}
let Some(colour) = mean.colour() else { return Some(surface.clone()) };
for (i, px) in raw.chunks_exact_mut(n).enumerate() {
if i % width == 0 && ctl.cancelled() {
return None;
}
mix(&fmt, px, colour, coverage(i));
}
let mut out = surface.clone();
if ctl.cancelled() {
return None;
}
out.try_write_region(area, &raw).ok()?;
out.prune();
ctl.progress(1.0);
Some(out)
}
// The direct kernel API has no selection. Its mean is over the reference bounds, even when
// the requested output is only a tile; the surface dispatcher above also handles selections.
pub(crate) fn kernel(src: &Image, out: Rect, ctx: &Ctx) -> Vec<f32> {
let fmt = PixelFormat::new(ctx.mode, SampleType::F32, ctx.alpha);
let n = fmt.channels();
if src.ch != n || !valid_area(src.rect, n) || !valid_area(out, n) {
return Vec::new();
}
let read = |x: i32, y: i32| -> Option<&[f32]> {
if !src.rect.contains(x, y) {
return None;
}
let i = (i64::from(y) - i64::from(src.rect.y0)) as usize;
let j = (i64::from(x) - i64::from(src.rect.x0)) as usize;
let start = i.checked_mul(src.rect.width() as usize)?.checked_add(j)?.checked_mul(n)?;
src.data.get(start..start.checked_add(n)?)
};
let bounds = ctx.bounds.intersect(&src.rect);
let mut mean = Mean::default();
for y in bounds.y0..bounds.y1 {
for x in bounds.x0..bounds.x1 {
if let Some(px) = read(x, y) {
mean.add(&fmt, px, 1.0);
}
}
}
let len = (out.width() as usize).checked_mul(out.height() as usize).and_then(|s| s.checked_mul(n));
let Some(len) = len else { return Vec::new() };
let mut result = Vec::new();
if result.try_reserve_exact(len).is_err() {
return result;
}
let colour = mean.colour();
for y in out.y0..out.y1 {
for x in out.x0..out.x1 {
let mut px = [0.0; 5];
let Some(pixel) = px.get_mut(..n) else { return Vec::new() };
if let Some(source) = read(x, y) {
pixel.copy_from_slice(source);
}
if let Some(colour) = colour {
mix(&fmt, pixel, colour, 1.0);
}
result.extend_from_slice(pixel);
}
}
result
}
#[cfg(test)]
mod tests;
+59
View File
@@ -0,0 +1,59 @@
use super::*;
use photocraft_color::ColorMode;
#[test]
fn preserves_alpha_and_averages_hidden_rgb_at_each_depth() {
let bounds = Rect::new(0, 0, 2, 1);
for sample in SampleType::ALL {
let fmt = PixelFormat::new(ColorMode::Rgb, sample, true);
let mut surface = Surface::new(fmt);
surface.write_region(bounds, &[1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0]);
let out = apply(&surface, bounds, None, &Interrupt::NONE).unwrap();
for (i, p) in out.read_region(bounds).as_chunks::<4>().0.iter().enumerate() {
assert!((p[0] - 0.5).abs() <= 1.0 / 255.0);
assert_eq!(p[1], 0.0);
assert!((p[2] - 0.5).abs() <= 1.0 / 255.0);
assert_eq!(p[3], if i == 0 { 1.0 } else { 0.0 });
}
}
}
#[test]
fn direct_kernel_uses_reference_bounds_for_each_output_tile() {
let bounds = Rect::new(-1, 0, 1, 1);
let src = Image { rect: bounds, ch: 4, data: vec![1.0, 0.0, 0.0, 0.25, 0.0, 0.0, 1.0, 0.75] };
let ctx = Ctx { bounds, mode: ColorMode::Rgb, alpha: true };
assert_eq!(kernel(&src, Rect::new(-1, 0, 0, 1), &ctx), vec![0.5, 0.0, 0.5, 0.25]);
assert_eq!(kernel(&src, Rect::new(0, 0, 1, 1), &ctx), vec![0.5, 0.0, 0.5, 0.75]);
}
#[test]
fn rejects_extreme_geometry_and_honours_pre_cancel() {
let surface = Surface::new(PixelFormat::RGBA8);
let huge = Rect::new(i32::MIN, 0, i32::MAX, 1);
assert!(apply(&surface, huge, None, &Interrupt::NONE).is_none());
assert!(apply(&surface, Rect::new(0, 0, 5, 5), None, &Interrupt::cancel_only(&|| true)).is_none());
assert_eq!(surface.tile_count(), 0);
}
#[test]
fn soft_selection_weights_the_mean_and_output_mix() {
let bounds = Rect::new(0, 0, 2, 1);
for sample in SampleType::ALL {
let mut surface = Surface::new(PixelFormat::new(ColorMode::Rgb, sample, true));
surface.write_region(bounds, &[1.0, 0.0, 0.0, 0.25, 0.0, 0.0, 1.0, 0.75]);
let before = surface.read_region(bounds);
let mut selection = Surface::new(PixelFormat::new(ColorMode::Grayscale, SampleType::F32, false));
selection.write_region(bounds, &[0.25, 1.0]);
let out = apply(&surface, bounds, Some(&selection), &Interrupt::NONE).unwrap();
// Unequal coverage makes the mean [0.2, 0, 0.8]. Each pixel then mixes
// toward it using its own coverage, independently of its original alpha.
for (i, expected) in [[0.8, 0.0, 0.2], [0.2, 0.0, 0.8]].iter().enumerate() {
let pixel = out.pixel(i as i32, 0);
for (actual, expected) in pixel.iter().zip(expected) {
assert!((actual - expected).abs() <= 1.0 / 255.0, "{sample:?}: {pixel:?}");
}
assert_eq!(pixel[3], before[i * 4 + 3]);
}
}
}
+13 -3
View File
@@ -21,6 +21,7 @@
pub mod art_history;
mod artistic;
mod artistic_fx;
mod average;
mod blur;
mod blur2;
pub mod camera_raw;
@@ -208,6 +209,8 @@ pub enum PolarMode {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "filter", rename_all = "camelCase", rename_all_fields = "camelCase")]
pub enum FilterParams {
/// Mean colour over the selected area, retaining each pixel's alpha.
Average,
/// Radius = Gaussian standard deviation in pixels (0.1–1000).
GaussianBlur {
radius: f32,
@@ -592,7 +595,8 @@ impl FilterParams {
FilterParams::Emboss { height, .. } => Halo::Radius(height.abs().ceil() as i32 + 2),
FilterParams::FindEdges => Halo::Radius(1),
FilterParams::AddNoise { .. } | FilterParams::Solarize | FilterParams::Invert | FilterParams::Desaturate => Halo::Radius(0),
FilterParams::RadialBlur { .. }
FilterParams::Average
| FilterParams::RadialBlur { .. }
| FilterParams::Offset { .. }
| FilterParams::Twirl { .. }
| FilterParams::Pinch { .. }
@@ -634,14 +638,16 @@ impl FilterParams {
}
/// Whether the filter places and sizes its pattern by the canvas even with a selection
/// (Clouds, Fibers): its bounds are then the canvas, and the selection only masks the result.
/// (Clouds, Fibers), or limits its working area to the canvas (Average). The output
/// area is still clipped to the selection; Average also weights its mean by that coverage.
pub fn sizes_by_canvas(&self) -> bool {
matches!(self, FilterParams::Clouds { .. } | FilterParams::Fibers { .. })
matches!(self, FilterParams::Average | FilterParams::Clouds { .. } | FilterParams::Fibers { .. })
}
/// Human-readable name.
pub fn label(&self) -> &'static str {
match self {
FilterParams::Average => "Average",
FilterParams::GaussianBlur { .. } => "Gaussian Blur",
FilterParams::BoxBlur { .. } => "Box Blur",
FilterParams::MotionBlur { .. } => "Motion Blur",
@@ -766,6 +772,7 @@ pub struct Ctx {
/// the halo, or the bounds for global filters). Returns interleaved samples.
pub fn kernel(params: &FilterParams, src: &Image, out: Rect, ctx: &Ctx) -> Vec<f32> {
match params {
FilterParams::Average => average::kernel(src, out, ctx),
FilterParams::GaussianBlur { radius } => blur::gaussian(src, out, ctx, *radius),
FilterParams::BoxBlur { radius } => blur::boxed(src, out, ctx, *radius),
FilterParams::MotionBlur { angle, distance } => blur::motion(src, out, ctx, *angle, *distance),
@@ -1141,6 +1148,9 @@ fn apply_tiled_with_impl(
if area.is_empty() {
return Some(out);
}
if matches!(params, FilterParams::Average) {
return average::apply(surface, extent.map_or(area, |e| area.intersect(&e)), selection, ctl);
}
#[cfg(not(target_arch = "wasm32"))]
if prepare_fast_paths && let Some(result) = sharpen::fft::apply(surface, params, area, selection, extent, ctl) {
return result;
+3 -29
View File
@@ -74,13 +74,9 @@ pub(crate) fn step_key(s: &Session, what: &str) -> String {
/// area, the pixels, and the selection coverage per pixel; results are blended back by coverage,
/// and transparency stays locked when the layer locks it. When `p` targets an alpha channel, the
/// Quick Mask or the layer mask (`Session::execute` fills that in from the Channels panel), that
/// grayscale surface is rewritten instead, as the core filters do (#935).
fn map_pixels(s: &mut Session, label: &str, p: &Value, f: impl FnOnce(Rect, &mut [[f32; 4]], &[f32])) -> Result<Value> {
map_pixels_in(s, label, p, false, f)
}
/// [`map_pixels`], or with `whole` the whole canvas: `f` still gets the selection coverage, and
/// every pixel takes its result in full.
/// grayscale surface is rewritten instead, as the core filters do (#935). With `whole` the whole
/// canvas is rewritten: `f` still gets the selection coverage, and every pixel takes its result
/// in full.
fn map_pixels_in(s: &mut Session, label: &str, p: &Value, whole: bool, f: impl FnOnce(Rect, &mut [[f32; 4]], &[f32])) -> Result<Value> {
let id = layer_param(s, p)?;
let channel = crate::channel_cmds::target_of(p) != crate::channel_cmds::Target::Pixels;
@@ -275,27 +271,6 @@ fn equalize(s: &mut Session, p: &Value) -> Result<Value> {
})
}
/// Filter › Blur › Average: the selection (or layer) filled with its mean colour.
fn average(s: &mut Session, p: &Value) -> Result<Value> {
map_pixels(s, "Average", p, |_, px, cover| {
let (mut sum, mut wsum, mut asum, mut n) = ([0f64; 3], 0f64, 0f64, 0f64);
for (p, &k) in px.iter().zip(cover) {
let w = (p[3] * k) as f64;
for c in 0..3 {
sum[c] += p[c] as f64 * w;
}
wsum += w;
asum += (p[3] * k) as f64;
n += k as f64;
}
if wsum <= 0.0 {
return;
}
let avg = [(sum[0] / wsum) as f32, (sum[1] / wsum) as f32, (sum[2] / wsum) as f32, (asum / n.max(1e-9)) as f32];
px.fill(avg);
})
}
// ---------- transform presets ----------
/// The presets' params: like Free Transform, they follow the Channels and Layers panel target.
@@ -1119,7 +1094,6 @@ pub fn specs() -> Vec<CommandSpec> {
has_layer,
show_only
),
spec!("filter.blur.average", "Average", &["Filter", "Blur"], None, "{}", has_pixels, average),
]
}
+63 -1
View File
@@ -24,7 +24,7 @@ fn equalize_average_and_clouds_edit_the_targeted_channel() {
s.tools.foreground = [0.0, 0.0, 0.0, 1.0];
s.tools.background = [1.0, 1.0, 1.0, 1.0];
s.execute("edit.fill", json!({"contents": "color", "color": "#808080"})).unwrap();
let p = match target {
let mut p = match target {
"quickMask" => {
s.execute("select.editInQuickMaskMode", json!({"on": true})).unwrap();
json!({"seed": 1})
@@ -39,6 +39,10 @@ fn equalize_average_and_clouds_edit_the_targeted_channel() {
json!({"seed": 1, "target": "mask"})
}
};
// Average has no seed parameter; only Clouds uses it.
if cmd == "filter.blur.average" {
p.as_object_mut().unwrap().remove("seed");
}
// Two levels in the target, so Equalize and Average have something to change.
let edit_target = json!({ "target": if target == "alpha" { json!({"channel": 0}) } else { json!(target) } });
s.edit("levels", |doc, active| {
@@ -65,6 +69,18 @@ fn equalize_average_and_clouds_edit_the_targeted_channel() {
let case = format!("{cmd} on {target} at {depth}");
assert_eq!(layer1, layer0, "{case}: the layer is untouched");
assert_ne!(target1, target0, "{case}: the target changed");
if cmd == "filter.blur.average" {
// The equal-sized halves contribute equally, using their stored sample values.
let mean = (f64::from(target0[0]) + f64::from(target0[20])) / 2.0;
let tolerance = match depth {
8 => 1.0 / 255.0,
16 => 1.0 / 65535.0,
_ => 1e-6,
};
for value in &target1 {
assert!((f64::from(*value) - mean).abs() <= tolerance, "{case}: {value}, expected {mean}");
}
}
assert_eq!(s.active().unwrap().history.past_len(), past + 1, "{case}");
s.undo();
assert_eq!(read(&s).1, target0, "{case}: undo restores the target");
@@ -556,6 +572,52 @@ fn clouds_in_a_selection_are_the_canvas_clouds() {
}
}
#[test]
fn average_preserves_transparency_and_averages_hidden_colours() {
// Photoshop 26.6.1, RGB8 PNG with opaque red and transparent blue halves:
// Average produces (128, 0, 128), retaining each pixel's original alpha.
for depth in [8, 16, 32] {
let mut s = session(depth);
paint_square(&mut s, Rect::new(0, 0, 20, 30), [1.0, 0.0, 0.0, 1.0]);
paint_square(&mut s, Rect::new(20, 0, 40, 30), [0.0, 0.0, 1.0, 0.0]);
let bounds = Rect::new(0, 0, 40, 30);
let before = active(&s).surface().unwrap().read_region(bounds);
let steps = s.active().unwrap().history.past_len();
s.execute("filter.blur.average", json!({})).unwrap();
let after = active(&s).surface().unwrap().read_region(bounds);
for (old, new) in before.as_chunks::<4>().0.iter().zip(after.as_chunks::<4>().0) {
assert_eq!(new[3], old[3], "depth {depth}: Average must preserve alpha");
assert!((new[0] - 0.5).abs() < 0.003 && new[1] == 0.0 && (new[2] - 0.5).abs() < 0.003, "{depth}: {new:?}");
}
assert_eq!(s.active().unwrap().history.past_len(), steps + 1);
s.undo();
assert_eq!(active(&s).surface().unwrap().read_region(bounds), before);
s.redo();
assert_eq!(active(&s).surface().unwrap().read_region(bounds), after);
}
}
#[test]
fn average_preserves_partial_alpha_and_pixels_outside_selection() {
for depth in [8, 16, 32] {
let mut s = session(depth);
paint_square(&mut s, Rect::new(0, 0, 20, 30), [1.0, 0.0, 0.0, 0.25]);
paint_square(&mut s, Rect::new(20, 0, 40, 30), [0.0, 0.0, 1.0, 0.75]);
s.execute("select.rect", json!({"x": 10, "y": 0, "width": 15, "height": 30})).unwrap();
let before: Vec<_> = (0..40).map(|x| pixel(&s, x, 15)).collect();
s.execute("filter.blur.average", json!({})).unwrap();
for x in 0..40 {
let p = pixel(&s, x, 15);
assert_eq!(p[3], before[x as usize][3], "depth {depth}, x {x}");
if (10..25).contains(&x) {
assert!((p[0] - 2.0 / 3.0).abs() < 0.003 && (p[2] - 1.0 / 3.0).abs() < 0.003, "{depth}: {p:?}");
} else {
assert_eq!(p, before[x as usize]);
}
}
}
}
#[test]
fn average_and_clouds() {
let mut s = session(8);
+2
View File
@@ -84,6 +84,7 @@ pub fn params_for(id: &str, p: &Value) -> Option<FilterParams> {
return Some(fp);
}
Some(match id {
"filter.blur.average" => FilterParams::Average,
"filter.blur.gaussianBlur" => FilterParams::GaussianBlur { radius: f(p, "radius", 1.0).clamp(0.1, 1000.0) },
"filter.blur.boxBlur" => FilterParams::BoxBlur { radius: f(p, "radius", 1.0).clamp(1.0, 2000.0) },
"filter.blur.motionBlur" => FilterParams::MotionBlur { angle: f(p, "angle", 0.0), distance: f(p, "distance", 10.0).clamp(1.0, 2000.0) },
@@ -471,6 +472,7 @@ macro_rules! filter_cmd {
pub fn specs() -> Vec<CommandSpec> {
vec![
filter_cmd!("filter.blur.gaussianBlur", "Gaussian Blur…", ["Filter", "Blur"], r##"{"radius":0.1..1000=1}"##),
filter_cmd!("filter.blur.average", "Average", ["Filter", "Blur"], "{}"),
filter_cmd!("filter.blur.blur", "Blur", ["Filter", "Blur"], "{}"),
filter_cmd!("filter.blur.blurMore", "Blur More", ["Filter", "Blur"], "{}"),
filter_cmd!("filter.sharpen.sharpen", "Sharpen", ["Filter", "Sharpen"], "{}"),
+2 -1
View File
@@ -94,7 +94,8 @@ fn fields(id: &str) -> Option<&'static [(&'static str, Rule)]> {
("vertical", Number(f64::MIN, f64::MAX)),
("undefinedAreas", Choice("wrap|repeat|transparent")),
],
"filter.blur.blur"
"filter.blur.average"
| "filter.blur.blur"
| "filter.blur.blurMore"
| "filter.sharpen.sharpen"
| "filter.sharpen.sharpenMore"
+124
View File
@@ -0,0 +1,124 @@
//! Exercise Average through the smart-filter renderer, not an imported cache.
use photocraft_doc::LayerContent;
use photocraft_engine::{Session, filters, smart_cmds};
use photocraft_geom::Rect;
use photocraft_raster::Surface;
use serde_json::json;
fn image(s: &Session) -> Surface {
let d = s.active().unwrap();
let l = d.doc.layer(d.active_layer.unwrap()).unwrap();
match &l.content {
LayerContent::Raster(r) => r.clone(),
LayerContent::Smart(sm) => sm.cache.clone().unwrap(),
_ => panic!("expected pixels"),
}
}
fn input(depth: u32) -> Session {
let mut s = Session::new();
s.execute("file.new", json!({"width": 16, "height": 16, "depth": depth})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
let d = s.active_mut().unwrap();
let l = std::sync::Arc::make_mut(&mut d.doc).layer_mut(d.active_layer.unwrap()).unwrap();
let surf = l.surface_mut().unwrap();
for y in 0..16 {
for x in 0..16 {
surf.write_region(Rect::new(x, y, x + 1, y + 1), if x < 8 { &[1.0, 0.0, 0.0, 1.0] } else { &[0.0, 0.0, 1.0, 1.0] });
}
}
s
}
fn purple(surf: &Surface) {
for p in surf.read_region(Rect::new(0, 0, 16, 16)).as_chunks::<4>().0 {
assert!((p[0] - 0.5).abs() <= 1.0 / 255.0, "{p:?}");
assert_eq!(p[1], 0.0);
assert!((p[2] - 0.5).abs() <= 1.0 / 255.0, "{p:?}");
assert_eq!(p[3], 1.0);
}
}
#[test]
fn average_stored_filter_executes() {
for depth in [8, 16, 32] {
let s = input(depth);
let bounds = s.active().unwrap().doc.bounds();
let out = filters::apply_filter_to_surface("filter.blur.average", &json!({}), &image(&s), bounds, None, bounds)
.unwrap()
.expect("Average must be executable, not treated as an unknown filter");
purple(&out);
}
}
#[test]
fn average_smart_command_rerenders_and_undoes() {
for depth in [8, 16, 32] {
let mut s = input(depth);
s.execute("filter.convertForSmartFilters", json!({})).unwrap();
let before = image(&s);
let steps = s.active().unwrap().history.past_len();
assert!(s.is_enabled("filter.blur.average"));
s.execute("filter.blur.average", json!({})).unwrap();
assert_eq!(s.active().unwrap().history.past_len(), steps + 1);
purple(&image(&s));
assert!(s.undo());
assert_eq!(image(&s), before);
assert!(s.redo());
purple(&image(&s));
s.execute("layer.smartFilter.setVisible", json!({"index": 0, "visible": false})).unwrap();
assert_eq!(image(&s), before);
s.execute("layer.smartFilter.setVisible", json!({"index": 0, "visible": true})).unwrap();
let d = s.active_mut().unwrap();
let snapshot = d.doc.clone();
let layer = std::sync::Arc::make_mut(&mut d.doc).layer_mut(d.active_layer.unwrap()).unwrap();
if let LayerContent::Smart(sm) = &mut layer.content {
assert_eq!(sm.smart_filters.len(), 1);
assert_eq!(sm.smart_filters[0].command, "filter.blur.average");
sm.cache = None;
}
smart_cmds::purge_stack_cache();
assert!(smart_cmds::refresh_layer(&snapshot, layer).unwrap());
purple(&image(&s));
}
}
#[test]
fn average_rejects_bad_params_without_an_edit() {
let mut s = input(8);
let before = image(&s);
let steps = s.active().unwrap().history.past_len();
for params in [json!("bad"), json!({"unknown": 1}), json!({"layer": -1}), json!({"target": false})] {
assert!(s.execute("filter.blur.average", params).is_err());
assert_eq!(image(&s), before);
assert_eq!(s.active().unwrap().history.past_len(), steps);
}
}
/// Optional local oracle: generate a 16×16 red/blue Smart Object in Photoshop, apply Average,
/// save as PSD, then set PHOTOCRAFT_AVERAGE_PSD to that file. Never accept cached pixels as proof.
#[test]
#[ignore = "requires a locally generated Photoshop Average PSD; see docs/average-filter.md"]
fn average_photoshop_psd_rerenders_from_source() {
let path = std::env::var("PHOTOCRAFT_AVERAGE_PSD").expect("set PHOTOCRAFT_AVERAGE_PSD");
let bytes = std::fs::read(&path).unwrap();
let mut doc = photocraft_io::import(&path, &bytes).unwrap().document;
let snapshot = doc.clone();
let ids: Vec<_> = snapshot
.walk()
.into_iter()
.filter_map(|(_, _, l)| match &l.content {
LayerContent::Smart(sm) if sm.smart_filters.iter().any(|f| f.command == "filter.blur.average") => Some(l.id),
_ => None,
})
.collect();
assert_eq!(ids.len(), 1);
let layer = doc.layer_mut(ids[0]).unwrap();
if let LayerContent::Smart(sm) = &mut layer.content {
sm.cache = None;
}
smart_cmds::purge_stack_cache();
assert!(smart_cmds::refresh_layer(&snapshot, layer).unwrap());
let LayerContent::Smart(sm) = &layer.content else { panic!("expected smart object") };
purple(sm.cache.as_ref().unwrap());
}
+1
View File
@@ -28,6 +28,7 @@ mod atn_stroke_width;
mod atn_type_path;
mod atn_v12;
mod atn_video_frames;
mod average_smart;
mod background_and_mask_depth;
mod background_layer_consistency;
mod brush_smoothing;
+74
View File
@@ -0,0 +1,74 @@
# Average filter: transparency reference
The Average filter replaces colour while retaining each pixel's transparency.
Two synthetic cases were executed in Photoshop **26.6.1 on Windows x64** on
2026-10-23. These are observations of RGB8 raster layers, without a selection or
transparency lock; they are not a claim of parity in other colour modes or at
other bit depths.
Each input is a 16 × 16 RGBA PNG. Its left eight columns are red and its right
eight columns are blue. Open it in Photoshop, run Filter › Blur › Average, and
save a PNG copy. Inspect the file's RGBA values, including invisible colours.
| Input left / right RGBA | Output left / right RGBA |
|---|---|
| `(255,0,0,255)` / `(0,0,255,0)` | `(128,0,128,255)` / `(128,0,128,0)` |
| `(255,0,0,64)` / `(0,0,255,192)` | `(128,0,128,64)` / `(128,0,128,192)` |
An opaque control produces `(128,0,128,255)` throughout. Thus transparency is
neither a weight in this colour average nor a channel to replace with its mean.
PhotoCraft previously alpha-weighted the RGB mean and replaced alpha with its
mean. On the first case that predicts translucent red throughout, rather than
purple pixels with their original transparency. The fix uses selection coverage
for the existing mean and leaves each pixel's alpha alone. Selection mixing and
the existing colour conversion path are unchanged. In particular, this does not
establish Photoshop parity for feathered selection weighting or CMYK/Lab means.
The engine tests construct their own pixels; no Photoshop binary, proprietary
code, or reference image is included. They cover transparent and partially
transparent pixels, selection boundaries, one history step, undo and redo at
8/16/32-bit storage. The higher-depth tests establish PhotoCraft invariants;
the external reference measurements above are RGB8 only.
Run the focused regressions with:
```sh
cargo test -p photocraft-engine --lib average_preserves
```
## Smart filters
The PSD importer already recognizes the optionless `Avrg` descriptor (see
[#6297](https://github.com/storytold/photocraft/pull/6297)). Recognition alone did
not execute it: the shared renderer returned `None`, leaving the source pixels
unchanged, and the raster-only command was disabled on Smart Objects.
Average now uses the shared filter command and algorithm. It can be added to a
Smart Object, toggled, undone and redone, or re-rendered from an imported record.
The raster path retains its canvas limit, selection weighting and alpha behavior.
Like other core filters, new invocations validate parameters before editing.
The integration tests create their own red/blue Smart Objects at RGB8/16/32f.
They check the dispatcher, command enablement, one history step, visibility,
undo/redo, and re-rendering after clearing both the layer and stage caches:
```sh
cargo test -p photocraft-engine --test it average_smart
```
An additional, explicitly ignored test accepts a local Photoshop oracle. To
reproduce it in Photoshop, create an opaque 16 × 16 RGB8 image with eight red
columns followed by eight blue columns, convert the layer to a Smart Object,
apply Average and save a PSD. Photoshop 26.6.1 on Windows produces purple
`(128,0,128,255)` throughout. Then run, in PowerShell:
```powershell
$env:PHOTOCRAFT_AVERAGE_PSD = 'C:\path\to\average-smart.psd'
cargo test -p photocraft-engine --test it average_photoshop_psd_rerenders_from_source -- --ignored --nocapture
```
This test discards the imported layer cache and the smart-filter stage cache,
then rebuilds from the embedded source. Merely importing or flattening the PSD
would reuse Photoshop's saved pixels and would not prove execution. The local
PSD is not committed and the opt-in test does not raise a corpus floor.
+4 -1
View File
@@ -1,6 +1,6 @@
# Where PhotoCraft falls short of Photoshop
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (G4: Both Axes on Scatter headers, #3825 partial; G4: combined Spacing checkbox and percentage header, #3825 partial; G14: Hasselblad 3FR/FFF, the X2D II 100C's uncompressed CFA, decodes; G4: Angle/Roundness fields left of the Brush Tip Shape widget, #3825 partial; G12: the eraser end of flipped pens selects the Eraser on Windows with Windows Ink on, #2285; no estimate changed; RGB and CMYK documents preview their spot inks as an overprint on the canvas and in the PSD thumbnail, FILE-215-17 narrows to PDF Separation, #10249; G2: compositor compilation moved off the first document refresh, with startup measurements pending, #9157; G11: Playback Options dialog with Step by Step and Pause For, #5212; G11: Stop step messages with Continue/Stop and nested resume, #8386; adjustment-layer Make keeps name, blend mode and clipping, #8384; tile-sparse Select and Mask outputs and preview coverage, #8822; classical Select › Sky can seed substantial high-confidence components beneath an obstructed top band; learned segmentation remains open; Art History Brush Opacity on the options bar, #10263; no estimate changed; G8: Type › OpenType › Stylistic Sets submenu, Set 1–20, #10285; G8: PhotoCraft-native PSDs keep stylistic sets and other Txt2-only OpenType flags through save/reopen, #10308; G10: TIFF Options dialog on Save As and Save a Copy, compression, Save Image Pyramid, Save Transparency, #8827; G11: Batch Esc Continue/Stop and read-only input previews; full live tabs remain open, #4579; G11: action recording folds Move-tool nudges, records relative keyboard document switches and scales point text placement; paragraph boxes remain open, #4514; G11: Batch measured output naming and UTF-16 log corrections; remaining progress and cancel prompt parity tracked, #4579; G11: Render ATN events mapped; Stylize/Pixelate ATN events mapped, current counts 128 supported/89 unmapped; G9: 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; selected colour sets edit drawing, tone and supported filters; geometry remains open; G11: `.atn` document offsets preserve opening order after tab reordering, #7103; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; Smooth and Feather command masks cropped to selection bounds plus blur reach; G1: Halftone Pattern Filter Gallery settings survive PSD/PSB saves (#8940); cropped selection morphology and command mask conversion performance evidence added; G6: Art History Brush landed as partial, TOOL-213-20, #1610, so no toolbar tool is missing; G4: five hard-round built-in presets use 5% spacing to remove stacked-tip outlines and reduce pressure-opacity bands, #8842; G9: Crop tool canvas menu, #6561; G2: Content-Aware Scale seam carving 1.65–3.6× faster with unchanged output, P28 row 3.3 → 1.6 s on a 4-core VM, #5236; G9: UI scale presets done, 105–120% steps, #3222; G14: lossy DNG, raw sidecars, Camera Raw Defaults live, Calibration and Auto, CR3 assessed as not clean-room feasible; G8: PSD type on a path opens on its path and saves back into the file's `Txt2`, #7962; G11: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; adjustment clipping stays visible in compact Properties, #5231; G9: Color panel modes, ramp and copy commands, the same in every theme, UI-217-17, #6000; G17: Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; G10: `.asl` style libraries landed, #1051; G8: area type (TYPE-218-10); G1: smart objects keep Photoshop's Cylinder warp, #4112; G8: paragraph direction buttons in the Paragraph panel and options bar, TYPE-218-2 partial; G1: 32-bit Hue/Saturation and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; G11: compound layer properties export as separate Photoshop Set events, rename last, #8304; G11: Batch and droplets take the result at the action's Save step, so actions ending in Save As and Close work; G11: `.atn` steps that switch to another open document import, play and save back, #7103; G14: the Nikon D4 white level follows the sample depth, so 12-bit D4 NEFs no longer open 2.6 stops dark, #5978; G14: a Nikon NEF's Adobe RGB preview is read as Adobe RGB (maker-note ColorSpace 0x001E), #5987; Apply Layer Comp action mapping, #7101; G10: JPEG Options dialog landed, FILE-215-19 partial (no Matte, progressive Scans or size preview); G10: Cineon write; G10: DPX read and write, #1049, Cineon and Amiga IFF read; Classical Object Finder improvement; learned segmentation remains open; G4: named, stateful section locks and visible keyboard focus in Brush Settings; G4: independent Brush Pose tilt-axis overrides, #3825 partial; G4: Transfer Line truncated
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (Average executes through shared filters on Smart Objects and imported PSD records; Average preserves per-pixel alpha and includes hidden RGB in its mean; RGB8 observations against Photoshop 26.6.1, see average-filter.md; G4: Both Axes on Scatter headers, #3825 partial; G4: combined Spacing checkbox and percentage header, #3825 partial; G14: Hasselblad 3FR/FFF, the X2D II 100C's uncompressed CFA, decodes; G4: Angle/Roundness fields left of the Brush Tip Shape widget, #3825 partial; G12: the eraser end of flipped pens selects the Eraser on Windows with Windows Ink on, #2285; no estimate changed; RGB and CMYK documents preview their spot inks as an overprint on the canvas and in the PSD thumbnail, FILE-215-17 narrows to PDF Separation, #10249; G2: compositor compilation moved off the first document refresh, with startup measurements pending, #9157; G11: Playback Options dialog with Step by Step and Pause For, #5212; G11: Stop step messages with Continue/Stop and nested resume, #8386; adjustment-layer Make keeps name, blend mode and clipping, #8384; tile-sparse Select and Mask outputs and preview coverage, #8822; classical Select › Sky can seed substantial high-confidence components beneath an obstructed top band; learned segmentation remains open; Art History Brush Opacity on the options bar, #10263; no estimate changed; G8: Type › OpenType › Stylistic Sets submenu, Set 1–20, #10285; G8: PhotoCraft-native PSDs keep stylistic sets and other Txt2-only OpenType flags through save/reopen, #10308; G10: TIFF Options dialog on Save As and Save a Copy, compression, Save Image Pyramid, Save Transparency, #8827; G11: Batch Esc Continue/Stop and read-only input previews; full live tabs remain open, #4579; G11: action recording folds Move-tool nudges, records relative keyboard document switches and scales point text placement; paragraph boxes remain open, #4514; G11: Batch measured output naming and UTF-16 log corrections; remaining progress and cancel prompt parity tracked, #4579; G11: Render ATN events mapped; Stylize/Pixelate ATN events mapped, current counts 128 supported/89 unmapped; G9: 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; selected colour sets edit drawing, tone and supported filters; geometry remains open; G11: `.atn` document offsets preserve opening order after tab reordering, #7103; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; Smooth and Feather command masks cropped to selection bounds plus blur reach; G1: Halftone Pattern Filter Gallery settings survive PSD/PSB saves (#8940); cropped selection morphology and command mask conversion performance evidence added; G6: Art History Brush landed as partial, TOOL-213-20, #1610, so no toolbar tool is missing; G4: five hard-round built-in presets use 5% spacing to remove stacked-tip outlines and reduce pressure-opacity bands, #8842; G9: Crop tool canvas menu, #6561; G2: Content-Aware Scale seam carving 1.65–3.6× faster with unchanged output, P28 row 3.3 → 1.6 s on a 4-core VM, #5236; G9: UI scale presets done, 105–120% steps, #3222; G14: lossy DNG, raw sidecars, Camera Raw Defaults live, Calibration and Auto, CR3 assessed as not clean-room feasible; G8: PSD type on a path opens on its path and saves back into the file's `Txt2`, #7962; G11: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; adjustment clipping stays visible in compact Properties, #5231; G9: Color panel modes, ramp and copy commands, the same in every theme, UI-217-17, #6000; G17: Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; G10: `.asl` style libraries landed, #1051; G8: area type (TYPE-218-10); G1: smart objects keep Photoshop's Cylinder warp, #4112; G8: paragraph direction buttons in the Paragraph panel and options bar, TYPE-218-2 partial; G1: 32-bit Hue/Saturation and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; G11: compound layer properties export as separate Photoshop Set events, rename last, #8304; G11: Batch and droplets take the result at the action's Save step, so actions ending in Save As and Close work; G11: `.atn` steps that switch to another open document import, play and save back, #7103; G14: the Nikon D4 white level follows the sample depth, so 12-bit D4 NEFs no longer open 2.6 stops dark, #5978; G14: a Nikon NEF's Adobe RGB preview is read as Adobe RGB (maker-note ColorSpace 0x001E), #5987; Apply Layer Comp action mapping, #7101; G10: JPEG Options dialog landed, FILE-215-19 partial (no Matte, progressive Scans or size preview); G10: Cineon write; G10: DPX read and write, #1049, Cineon and Amiga IFF read; ClassicLine truncated
Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact
on the way to beta. This is the work list agents pick from: take the highest-ranked entry that
@@ -41,6 +41,7 @@ what an agent can't finish alone.
### G1. PSD fidelity and Photoshop acceptance
- **Done 2026-10-23 (#8940):** Filter Gallery Halftone Pattern preserves size, contrast and dot/circle/line through PSD/PSB import/export, with the reference application's descriptor keys (`HlSz`, `Cntr`, `ScrT`). Malformed descriptors stay verbatim. Other unsupported gallery effects remain opaque on import or produce an export warning.
- **Average (2026-10-23):** the existing `Avrg` PSD mapping now has an executable smart-filter path. [Focused tests](average-filter.md#smart-filters) cover the command and re-rendering after discarding cached pixels; the local Photoshop RGB8 oracle is opt-in and does not change corpus floors. Broader filter parity remains open.
- **Missing:** PSDs we write are not always accepted by Photoshop, and Photoshop-authored files
don't always render as Photoshop renders them.
- **Partial (2026-10-15, #5972 A1):** `GdFl` noise fills render when all three RGB ranges are
@@ -513,6 +514,8 @@ what an agent can't finish alone.
| 2026-10-23 | minor | G10: Amiga IFF write landed (24-plane ILBM, Save As `.iff`) and 24-plane ILBMs open |
| 2026-10-23 | minor | #8940: Filter Gallery Halftone Pattern maps size, contrast and dot/circle/line both ways with the reference application's descriptor keys (`HlSz`, `Cntr`, `ScrT`); PSD/PSB round trips at 8/16/32 bits, malformed descriptors preserved verbatim. Other gallery effects remain partial; no floor or estimate changed. |
| 2026-10-22 | minor | G11: document offsets use opening order despite tab reordering; lifecycle and `.atn` playback regressions; behavioral source evidence, no new live Photoshop reproduction |
| 2026-10-23 | minor | Average runs on Smart Objects and imported `Avrg` records; synthetic depth tests and an opt-in cold Photoshop PSD re-render are documented in [average-filter.md](average-filter.md). Corpus floors and percentages unchanged. |
| 2026-10-23 | minor | Average preserves transparency; synthetic Photoshop 26.6.1 RGB8 observations and 8/16/32-bit engine regressions in [average-filter.md](average-filter.md). Broader colour-model and feathering parity remains unmeasured; percentages and corpus floors unchanged. |
| 2026-10-22 | minor | G17: Help › Check for Updates and an opt-out check once per launch (#5485), DIST-220-1 partial; estimate unchanged |
| 2026-10-19 | minor | G5: Free Transform of several selected layers (#6501): one box moves them all; no estimate changed |
| 2026-10-22 | minor | G1: 32-bit Hue/Saturation unclipped with Photoshop's saturation factor, bright colours keep their hue, Colorize's saturation scaled by 1 − \|Lightness\|; photoshop rgb32 hue-saturation and hue-saturation-colorize pass; Photoshop-oracle floor 188 → 190 of 258 (#5781) |
+8 -1
View File
@@ -1,6 +1,6 @@
# PhotoCraft vs Adobe Photoshop: target-app parity
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-11-02 · **Change:** minor (RGB and CMYK documents preview their spot inks as an overprint, FILE-215-17 narrows to PDF Separation, #10249; tile-sparse Select and Mask outputs and preview coverage, #8822; classical Select › Sky can seed substantial high-confidence components beneath an obstructed top band; learned segmentation remains open; Art History Brush Opacity on the options bar, #10263; no estimate changed; Automation Batch Esc Continue/Stop and read-only input previews now follow the measured cancellation flow; full live tabs remain partial, AUTO-219-5, #4579; Automation recording folds Move-tool nudge runs, records keyboard document switches by opening-order offset and scales point text placement; paragraph boxes remain open, #4514; Automation Batch output naming and UTF-16 logs now follow measured Photoshop behavior; progress and cancel prompt remain partial, AUTO-219-5, #4579; 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; selected colour sets edit drawing, tone and supported filters; geometry remains open; Smooth and Feather command masks cropped to selection bounds plus blur reach; Equalize with a selection asks Selected Area Only or Entire Image Based On Selected Area; cropped selection morphology and command mask conversion performance evidence added; the Camera Raw row names per-camera colour for non-DNG cameras as missing, not the Calibration panel, which landed 2026-10-13; Content-Aware Scale measurements added, #5236; Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; area type, TYPE-218-10 partial, Type scorecard 5 / 5 / 0; compound layer properties export as separate Photoshop Set events, #8304; JPEG Options dialog landed, FILE-215-19 partial; Print › Functions › Bleed through `file.print`; Content-Aware Move live preview for every selection size, TOOL-213-4 done, #6680; retouching evidence: Pattern Stamp and Background Eraser draw live, only the Mixer Brush is left of TOOL-213-7; fill and adjustment layers copy and paste whole, UI-217-23 partial, #2344; shape layers take Density and Feather, TOOL-214-12 done, #5846; on-canvas corner radius widgets, TOOL-214-12; `.atn` import maps 112 events; Free Transform H/V skew fields; stale rows corrected against `main` and the scorecard, estimates unchanged; Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment; stale missing-tool rows corrected: Color Replacement and Red Eye are live toolbar tools, Frame landed; percentages unchanged; PSD spot plates in Photoshop's polarity, #7415 split; TIFF spot plates, FILE-215-17's TIFF part, #7415) · **Target:** Adobe Photoshop 2026; Print: Japanese paper sizes — はがき, 往復はがき, JIS B5/B4, A6, L判, 2L判, KG判, 六切, 四切;
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-11-02 · **Change:** minor (Average executes through shared filters on Smart Objects and imported PSD records; Average preserves per-pixel alpha and includes hidden RGB in its mean; RGB8 observations against Photoshop 26.6.1, see average-filter.md; RGB and CMYK documents preview their spot inks as an overprint, FILE-215-17 narrows to PDF Separation, #10249; tile-sparse Select and Mask outputs and preview coverage, #8822; classical Select › Sky can seed substantial high-confidence components beneath an obstructed top band; learned segmentation remains open; Art History Brush Opacity on the options bar, #10263; no estimate changed; Automation Batch Esc Continue/Stop and read-only input previews now follow the measured cancellation flow; full live tabs remain partial, AUTO-219-5, #4579; Automation recording folds Move-tool nudge runs, records keyboard document switches by opening-order offset and scales point text placement; paragraph boxes remain open, #4514; Automation Batch output naming and UTF-16 logs now follow measured Photoshop behavior; progress and cancel prompt remain partial, AUTO-219-5, #4579; 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; selected colour sets edit drawing, tone and supported filters; geometry remains open; Smooth and Feather command masks cropped to selection bounds plus blur reach; Equalize with a selection asks Selected Area Only or Entire Image Based On Selected Area; cropped selection morphology and command mask conversion performance evidence added; the Camera Raw row names per-camera colour for non-DNG cameras as missing, not the Calibration panel, which landed 2026-10-13; Content-Aware Scale measurements added, #5236; Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; area type, TYPE-218-10 partial, Type scorecard 5 / 5 / 0; compound layer properties export as separate Photoshop Set events, #8304; JPEG Options dialog landed, FILE-215-19 partial; Print › Functions › Bleed through `file.print`; Content-Aware Move live preview for every selection size, TOOL-213-4 done, #6680; retouching evidence: Pattern Stamp and Background Eraser draw live, only the Mixer Brush is left of TOOL-213-7; fill and adjustment layers copy and paste whole, UI-217-23 partial, #2344; shape layers take Density and Feather, TOOL-214-12 done, #5846; on-canvas corner radius widgets, TOOL-214-12; `.atn` import maps 112 events; Free Transform H/V skew fields; stale rows corrected against `main` and the scorecard, estimates unchanged; Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment; stale missing-tool rows corrected: Color Replacement and Red Eye are live toolbar tools, Frame landed; percentages unchanged; PSD spot plates in Photoshop's polarity, #7415 split; TIFF spot plates, FILE-215-17's TIFF part, #7415) · **Target:** Adobe Photoshop 2026; Print: Japanese paper sizes — はがき, 往復はがき, JIS B5/B4, A6, L判, 2L判, KG判, 六切, 四切;
The authoritative answer to "how close is PhotoCraft to Photoshop, and how much work is left". The
one-page summary is [`ROADMAP.md`](../ROADMAP.md); the ranked work list is [`gaps.md`](gaps.md);
@@ -162,6 +162,11 @@ people send ×0.95 (JPEG, PNG, HEIC, WebP, PSD open; AVIF and PDF don't, #463, #
## Feature areas
Average now executes through the shared filter pipeline on Smart Objects and imported
PSD records. [Focused evidence](average-filter.md#smart-filters) covers synthetic
RGB8/16/32f cases and an opt-in Photoshop 26.6.1 RGB8 re-render after clearing caches;
this does not raise the broad filter estimate or any corpus floor.
Percent is ready-for-real-work for that area (depth and fidelity, not presence). Hours are to full
parity for the area and are already contained in the dimension rows above.
@@ -607,6 +612,8 @@ See [method, raw measurements and reproduction](five-algorithm-performance.md).
| 2026-10-25 | minor | UI / UX and Workspace rows: 12 of 178 preferences do nothing, the scorecard's count. Camera Raw row: the Calibration panel landed on 2026-10-13 (#5128); what is missing is per-camera colour for non-DNG cameras ([gaps.md](gaps.md) G14). Percentages and estimates unchanged |
| 2026-10-23 | minor | Colour management and modes: PSD spot plates are stored as Photoshop stores them (ink 0 = solid), and PSDs from earlier PhotoCraft versions keep their ink (#7415 split, part 4); estimates unchanged |
| 2026-10-23 | minor | Colour management and modes: spot plates now save and open in TIFF (#7415); estimates unchanged |
| 2026-10-23 | minor | Average runs on Smart Objects and imported `Avrg` records; synthetic depth tests and an opt-in cold Photoshop PSD re-render are documented in [average-filter.md](average-filter.md). Corpus floors and percentages unchanged. |
| 2026-10-23 | minor | Average preserves transparency; synthetic Photoshop 26.6.1 RGB8 observations and 8/16/32-bit engine regressions in [average-filter.md](average-filter.md). Broader colour-model and feathering parity remains unmeasured; percentages and corpus floors unchanged. |
| 2026-10-22 | minor | Platforms: Help › Check for Updates and an opt-out check once per launch (#5485), DIST-220-1 partial; percentages unchanged |
| 2026-10-22 | minor | Print: Japanese paper sizes (はがき, 往復はがき, JIS B5/B4, A6, L判, 2L判, KG判, 六切, 四切) in the Print dialog, `file.print` and the PDF page, by their customary names; no percentage changed |
| 2026-10-21 | minor | #8304: compound layer properties export as separate Set events, rename last; independent Photoshop replay pending; percentages and estimates unchanged |