Execute Average through the shared smart-filter pipeline

This commit is contained in:
ArjenS-LA committed 2026-10-24 00:38:17 -07:00
1 parent 8e0fc5299a
commit 81988038ff
11 files changed
+365 -30

No files matched your search

+135
View File
@@ -0,0 +1,135 @@
//! Average changes colour, never coverage. Keep the existing RGB conversion and selection
//! weighting; Photoshop RGB8 observations are documented in docs/average-filter.md.
use photocraft_color::{PixelFormat, SampleType};
use photocraft_geom::Rect;
use photocraft_raster::{Interrupt, Surface, from_rgba_into, to_rgba};
use crate::{Ctx, Image};
#[derive(Default)]
struct Mean {
sum: [f64; 3],
weight: f64,
}
impl Mean {
fn add(&mut self, fmt: &PixelFormat, px: &[f32], coverage: f32) {
let rgba = to_rgba(fmt, px);
let w = f64::from(coverage);
for (sum, value) in self.sum.iter_mut().zip(rgba) {
*sum += f64::from(value) * w;
}
self.weight += w;
}
fn colour(&self) -> Option<[f32; 3]> {
(self.weight > 0.0).then(|| self.sum.map(|s| (s / self.weight) as f32))
}
}
fn mix(fmt: &PixelFormat, px: &mut [f32], colour: [f32; 3], coverage: f32) {
if coverage <= 0.0 {
return;
}
let mut rgba = to_rgba(fmt, px);
for (value, mean) in rgba.iter_mut().zip(colour) {
*value += (mean - *value) * coverage;
}
from_rgba_into(fmt, rgba, px);
}
// Rect's public coordinates can span more than i32::MAX. Reject these before raster helpers
// do their signed coordinate arithmetic, and cap a single operation's sample count.
fn valid_area(area: Rect, channels: usize) -> bool {
area.width() <= i32::MAX as u32
&& area.height() <= i32::MAX as u32
&& u64::from(area.width()).saturating_mul(u64::from(area.height())).saturating_mul(channels as u64) <= 512 * 1024 * 1024
}
pub(crate) fn apply(surface: &Surface, area: Rect, selection: Option<&Surface>, ctl: &Interrupt<'_>) -> Option<Surface> {
let fmt = surface.format();
let n = fmt.channels();
if !valid_area(area, n) || ctl.cancelled() {
return None;
}
if area.is_empty() {
return Some(surface.clone());
}
let mut raw = surface.try_read_region(area).ok()?;
let width = area.width() as usize;
let coverage = |i: usize| selection.map_or(1.0, |s| s.sample_channel(area.x0 + (i % width) as i32, area.y0 + (i / width) as i32, 0));
let mut mean = Mean::default();
for (i, px) in raw.chunks_exact(n).enumerate() {
if i % width == 0 && ctl.cancelled() {
return None;
}
mean.add(&fmt, px, coverage(i));
}
let Some(colour) = mean.colour() else { return Some(surface.clone()) };
for (i, px) in raw.chunks_exact_mut(n).enumerate() {
if i % width == 0 && ctl.cancelled() {
return None;
}
mix(&fmt, px, colour, coverage(i));
}
let mut out = surface.clone();
if ctl.cancelled() {
return None;
}
out.try_write_region(area, &raw).ok()?;
out.prune();
ctl.progress(1.0);
Some(out)
}
// The direct kernel API has no selection. Its mean is over the reference bounds, even when
// the requested output is only a tile; the surface dispatcher above also handles selections.
pub(crate) fn kernel(src: &Image, out: Rect, ctx: &Ctx) -> Vec<f32> {
let fmt = PixelFormat::new(ctx.mode, SampleType::F32, ctx.alpha);
let n = fmt.channels();
if src.ch != n || !valid_area(src.rect, n) || !valid_area(out, n) {
return Vec::new();
}
let read = |x: i32, y: i32| -> Option<&[f32]> {
if !src.rect.contains(x, y) {
return None;
}
let i = (i64::from(y) - i64::from(src.rect.y0)) as usize;
let j = (i64::from(x) - i64::from(src.rect.x0)) as usize;
let start = i.checked_mul(src.rect.width() as usize)?.checked_add(j)?.checked_mul(n)?;
src.data.get(start..start.checked_add(n)?)
};
let bounds = ctx.bounds.intersect(&src.rect);
let mut mean = Mean::default();
for y in bounds.y0..bounds.y1 {
for x in bounds.x0..bounds.x1 {
if let Some(px) = read(x, y) {
mean.add(&fmt, px, 1.0);
}
}
}
let len = (out.width() as usize).checked_mul(out.height() as usize).and_then(|s| s.checked_mul(n));
let Some(len) = len else { return Vec::new() };
let mut result = Vec::new();
if result.try_reserve_exact(len).is_err() {
return result;
}
let colour = mean.colour();
for y in out.y0..out.y1 {
for x in out.x0..out.x1 {
let mut px = [0.0; 5];
let Some(pixel) = px.get_mut(..n) else { return Vec::new() };
if let Some(source) = read(x, y) {
pixel.copy_from_slice(source);
}
if let Some(colour) = colour {
mix(&fmt, pixel, colour, 1.0);
}
result.extend_from_slice(pixel);
}
}
result
}
#[cfg(test)]
mod tests;
+37
View File
@@ -0,0 +1,37 @@
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);
}
+13 -3
View File
@@ -20,6 +20,7 @@
mod artistic;
mod artistic_fx;
mod average;
mod blur;
mod blur2;
pub mod camera_raw;
@@ -202,6 +203,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,
@@ -577,7 +580,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 { .. }
@@ -619,14 +623,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",
@@ -750,6 +756,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),
@@ -1077,6 +1084,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;
+5 -24
View File
@@ -262,28 +262,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) = ([0f64; 3], 0f64);
for (p, &k) in px.iter().zip(cover) {
// Average changes colour, not transparency; hidden RGB contributes too.
let w = k as f64;
for c in 0..3 {
sum[c] += p[c] as f64 * w;
}
wsum += w;
}
if wsum <= 0.0 {
return;
}
let avg = sum.map(|v| (v / wsum) as f32);
for p in px {
p[..3].copy_from_slice(&avg);
}
})
}
// ---------- transform presets ----------
/// The presets' params: like Free Transform, they follow the Channels and Layers panel target.
@@ -1023,7 +1001,6 @@ pub fn specs() -> Vec<CommandSpec> {
has_layer,
show_only
),
spec!("filter.blur.average", "Average", &["Filter", "Blur"], None, "{}", has_pixels, average),
]
}
@@ -1055,7 +1032,7 @@ mod tests {
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})
@@ -1070,6 +1047,10 @@ mod tests {
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| {
+2
View File
@@ -84,6 +84,7 @@ pub fn params_for(id: &str, p: &Value) -> Option<FilterParams> {
return Some(fp);
}
Some(match id {
"filter.blur.average" => FilterParams::Average,
"filter.blur.gaussianBlur" => FilterParams::GaussianBlur { radius: f(p, "radius", 1.0).clamp(0.1, 1000.0) },
"filter.blur.boxBlur" => FilterParams::BoxBlur { radius: f(p, "radius", 1.0).clamp(1.0, 2000.0) },
"filter.blur.motionBlur" => FilterParams::MotionBlur { angle: f(p, "angle", 0.0), distance: f(p, "distance", 10.0).clamp(1.0, 2000.0) },
@@ -408,6 +409,7 @@ macro_rules! filter_cmd {
pub fn specs() -> Vec<CommandSpec> {
vec![
filter_cmd!("filter.blur.gaussianBlur", "Gaussian Blur…", ["Filter", "Blur"], r##"{"radius":0.1..1000=1}"##),
filter_cmd!("filter.blur.average", "Average", ["Filter", "Blur"], "{}"),
filter_cmd!("filter.blur.blur", "Blur", ["Filter", "Blur"], "{}"),
filter_cmd!("filter.blur.blurMore", "Blur More", ["Filter", "Blur"], "{}"),
filter_cmd!("filter.sharpen.sharpen", "Sharpen", ["Filter", "Sharpen"], "{}"),
+2 -1
View File
@@ -94,7 +94,8 @@ fn fields(id: &str) -> Option<&'static [(&'static str, Rule)]> {
("vertical", Number(f64::MIN, f64::MAX)),
("undefinedAreas", Choice("wrap|repeat|transparent")),
],
"filter.blur.blur"
"filter.blur.average"
| "filter.blur.blur"
| "filter.blur.blurMore"
| "filter.sharpen.sharpen"
| "filter.sharpen.sharpenMore"
+124
View File
@@ -0,0 +1,124 @@
//! Exercise Average through the smart-filter renderer, not an imported cache.
use photocraft_doc::LayerContent;
use photocraft_engine::{Session, filters, smart_cmds};
use photocraft_geom::Rect;
use photocraft_raster::Surface;
use serde_json::json;
fn image(s: &Session) -> Surface {
let d = s.active().unwrap();
let l = d.doc.layer(d.active_layer.unwrap()).unwrap();
match &l.content {
LayerContent::Raster(r) => r.clone(),
LayerContent::Smart(sm) => sm.cache.clone().unwrap(),
_ => panic!("expected pixels"),
}
}
fn input(depth: u32) -> Session {
let mut s = Session::new();
s.execute("file.new", json!({"width": 16, "height": 16, "depth": depth})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
let d = s.active_mut().unwrap();
let l = std::sync::Arc::make_mut(&mut d.doc).layer_mut(d.active_layer.unwrap()).unwrap();
let surf = l.surface_mut().unwrap();
for y in 0..16 {
for x in 0..16 {
surf.write_region(Rect::new(x, y, x + 1, y + 1), if x < 8 { &[1.0, 0.0, 0.0, 1.0] } else { &[0.0, 0.0, 1.0, 1.0] });
}
}
s
}
fn purple(surf: &Surface) {
for p in surf.read_region(Rect::new(0, 0, 16, 16)).as_chunks::<4>().0 {
assert!((p[0] - 0.5).abs() <= 1.0 / 255.0, "{p:?}");
assert_eq!(p[1], 0.0);
assert!((p[2] - 0.5).abs() <= 1.0 / 255.0, "{p:?}");
assert_eq!(p[3], 1.0);
}
}
#[test]
fn average_stored_filter_executes() {
for depth in [8, 16, 32] {
let s = input(depth);
let bounds = s.active().unwrap().doc.bounds();
let out = filters::apply_filter_to_surface("filter.blur.average", &json!({}), &image(&s), bounds, None, bounds)
.unwrap()
.expect("Average must be executable, not treated as an unknown filter");
purple(&out);
}
}
#[test]
fn average_smart_command_rerenders_and_undoes() {
for depth in [8, 16, 32] {
let mut s = input(depth);
s.execute("filter.convertForSmartFilters", json!({})).unwrap();
let before = image(&s);
let steps = s.active().unwrap().history.past_len();
assert!(s.is_enabled("filter.blur.average"));
s.execute("filter.blur.average", json!({})).unwrap();
assert_eq!(s.active().unwrap().history.past_len(), steps + 1);
purple(&image(&s));
assert!(s.undo());
assert_eq!(image(&s), before);
assert!(s.redo());
purple(&image(&s));
s.execute("layer.smartFilter.setVisible", json!({"index": 0, "visible": false})).unwrap();
assert_eq!(image(&s), before);
s.execute("layer.smartFilter.setVisible", json!({"index": 0, "visible": true})).unwrap();
let d = s.active_mut().unwrap();
let snapshot = d.doc.clone();
let layer = std::sync::Arc::make_mut(&mut d.doc).layer_mut(d.active_layer.unwrap()).unwrap();
if let LayerContent::Smart(sm) = &mut layer.content {
assert_eq!(sm.smart_filters.len(), 1);
assert_eq!(sm.smart_filters[0].command, "filter.blur.average");
sm.cache = None;
}
smart_cmds::purge_stack_cache();
assert!(smart_cmds::refresh_layer(&snapshot, layer).unwrap());
purple(&image(&s));
}
}
#[test]
fn average_rejects_bad_params_without_an_edit() {
let mut s = input(8);
let before = image(&s);
let steps = s.active().unwrap().history.past_len();
for params in [json!("bad"), json!({"unknown": 1}), json!({"layer": -1}), json!({"target": false})] {
assert!(s.execute("filter.blur.average", params).is_err());
assert_eq!(image(&s), before);
assert_eq!(s.active().unwrap().history.past_len(), steps);
}
}
/// Optional local oracle: generate a 16×16 red/blue Smart Object in Photoshop, apply Average,
/// save as PSD, then set PHOTOCRAFT_AVERAGE_PSD to that file. Never accept cached pixels as proof.
#[test]
#[ignore = "requires a locally generated Photoshop Average PSD; see docs/average-filter.md"]
fn average_photoshop_psd_rerenders_from_source() {
let path = std::env::var("PHOTOCRAFT_AVERAGE_PSD").expect("set PHOTOCRAFT_AVERAGE_PSD");
let bytes = std::fs::read(&path).unwrap();
let mut doc = photocraft_io::import(&path, &bytes).unwrap().document;
let snapshot = doc.clone();
let ids: Vec<_> = snapshot
.walk()
.into_iter()
.filter_map(|(_, _, l)| match &l.content {
LayerContent::Smart(sm) if sm.smart_filters.iter().any(|f| f.command == "filter.blur.average") => Some(l.id),
_ => None,
})
.collect();
assert_eq!(ids.len(), 1);
let layer = doc.layer_mut(ids[0]).unwrap();
if let LayerContent::Smart(sm) = &mut layer.content {
sm.cache = None;
}
smart_cmds::purge_stack_cache();
assert!(smart_cmds::refresh_layer(&snapshot, layer).unwrap());
let LayerContent::Smart(sm) = &layer.content else { panic!("expected smart object") };
purple(sm.cache.as_ref().unwrap());
}
+1
View File
@@ -6,6 +6,7 @@ mod atn_crystallize_cell_size;
mod atn_ripple_amount;
mod atn_selective_color;
mod atn_stroke_width;
mod average_smart;
mod background_and_mask_depth;
mod background_layer_consistency;
mod brush_smoothing;
+36
View File
@@ -36,3 +36,39 @@ 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.
+3 -1
View File
@@ -1,6 +1,6 @@
# Where PhotoCraft falls short of Photoshop
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (Average preserves per-pixel alpha and includes hidden RGB in its mean; RGB8 observations against Photoshop 26.6.1, see average-filter.md; 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) · **Target:** Adobe Photoshop 2026
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (Average smart-filter execution uses the shared algorithm; Average preserves per-pixel alpha and includes hidden RGB in its mean; RGB8 observations against Photoshop 26.6.1, see average-filter.md; 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) · **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
@@ -40,6 +40,7 @@ what an agent can't finish alone.
## Feature gaps
### G1. PSD fidelity and Photoshop acceptance
- **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
@@ -358,6 +359,7 @@ what an agent can't finish alone.
| Date | Change | Summary |
|---|---|---|
| 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-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) |
+7 -1
View File
@@ -1,6 +1,6 @@
# PhotoCraft vs Adobe Photoshop: target-app parity
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-23 · **Change:** minor (Average preserves per-pixel alpha and includes hidden RGB in its mean; RGB8 observations against Photoshop 26.6.1, see average-filter.md; 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) · **Target:** Adobe Photoshop 2026
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-23 · **Change:** minor (Average smart-filter execution uses the shared algorithm; Average preserves per-pixel alpha and includes hidden RGB in its mean; RGB8 observations against Photoshop 26.6.1, see average-filter.md; 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) · **Target:** Adobe Photoshop 2026
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.
@@ -565,6 +570,7 @@ See [method, raw measurements and reproduction](five-algorithm-performance.md).
| Date | Change | Summary |
|---|---|---|
| 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-21 | minor | #8304: compound layer properties export as separate Set events, rename last; independent Photoshop replay pending; percentages and estimates unchanged |