diff --git a/crates/algo/src/average.rs b/crates/algo/src/average.rs new file mode 100644 index 00000000000..982ec7cb6f6 --- /dev/null +++ b/crates/algo/src/average.rs @@ -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 { + 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 { + 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; diff --git a/crates/algo/src/average/tests.rs b/crates/algo/src/average/tests.rs new file mode 100644 index 00000000000..c7158bd7438 --- /dev/null +++ b/crates/algo/src/average/tests.rs @@ -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]); + } + } +} diff --git a/crates/algo/src/lib.rs b/crates/algo/src/lib.rs index ad72954a22d..d1c797b228c 100644 --- a/crates/algo/src/lib.rs +++ b/crates/algo/src/lib.rs @@ -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 { 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; diff --git a/crates/engine/src/cmd/extra_cmds.rs b/crates/engine/src/cmd/extra_cmds.rs index da5bc746b4a..275e3446479 100644 --- a/crates/engine/src/cmd/extra_cmds.rs +++ b/crates/engine/src/cmd/extra_cmds.rs @@ -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 { - 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 { 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 { }) } -/// Filter › Blur › Average: the selection (or layer) filled with its mean colour. -fn average(s: &mut Session, p: &Value) -> Result { - 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 { has_layer, show_only ), - spec!("filter.blur.average", "Average", &["Filter", "Blur"], None, "{}", has_pixels, average), ] } diff --git a/crates/engine/src/cmd/extra_cmds/tests.rs b/crates/engine/src/cmd/extra_cmds/tests.rs index 3543a012b44..ba0140504e4 100644 --- a/crates/engine/src/cmd/extra_cmds/tests.rs +++ b/crates/engine/src/cmd/extra_cmds/tests.rs @@ -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); diff --git a/crates/engine/src/filters.rs b/crates/engine/src/filters.rs index d2fcd10064e..7c9e28a64d4 100644 --- a/crates/engine/src/filters.rs +++ b/crates/engine/src/filters.rs @@ -84,6 +84,7 @@ pub fn params_for(id: &str, p: &Value) -> Option { 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 { 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"], "{}"), diff --git a/crates/engine/src/filters/validation.rs b/crates/engine/src/filters/validation.rs index da816353d56..c658dd01ac9 100644 --- a/crates/engine/src/filters/validation.rs +++ b/crates/engine/src/filters/validation.rs @@ -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" diff --git a/crates/engine/tests/it/average_smart.rs b/crates/engine/tests/it/average_smart.rs new file mode 100644 index 00000000000..0f6458f5f18 --- /dev/null +++ b/crates/engine/tests/it/average_smart.rs @@ -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()); +} diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index b53745ab2e9..33e9e9e7cf7 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -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; diff --git a/docs/average-filter.md b/docs/average-filter.md new file mode 100644 index 00000000000..65e91a84337 --- /dev/null +++ b/docs/average-filter.md @@ -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. diff --git a/docs/gaps.md b/docs/gaps.md index 7a038cf6cf1..65ef3715f22 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -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 Mixer-only controls, #3825 partial; G9: Preferences › File Handling › Save in Background takes effect, #6207, 24 of 169 preferences do nothing; G7: Content-Aware Move previews every selection the command takes, coarse then exact, TOOL-213-4 done, #6680; G4: Pattern Stamp and Background Eraser draw live, TOOL-213-7; G9: Animated Zoom pref read: holding the Zoom tool zooms continuously, #6210, prefs that do nothing 25 of 166; G2: a cancelled 24 MP Gaussian Blur job stops in at most 44 ms, P25 under its 200 ms budget in local runs, baseline not re-run, #6205; G1: generated large-file fixtures, a 4.38 GB PSB opens on Windows through io, FILE-216-2 done, #5107; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed; G10: Amiga IFF write; G10: PSD spot channels in Photoshop's polarity, earlier PhotoCraft PSDs keep their ink, #7415 split; G10: TIFF alpha and spot channels written and read as extra samples, FILE-215-7 done, FILE-215-17's TIFF part, #7415) · **Target:** Adobe Photoshop 2026) +> **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; 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 Mixer-only controls, #3825 partial; G9: Preferences › File Handling › Save in Background takes effect, #6207, 24 of 169 preferences do nothing; G7: Content-Aware Move previews every selection the command takes, coarse then exact, TOOL-213-4 done, #6680; G4: Pattern Stamp and Background Eraser draw live, TOOL-213-7; G9: Animated Zoom pref read: holding the Zoom tool zooms continuously, #6210, prefs that do nothing 25 of 166; G2: a cancelled 24 MP Gaussian Blur job stops in at most 44 ms, P25 under its 200 ms budget in local runs, baseline not re-run, #6205; G1: generated large-file fixtures, a 4.38 GB PSB opens on Windows through io, FILE-216-2 done, #5107; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed; G10: Amiga IFF write; G10: PSD spot channels in Photoshop's polarity, earlier PhotoCraft PSDs keep their ink, #7415 split; G10: TIFF alpha and spot channels written and read as extra samples, FILE-215-7 done, FILE-215-17's TIFF part, #7415) · **Target:** Adobe Photoshop 2026) 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) | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 1ac44f19948..16d0a79e8c3 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -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 |