mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-11 16:31:34 +00:00
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:
12 files changed
+488
-36
No files matched your search
@@ -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;
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"], "{}"),
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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) |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in new issue
Block a user