mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-11 00:11:46 +00:00
Execute Average through the shared smart-filter pipeline
This commit is contained in:
1 parent
8e0fc5299a
commit
81988038ff
11 files changed
+365
-30
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,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
@@ -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;
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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"], "{}"),
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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,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
@@ -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) |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in new issue
Block a user