From 0456bc3f22e1b4baf1d934a9ea20f8070ddc4a73 Mon Sep 17 00:00:00 2001 From: vistaero Date: Sat, 10 Oct 2026 06:32:13 +0200 Subject: [PATCH 001/216] Add guarded scratch storage with owned memory supervision --- Cargo.lock | 215 +++++++-- crates/algo/src/lib.rs | 24 + crates/algo/src/selection.rs | 8 +- crates/algo/src/selection/marquee.rs | 12 +- crates/algo/src/tests.rs | 9 + crates/compose/src/bounds.rs | 11 +- crates/compose/src/lib.rs | 15 + crates/compose/src/masks.rs | 12 +- crates/compose/src/shape_split.rs | 8 + crates/compose/tests/scratch_retry.rs | 49 ++ crates/engine/src/color_cmds.rs | 33 +- crates/engine/src/fill_cmds.rs | 12 +- crates/engine/src/filters.rs | 29 ++ crates/engine/src/jobs.rs | 8 + crates/engine/src/lens_cmds.rs | 2 + crates/engine/src/lib.rs | 11 + crates/engine/src/pixels.rs | 14 +- crates/engine/src/prefs.rs | 32 +- crates/engine/src/scratch.rs | 142 ++++++ crates/engine/tests/memory_prefs.rs | 24 + crates/engine/tests/scratch_disk.rs | 90 ++++ crates/format/src/store.rs | 49 +- crates/format/tests/scratch_retry.rs | 48 ++ crates/gpu/src/lib.rs | 6 +- crates/io/src/lib.rs | 8 + crates/ops/src/lib.rs | 2 +- crates/plugins/src/apply.rs | 4 +- crates/raster/Cargo.toml | 5 + crates/raster/examples/scratch_bench.rs | 52 +++ crates/raster/src/lib.rs | 259 ++++++++--- crates/raster/src/memory.rs | 181 ++++++++ crates/raster/src/scratch_file.rs | 430 +++++++++++++++++ crates/raster/src/spill.rs | 563 +++++++++++++++++++++++ crates/raster/tests/scratch_translate.rs | 44 ++ crates/raster/tests/spill.rs | 149 ++++++ crates/ui-egui/src/canvas.rs | 8 +- crates/ui-egui/src/control.rs | 2 + crates/ui-egui/src/i18n/cs.tsv | 2 + crates/ui-egui/src/i18n/de.tsv | 2 + crates/ui-egui/src/i18n/el.tsv | 2 + crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/fr.tsv | 2 + crates/ui-egui/src/i18n/hu.tsv | 2 + crates/ui-egui/src/i18n/id.tsv | 2 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/ko.tsv | 2 + crates/ui-egui/src/i18n/nl.tsv | 2 + crates/ui-egui/src/i18n/pl.tsv | 2 + crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/sr.tsv | 2 + crates/ui-egui/src/i18n/tr.tsv | 2 + crates/ui-egui/src/i18n/uk.tsv | 2 + crates/ui-egui/src/i18n/vi.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + crates/ui-egui/src/lib.rs | 8 + crates/ui-egui/src/prefs_ui.rs | 21 +- docs/development.md | 4 + docs/roadmap.md | 2 + docs/scorecard.md | 8 +- docs/scratch-disks.md | 39 ++ scorecard/reliability.toml | 2 +- 64 files changed, 2543 insertions(+), 151 deletions(-) create mode 100644 crates/compose/tests/scratch_retry.rs create mode 100644 crates/engine/src/scratch.rs create mode 100644 crates/engine/tests/memory_prefs.rs create mode 100644 crates/engine/tests/scratch_disk.rs create mode 100644 crates/format/tests/scratch_retry.rs create mode 100644 crates/raster/examples/scratch_bench.rs create mode 100644 crates/raster/src/memory.rs create mode 100644 crates/raster/src/scratch_file.rs create mode 100644 crates/raster/src/spill.rs create mode 100644 crates/raster/tests/scratch_translate.rs create mode 100644 crates/raster/tests/spill.rs create mode 100644 docs/scratch-disks.md diff --git a/Cargo.lock b/Cargo.lock index 6820d6dee5..79b80aae04 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -99,8 +99,8 @@ dependencies = [ "accesskit_consumer 0.35.0", "hashbrown 0.16.1", "static_assertions", - "windows", - "windows-core", + "windows 0.62.2", + "windows-core 0.62.2", ] [[package]] @@ -898,7 +898,7 @@ dependencies = [ "iana-time-zone", "num-traits", "serde", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -2080,7 +2080,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ "rustix 1.1.5", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -2267,7 +2267,7 @@ dependencies = [ "log", "presser", "thiserror 2.0.21", - "windows", + "windows 0.62.2", ] [[package]] @@ -2371,7 +2371,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core", + "windows-core 0.62.2", ] [[package]] @@ -2709,7 +2709,7 @@ dependencies = [ "simd_cesu8", "thiserror 2.0.21", "walkdir", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -3059,7 +3059,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" dependencies = [ "cfg-if", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -3188,6 +3188,12 @@ dependencies = [ "imgref", ] +[[package]] +name = "lz4_flex" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "373f5eceeeab7925e0c1098212f2fbc4d416adec9d35051a6ab251e824c1854a" + [[package]] name = "maybe-owned" version = "0.3.4" @@ -3416,6 +3422,15 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + [[package]] name = "num-bigint" version = "0.4.8" @@ -3822,6 +3837,16 @@ dependencies = [ "objc2-core-graphics", ] +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + [[package]] name = "objc2-io-surface" version = "0.3.2" @@ -4062,7 +4087,7 @@ dependencies = [ "libc", "redox_syscall 0.5.18", "smallvec", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -4519,10 +4544,13 @@ dependencies = [ name = "photocraft-raster" version = "0.8.2" dependencies = [ + "crc32fast", + "lz4_flex", "photocraft-color", "photocraft-geom", "proptest", "serde", + "sysinfo", ] [[package]] @@ -5885,6 +5913,20 @@ dependencies = [ "libc", ] +[[package]] +name = "sysinfo" +version = "0.37.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16607d5caffd1c07ce073528f9ed972d88db15dd44023fa57142963be3feb11f" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows 0.61.3", +] + [[package]] name = "system-deps" version = "7.0.8" @@ -6913,9 +6955,9 @@ dependencies = [ "web-sys", "wgpu-naga-bridge", "wgpu-types", - "windows", - "windows-core", - "windows-result", + "windows 0.62.2", + "windows-core 0.62.2", + "windows-result 0.4.1", ] [[package]] @@ -6950,6 +6992,22 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -6959,16 +7017,44 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections 0.2.0", + "windows-core 0.61.2", + "windows-future 0.2.1", + "windows-link 0.1.3", + "windows-numerics 0.2.0", +] + [[package]] name = "windows" version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" dependencies = [ - "windows-collections", - "windows-core", - "windows-future", - "windows-numerics", + "windows-collections 0.3.2", + "windows-core 0.62.2", + "windows-future 0.3.2", + "windows-numerics 0.3.1", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", ] [[package]] @@ -6977,7 +7063,20 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" dependencies = [ - "windows-core", + "windows-core 0.62.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", ] [[package]] @@ -6988,9 +7087,20 @@ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement", "windows-interface", - "windows-link", - "windows-result", - "windows-strings", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading 0.1.0", ] [[package]] @@ -6999,9 +7109,9 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" dependencies = [ - "windows-core", - "windows-link", - "windows-threading", + "windows-core 0.62.2", + "windows-link 0.2.1", + "windows-threading 0.2.1", ] [[package]] @@ -7026,20 +7136,45 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + [[package]] name = "windows-numerics" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" dependencies = [ - "windows-core", - "windows-link", + "windows-core 0.62.2", + "windows-link 0.2.1", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", ] [[package]] @@ -7048,7 +7183,16 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-link", + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", ] [[package]] @@ -7057,7 +7201,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -7093,7 +7237,7 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -7118,7 +7262,7 @@ version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ - "windows-link", + "windows-link 0.2.1", "windows_aarch64_gnullvm 0.53.1", "windows_aarch64_msvc 0.53.1", "windows_i686_gnu 0.53.1", @@ -7129,13 +7273,22 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + [[package]] name = "windows-threading" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -7408,7 +7561,7 @@ dependencies = [ "thiserror 2.0.21", "url", "widestring", - "windows", + "windows 0.62.2", "xcb", "zbus", ] diff --git a/crates/algo/src/lib.rs b/crates/algo/src/lib.rs index 78dae7b06f..9e2bc9c702 100644 --- a/crates/algo/src/lib.rs +++ b/crates/algo/src/lib.rs @@ -962,6 +962,30 @@ fn auto_tile(params: &FilterParams, bounds: Rect) -> i32 { } } +/// Conservative transient allocation estimate for filter admission. Output tiles remain in +/// the paged store; whole-image kernels must admit their float input before reading it. +pub fn working_bytes(params: &FilterParams, area: Rect, bounds: Rect, channels: usize) -> u64 { + if area.is_empty() { + return 0; + } + #[cfg(not(target_arch = "wasm32"))] + let threads = rayon::current_num_threads().max(1) as u64; + #[cfg(target_arch = "wasm32")] + let threads = 1u64; + let tile = auto_tile(params, bounds).max(1) as u64; + let floats = (channels as u64).saturating_mul(4); + let base = (RESULT_BUDGET as u64).saturating_mul(3); + match params.halo_for(bounds) { + Halo::Bounds => base.saturating_add(bounds.union(&area).size().area().saturating_mul(floats)), + Halo::Radius(r) => { + let side = tile.saturating_add((r.max(0) as u64).saturating_mul(2)); + base.saturating_add( + side.saturating_mul(side).saturating_add(tile.saturating_mul(tile).saturating_mul(3)).saturating_mul(floats).saturating_mul(threads), + ) + } + } +} + /// [`apply`] / [`apply_in`] with an explicit tile size (tests use it to check tile independence). pub fn apply_tiled( surface: &Surface, diff --git a/crates/algo/src/selection.rs b/crates/algo/src/selection.rs index ad858df2a2..5b3f629715 100644 --- a/crates/algo/src/selection.rs +++ b/crates/algo/src/selection.rs @@ -53,9 +53,10 @@ pub fn mask_from_surface(s: Option<&Surface>, area: Rect) -> Vec { let tc = TileCoord::containing(x, y); let x1 = (tc.rect().x1).min(area.x1); let dst = row.get_mut((x - area.x0) as usize..(x1 - area.x0) as usize).unwrap_or_default(); - let src = s.tile(tc).and_then(|t| { + let guard = s.tile(tc).map(|t| t.bytes()); + let src = guard.as_deref().and_then(|bytes| { let base = (y - tc.ty * TILE_SIZE) as usize * TILE_SIZE as usize + (x - tc.tx * TILE_SIZE) as usize; - t.bytes().get(base..base + dst.len()) + bytes.get(base..base + dst.len()) }); match src { Some(src) => dst.iter_mut().zip(src).for_each(|(d, b)| *d = lut[*b as usize]), @@ -115,7 +116,7 @@ fn surface_from_masks(area: Rect, new: &[f32], prev: Option<&[f32]>, f: impl Fn( let tr = tc.rect().intersect(&area); let (lx, span) = ((tr.x0 - area.x0) as usize, tr.width() as usize); let mut tile = Arc::clone(blank); - let data = Arc::make_mut(&mut tile).bytes_mut(); + let mut data = Arc::make_mut(&mut tile).bytes_mut(); let (mut lo, mut hi) = (if tr == tc.rect() { u8::MAX } else { 0 }, 0u8); for y in tr.y0..tr.y1 { let o = (y - tc.ty * TILE_SIZE) as usize * ts + (tr.x0 - tc.tx * TILE_SIZE) as usize; @@ -123,6 +124,7 @@ fn surface_from_masks(area: Rect, new: &[f32], prev: Option<&[f32]>, f: impl Fn( row((y - area.y0) as usize * w + lx, dst); (lo, hi) = dst.iter().fold((lo, hi), |(lo, hi), b| (lo.min(*b), hi.max(*b))); } + drop(data); (hi > 0).then(|| (*tc, if lo == u8::MAX { Arc::clone(&full) } else { tile })) }); s.put_tiles(built.into_iter().flatten()); diff --git a/crates/algo/src/selection/marquee.rs b/crates/algo/src/selection/marquee.rs index 7dd861164a..12db42b505 100644 --- a/crates/algo/src/selection/marquee.rs +++ b/crates/algo/src/selection/marquee.rs @@ -217,19 +217,25 @@ pub fn combine_surfaces(a: &Surface, b: &Surface, area: Rect, f: impl Fn(f32, f3 } } let mut tile = Tile::clone(&blank); - let data = tile.bytes_mut(); + let mut data = tile.bytes_mut(); + let same_tile = ta.zip(tb).is_some_and(|(left, right)| Arc::ptr_eq(left, right)); + let read_a = ta.map(|tile| tile.bytes()); + let read_b = tb.filter(|_| !same_tile).map(|tile| tile.bytes()); + let bytes_a = read_a.as_deref(); + let bytes_b = if same_tile { bytes_a } else { read_b.as_deref() }; let (mut lo, mut hi) = (if tr == rect { u8::MAX } else { 0 }, 0u8); let (x0, x1) = ((tr.x0 - rect.x0) as usize, (tr.x1 - rect.x0) as usize); for ly in (tr.y0 - rect.y0) as usize..(tr.y1 - rect.y0) as usize { let (s, e) = (ly * TS + x0, ly * TS + x1); - let src_a = ta.and_then(|t| t.bytes().get(s..e)).unwrap_or(&row_a[..x1 - x0]); - let src_b = tb.and_then(|t| t.bytes().get(s..e)).unwrap_or(&row_b[..x1 - x0]); + let src_a = bytes_a.and_then(|bytes| bytes.get(s..e)).unwrap_or(&row_a[..x1 - x0]); + let src_b = bytes_b.and_then(|bytes| bytes.get(s..e)).unwrap_or(&row_b[..x1 - x0]); let Some(dst) = data.get_mut(s..e) else { continue }; for (d, (x, y)) in dst.iter_mut().zip(src_a.iter().zip(src_b)) { *d = pair(*x, *y); (lo, hi) = (lo.min(*d), hi.max(*d)); } } + drop(data); (hi > 0).then(|| (tc, if lo == u8::MAX { Arc::clone(&full) } else { Arc::new(tile) })) }); out.put_tiles(built.into_iter().flatten()); diff --git a/crates/algo/src/tests.rs b/crates/algo/src/tests.rs index 9a90012df9..abca6e3b86 100644 --- a/crates/algo/src/tests.rs +++ b/crates/algo/src/tests.rs @@ -1,6 +1,15 @@ use super::*; use photocraft_color::{PixelFormat, SampleType}; +#[test] +fn relight_admission_tracks_the_document_dependent_working_set() { + let params = FilterParams::Relight { angle: 0.0, elevation: 45.0, intensity: 50.0, ambient: 50.0, warmth: 0.0, softness: 100.0 }; + let small = Rect::new(0, 0, 128, 128); + let large = Rect::new(0, 0, 86400, 43200); + assert!(working_bytes(¶ms, small, small, 4) < working_bytes(¶ms, small, large, 4)); + assert_eq!(working_bytes(¶ms, Rect::EMPTY, large, 4), 0); +} + fn fmt(s: SampleType) -> PixelFormat { PixelFormat::new(ColorMode::Rgb, s, true) } diff --git a/crates/compose/src/bounds.rs b/crates/compose/src/bounds.rs index 985a400e08..dec2d868d9 100644 --- a/crates/compose/src/bounds.rs +++ b/crates/compose/src/bounds.rs @@ -34,6 +34,12 @@ fn cache() -> &'static Mutex { C.get_or_init(|| Mutex::new(Cache { entries: HashMap::new(), sweep_at: MIN_SWEEP })) } +pub(crate) fn purge() { + let mut cache = cache().lock().unwrap_or_else(|e| e.into_inner()); + cache.entries.clear(); + cache.sweep_at = MIN_SWEEP; +} + /// `Surface::content_bounds`, cached per tile. pub fn content_bounds(s: &Surface) -> Rect { if s.tile_count() == 0 { @@ -54,8 +60,11 @@ pub fn content_bounds(s: &Surface) -> Rect { let b = match hit { Some(b) => b, None => { + let generation = photocraft_raster::spill::read_error_generation(); let b = t.content_bounds(&dp); - c.entries.insert(key, Entry { tile: Arc::downgrade(t), default: dp.clone().into_boxed_slice(), bounds: b }); + if photocraft_raster::spill::check_since(generation).is_ok() { + c.entries.insert(key, Entry { tile: Arc::downgrade(t), default: dp.clone().into_boxed_slice(), bounds: b }); + } b } }; diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 8813b122d9..d05766bc43 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -1563,6 +1563,17 @@ pub fn purge_effect_cache() -> usize { freed } +/// Discard derived pixels and their source pins after a scratch read failure. This also lets +/// closing the affected document release its failed tiles instead of keeping them in a cache. +pub fn discard_unreadable_pixel_caches() { + if photocraft_raster::spill::check_integrity().is_err() { + purge_effect_cache(); + bounds::purge(); + masks::purge(); + shape_split::purge(); + } +} + fn fx_cache() -> &'static std::sync::Mutex { static C: std::sync::OnceLock> = std::sync::OnceLock::new(); C.get_or_init(|| std::sync::Mutex::new(FxCache { map: Default::default(), order: Default::default(), bytes: 0 })) @@ -1623,6 +1634,7 @@ fn effect_maps(layer: &Layer, rect: Rect, cx: &Ctx) -> std::sync::Arc std::sync::Arc { + let generation = photocraft_raster::spill::read_error_generation(); let slot = { let mut c = fx_cache().lock().unwrap_or_else(|e| e.into_inner()); if let Some(s) = c.map.get(&key) { @@ -1641,6 +1653,9 @@ fn cached_effect_maps(layer: &Layer, region: Rect, key: u64, cx: &Ctx) -> std::s let maps = effects::build_maps_prepared(layer, shape, region, &cx.light, &texture_ctx(layer, region, cx), cx.patterns); let bytes = maps.bytes(); let maps = std::sync::Arc::new(maps); + if photocraft_raster::spill::check_since(generation).is_err() { + return maps; + } if slot.set(FxEntry { maps: maps.clone(), _pin: layer.clone(), bytes }).is_ok() { // Counted exactly once, by the build that filled the slot, and only while the // slot is still cached (eviction may have dropped it meanwhile). diff --git a/crates/compose/src/masks.rs b/crates/compose/src/masks.rs index d70947ec7c..c8c73df866 100644 --- a/crates/compose/src/masks.rs +++ b/crates/compose/src/masks.rs @@ -81,6 +81,12 @@ fn cache() -> &'static Mutex { C.get_or_init(|| Mutex::new(Cache::default())) } +pub(crate) fn purge() { + let mut c = cache().lock().unwrap_or_else(|e| e.into_inner()); + c.clear(); + c.in_flight.clear(); +} + /// Entries kept (least recently used dropped first). const CAPACITY: usize = 64; @@ -116,7 +122,11 @@ fn lookup(c: &Mutex, k: u64) -> CachedMask { fn finish_mask(c: &Mutex, k: u64, slot: &Arc>, generation: u64, build: impl FnOnce() -> (Surface, Option)) -> Surface { slot.get_or_init(|| { // Never hold the global cache mutex while rasterizing or feathering. + let read_generation = photocraft_raster::spill::read_error_generation(); let (surface, pixel_input) = build(); + if photocraft_raster::spill::check_since(read_generation).is_err() { + return surface; + } let bytes = entry_bytes(&surface, pixel_input.as_ref()); let mut c = c.lock().unwrap_or_else(|e| e.into_inner()); if c.in_flight.get(&k).and_then(Weak::upgrade).is_some_and(|active| Arc::ptr_eq(&active, slot)) { @@ -135,7 +145,7 @@ fn cached_mask(c: &Mutex, k: u64, build: impl FnOnce() -> (Surface, Optio } fn surface_bytes(surface: &Surface) -> usize { - surface.tiles().fold(surface.default_bytes().len(), |bytes, (_, tile)| bytes.saturating_add(tile.bytes().len())) + surface.tiles().fold(surface.default_bytes().len(), |bytes, (_, tile)| bytes.saturating_add(tile.len())) } fn entry_bytes(surface: &Surface, pixel_input: Option<&Surface>) -> usize { diff --git a/crates/compose/src/shape_split.rs b/crates/compose/src/shape_split.rs index 9f0143f889..52b76ccd41 100644 --- a/crates/compose/src/shape_split.rs +++ b/crates/compose/src/shape_split.rs @@ -20,6 +20,10 @@ fn cache() -> &'static Mutex<(HashMap, u64)> { C.get_or_init(|| Mutex::new((HashMap::new(), 0))) } +pub(crate) fn purge() { + cache().lock().unwrap_or_else(|e| e.into_inner()).0.clear(); +} + const CAPACITY: usize = 32; /// Most pixel bytes the split parts may hold (#4530): each entry is two canvas-sized surfaces, /// so 32 of them at 24 MP could hold several GB outside every memory budget. @@ -48,6 +52,7 @@ pub fn purge_cache() -> usize { /// (Photoshop's rendering, `sh.cache`) reach past our fill, the part the fill doesn't explain is /// stroke: the stroke follows Photoshop's coverage (psd-tools double-stroke-effects). pub fn split(sh: &ShapeLayer, canvas: Rect, depth: SampleType) -> Option<(Surface, Surface)> { + let generation = photocraft_raster::spill::read_error_generation(); sh.stroke.as_ref()?; let mut h = std::collections::hash_map::DefaultHasher::new(); (canvas.x0, canvas.y0, canvas.x1, canvas.y1).hash(&mut h); @@ -76,6 +81,9 @@ pub fn split(sh: &ShapeLayer, canvas: Rect, depth: SampleType) -> Option<(Surfac if let Some(cache) = &sh.cache { fit_stroke(&parts.0, &mut parts.1, cache, canvas); } + if photocraft_raster::spill::check_since(generation).is_err() { + return Some(parts); + } let mut c = cache().lock().unwrap_or_else(|e| e.into_inner()); // Least recently used first, until the new entry fits both the count and the byte budget. let new_bytes = entry_bytes(&parts); diff --git a/crates/compose/tests/scratch_retry.rs b/crates/compose/tests/scratch_retry.rs new file mode 100644 index 0000000000..51fe07b1e9 --- /dev/null +++ b/crates/compose/tests/scratch_retry.rs @@ -0,0 +1,49 @@ +#![cfg(not(target_arch = "wasm32"))] + +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_doc::{Document, Effect, Layer}; +use photocraft_geom::{Rect, Size}; +use photocraft_raster::spill; +use std::io::{Read, Seek, SeekFrom, Write}; + +#[test] +fn failed_effect_build_is_not_reused_after_original_pixels_recover() { + let dir = std::env::temp_dir().join(format!("photocraft-compose-scratch-retry-{}", std::process::id())); + spill::configure(Some(&dir), 1).unwrap(); + let mut doc = Document::new("Shadow", Size::new(64, 64), ColorMode::Rgb, SampleType::U8); + let mut background = Layer::raster("Background", PixelFormat::RGBA8); + background.surface_mut().unwrap().fill_rect(Rect::from_xywh(0, 0, 64, 64), &[1.0; 4]); + let mut foreground = Layer::raster("Shadow", PixelFormat::RGBA8); + foreground.surface_mut().unwrap().fill_rect(Rect::new(16, 16, 48, 48), &[1.0, 0.0, 0.0, 1.0]); + foreground.effects.items.push(Effect::default_drop_shadow()); + doc.layers.extend([background, foreground]); + let expected = photocraft_compose::flatten(&doc).px; + // Keep the known-good bounds, but force a new effect-map build from the damaged source. + photocraft_compose::purge_effect_cache(); + spill::settle(); + assert_eq!(spill::stats().resident_bytes, 0); + let path = std::fs::read_dir(&dir).unwrap().filter_map(Result::ok).map(|e| e.path()).find(|p| p.extension().is_some_and(|e| e == "scratch")).unwrap(); + let mut file = std::fs::OpenOptions::new().read(true).write(true).open(path).unwrap(); + let mut good = Vec::new(); + file.read_to_end(&mut good).unwrap(); + file.seek(SeekFrom::Start(0)).unwrap(); + file.write_all(&vec![0; good.len()]).unwrap(); + file.sync_all().unwrap(); + drop(photocraft_compose::flatten(&doc)); + assert!(spill::check_integrity().is_err()); + + file.seek(SeekFrom::Start(0)).unwrap(); + file.write_all(&good).unwrap(); + file.sync_all().unwrap(); + for layer in &doc.layers { + for (_, tile) in layer.surface().unwrap().tiles() { + tile.try_bytes().unwrap(); + } + } + assert!(spill::check_integrity().is_ok()); + assert_eq!(photocraft_compose::flatten(&doc).px, expected); + photocraft_compose::purge_effect_cache(); + drop((doc, file)); + spill::configure(None, 0).unwrap(); + let _ = std::fs::remove_dir_all(dir); +} diff --git a/crates/engine/src/color_cmds.rs b/crates/engine/src/color_cmds.rs index d3a6d36931..d739e74fe4 100644 --- a/crates/engine/src/color_cmds.rs +++ b/crates/engine/src/color_cmds.rs @@ -641,24 +641,29 @@ pub fn convert_surface(s: &Surface, from: ColorMode, to: PixelFormat, t: &Transf .collect(); let tile_px = (photocraft_geom::TILE_SIZE * photocraft_geom::TILE_SIZE) as usize; let dst_len = tile_px * to.bytes_per_pixel(); - let mut staging = vec![0u8; dst_len * distinct.len()]; - let jobs: Vec<(&[u8], &mut [u8])> = distinct.iter().map(|t| t.bytes()).zip(staging.chunks_mut(dst_len.max(1))).collect(); let kind = match sf.sample { SampleType::U8 => SampleKind::U8, SampleType::U16 => SampleKind::U16, SampleType::F32 => SampleKind::F32, }; - t.convert_bytes_many(kind, jobs, ss, ds, true); - let mut converted: Vec>> = vec![None; distinct.len()]; - for (c, i) in tiles { - if let Some(Some(tile)) = converted.get(i) { - out.put_tiles([(c, tile.clone())]); - continue; + let mut converted = Vec::with_capacity(distinct.len()); + for batch in distinct.chunks(32) { + let mut staging = vec![0u8; dst_len * batch.len()]; + { + let sources: Vec<_> = batch.iter().map(|tile| tile.bytes()).collect(); + let jobs: Vec<(&[u8], &mut [u8])> = sources.iter().map(|bytes| &**bytes).zip(staging.chunks_mut(dst_len.max(1))).collect(); + t.convert_bytes_many(kind, jobs, ss, ds, true); } - let Some(bytes) = staging.chunks(dst_len.max(1)).nth(i) else { continue }; - out.tile_mut(c).bytes_mut().copy_from_slice(bytes); - if let Some(slot) = converted.get_mut(i) { - *slot = out.tile(c).cloned(); + for bytes in staging.chunks(dst_len.max(1)) { + let coord = photocraft_geom::TileCoord::new(0, 0); + let mut surface = Surface::new(to); + surface.tile_mut(coord).bytes_mut().copy_from_slice(bytes); + converted.push(surface.tile(coord).cloned()); + } + } + for (coord, index) in tiles { + if let Some(Some(tile)) = converted.get(index) { + out.put_tiles([(coord, tile.clone())]); } } out @@ -824,7 +829,7 @@ fn ink_surface(s: &Surface, d: &Duotone) -> Surface { let (mut px, mut o) = ([0.0f32; 2], [0.0f32; 4]); for i in 0..n { for (k, v) in px.iter_mut().enumerate().take(ss) { - *v = photocraft_color::read_sample(src, sf.sample, i * ss + k); + *v = photocraft_color::read_sample(&src, sf.sample, i * ss + k); } ink(&px[..ss], &mut o[..ds]); for (k, v) in o.iter().enumerate().take(ds) { @@ -835,7 +840,7 @@ fn ink_surface(s: &Surface, d: &Duotone) -> Surface { }) .collect(); for ((c, _), bytes) in tiles.iter().zip(inked) { - let tile = out.tile_mut(*c).bytes_mut(); + let mut tile = out.tile_mut(*c).bytes_mut(); if tile.len() == bytes.len() { tile.copy_from_slice(&bytes); } diff --git a/crates/engine/src/fill_cmds.rs b/crates/engine/src/fill_cmds.rs index 68e617bc8c..e14e62c8e9 100644 --- a/crates/engine/src/fill_cmds.rs +++ b/crates/engine/src/fill_cmds.rs @@ -410,7 +410,7 @@ fn blend_into(surf: &mut Surface, area: Rect, source: &Source, sel: Option<&Surf .try_clone() .map_err(|e| EngineError::Other(format!("not enough memory while blending a pixel tile ({e}); the document was not changed")))?, }; - blend_tile(tile.bytes_mut(), &fmt, tc.rect(), tr, source, mask.as_ref(), b); + blend_tile(&mut tile.bytes_mut(), &fmt, tc.rect(), tr, source, mask.as_ref(), b); done.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push((tc, std::sync::Arc::new(tile))); Ok(()) })?; @@ -421,7 +421,8 @@ fn blend_into(surf: &mut Surface, area: Rect, source: &Source, sel: Option<&Surf /// The selection's coverage over one tile (its channel 0). pub(crate) struct Mask<'a> { - bytes: &'a [u8], + bytes: Option>, + default: &'a [u8], fmt: photocraft_color::PixelFormat, origin: Rect, } @@ -429,13 +430,14 @@ pub(crate) struct Mask<'a> { impl<'a> Mask<'a> { /// `sel` over the tile at `tc` (its default pixel where it has no tile there). pub(crate) fn of(sel: &'a Surface, tc: photocraft_geom::TileCoord) -> Self { - Mask { bytes: sel.tile(tc).map_or(sel.default_bytes(), |t| t.bytes()), fmt: sel.format(), origin: tc.rect() } + Mask { bytes: sel.tile(tc).map(|t| t.bytes()), default: sel.default_bytes(), fmt: sel.format(), origin: tc.rect() } } pub(crate) fn at(&self, x: i32, y: i32) -> f32 { + let bytes = self.bytes.as_deref().unwrap_or(self.default); let bpp = self.fmt.bytes_per_pixel(); // A default-pixel mask is one pixel long: every position reads it. - let i = if self.bytes.len() == bpp { 0 } else { ((y - self.origin.y0) as usize * TILE_SIZE as usize + (x - self.origin.x0) as usize) * bpp }; - self.bytes.get(i..i + bpp).map_or(0.0, |px| photocraft_color::read_sample(px, self.fmt.sample, 0)) + let i = if bytes.len() == bpp { 0 } else { ((y - self.origin.y0) as usize * TILE_SIZE as usize + (x - self.origin.x0) as usize) * bpp }; + bytes.get(i..i + bpp).map_or(0.0, |px| photocraft_color::read_sample(px, self.fmt.sample, 0)) } /// Fully selected over `r`? pub(crate) fn full(&self, r: Rect) -> bool { diff --git a/crates/engine/src/filters.rs b/crates/engine/src/filters.rs index a9b6564309..30366cb286 100644 --- a/crates/engine/src/filters.rs +++ b/crates/engine/src/filters.rs @@ -252,6 +252,8 @@ pub(crate) fn run_filter(s: &mut Session, id: &str, p: &Value) -> Result &label, move |doc, _, ctx| { let filter = |surf: &photocraft_raster::Surface, fp: &FilterParams, area, bounds, sel: Option<&photocraft_raster::Surface>, extent| { + let _working = photocraft_raster::memory::try_reserve_operation(algo::working_bytes(fp, area, bounds, surf.format().channels())) + .map_err(EngineError::Other)?; ctx.stage(0.0, 1.0, &msg, |ctl| algo::apply_in_with(surf, fp, area, bounds, sel, extent, ctl)).ok_or(EngineError::Cancelled) }; let selection = doc.selection.clone(); @@ -520,6 +522,33 @@ mod tests { "filter.other.offset", ]; + #[test] + fn enormous_global_filter_is_refused_before_allocating_and_preserves_document() { + let mut s = Session::new(); + // A sparse document exercises admission without allocating a giant background first. + s.add_document( + photocraft_doc::Document::new( + "large", + photocraft_doc::Size::new(300000, 300000), + photocraft_color::ColorMode::Rgb, + photocraft_color::SampleType::U8, + ), + None, + ); + s.execute("layer.new.layer", json!({})).unwrap(); + s.edit("one pixel", |doc, active| { + doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().write_pixel(0, 0, &[0.2, 0.4, 0.6, 1.0]); + Ok(()) + }) + .unwrap(); + let before = s.active().unwrap().doc.clone(); + let revision = s.active().unwrap().revision; + let err = s.execute("filter.distort.twirl", json!({})).unwrap_err().to_string(); + assert!(err.contains("working memory"), "{err}"); + assert_eq!(s.active().unwrap().revision, revision); + assert_eq!(s.active().unwrap().doc, before); + } + #[test] fn every_filter_command_runs_changes_pixels_and_undoes() { for id in ALL { diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index dc43f4d794..1fc75ead29 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -284,6 +284,14 @@ pub fn run( work: impl FnOnce(&JobCtx) -> Result + Send + 'static, apply: impl FnOnce(&mut Session, T) -> Result + Send + 'static, ) -> Result { + let work = move |ctx: &JobCtx| -> Result { + photocraft_compose::discard_unreadable_pixel_caches(); + photocraft_raster::spill::check_integrity().map_err(EngineError::Other)?; + let generation = photocraft_raster::spill::read_error_generation(); + let value = work(ctx)?; + photocraft_raster::spill::check_since(generation).map_err(EngineError::Other)?; + Ok(value) + }; if !s.jobs.spawn || cfg!(target_arch = "wasm32") { let t = work(&s.jobs.inline.clone().unwrap_or_default())?; return apply(s, t); diff --git a/crates/engine/src/lens_cmds.rs b/crates/engine/src/lens_cmds.rs index 379ca2402a..76881428e9 100644 --- a/crates/engine/src/lens_cmds.rs +++ b/crates/engine/src/lens_cmds.rs @@ -278,6 +278,8 @@ fn raw_enabled(s: &Session) -> std::result::Result<(), String> { /// Runs `f` on the target layer (pixel layer edited in place inside the selection; smart object /// gets a smart filter with `stored` params). fn run_filter(s: &mut Session, cmd: &str, label: &str, stored: Value, float_bg: bool, f: &dyn Fn(&Surface, Rect) -> Surface) -> Result { + let state = s.active().ok_or(EngineError::NoDocument)?; + let _working = photocraft_raster::memory::try_reserve_operation(state.doc.size.area().saturating_mul(64)).map_err(EngineError::Other)?; let id = target(s, &stored)?; let mut stored = stored; if let Value::Object(m) = &mut stored { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index ff3cde6e4c..5b86a64040 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -98,6 +98,7 @@ pub mod render_cmds; pub mod retouch_cmds; pub mod sample_cmds; pub mod sampler_cmds; +mod scratch; pub mod select_extra_cmds; pub mod selection_cmds; pub mod slice_cmds; @@ -464,6 +465,8 @@ pub struct Session { /// document, source read-only)`. The shell takes them with [`Session::take_held_open`]. held_opens: Vec<(u64, Document, bool)>, next_held_open: u64, + /// Shared native memory supervision, owned and stopped by the last session using it. + scratch_runtime: Option, } /// Move item `i` of `v` to position `to`, clamped to the end. Returns where it went; `None` when @@ -690,6 +693,9 @@ impl Session { /// Apply an undoable edit to the active document. pub fn edit(&mut self, label: &str, f: impl FnOnce(&mut Document, &mut Option) -> Result) -> Result { + photocraft_compose::discard_unreadable_pixel_caches(); + photocraft_raster::spill::check_integrity().map_err(EngineError::Other)?; + let scratch_generation = photocraft_raster::spill::read_error_generation(); let restrict = self.color_restrict; let sampler_map = self.sampler_map.take(); let st = self.active_mut().ok_or(EngineError::NoDocument)?; @@ -697,12 +703,17 @@ impl Session { // Selecting layers is not a step, so the state this edit leaves behind targets what was // selected just before it (#1356): undoing a stroke keeps the painted layer active. let prior = st.layer_target(); + let previous_active_layer = st.active_layer; let mut doc = (*before).clone(); let mut active = st.active_layer; let r = f(&mut doc, &mut active)?; if let (Some(k), Some(id)) = (restrict, active) { channel_cmds::restrict_to_color(&before, &mut doc, id, k); } + if let Err(error) = photocraft_raster::spill::check_since(scratch_generation) { + st.active_layer = previous_active_layer; + return Err(EngineError::Other(error)); + } st.doc = Arc::new(doc); st.active_layer = active; fix_selection(st); diff --git a/crates/engine/src/pixels.rs b/crates/engine/src/pixels.rs index cde7fa41db..f5ee5ec3c8 100644 --- a/crates/engine/src/pixels.rs +++ b/crates/engine/src/pixels.rs @@ -278,6 +278,7 @@ fn edit_tiles( None => {} } let Some(src) = current.map(|t| &**t).or(blank.as_ref()) else { return Ok(None) }; + let source = src.try_bytes().map_err(|e| crate::EngineError::Other(format!("could not read pixels while {purpose}: {e}")))?; let mut out: Option = None; let (mut px, mut enc) = ([0.0f32; 8], [0u8; 32]); let (Some(px), Some(enc)) = (px.get_mut(..n), enc.get_mut(..bpp)) else { return Ok(None) }; @@ -285,7 +286,7 @@ fn edit_tiles( let row = (y - tc.ty * TILE_SIZE) as usize * TILE_SIZE as usize; for x in tr.x0..tr.x1 { let i = (row + (x - tc.tx * TILE_SIZE) as usize) * bpp; - let Some(old) = src.bytes().get(i..i + bpp) else { continue }; + let Some(old) = source.get(i..i + bpp) else { continue }; for (c, v) in px.iter_mut().enumerate() { *v = photocraft_color::read_sample(old, fmt.sample, c); } @@ -297,10 +298,15 @@ fn edit_tiles( continue; } if out.is_none() { - out = Some(src.try_clone().map_err(oom)?); + let mut tile = surf.try_default_tile().map_err(oom)?; + tile.bytes_mut().copy_from_slice(&source); + out = Some(tile); } - if let Some(dst) = out.as_mut().and_then(|t| t.bytes_mut().get_mut(i..i + bpp)) { - dst.copy_from_slice(enc); + if let Some(tile) = out.as_mut() { + let mut bytes = tile.bytes_mut(); + if let Some(dst) = bytes.get_mut(i..i + bpp) { + dst.copy_from_slice(enc); + } } } } diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 5e728cb546..3bf19692dc 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -582,6 +582,11 @@ pub struct Performance { /// Memory PhotoCraft may use, in MB: bounds each document's pixels plus its History (the /// oldest states are dropped beyond it). pub memory_usage_mb: u32, + /// Percent of physical RAM for the complete process; None preserves the legacy MB limit. + #[serde(default)] + pub memory_usage_percent: Option, + /// Independent graphics budget, including canvas pages and compositor working sets. + pub gpu_memory_mb: u32, /// Undo steps kept per document (History panel states). pub history_states: u32, /// Display cache levels (mip levels kept for zoomed-out views). @@ -612,16 +617,34 @@ impl Performance { } /// Pixel memory a document and its History may hold (Memory Usage), in bytes: beyond it - /// the oldest history states are dropped. + /// the oldest history states are dropped. 0 (no byte limit, only History States) while the + /// scratch disk is on: tiles only History holds go to disk instead. pub fn history_budget_bytes(&self) -> usize { + if photocraft_raster::spill::enabled() { + return 0; + } (self.memory_usage_mb as usize).saturating_mul(1 << 20) } + + /// Pixel bytes kept in memory before tiles move to the scratch disk: half of Memory Usage + /// (the rest is for the GPU canvas, caches and the app). `PHOTOCRAFT_TILE_BUDGET_MB` + /// overrides it (tests, stress runs). + pub fn tile_budget_bytes(&self) -> usize { + let bytes = std::env::var("PHOTOCRAFT_TILE_BUDGET_MB") + .ok() + .and_then(|v| v.trim().parse::().ok()) + .map(|mb| mb.saturating_mul(1 << 20)) + .unwrap_or_else(|| u64::from(self.memory_usage_mb).saturating_mul(1 << 20) / 2); + usize::try_from(bytes).unwrap_or(usize::MAX) + } } impl Default for Performance { fn default() -> Self { Self { memory_usage_mb: 8192, + memory_usage_percent: Some(70), + gpu_memory_mb: 1024, history_states: 50, cache_levels: 4, cache_tile_size: 8192, @@ -1061,7 +1084,6 @@ pub const HIDDEN_UNTIL_IMPLEMENTED: &[&str] = &[ "performance.cacheLevels", "performance.effectCacheMb", "performance.legacyCompositing", - "scratchDisks.disks", "unitsAndRulers.columnWidth", "unitsAndRulers.gutter", "plugIns.showExtensionPanels", @@ -1141,6 +1163,8 @@ pub fn range(path: &str) -> Option<(f64, f64)> { "interface.notificationDurationSeconds" => (1.0, 120.0), "export.jpegQuality" | "export.webpQuality" => (1.0, 100.0), "performance.memoryUsageMb" => (256.0, 1_048_576.0), + "performance.memoryUsagePercent" => (1.0, 95.0), + "performance.gpuMemoryMb" => (64.0, 65536.0), "performance.historyStates" => (1.0, 1000.0), "performance.cacheLevels" => (1.0, 8.0), "performance.cacheTileSize" => (256.0, 16384.0), @@ -1248,7 +1272,7 @@ fn check_value(path: &str, v: &Value) -> std::result::Result<(), String> { seen.push(theme); } } - if path == "performance.renderingMode" && v.is_null() { + if matches!(path, "performance.renderingMode" | "performance.memoryUsagePercent") && v.is_null() { return Ok(()); // Legacy policy, resolved from useGpu and gpuBackend. } if let Some(c) = choices(path) { @@ -1504,6 +1528,8 @@ impl Session { /// Push engine-relevant preferences into live state: every document's history limit, the /// layer-effect cache budget and the text engine's missing-glyph protection. pub fn apply_prefs(&mut self) { + self.scratch_runtime.get_or_insert_with(crate::scratch::Runtime::default); + crate::scratch::configure(self.prefs()); let n = self.prefs().performance.history_states.max(1) as usize; let bytes = self.prefs().performance.history_budget_bytes(); let budget = self.prefs().performance.effect_cache_mb as usize; diff --git a/crates/engine/src/scratch.rs b/crates/engine/src/scratch.rs new file mode 100644 index 0000000000..79d67ab0db --- /dev/null +++ b/crates/engine/src/scratch.rs @@ -0,0 +1,142 @@ +//! Native tile storage configuration, kept out of the preference data model. + +use crate::prefs::Preferences; + +/// A web session has no filesystem or memory-supervision thread. +#[cfg(target_arch = "wasm32")] +#[derive(Default)] +pub(crate) struct Runtime; + +#[cfg(not(target_arch = "wasm32"))] +pub(crate) use native::Runtime; + +#[cfg(not(target_arch = "wasm32"))] +mod native { + use std::sync::{Arc, Condvar, Mutex, OnceLock, PoisonError, Weak}; + use std::thread::JoinHandle; + use std::time::{Duration, Instant}; + + // Weak ownership: the process-wide cache cannot keep a session's worker alive. + pub(crate) struct Runtime { + _worker: Arc, + } + + impl Default for Runtime { + fn default() -> Self { + static SHARED: OnceLock>> = OnceLock::new(); + let mut shared = SHARED.get_or_init(Mutex::default).lock().unwrap_or_else(PoisonError::into_inner); + let worker = shared.upgrade().unwrap_or_else(|| { + let worker = Arc::new(Worker::start()); + *shared = Arc::downgrade(&worker); + worker + }); + Self { _worker: worker } + } + } + + struct Worker { + stop: Arc<(Mutex, Condvar)>, + thread: Option>, + } + + impl Worker { + fn start() -> Self { + let stop = Arc::new((Mutex::new(false), Condvar::new())); + let flag = stop.clone(); + let thread = std::thread::Builder::new() + .name("pc-memory-residency".into()) + .spawn(move || { + let _maintenance = photocraft_raster::spill::background_maintenance(); + let mut measured = Instant::now(); + loop { + let (lock, wake) = &*flag; + let stopped = lock.lock().unwrap_or_else(PoisonError::into_inner); + let (stopped, _) = wake.wait_timeout_while(stopped, Duration::from_millis(100), |stop| !*stop).unwrap_or_else(PoisonError::into_inner); + if *stopped { + break; + } + drop(stopped); + if measured.elapsed() >= Duration::from_secs(2) { + photocraft_raster::memory::refresh(); + measured = Instant::now(); + } + photocraft_raster::spill::maintain_budget(); + } + }) + .ok(); + // If spawning fails, raster's synchronous fallback still enforces the budget. + Self { stop, thread } + } + } + + impl Drop for Worker { + fn drop(&mut self) { + let (lock, wake) = &*self.stop; + *lock.lock().unwrap_or_else(PoisonError::into_inner) = true; + wake.notify_all(); + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + } + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn workers_stop_join_and_can_restart() { + for _ in 0..3 { + let worker = Worker::start(); + assert!(worker.thread.is_some()); + let stop = worker.stop.clone(); + let began = Instant::now(); + drop(worker); + assert!(*stop.0.lock().unwrap()); + assert!(began.elapsed() < Duration::from_secs(2)); + } + } + + #[test] + fn sessions_share_supervision_without_static_ownership() { + let a = Runtime::default(); + let b = Runtime::default(); + assert!(Arc::ptr_eq(&a._worker, &b._worker)); + let weak = Arc::downgrade(&a._worker); + drop((a, b)); + // Other engine tests can hold the shared runtime concurrently; a private worker + // proves the same ownership contract without depending on their timing. + let worker = Arc::new(Worker::start()); + let private = Arc::downgrade(&worker); + drop(worker); + assert!(private.upgrade().is_none()); + drop(weak); + } + } +} + +pub(crate) fn configure(prefs: &Preferences) { + let budget = photocraft_raster::memory::configure(photocraft_raster::memory::Policy { + percent: prefs.performance.memory_usage_percent, + absolute_bytes: u64::from(prefs.performance.memory_usage_mb).saturating_mul(1 << 20), + }); + #[cfg(not(target_arch = "wasm32"))] + { + let dir = prefs + .scratch_disks + .disks + .iter() + .find(|d| d.enabled && !d.path.trim().is_empty()) + .map(|d| if d.path.trim() == "(system temp)" { std::env::temp_dir() } else { std::path::PathBuf::from(d.path.trim()) }); + // An unusable directory disables eviction without dropping resident pixels. + // The reason is exposed in ui.inspect and the application's status bar. + let tiles = if std::env::var_os("PHOTOCRAFT_TILE_BUDGET_MB").is_some() { + prefs.performance.tile_budget_bytes() + } else { + usize::try_from(budget.tile_bytes).unwrap_or(usize::MAX) + }; + let _ = photocraft_raster::spill::configure(dir.as_deref(), tiles); + } + #[cfg(target_arch = "wasm32")] + let _ = (prefs, budget); +} diff --git a/crates/engine/tests/memory_prefs.rs b/crates/engine/tests/memory_prefs.rs new file mode 100644 index 0000000000..4ef6dc1728 --- /dev/null +++ b/crates/engine/tests/memory_prefs.rs @@ -0,0 +1,24 @@ +use photocraft_engine::prefs::{Performance, Preferences}; +use serde_json::json; + +#[test] +fn old_preferences_keep_their_absolute_limit() { + let p: Performance = serde_json::from_value(json!({"memoryUsageMb": 2048})).unwrap(); + assert_eq!(p.memory_usage_percent, None); + assert_eq!(p.memory_usage_mb, 2048); + assert_eq!(Performance::default().memory_usage_percent, Some(70)); +} + +#[test] +fn memory_preferences_validate_and_can_restore_absolute_mode() { + let mut p = Preferences::default(); + assert!(p.set("performance.memoryUsagePercent", json!(96)).is_err()); + assert!(p.set("performance.memoryUsagePercent", json!(0)).is_err()); + assert!(p.set("performance.gpuMemoryMb", json!(1)).is_err()); + p.set("performance.memoryUsagePercent", json!(75)).unwrap(); + assert_eq!(p.performance.memory_usage_percent, Some(75)); + p.set("performance.memoryUsagePercent", json!(null)).unwrap(); + assert_eq!(p.performance.memory_usage_percent, None); + p.set("performance.gpuMemoryMb", json!(512)).unwrap(); + assert_eq!(p.performance.gpu_memory_mb, 512); +} diff --git a/crates/engine/tests/scratch_disk.rs b/crates/engine/tests/scratch_disk.rs new file mode 100644 index 0000000000..5f9a392380 --- /dev/null +++ b/crates/engine/tests/scratch_disk.rs @@ -0,0 +1,90 @@ +//! Disk-backed current pixels and history: lossless undo, and failed reads never commit. +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_doc::{Document, Layer}; +use photocraft_engine::Session; +use photocraft_geom::{Rect, Size, TileCoord}; +use photocraft_raster::spill; +use std::io::{Read, Seek, SeekFrom, Write}; +use std::sync::Arc; + +#[test] +fn scratch_pixels_history_and_failed_save_are_transactional() { + let dir = std::env::temp_dir().join(format!("photocraft-engine-scratch-{}", std::process::id())); + let mut session = Session::new(); + session.edit_prefs(|p| { + p.scratch_disks.disks = vec![photocraft_engine::prefs::ScratchDisk { path: dir.to_string_lossy().into(), enabled: true }]; + p.performance.memory_usage_mb = 1; + }); + assert!(spill::enabled()); + assert_eq!(session.prefs().performance.history_budget_bytes(), 0); + let mut doc = Document::new("Scratch fixture", Size::new(2048, 256), ColorMode::Rgb, SampleType::U8); + let mut layer = Layer::raster("Pixels", PixelFormat::RGBA8); + let id = layer.id; + for i in 0..8 { + let mut bytes = layer.surface_mut().unwrap().tile_mut(TileCoord::new(i, 0)).bytes_mut(); + for (j, b) in bytes.iter_mut().enumerate() { + *b = (j as u32).wrapping_mul(17).wrapping_add(i as u32 * 43) as u8; + } + } + doc.layers.push(layer); + session.add_document(doc, None); + session.select_layer(id).unwrap(); + let before = session.active().unwrap().doc.clone(); + session + .edit("Paint", |d, _| { + d.layer_mut(id).unwrap().surface_mut().unwrap().fill_rect(Rect::from_xywh(0, 0, 4, 4), &[1.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + spill::settle(); + assert!(session.undo()); + assert_eq!(*session.active().unwrap().doc, *before); + assert!(session.redo()); + assert_eq!(session.active().unwrap().doc.layer(id).unwrap().surface().unwrap().pixel(1, 1), vec![1.0, 0.0, 0.0, 1.0]); + spill::settle(); + + // Force all tiles to disk, including the tile the failed edit will touch. + spill::configure(Some(&dir), 1).unwrap(); + spill::settle(); + assert_eq!(spill::stats().resident_bytes, 0); + // Damage only this test's scratch file; retain its exact contents to restore the backing. + let path = std::fs::read_dir(&dir).unwrap().filter_map(Result::ok).map(|e| e.path()).find(|p| p.extension().is_some_and(|e| e == "scratch")).unwrap(); + let mut file = std::fs::OpenOptions::new().read(true).write(true).open(path).unwrap(); + let mut good = Vec::new(); + file.read_to_end(&mut good).unwrap(); + file.seek(SeekFrom::Start(0)).unwrap(); + file.write_all(&vec![0; good.len()]).unwrap(); + file.sync_all().unwrap(); + let retained = session.active().unwrap().doc.clone(); + let revision = session.active().unwrap().revision; + let failed = session.edit("Failed paint", |d, active| { + let discarded = Layer::raster("Discarded", PixelFormat::RGBA8); + *active = Some(discarded.id); + d.layers.push(discarded); + d.layer_mut(id).unwrap().surface_mut().unwrap().fill_rect(Rect::from_xywh(1800, 0, 4, 4), &[0.0, 1.0, 0.0, 1.0]); + Ok(()) + }); + assert!(failed.is_err()); + assert!(Arc::ptr_eq(&session.active().unwrap().doc, &retained)); + assert_eq!(session.active().unwrap().revision, revision); + assert_eq!(session.active().unwrap().active_layer, Some(id)); + let mut writer = photocraft_format::PcraftWriter::new(); + assert!(writer.save_zip(&retained, &Default::default()).is_err()); + assert!(photocraft_io::export(&retained, "png", &Default::default()).is_err()); + + file.seek(SeekFrom::Start(0)).unwrap(); + file.write_all(&good).unwrap(); + file.sync_all().unwrap(); + for d in [&before, &retained] { + for (_, tile) in d.layer(id).unwrap().surface().unwrap().tiles() { + tile.try_bytes().unwrap(); + } + } + assert!(spill::check_integrity().is_ok()); + let (bytes, _) = writer.save_zip(&retained, &Default::default()).unwrap(); + let loaded = photocraft_format::load_from_bytes(&bytes).unwrap(); + assert_eq!(loaded.layers, retained.layers); + drop((session, before, retained, loaded, writer, file)); + spill::configure(None, 0).unwrap(); + let _ = std::fs::remove_dir_all(dir); +} diff --git a/crates/format/src/store.rs b/crates/format/src/store.rs index d047668cb0..19ba8a1eb8 100644 --- a/crates/format/src/store.rs +++ b/crates/format/src/store.rs @@ -333,13 +333,32 @@ impl Sink for Collect<'_> { } } -fn tile_le(t: &Tile, sample: SampleType) -> std::borrow::Cow<'_, [u8]> { +/// A tile's bytes in little-endian order: the tile itself (kept in memory while this lives), or +/// a byte-swapped copy on big-endian machines. +enum LeBytes<'a> { + Native(photocraft_raster::TileBytes<'a>), + Swapped(Vec), + Borrowed(&'a [u8]), +} + +impl std::ops::Deref for LeBytes<'_> { + type Target = [u8]; + fn deref(&self) -> &[u8] { + match self { + LeBytes::Native(b) => b, + LeBytes::Swapped(b) => b, + LeBytes::Borrowed(b) => b, + } + } +} + +fn tile_le(t: &Tile, sample: SampleType) -> LeBytes<'_> { if cfg!(target_endian = "big") { let mut b = t.bytes().to_vec(); swap_to_le(&mut b, sample); - std::borrow::Cow::Owned(b) + LeBytes::Swapped(b) } else { - std::borrow::Cow::Borrowed(t.bytes()) + LeBytes::Native(t.bytes()) } } @@ -417,10 +436,10 @@ impl Object { } /// The bytes this object stores (tiles little-endian). - fn content(&self) -> std::borrow::Cow<'_, [u8]> { + fn content(&self) -> LeBytes<'_> { match self { Object::Tile(tile, sample) => tile_le(tile, *sample), - Object::Blob(blob) => std::borrow::Cow::Borrowed(blob.as_slice()), + Object::Blob(blob) => LeBytes::Borrowed(blob.as_slice()), } } } @@ -726,6 +745,19 @@ impl PcraftWriter { } fn prepare(&mut self, doc: &Document, opts: &SaveOptions) -> Result { + photocraft_raster::spill::check_integrity().map_err(FormatError::corrupt)?; + let generation = photocraft_raster::spill::read_error_generation(); + let prepared = self.prepare_inner(doc, opts); + if let Err(error) = photocraft_raster::spill::check_since(generation) { + // Failed reads may have hashed display placeholders. Never reuse those hashes + // when the original backing becomes readable and the same writer retries. + self.hash_cache.clear(); + return Err(FormatError::corrupt(error)); + } + prepared + } + + fn prepare_inner(&mut self, doc: &Document, opts: &SaveOptions) -> Result { self.hash_cache.retain(|_, (w, _)| w.strong_count() > 0); convert::check_nesting(&doc.layers)?; let mut c = Collect { hash_cache: Some(&mut self.hash_cache), ..Default::default() }; @@ -760,6 +792,7 @@ impl PcraftWriter { for (h, b) in c.blobs { objects.insert(blob_path(&h), Object::Blob(b)); } + photocraft_raster::spill::check_integrity().map_err(FormatError::corrupt)?; Ok(Prepared { manifest, objects, previews, stats }) } @@ -795,6 +828,7 @@ impl PcraftWriter { z.add(path, &data)?; next.insert(path.clone(), data); } + photocraft_raster::spill::check_integrity().map_err(FormatError::corrupt)?; self.compressed = next; Ok((z.finish()?, stats)) } @@ -858,7 +892,9 @@ impl PcraftWriter { Object::Tile(..) => stats.tiles_written += 1, Object::Blob(_) => stats.blobs_written += 1, } - write_bundle_file(dir, &object_path, &obj.compressed())?; + let compressed = obj.compressed(); + photocraft_raster::spill::check_integrity().map_err(FormatError::corrupt)?; + write_bundle_file(dir, &object_path, &compressed)?; let written = path_signature(&object_path)?; if let Some(cache) = &mut self.verified_directory { match written { @@ -887,6 +923,7 @@ impl PcraftWriter { } } } + photocraft_raster::spill::check_integrity().map_err(FormatError::corrupt)?; write_bundle_file(dir, &dir.join(MANIFEST), &p.manifest)?; // The manifest is committed: the save has succeeded. Removing objects it no longer // references is best-effort housekeeping (a vanished or locked file must not turn a diff --git a/crates/format/tests/scratch_retry.rs b/crates/format/tests/scratch_retry.rs new file mode 100644 index 0000000000..1bb06110ec --- /dev/null +++ b/crates/format/tests/scratch_retry.rs @@ -0,0 +1,48 @@ +#![cfg(not(target_arch = "wasm32"))] + +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_doc::{Document, Layer}; +use photocraft_geom::{Size, TileCoord}; +use photocraft_raster::spill; +use std::io::{Read, Seek, SeekFrom, Write}; + +#[test] +fn retry_after_failed_hashing_never_reuses_placeholder_hashes() { + let dir = std::env::temp_dir().join(format!("photocraft-format-scratch-retry-{}", std::process::id())); + spill::configure(Some(&dir), 1).unwrap(); + let mut doc = Document::new("Retry", Size::new(512, 256), ColorMode::Rgb, SampleType::U8); + let mut layer = Layer::raster("Pixels", PixelFormat::RGBA8); + for i in 0..2 { + let mut bytes = layer.surface_mut().unwrap().tile_mut(TileCoord::new(i, 0)).bytes_mut(); + for (j, byte) in bytes.iter_mut().enumerate() { + *byte = (j as u8).wrapping_mul(17).wrapping_add((i * 43) as u8); + } + } + doc.layers.push(layer); + spill::settle(); + assert_eq!(spill::stats().resident_bytes, 0); + let path = std::fs::read_dir(&dir).unwrap().filter_map(Result::ok).map(|e| e.path()).find(|p| p.extension().is_some_and(|e| e == "scratch")).unwrap(); + let mut file = std::fs::OpenOptions::new().read(true).write(true).open(path).unwrap(); + let mut good = Vec::new(); + file.read_to_end(&mut good).unwrap(); + file.seek(SeekFrom::Start(0)).unwrap(); + file.write_all(&vec![0; good.len()]).unwrap(); + file.sync_all().unwrap(); + + let mut writer = photocraft_format::PcraftWriter::new(); + // This save discovers the failure itself, after entering the hash collection stage. + assert!(writer.save_zip(&doc, &Default::default()).is_err()); + file.seek(SeekFrom::Start(0)).unwrap(); + file.write_all(&good).unwrap(); + file.sync_all().unwrap(); + for (_, tile) in doc.layers[0].surface().unwrap().tiles() { + tile.try_bytes().unwrap(); + } + assert!(spill::check_integrity().is_ok()); + let (saved, _) = writer.save_zip(&doc, &Default::default()).unwrap(); + let loaded = photocraft_format::load_from_bytes(&saved).unwrap(); + assert_eq!(loaded.layers, doc.layers); + drop((doc, loaded, writer, file)); + spill::configure(None, 0).unwrap(); + let _ = std::fs::remove_dir_all(dir); +} diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 223d4ed983..cedaf77cd8 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -2093,7 +2093,7 @@ fn convert_tile(surface: &Surface, tile: Option<&Arc>, kind: TexKind, c: T let fmt = surface.format(); let ch = fmt.channels(); if let (Some(t), TexKind::Rgba16F) = (tile, kind) - && let Some(out) = rgb_gray16_to_f16(&fmt, t.bytes()).or_else(|| rgb_gray32_to_f16(&fmt, t.bytes())) + && let Some(out) = rgb_gray16_to_f16(&fmt, &t.bytes()).or_else(|| rgb_gray32_to_f16(&fmt, &t.bytes())) { return out; } @@ -2231,7 +2231,7 @@ fn words(v: &[W]) -> Vec { .collect() } -/// IEEE half from f32 (round to nearest even; overflow → inf). +/// IEEE half from f32 (round to nearest even; overflow → inf). pub fn f32_to_f16(x: f32) -> u16 { let b = x.to_bits(); let sign = ((b >> 16) & 0x8000) as u16; @@ -2519,7 +2519,7 @@ mod tests { let data: Vec = (0..n).map(|i| (i as u32).wrapping_mul(2_654_435_761) as f32 / u32::MAX as f32 * hdr - (hdr - 1.0) / 4.0).collect(); s.write_region(r, &data); let t = s.tile(TileCoord::new(0, 0)).expect("written").clone(); - let fast = rgb_gray16_to_f16(&fmt, t.bytes()).or_else(|| rgb_gray32_to_f16(&fmt, t.bytes())).expect("fast path"); + let fast = rgb_gray16_to_f16(&fmt, &t.bytes()).or_else(|| rgb_gray32_to_f16(&fmt, &t.bytes())).expect("fast path"); let raw = s.read_region(r); let general: Vec = raw.chunks_exact(fmt.channels()).flat_map(|px| photocraft_raster::to_rgba(&fmt, px).map(|x| f32_to_f16(x).to_le_bytes())).flatten().collect(); diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 49e4bb60e2..936bab32b9 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -260,10 +260,14 @@ pub fn import_with_svg_group_depth_and_interrupt( max_group_depth: usize, ctl: &photocraft_raster::Interrupt, ) -> Result { + photocraft_compose::discard_unreadable_pixel_caches(); + photocraft_raster::spill::check_integrity().map_err(IoError::Unsupported)?; + let scratch_generation = photocraft_raster::spill::read_error_generation(); ctl.check().map_err(|_| IoError::Cancelled)?; let r = import_stages(name, bytes, ctl, max_group_depth)?; ctl.check().map_err(|_| IoError::Cancelled)?; ctl.progress(1.0); + photocraft_raster::spill::check_since(scratch_generation).map_err(IoError::Unsupported)?; Ok(r) } @@ -355,7 +359,11 @@ pub fn export(doc: &Document, name_or_ext: &str, opts: &ExportOptions) -> Result /// export fails with [`IoError::Cancelled`]; otherwise the bytes are exactly [`export`]'s. pub fn export_with(doc: &Document, name_or_ext: &str, opts: &ExportOptions, ctl: &photocraft_raster::Interrupt) -> Result { ctl.check().map_err(|_| IoError::Cancelled)?; + photocraft_compose::discard_unreadable_pixel_caches(); + photocraft_raster::spill::check_integrity().map_err(IoError::Unsupported)?; + let scratch_generation = photocraft_raster::spill::read_error_generation(); let r = export_stages(doc, name_or_ext, opts, ctl)?; + photocraft_raster::spill::check_since(scratch_generation).map_err(IoError::Unsupported)?; ctl.check().map_err(|_| IoError::Cancelled)?; ctl.progress(1.0); Ok(r) diff --git a/crates/ops/src/lib.rs b/crates/ops/src/lib.rs index a20b841c17..f9671fbb48 100644 --- a/crates/ops/src/lib.rs +++ b/crates/ops/src/lib.rs @@ -254,7 +254,7 @@ fn tile_bytes(doc: &Document, seen: &mut HashSet) -> usize { tiles::surfaces(doc, |surface| { for (_, tile) in surface.tiles() { if seen.insert(Arc::as_ptr(tile) as usize) { - n = n.saturating_add(tile.bytes().len()); + n = n.saturating_add(tile.len()); } } }); diff --git a/crates/plugins/src/apply.rs b/crates/plugins/src/apply.rs index 7a144a28b9..250faf9ef5 100644 --- a/crates/plugins/src/apply.rs +++ b/crates/plugins/src/apply.rs @@ -145,7 +145,7 @@ impl Plugin { let results: Vec> = chunk.iter().map(run).collect(); for r in results { for (c, t) in r?.tiles() { - out.tile_mut(*c).bytes_mut().copy_from_slice(t.bytes()); + out.tile_mut(*c).bytes_mut().copy_from_slice(&t.bytes()); } } } @@ -215,7 +215,7 @@ fn encode_band(surface: &Surface, band: Rect, data: &[f32]) -> Surface { let mut s = Surface::with_default(surface.format(), &surface.default_pixel()); for c in band.tiles() { if let Some(t) = surface.tile(c) { - s.tile_mut(c).bytes_mut().copy_from_slice(t.bytes()); + s.tile_mut(c).bytes_mut().copy_from_slice(&t.bytes()); } else { s.tile_mut(c); } diff --git a/crates/raster/Cargo.toml b/crates/raster/Cargo.toml index 956d1c9545..5fe5b0dd5b 100644 --- a/crates/raster/Cargo.toml +++ b/crates/raster/Cargo.toml @@ -11,6 +11,11 @@ workspace = true photocraft-geom = { workspace = true } photocraft-color = { workspace = true } serde = { workspace = true } +crc32fast = "1" +lz4_flex = { version = "0.11", default-features = false, features = ["std", "safe-encode", "safe-decode"] } + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +sysinfo = { version = "0.37", default-features = false, features = ["system"] } [dev-dependencies] proptest = { workspace = true } diff --git a/crates/raster/examples/scratch_bench.rs b/crates/raster/examples/scratch_bench.rs new file mode 100644 index 0000000000..c7d04a56c4 --- /dev/null +++ b/crates/raster/examples/scratch_bench.rs @@ -0,0 +1,52 @@ +//! 24 MP release benchmark: identical pixel access with RAM-only and disk-backed tiles. +use photocraft_color::PixelFormat; +use photocraft_geom::{Rect, TILE_SIZE}; +use photocraft_raster::{Surface, spill}; +use std::time::Instant; + +fn run(dir: Option<&std::path::Path>) -> Result<(), String> { + spill::configure(dir, 16 << 20)?; + let start = Instant::now(); + let area = Rect::from_xywh(0, 0, 6000, 4000); + let mut surface = Surface::new(PixelFormat::RGBA8); + let mut random = 0x1234abcd_u32; + for tc in area.tiles() { + let mut bytes = surface.tile_mut(tc).bytes_mut(); + for byte in bytes.iter_mut() { + random ^= random << 13; + random ^= random >> 17; + random ^= random << 5; + *byte = random as u8; + } + } + spill::settle(); + let build_ms = start.elapsed().as_secs_f64() * 1000.0; + let resident = spill::stats().resident_bytes; + let disk = spill::stats().scratch_bytes; + let read = Instant::now(); + let mut checksum = 0u64; + // Rows exercise the same locks and conversions the CPU renderer uses. + let mut row = vec![[0; 4]; 6000]; + for y in 0..4000 { + surface.read_rgba8_into(Rect::from_xywh(0, y, 6000, 1), &mut row); + for pixel in &row { + checksum = checksum.wrapping_add(u32::from_le_bytes(*pixel) as u64); + } + } + spill::check_integrity()?; + println!( + "mode={} build_ms={build_ms:.2} read_ms={:.2} resident_bytes={resident} scratch_bytes={disk} checksum={checksum} tile_side={TILE_SIZE}", + if dir.is_some() { "scratch" } else { "ram" }, + read.elapsed().as_secs_f64() * 1000.0 + ); + drop(surface); + spill::configure(None, 0)?; + Ok(()) +} + +fn main() -> Result<(), String> { + run(None)?; + let dir = std::env::temp_dir().join(format!("photocraft-24mp-{}", std::process::id())); + run(Some(&dir))?; + std::fs::remove_dir(&dir).map_err(|e| e.to_string()) +} diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs index ff45764980..2019cd7773 100644 --- a/crates/raster/src/lib.rs +++ b/crates/raster/src/lib.rs @@ -12,14 +12,16 @@ pub use interrupt::{Cancelled, Interrupt}; use std::collections::BTreeMap; use std::fmt; -use std::sync::Arc; +use std::ops::{Deref, DerefMut}; +use std::sync::{Arc, RwLockReadGuard, RwLockWriteGuard}; use photocraft_color::{ColorMode, PixelFormat, SampleType, read_sample, write_sample}; use photocraft_geom::{Rect, TILE_SIZE, TileCoord}; /// A checked surface allocation could not be represented or reserved. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq)] pub enum AllocationError { + ScratchRead(String), SizeOverflow, NotEnoughMemory { bytes: usize }, } @@ -27,6 +29,7 @@ pub enum AllocationError { impl fmt::Display for AllocationError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { + Self::ScratchRead(error) => write!(f, "scratch tile could not be read: {error}"), Self::SizeOverflow => f.write_str("requested surface allocation is too large"), Self::NotEnoughMemory { bytes } => write!(f, "not enough memory to allocate {} bytes", bytes), } @@ -34,11 +37,17 @@ impl fmt::Display for AllocationError { } impl std::error::Error for AllocationError {} +pub mod memory; +mod scratch_file; +pub mod spill; /// Pixel storage for one tile: `TILE_SIZE² × bytes_per_pixel`, row-major, interleaved channels. -#[derive(Clone, Debug, PartialEq, Eq)] +/// +/// The bytes may live on the scratch disk ([`spill`]); [`Tile::bytes`] and [`Tile::bytes_mut`] +/// bring them back and keep them in memory while the returned guard lives. Take one guard per +/// tile, not per pixel, in hot loops. pub struct Tile { - data: Box<[u8]>, + cell: Arc, } impl Tile { @@ -53,16 +62,17 @@ impl Tile { px.copy_from_slice(pixel); } } - Ok(Tile { data: data.into_boxed_slice() }) + Ok(Tile::from_vec(data)) } /// Clone this tile's pixel buffer with a fallible reservation. pub fn try_clone(&self) -> Result { - let bytes = self.data.len(); + let source = self.try_bytes().map_err(AllocationError::ScratchRead)?; + let bytes = source.len(); let mut data = Vec::new(); data.try_reserve_exact(bytes).map_err(|_| AllocationError::NotEnoughMemory { bytes })?; - data.extend_from_slice(&self.data); - Ok(Tile { data: data.into_boxed_slice() }) + data.extend_from_slice(&source); + Ok(Tile::from_vec(data)) } fn filled(format: &PixelFormat, pixel: &[u8]) -> Self { @@ -73,15 +83,30 @@ impl Tile { px.copy_from_slice(pixel); } } - Tile { data: data.into_boxed_slice() } + Tile::from_vec(data) } - pub fn bytes(&self) -> &[u8] { - &self.data + fn from_vec(data: Vec) -> Self { + Tile { cell: spill::Cell::new(data) } } - pub fn bytes_mut(&mut self) -> &mut [u8] { - &mut self.data + /// The tile's bytes (read back from the scratch disk if needed). + pub fn bytes(&self) -> TileBytes<'_> { + let guard = self.cell.read(); + let placeholder = if guard.is_none() { vec![0; self.len()] } else { Vec::new() }; + TileBytes(guard, placeholder) + } + pub fn bytes_mut(&mut self) -> TileBytesMut<'_> { + let guard = self.cell.write(); + let placeholder = if guard.is_none() { vec![0; self.len()] } else { Vec::new() }; + TileBytesMut(guard, placeholder) + } + /// Fallible pixel access for callers that can propagate a scratch read failure. + pub fn try_bytes(&self) -> Result, String> { + let bytes = self.bytes(); + match self.cell.read_error() { + Some(error) => Err(error), + None => Ok(bytes), + } } - /// Tile-local half-open bounds `(x0, y0, x1, y1)` of the pixels that differ from the encoded /// pixel `dp` (one pixel of the tile's format), or `None` when every pixel equals it (or `dp` /// is empty). @@ -92,7 +117,7 @@ impl Tile { } let ts = TILE_SIZE as usize; let (mut x0, mut y0, mut x1, mut y1) = (usize::MAX, usize::MAX, 0usize, 0usize); - for (y, row) in self.data.chunks_exact(ts * bpp).enumerate() { + for (y, row) in self.bytes().chunks_exact(ts * bpp).enumerate() { let Some(first) = row.chunks_exact(bpp).position(|px| px != dp) else { continue }; let last = ts - 1 - row.chunks_exact(bpp).rev().position(|px| px != dp).unwrap_or(0); x0 = x0.min(first); @@ -102,6 +127,65 @@ impl Tile { } (x0 != usize::MAX).then_some((x0 as u16, y0 as u16, x1 as u16, y1 as u16)) } + /// Byte length, without touching the pixels (they may be on disk). + pub fn len(&self) -> usize { + self.cell.len() + } + pub fn is_empty(&self) -> bool { + self.cell.len() == 0 + } +} + +impl Clone for Tile { + fn clone(&self) -> Self { + match self.try_bytes() { + Ok(bytes) => Tile::from_vec(bytes.to_vec()), + // Preserve the original backing; the transaction will reject this poisoned tile. + Err(_) => Tile { cell: self.cell.clone() }, + } + } +} + +impl PartialEq for Tile { + fn eq(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.cell, &other.cell) || (self.len() == other.len() && *self.bytes() == *other.bytes()) + } +} + +impl Eq for Tile {} + +impl std::fmt::Debug for Tile { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Tile").field("len", &self.len()).finish_non_exhaustive() + } +} + +/// Read guard over a tile's bytes; derefs to `[u8]`. +pub struct TileBytes<'a>(RwLockReadGuard<'a, Option>>, Vec); + +impl Deref for TileBytes<'_> { + type Target = [u8]; + fn deref(&self) -> &[u8] { + self.0.as_deref().unwrap_or(&self.1) + } +} + +/// Write guard over a tile's bytes; derefs to `[u8]`. +pub struct TileBytesMut<'a>(RwLockWriteGuard<'a, Option>>, Vec); + +impl Deref for TileBytesMut<'_> { + type Target = [u8]; + fn deref(&self) -> &[u8] { + self.0.as_deref().unwrap_or(&self.1) + } +} + +impl DerefMut for TileBytesMut<'_> { + fn deref_mut(&mut self) -> &mut [u8] { + self.0.as_deref_mut().unwrap_or(&mut self.1) + } + + } #[derive(Clone, Debug)] @@ -141,8 +225,9 @@ impl UniformTiles { if let Some(k) = self.of.get(&Arc::as_ptr(t)) { return *k; } - let mut px = t.data.chunks_exact(bpp); - let k = px.next().filter(|p0| px.all(|p| p == *p0)).map(|p0| match self.canonical.iter().position(|c| c.data.get(..bpp) == Some(p0)) { + let bytes = t.try_bytes().ok()?; + let mut px = bytes.chunks_exact(bpp); + let k = px.next().filter(|p0| px.all(|p| p == *p0)).map(|p0| match self.canonical.iter().position(|c| c.try_bytes().is_ok_and(|bytes| bytes.get(..bpp) == Some(p0))) { Some(k) => k, None => { self.canonical.push(t.clone()); @@ -169,7 +254,9 @@ impl Surface { let mut out = Vec::new(); out.try_reserve_exact(len).map_err(|_| AllocationError::NotEnoughMemory { bytes })?; out.resize(len, 0.0); + let generation = spill::read_error_generation(); self.read_region_into(r, &mut out); + spill::check_since(generation).map_err(AllocationError::ScratchRead)?; Ok(out) } @@ -189,7 +276,7 @@ impl Surface { match self.tiles.get(&tc) { Some(t) => { let src = (((y - tc.ty * TILE_SIZE) as usize) * TILE_SIZE as usize + (tr.x0 - tc.tx * TILE_SIZE) as usize) * bpp; - out[dst..dst + span].copy_from_slice(&t.data[src..src + span]); + out[dst..dst + span].copy_from_slice(&t.try_bytes().map_err(AllocationError::ScratchRead)?[src..src + span]); } None => { for px in out[dst..dst + span].chunks_exact_mut(bpp) { @@ -244,6 +331,9 @@ impl Surface { /// Fallible counterpart to [`Surface::tile_mut`]. Pixel storage is reserved before changing /// the surface, and a shared tile is copied only after its replacement has been allocated. pub fn try_tile_mut(&mut self, c: TileCoord) -> Result<&mut Tile, AllocationError> { + if let Some(tile) = self.tiles.get(&c) { + drop(tile.try_bytes().map_err(AllocationError::ScratchRead)?); + } if !self.tiles.contains_key(&c) { let tile = Arc::new(Tile::try_filled(&self.format, &self.default_pixel)?); self.tiles.insert(c, tile); @@ -267,18 +357,41 @@ impl Surface { for tc in r.tiles() { let tr = tc.rect().intersect(&r); let t = self.try_tile_mut(tc)?; + let mut target = t.bytes_mut(); let span = tr.width() as usize * n; for y in tr.y0..tr.y1 { let src = (((y - r.y0) as usize) * r.width() as usize + (tr.x0 - r.x0) as usize) * n; let dst = (((y - tc.ty * TILE_SIZE) as usize) * TILE_SIZE as usize + (tr.x0 - tc.tx * TILE_SIZE) as usize) * n; for (i, v) in data[src..src + span].iter().enumerate() { - write_sample(&mut t.data, sample, dst + i, *v); + write_sample(&mut target, sample, dst + i, *v); } } } Ok(()) } + /// Tile rects that differ between `self` and `other`: tiles present in only one, or holding + /// a different (copied-on-write) tile. Compares pointers, never pixels, so it is cheap and + /// never reads spilled tiles back. `None` when the format or default pixel differ (every + /// pixel may read differently). + pub fn changed_rect(&self, other: &Surface) -> Option { + if self.format != other.format || self.default_pixel != other.default_pixel { + return None; + } + let mut r = Rect::EMPTY; + for (c, t) in &self.tiles { + if other.tiles.get(c).is_none_or(|o| !Arc::ptr_eq(t, o)) { + r = r.union(&c.rect()); + } + } + for c in other.tiles.keys() { + if !self.tiles.contains_key(c) { + r = r.union(&c.rect()); + } + } + Some(r) + } + /// Coarse bounds: union of allocated tile rects. pub fn tile_bounds(&self) -> Rect { self.tiles.keys().fold(Rect::EMPTY, |r, c| r.union(&c.rect())) @@ -315,7 +428,7 @@ impl Surface { pub fn prune(&mut self) { let dp = self.default_pixel.clone(); let bpp = dp.len(); - self.tiles.retain(|_, t| t.data.chunks_exact(bpp).any(|px| px != &*dp)); + self.tiles.retain(|_, t| t.bytes().chunks_exact(bpp).any(|px| px != &*dp)); } #[inline] @@ -338,8 +451,9 @@ impl Surface { let n = self.channels(); match self.tiles.get(&c) { Some(t) => { + let data = t.bytes(); for (i, o) in out.iter_mut().enumerate().take(n) { - *o = read_sample(&t.data, self.format.sample, base + i); + *o = read_sample(&data, self.format.sample, base + i); } } None => { @@ -354,9 +468,9 @@ impl Surface { let (c, base) = self.locate(x, y); let n = self.channels(); let sample = self.format.sample; - let t = self.tile_mut(c); + let mut t = self.tile_mut(c).bytes_mut(); for (i, v) in px.iter().enumerate().take(n) { - write_sample(&mut t.data, sample, base + i, *v); + write_sample(&mut t, sample, base + i, *v); } } @@ -365,7 +479,7 @@ impl Surface { pub fn sample_channel(&self, x: i32, y: i32, c: usize) -> f32 { let (tc, base) = self.locate(x, y); match self.tiles.get(&tc) { - Some(t) => read_sample(&t.data, self.format.sample, base + c), + Some(t) => read_sample(&t.bytes(), self.format.sample, base + c), None => read_sample(&self.default_pixel, self.format.sample, c), } } @@ -389,9 +503,10 @@ impl Surface { match self.tiles.get(&TileCoord { tx, ty }) { None => out[i..end].fill(dp), Some(t) => { + let data = t.bytes(); for (k, o) in out[i..end].iter_mut().enumerate() { let lx = (x + k as i32 * step - tx * TILE_SIZE) as usize; - *o = read_sample(&t.data, self.format.sample, (ly * TILE_SIZE as usize + lx) * n + c); + *o = read_sample(&data, self.format.sample, (ly * TILE_SIZE as usize + lx) * n + c); } } } @@ -408,7 +523,8 @@ impl Surface { let dp = self.default_pixel(); for tc in r.tiles() { let tr = tc.rect().intersect(&r); - let tile = self.tiles.get(&tc); + let guard = self.tiles.get(&tc).map(|t| t.bytes()); + let tile = guard.as_deref(); for y in tr.y0..tr.y1 { let o = (((y - r.y0) as usize) * w + (tr.x0 - r.x0) as usize) * n; let span = tr.width() as usize * n; @@ -417,7 +533,7 @@ impl Surface { Some(t) => { let base = (((y - tc.ty * TILE_SIZE) as usize) * TILE_SIZE as usize + (tr.x0 - tc.tx * TILE_SIZE) as usize) * n; for (i, d) in dst.iter_mut().enumerate() { - *d = read_sample(&t.data, self.format.sample, base + i); + *d = read_sample(t, self.format.sample, base + i); } } None => { @@ -442,7 +558,8 @@ impl Surface { let dp = to_rgba(&fmt, &self.default_pixel()); for tc in r.tiles() { let tr = tc.rect().intersect(&r); - let tile = self.tiles.get(&tc); + let guard = self.tiles.get(&tc).map(|t| t.bytes()); + let tile = guard.as_deref(); for y in tr.y0..tr.y1 { let o = ((y - r.y0) as usize) * w + (tr.x0 - r.x0) as usize; let dst = &mut out[o..o + tr.width() as usize]; @@ -453,13 +570,13 @@ impl Surface { let base = (((y - tc.ty * TILE_SIZE) as usize) * TILE_SIZE as usize + (tr.x0 - tc.tx * TILE_SIZE) as usize) * n; match (fmt.mode, fmt.sample, fmt.alpha) { (ColorMode::Rgb, SampleType::U8, true) => { - let src = &t.data[base..base + dst.len() * 4]; + let src = &t[base..base + dst.len() * 4]; for (d, s) in dst.iter_mut().zip(src.as_chunks::<4>().0) { *d = [s[0] as f32 / 255.0, s[1] as f32 / 255.0, s[2] as f32 / 255.0, s[3] as f32 / 255.0]; } } (ColorMode::Grayscale, SampleType::U8, true) => { - let src = &t.data[base..base + dst.len() * 2]; + let src = &t[base..base + dst.len() * 2]; for (d, s) in dst.iter_mut().zip(src.as_chunks::<2>().0) { let g = s[0] as f32 / 255.0; *d = [g, g, g, s[1] as f32 / 255.0]; @@ -468,7 +585,7 @@ impl Surface { (ColorMode::Cmyk, SampleType::U8, _) => { // Identical ink bytes share the exact ICC result; alpha is independent. let mut previous: Option<([u8; 4], [f32; 3])> = None; - let src = &t.data[base..base + dst.len() * n]; + let src = &t[base..base + dst.len() * n]; for (d, s) in dst.iter_mut().zip(src.chunks_exact(n)) { let ink = [s[0], s[1], s[2], s[3]]; let rgb = match previous { @@ -486,7 +603,7 @@ impl Surface { let mut px = [0.0f32; 8]; for (i, d) in dst.iter_mut().enumerate() { for (c, v) in px.iter_mut().enumerate().take(n) { - *v = read_sample(&t.data, fmt.sample, base + i * n + c); + *v = read_sample(t, fmt.sample, base + i * n + c); } *d = to_rgba(&fmt, &px[..n]); } @@ -497,7 +614,7 @@ impl Surface { } /// Reads `r` as straight 8-bit RGBA into `out` (`w*h` entries). RGBA8 surfaces copy tile bytes - /// directly; other formats convert through [`Surface::read_rgba_into`] one tile row at a time. + /// directly; other formats convert while holding the same tile read guard. pub fn read_rgba8_into(&self, r: Rect, out: &mut [[u8; 4]]) { let w = r.width() as usize; debug_assert_eq!(out.len(), w * r.height() as usize); @@ -506,10 +623,11 @@ impl Surface { let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8; let dp = to_rgba(&fmt, &self.default_pixel()); let dp8 = [q(dp[0]), q(dp[1]), q(dp[2]), q(dp[3])]; - let mut row = Vec::new(); + let mut px = [0.0f32; 8]; for tc in r.tiles() { let tr = tc.rect().intersect(&r); - let tile = self.tiles.get(&tc); + let guard = self.tiles.get(&tc).map(|t| t.bytes()); + let tile = guard.as_deref(); for y in tr.y0..tr.y1 { let o = ((y - r.y0) as usize) * w + (tr.x0 - r.x0) as usize; let dst = &mut out[o..o + tr.width() as usize]; @@ -518,14 +636,19 @@ impl Surface { Some(t) if rgba8 => { let base = (((y - tc.ty * TILE_SIZE) as usize) * TILE_SIZE as usize + (tr.x0 - tc.tx * TILE_SIZE) as usize) * 4; let len = dst.len(); - for (d, s) in dst.iter_mut().zip(t.data[base..base + len * 4].as_chunks::<4>().0) { + for (d, s) in dst.iter_mut().zip(t[base..base + len * 4].as_chunks::<4>().0) { *d = [s[0], s[1], s[2], s[3]]; } } - Some(_) => { - row.resize(dst.len(), [0.0f32; 4]); - self.read_rgba_into(Rect::new(tr.x0, y, tr.x1, y + 1), &mut row); - for (d, p) in dst.iter_mut().zip(&row) { + Some(t) => { + // A second read lock could deadlock behind an eviction writer. + let n = fmt.channels(); + let base = (((y - tc.ty * TILE_SIZE) as usize) * TILE_SIZE as usize + (tr.x0 - tc.tx * TILE_SIZE) as usize) * n; + for (i, d) in dst.iter_mut().enumerate() { + for (c, v) in px.iter_mut().enumerate().take(n) { + *v = read_sample(t, fmt.sample, base + i * n + c); + } + let p = to_rgba(&fmt, &px[..n]); *d = [q(p[0]), q(p[1]), q(p[2]), q(p[3])]; } } @@ -548,7 +671,8 @@ impl Surface { let dp = self.default_pixel(); for tc in r.tiles() { let tr = tc.rect().intersect(&r); - let tile = self.tiles.get(&tc); + let guard = self.tiles.get(&tc).map(|t| t.bytes()); + let tile = guard.as_deref(); for y in tr.y0..tr.y1 { for x in tr.x0..tr.x1 { let o = (((y - r.y0) as usize) * w + (x - r.x0) as usize) * n; @@ -556,7 +680,7 @@ impl Surface { Some(t) => { let base = (((y - tc.ty * TILE_SIZE) as usize) * TILE_SIZE as usize + (x - tc.tx * TILE_SIZE) as usize) * n; for i in 0..n { - out[o + i] = read_sample(&t.data, self.format.sample, base + i); + out[o + i] = read_sample(t, self.format.sample, base + i); } } None => out[o..o + n].copy_from_slice(&dp), @@ -576,6 +700,7 @@ impl Surface { for tc in r.tiles() { let tr = tc.rect().intersect(&r); let t = self.tile_mut(tc); + let mut target = t.bytes_mut(); // Convert contiguous channel samples a row at a time. Dispatching on depth here // removes the per-pixel channel loop and lets the compiler vectorise conversion. let span = tr.width() as usize * n; @@ -584,7 +709,7 @@ impl Surface { let base = (((y - tc.ty * TILE_SIZE) as usize) * TILE_SIZE as usize + (tr.x0 - tc.tx * TILE_SIZE) as usize) * n; let src = &data[o..o + span]; let bytes = sample.bytes(); - let dst = &mut t.data[base * bytes..(base + span) * bytes]; + let dst = &mut target[base * bytes..(base + span) * bytes]; match sample { SampleType::U8 => { for (d, v) in dst.iter_mut().zip(src) { @@ -681,12 +806,12 @@ impl Surface { self.tiles.insert(tc, t.clone()); continue; } - let t = self.tile_mut(tc); + let mut t = self.tile_mut(tc).bytes_mut(); for y in tr.y0..tr.y1 { let ly = (y - tc.ty * TILE_SIZE) as usize; let lx0 = (tr.x0 - tc.tx * TILE_SIZE) as usize; let lx1 = (tr.x1 - tc.tx * TILE_SIZE) as usize; - let row = &mut t.data[(ly * TILE_SIZE as usize + lx0) * bpp..(ly * TILE_SIZE as usize + lx1) * bpp]; + let row = &mut t[(ly * TILE_SIZE as usize + lx0) * bpp..(ly * TILE_SIZE as usize + lx1) * bpp]; for p in row.chunks_exact_mut(bpp) { p.copy_from_slice(&enc); } @@ -708,12 +833,12 @@ impl Surface { assert_eq!(bytes.len(), w * r.height() as usize * bpp, "interleaved length mismatch"); for tc in r.tiles() { let tr = tc.rect().intersect(&r); - let t = self.tile_mut(tc); + let mut t = self.tile_mut(tc).bytes_mut(); let span = tr.width() as usize * bpp; for y in tr.y0..tr.y1 { let src = (((y - r.y0) as usize) * w + (tr.x0 - r.x0) as usize) * bpp; let dst = (((y - tc.ty * TILE_SIZE) as usize) * TILE_SIZE as usize + (tr.x0 - tc.tx * TILE_SIZE) as usize) * bpp; - t.data[dst..dst + span].copy_from_slice(&bytes[src..src + span]); + t[dst..dst + span].copy_from_slice(&bytes[src..src + span]); } } } @@ -728,10 +853,10 @@ impl Surface { let span = tr.width() as usize * bpp; for y in tr.y0..tr.y1 { let dst = (((y - r.y0) as usize) * w + (tr.x0 - r.x0) as usize) * bpp; - match self.tiles.get(&tc) { + match self.tiles.get(&tc).map(|t| t.bytes()) { Some(t) => { let src = (((y - tc.ty * TILE_SIZE) as usize) * TILE_SIZE as usize + (tr.x0 - tc.tx * TILE_SIZE) as usize) * bpp; - out[dst..dst + span].copy_from_slice(&t.data[src..src + span]); + out[dst..dst + span].copy_from_slice(&t[src..src + span]); } None => { for p in out[dst..dst + span].chunks_exact_mut(bpp) { @@ -778,32 +903,36 @@ impl Surface { && sr.tiles().all(|sc| self.tiles.contains_key(&sc)); if covered && let Some(shared) = uniform.window(self, sr) { // A tile of only the default pixel is left out, as `prune` would. - if shared.data.get(..bpp) != Some(&*self.default_pixel) { + if shared.bytes().get(..bpp) != Some(&*self.default_pixel) { out.tiles.insert(dc, shared); } continue; } // A covered target is overwritten whole, so skip filling it with the default pixel. let mut tile = Tile::filled(&self.format, if covered { &zero } else { &self.default_pixel }); + let mut target = tile.bytes_mut(); for sc in sr.intersect(&content).tiles() { let Some(st) = self.tiles.get(&sc) else { continue }; + let source = st.bytes(); let part = sc.rect().intersect(&sr); let span = part.width() as usize * bpp; // Offsets inside the tiles (the target's computed wide: an offset near the i32 // limits saturates `translate`, and such rows are skipped rather than misplaced). - let at = |x: i64, y: i64| usize::try_from(y * i64::from(TILE_SIZE) + x).ok().map(|i| i * bpp); + let at = |x: i64, y: i64| usize::try_from(y * i64::from(TILE_SIZE) + x).ok().and_then(|i| i.checked_mul(bpp)); for y in part.y0..part.y1 { let src = at(i64::from(part.x0 - sc.tx * TILE_SIZE), i64::from(y - sc.ty * TILE_SIZE)); let dst = at(i64::from(part.x0) + i64::from(dx) - i64::from(dr.x0), i64::from(y) + i64::from(dy) - i64::from(dr.y0)); let (Some(src), Some(dst)) = (src, dst) else { continue }; - if let (Some(d), Some(s)) = (tile.data.get_mut(dst..dst + span), st.data.get(src..src + span)) { + if let (Some(d), Some(s)) = (target.get_mut(dst..dst.saturating_add(span)), source.get(src..src.saturating_add(span))) { d.copy_from_slice(s); } } } // A target that got only default pixels isn't stored, as `prune` would drop it. Checked // after the copy, a row at a time: a tile with content stops at its first such row. - if tile.data.chunks(default_row.len()).any(|row| row != default_row) { + let has_content = target.chunks(default_row.len()).any(|row| row != default_row); + drop(target); + if has_content { out.tiles.insert(dc, Arc::new(tile)); } } @@ -834,13 +963,15 @@ impl Surface { continue; } let tile = self.tile_mut(c); + let mut bytes = tile.bytes_mut(); for i in 0..px_count { for k in 0..color { let at = i * n + k; - let v = read_sample(&tile.data, format.sample, at); - write_sample(&mut tile.data, format.sample, at, f(v)); + let v = read_sample(&bytes, format.sample, at); + write_sample(&mut bytes, format.sample, at, f(v)); } } + drop(bytes); if let Some(mapped) = self.tiles.get(&c) { done.insert(key, mapped.clone()); } @@ -859,17 +990,19 @@ impl Surface { out.tiles.insert(*c, converted.clone()); continue; } - let dst = out.tile_mut(*c); + let source = t.bytes(); + let mut dst = out.tile_mut(*c).bytes_mut(); let px_count = (TILE_SIZE * TILE_SIZE) as usize; for i in 0..px_count { for (k, v) in src.iter_mut().enumerate() { - *v = read_sample(&t.data, self.format.sample, i * from_n + k); + *v = read_sample(&source, self.format.sample, i * from_n + k); } let px = convert_pixel(&self.format, &to, &src); for (k, v) in px.iter().enumerate() { - write_sample(&mut dst.data, to.sample, i * to.channels() + k, *v); + write_sample(&mut dst, to.sample, i * to.channels() + k, *v); } } + drop(dst); if let Some(converted) = out.tiles.get(c) { done.insert(Arc::as_ptr(t), converted.clone()); } @@ -891,16 +1024,19 @@ impl Surface { continue; } let dst = out.try_tile_mut(*c)?; + let source_bytes = t.try_bytes().map_err(AllocationError::ScratchRead)?; + let mut dest_bytes = dst.bytes_mut(); let px_count = (TILE_SIZE * TILE_SIZE) as usize; for i in 0..px_count { for (k, v) in src.iter_mut().enumerate() { - *v = read_sample(&t.data, self.format.sample, i * from_n + k); + *v = read_sample(&source_bytes, self.format.sample, i * from_n + k); } let px = convert_pixel(&self.format, &to, &src); for (k, v) in px.iter().enumerate() { - write_sample(&mut dst.data, to.sample, i * to.channels() + k, *v); + write_sample(&mut dest_bytes, to.sample, i * to.channels() + k, *v); } } + drop(dest_bytes); if let Some(converted) = out.tiles.get(c) { done.insert(Arc::as_ptr(t), converted.clone()); } @@ -1072,12 +1208,13 @@ mod tests { for tc in r.tiles() { let tr = tc.rect().intersect(&r); let t = surface.tile_mut(tc); + let mut target = t.bytes_mut(); for y in tr.y0..tr.y1 { for x in tr.x0..tr.x1 { let o = (((y - r.y0) as usize) * w + (x - r.x0) as usize) * n; let base = (((y - tc.ty * TILE_SIZE) as usize) * TILE_SIZE as usize + (x - tc.tx * TILE_SIZE) as usize) * n; for i in 0..n { - write_sample(&mut t.data, sample, base + i, data[o + i]); + write_sample(&mut target, sample, base + i, data[o + i]); } } } diff --git a/crates/raster/src/memory.rs b/crates/raster/src/memory.rs new file mode 100644 index 0000000000..069f8c0af0 --- /dev/null +++ b/crates/raster/src/memory.rs @@ -0,0 +1,181 @@ +//! Process-wide memory admission and adaptive tile residency. Native measurements are safe +//! `sysinfo` calls; web clients retain their explicit MB limit without filesystem assumptions. + +#[cfg(not(target_arch = "wasm32"))] +use std::sync::OnceLock; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Mutex, PoisonError}; + +const MIB: u64 = 1 << 20; + +#[derive(Clone, Copy, Debug, Default, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Snapshot { + pub total_bytes: u64, + pub available_bytes: u64, + pub process_resident_bytes: u64, + /// Address-space/virtual usage; this is not a portable measure of committed memory. + pub process_virtual_bytes: u64, +} + +#[derive(Clone, Copy, Debug)] +pub struct Policy { + /// None keeps the pre-streaming absolute MB setting. + pub percent: Option, + pub absolute_bytes: u64, +} + +impl Default for Policy { + fn default() -> Self { + Self { percent: None, absolute_bytes: 8 << 30 } + } +} + +#[derive(Clone, Copy, Debug, Default, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Budgets { + pub process_ceiling_bytes: u64, + pub effective_process_bytes: u64, + pub tile_bytes: u64, + pub transient_bytes: u64, +} + +/// Other applications and untiled buffers count against the same ceiling. Keep both system +/// headroom and a working reserve; virtual address-space size is deliberately not treated as RSS. +pub fn budgets(policy: Policy, sample: Snapshot, resident_tiles: u64) -> Budgets { + let ceiling = match (policy.percent, sample.total_bytes) { + (Some(p), total) if total > 0 => total / 100 * u64::from(p.clamp(1, 95)), + _ => policy.absolute_bytes, + } + .max(MIB); + let headroom = (sample.total_bytes / 20).max(256 * MIB); + let effective = if sample.total_bytes == 0 { + ceiling + } else { + ceiling.min(sample.process_resident_bytes.saturating_add(sample.available_bytes).saturating_sub(headroom)) + } + .max(MIB); + let transient = (effective / 8).clamp(MIB, 2 << 30); + let other = sample.process_resident_bytes.saturating_sub(resident_tiles); + let tiles = effective.saturating_sub(other).saturating_sub(transient).max(MIB); + Budgets { process_ceiling_bytes: ceiling, effective_process_bytes: effective, tile_bytes: tiles, transient_bytes: transient } +} + +static POLICY: Mutex = Mutex::new(Policy { percent: None, absolute_bytes: 8 << 30 }); +static CURRENT: Mutex<(Snapshot, Budgets)> = Mutex::new(( + Snapshot { total_bytes: 0, available_bytes: 0, process_resident_bytes: 0, process_virtual_bytes: 0 }, + Budgets { process_ceiling_bytes: 8 << 30, effective_process_bytes: 8 << 30, tile_bytes: 7 << 30, transient_bytes: 1 << 30 }, +)); +static RESERVED: AtomicU64 = AtomicU64::new(0); +static CONFIGURATION: AtomicU64 = AtomicU64::new(0); + +pub fn configuration_generation() -> u64 { + CONFIGURATION.load(Ordering::Relaxed) +} + +#[cfg(not(target_arch = "wasm32"))] +pub fn measure() -> Snapshot { + use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, System}; + static SYSTEM: OnceLock> = OnceLock::new(); + let mut s = SYSTEM.get_or_init(|| Mutex::new(System::new())).lock().unwrap_or_else(PoisonError::into_inner); + s.refresh_memory(); + let pid = Pid::from_u32(std::process::id()); + s.refresh_processes_specifics(ProcessesToUpdate::Some(&[pid]), true, ProcessRefreshKind::nothing().with_memory()); + Snapshot { + total_bytes: s.total_memory(), + available_bytes: s.available_memory(), + process_resident_bytes: s.process(pid).map_or(0, |p| p.memory()), + process_virtual_bytes: s.process(pid).map_or(0, |p| p.virtual_memory()), + } +} + +#[cfg(target_arch = "wasm32")] +pub fn measure() -> Snapshot { + Snapshot::default() +} + +pub fn refresh() -> Budgets { + let policy = *POLICY.lock().unwrap_or_else(PoisonError::into_inner); + let sample = measure(); + let b = budgets(policy, sample, crate::spill::stats().resident_bytes as u64); + *CURRENT.lock().unwrap_or_else(PoisonError::into_inner) = (sample, b); + // The test override remains fixed even when process pressure changes. + let tiles = std::env::var("PHOTOCRAFT_TILE_BUDGET_MB").ok().and_then(|s| s.parse::().ok()).map_or(b.tile_bytes, |mb| mb.saturating_mul(MIB)); + crate::spill::set_budget(usize::try_from(tiles).unwrap_or(usize::MAX)); + b +} + +pub fn configure(policy: Policy) -> Budgets { + *POLICY.lock().unwrap_or_else(PoisonError::into_inner) = policy; + CONFIGURATION.fetch_add(1, Ordering::Relaxed); + refresh() +} + +pub fn stats() -> (Snapshot, Budgets, u64) { + let (s, b) = *CURRENT.lock().unwrap_or_else(PoisonError::into_inner); + (s, b, RESERVED.load(Ordering::Relaxed)) +} + +/// A bounded temporary working set. Callers retain the guard until the allocation is released. +pub struct Reservation(u64); + +pub fn try_reserve(bytes: u64) -> Result { + let (_, b, _) = stats(); + RESERVED + .fetch_update(Ordering::AcqRel, Ordering::Relaxed, |n| n.checked_add(bytes).filter(|next| *next <= b.transient_bytes)) + .map_err(|_| "not enough working memory; reduce the operation or wait for other jobs".to_string())?; + Ok(Reservation(bytes)) +} + +/// Admit a legacy operation that needs a whole buffer, including concurrent working sets and +/// the current process RSS. Streaming workers keep the smaller [`try_reserve`] allowance. +/// This deliberately refuses work before an allocation rather than relying on OS paging. +pub fn try_reserve_operation(bytes: u64) -> Result { + let b = refresh(); + let (sample, _, _) = stats(); + let available = if sample.total_bytes == 0 { b.transient_bytes } else { b.effective_process_bytes.saturating_sub(sample.process_resident_bytes) }; + RESERVED + .fetch_update(Ordering::AcqRel, Ordering::Relaxed, |n| n.checked_add(bytes).filter(|next| *next <= available)) + .map_err(|_| "not enough working memory for this operation; reduce its region or use a streaming format".to_string())?; + Ok(Reservation(bytes)) +} + +impl Drop for Reservation { + fn drop(&mut self) { + RESERVED.fetch_sub(self.0, Ordering::AcqRel); + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn ceiling_includes_non_tile_memory_and_other_apps() { + let s = Snapshot { total_bytes: 64 << 30, available_bytes: 10 << 30, process_resident_bytes: 20 << 30, process_virtual_bytes: 100 << 30 }; + let b = budgets(Policy { percent: Some(90), absolute_bytes: 8 << 30 }, s, 15 << 30); + assert!(b.process_ceiling_bytes > 57 << 30); + assert!(b.effective_process_bytes < 30 << 30); + assert!(b.tile_bytes + (5 << 30) + b.transient_bytes <= b.effective_process_bytes); + } + #[test] + fn old_absolute_setting_and_missing_measurements_work() { + let b = budgets(Policy { percent: None, absolute_bytes: 512 * MIB }, Snapshot::default(), 0); + assert_eq!(b.process_ceiling_bytes, 512 * MIB); + assert_eq!(b.tile_bytes + b.transient_bytes, b.effective_process_bytes); + } + #[test] + fn hostile_percent_and_pressure_do_not_overflow() { + let b = budgets(Policy { percent: Some(255), absolute_bytes: u64::MAX }, Snapshot { total_bytes: u64::MAX, ..Snapshot::default() }, u64::MAX); + assert!(b.process_ceiling_bytes < u64::MAX); + assert_eq!(b.tile_bytes, MIB); + } + #[test] + fn reservations_are_bounded_and_returned() { + let before = stats().2; + let r = try_reserve(1024).unwrap(); + assert_eq!(stats().2, before + 1024); + assert!(try_reserve(u64::MAX).is_err()); + drop(r); + assert_eq!(stats().2, before); + } +} diff --git a/crates/raster/src/scratch_file.rs b/crates/raster/src/scratch_file.rs new file mode 100644 index 0000000000..f597fd4bba --- /dev/null +++ b/crates/raster/src/scratch_file.rs @@ -0,0 +1,430 @@ +//! The scratch file: where cold tiles go when pixel memory is over budget. +//! +//! - One file per configured directory. Windows deletes it even after a crash; Unix uses private +//! permissions and reaps files of dead processes when the directory is next configured. +//! - Space is handed out in 4 KiB-aligned extents from a free list (best fit, coalescing), else +//! appended at the end. +//! - All I/O is positioned: no shared cursor, no lock held during I/O. The allocator lock only +//! guards the free list. +//! - Reads go through one handle per thread (the file opened again by path): Windows serialises +//! I/O on one synchronous handle, so parallel readers sharing it would run one at a time. +//! +//! Ported from Fotox's `fx-tiles` scratch file (same author), minus its `unsafe` `ReOpenFile`. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fs::File; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Mutex, OnceLock, PoisonError}; + +const ALIGN: u64 = 4096; + +/// A region of the scratch file holding one compressed tile. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct Extent { + pub offset: u64, + /// Exact length of the data. + pub len: u32, + /// Allocated length (multiple of 4 KiB, ≥ `len`). + pub alloc: u64, + /// CRC-32 of the stored bytes, checked on every read. + pub crc32: u32, +} + +/// Free-list allocator. Pure bookkeeping, no I/O. +#[derive(Debug, Default)] +pub(crate) struct Allocator { + by_offset: BTreeMap, + by_size: BTreeSet<(u64, u64)>, + end: u64, +} + +impl Allocator { + /// Bytes currently allocated (end of file minus free space). + pub fn used(&self) -> u64 { + self.end - self.by_offset.values().sum::() + } + + pub fn alloc(&mut self, len: u32) -> Extent { + let size = u64::from(len).div_ceil(ALIGN).max(1) * ALIGN; + if let Some(&(free_size, offset)) = self.by_size.range((size, 0)..).next() { + self.remove_free(offset, free_size); + if free_size > size { + self.insert_free(offset + size, free_size - size); + } + return Extent { offset, len, alloc: size, crc32: 0 }; + } + let offset = self.end; + self.end += size; + Extent { offset, len, alloc: size, crc32: 0 } + } + + pub fn free(&mut self, extent: Extent) { + let mut offset = extent.offset; + let mut size = extent.alloc; + if let Some((&prev_off, &prev_size)) = self.by_offset.range(..offset).next_back() + && prev_off + prev_size == offset + { + self.remove_free(prev_off, prev_size); + offset = prev_off; + size += prev_size; + } + if let Some(&next_size) = self.by_offset.get(&(offset + size)) { + self.remove_free(offset + size, next_size); + size += next_size; + } + if offset + size == self.end { + // Free space at the end of the file just shrinks the used range. + self.end = offset; + } else { + self.insert_free(offset, size); + } + } + + fn insert_free(&mut self, offset: u64, size: u64) { + self.by_offset.insert(offset, size); + self.by_size.insert((size, offset)); + } + + fn remove_free(&mut self, offset: u64, size: u64) { + self.by_offset.remove(&offset); + self.by_size.remove(&(size, offset)); + } +} + +pub(crate) struct ScratchFile { + main: File, + readers: OnceLock>, + path: PathBuf, + allocator: Mutex, +} + +impl std::fmt::Debug for ScratchFile { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ScratchFile").field("path", &self.path).finish() + } +} + +impl ScratchFile { + pub fn create(dir: &Path) -> std::io::Result { + std::fs::create_dir_all(dir)?; + static COUNTER: AtomicUsize = AtomicUsize::new(0); + // A reused process id must not collide with an abandoned file from an older launch. + let n = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + .saturating_add(COUNTER.fetch_add(1, Ordering::Relaxed) as u128); + let path = dir.join(format!("photocraft-{}-{n}.scratch", std::process::id())); + let mut options = std::fs::OpenOptions::new(); + options.read(true).write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + const FILE_FLAG_DELETE_ON_CLOSE: u32 = 0x0400_0000; + options.custom_flags(FILE_FLAG_DELETE_ON_CLOSE); + } + let main = options.open(&path)?; + #[cfg(unix)] + reap_abandoned(dir, &main); + Ok(Self { main, readers: OnceLock::new(), path, allocator: Mutex::new(Allocator::default()) }) + } + + pub fn path(&self) -> &Path { + &self.path + } + + /// Reserve space and write `data` there. + pub fn write(&self, data: &[u8]) -> std::io::Result { + let len = u32::try_from(data.len()).map_err(|_| std::io::Error::other("block larger than 4 GiB"))?; + let mut extent = self.lock().alloc(len); + if let Err(e) = write_all_at(&self.main, data, extent.offset) { + self.free(extent); + return Err(e); + } + extent.crc32 = crc32fast::hash(data); + Ok(extent) + } + + /// Read an extent into `buf` (resized to it) and check its CRC. + pub fn read_into(&self, extent: Extent, buf: &mut Vec) -> std::io::Result<()> { + buf.resize(extent.len as usize, 0); + read_exact_at(self.reader(), buf, extent.offset)?; + if crc32fast::hash(buf) != extent.crc32 { + return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, format!("scratch CRC mismatch at offset {}", extent.offset))); + } + Ok(()) + } + + pub fn free(&self, extent: Extent) { + let mut allocator = self.lock(); + allocator.free(extent); + if allocator.end == 0 { + // Release the physical disk space after the last document/history tile closes. + // Keep the allocator locked so a new writer cannot reserve an extent before + // this truncation; failures retain excess space, never live tile bytes. + let _ = self.main.set_len(0); + } + } + + pub fn used(&self) -> u64 { + self.lock().used() + } + + fn lock(&self) -> std::sync::MutexGuard<'_, Allocator> { + self.allocator.lock().unwrap_or_else(PoisonError::into_inner) + } + + /// A handle for reading on the calling thread. + fn reader(&self) -> &File { + let extra = self.readers.get_or_init(|| { + // The same file opened again by path: std shares read, write and delete, so this + // works while `main` (opened delete-on-close) is alive. A failure leaves fewer + // handles, never an error. + let count = std::thread::available_parallelism().map_or(4, |n| n.get()).clamp(1, 16) - 1; + (0..count).map_while(|_| std::fs::OpenOptions::new().read(true).open(&self.path).ok()).collect() + }); + match thread_slot() % (extra.len() + 1) { + 0 => &self.main, + i => extra.get(i - 1).unwrap_or(&self.main), + } + } +} + +/// Accept only the names we generate, never other temporary files or directories. +#[cfg(any(unix, test))] +fn scratch_pid(name: &str) -> Option { + let (pid, nonce) = name.strip_prefix("photocraft-")?.strip_suffix(".scratch")?.split_once('-')?; + if pid.is_empty() || nonce.is_empty() || !pid.bytes().chain(nonce.bytes()).all(|b| b.is_ascii_digit()) { + return None; + } + let pid = pid.parse::().ok()?; + (pid > 0 && nonce.parse::().is_ok()).then_some(pid) +} + +#[cfg(unix)] +fn reap_abandoned(dir: &Path, own: &File) { + use std::os::unix::fs::MetadataExt; + use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, System}; + let Ok(metadata) = own.metadata() else { return }; + let mut system = System::new(); + system.refresh_processes_specifics(ProcessesToUpdate::All, true, ProcessRefreshKind::nothing()); + // Fail closed when process enumeration isn't available. Live files are never removed. + if system.process(Pid::from_u32(std::process::id())).is_none() { + return; + } + reap_with(dir, metadata.uid(), |pid| system.process(Pid::from_u32(pid)).is_some()); +} + +#[cfg(unix)] +fn reap_with(dir: &Path, uid: u32, live: impl Fn(u32) -> bool) { + use std::os::unix::fs::MetadataExt; + let Ok(entries) = std::fs::read_dir(dir) else { return }; + for entry in entries.flatten() { + let name = entry.file_name(); + let Some(pid) = name.to_str().and_then(scratch_pid) else { continue }; + if pid == std::process::id() || live(pid) { + continue; + } + let path = entry.path(); + let Ok(meta) = std::fs::symlink_metadata(&path) else { continue }; + // Shared /tmp: do not remove other users' files or follow symlinks. + if meta.file_type().is_file() && meta.uid() == uid { + let _ = std::fs::remove_file(path); + } + } +} + +impl Drop for ScratchFile { + fn drop(&mut self) { + // On Windows the file is already gone (delete-on-close); elsewhere remove it. + if !cfg!(windows) { + let _ = std::fs::remove_file(&self.path); + } + } +} + +/// A stable small number per thread, handed out in turn, so the worker threads of a pool land +/// on different handles. +fn thread_slot() -> usize { + static NEXT: AtomicUsize = AtomicUsize::new(0); + thread_local! { + static SLOT: usize = NEXT.fetch_add(1, Ordering::Relaxed); + } + SLOT.with(|slot| *slot) +} + +#[cfg(unix)] +fn write_all_at(file: &File, data: &[u8], offset: u64) -> std::io::Result<()> { + std::os::unix::fs::FileExt::write_all_at(file, data, offset) +} + +#[cfg(unix)] +fn read_exact_at(file: &File, buf: &mut [u8], offset: u64) -> std::io::Result<()> { + std::os::unix::fs::FileExt::read_exact_at(file, buf, offset) +} + +#[cfg(windows)] +fn write_all_at(file: &File, mut data: &[u8], mut offset: u64) -> std::io::Result<()> { + use std::os::windows::fs::FileExt; + while !data.is_empty() { + let n = file.seek_write(data, offset)?; + if n == 0 { + return Err(std::io::ErrorKind::WriteZero.into()); + } + data = data.get(n..).unwrap_or_default(); + offset += n as u64; + } + Ok(()) +} + +#[cfg(windows)] +fn read_exact_at(file: &File, mut buf: &mut [u8], mut offset: u64) -> std::io::Result<()> { + use std::os::windows::fs::FileExt; + while !buf.is_empty() { + let n = file.seek_read(buf, offset)?; + if n == 0 { + return Err(std::io::ErrorKind::UnexpectedEof.into()); + } + buf = buf.get_mut(n..).unwrap_or_default(); + offset += n as u64; + } + Ok(()) +} + +#[cfg(not(any(unix, windows)))] +fn write_all_at(_file: &File, _data: &[u8], _offset: u64) -> std::io::Result<()> { + Err(std::io::ErrorKind::Unsupported.into()) +} + +#[cfg(not(any(unix, windows)))] +fn read_exact_at(_file: &File, _buf: &mut [u8], _offset: u64) -> std::io::Result<()> { + Err(std::io::ErrorKind::Unsupported.into()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cleanup_names_are_strict() { + assert_eq!(scratch_pid("photocraft-123-987.scratch"), Some(123)); + for name in [ + "photocraft-0-1.scratch", + "photocraft-1-.scratch", + "photocraft-1-../notes.scratch", + "photocraft-1-2.scratch.bak", + "other-1-2.scratch", + "photocraft-4294967296-2.scratch", + ] { + assert_eq!(scratch_pid(name), None, "{name}"); + } + } + + #[cfg(any(unix, windows))] + #[test] + fn abrupt_exit_does_not_leave_permanent_scratch_files() { + const CHILD_DIR: &str = "PHOTOCRAFT_SCRATCH_EXIT_TEST_DIR"; + if let Some(dir) = std::env::var_os(CHILD_DIR) { + let file = ScratchFile::create(Path::new(&dir)).unwrap(); + file.write(b"unreleased pixels").unwrap(); + // No Rust destructors run: this exercises the lifetime of the static manager. + std::process::exit(0); + } + let dir = std::env::temp_dir().join(format!("pc-abrupt-exit-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let result = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "scratch_file::tests::abrupt_exit_does_not_leave_permanent_scratch_files", "--nocapture"]) + .env(CHILD_DIR, &dir) + .status() + .unwrap(); + assert!(result.success()); + #[cfg(windows)] + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0, "delete-on-close survives exit without Drop"); + let next = ScratchFile::create(&dir).unwrap(); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1, "a new launch reaps the dead process's files"); + drop(next); + std::fs::remove_dir(dir).unwrap(); + } + + #[cfg(unix)] + #[test] + fn unix_files_are_private_and_reaping_preserves_live_and_unrelated_files() { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + let dir = std::env::temp_dir().join(format!("pc-reap-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let own = ScratchFile::create(&dir).unwrap(); + let meta = own.main.metadata().unwrap(); + assert_eq!(meta.permissions().mode() & 0o777, 0o600); + let stale = dir.join("photocraft-123-1.scratch"); + let live = dir.join("photocraft-124-1.scratch"); + let unrelated = dir.join("notes.scratch"); + let link = dir.join("photocraft-125-1.scratch"); + for p in [&stale, &live, &unrelated] { + std::fs::write(p, b"test").unwrap(); + } + std::os::unix::fs::symlink(&unrelated, &link).unwrap(); + reap_with(&dir, meta.uid().wrapping_add(1), |_| false); + assert!(stale.exists(), "another owner's files remain"); + reap_with(&dir, meta.uid(), |pid| pid == 124); + assert!(!stale.exists()); + assert!(live.exists() && unrelated.exists() && link.exists() && own.path.exists()); + drop(own); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn alloc_reuses_and_coalesces() { + let mut a = Allocator::default(); + let x = a.alloc(100); + let y = a.alloc(5000); + let z = a.alloc(4096); + assert_eq!((x.offset, y.offset, z.offset), (0, 4096, 12288)); + assert_eq!(a.used(), 16384); + a.free(x); + a.free(y); // coalesces with x: one free extent of 12 KiB at 0 + let w = a.alloc(12000); + assert_eq!(w.offset, 0, "coalesced hole is reused"); + a.free(z); // tail extent: the file end shrinks + assert_eq!(a.used(), 12288); + a.free(w); + assert_eq!(a.used(), 0); + assert_eq!(a.end, 0, "everything freed collapses the file"); + } + + #[test] + fn best_fit_splits_larger_holes() { + let mut a = Allocator::default(); + let big = a.alloc(40000); + let _tail = a.alloc(1); + a.free(big); + assert_eq!(a.alloc(10).offset, 0); + assert_eq!(a.alloc(10).offset, 4096, "remainder of the split hole is used next"); + } + + #[test] + fn file_roundtrip_and_crc() { + let dir = std::env::temp_dir().join("photocraft-scratch-test"); + let scratch = ScratchFile::create(&dir).unwrap(); + let a = scratch.write(b"hello tiles").unwrap(); + let b = scratch.write(&[7u8; 9000]).unwrap(); + let mut buf = Vec::new(); + scratch.read_into(a, &mut buf).unwrap(); + assert_eq!(buf, b"hello tiles"); + scratch.read_into(b, &mut buf).unwrap(); + assert_eq!(buf, vec![7u8; 9000]); + let bad = Extent { crc32: a.crc32 ^ 1, ..a }; + assert!(scratch.read_into(bad, &mut buf).is_err(), "a CRC mismatch is an error, not garbage"); + scratch.free(a); + scratch.free(b); + assert_eq!(scratch.main.metadata().unwrap().len(), 0, "closing the last tile releases physical disk space"); + let path = scratch.path().to_path_buf(); + drop(scratch); + assert!(!path.exists(), "scratch file is removed on drop"); + } +} diff --git a/crates/raster/src/spill.rs b/crates/raster/src/spill.rs new file mode 100644 index 0000000000..6b6ba3c00c --- /dev/null +++ b/crates/raster/src/spill.rs @@ -0,0 +1,563 @@ +//! Scratch disk: tile pixels beyond a memory budget move to a file and come back on access. +//! +//! Every [`Tile`](crate::Tile) keeps its bytes in a [`Cell`]: resident (`Some`) or on disk +//! (`None`, with its [`Extent`] in the scratch file). Readers take the cell's read lock, so a +//! tile in use is never evicted; a spilled tile is read back (LZ4 + CRC-checked) by whoever +//! touches it first. +//! +//! - **Accounting.** [`stats`] counts the bytes of every live tile that is resident, whether or +//! not a scratch disk is configured. +//! - **Eviction** is the clock ("second chance") algorithm over a registry of weak cell +//! pointers: a tile touched since the hand last passed keeps its place once. A background +//! thread evicts down to 90 % of the budget; a thread that pushes memory past 125 % helps. +//! - **Clean tiles are free to drop.** A tile written once and not changed since keeps its +//! extent, so evicting it again only frees memory; changing it frees the extent. +//! - **Off by default.** Until [`configure`] sets a directory and a budget nothing is evicted +//! (tests, the web build). +//! +//! Failures never panic: a scratch write that fails keeps the tile in memory and turns spilling +//! off (reported in [`SpillStats::last_error`]). A failed read retains its disk extent and +//! poisons the tile: edits and saves must check [`check_integrity`] before committing. The +//! infallible display API uses a temporary placeholder, never replacement pixel storage. + +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex, PoisonError, RwLock, RwLockReadGuard, RwLockWriteGuard, Weak}; + +use crate::scratch_file::{Extent, ScratchFile}; + +/// One tile's bytes and where they live. +pub(crate) struct Cell { + data: RwLock>>, + /// Byte length of the tile (fixed for its lifetime). + len: usize, + error: Mutex>, + /// A copy on disk that matches `data` (kept while the tile is unchanged). + slot: Mutex, Extent)>>, + /// Touched since the clock hand last passed. + referenced: AtomicBool, +} + +impl Cell { + pub(crate) fn new(bytes: Vec) -> Arc { + let len = bytes.len(); + let cell = Arc::new(Cell { data: RwLock::new(Some(bytes)), len, error: Mutex::new(None), slot: Mutex::new(None), referenced: AtomicBool::new(true) }); + M.live.fetch_add(1, Ordering::Relaxed); + M.resident.fetch_add(len, Ordering::Relaxed); + M.register(&cell); + maybe_evict(); + cell + } + + pub(crate) fn len(&self) -> usize { + self.len + } + + pub(crate) fn read_error(&self) -> Option { + self.error.lock().unwrap_or_else(PoisonError::into_inner).clone() + } + + fn set_error(&self, error: Option) { + let mut old = self.error.lock().unwrap_or_else(PoisonError::into_inner); + match (old.is_some(), error.is_some()) { + (false, true) => { + M.failed.fetch_add(1, Ordering::Release); + M.read_errors.fetch_add(1, Ordering::Release); + } + (true, false) => { + // Invalidate computations that started while pixels were unreadable too. + // Publish this before reporting the tile healthy to commit guards. + M.read_errors.fetch_add(1, Ordering::Release); + M.failed.fetch_sub(1, Ordering::Release); + } + _ => {} + } + *old = error; + } + + #[inline] + fn touch(&self) { + if !self.referenced.load(Ordering::Relaxed) { + self.referenced.store(true, Ordering::Relaxed); + } + } + + /// Read access; reads the tile back from the scratch disk if it was evicted. + pub(crate) fn read(&self) -> RwLockReadGuard<'_, Option>> { + let mut loaded_pixels = false; + loop { + self.touch(); + let g = self.data.read().unwrap_or_else(PoisonError::into_inner); + if g.is_some() { + if loaded_pixels { + // Pin the tile before eviction: even a budget smaller than one tile + // must not repeatedly evict the pixels this reader just loaded. + maybe_evict(); + } + return g; + } + drop(g); + let mut w = self.data.write().unwrap_or_else(PoisonError::into_inner); + let loaded = self.load(&mut w).is_ok(); + drop(w); + if !loaded { + return self.data.read().unwrap_or_else(PoisonError::into_inner); + } + loaded_pixels = true; + } + } + + /// Write access: resident, and any disk copy is released (it no longer matches). + pub(crate) fn write(&self) -> RwLockWriteGuard<'_, Option>> { + self.touch(); + let mut w = self.data.write().unwrap_or_else(PoisonError::into_inner); + let loaded = w.is_none(); + if self.load(&mut w).is_err() { + // Keep the only original copy. The write guard's temporary placeholder is + // discarded, and the transaction's integrity check refuses to commit it. + return w; + } + if let Some((file, extent)) = self.slot.lock().unwrap_or_else(PoisonError::into_inner).take() { + file.free(extent); + } + if loaded { + // Evicting here could only pick other tiles (this one is locked), so it is safe, and + // a long run of writes to spilled tiles must not outgrow the budget. + maybe_evict(); + } + w + } + + /// Fill `w` from the scratch disk if it is not resident (`w` is this cell's write guard). + fn load(&self, w: &mut Option>) -> Result<(), String> { + if w.is_some() { + return Ok(()); + } + let slot = self.slot.lock().unwrap_or_else(PoisonError::into_inner).clone(); + let bytes = match slot { + Some((file, extent)) => { + let mut packed = Vec::new(); + match file.read_into(extent, &mut packed).map_err(|e| e.to_string()).and_then(|()| unpack(&packed, self.len)) { + Ok(b) => b, + Err(e) => { + let error = format!("scratch read failed ({e}); editing and saving are blocked until the tile is readable"); + self.set_error(Some(error.clone())); + M.error(error.clone()); + return Err(error); + } + } + } + None => { + let error = "a spilled tile has no scratch copy; editing and saving are blocked".to_string(); + self.set_error(Some(error.clone())); + M.error(error.clone()); + return Err(error); + } + }; + *w = Some(bytes); + self.set_error(None); + M.resident.fetch_add(self.len, Ordering::Relaxed); + M.reloads.fetch_add(1, Ordering::Relaxed); + Ok(()) + } + + /// Move this tile to disk if nobody is using it. Returns the bytes freed. + fn evict(&self, file: &Arc) -> Result { + let Ok(mut w) = self.data.try_write() else { return Ok(0) }; + let Some(bytes) = w.as_ref() else { return Ok(0) }; + let mut slot = self.slot.lock().unwrap_or_else(PoisonError::into_inner); + if slot.is_none() { + let packed = lz4_flex::block::compress(bytes); + let extent = file.write(&packed).map_err(|e| format!("scratch write to {} failed: {e}", file.path().display()))?; + M.written.fetch_add(packed.len() as u64, Ordering::Relaxed); + *slot = Some((file.clone(), extent)); + } + drop(slot); + *w = None; + M.resident.fetch_sub(self.len, Ordering::Relaxed); + M.evictions.fetch_add(1, Ordering::Relaxed); + Ok(self.len) + } +} + +impl Drop for Cell { + fn drop(&mut self) { + if self.error.get_mut().unwrap_or_else(PoisonError::into_inner).is_some() { + M.read_errors.fetch_add(1, Ordering::Release); + M.failed.fetch_sub(1, Ordering::Release); + } + let resident = self.data.get_mut().unwrap_or_else(PoisonError::into_inner).is_some(); + if resident { + M.resident.fetch_sub(self.len, Ordering::Relaxed); + } + if let Some((file, extent)) = self.slot.get_mut().unwrap_or_else(PoisonError::into_inner).take() { + file.free(extent); + } + M.live.fetch_sub(1, Ordering::Relaxed); + } +} + +fn unpack(packed: &[u8], len: usize) -> Result, String> { + let bytes = lz4_flex::block::decompress(packed, len).map_err(|e| e.to_string())?; + if bytes.len() == len { Ok(bytes) } else { Err(format!("tile decoded to {} bytes, expected {len}", bytes.len())) } +} + +/// Process-wide state. Plain statics: everything here is const-constructible. +struct Manager { + failed: AtomicUsize, + read_errors: AtomicU64, + resident: AtomicUsize, + live: AtomicUsize, + /// Pixel bytes to keep resident; 0 = spilling off. + budget: AtomicUsize, + registry: Mutex, + config: Mutex, + evictions: AtomicU64, + reloads: AtomicU64, + written: AtomicU64, +} + +struct Registry { + cells: Vec>, + hand: usize, +} + +struct Config { + dir: Option, + /// Opened at configuration time; startup also reaps abandoned Unix files. + file: Option>, + last_error: Option, +} + +static M: Manager = Manager { + failed: AtomicUsize::new(0), + read_errors: AtomicU64::new(0), + resident: AtomicUsize::new(0), + live: AtomicUsize::new(0), + budget: AtomicUsize::new(0), + registry: Mutex::new(Registry { cells: Vec::new(), hand: 0 }), + config: Mutex::new(Config { dir: None, file: None, last_error: None }), + evictions: AtomicU64::new(0), + reloads: AtomicU64::new(0), + written: AtomicU64::new(0), +}; + +impl Manager { + fn register(&self, cell: &Arc) { + let mut r = self.registry.lock().unwrap_or_else(PoisonError::into_inner); + r.cells.push(Arc::downgrade(cell)); + // Drop the pointers of freed tiles once they outnumber the live ones. + if r.cells.len() > self.live.load(Ordering::Relaxed).saturating_mul(2).saturating_add(4096) { + r.cells.retain(|w| w.strong_count() > 0); + r.hand = 0; + } + } + + fn config(&self) -> std::sync::MutexGuard<'_, Config> { + self.config.lock().unwrap_or_else(PoisonError::into_inner) + } + + fn error(&self, msg: String) { + log_error(&msg); + self.config().last_error = Some(msg); + } + + /// The scratch file, created on first use; `None` (and spilling off) if it can't be. + fn file(&self) -> Option> { + let mut c = self.config(); + if let Some(f) = &c.file { + return Some(f.clone()); + } + let dir = c.dir.clone()?; + match ScratchFile::create(&dir) { + Ok(f) => { + let f = Arc::new(f); + c.file = Some(f.clone()); + Some(f) + } + Err(e) => { + let msg = format!("could not create a scratch file in {}: {e}; spilling is off", dir.display()); + log_error(&msg); + c.last_error = Some(msg); + c.dir = None; + self.budget.store(0, Ordering::Relaxed); + None + } + } + } + + /// Evict until resident memory is at most `target` or `max_scan` cells were looked at. + /// Returns the bytes freed. + fn sweep(&self, target: usize, max_scan: usize) -> usize { + let Some(file) = self.file() else { return 0 }; + let mut freed = 0; + let mut scanned = 0; + while self.resident.load(Ordering::Relaxed) > target && scanned < max_scan { + // Pick victims under the registry lock (cheap), evict them after releasing it. + let mut victims = Vec::with_capacity(64); + { + let mut r = self.registry.lock().unwrap_or_else(PoisonError::into_inner); + let mut i = 0; + while victims.len() < 64 && i < 4096 && !r.cells.is_empty() { + if r.hand >= r.cells.len() { + r.hand = 0; + } + let h = r.hand; + i += 1; + match r.cells.get(h).and_then(Weak::upgrade) { + None => { + r.cells.swap_remove(h); + continue; + } + Some(cell) => { + r.hand += 1; + if cell.referenced.swap(false, Ordering::Relaxed) { + continue; + } + if cell.data.try_read().is_ok_and(|d| d.is_some()) { + victims.push(cell); + } + } + } + } + scanned += i; + if i == 0 { + break; + } + } + for cell in victims { + match cell.evict(&file) { + Ok(n) => freed += n, + Err(e) => { + // Disk full or gone: keep everything in memory from now on. + self.budget.store(0, Ordering::Relaxed); + self.error(format!("{e}; spilling is off")); + return freed; + } + } + } + } + freed + } +} + +/// Called after resident memory grew. +fn maybe_evict() { + let budget = M.budget.load(Ordering::Relaxed); + if budget == 0 { + return; + } + let resident = M.resident.load(Ordering::Relaxed); + if resident <= budget { + return; + } + let worker = MAINTENANCE.load(Ordering::Acquire) > 0; + // Far over budget (or no worker thread): this thread helps. + if !worker || resident > budget.saturating_add(budget / 4) { + M.sweep(low_water(budget), 1 << 16); + } +} + +fn low_water(budget: usize) -> usize { + budget / 10 * 9 +} + +// The engine owns and joins the worker. Standalone raster consumers keep synchronous +// eviction; this crate never starts a background thread or retains its owner. +static MAINTENANCE: AtomicUsize = AtomicUsize::new(0); + +/// Register an externally owned residency worker. Keep this guard until that worker exits. +pub struct Maintenance; + +pub fn background_maintenance() -> Maintenance { + MAINTENANCE.fetch_add(1, Ordering::AcqRel); + Maintenance +} + +impl Drop for Maintenance { + fn drop(&mut self) { + MAINTENANCE.fetch_sub(1, Ordering::AcqRel); + } +} + +/// One bounded clock pass. The caller owns scheduling and shutdown. +pub fn maintain_budget() -> usize { + let budget = M.budget.load(Ordering::Relaxed); + if budget == 0 || M.resident.load(Ordering::Relaxed) <= budget { + return 0; + } + M.sweep(low_water(budget), 4096) +} + +/// Turn the scratch disk on (`dir` and a non-zero `budget_bytes`) or off. Tiles already on disk +/// stay readable either way. Changing the directory starts a new scratch file there; the old one +/// is deleted once its last tile is freed or changed. +pub fn set_budget(budget_bytes: usize) { + if enabled() { + M.budget.store(budget_bytes.max(1), Ordering::Relaxed); + maybe_evict(); + } +} + +pub fn configure(dir: Option<&Path>, budget_bytes: usize) -> Result<(), String> { + let mut c = M.config(); + let dir = dir.filter(|_| budget_bytes > 0).map(Path::to_path_buf); + if let Some(d) = &dir + && let Err(e) = std::fs::create_dir_all(d) + { + let msg = format!("scratch disk {}: {e}; spilling is off", d.display()); + c.dir = None; + c.file = None; + c.last_error = Some(msg.clone()); + M.budget.store(0, Ordering::Relaxed); + return Err(msg); + } + if dir != c.dir || (dir.is_some() && c.file.is_none()) { + c.file = match dir.as_deref().map(ScratchFile::create).transpose() { + Ok(file) => file.map(Arc::new), + Err(e) => { + let msg = format!("scratch disk: {e}; spilling is off"); + c.dir = None; + c.last_error = Some(msg.clone()); + M.budget.store(0, Ordering::Relaxed); + return Err(msg); + } + }; + } + c.dir = dir.clone(); + c.last_error = None; + M.budget.store(if dir.is_some() { budget_bytes } else { 0 }, Ordering::Relaxed); + drop(c); + maybe_evict(); + Ok(()) +} + +/// Where the tile memory stands. +#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SpillStats { + /// Pixel bytes of live tiles in memory. + pub resident_bytes: usize, + pub live_tiles: usize, + /// 0 = spilling off. + pub budget_bytes: usize, + pub dir: Option, + /// Bytes allocated in the current scratch file. + pub scratch_bytes: u64, + pub evictions: u64, + pub reloads: u64, + /// Compressed bytes written to scratch so far. + pub written_bytes: u64, + pub last_error: Option, +} + +pub fn stats() -> SpillStats { + let c = M.config(); + SpillStats { + resident_bytes: M.resident.load(Ordering::Relaxed), + live_tiles: M.live.load(Ordering::Relaxed), + budget_bytes: M.budget.load(Ordering::Relaxed), + dir: c.dir.clone(), + scratch_bytes: c.file.as_ref().map_or(0, |f| f.used()), + evictions: M.evictions.load(Ordering::Relaxed), + reloads: M.reloads.load(Ordering::Relaxed), + written_bytes: M.written.load(Ordering::Relaxed), + last_error: c.last_error.clone(), + } +} + +/// Last storage error, without scanning the scratch allocator for UI status updates. +pub fn last_error() -> Option { + M.config().last_error.clone() +} + +/// Whether spilling is on (tiles beyond the budget go to disk). +pub fn enabled() -> bool { + M.budget.load(Ordering::Relaxed) > 0 +} + +/// Refuse to publish edited or serialized pixels after a scratch read failure. A failed +/// tile can retry its original extent; changing preferences cannot clear this protection. +pub fn check_integrity() -> Result<(), String> { + if M.failed.load(Ordering::Acquire) == 0 { + Ok(()) + } else { + Err(M.config().last_error.clone().unwrap_or_else(|| "scratch pixels are unreadable; editing and saving are blocked".into())) + } +} + +/// Capture before an edit/import/save or derived cache computation. A failed tile might be dropped or repaired +/// before the operation ends; its placeholder must still never be committed as real pixels. +/// Both failure and recovery change this generation, including computations begun during a failure. +pub fn read_error_generation() -> u64 { + M.read_errors.load(Ordering::Acquire) +} + +pub fn check_since(generation: u64) -> Result<(), String> { + check_integrity()?; + if generation == read_error_generation() { + Ok(()) + } else { + Err(last_error().unwrap_or_else(|| "scratch read failed during this operation; the result was discarded".into())) + } +} + +/// Evict down to the budget now, on this thread (tests, and before a measurement). +pub fn settle() { + let budget = M.budget.load(Ordering::Relaxed); + // Stops when nothing more can be evicted (every remaining tile is in use). + while budget > 0 && M.resident.load(Ordering::Relaxed) > budget { + if M.sweep(budget, usize::MAX) == 0 { + break; + } + } +} + +fn log_error(msg: &str) { + #[cfg(not(target_arch = "wasm32"))] + eprintln!("photocraft: {msg}"); + #[cfg(target_arch = "wasm32")] + let _ = msg; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn failed_read_retains_backing_and_blocks_commit_until_repaired() { + let dir = std::env::temp_dir().join(format!("photocraft-read-failure-{}", std::process::id())); + let file = Arc::new(ScratchFile::create(&dir).unwrap()); + let mut tile = crate::Tile { cell: Cell::new(vec![73; 1024]) }; + let generation = read_error_generation(); + assert_eq!(tile.cell.evict(&file).unwrap(), 1024); + let good = tile.cell.slot.lock().unwrap().as_ref().unwrap().1; + tile.cell.slot.lock().unwrap().as_mut().unwrap().1.crc32 ^= 1; + + assert!(tile.try_bytes().is_err()); + let failed_generation = read_error_generation(); + assert!(tile.cell.data.read().unwrap().is_none(), "placeholder must never become stored pixels"); + assert!(check_integrity().is_err()); + tile.bytes_mut()[0] = 9; + assert!(tile.cell.data.read().unwrap().is_none()); + assert!(tile.cell.slot.lock().unwrap().is_some(), "a failed mutation must preserve the original extent"); + let snapshot = tile.clone(); + assert!(Arc::ptr_eq(&tile.cell, &snapshot.cell)); + configure(None, 0).unwrap(); + assert!(check_integrity().is_err(), "changing preferences must not clear unreadable pixels"); + + tile.cell.slot.lock().unwrap().as_mut().unwrap().1 = good; + assert_eq!(&*tile.try_bytes().unwrap(), &[73; 1024]); + assert!(check_integrity().is_ok()); + assert!(check_since(generation).is_err(), "repairing or dropping a failed tile must not validate an earlier result"); + assert!(check_since(failed_generation).is_err(), "recovery must reject a computation begun during the failure"); + assert_eq!(tile.cell.evict(&file).unwrap(), 1024); + tile.cell.slot.lock().unwrap().as_mut().unwrap().1.crc32 ^= 1; + assert!(tile.try_bytes().is_err()); + let dropped_generation = read_error_generation(); + drop((snapshot, tile, file)); + assert!(check_integrity().is_ok()); + assert!(check_since(dropped_generation).is_err(), "dropping the failed tile must also reject its placeholder result"); + let _ = std::fs::remove_dir(&dir); + } +} diff --git a/crates/raster/tests/scratch_translate.rs b/crates/raster/tests/scratch_translate.rs new file mode 100644 index 0000000000..f3e9741da7 --- /dev/null +++ b/crates/raster/tests/scratch_translate.rs @@ -0,0 +1,44 @@ +//! Upstream's shared fills and pixel translations must also work with paged tile bytes. + +use photocraft_color::{PixelFormat, SampleType}; +use photocraft_geom::{Rect, TILE_SIZE, TileCoord}; +use photocraft_raster::{Surface, spill}; + +#[test] +fn translations_and_shared_fills_preserve_spilled_pixels_at_every_depth() { + let dir = std::env::temp_dir().join(format!("photocraft-translate-spill-{}", std::process::id())); + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let format = PixelFormat::RGBA8.with_sample(depth); + let tile_bytes = (TILE_SIZE * TILE_SIZE) as usize * format.bytes_per_pixel(); + spill::configure(Some(&dir), tile_bytes * 2).unwrap(); + let bounds = Rect::new(-17, -21, 281, 271); + let pixels: Vec = (0..bounds.size().area() as usize).flat_map(|i| [(i % 127) as f32 / 126.0, (i % 59) as f32 / 58.0, 0.75, 1.0]).collect(); + let mut original = Surface::new(format); + original.write_region(bounds, &pixels); + let expected = original.to_interleaved(bounds); + spill::settle(); + assert!(spill::stats().resident_bytes <= tile_bytes * 2); + let reloads = spill::stats().reloads; + for (dx, dy) in [(19, -31), (256, -512)] { + let mut moved = original.translated(dx, dy, original.content_bounds()); + assert_eq!(moved.to_interleaved(bounds.translate(dx, dy)), expected, "{depth:?} ({dx}, {dy})"); + moved.fill_rect(Rect::from_xywh(bounds.x0 + dx, bounds.y0 + dy, 4, 4), &[1.0, 0.0, 0.0, 1.0]); + spill::settle(); + assert_eq!(original.to_interleaved(bounds), expected, "translation edits changed the source"); + } + assert!(spill::stats().reloads > reloads, "translation must read paged pixels"); + let mut fill = Surface::new(format); + fill.fill_rect(Rect::new(0, 0, 1024, 1024), &[0.5, 0.25, 0.75, 1.0]); + assert!(std::sync::Arc::ptr_eq(fill.tile(TileCoord::new(0, 0)).unwrap(), fill.tile(TileCoord::new(3, 3)).unwrap())); + let snapshot = fill.clone(); + let color = snapshot.pixel(10, 10); + spill::settle(); + fill.fill_rect(Rect::from_xywh(0, 0, 4, 4), &[1.0, 0.0, 0.0, 1.0]); + assert_eq!(snapshot.pixel(1, 1), color); + assert_eq!(fill.pixel(10, 10), color); + assert_eq!(fill.pixel(1, 1), vec![1.0, 0.0, 0.0, 1.0]); + spill::check_integrity().unwrap(); + } + spill::configure(None, 0).unwrap(); + let _ = std::fs::remove_dir_all(dir); +} diff --git a/crates/raster/tests/spill.rs b/crates/raster/tests/spill.rs new file mode 100644 index 0000000000..078c1c4bf6 --- /dev/null +++ b/crates/raster/tests/spill.rs @@ -0,0 +1,149 @@ +//! Scratch disk: tiles over the budget go to disk and come back unchanged. +//! +//! One test function: the spill manager is process-wide, so its steps must not interleave. + +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_geom::{Rect, TILE_SIZE, TileCoord}; +use photocraft_raster::{Surface, spill}; + +const TILE_BYTES: usize = (TILE_SIZE * TILE_SIZE) as usize * 4; + +fn rgba8() -> PixelFormat { + PixelFormat { mode: ColorMode::Rgb, sample: SampleType::U8, alpha: true } +} + +/// Incompressible bytes, different per tile and per seed. +fn noise(seed: u32, len: usize) -> Vec { + let mut x = seed.wrapping_mul(2_654_435_761).max(1); + (0..len) + .map(|_| { + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + x as u8 + }) + .collect() +} + +/// A surface of `n × 1` tiles of noise; tile `i` holds `noise(seed + i)`. +fn surface(seed: u32, n: i32) -> Surface { + let mut s = Surface::new(rgba8()); + for i in 0..n { + let r = Rect::from_xywh(i * TILE_SIZE, 0, TILE_SIZE as u32, TILE_SIZE as u32); + s.write_interleaved(r, &noise(seed + i as u32, TILE_BYTES)); + } + s +} + +fn check(s: &Surface, seed: u32, n: i32) { + for i in 0..n { + let t = s.tile(TileCoord { tx: i, ty: 0 }).unwrap(); + assert!(*t.bytes() == *noise(seed + i as u32, TILE_BYTES), "tile {i} of seed {seed} changed"); + } +} + +#[test] +fn tiles_spill_and_come_back() { + let dir = std::env::temp_dir().join(format!("photocraft-spill-test-{}", std::process::id())); + let budget = 8 * TILE_BYTES; + spill::configure(Some(&dir), budget).unwrap(); + assert!(spill::enabled()); + + // 1. Over budget: the oldest tiles go to disk, resident memory drops to the budget. + let a = surface(1, 32); + let b = surface(1000, 32); + spill::settle(); + let st = spill::stats(); + assert!(st.resident_bytes <= budget, "resident {} > budget {budget}", st.resident_bytes); + assert!(st.evictions >= 48, "only {} evictions", st.evictions); + assert!(st.scratch_bytes > 0 && st.last_error.is_none(), "{st:?}"); + + // 2. Every pixel reads back exactly (CRC-checked, LZ4 round trip), through every reader. + check(&a, 1, 32); + check(&b, 1000, 32); + assert!(spill::stats().reloads > 0); + let mut row = vec![[0u8; 4]; TILE_SIZE as usize]; + a.read_rgba8_into(Rect::from_xywh(5 * TILE_SIZE, 7, TILE_SIZE as u32, 1), &mut row); + let want = noise(6, TILE_BYTES); + let off = 7 * TILE_SIZE as usize * 4; + assert!(row.iter().flatten().eq(want[off..off + TILE_SIZE as usize * 4].iter())); + + // Upstream's fallible readers and clones must reload evicted storage too. + spill::settle(); + let rect = Rect::from_xywh(0, 0, TILE_SIZE as u32, TILE_SIZE as u32); + assert_eq!(a.try_to_interleaved(rect).unwrap(), noise(1, TILE_BYTES)); + assert_eq!(a.try_read_region(rect).unwrap(), a.read_region(rect)); + let copied = a.tile(TileCoord::new(0, 0)).unwrap().try_clone().unwrap(); + assert_eq!(&*copied.try_bytes().unwrap(), &noise(1, TILE_BYTES)); + drop(copied); + + // 3. Copy-on-write still works on spilled tiles: the clone changes, the original doesn't. + spill::settle(); + let mut c = a.clone(); + c.fill_rect(Rect::from_xywh(0, 0, 10, 10), &[1.0, 0.0, 0.0, 1.0]); + assert_eq!(c.pixel(3, 3), vec![1.0, 0.0, 0.0, 1.0]); + check(&a, 1, 32); + spill::settle(); + // The edit survives its own eviction (the stale disk copy was released, a new one written). + assert_eq!(c.pixel(3, 3), vec![1.0, 0.0, 0.0, 1.0]); + check(&a, 1, 32); + + // 4. Parallel readers of spilled tiles all see the right bytes. + spill::settle(); + std::thread::scope(|scope| { + for k in 0..8 { + let (a, b) = (&a, &b); + scope.spawn(move || { + for round in 0..4 { + let (s, seed) = if (k + round) % 2 == 0 { (a, 1) } else { (b, 1000) }; + check(s, seed, 32); + } + }); + } + }); + + // 5. Dropping everything frees memory and scratch space. + drop((a, b, c)); + let st = spill::stats(); + assert_eq!(st.scratch_bytes, 0, "scratch extents leaked: {st:?}"); + + // 6. Off: nothing more is evicted, and the budget no longer applies. + spill::configure(None, 0).unwrap(); + assert!(!spill::enabled()); + let before = spill::stats().evictions; + let d = surface(5000, 32); + assert_eq!(spill::stats().evictions, before); + check(&d, 5000, 32); + drop(d); + + // 7. Encoded bytes and COW edits survive eviction at every supported depth. + for sample in [SampleType::U8, SampleType::U16, SampleType::F32] { + let fmt = PixelFormat { mode: ColorMode::Rgb, sample, alpha: true }; + let len = (TILE_SIZE * TILE_SIZE) as usize * fmt.bytes_per_pixel(); + spill::configure(Some(&dir), len * 2).unwrap(); + let mut s = Surface::new(fmt); + for i in 0..12 { + s.tile_mut(TileCoord::new(i, 0)).bytes_mut().copy_from_slice(&noise(8000 + i as u32, len)); + } + let original = s.clone(); + spill::settle(); + assert!(spill::stats().resident_bytes <= len * 2); + s.fill_rect(Rect::from_xywh(0, 0, 4, 4), &[1.0, 0.0, 0.0, 1.0]); + spill::settle(); + assert_eq!(s.pixel(1, 1), vec![1.0, 0.0, 0.0, 1.0]); + for i in 0..12 { + assert_eq!(&*original.tile(TileCoord::new(i, 0)).unwrap().bytes(), &noise(8000 + i as u32, len)); + } + let mut converted = [[0; 4]; 4]; + s.read_rgba8_into(Rect::from_xywh(0, 0, 4, 1), &mut converted); + assert_eq!(converted, [[255, 0, 0, 255]; 4]); + } + // A tiny budget must still allow a pinned reader to finish instead of evicting itself. + spill::configure(Some(&dir), 1).unwrap(); + let tiny = surface(9000, 2); + spill::settle(); + check(&tiny, 9000, 2); + drop(tiny); + spill::configure(None, 0).unwrap(); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index db0efe31fa..7e07c2dbca 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1635,13 +1635,13 @@ fn ensure_gpu(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, visible: /// counted once per structural change, not per brush dab) and whenever the preference changes. fn gpu_budget(app: &mut PhotocraftApp, gpu: &crate::gpu_canvas::GpuCanvas, idx: usize, partial: bool, visible: DRect) { gpu.set_focus(Some(visible)); - let allowance = u64::from(app.session.prefs().performance.memory_usage_mb).saturating_mul(1 << 20); + let allowance = u64::from(app.session.prefs().performance.gpu_memory_mb).saturating_mul(1 << 20); if partial && app.perf.gpu_budget_allowance == allowance && gpu.memory_budget().is_some() { return; } - let Some(st) = app.session.documents().get(idx) else { return }; - let pixels = st.history.pixel_bytes(&st.doc) as u64; - let budget = crate::gpu_canvas::memory_budget(allowance, pixels, crate::gpu_canvas::physical_memory()); + let _ = idx; + // Graphics resources have their own cap. Reserve space for visible output pages and staging. + let budget = allowance.saturating_sub((allowance / 4).min(128 << 20)).max(8 << 20); gpu.set_memory_budget(budget); app.perf.gpu_budget_mb = budget >> 20; app.perf.gpu_budget_allowance = allowance; diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index db908d0943..64f9c31556 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -1262,6 +1262,8 @@ pub fn inspect(app: &PhotocraftApp, ctx: &egui::Context) -> Value { dialogs.extend(crate::discard_ui::inspect(app)); json!({ "window": {"width": screen.width(), "height": screen.height(), "pixelsPerPoint": ctx.pixels_per_point()}, + "scratch": photocraft_raster::spill::stats(), + "memory": photocraft_raster::memory::stats(), "tool": app.ui.tool, "toolOptions": app.ui.tool_options, "perspectiveCrop": app.ui.perspective_crop, diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 805c20f76a..a95010b405 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3745,3 +3745,5 @@ navigator Zoom Zvětšení @plural {n} action|{n} actions {n} akce|{n} akce|{n} akcí Auto Add/Delete Automaticky přidat/odstranit ICO is limited to 256 × 256 pixels ICO je omezen na 256 × 256 pixelů + GPU memory mb Paměť GPU (MB) + Memory usage percent Využití paměti (%) diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 88ef957675..50abfac03e 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3135,3 +3135,5 @@ curves Smooth Glätten @plural {n} action|{n} actions {n} Aktion|{n} Aktionen Auto Add/Delete Autom. hinzuf./löschen ICO is limited to 256 × 256 pixels ICO ist auf 256 × 256 Pixel begrenzt + GPU memory mb GPU-Speicher (MB) + Memory usage percent Speichernutzung (%) diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index e8889313cb..d2c7c6bb4c 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3835,3 +3835,5 @@ curves Smooth Εξομάλυνση @plural {n} action|{n} actions {n} ενέργεια|{n} ενέργειες Auto Add/Delete Αυτόματη προσθήκη/διαγραφή ICO is limited to 256 × 256 pixels Το ICO περιορίζεται σε 256 × 256 εικονοστοιχεία + GPU memory mb Μνήμη GPU (MB) + Memory usage percent Χρήση μνήμης (%) diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 37f88f4294..4f7c18f908 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2822,3 +2822,5 @@ curves Smooth Suavizar @plural {n} action|{n} actions {n} acción|{n} acciones Auto Add/Delete Añadir/eliminar automáticamente ICO is limited to 256 × 256 pixels ICO está limitado a 256 × 256 píxeles + GPU memory mb Memoria GPU (MB) + Memory usage percent Uso de memoria (%) diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index f7d508fb5c..76064d7541 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3139,3 +3139,5 @@ curves Smooth Lisser @plural {n} action|{n} actions {n} action|{n} actions Auto Add/Delete Ajout/suppr. auto ICO is limited to 256 × 256 pixels Le format ICO est limité à 256 × 256 pixels + GPU memory mb Mémoire GPU (Mo) + Memory usage percent Utilisation de la mémoire (%) diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 70c2294205..3b64203ab5 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4058,3 +4058,5 @@ cameraRaw Yellow Sárga Ink Characteristics Festék jellemzői Solidity: Fedőképesség: Redrawing Újrarajzolás + GPU memory mb GPU memória MB + Memory usage percent Memóriahasználat százaléka diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index dcc02ec185..6f79f4f511 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3126,3 +3126,5 @@ curves Smooth Haluskan @plural {n} action|{n} actions {n} tindakan Auto Add/Delete Tambah/Hapus Otomatis ICO is limited to 256 × 256 pixels ICO dibatasi hingga 256 × 256 piksel + GPU memory mb Memori GPU (MB) + Memory usage percent Pemakaian memori (%) diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index c077d90aa1..0ad5aa345a 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4100,3 +4100,5 @@ navigator Zoom Zoom @plural {n} action|{n} actions {n} azione|{n} azioni Auto Add/Delete Aggiungi/elimina automaticamente ICO is limited to 256 × 256 pixels ICO è limitato a 256 × 256 pixel + GPU memory mb Memoria GPU (MB) + Memory usage percent Uso memoria (%) diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 9f074e3e30..8495dc71c7 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3815,3 +3815,5 @@ curves Smooth スムーズ Reset All Locked Settings ロックされた設定をすべて初期化 Copy Texture to Other Tools テクスチャを他のツールにコピー ICO is limited to 256 × 256 pixels ICO は 256 × 256 ピクセルまでです + GPU memory mb GPU メモリ (MB) + Memory usage percent メモリ使用率 (%) diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index eefa162a65..410ab4c31d 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3312,3 +3312,5 @@ curves Smooth 매끄럽게 @plural {n} action|{n} actions 액션 {n}개 Auto Add/Delete 자동 추가/삭제 ICO is limited to 256 × 256 pixels ICO는 256 × 256픽셀로 제한됩니다 + GPU memory mb GPU 메모리(MB) + Memory usage percent 메모리 사용 비율(%) diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 68e98a6fef..961d855004 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3897,3 +3897,5 @@ curves Smooth Vloeiend maken @plural {n} action|{n} actions {n} handeling|{n} handelingen Auto Add/Delete Automatisch toevoegen/verwijderen ICO is limited to 256 × 256 pixels ICO is beperkt tot 256 × 256 pixels + GPU memory mb GPU-geheugen (MB) + Memory usage percent Geheugengebruik (%) diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 2d76183794..dd34855304 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3735,3 +3735,5 @@ curves Smooth Wygładź @plural {n} action|{n} actions {n} operacja|{n} operacje|{n} operacji Auto Add/Delete Automatyczne dodawanie/usuwanie ICO is limited to 256 × 256 pixels ICO jest ograniczony do 256 × 256 pikseli + GPU memory mb Pamięć GPU (MB) + Memory usage percent Użycie pamięci (%) diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 0d48ba9b47..9b49500062 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3927,3 +3927,5 @@ layerLabel No Color Sem cor @plural {n} action|{n} actions {n} ação|{n} ações Auto Add/Delete Adicionar/excluir automaticamente ICO is limited to 256 × 256 pixels ICO é limitado a 256 × 256 pixels + GPU memory mb Memória da GPU (MB) + Memory usage percent Uso de memória (%) diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 11a3cb6b2e..ed611ce8e8 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3103,3 +3103,5 @@ curves Smooth Сгладить Gray Value (Median) Уровень серого (медиана) Integrated Density Интегральная плотность Length Длина + GPU memory mb Память GPU (МБ) + Memory usage percent Использование памяти (%) diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index f47fe20162..648d9ee7fc 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -4211,3 +4211,5 @@ shape Appearance Izgled Ink Characteristics Karakteristike mastila Solidity: Punoća: Redrawing Ponovno iscrtavanje + GPU memory mb GPU меморија MB + Memory usage percent Проценат употребе меморије diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 948c967467..73bb2f6a48 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3869,3 +3869,5 @@ cameraData Lens Lens Ink Characteristics Mürekkep Özellikleri Solidity: Doluluk: Redrawing Yeniden çiziliyor + GPU memory mb GPU belleği MB + Memory usage percent Bellek kullanım yüzdesi diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 2a72fdaf19..b9dbce3b57 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3836,3 +3836,5 @@ curves Smooth Згладити @plural {n} action|{n} actions {n} операція|{n} операції|{n} операцій Auto Add/Delete Автододавання/видалення ICO is limited to 256 × 256 pixels ICO обмежено розміром 256 × 256 пікселів + GPU memory mb Пам’ять GPU (МБ) + Memory usage percent Використання пам’яті (%) diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 88b855a707..63ee112d43 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3624,3 +3624,5 @@ curves Smooth Làm mượt Choose a gradient file (.grd). Chọn tệp chuyển sắc (.grd). Auto Add/Delete Tự động thêm/xóa ICO is limited to 256 × 256 pixels ICO giới hạn ở 256 × 256 pixel + GPU memory mb Bộ nhớ GPU MB + Memory usage percent Tỷ lệ sử dụng bộ nhớ diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 2842771124..fb2cf4ae92 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2747,3 +2747,5 @@ curves Smooth 平滑 @plural {n} action|{n} actions {n} 个动作 Auto Add/Delete 自动添加/删除 ICO is limited to 256 × 256 pixels ICO 最大为 256 × 256 像素 + GPU memory mb GPU 内存 (MB) + Memory usage percent 内存使用比例 (%) diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index d02e06646e..534affa728 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2732,3 +2732,5 @@ curves Smooth 平滑 @plural {n} action|{n} actions {n} 個動作 Auto Add/Delete 自動增加/刪除 ICO is limited to 256 × 256 pixels ICO 最大為 256 × 256 像素 + GPU memory mb GPU 記憶體 (MB) + Memory usage percent 記憶體使用比例 (%) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 2cd006087d..04027176b1 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1782,6 +1782,14 @@ impl eframe::App for PhotocraftApp { } fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { + photocraft_compose::discard_unreadable_pixel_caches(); + if let Err(error) = photocraft_raster::spill::check_integrity() { + self.ui.status = error; + self.ui.status_error = true; + } else if let Some(error) = photocraft_raster::spill::last_error() { + self.ui.status = error; + self.ui.status_error = true; + } let ctx = ui.ctx().clone(); self.hover_layer = None; i18n::set_current(i18n::Lang::from_pref(&self.session.prefs().interface.language)); diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index db908ae23f..c29ff03420 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -218,6 +218,7 @@ pub fn load(app: &mut PhotocraftApp) { let template = "Preferences were reset: {error}"; app.ui.status = crate::i18n::fmt(tl!(template), &[("error", &e)]); } + app.session.apply_prefs(); crate::dock::restore(app); crate::brush_picker::restore(app); crate::last_brush::restore(app); @@ -1477,7 +1478,7 @@ fn field_visible(section: &str, obj: &Map, k: &str, query: &str) (query.is_empty() || preference_matches(section, k, query)) && !drawn_elsewhere && !prefs::is_hidden(&path) - && obj.get(k).is_some_and(|v| !matches!(v, Value::Null | Value::Object(_))) + && obj.get(k).is_some_and(|v| (path == "performance.memoryUsagePercent" && v.is_null()) || !matches!(v, Value::Null | Value::Object(_))) } /// Generic editor for a section's fields: checkboxes, dropdowns for choices, colour swatches, @@ -1513,6 +1514,18 @@ fn field_rows(ui: &mut egui::Ui, section: &str, obj: &mut Map, ke let v = obj.get(&k).cloned().unwrap_or(Value::Null); let human = humanize(&k); let label = tl!(&human).to_string(); + if path == "performance.memoryUsagePercent" { + ui.label(RichText::new(&label).color(t.text_dim)); + let mut enabled = v.is_number(); + let mut percent = v.as_u64().unwrap_or(70) as u32; + ui.horizontal(|ui| { + crate::widgets::checkbox(ui, &mut enabled, ""); + ui.add_enabled(enabled, egui::DragValue::new(&mut percent).range(1..=95).suffix("%")); + }); + obj.insert(k, if enabled { json!(percent) } else { Value::Null }); + ui.end_row(); + continue; + } match &v { Value::Bool(b) => { ui.label(""); @@ -2835,10 +2848,10 @@ mod tests { let values = prefs::Preferences::default().to_json(); assert!(has_visible_fields(&values, "general")); assert!(has_visible_fields(&values, "fileHandling")); + assert!(has_visible_fields(&values, "scratchDisks")); + assert!(!prefs::is_hidden("scratchDisks.disks")); // Every setting of these sections is still unimplemented. - for section in ["integrations", "scratchDisks"] { - assert!(!has_visible_fields(&values, section), "{section}"); - } + assert!(!has_visible_fields(&values, "integrations")); // "Fill new type layers with placeholder text" and "Use Escape to Commit" are live; other Type rows stay hidden. assert!(has_visible_fields(&values, "type")); assert!(!prefs::is_hidden("type.fillNewTypeLayersWithPlaceholder")); diff --git a/docs/development.md b/docs/development.md index 17c7e67dce..fd90a1a633 100644 --- a/docs/development.md +++ b/docs/development.md @@ -95,6 +95,10 @@ If the device is lost while running (#243), every GPU entry point checks the dev ## Logs +Native pixel residency, RAM limits and scratch failure recovery are described in +[`scratch-disks.md`](scratch-disks.md). Control `ui.inspect` includes `scratch` counters and +the `memory` sample, effective allowances and current working reservations. + The desktop app writes its `log` records to standard error and to `logs/photocraft.log` in the settings directory (Linux `~/.config/photocraft/logs/`, macOS `~/Library/Application Support/Photocraft/logs/`, Windows `%APPDATA%\Photocraft\logs\`, or under `PHOTOCRAFT_CONFIG_DIR` / the portable data folder). A start launched from a desktop menu or the Dock has no terminal, so this file is what to attach to a bug report: GPU startup fallbacks, a lost device and the crash guard's panic report all land there. Each launch moves the previous log to `photocraft.1.log` (and that one to `photocraft.2.log`), so the log of a run that crashed survives the next start. The file stops growing at 16 MiB. `--version` and command-line errors write no file. By default PhotoCraft's own crates log at `info` and everything else at `warn`. `RUST_LOG` replaces that with env_logger-style directives, for example `RUST_LOG=debug`, `RUST_LOG=warn,photocraft_gpu=trace` or `RUST_LOG=info,wgpu_core=warn`; a directive ending in `*` covers every target starting with it (`photocraft*=debug`). The logger is `apps/photocraft/src/logging.rs`; the web build logs to the browser console instead. diff --git a/docs/roadmap.md b/docs/roadmap.md index bca91616c7..ccdd5b8291 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -122,3 +122,5 @@ Each milestone has a **definition of done (DoD)** and must leave `main` green on | 2026-10-10 | major | Progress-docs standard: honest assessment and dated measurements moved to `target-app-parity.md`, Affinity notes to `file-format-parity.md`; new Current focus and estimates; `parity.md` renamed `parity-checklist.md` | | 2026-10-08 | minor | Tools, Affinity import and performance measurements added | | 2026-10-05 | major | Honest parity assessment added | + +2026-10-13: native scratch storage pages current and history tiles through compressed, CRC-checked files. Fresh RAM budgets default to 70%; engine-owned memory supervision stops with its last session. Scratch errors block edit/save commits and permit retry. Unix files are private and stale files swept at startup; Windows files use delete-on-close. See [scratch disks](scratch-disks.md). Streaming import and viewport rendering follow separately. diff --git a/docs/scorecard.md b/docs/scorecard.md index 92f9315ebd..3b7b0f40e8 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -9,7 +9,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [Performance](#performance) | 3 | 22 | 22 | 56 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 17 not run, 5 not measurable yet (baseline 2026-10-05) | | [File compatibility](#file-compatibility) | 7 | 13 | 7 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 145/256, round trip ≥ 258/256 | | [Tools](#tools) | 28 | 14 | 1 | | -| [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 26 of 157 (target 0) | +| [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 25 of 159 (target 0) | | [Type](#type) | 4 | 3 | 2 | | | [Automation](#automation) | 5 | 2 | 4 | 631 of 631 menu items live (100.0%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 28 of 28 crates; panic_hunt present | @@ -176,9 +176,9 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t ## Workspace and UI -**Settings that do nothing: 26 of 157** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. +**Settings that do nothing: 25 of 159** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. -Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_windows`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `file_handling.save_in_background`, `performance.cache_levels`, `performance.legacy_compositing`, `scratch_disks.disks`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.text_engine`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `enhanced_controls.touch_gestures`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.resolution`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`, `integrations.allow_agent_control`, `integrations.control_port`. +Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_windows`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `file_handling.save_in_background`, `performance.cache_levels`, `performance.legacy_compositing`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.text_engine`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `enhanced_controls.touch_gestures`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.resolution`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`, `integrations.allow_agent_control`, `integrations.control_port`. | Id | Target | Status | Issue | Note | |---|---|---|---|---| @@ -248,7 +248,7 @@ Never-crash attribute on 28 of 28 crate roots. | REL-2 | Every workspace crate carries the never-crash deny(clippy::unwrap_used, ...) attribute | done | | Counted automatically above (crate roots under crates/ and apps/); testkit gained it in #221 | | REL-3 | The app catches escaped panics around command dispatch and import/export | done | | a_panic_in_a_command_... runs the test-only `test.panic` spec through Session::execute and asserts Err plus an unchanged document (engine jobs_tests.rs); the worker guard is covered by a_panic_in_the_worker_...; crash_guard.rs guard_turns_a_panic_into_an_error covers import/export (apps/photocraft) | | REL-4 | Atomic save (temp, fsync, rename) for every document write | done | [#203](https://github.com/storytold/photocraft/issues/203) | photocraft_format::atomic_write (format/src/atomic.rs, #230): same-dir temp, sync_all, rename with Windows PermissionDenied retry, Unix dir fsync; every document write site routes through it and format/src/atomic/tests.rs failure-injects write, rename and read-only-dir errors | -| REL-212-1 | Fallible allocation for paste/erase/fill/transform/save/import, clear out-of-memory error | partial | [#212](https://github.com/storytold/photocraft/issues/212) | Raster region/tile buffers, paste/erase/fill/transform and warp output use fallible reservations; save/import boundaries preserve existing state, but encoder/decoder internals still need a broader fallible-allocation audit | +| REL-212-1 | Fallible allocation for paste/erase/fill/transform/save/import, clear out-of-memory error | partial | [#212](https://github.com/storytold/photocraft/issues/212) | Raster region/tile buffers, paste/erase/fill/transform and warp output use fallible reservations; filter and lens working memory is admitted before edits, and current/history tiles spill to CRC-checked scratch. Save/import boundaries preserve state; remaining encoder/decoder allocations still need broader audit. | | REL-212-2 | Export switches to PSB when the estimated file size exceeds 2 GB | done | [#212](https://github.com/storytold/photocraft/issues/212) | PSD export uses checked size estimates for raster, masks, channels, and retained metadata | | REL-212-3 | Tests: an allocation-limit injection makes each command Err with the document unchanged | partial | [#212](https://github.com/storytold/photocraft/issues/212) | Engine failpoint tests cover paste, erase, fill, transform, import and save transaction boundaries; codec-level allocator failure coverage remains open | diff --git a/docs/scratch-disks.md b/docs/scratch-disks.md new file mode 100644 index 0000000000..a99cc7b30d --- /dev/null +++ b/docs/scratch-disks.md @@ -0,0 +1,39 @@ +# Scratch disks and memory residency + +Native PhotoCraft keeps pixel tiles in RAM and spills cold tiles to a configured scratch +directory. A read or write guard keeps a tile resident while a consumer uses its bytes. Undo +snapshots keep their copy-on-write tiles, including disk-backed tiles, until History States +retires them. Web builds retain their explicit memory limit and do not use a filesystem. + +New preferences use **70% of physical RAM** as the process ceiling. This is configurable from +1–95%; older preferences without `performance.memoryUsagePercent` retain their absolute +`performance.memoryUsageMb` limit. Other applications, non-tile process memory and bounded +working buffers reduce the live tile allowance. The separate `performance.gpuMemoryMb` +setting limits graphics resources. Memory virtual address-space usage is diagnostic data, +not a measurement of committed memory or a substitute for scratch storage. + +Enable a directory in Preferences → Scratch Disks. `(system temp)` selects the operating +system's temporary directory. Only the first enabled, nonempty directory is used. Changing +directories creates a new scratch file; old disk-backed tiles keep their old file alive. +The development override `PHOTOCRAFT_TILE_BUDGET_MB` fixes the tile allowance for stress tests. + +Scratch extents are LZ4 compressed, CRC-checked and reused after a tile is released. Closing +the last tile truncates its file. Windows opens files with delete-on-close so process exit, +including exit without Rust destructors, removes them. Unix creates files with mode `0600` +and sweeps `photocraft--.scratch` when configuring the directory. It preserves +live-process files, other users' files, symlinks and unrelated names; unavailable process +enumeration leaves files untouched. PID reuse conservatively retains a stale file, and a +unique launch nonce avoids a collision. Normal Unix drops remove the file immediately. + +The engine owns a shared, stoppable residency supervisor and joins it when its last session +is dropped. `photocraft-raster` only exposes synchronous measurement and bounded eviction +operations; standalone callers work without a background thread. `sysinfo` is native-only +with its default features disabled and only the `system` feature enabled. + +An unavailable or full scratch disk retains resident pixels and reports the failure. +A failed read retains its backing extent and blocks editing, cache admission and saving +until the tile is readable again. Recovery can retry the original bytes after the storage +becomes available; zeros from a failed display read must never become saved pixels. + +Scratch storage alone does not make every format, filter or renderer streamable. The native +PSD/EXR import and viewport streaming changes build on these guards and memory reservations. diff --git a/scorecard/reliability.toml b/scorecard/reliability.toml index 081af4461f..d5fc1fb5a7 100644 --- a/scorecard/reliability.toml +++ b/scorecard/reliability.toml @@ -35,7 +35,7 @@ id = "REL-212-1" target = "Fallible allocation for paste/erase/fill/transform/save/import, clear out-of-memory error" status = "partial" issue = 212 -note = "Raster region/tile buffers, paste/erase/fill/transform and warp output use fallible reservations; save/import boundaries preserve existing state, but encoder/decoder internals still need a broader fallible-allocation audit" +note = "Raster region/tile buffers, paste/erase/fill/transform and warp output use fallible reservations; filter and lens working memory is admitted before edits, and current/history tiles spill to CRC-checked scratch. Save/import boundaries preserve state; remaining encoder/decoder allocations still need broader audit." [[item]] id = "REL-212-2" From 72dd11087b81c9cac32364ee367bf71d3fd86278 Mon Sep 17 00:00:00 2001 From: vistaero Date: Sat, 10 Oct 2026 07:23:37 +0200 Subject: [PATCH 002/216] Stream viewport pages with bounded GPU staging and cancellation --- crates/compose/src/lib.rs | 1 + crates/compose/src/viewport.rs | 191 ++++++ crates/gpu/src/lib.rs | 35 +- crates/raster/src/lib.rs | 19 +- crates/raster/src/spill.rs | 35 ++ crates/ui-egui/src/background_redraw_tests.rs | 3 + crates/ui-egui/src/canvas.rs | 134 +++- crates/ui-egui/src/canvas_stream.rs | 577 ++++++++++++++++++ crates/ui-egui/src/control.rs | 1 + crates/ui-egui/src/gpu_canvas.rs | 317 +++++++++- crates/ui-egui/src/gpu_status.rs | 3 + crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/tests/canvas_16f.rs | 135 ++++ docs/roadmap.md | 2 + docs/scratch-disks.md | 4 +- docs/streaming-gpu.md | 26 + 16 files changed, 1459 insertions(+), 27 deletions(-) create mode 100644 crates/compose/src/viewport.rs create mode 100644 crates/ui-egui/src/canvas_stream.rs create mode 100644 docs/streaming-gpu.md diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index d05766bc43..88bc47c6df 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -29,6 +29,7 @@ pub mod pattern; pub mod proxy; pub mod psblend; pub mod shape_split; +pub mod viewport; use photocraft_color::blend::BlendMode; use photocraft_doc::{Document, Fill, Layer, LayerContent, Pattern}; diff --git a/crates/compose/src/viewport.rs b/crates/compose/src/viewport.rs new file mode 100644 index 0000000000..4f60ce80ed --- /dev/null +++ b/crates/compose/src/viewport.rs @@ -0,0 +1,191 @@ +//! Bounded, cancellable viewport pages. Reduction is globally aligned, so independently +//! rendered pages have exactly the same pixel footprints at their shared edges. +use crate::{Buffer, render, render_bands}; +use photocraft_doc::Document; +use photocraft_geom::Rect; +use photocraft_raster::{Interrupt, Surface}; + +/// A quick, explicitly provisional reduction. Only the requested source rows are read; the +/// exact composite below replaces it after visible pages have first become available. +pub fn preview_page(doc: &Document, source: Rect, factor: u32, interrupt: Interrupt<'_>) -> Result { + if factor <= 1 || !crate::proxy::proxy_faithful(doc) { + return render_page(doc, source, factor, interrupt); + } + let k = i32::try_from(factor).ok().filter(|k| *k > 0 && *k <= 1 << 20).ok_or("invalid viewport reduction")?; + let source = source.intersect(&doc.bounds()); + if source.is_empty() { + return Ok(Buffer::transparent(Rect::EMPTY)); + } + let rect = Rect::new( + source.x0 / k, + source.y0 / k, + source.x1.div_euclid(k) + i32::from(source.x1.rem_euclid(k) != 0), + source.y1.div_euclid(k) + i32::from(source.y1.rem_euclid(k) != 0), + ); + if rect.width() > 1024 || rect.height() > 1024 { + return Err("viewport page is too large".into()); + } + fn shrink(s: &Surface, r: Rect, k: i32, i: Interrupt<'_>) -> Result { + let mut out = Surface::with_default(s.format(), &s.default_pixel()); + let bpp = s.format().bytes_per_pixel(); + for y in r.y0..r.y1 { + i.check().map_err(|e| e.to_string())?; + let sy = y.saturating_mul(k).saturating_add(k / 2); + let row = s.to_interleaved(Rect::new(r.x0.saturating_mul(k), sy, r.x1.saturating_mul(k), sy.saturating_add(1))); + let mut reduced = Vec::with_capacity(r.width() as usize * bpp); + for x in 0..r.width() as usize { + let offset = (x * k as usize + k as usize / 2) * bpp; + reduced.extend_from_slice(row.get(offset..offset + bpp).ok_or("invalid viewport sample")?); + } + out.write_interleaved(Rect::new(r.x0, y, r.x1, y + 1), &reduced); + } + Ok(out) + } + fn layer(l: &mut photocraft_doc::Layer, r: Rect, k: i32, i: Interrupt<'_>, depth: usize) -> Result<(), String> { + if depth > 256 { + return Err("viewport layer nesting limit exceeded".into()); + } + if let Some(m) = &mut l.mask { + m.surface = shrink(&m.surface, r, k, i)?; + } + if let Some(c) = &mut l.fill_cache { + c.surface = shrink(&c.surface, r, k, i)?; + } + use photocraft_doc::LayerContent; + match &mut l.content { + LayerContent::Raster(s) => *s = shrink(s, r, k, i)?, + LayerContent::Group(g) => { + if let Some(a) = &mut g.artboard { + a.rect = Rect::new(a.rect.x0.div_euclid(k), a.rect.y0.div_euclid(k), a.rect.x1.div_euclid(k), a.rect.y1.div_euclid(k)); + } + for c in &mut g.children { + layer(c, r, k, i, depth + 1)?; + } + } + LayerContent::Text(t) => { + if let Some(s) = &mut t.cache { + *s = shrink(s, r, k, i)?; + } + } + LayerContent::Shape(t) => { + if let Some(s) = &mut t.cache { + *s = shrink(s, r, k, i)?; + } + } + LayerContent::Smart(t) => { + if let Some(s) = &mut t.cache { + *s = shrink(s, r, k, i)?; + } + } + _ => {} + } + Ok(()) + } + let mut p = doc.clone(); + p.size = photocraft_doc::Size::new(doc.size.width.div_ceil(factor), doc.size.height.div_ceil(factor)); + for l in &mut p.layers { + layer(l, rect, k, interrupt, 0)?; + } + for c in &mut p.channels { + c.surface = shrink(&c.surface, rect, k, interrupt)?; + } + interrupt.check().map_err(|e| e.to_string())?; + Ok(render(&p, rect)) +} + +pub fn render_page(doc: &Document, source: Rect, factor: u32, interrupt: Interrupt<'_>) -> Result { + interrupt.check().map_err(|e| e.to_string())?; + let source = source.intersect(&doc.bounds()); + if source.is_empty() { + return Ok(Buffer::transparent(Rect::EMPTY)); + } + let k = i32::try_from(factor).ok().filter(|k| *k > 0 && *k <= 1 << 20).ok_or("invalid viewport reduction")?; + let out = Rect::new( + source.x0.div_euclid(k), + source.y0.div_euclid(k), + source.x1.div_euclid(k) + i32::from(source.x1.rem_euclid(k) != 0), + source.y1.div_euclid(k) + i32::from(source.y1.rem_euclid(k) != 0), + ); + if out.width() > 1024 || out.height() > 1024 { + return Err("viewport page is too large".into()); + } + if k == 1 { + return Ok(render(doc, source)); + } + let mut result = Buffer::transparent(out); + render_bands(doc, source, 128, |band| -> Result<(), String> { + interrupt.check().map_err(|e| e.to_string())?; + for (i, p) in band.px.iter().enumerate() { + let x = band.rect.x0 + (i % band.rect.width() as usize) as i32; + let y = band.rect.y0 + (i / band.rect.width() as usize) as i32; + let offset = (y.div_euclid(k) - out.y0) as usize * out.width() as usize + (x.div_euclid(k) - out.x0) as usize; + let q = result.px.get_mut(offset).ok_or("invalid viewport accumulator")?; + for (a, b) in q.iter_mut().take(3).zip(p.iter()) { + *a += *b * p.get(3).copied().unwrap_or(0.0); + } + if let Some(a) = q.get_mut(3) { + *a += p.get(3).copied().unwrap_or(0.0); + } + } + Ok(()) + })?; + for (i, p) in result.px.iter_mut().enumerate() { + let x = out.x0 + (i % out.width() as usize) as i32; + let y = out.y0 + (i / out.width() as usize) as i32; + let footprint = Rect::from_xywh(x.saturating_mul(k), y.saturating_mul(k), factor, factor).intersect(&source); + let alpha = p.get(3).copied().unwrap_or(0.0); + if alpha > 0.0 { + for c in p.iter_mut().take(3) { + *c /= alpha; + } + if let Some(a) = p.get_mut(3) { + *a /= (u64::from(footprint.width()) * u64::from(footprint.height())).max(1) as f32; + } + } + } + interrupt.check().map_err(|e| e.to_string())?; + Ok(result) +} + +#[cfg(test)] +mod tests { + use super::*; + use photocraft_color::{Color, ColorMode, SampleType}; + use photocraft_doc::Size; + #[test] + fn high_zoom_is_original_resolution_at_every_depth() { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let r = Rect::new(65001, 22003, 65513, 22515); + let mut d = Document::new("large", Size::new(86400, 43200), ColorMode::Rgb, depth); + let mut s = photocraft_raster::Surface::new(d.pixel_format()); + s.fill_rect(r, &[1.0; 4]); + d.layers.push(photocraft_doc::Layer::new("region", photocraft_doc::LayerContent::Raster(s))); + let p = render_page(&d, r, 1, Interrupt::NONE).unwrap(); + assert_eq!(p.rect, r); + assert_eq!(p.px, render(&d, r).px); + } + } + #[test] + fn independent_pages_match_whole_reduction_and_odd_edges() { + let d = Document::with_background("d", Size::new(53, 31), ColorMode::Rgb, SampleType::U8, Color::WHITE); + let whole = render_page(&d, d.bounds(), 4, Interrupt::NONE).unwrap(); + for r in [Rect::new(0, 0, 28, 31), Rect::new(28, 0, 53, 31)] { + let p = render_page(&d, r, 4, Interrupt::NONE).unwrap(); + for y in p.rect.y0..p.rect.y1 { + for x in p.rect.x0..p.rect.x1 { + assert_eq!( + p.px[((y - p.rect.y0) * p.rect.width() as i32 + x - p.rect.x0) as usize], + whole.px[(y * whole.rect.width() as i32 + x) as usize] + ); + } + } + } + } + #[test] + fn rejects_invalid_sizes_and_honors_cancellation() { + let d = Document::new("d", Size::new(86400, 43200), ColorMode::Rgb, SampleType::U8); + assert!(render_page(&d, d.bounds(), 1, Interrupt::NONE).is_err()); + assert!(render_page(&d, d.bounds(), 0, Interrupt::NONE).is_err()); + assert!(render_page(&d, Rect::new(0, 0, 512, 512), 1, Interrupt::cancel_only(&|| true)).is_err()); + } +} diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index cedaf77cd8..5a5e9f883a 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -76,8 +76,9 @@ pub const PAGE: u32 = 2048; /// GPU memory resident layer pages may hold before the least recently used are evicted. pub const RESIDENT_BUDGET: u64 = 4 << 30; /// Uploads (bytes) [`Compositor::render`] stages before submitting the work recorded so far, so -/// a refresh of a huge document never holds all its uploads in staging memory at once. -const FLUSH_BYTES: u64 = 512 << 20; +/// mapped buffers stay bounded separately from VRAM residency: some discrete GPUs have a +/// small host-visible heap. Two batches can be in flight; one RGBA16F page is already 32 MiB. +const FLUSH_BYTES: u64 = 32 << 20; /// Pass pixels (chunk pixels × passes) [`Compositor::render`] records before submitting: one /// command buffer holding a whole huge refresh can run for seconds, long enough for the OS to /// reset the GPU. @@ -909,7 +910,9 @@ impl Compositor { /// still open; the chunk texture is reused afterwards, so the sink must record any copies /// or passes that read it into the given encoder. Submits the work before returning; a /// refresh of a huge document also submits in between (the sink may be handed a new - /// encoder), so its uploads and evicted pages never pile up in memory. + /// encoder), so its uploads and evicted pages never pile up in memory. Native renders that + /// uploaded data drain their final batch before returning: consecutive regional renders + /// must share the same bound as one large refresh. Cached renders remain asynchronous. pub fn render( &mut self, device: &wgpu::Device, @@ -921,7 +924,13 @@ impl Compositor { let result = scoped_work(device, self.health.is_some(), || { let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("pc_compose") }); let stats = self.encode_inner(device, queue, &mut encoder, doc, region, &mut sink, true)?; - queue.submit([encoder.finish()]); + let index = queue.submit([encoder.finish()]); + #[cfg(not(target_arch = "wasm32"))] + if self.staged > 0 || stats.flushes > 0 { + self.wait_uploads(device, index)?; + } + #[cfg(target_arch = "wasm32")] + let _ = index; Ok(stats) }); if result.is_err() { @@ -930,6 +939,24 @@ impl Compositor { result } + #[cfg(not(target_arch = "wasm32"))] + fn wait_uploads(&self, device: &wgpu::Device, index: wgpu::SubmissionIndex) -> Result<(), Unsupported> { + if let Some(health) = &self.health { + if !health.wait(device, Some(index)) { + return Err(Unsupported(health.fault().map_or_else(|| "GPU upload failed".into(), |f| f.to_string()))); + } + } else { + let result = + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| device.poll(wgpu::PollType::Wait { submission_index: Some(index), timeout: None }))); + match result { + Ok(Ok(_)) => {} + Ok(Err(e)) => return Err(Unsupported(format!("GPU upload wait failed: {e}"))), + Err(_) => return Err(Unsupported("the GPU stopped responding during upload".into())), + } + } + Ok(()) + } + /// Like [`Self::render`] but records into `encoder` without submitting. pub fn encode( &mut self, diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs index 2019cd7773..d0154ac7a1 100644 --- a/crates/raster/src/lib.rs +++ b/crates/raster/src/lib.rs @@ -127,6 +127,11 @@ impl Tile { } (x0 != usize::MAX).then_some((x0 as u16, y0 as u16, x1 as u16, y1 as u16)) } + + /// Nonblocking residency check; use [`spill::try_resident_scope`] to keep it stable. + pub fn is_resident(&self) -> bool { + self.cell.is_resident() + } /// Byte length, without touching the pixels (they may be on disk). pub fn len(&self) -> usize { self.cell.len() @@ -184,8 +189,6 @@ impl DerefMut for TileBytesMut<'_> { fn deref_mut(&mut self) -> &mut [u8] { self.0.as_deref_mut().unwrap_or(&mut self.1) } - - } #[derive(Clone, Debug)] @@ -227,11 +230,13 @@ impl UniformTiles { } let bytes = t.try_bytes().ok()?; let mut px = bytes.chunks_exact(bpp); - let k = px.next().filter(|p0| px.all(|p| p == *p0)).map(|p0| match self.canonical.iter().position(|c| c.try_bytes().is_ok_and(|bytes| bytes.get(..bpp) == Some(p0))) { - Some(k) => k, - None => { - self.canonical.push(t.clone()); - self.canonical.len() - 1 + let k = px.next().filter(|p0| px.all(|p| p == *p0)).map(|p0| { + match self.canonical.iter().position(|c| c.try_bytes().is_ok_and(|bytes| bytes.get(..bpp) == Some(p0))) { + Some(k) => k, + None => { + self.canonical.push(t.clone()); + self.canonical.len() - 1 + } } }); self.of.insert(Arc::as_ptr(t), k); diff --git a/crates/raster/src/spill.rs b/crates/raster/src/spill.rs index 6b6ba3c00c..9616a0ad34 100644 --- a/crates/raster/src/spill.rs +++ b/crates/raster/src/spill.rs @@ -57,6 +57,10 @@ impl Cell { self.error.lock().unwrap_or_else(PoisonError::into_inner).clone() } + pub(crate) fn is_resident(&self) -> bool { + self.data.try_read().is_ok_and(|data| data.is_some()) + } + fn set_error(&self, error: Option) { let mut old = self.error.lock().unwrap_or_else(PoisonError::into_inner); match (old.is_some(), error.is_some()) { @@ -163,6 +167,7 @@ impl Cell { /// Move this tile to disk if nobody is using it. Returns the bytes freed. fn evict(&self, file: &Arc) -> Result { + let Ok(_residency) = RESIDENCY_BARRIER.try_lock() else { return Ok(0) }; let Ok(mut w) = self.data.try_write() else { return Ok(0) }; let Some(bytes) = w.as_ref() else { return Ok(0) }; let mut slot = self.slot.lock().unwrap_or_else(PoisonError::into_inner); @@ -242,6 +247,14 @@ static M: Manager = Manager { written: AtomicU64::new(0), }; +static RESIDENCY_BARRIER: Mutex<()> = Mutex::new(()); + +/// A short nonblocking barrier for consumers that must never fault disk pixels on the UI +/// thread. After checking all their input tiles, eviction cannot invalidate that check. +pub fn try_resident_scope() -> Option> { + RESIDENCY_BARRIER.try_lock().ok() +} + impl Manager { fn register(&self, cell: &Arc) { let mut r = self.registry.lock().unwrap_or_else(PoisonError::into_inner); @@ -523,9 +536,31 @@ fn log_error(msg: &str) { #[cfg(test)] mod tests { use super::*; + // These tests exercise the same process-wide eviction barrier and integrity counters. + static EVICTION_TESTS: Mutex<()> = Mutex::new(()); + + #[test] + fn resident_scope_prevents_eviction_and_check_never_reloads() { + let _test = EVICTION_TESTS.lock().unwrap(); + let dir = std::env::temp_dir().join(format!("pc-resident-scope-{}", std::process::id())); + let file = Arc::new(ScratchFile::create(&dir).unwrap()); + let cell = Cell::new(vec![23; 1024]); + let scope = try_resident_scope().unwrap(); + assert!(cell.is_resident()); + assert_eq!(cell.evict(&file).unwrap(), 0); + assert!(try_resident_scope().is_none(), "busy must not block the UI"); + drop(scope); + assert_eq!(cell.evict(&file).unwrap(), 1024); + let reloads = stats().reloads; + assert!(!cell.is_resident()); + assert_eq!(stats().reloads, reloads); + drop((cell, file)); + let _ = std::fs::remove_dir(dir); + } #[test] fn failed_read_retains_backing_and_blocks_commit_until_repaired() { + let _test = EVICTION_TESTS.lock().unwrap(); let dir = std::env::temp_dir().join(format!("photocraft-read-failure-{}", std::process::id())); let file = Arc::new(ScratchFile::create(&dir).unwrap()); let mut tile = crate::Tile { cell: Cell::new(vec![73; 1024]) }; diff --git a/crates/ui-egui/src/background_redraw_tests.rs b/crates/ui-egui/src/background_redraw_tests.rs index e054ffe2a6..89f1c0265b 100644 --- a/crates/ui-egui/src/background_redraw_tests.rs +++ b/crates/ui-egui/src/background_redraw_tests.rs @@ -49,6 +49,9 @@ fn gpu_app() -> Option { app.set_wgpu(rs.clone()); } app.background_jobs = true; + // These tests exercise full-texture redraws, not viewport streaming. Admit their + // 12 MP fixtures explicitly; small budgets correctly select the page renderer. + app.run("prefs.set", json!({"path": "performance.gpuMemoryMb", "value": 4096})).expect("GPU budget"); app }) })); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 7e07c2dbca..a1b3f4d388 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1277,6 +1277,26 @@ pub fn navigator_waits_for_gpu(app: &PhotocraftApp, idx: usize, ctx: &egui::Cont } pub fn navigator_texture(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize) -> Option { + if app.session.documents().get(idx)?.doc.size.area() > 16 << 20 { + let revision = app.session.documents().get(idx)?.revision; + let (doc, preview) = display_doc(app, idx); + let (display, display_key) = canvas_display(app, &doc, None); + let k = doc.size.width.max(doc.size.height).div_ceil(512).next_power_of_two(); + let key = crate::canvas_stream::PageKey { + doc: doc.id.0, + snapshot: std::sync::Arc::as_ptr(&doc) as usize as u64, + revision, + preview, + display: display_key, + factor: k, + x: 0, + y: 0, + }; + let s = app.streams.entry((doc.id, egui::Id::new("stream-navigator").value())).or_default(); + s.update(ctx, doc.clone(), &[key], display, crate::canvas_stream::StreamOptions { limit: 32 << 20, gpu: None, refine: false }); + s.upload(ctx, &doc, &[key], None, 8 << 20, false); + return s.texture(&key); + } if app.gpu.is_none() { return ensure_texture(app, ctx, idx, app.session.color.main_display).map(|(t, _)| t); } @@ -1641,7 +1661,12 @@ fn gpu_budget(app: &mut PhotocraftApp, gpu: &crate::gpu_canvas::GpuCanvas, idx: } let _ = idx; // Graphics resources have their own cap. Reserve space for visible output pages and staging. - let budget = allowance.saturating_sub((allowance / 4).min(128 << 20)).max(8 << 20); + let output = (allowance / 8).clamp(8 << 20, 256 << 20); + gpu.set_output_budget(output); + gpu.set_canvas_budget(output); + let staging = (allowance / 16).max(4 << 20); + let budget = allowance.saturating_sub(output).saturating_sub(staging).saturating_mul(4) / 5; + let budget = budget.max(8 << 20); gpu.set_memory_budget(budget); app.perf.gpu_budget_mb = budget >> 20; app.perf.gpu_budget_allowance = allowance; @@ -1940,11 +1965,16 @@ pub(crate) fn retain_gpu_documents(app: &mut PhotocraftApp) { let mut live: Vec = app.session.documents().iter().flat_map(|st| [st.doc.id.0, st.doc.id.0 ^ (1u64 << 61), st.doc.id.0 ^ (1u64 << 62)]).collect(); live.extend(crate::adjust_preview::gpu_keys(app)); gpu.retain(&live); + for st in app.session.documents() { + let keys: Vec = app.streams.iter().filter(|((id, _), _)| *id == st.doc.id).flat_map(|(_, s)| s.gpu_keys()).collect(); + gpu.retain_pages(st.doc.id.0, &keys); + } } crate::background_redraw::retain(app); // Upload markers must not outlive the resources they describe: native reopen can reuse // both the document ID and revision before the next frame while a preview dialog stays open. let documents = app.session.documents(); + app.streams.retain(|(id, _), _| documents.iter().any(|d| d.doc.id == *id)); if app.filter_preview.as_ref().is_some_and(|preview| !documents.iter().any(|st| st.doc.id == preview.key.doc)) { app.filter_preview = None; } @@ -2901,7 +2931,75 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: // A flipped or pixel-aspect-corrected view draws through the CPU path (the GPU canvas shader // has no mirroring or non-square pixels). Rotation stays on the GPU: it is a view uniform. let gpu_ok = crate::gpu_canvas::gpu_view_ok(flip, view.rotation, aspect); - if app.gpu.is_some() + let filter_proxy = ensure_filter_preview(app, idx, &ctx); + if filter_proxy.is_none() + && !app.ui.view.pattern_preview + && (doc.size.area() > 16 << 20 || doc.size.area().saturating_mul(16) > (u64::from(app.session.prefs().performance.gpu_memory_mb) << 20) / 8) + { + let revision = app.session.documents().get(idx).map_or(0, |s| s.revision); + let (render_doc, preview) = display_doc(app, idx); + let (display, display_key) = canvas_display(app, &render_doc, output); + let wanted = crate::canvas_stream::pages(&render_doc, visible_doc_rect(&xf), view.zoom, revision, preview, display_key); + let gpu = app.gpu.clone().filter(|_| gpu_ok); + let allowance = u64::from(app.session.prefs().performance.gpu_memory_mb) << 20; + let output_limit = (allowance / 8).clamp(8 << 20, 256 << 20); + if let Some(g) = &gpu { + gpu_budget(app, g, idx, true, visible_doc_rect(&xf)); + } + // Tab indices move when documents close or reorder; the view belongs to the document. + let stream_key = (doc.id, egui::Id::new(("stream-canvas", ctx.viewport_id())).value()); + let count = (app.streams.len() + usize::from(!app.streams.contains_key(&stream_key))).max(1) as u64; + let s = app.streams.entry(stream_key).or_default(); + let encode_srgb = display.as_ref().is_some_and(|d| d.encode_srgb); + if let Some(target) = wanted.first() + && let Some(damage) = app.session.documents().get(idx).and_then(|s| s.last_damage) + { + let damage = if damage.is_empty() { damage } else { damage.inflate(effect_reach(&render_doc.layers)) }; + s.rebase_damage(&render_doc, *target, damage, gpu.as_ref()); + } + s.update( + &ctx, + render_doc.clone(), + &wanted, + display, + crate::canvas_stream::StreamOptions { limit: (256 << 20) / count, gpu: gpu.as_ref(), refine: true }, + ); + s.upload(&ctx, &render_doc, &wanted, gpu.as_ref(), output_limit, encode_srgb); + if let Some(g) = &gpu { + on_gpu = true; + app.perf.gpu = true; + let params = crate::gpu_canvas::ViewParams { + doc: doc.id.0, + doc_size: size, + zoom: point_zoom, + center: view.center, + rotation: if view.rotation.is_finite() { view.rotation.to_radians() } else { 0.0 }, + shadow: drop_shadow, + pixel_grid, + view_key: egui::Id::new(("pc-stream-canvas", ctx.viewport_id(), idx)).value(), + display: sync_display_lut(app, &doc, doc.id.0, output), + output: output.unwrap_or(0), + hdr: hdr_preview(app, &doc), + offset: [0.0, 0.0], + }; + let _ = g; + crate::gpu_canvas::GpuCanvas::paint(&painter, rect, params); + } else { + if drop_shadow { + painter.add(egui::Shadow { offset: [0, 8], blur: 28, spread: 0, color: Color32::from_black_alpha(150) }.as_shape(img_rect, 0)); + } + if let Some(square) = app.session.prefs().transparency_and_gamut.square() { + let id = checker(app, &ctx); + let tiles = img_rect.size() / (2.0 * square); + painter.image(id, img_rect, Rect::from_min_max(Pos2::ZERO, pos2(tiles.x, tiles.y)), Color32::WHITE); + } else { + painter.rect_filled(img_rect, 0.0, Color32::WHITE); + } + if let Some(s) = app.streams.get(&stream_key) { + s.paint_cpu(&painter, &xf, &doc, &wanted); + } + } + } else if app.gpu.is_some() && gpu_ok && let Some((k, key, preview_size)) = ensure_adjust_proxy(app, idx, view.zoom).or_else(|| ensure_filter_preview(app, idx, &ctx)).or_else(|| ensure_proxy_preview(app, idx)) @@ -5941,6 +6039,38 @@ mod tests { use super::*; + #[test] + fn closing_earlier_tabs_keeps_the_surviving_document_stream() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + for name in ["closed", "survivor"] { + app.session.add_document( + Document::new(name, photocraft_doc::Size::new(8192, 4096), photocraft_color::ColorMode::Rgb, photocraft_color::SampleType::F32), + None, + ); + } + app.sync_views(); + let ctx = egui::Context::default(); + PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::default()); + let draw = |app: &mut PhotocraftApp| { + let input = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1000.0, 700.0))), ..Default::default() }; + let mut output = ctx.run_ui(input, |ui| { + egui::CentralPanel::default().show(ui, |ui| document_area(app, ui)); + }); + output.textures_delta.clear(); + }; + draw(&mut app); + assert_eq!(app.streams.len(), 1); + let key = *app.streams.keys().next().unwrap(); + let before = app.streams[&key].stats.cache_limit; + app.run("file.close", json!({"document": 0})).unwrap(); + draw(&mut app); + assert_eq!(app.streams.len(), 1, "the surviving view must not duplicate its cache after its tab index changes"); + assert!(app.streams.contains_key(&key)); + assert_eq!(app.streams[&key].stats.cache_limit, before); + app.run("file.close", json!({})).unwrap(); + assert!(app.streams.is_empty()); + } + /// #569: displays 1 (sRGB) and 4 (Display P3) side by side, and a document filled with an /// sRGB colour. fn app_on_two_displays() -> PhotocraftApp { diff --git a/crates/ui-egui/src/canvas_stream.rs b/crates/ui-egui/src/canvas_stream.rs new file mode 100644 index 0000000000..b69adc8152 --- /dev/null +++ b/crates/ui-egui/src/canvas_stream.rs @@ -0,0 +1,577 @@ +//! Viewport residency with bounded background work. A cached page never represents the +//! document's storage: zoom, device loss and eviction cannot change original pixels. +use crate::gpu_canvas::GpuCanvas; +use egui::{Color32, TextureHandle, TextureOptions}; +use photocraft_doc::Document; +use photocraft_engine::display_color::CanvasDisplay; +use photocraft_geom::Rect; +use std::collections::{HashMap, HashSet}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Arc, mpsc}; + +pub const PAGE_SIDE: u32 = 512; +const MAX_PAGES: usize = 512; +const MAX_JOBS: usize = 2; +// RGBA F32 plus its display image, including the one-pixel apron on each side. +const PAGE_BYTES: u64 = (PAGE_SIDE as u64 + 2) * (PAGE_SIDE as u64 + 2) * 20; +static ACTIVE: AtomicUsize = AtomicUsize::new(0); +struct WorkerSlot; +impl Drop for WorkerSlot { + fn drop(&mut self) { + ACTIVE.fetch_sub(1, Ordering::AcqRel); + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct PageKey { + pub doc: u64, + pub snapshot: u64, + pub revision: u64, + pub preview: u64, + pub display: u64, + pub factor: u32, + pub x: u32, + pub y: u32, +} + +impl PageKey { + pub fn id(self) -> u64 { + egui::Id::new(self).value() + } + pub fn core(self, doc: &Document) -> Rect { + let side = PAGE_SIDE.saturating_mul(self.factor); + let Some(x) = self.x.checked_mul(side).and_then(|x| i32::try_from(x).ok()) else { return Rect::EMPTY }; + let Some(y) = self.y.checked_mul(side).and_then(|y| i32::try_from(y).ok()) else { return Rect::EMPTY }; + if self.factor == 0 || self.factor > 1 << 16 { + return Rect::EMPTY; + } + Rect::from_xywh(x, y, side, side).intersect(&doc.bounds()) + } + pub fn source(self, doc: &Document) -> Rect { + self.core(doc).inflate(self.factor as i32).intersect(&doc.bounds()) + } +} + +pub fn factor(zoom: f32) -> u32 { + if !zoom.is_finite() || zoom <= 0.0 { + return 1; + } + let mut k = 1u32; + while k < 1 << 16 && zoom * (k * 2) as f32 <= 1.0 { + k *= 2; + } + k +} + +pub fn pages(doc: &Document, visible: Rect, zoom: f32, revision: u64, preview: u64, display: u64) -> Vec { + let visible = visible.intersect(&doc.bounds()); + if visible.is_empty() { + return Vec::new(); + } + let mut k = factor(zoom); + loop { + let side = PAGE_SIDE.saturating_mul(k); + let (x0, x1, y0, y1) = (visible.x0 as u32 / side, (visible.x1 as u32).div_ceil(side), visible.y0 as u32 / side, (visible.y1 as u32).div_ceil(side)); + if u64::from(x1 - x0) * u64::from(y1 - y0) <= MAX_PAGES as u64 { + let mut out = Vec::new(); + for y in y0..y1 { + for x in x0..x1 { + out.push(PageKey { doc: doc.id.0, snapshot: doc as *const Document as usize as u64, revision, preview, display, factor: k, x, y }); + } + } + let (cx, cy) = (i64::from(x0 + x1), i64::from(y0 + y1)); + out.sort_by_key(|p| (i64::from(p.x) * 2 - cx).abs() + (i64::from(p.y) * 2 - cy).abs()); + return out; + } + if k >= 1 << 16 { + return Vec::new(); + } + k *= 2; + } +} + +struct Page { + buffer: Option, + rect: Rect, + image: egui::ColorImage, + texture: Option, + gpu: bool, + exact: bool, + touched: u64, +} +impl Page { + fn bytes(&self) -> u64 { + self.image.pixels.len() as u64 * 4 + self.buffer.as_ref().map_or(0, |b| b.px.len() as u64 * 16) + } +} +struct Completed { + key: PageKey, + exact: bool, + result: Result<(photocraft_compose::Buffer, egui::ColorImage), String>, +} + +#[derive(Default, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Stats { + pub resident_pages: usize, + pub resident_bytes: u64, + pub active_jobs: usize, + pub uploads: u64, + pub cancellations: u64, + pub gpu_composites: u64, + pub last_error: Option, + pub visible_pages: usize, + pub ready_pages: usize, + pub pending_bytes: u64, + pub cache_limit: u64, +} + +pub struct Stream { + cache: HashMap, + jobs: HashMap>, + tx: mpsc::SyncSender, + rx: mpsc::Receiver, + clock: u64, + failed: HashSet, + health_generation: u64, + memory_generation: u64, + pub stats: Stats, +} +impl Default for Stream { + fn default() -> Self { + let (tx, rx) = mpsc::sync_channel(MAX_JOBS); + Self { + cache: HashMap::new(), + jobs: HashMap::new(), + tx, + rx, + clock: 0, + failed: HashSet::new(), + health_generation: 0, + memory_generation: 0, + stats: Stats::default(), + } + } +} +impl Drop for Stream { + fn drop(&mut self) { + for c in self.jobs.values() { + c.store(true, Ordering::Relaxed); + } + } +} + +fn compute( + doc: &Document, + key: PageKey, + display: Option<&CanvasDisplay>, + exact: bool, + cancel: &AtomicBool, +) -> Result<(photocraft_compose::Buffer, egui::ColorImage), String> { + let source = key.source(doc); + let working = u64::from(source.width()) * u64::from(source.height().min(128)) * 16 + (16 << 20); + let _working = photocraft_raster::memory::try_reserve(working)?; + let before = photocraft_raster::spill::read_error_generation(); + let check = || cancel.load(Ordering::Relaxed); + let i = photocraft_raster::Interrupt::cancel_only(&check); + let buf = if exact { + photocraft_compose::viewport::render_page(doc, key.source(doc), key.factor, i)? + } else { + photocraft_compose::viewport::preview_page(doc, key.source(doc), key.factor, i)? + }; + i.check().map_err(|e| e.to_string())?; + photocraft_raster::spill::check_since(before)?; + let img = display.map_or_else(|| buf.to_rgba8(), |d| d.to_rgba8(&buf)); + let image = egui::ColorImage::from_rgba_unmultiplied([img.width as usize, img.height as usize], &img.pixels); + let encoded = display.map_or_else(|| buf.clone(), |d| d.texture_buffer(&buf).into_owned()); + Ok((encoded, image)) +} + +impl Stream { + /// Carry unaffected derived pages across a single local edit. Jobs still in flight retain + /// their old identity and are cancelled by update; no stale worker result is rebased. + pub fn rebase_damage(&mut self, doc: &Document, target: PageKey, damage: Rect, gpu: Option<&GpuCanvas>) { + if target.preview != 0 { + return; + } + let keys: Vec<_> = self + .cache + .keys() + .copied() + .filter(|k| { + k.doc == target.doc + && k.revision.checked_add(1) == Some(target.revision) + && k.preview == 0 + && k.display == target.display + && k.source(doc).intersect(&damage).is_empty() + }) + .collect(); + for key in keys { + let new = PageKey { snapshot: target.snapshot, revision: target.revision, ..key }; + if let Some(mut p) = self.cache.remove(&key) { + if p.gpu { + p.gpu = gpu.is_some_and(|g| g.rename_page(key.doc, key.id(), new.id())); + } + self.cache.insert(new, p); + } + } + } + pub fn gpu_keys(&self) -> Vec { + self.cache.keys().map(|k| k.id()).collect() + } + pub fn texture(&self, key: &PageKey) -> Option { + self.cache.get(key)?.texture.as_ref().map(TextureHandle::id) + } + pub fn reset_gpu(&mut self) { + for p in self.cache.values_mut() { + p.gpu = false; + } + } + pub fn update(&mut self, ctx: &egui::Context, doc: Arc, wanted: &[PageKey], display: Option>, options: StreamOptions<'_>) { + let StreamOptions { limit, gpu, refine } = options; + self.clock = self.clock.wrapping_add(1); + let generation = photocraft_raster::spill::read_error_generation(); + let memory_generation = photocraft_raster::memory::configuration_generation(); + if generation != self.health_generation || memory_generation != self.memory_generation { + self.failed.clear(); + self.health_generation = generation; + self.memory_generation = memory_generation; + } + let desired: HashSet = wanted.iter().copied().collect(); + // Keep cancelled workers in the job count until they exit, so rapid pans cannot spawn + // unbounded threads or retain unbounded document snapshots. + for (key, cancel) in &self.jobs { + if !desired.contains(key) && !cancel.swap(true, Ordering::Relaxed) { + self.stats.cancellations += 1; + } + } + while let Ok(c) = self.rx.try_recv() { + let cancelled = self.jobs.remove(&c.key).is_some_and(|c| c.load(Ordering::Relaxed)); + if cancelled || !desired.contains(&c.key) { + continue; + } + match c.result { + Ok((buffer, image)) => { + let rect = buffer.rect; + self.cache.insert(c.key, Page { buffer: Some(buffer), rect, image, texture: None, gpu: false, exact: c.exact, touched: self.clock }); + self.stats.last_error = None; + } + Err(e) => { + self.failed.insert(c.key); + self.stats.last_error = Some(e); + } + } + } + if let Some(g) = gpu { + g.touch_pages(doc.id.0, &wanted.iter().map(|k| k.id()).collect::>()); + self.cache.retain(|k, p| !(desired.contains(k) && p.gpu && !g.has_page(k.doc, k.id()))); + } + // Revision changes invalidate derived pages; master tiles and History remain untouched. + self.cache.retain(|k, _| { + k.doc != doc.id.0 + || wanted.first().is_some_and(|w| (k.snapshot, k.revision, k.preview, k.display) == (w.snapshot, w.revision, w.preview, w.display)) + }); + for key in wanted { + if let Some(p) = self.cache.get_mut(key) { + p.touched = self.clock; + } + } + let mut bytes: u64 = self.cache.values().map(Page::bytes).sum(); + let mut pending = self.jobs.len() as u64 * PAGE_BYTES; + while bytes.saturating_add(pending) > limit || self.cache.len() > MAX_PAGES { + let cold = self + .cache + .iter() + .filter(|(k, _)| !desired.contains(k)) + .min_by_key(|(_, p)| p.touched) + .map(|(k, _)| *k) + .or_else(|| self.cache.iter().min_by_key(|(_, p)| p.touched).map(|(k, _)| *k)); + let Some(key) = cold else { break }; + if let Some(p) = self.cache.remove(&key) { + bytes = bytes.saturating_sub(p.bytes()); + } + } + // First make every visible page available, then refine provisional reduced pages. + let previews_ready = wanted.iter().all(|k| self.cache.contains_key(k)); + for key in wanted { + if self.jobs.len() >= MAX_JOBS { + break; + } + if self.jobs.contains_key(key) || self.failed.contains(key) { + continue; + } + let exact = key.factor == 1 || !photocraft_compose::proxy::proxy_faithful(&doc) || (refine && previews_ready); + if self.cache.get(key).is_some_and(|p| !exact || p.exact) { + continue; + } + // Admission must make room before the budget is exhausted. Otherwise old zooms + // just below the limit block a new visible page forever, even with idle workers. + while bytes.saturating_add(pending).saturating_add(PAGE_BYTES) > limit { + let cold = self.cache.iter().filter(|(k, _)| !desired.contains(k)).min_by_key(|(_, p)| p.touched).map(|(k, _)| *k); + let Some(cold) = cold else { break }; + if let Some(page) = self.cache.remove(&cold) { + bytes = bytes.saturating_sub(page.bytes()); + } + } + if bytes.saturating_add(pending).saturating_add(PAGE_BYTES) > limit { + break; + } + if !self.spawn(ctx, doc.clone(), *key, display.clone(), exact) { + // Another view owns the shared workers. Keep this view runnable even if it + // has no local jobs, and stop probing the pool once per remaining page. + ctx.request_repaint_after(std::time::Duration::from_millis(16)); + break; + } + pending = pending.saturating_add(PAGE_BYTES); + } + self.stats.resident_pages = self.cache.len(); + self.stats.resident_bytes = bytes; + self.stats.active_jobs = self.jobs.len(); + self.stats.visible_pages = wanted.len(); + self.stats.ready_pages = wanted.iter().filter(|key| self.cache.get(key).is_some_and(|p| p.gpu || p.texture.is_some())).count(); + self.stats.pending_bytes = pending; + self.stats.cache_limit = limit; + if !self.jobs.is_empty() { + ctx.request_repaint_after(std::time::Duration::from_millis(16)); + } + } + + fn spawn(&mut self, ctx: &egui::Context, doc: Arc, key: PageKey, display: Option>, exact: bool) -> bool { + if ACTIVE.fetch_update(Ordering::AcqRel, Ordering::Relaxed, |n| (n < MAX_JOBS).then_some(n + 1)).is_err() { + return false; + } + let slot = WorkerSlot; + let cancel = Arc::new(AtomicBool::new(false)); + self.jobs.insert(key, cancel.clone()); + let tx = self.tx.clone(); + let ctx = ctx.clone(); + #[cfg(not(target_arch = "wasm32"))] + { + let result = std::thread::Builder::new().name("pc-viewport-page".into()).spawn(move || { + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| compute(&doc, key, display.as_deref(), exact, &cancel))) + .unwrap_or_else(|_| Err("viewport rendering failed; original document preserved".into())); + let _ = tx.try_send(Completed { key, exact, result }); + // Release capacity before waking any view waiting for it. + drop(slot); + ctx.request_repaint(); + }); + if let Err(e) = result { + self.jobs.remove(&key); + self.failed.insert(key); + self.stats.last_error = Some(e.to_string()); + return false; + } + } + #[cfg(target_arch = "wasm32")] + { + let result = compute(&doc, key, display.as_deref(), exact, &cancel); + let _ = tx.try_send(Completed { key, exact, result }); + drop(slot); + ctx.request_repaint(); + } + true + } + + /// Upload at most two pages per frame; all disk reads and colour transforms ran on workers. + pub fn upload(&mut self, ctx: &egui::Context, doc: &Document, wanted: &[PageKey], gpu: Option<&GpuCanvas>, limit: u64, encode_srgb: bool) { + let mut n = 0; + for key in wanted { + let Some(page) = self.cache.get_mut(key) else { continue }; + if let Some(g) = gpu { + if !page.gpu && n < 2 { + page.gpu = page.buffer.as_ref().is_some_and(|b| { + g.upload_page( + crate::gpu_canvas::PageUpload { + key: doc.id.0, + page: key.id(), + size: [doc.size.width, doc.size.height], + factor: key.factor, + depth: doc.depth, + limit, + core: key.core(doc), + }, + b, + ) + }); + if page.gpu { + if key.factor == 1 && g.composite_resident(doc, key.source(doc), encode_srgb).is_ok() { + self.stats.gpu_composites += 1; + } + self.stats.uploads += 1; + n += 1; + page.buffer = None; + } + } + } else if page.texture.is_none() && n < 2 { + page.texture = Some(ctx.load_texture(format!("page-{}", key.id()), page.image.clone(), TextureOptions::LINEAR)); + self.stats.uploads += 1; + n += 1; + page.buffer = None; + } + } + if n > 0 { + ctx.request_repaint(); + } + self.stats.resident_bytes = self.cache.values().map(Page::bytes).sum(); + self.stats.ready_pages = wanted.iter().filter(|key| self.cache.get(key).is_some_and(|p| p.gpu || p.texture.is_some())).count(); + } + + pub fn paint_cpu(&self, painter: &egui::Painter, xf: &crate::canvas::ViewXform, doc: &Document, wanted: &[PageKey]) { + for key in wanted { + let Some(p) = self.cache.get(key) else { continue }; + let Some(t) = &p.texture else { continue }; + let core = key.core(doc); + let full = Rect::from_xywh(p.rect.x0 * key.factor as i32, p.rect.y0 * key.factor as i32, p.rect.width() * key.factor, p.rect.height() * key.factor); + let uv = egui::Rect::from_min_max( + egui::pos2((core.x0 - full.x0) as f32 / full.width() as f32, (core.y0 - full.y0) as f32 / full.height() as f32), + egui::pos2((core.x1 - full.x0) as f32 / full.width() as f32, (core.y1 - full.y0) as f32 / full.height() as f32), + ); + let uv = if xf.flip { egui::Rect::from_min_max(egui::pos2(uv.max.x, uv.min.y), egui::pos2(uv.min.x, uv.max.y)) } else { uv }; + painter.image(t.id(), xf.doc_rect(core), uv, Color32::WHITE); + } + } +} + +pub struct StreamOptions<'a> { + pub limit: u64, + pub gpu: Option<&'a GpuCanvas>, + pub refine: bool, +} + +#[cfg(test)] +mod tests { + use super::*; + use photocraft_color::{ColorMode, SampleType}; + #[test] + fn page_count_depends_on_view_not_document_size() { + let d = Document::new("Earth", photocraft_doc::Size::new(86400, 43200), ColorMode::Rgb, SampleType::U8); + let p = pages(&d, Rect::new(64000, 22000, 66048, 23080), 1.0, 1, 0, 0); + assert!(p.len() <= 20); + assert!(p.iter().all(|p| p.factor == 1)); + assert!(pages(&d, d.bounds(), 0.01, 1, 0, 0).len() <= MAX_PAGES); + assert_eq!(factor(f32::NAN), 1); + } + #[test] + fn neighboring_pages_have_matching_border_footprints() { + let d = Document::new("d", photocraft_doc::Size::new(5000, 3000), ColorMode::Rgb, SampleType::U8); + let p = pages(&d, d.bounds(), 0.5, 1, 0, 0); + assert!(p.iter().all(|p| p.source(&d).width() <= 514 * p.factor)); + assert!(p.iter().all(|p| p.source(&d).x0 % p.factor as i32 == 0)); + } + + #[test] + fn local_damage_reuses_only_unaffected_pages() { + let d = Document::new("d", photocraft_doc::Size::new(2048, 512), ColorMode::Rgb, SampleType::U8); + let old = pages(&d, d.bounds(), 1.0, 1, 0, 0); + let mut s = Stream::default(); + for key in &old { + s.cache.insert( + *key, + Page { + buffer: None, + rect: key.core(&d), + image: egui::ColorImage::filled([1, 1], Color32::RED), + texture: None, + gpu: false, + exact: true, + touched: 0, + }, + ); + } + let target = PageKey { revision: 2, snapshot: 123, ..old[0] }; + let damage = Rect::new(10, 10, 20, 20); + s.rebase_damage(&d, target, damage, None); + assert_eq!(s.cache.keys().filter(|k| k.revision == 2).count(), 3); + assert!(s.cache.keys().filter(|k| k.revision == 2).all(|k| k.source(&d).intersect(&damage).is_empty())); + assert_eq!(PageKey { x: u32::MAX, ..target }.core(&d), Rect::EMPTY); + } + + #[test] + fn cold_zoom_pages_cannot_block_new_visible_pages() { + let doc = Arc::new(Document::new("tabs", photocraft_doc::Size::new(8192, 4096), ColorMode::Rgb, SampleType::F32)); + let ctx = egui::Context::default(); + let mut stream = Stream::default(); + let old = pages(&doc, doc.bounds(), 0.5, 1, 0, 0); + for key in old.iter().take(8) { + stream.cache.insert( + *key, + Page { + buffer: None, + rect: key.core(&doc), + image: egui::ColorImage::filled([514, 514], Color32::BLACK), + texture: None, + gpu: false, + exact: true, + touched: 0, + }, + ); + } + let wanted = pages(&doc, Rect::new(0, 0, 512, 512), 1.0, 1, 0, 0); + let limit = 12 << 20; + assert!(stream.cache.values().map(Page::bytes).sum::() < limit); + stream.update(&ctx, doc.clone(), &wanted, None, StreamOptions { limit, gpu: None, refine: true }); + assert!(stream.cache.len() < 8, "evict cold zoom pages before admitting the visible page, even when another view owns the workers"); + for _ in 0..1000 { + stream.update(&ctx, doc.clone(), &wanted, None, StreamOptions { limit, gpu: None, refine: true }); + stream.upload(&ctx, &doc, &wanted, None, 0, false); + if wanted.iter().all(|k| stream.texture(k).is_some()) && stream.jobs.is_empty() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!(wanted.iter().all(|k| stream.texture(k).is_some())); + assert!(stream.stats.resident_bytes <= limit); + } + + #[test] + fn pending_pages_reserve_cache_capacity_until_they_are_consumed() { + let doc = Arc::new(Document::new("pending", photocraft_doc::Size::new(8192, 4096), ColorMode::Rgb, SampleType::F32)); + let ctx = egui::Context::default(); + let mut stream = Stream::default(); + let wanted = pages(&doc, Rect::new(0, 0, 1024, 512), 1.0, 1, 0, 0); + // A completed inactive-view job still owns its queued result until update consumes it. + stream.jobs.insert(wanted[0], Arc::new(AtomicBool::new(false))); + let limit = PAGE_BYTES + 1; + stream.update(&ctx, doc.clone(), &wanted, None, StreamOptions { limit, gpu: None, refine: true }); + assert_eq!(stream.jobs.len(), 1, "the second result would exceed the shared view capacity"); + assert_eq!(stream.stats.pending_bytes, PAGE_BYTES); + assert!(stream.stats.resident_bytes + stream.stats.pending_bytes <= limit); + } + + #[test] + fn asynchronous_cpu_pages_preserve_source_pixels_and_discard_obsolete_work() { + let mut d = Document::new("large", photocraft_doc::Size::new(86400, 43200), ColorMode::Rgb, SampleType::U8); + let mut surface = photocraft_raster::Surface::new(d.pixel_format()); + for x in 65000..65080 { + surface.fill_rect(Rect::new(x, 22000, x + 1, 22080), &[if x % 2 == 0 { 1.0 } else { 0.0 }, 0.5, 0.25, 1.0]); + } + d.layers.push(photocraft_doc::Layer::new("detail", photocraft_doc::LayerContent::Raster(surface))); + let doc = Arc::new(d); + let ctx = egui::Context::default(); + let mut s = Stream::default(); + let old = pages(&doc, Rect::new(10, 10, 20, 20), 1.0, 0, 0, 0); + let wanted = pages(&doc, Rect::new(65000, 22000, 65080, 22080), 2.0, 1, 0, 0); + let limit = 12 << 20; + s.update(&ctx, doc.clone(), &old, None, StreamOptions { limit, gpu: None, refine: true }); + for _ in 0..1000 { + s.update(&ctx, doc.clone(), &wanted, None, StreamOptions { limit, gpu: None, refine: true }); + s.upload(&ctx, &doc, &wanted, None, 0, false); + assert!(s.stats.active_jobs <= MAX_JOBS); + assert!(s.stats.resident_bytes <= limit); + if wanted.iter().all(|k| s.texture(k).is_some()) && s.jobs.is_empty() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!(s.stats.cancellations > 0); + assert!(s.stats.last_error.is_none(), "{:?}", s.stats.last_error); + assert!(wanted.iter().all(|k| s.texture(k).is_some())); + assert!(s.cache.keys().all(|k| k.revision == 1 && k.factor == 1)); + for key in wanted { + let p = &s.cache[&key]; + let reference = photocraft_compose::render(&doc, p.rect).to_rgba8(); + assert_eq!(p.image, egui::ColorImage::from_rgba_unmultiplied([reference.width as usize, reference.height as usize], &reference.pixels)); + assert!(p.buffer.is_none(), "upload must release the float buffer"); + } + } +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 69dc5ddc34..3cb1818e5c 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -1269,6 +1269,7 @@ pub fn inspect(app: &PhotocraftApp, ctx: &egui::Context) -> Value { "window": {"width": screen.width(), "height": screen.height(), "pixelsPerPoint": ctx.pixels_per_point()}, "scratch": photocraft_raster::spill::stats(), "memory": photocraft_raster::memory::stats(), + "streaming": app.streams.iter().map(|((doc,view),s)| json!({"doc":doc.0,"view":view,"stats":s.stats})).collect::>(), "tool": app.ui.tool, "toolOptions": app.ui.tool_options, "perspectiveCrop": app.ui.perspective_crop, diff --git a/crates/ui-egui/src/gpu_canvas.rs b/crates/ui-egui/src/gpu_canvas.rs index 046d4ac2a7..afc4a0658a 100644 --- a/crates/ui-egui/src/gpu_canvas.rs +++ b/crates/ui-egui/src/gpu_canvas.rs @@ -45,7 +45,7 @@ const FORMAT_HIGH: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float; pub const F16_BUDGET_PX: u64 = 100_000_000; const VIEW_UNIFORM_SIZE: u64 = 128; const VIEW_FLOATS: usize = 32; -const TILE_UNIFORM_SIZE: u64 = 16; +const TILE_UNIFORM_SIZE: u64 = 32; /// Whether this view can use the GPU canvas. Flip and non-square pixel aspect correction are /// still CPU blits (the shader's filtering assumes square pixels); rotation is a shader uniform. @@ -202,7 +202,9 @@ impl GpuCanvas { let bytes = d .tiles .iter() - .map(|t| (0..t.levels.len() as u32).map(|l| ((t.rect[2] >> l).max(1) as u64) * ((t.rect[3] >> l).max(1) as u64) * bpp).sum::()) + .map(|t| { + (0..t.levels.len() as u32).map(|l| ((t.texture.width() >> l).max(1) as u64) * ((t.texture.height() >> l).max(1) as u64) * bpp).sum::() + }) .sum(); Some((d.format, bytes)) } @@ -266,6 +268,21 @@ impl GpuCanvas { let c = r.callback_resources.get::()?.compositor.as_ref()?; Some((c.resident_bytes(), c.fx_cache_bytes().saturating_add(usize::try_from(c.composite_cache_bytes()).unwrap_or(usize::MAX)))) } + /// Shared display-texture cap across every document, including older full-canvas entries. + pub fn set_output_budget(&self, bytes: u64) { + let mut r = self.rs.renderer.write(); + if let Some(res) = r.callback_resources.get_mut::() { + res.output_budget = Some(bytes); + } + } + /// Re-key an unchanged region after a local edit without copying or uploading pixels. + pub fn rename_page(&self, doc: u64, old: u64, new: u64) -> bool { + let mut r = self.rs.renderer.write(); + let Some(d) = r.callback_resources.get_mut::().and_then(|r| r.docs.get_mut(&doc)) else { return false }; + let Some(t) = d.tiles.iter_mut().find(|t| t.stream_key == old) else { return false }; + t.stream_key = new; + true + } /// Set the checkerboard and gamut warning colours. pub fn set_style(&self, style: CanvasStyle) { @@ -278,7 +295,7 @@ impl GpuCanvas { /// Whether document `doc` has been uploaded with this size. pub fn has(&self, doc: u64, size: [u32; 2]) -> bool { let r = self.rs.renderer.read(); - r.callback_resources.get::().and_then(|res| res.docs.get(&doc)).is_some_and(|d| d.size == size) + r.callback_resources.get::().and_then(|res| res.docs.get(&doc)).is_some_and(|d| !d.streamed && d.size == size) } /// Whether [`GpuCanvas::composite`] would draw `doc` with the wgpu compositor (rather than @@ -313,7 +330,10 @@ impl GpuCanvas { let (device, queue) = (&self.rs.device, &self.rs.queue); let mut renderer = self.rs.renderer.write(); let Some(res) = renderer.callback_resources.get_mut::() else { return }; - if res.docs.get(&doc).is_none_or(|d| d.size != size || d.format != format) { + if res.docs.get(&doc).is_none_or(|d| d.streamed || d.size != size || d.format != format) { + if !res.admit_output(doc, u64::from(size[0]) * u64::from(size[1]) * texel_bytes(format) * 3 / 2) { + return; + } let Some(tex) = DocTextures::new(device, res, size, self.tile, format) else { res.docs.remove(&doc); return; @@ -422,8 +442,12 @@ impl GpuCanvas { } comp.set_focus(res.compositor_focus); let key = doc.id.0; - let format = self.format_for(doc.depth, size); + let format = res.docs.get(&key).filter(|d| d.streamed).map_or_else(|| self.format_for(doc.depth, size), |d| d.format); let fresh = res.docs.get(&key).is_none_or(|d| d.size != size || d.format != format); + if fresh && doc.size.area() > 16 << 20 { + res.compositor = Some(comp); + return Err(photocraft_gpu::Unsupported("large canvas requires visible stream pages".into())); + } let region = if fresh { doc.bounds() } else { region.intersect(&doc.bounds()) }; // Checked before allocating a new canvas texture only: `render` plans (and refuses) itself. if fresh && let Err(e) = comp.supports(doc) { @@ -433,11 +457,15 @@ impl GpuCanvas { // A full refresh whose layer pages don't fit the memory budget would re-upload the // evicted ones every time (no faster than the CPU, and more memory): the banded CPU // compositor does it, and edits in the view (damage rects) stay on the GPU. - if region == doc.bounds() && !comp.fits_budget(doc, region) { + if !comp.fits_budget(doc, region) { res.compositor = Some(comp); return Err(photocraft_gpu::Unsupported(OVER_BUDGET.into())); } if fresh { + if !res.admit_output(key, u64::from(size[0]) * u64::from(size[1]) * texel_bytes(format) * 3 / 2) { + res.compositor = Some(comp); + return Err(photocraft_gpu::Unsupported("display texture exceeds the GPU budget; use visible pages".into())); + } let Some(tex) = DocTextures::new(device, res, size, self.tile, format) else { res.docs.remove(&key); res.compositor = Some(comp); @@ -457,6 +485,11 @@ impl GpuCanvas { let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { comp.render(device, queue, doc, region, |enc, out| { for t in &d.tiles { + // Independently reduced pages use document-coordinate rectangles larger + // than their textures. Native-resolution passes only target native pages. + if t.rect[2] != t.texture.width() || t.rect[3] != t.texture.height() { + continue; + } let [tx, ty, tw, th] = t.rect.map(|v| v as i32); let r = out.rect.intersect(&photocraft_geom::Rect::from_xywh(tx, ty, tw as u32, th as u32)); if r.is_empty() { @@ -516,6 +549,31 @@ impl GpuCanvas { result } + /// The interactive stream never reads scratch on the UI thread. Simple raster documents + /// can reuse the GPU compositor while all its inputs are resident; otherwise the already + /// prepared worker page is displayed by the same GPU canvas. + pub fn composite_resident( + &self, + doc: &photocraft_doc::Document, + region: photocraft_geom::Rect, + encode_srgb: bool, + ) -> Result { + let _scope = photocraft_raster::spill::try_resident_scope().ok_or_else(|| photocraft_gpu::Unsupported("scratch transfer in progress".into()))?; + for (_, _, layer) in doc.walk() { + if !matches!(layer.content, photocraft_doc::LayerContent::Raster(_) | photocraft_doc::LayerContent::Group(_)) + || layer.mask.is_some() + || layer.vector_mask.is_some() + || layer.effects.enabled && !layer.effects.items.is_empty() + { + return Err(photocraft_gpu::Unsupported("derived inputs are prepared on a worker".into())); + } + if layer.surface().is_some_and(|s| s.tiles().any(|(_, t)| !t.is_resident())) { + return Err(photocraft_gpu::Unsupported("pixel inputs are on scratch".into())); + } + } + self.composite(doc, region, encode_srgb) + } + /// Bring document `key`'s display texture up to date with `doc`: only `damage` when given (a /// brush stroke on an already uploaded document), else everything. The wgpu compositor renders /// straight into the texture when it supports the document; otherwise the CPU compositor does. @@ -542,6 +600,9 @@ impl GpuCanvas { display: Option<&photocraft_engine::display_color::CanvasDisplay>, defer: bool, ) -> Refresh { + if doc.size.area() > 16 << 20 { + return Refresh { kind: "viewport", fallback: Some("large canvas requires visible stream pages".into()), ..Default::default() }; + } if let Some(f) = self.fault() { // Nothing reaches the GPU any more; the app switches to the CPU canvas. return Refresh { kind: "lost", fallback: Some(f.to_string()), ..Default::default() }; @@ -610,6 +671,10 @@ impl GpuCanvas { /// band (each band converted and uploaded before the next is rendered), so a huge document /// never needs a full-size float composite or texture-format copy in memory. pub fn upload_composite(&self, key: u64, doc: &photocraft_doc::Document, display: Option<&photocraft_engine::display_color::CanvasDisplay>) { + if doc.size.area() > 16 << 20 { + log::warn!("large canvas requires visible stream pages"); + return; + } let size = [doc.size.width, doc.size.height]; let Some(format) = self.prepare_texture(key, doc) else { return }; let (device, queue) = (&self.rs.device, &self.rs.queue); @@ -649,7 +714,11 @@ impl GpuCanvas { let format = self.format_for(doc.depth, size); let mut renderer = self.rs.renderer.write(); let res = renderer.callback_resources.get_mut::()?; - if res.docs.get(&key).is_none_or(|d| d.size != size || d.format != format) { + if res.docs.get(&key).is_none_or(|d| d.streamed || d.size != size || d.format != format) { + if !res.admit_output(key, canvas_texture_bytes(size, self.tile, texel_bytes(format))) { + res.docs.remove(&key); + return None; + } let Some(tex) = DocTextures::new(&self.rs.device, res, size, self.tile, format) else { res.docs.remove(&key); return None; @@ -735,6 +804,9 @@ impl GpuCanvas { let renderer = self.rs.renderer.read(); let d = renderer.callback_resources.get::()?.docs.get(&key)?; let bpp = texel_bytes(d.format); + if d.streamed { + return None; + } // Never allocate full-document readback for a sparse canvas. let (w, h) = (d.size[0] as usize, d.size[1] as usize); let mut out = vec![[0.0f32; 4]; w.checked_mul(h)?]; for t in &d.tiles { @@ -773,6 +845,172 @@ impl GpuCanvas { Some((d.format, d.size, out)) } + /// Read only one resident page, never a full-document allocation (diagnostics/tests). + #[cfg(not(target_arch = "wasm32"))] + pub fn read_page(&self, key: u64, page: u64) -> Option<(wgpu::TextureFormat, [u32; 2], Vec<[f32; 4]>)> { + if !self.health.is_ok() { + return None; + } + let r = self.rs.renderer.read(); + let d = r.callback_resources.get::()?.docs.get(&key)?; + let t = d.tiles.iter().find(|t| t.stream_key == page)?; + let (w, h, bpp) = (t.texture.width(), t.texture.height(), texel_bytes(d.format)); + if w > 1024 || h > 1024 { + return None; + } + let row = (u64::from(w) * bpp).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as u64); + let device = &self.rs.device; + let buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("pc_page_readback"), + size: row * u64::from(h), + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + let mut encoder = device.create_command_encoder(&Default::default()); + encoder.copy_texture_to_buffer( + t.texture.as_image_copy(), + wgpu::TexelCopyBufferInfo { + buffer: &buffer, + layout: wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(row as u32), rows_per_image: Some(h) }, + }, + wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, + ); + self.rs.queue.submit([encoder.finish()]); + buffer.slice(..).map_async(wgpu::MapMode::Read, |_| {}); + if !self.health.wait(device, None) { + return None; + } + let data = buffer.slice(..).get_mapped_range().ok()?; + let mut out = Vec::with_capacity(w as usize * h as usize); + for y in 0..h as usize { + for x in 0..w as usize { + let offset = y * row as usize + x * bpp as usize; + out.push(texel_to_f32(d.format, data.get(offset..offset + bpp as usize)?)); + } + } + Some((d.format, [w, h], out)) + } + + /// Store one independently reduced page at its original document coordinates. No other + /// document pages or mip levels are allocated. The caller evicts cold pages before admission. + pub fn upload_page(&self, request: PageUpload, buf: &photocraft_compose::Buffer) -> bool { + let PageUpload { key, page, size, factor, depth, limit, core } = request; + let (w, h) = (buf.rect.width(), buf.rect.height()); + if !self.health.is_ok() + || factor == 0 + || w == 0 + || h == 0 + || w > 1024 + || h > 1024 + || buf.rect.x0 < 0 + || buf.rect.y0 < 0 + || buf.px.len() as u64 != u64::from(w) * u64::from(h) + { + return false; + } + let Some(x) = (buf.rect.x0 as u32).checked_mul(factor) else { return false }; + let Some(y) = (buf.rect.y0 as u32).checked_mul(factor) else { return false }; + let Some(dw) = w.checked_mul(factor) else { return false }; + let Some(dh) = h.checked_mul(factor) else { return false }; + let format = self.format_for(depth, [w, h]); + let bytes = u64::from(w) * u64::from(h) * texel_bytes(format); + let (device, queue) = (&self.rs.device, &self.rs.queue); + let mut renderer = self.rs.renderer.write(); + let Some(res) = renderer.callback_resources.get_mut::() else { return false }; + if res.docs.get(&key).is_none_or(|d| !d.streamed || d.size != size || d.format != format) { + res.docs.insert(key, DocTextures { size, format, tiles: Vec::new(), streamed: true }); + } + if let Some(d) = res.docs.get_mut(&key) { + d.tiles.retain(|t| t.stream_key != page); + } + let mut live: u64 = res.docs.values().map(DocTextures::bytes).sum(); + if bytes > limit { + return false; + } + while live.saturating_add(bytes) > limit { + let cold = res.docs.iter().flat_map(|(id, d)| d.tiles.iter().enumerate().map(move |(i, t)| (*id, i, t.stamp))).min_by_key(|(_, _, stamp)| *stamp); + let Some((id, index, _)) = cold else { return false }; + if let Some(d) = res.docs.get_mut(&id) { + if index >= d.tiles.len() { + return false; + } + if d.streamed { + let t = d.tiles.remove(index); + live = live.saturating_sub(texture_bytes(&t.texture, d.format)); + } else { + live = live.saturating_sub(d.bytes()); + res.docs.remove(&id); + } + } + } + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("pc_stream_page"), + size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format, + usage: wgpu::TextureUsages::TEXTURE_BINDING + | wgpu::TextureUsages::COPY_DST + | wgpu::TextureUsages::COPY_SRC + | wgpu::TextureUsages::RENDER_ATTACHMENT, + view_formats: &[], + }); + let view = texture.create_view(&Default::default()); + let rect = [x, y, dw, dh]; + let mut uniforms = rect.map(|v| v as f32).to_vec(); + uniforms.extend([core.x0 as f32, core.y0 as f32, core.x1 as f32, core.y1 as f32]); + let uniform = init_buffer(device, "pc_stream_page", &f32_bytes(&uniforms), wgpu::BufferUsages::UNIFORM); + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("pc_stream_page"), + layout: &res.tile_bgl, + entries: &[ + wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&view) }, + wgpu::BindGroupEntry { binding: 1, resource: uniform.as_entire_binding() }, + ], + }); + queue.write_texture( + texture.as_image_copy(), + &texels(format, &buf.px), + wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(w * texel_bytes(format) as u32), rows_per_image: Some(h) }, + wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, + ); + if let Some(d) = res.docs.get_mut(&key) { + d.tiles.push(Tile { stream_key: page, stamp: stream_stamp(), rect, texture, levels: vec![view], bind_group, _uniform: uniform }); + } + true + } + + pub fn retain_pages(&self, key: u64, live: &[u64]) { + let mut renderer = self.rs.renderer.write(); + if let Some(d) = renderer.callback_resources.get_mut::().and_then(|r| r.docs.get_mut(&key)) + && d.streamed + { + d.tiles.retain(|t| live.contains(&t.stream_key)); + } + } + + pub fn has_page(&self, key: u64, page: u64) -> bool { + self.rs + .renderer + .read() + .callback_resources + .get::() + .and_then(|r| r.docs.get(&key)) + .is_some_and(|d| d.tiles.iter().any(|t| t.stream_key == page)) + } + pub fn touch_pages(&self, key: u64, pages: &[u64]) { + let mut r = self.rs.renderer.write(); + if let Some(d) = r.callback_resources.get_mut::().and_then(|r| r.docs.get_mut(&key)) { + let stamp = stream_stamp(); + for t in &mut d.tiles { + if pages.contains(&t.stream_key) { + t.stamp = stamp; + } + } + } + } + /// Add the paint callback drawing `params` into `rect` (clipped by the painter's clip rect). pub fn paint(painter: &egui::Painter, rect: egui::Rect, params: ViewParams) { painter.add(egui_wgpu::Callback::new_paint_callback(rect, CanvasCallback { rect, params })); @@ -800,6 +1038,21 @@ pub fn display_texels( texels(format, &texture_buffer(display, buf).px) } +fn stream_stamp() -> u64 { + static STAMP: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + STAMP.fetch_add(1, std::sync::atomic::Ordering::Relaxed) +} + +pub struct PageUpload { + pub key: u64, + pub page: u64, + pub size: [u32; 2], + pub factor: u32, + pub depth: photocraft_doc::SampleType, + pub limit: u64, + pub core: photocraft_geom::Rect, +} + /// What a [`GpuCanvas::refresh`] did. #[derive(Clone, Debug, Default)] pub struct Refresh { @@ -836,6 +1089,7 @@ pub fn use_adapter_limits(setup: &mut egui_wgpu::WgpuSetup) { /// is requested (the desktop app records it in its crash-safe startup marker). pub fn use_adapter_limits_with(setup: &mut egui_wgpu::WgpuSetup, on_adapter: impl Fn(&wgpu::Adapter) + Send + Sync + 'static) { if let egui_wgpu::WgpuSetup::CreateNew(create) = setup { + create.instance_descriptor.memory_budget_thresholds.for_resource_creation = Some(90); create.device_descriptor = std::sync::Arc::new(move |adapter| { on_adapter(adapter); wgpu::DeviceDescriptor { label: Some("photocraft wgpu device"), required_limits: device_limits(adapter), ..Default::default() } @@ -1291,6 +1545,7 @@ struct Resources { /// view shows; applied before every composite. compositor_budget: Option, compositor_cache_budget: Option, + output_budget: Option, compositor_focus: Option, /// GPU bytes one document's display texture set may take (see [`canvas_budget`]). canvas_budget: u64, @@ -1367,9 +1622,38 @@ struct DocTextures { size: [u32; 2], format: wgpu::TextureFormat, tiles: Vec, + streamed: bool, +} + +fn texture_bytes(t: &wgpu::Texture, format: wgpu::TextureFormat) -> u64 { + (0..t.mip_level_count()).map(|l| u64::from((t.width() >> l).max(1)) * u64::from((t.height() >> l).max(1)) * texel_bytes(format)).sum() +} + +impl DocTextures { + fn bytes(&self) -> u64 { + self.tiles.iter().map(|t| texture_bytes(&t.texture, self.format)).sum() + } +} + +impl Resources { + fn admit_output(&mut self, key: u64, bytes: u64) -> bool { + let Some(limit) = self.output_budget else { return true }; + if bytes > limit { + return false; + } + while self.docs.iter().filter(|(id, _)| **id != key).map(|(_, d)| d.bytes()).sum::().saturating_add(bytes) > limit { + let cold = + self.docs.iter().filter(|(id, _)| **id != key).min_by_key(|(_, d)| d.tiles.iter().map(|t| t.stamp).max().unwrap_or(0)).map(|(id, _)| *id); + let Some(id) = cold else { return false }; + self.docs.remove(&id); + } + true + } } struct Tile { + stream_key: u64, + stamp: u64, /// Document-pixel rect: x, y, w, h. rect: [u32; 4], texture: wgpu::Texture, @@ -1558,6 +1842,7 @@ impl Resources { health: photocraft_gpu::DeviceHealth::new(), compositor_budget: None, compositor_cache_budget: None, + output_budget: None, compositor_focus: None, canvas_budget: canvas_budget(physical_memory()), encode_bgl, @@ -1623,7 +1908,12 @@ impl DocTextures { .map(|l| texture.create_view(&wgpu::TextureViewDescriptor { base_mip_level: l, mip_level_count: Some(1), ..Default::default() })) .collect(); let full = texture.create_view(&wgpu::TextureViewDescriptor::default()); - let uniform = init_buffer(device, "pc_canvas_tile", &f32_bytes(&[tx as f32, ty as f32, w as f32, h as f32]), wgpu::BufferUsages::UNIFORM); + let uniform = init_buffer( + device, + "pc_canvas_tile", + &f32_bytes(&[tx as f32, ty as f32, w as f32, h as f32, tx as f32, ty as f32, (tx + w) as f32, (ty + h) as f32]), + wgpu::BufferUsages::UNIFORM, + ); let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("pc_canvas_tile"), layout: &res.tile_bgl, @@ -1632,10 +1922,10 @@ impl DocTextures { wgpu::BindGroupEntry { binding: 1, resource: uniform.as_entire_binding() }, ], }); - tiles.push(Tile { rect: [tx, ty, w, h], texture, levels, bind_group, _uniform: uniform }); + tiles.push(Tile { stream_key: 0, stamp: 0, rect: [tx, ty, w, h], texture, levels, bind_group, _uniform: uniform }); } } - Some(Self { size, format, tiles }) + Some(Self { size, format, tiles, streamed: false }) } /// Write `size` pixels at document `origin` from `texels` (in the texture format, row stride @@ -1959,7 +2249,7 @@ struct View { g: vec4, // gamut warning rgb, 32-bit preview 1 / gamma h: vec4, // cos, sin, center_doc_x, center_doc_y }; -struct Tile { r: vec4 }; // x, y, w, h in doc px +struct Tile { r: vec4, core: vec4 }; // padded rect and exclusive core bounds @group(0) @binding(0) var view: View; @group(0) @binding(1) var samp: sampler; @@ -2083,13 +2373,16 @@ fn fs_tile(in: VOut) -> @location(0) vec4 { let p = in.pos.xy; let scale = view.a.z; let d = px_to_doc(p) - view.d.xy; // document pixel coordinates (within this copy) + if (any(d < vec2(0.0)) || any(d >= view.b.zw)) { discard; } + if (any(d < tile.core.xy) || any(d >= tile.core.zw)) { discard; } let size = tile.r.zw; let t = d - tile.r.xy; // texel coordinates within this tile let mode = view.c.x; var col: vec4; if (mode < 0.5) { // Zoomed out: trilinear through the mip chain. - col = textureSampleLevel(tex, samp, t / size, view.c.y); + let density = f32(textureDimensions(tex).x) / size.x; + col = textureSampleLevel(tex, samp, t / size, max(0.0, view.c.y + log2(density))); } else if (mode < 1.5) { col = textureSampleLevel(tex, samp, t / size, 0.0); } else { diff --git a/crates/ui-egui/src/gpu_status.rs b/crates/ui-egui/src/gpu_status.rs index bbbc997fc1..c9da353609 100644 --- a/crates/ui-egui/src/gpu_status.rs +++ b/crates/ui-egui/src/gpu_status.rs @@ -46,6 +46,9 @@ pub fn fall_back(app: &mut PhotocraftApp, fault: &photocraft_gpu::Fault) { gpu.release(); // Canvas caches pointed at GPU textures: rebuild them as egui textures. app.canvases.clear(); + for s in app.streams.values_mut() { + s.reset_gpu(); + } app.proxy_uploaded = None; app.prefs_rt.gpu_style = None; app.perf.gpu = false; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a62ab4c161..10d050d5e4 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -46,6 +46,7 @@ pub mod camera_raw_ui; pub mod canvas; pub mod canvas_autoscroll; mod canvas_popup; +pub mod canvas_stream; pub mod canvas_tool_menu; pub mod cas_transform; pub mod channel_options_ui; @@ -504,6 +505,7 @@ pub struct PhotocraftApp { /// Canvas caches per (document, display): CPU textures hold monitor values; the GPU /// canvas state is shared (`canvas::GPU_OUTPUT`). canvases: HashMap<(DocId, u32), canvas::CanvasCache>, + streams: HashMap<(DocId, u64), canvas_stream::Stream>, /// Navigator textures belong to open documents, not the lifetime of the egui context. navigator_textures: HashMap, /// `canvas::display_doc`'s live-adjust and Duotone documents per document: (snapshot, @@ -770,6 +772,7 @@ impl PhotocraftApp { ui: UiState::default(), services, canvases: HashMap::new(), + streams: HashMap::new(), navigator_textures: HashMap::new(), display_memo: HashMap::new(), navigator_gpu: HashMap::new(), diff --git a/crates/ui-egui/tests/canvas_16f.rs b/crates/ui-egui/tests/canvas_16f.rs index 068de36c49..36e604cdc7 100644 --- a/crates/ui-egui/tests/canvas_16f.rs +++ b/crates/ui-egui/tests/canvas_16f.rs @@ -18,6 +18,140 @@ use photocraft_ui_egui::gpu_canvas::{self, GpuCanvas}; use serde_json::json; use photocraft_testkit::gpu_lock; +#[test] +fn native_float_pages_composite_after_reduced_pages_without_losing_the_device() { + let _lock = gpu_lock(); + let Some((g, rs)) = canvas() else { return }; + let mut doc = Document::new("16K EXR dimensions", Size::new(16384, 8192), ColorMode::Rgb, SampleType::F32); + // A native viewport page with its filtering apron straddles four 2048-square input + // cells. The previous staging policy exhausted the mapped-buffer heap on AMD Vulkan + // here, then egui's next index-buffer upload panicked on the same device. + let r = Rect::new(8191, 3583, 8705, 4097); + let mut surface = Surface::with_default(doc.pixel_format(), &[0.25, 0.5, 0.75, 1.0]); + surface.fill_rect(Rect::new(0, 0, 1, 1), &[1.0; 4]); + surface.fill_rect(Rect::new(16383, 8191, 16384, 8192), &[1.0; 4]); + surface.fill_rect(r, &[2.0, 0.5, 0.25, 1.0]); + doc.layers.push(Layer::new("Background", LayerContent::Raster(surface))); + let reduced = photocraft_compose::Buffer::transparent(Rect::new(4096, 1792, 4353, 2049)); + let native = photocraft_compose::Buffer::transparent(r); + for (page, factor, buffer) in [(1, 2, &reduced), (2, 1, &native)] { + assert!( + g.upload_page(gpu_canvas::PageUpload { key: doc.id.0, page, size: [16384, 8192], factor, depth: doc.depth, limit: 128 << 20, core: r }, buffer) + ); + } + g.set_memory_budget(665 << 20); + g.composite_resident(&doc, r, true).expect("native float regional composite"); + // Separate regional renders must not accumulate each other's pending staging buffers. + for page in 3..40 { + let r = Rect::from_xywh(6655 + (page % 6) * 512, 2559 + (page % 4) * 512, 514, 514); + let buffer = photocraft_compose::Buffer::transparent(r); + assert!(g.upload_page( + gpu_canvas::PageUpload { + key: doc.id.0, + page: u64::try_from(page).unwrap(), + size: [16384, 8192], + factor: 1, + depth: doc.depth, + limit: 128 << 20, + core: r + }, + &buffer + )); + g.composite_resident(&doc, r, true).expect("repeated native float composite"); + } + assert!(g.health().wait(&rs.device, None), "{:?}", g.fault()); + let indices = rs.device.create_buffer(&eframe::wgpu::BufferDescriptor { + label: Some("regression egui index buffer"), + size: 184392, + usage: eframe::wgpu::BufferUsages::INDEX | eframe::wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + assert!(rs.queue.write_buffer_with(&indices, 0, std::num::NonZeroU64::new(114888).unwrap()).is_some(), "{:?}", g.fault()); +} + +#[test] +fn huge_document_stream_pages_have_bounded_residency_and_exact_texels() { + let _lock = gpu_lock(); + let Some((g, _rs)) = canvas() else { return }; + let r = Rect::new(64000, 22000, 64512, 22512); + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let d = Document::new("Earth dimensions", Size::new(86400, 43200), ColorMode::Rgb, depth); + let b = photocraft_compose::Buffer { rect: r, px: vec![[0.25, 0.5, 0.75, 0.5]; 512 * 512] }; + let cap = 512 * 512 * 8; + assert!(g.upload_page(gpu_canvas::PageUpload { key: d.id.0, page: 1, size: [86400, 43200], factor: 1, depth, limit: cap, core: r }, &b)); + let (f, size, px) = g.read_page(d.id.0, 1).expect("page readback"); + assert_eq!(size, [512, 512]); + assert_eq!(f, if depth == SampleType::U8 { TextureFormat::Rgba8Unorm } else { TextureFormat::Rgba16Float }); + for (a, b) in px[0].iter().zip([0.125, 0.25, 0.375, 0.5]) { + assert!((*a - b).abs() < 1.0 / 255.0); + } + assert!(g.texture_info(d.id.0).unwrap().1 <= cap); + assert!(g.read_texels(d.id.0).is_none()); + assert!(g.upload_page(gpu_canvas::PageUpload { key: d.id.0, page: 2, size: [86400, 43200], factor: 1, depth, limit: cap, core: r }, &b)); + assert!(g.upload_page(gpu_canvas::PageUpload { key: d.id.0, page: 3, size: [86400, 43200], factor: 1, depth, limit: cap, core: r }, &b)); + assert!(!g.has_page(d.id.0, 1)); + assert!(g.texture_info(d.id.0).unwrap().1 <= cap); + g.retain(&[]); + } +} + +#[test] +fn native_gpu_composite_does_not_write_into_reduced_stream_pages() { + let _lock = gpu_lock(); + let Some((g, _rs)) = canvas() else { return }; + let mut d = Document::new("large", Size::new(86400, 43200), ColorMode::Rgb, SampleType::U16); + let r = Rect::new(64000, 22000, 64512, 22512); + let mut s = Surface::new(d.pixel_format()); + s.fill_rect(r, &[0.25, 0.5, 0.75, 0.5]); + d.layers.push(Layer::new("region", LayerContent::Raster(s))); + let small = photocraft_compose::Buffer { rect: Rect::new(32000, 11000, 32256, 11256), px: vec![[0.0; 4]; 256 * 256] }; + let native = photocraft_compose::Buffer::transparent(r); + for (page, factor, b) in [(1, 2, &small), (2, 1, &native)] { + assert!(g.upload_page(gpu_canvas::PageUpload { key: d.id.0, page, size: [86400, 43200], factor, depth: d.depth, limit: 8 << 20, core: r }, b)); + } + g.composite_resident(&d, r, false).expect("regional GPU composition"); + let (_, _, px) = g.read_page(d.id.0, 2).expect("native page"); + let reference = photocraft_compose::render(&d, r); + for (p, q) in px.iter().zip(reference.px.iter()) { + for c in 0..4 { + let want = if c < 3 { q[c] * q[3] } else { q[c] }; + assert!((p[c] - want).abs() < 0.002); + } + } + assert!(g.read_page(d.id.0, 1).expect("reduced page").2.iter().all(|p| *p == [0.0; 4])); + assert!(g.fault().is_none()); +} + +#[test] +fn background_redraw_replaces_stream_pages_with_a_full_canvas() { + let _lock = gpu_lock(); + let Some((g, _rs)) = canvas() else { return }; + let doc = gradient(SampleType::U8, 32, 32, 0.0, 1.0); + let b = photocraft_compose::render(&doc, doc.bounds()); + assert!( + g.upload_page(gpu_canvas::PageUpload { key: doc.id.0, page: 1, size: [32, 32], factor: 1, depth: doc.depth, limit: 1 << 20, core: doc.bounds() }, &b) + ); + assert!(!g.has(doc.id.0, [32, 32]), "stream pages are not a complete redraw texture"); + assert!(g.prepare_texture(doc.id.0, &doc).is_some()); + assert!(g.has(doc.id.0, [32, 32])); + assert!(!g.has_page(doc.id.0, 1)); + assert!(g.fault().is_none()); +} + +#[test] +fn stream_budget_includes_full_canvas_entries_of_other_documents() { + let _lock = gpu_lock(); + let Some((g, _rs)) = canvas() else { return }; + let old = gradient(SampleType::U8, 512, 512, 0.0, 1.0); + g.upload_composite(old.id.0, &old, None); + assert!(g.has(old.id.0, [512, 512])); + let doc = Document::new("large", Size::new(86400, 43200), ColorMode::Rgb, SampleType::U8); + let r = Rect::new(64000, 22000, 64512, 22512); + let b = photocraft_compose::Buffer::transparent(r); + assert!(g.upload_page(gpu_canvas::PageUpload { key: doc.id.0, page: 1, size: [86400, 43200], factor: 1, depth: doc.depth, limit: 2 << 20, core: r }, &b)); + assert!(!g.has(old.id.0, [512, 512]), "the older full canvas must count against the shared cap"); + assert!(g.fault().is_none()); +} /// A headless GPU canvas on a device created like the app's, if the adapter has a float canvas. fn canvas() -> Option<(GpuCanvas, RenderState)> { @@ -31,6 +165,7 @@ fn canvas() -> Option<(GpuCanvas, RenderState)> { eprintln!("skipping: adapter can't render/filter Rgba16Float"); return None; } + eprintln!("adapter: {:?}", rs.adapter.get_info()); Some((GpuCanvas::new(&rs), rs)) } diff --git a/docs/roadmap.md b/docs/roadmap.md index 5b03188984..dca334cfe0 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -126,3 +126,5 @@ Each milestone has a **definition of done (DoD)** and must leave `main` green on 2026-10-13: native scratch storage pages current and history tiles through compressed, CRC-checked files. Fresh RAM budgets default to 70%; engine-owned memory supervision stops with its last session. Scratch errors block edit/save commits and permit retry. Unix files are private and stale files swept at startup; Windows files use delete-on-close. See [scratch disks](scratch-disks.md). Streaming import and viewport rendering follow separately. 2026-10-13: seekable native PSD/PSB and supported scanline EXR imports decode bounded bands at the original resolution. Authorized opens retain read-only source protection. See [native streaming I/O](streaming-io.md). + +2026-10-13: large canvases render visible original-resolution pages with bounded RAM/VRAM, cancellable workers and stale-result rejection; the new upstream background redraw remains available for full-canvas paths. See [GPU streaming](streaming-gpu.md). diff --git a/docs/scratch-disks.md b/docs/scratch-disks.md index f5cda32305..c05f6e19f5 100644 --- a/docs/scratch-disks.md +++ b/docs/scratch-disks.md @@ -36,5 +36,5 @@ until the tile is readable again. Recovery can retry the original bytes after th becomes available; zeros from a failed display read must never become saved pixels. Scratch storage alone does not make every format, filter or renderer streamable. See -[native PSD/EXR streaming](streaming-io.md) for the seekable import and output APIs. Viewport -rendering is a separate change built on these guards and memory reservations. +[native PSD/EXR streaming](streaming-io.md) for the seekable import and output APIs and +[viewport streaming](streaming-gpu.md) for rendering built on these guards and reservations. diff --git a/docs/streaming-gpu.md b/docs/streaming-gpu.md new file mode 100644 index 0000000000..9095e330bb --- /dev/null +++ b/docs/streaming-gpu.md @@ -0,0 +1,26 @@ +# Viewport streaming + +Large documents render original-pixel viewport pages instead of a document-sized canvas texture. +Visible pages use a power-of-two sampling level appropriate to the screen density; zooming in +requests finer pages from the original document. The document's pixels and dimensions are unchanged. +The CPU renderer follows the same page requests when the GPU is unavailable. + +Requests and ready pages belong to document IDs and views, rather than shifting tab indices. +Zoom, edits and display changes invalidate or rebase pages; obsolete workers cancel, and their +completion cannot replace current work. Closing a tab removes its stream. CPU page caches share +a bounded allowance across views; persistent GPU input and output pages have separate caps. + +GPU uploads submit bounded batches. Staging limits account for padded readback/transfer sizes and +keep transient buffers bounded independently of resident texture memory. Resident-only composition +uses a short nonblocking eviction barrier; disk-backed pages compose on a worker instead of +faulting pixel data on the UI thread. Incomplete output keeps requesting current visible pages +until it finishes or reports an actionable error. + +The integration preserves upstream's GPU rotation uniforms, asynchronous filter previews and +full-canvas background redraw. Streaming pages and complete redraw textures have distinct cache +identities, so a partial page cache cannot stand in for a complete image. Pattern Preview retains +the upstream full-canvas/proxy route for its repeated copies; ordinary large canvases use pages. +Synthetic regressions cover oversized staging/readback allocations, coarse/fine page replacement, +cancelled workers, eviction and tab switching. Native stress uses Earth.psb with the 2K/4K/8K/16K +Milky Way EXR variants, rapid magnifier clicks and tab changes, then the original-size 64K EXR. +Public large fixtures are linked from [native streaming I/O](streaming-io.md). From 89ed4cb28414a173126e97d1853e957343c5ba98 Mon Sep 17 00:00:00 2001 From: vistaero Date: Sat, 10 Oct 2026 07:08:54 +0200 Subject: [PATCH 003/216] Stream native PSD and EXR imports through bounded seekable bands --- Cargo.lock | 1 + apps/photocraft/src/services.rs | 6 + crates/automation/Cargo.toml | 1 + crates/automation/src/files.rs | 22 +- crates/automation/src/headless.rs | 40 +- crates/automation/src/workspace.rs | 105 ++++- crates/codecs/examples/exr_region_probe.rs | 74 +++ crates/codecs/src/exr_stream.rs | 292 ++++++++++++ crates/codecs/src/lib.rs | 6 +- crates/codecs/tests/exr_stream.rs | 161 +++++++ crates/engine/src/file_cmds.rs | 30 +- crates/engine/src/jobs.rs | 31 +- crates/format/src/atomic.rs | 60 +++ crates/format/src/atomic/tests.rs | 84 ++++ crates/io/examples/scratch_psb_probe.rs | 36 ++ crates/io/src/exr_native.rs | 76 ++++ crates/io/src/lib.rs | 165 +++++++ crates/io/src/psd_export.rs | 85 +++- crates/io/src/psd_import.rs | 19 +- crates/io/src/psd_stream.rs | 395 ++++++++++++++++ crates/io/tests/streaming_exr.rs | 94 ++++ crates/io/tests/streaming_psd.rs | 136 ++++++ crates/io/tests/svg.rs | 17 + crates/psd/src/layer.rs | 4 +- crates/psd/src/lib.rs | 1 + crates/psd/src/stream.rs | 499 +++++++++++++++++++++ crates/ui-egui/src/control.rs | 50 +++ crates/ui-egui/src/file_open.rs | 26 +- crates/ui-egui/src/jobs_ui.rs | 45 ++ crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/tests/exr_open.rs | 44 ++ docs/roadmap.md | 2 + docs/scorecard.md | 2 +- docs/scratch-disks.md | 5 +- docs/streaming-io.md | 36 ++ scorecard/reliability.toml | 2 +- 36 files changed, 2587 insertions(+), 68 deletions(-) create mode 100644 crates/codecs/examples/exr_region_probe.rs create mode 100644 crates/codecs/src/exr_stream.rs create mode 100644 crates/codecs/tests/exr_stream.rs create mode 100644 crates/io/examples/scratch_psb_probe.rs create mode 100644 crates/io/src/exr_native.rs create mode 100644 crates/io/src/psd_stream.rs create mode 100644 crates/io/tests/streaming_exr.rs create mode 100644 crates/io/tests/streaming_psd.rs create mode 100644 crates/psd/src/stream.rs create mode 100644 crates/ui-egui/tests/exr_open.rs create mode 100644 docs/streaming-io.md diff --git a/Cargo.lock b/Cargo.lock index 79b80aae04..9dfd9946f6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4295,6 +4295,7 @@ dependencies = [ "photocraft-format", "photocraft-geom", "photocraft-io", + "photocraft-raster", "rmcp", "schemars", "serde", diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index d4a3869f31..3cae110ea3 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -406,6 +406,11 @@ fn shell_word(s: &str) -> String { pub fn native(automation: Option) -> Services { let clip: Rc>> = Rc::default(); + let automation_import_path = automation.clone().map(|workspace| { + std::sync::Arc::new(move |path: &str, ctl: &photocraft_raster::Interrupt<'_>| { + crate::crash_guard::guard("Open", || workspace.read_document(path, ctl).map_err(|e| e.to_string())) + }) as photocraft_ui_egui::AutomationImportPathFn + }); let automation_read = automation.clone().map(|workspace| { Box::new(move |path: &str| { let bytes = workspace.read(path).map_err(|error| error.to_string())?; @@ -449,6 +454,7 @@ pub fn native(automation: Option) -> file_dialog: Some(Box::new(show_file_dialog)), write: Some(Box::new(|path: &str, bytes: &[u8]| write_atomic(Path::new(path), bytes))), automation_read, + automation_import_path, automation_write, automation_open_pcraft, automation_save, diff --git a/crates/automation/Cargo.toml b/crates/automation/Cargo.toml index 0738140a41..f15f500e05 100644 --- a/crates/automation/Cargo.toml +++ b/crates/automation/Cargo.toml @@ -15,6 +15,7 @@ photocraft-doc = { workspace = true } photocraft-compose = { workspace = true } photocraft-codecs = { workspace = true } photocraft-io = { workspace = true } +photocraft-raster = { workspace = true } photocraft-format = { path = "../format" } photocraft-engine = { workspace = true } serde = { workspace = true } diff --git a/crates/automation/src/files.rs b/crates/automation/src/files.rs index cd34ab3410..61c6ba246a 100644 --- a/crates/automation/src/files.rs +++ b/crates/automation/src/files.rs @@ -31,9 +31,17 @@ pub fn open(path: &Path) -> Result { if path.is_dir() { return Ok(Opened { document: photocraft_format::load_path(path)?, warnings: Vec::new(), source_read_only: false }); } - let bytes = photocraft_format::read_file(path).map_err(|e| AutomationError::Io(format!("{}: {e}", path.display())))?; let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); - open_bytes(&name, &bytes) + #[cfg(not(target_arch = "wasm32"))] + { + let r = photocraft_io::import_path_with(&name, path, &photocraft_raster::Interrupt::NONE)?; + Ok(Opened { document: r.document, warnings: r.warnings, source_read_only: r.source_read_only }) + } + #[cfg(target_arch = "wasm32")] + { + let bytes = photocraft_format::read_file(path).map_err(|e| AutomationError::Io(format!("{}: {e}", path.display())))?; + open_bytes(&name, &bytes) + } } /// Previews for a `.pcraft` bundle. @@ -73,6 +81,16 @@ pub fn save( w.save_path(&named, path, &previews(&named))?; return Ok(Vec::new()); } + #[cfg(not(target_arch = "wasm32"))] + if ext == "psb" || ext == "psd" && (opts.force_psb || doc.size.width > 30_000 || doc.size.height > 30_000) { + let mut warnings = Vec::new(); + photocraft_format::atomic::atomic_write_stream(path, |file| { + warnings = photocraft_io::export_psb_into(doc, file, &photocraft_raster::Interrupt::NONE).map_err(|e| std::io::Error::other(e.to_string()))?; + Ok(()) + }) + .map_err(|e| AutomationError::Io(e.to_string()))?; + return Ok(warnings); + } let r = photocraft_io::export(doc, &ext, opts)?; // Crash-safe: a failed write never destroys the previous file. photocraft_format::atomic_write(path, &r.bytes).map_err(|e| AutomationError::Io(e.to_string()))?; diff --git a/crates/automation/src/headless.rs b/crates/automation/src/headless.rs index d6b3c17102..3a61ffa4dc 100644 --- a/crates/automation/src/headless.rs +++ b/crates/automation/src/headless.rs @@ -79,9 +79,17 @@ impl Headless { Filesystem::Denied => return Err(AutomationError::BadRequest("automation filesystem access is not granted: read authority is absent".into())), Filesystem::TrustedLocal => files::open(path)?, Filesystem::Workspace(workspace) => { - let bytes = workspace.read(requested)?; - let name = path.file_name().and_then(|name| name.to_str()).unwrap_or(requested); - files::open_bytes(name, &bytes)? + #[cfg(not(target_arch = "wasm32"))] + { + let r = workspace.read_document(requested, &photocraft_raster::Interrupt::NONE)?; + files::Opened { document: r.document, warnings: r.warnings, source_read_only: r.source_read_only } + } + #[cfg(target_arch = "wasm32")] + { + let bytes = workspace.read(requested)?; + let name = path.file_name().and_then(|name| name.to_str()).unwrap_or(requested); + files::open_bytes(name, &bytes)? + } } }; let path = match file_cmds::template_name(&self.session, requested) { @@ -251,9 +259,29 @@ impl Headless { files::save(&doc, &target, format, opts, Some(writer))? } Filesystem::Workspace(workspace) => { - let (bytes, warnings) = files::save_bytes(&doc, target_text, format, opts)?; - workspace.write(target_text, &bytes)?; - warnings + #[cfg(not(target_arch = "wasm32"))] + if format + .map(|f| f.trim_start_matches('.').eq_ignore_ascii_case("psb")) + .unwrap_or_else(|| target.extension().is_some_and(|ext| ext.eq_ignore_ascii_case("psb"))) + { + let mut warnings = Vec::new(); + workspace.write_stream(target_text, |file| { + warnings = photocraft_io::export_psb_into(&doc, file, &photocraft_raster::Interrupt::NONE) + .map_err(|e| std::io::Error::other(e.to_string()))?; + Ok(()) + })?; + warnings + } else { + let (bytes, warnings) = files::save_bytes(&doc, target_text, format, opts)?; + workspace.write(target_text, &bytes)?; + warnings + } + #[cfg(target_arch = "wasm32")] + { + let (bytes, warnings) = files::save_bytes(&doc, target_text, format, opts)?; + workspace.write(target_text, &bytes)?; + warnings + } } }; warnings.extend(photocraft_engine::type_extra_cmds::missing_font_warnings(&doc)); diff --git a/crates/automation/src/workspace.rs b/crates/automation/src/workspace.rs index cde3a3a702..6321bc241f 100644 --- a/crates/automation/src/workspace.rs +++ b/crates/automation/src/workspace.rs @@ -68,6 +68,24 @@ impl AuthorizedWorkspace { let source = DirHandle { dir }; Ok(Some(photocraft_format::load_bytes_source(&source, &photocraft_format::LoadOptions::default())?)) } + /// Open a large document by rows through the held read capability, without a file-sized + /// byte array. Independent channel cursors remain rooted beneath the authorized directory. + #[cfg(not(target_arch = "wasm32"))] + pub fn read_document(&self, path: &str, ctl: &photocraft_raster::Interrupt<'_>) -> Result { + let relative = request_path(path, self.read.as_ref())?; + let root = self.read.as_ref().ok_or_else(|| AutomationError::BadRequest(format!("{DENIED}: read authority is absent")))?; + let original = root.dir.metadata(&relative).map_err(|e| file_error("read", path, e))?; + if !original.is_file() { + return Err(AutomationError::BadRequest("automation read path is not a regular file".into())); + } + let name = relative.file_name().and_then(|n| n.to_str()).unwrap_or(path); + let result = photocraft_io::import_seekable_with(name, &|| root.dir.open(&relative), ctl)?; + let current = root.dir.metadata(&relative).map_err(|e| file_error("read", path, e))?; + if current.len() != original.len() || current.modified().ok() != original.modified().ok() { + return Err(AutomationError::Io("source changed during import; open it again".into())); + } + Ok(result) + } /// Create or replace one file below the configured write root, crash-safely: the bytes go to /// a temporary file beside the target, which is synced and renamed over it (the same steps @@ -78,18 +96,30 @@ impl AuthorizedWorkspace { /// resolution and file creation relative to the held directory handle, so /// a non-existent final target is supported without ambient path access. pub fn write(&self, path: &str, bytes: &[u8]) -> Result<(), AutomationError> { + self.write_stream(path, |file| file.write_all(bytes)) + } + + /// Atomically publish a seekable encoder through the held write capability. No ambient + /// paths are exposed to the encoder, including for temporary-file creation and rename. + pub fn write_stream(&self, path: &str, encode: impl FnOnce(&mut cap_std::fs::File) -> std::io::Result<()>) -> Result<(), AutomationError> { + struct Cleanup(Arc, PathBuf); + impl Drop for Cleanup { + fn drop(&mut self) { + let _ = self.0.remove_file(&self.1); + } + } let relative = request_path(path, self.write.as_ref())?; let root = self.write.as_ref().ok_or_else(|| AutomationError::BadRequest(format!("{DENIED}: write authority is absent")))?; let leaf = relative.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); let parent = relative.parent().map(Path::to_path_buf).unwrap_or_default(); let tmp = parent.join(photocraft_format::atomic::temp_name(&leaf)); let mut options = OpenOptions::new(); - options.write(true).create_new(true); - let written = root.dir.open_with(&tmp, &options).and_then(|mut file| { - file.write_all(bytes)?; - // Falls back to a plain fsync on shares that refuse a full flush (#1336). - photocraft_format::atomic::sync_file(&file.into_std()) - }); + options.read(true).write(true).create_new(true); + let file = root.dir.open_with(&tmp, &options).map_err(|e| file_error("write", path, e))?; + let _cleanup = Cleanup(root.dir.clone(), tmp.clone()); + // This binding drops before the cleanup guard if an encoder unwinds. + let mut file = file; + let written = encode(&mut file).and_then(|()| photocraft_format::atomic::sync_file(&file.into_std())); let renamed = written.and_then(|()| photocraft_format::atomic::retry_rename(RenameRetry::platform(), || root.dir.rename(&tmp, &root.dir, &relative))); if let Err(e) = renamed { let _ = root.dir.remove_file(&tmp); @@ -446,6 +476,69 @@ impl Drop for ExportSequence { mod tests { use super::*; + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn seekable_document_import_stays_within_read_capability() { + let (_, _, workspace) = roots("stream-read"); + let mut session = photocraft_engine::Session::new(); + session.execute("file.new", serde_json::json!({"width": 32, "height": 16})).unwrap(); + session.execute("layer.new.layer", serde_json::json!({})).unwrap(); + let doc = &session.active().unwrap().doc; + workspace + .write_stream("small.psb", |file| { + photocraft_io::export_psb_into(doc, file, &photocraft_raster::Interrupt::NONE).map_err(|e| std::io::Error::other(e.to_string()))?; + Ok(()) + }) + .unwrap(); + let opened = workspace.read_document("small.psb", &photocraft_raster::Interrupt::NONE).unwrap(); + assert_eq!(opened.document.size, doc.size); + assert_eq!(opened.document.layers.len(), doc.layers.len()); + assert!(workspace.read_document("../small.psb", &photocraft_raster::Interrupt::NONE).is_err()); + assert!(workspace.read_document("C:/small.psb", &photocraft_raster::Interrupt::NONE).is_err()); + } + + #[test] + fn seekable_capability_writer_rejects_escape_and_preserves_failed_destination() { + use std::io::{Seek, SeekFrom}; + let (inside, outside, workspace) = roots("stream"); + std::fs::write(inside.join("kept.psb"), b"original").unwrap(); + assert!(workspace.write_stream("../escaped.psb", |_| Ok(())).is_err()); + assert!(!outside.join("escaped.psb").exists()); + assert!( + workspace + .write_stream("kept.psb", |file| { + file.write_all(b"partial")?; + Err(std::io::Error::new(std::io::ErrorKind::StorageFull, "injected full disk")) + }) + .is_err() + ); + assert_eq!(std::fs::read(inside.join("kept.psb")).unwrap(), b"original"); + assert!(temp_files(&inside).is_empty()); + workspace + .write_stream("kept.psb", |file| { + file.write_all(b"0000payload")?; + file.seek(SeekFrom::Start(0))?; + file.write_all(b"0007") + }) + .unwrap(); + assert_eq!(std::fs::read(inside.join("kept.psb")).unwrap(), b"0007payload"); + } + + #[test] + fn seekable_capability_unwind_removes_temporary_after_closing_handle() { + let (inside, _, workspace) = roots("stream-unwind"); + std::fs::write(inside.join("kept.psb"), b"original").unwrap(); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _ = workspace.write_stream("kept.psb", |file| { + file.write_all(b"partial")?; + panic!("injected encoder unwind"); + }); + })); + assert!(result.is_err()); + assert_eq!(std::fs::read(inside.join("kept.psb")).unwrap(), b"original"); + assert!(temp_files(&inside).is_empty()); + } + fn roots(name: &str) -> (PathBuf, PathBuf, AuthorizedWorkspace) { let base = std::env::temp_dir().join(format!("photocraft-workspace-{}-{name}", std::process::id())); let inside = base.join("inside"); diff --git a/crates/codecs/examples/exr_region_probe.rs b/crates/codecs/examples/exr_region_probe.rs new file mode 100644 index 0000000000..4b57e803aa --- /dev/null +++ b/crates/codecs/examples/exr_region_probe.rs @@ -0,0 +1,74 @@ +//! Read-only independent EXR sample oracle: `exr_region_probe file.exr x y [x y ...]`. +//! Uses the dependency's filtered block reader, independently of our band conversion. +use exr::{block::reader::ChunksReader, meta::attribute::SampleType}; +use std::io::BufReader; + +fn run() -> Result<(), Box> { + let args: Vec = std::env::args().skip(1).collect(); + let path = args.first().ok_or("usage: exr_region_probe file.exr x y [x y ...]")?; + let coordinates = args.get(1..).ok_or("missing coordinates")?; + if coordinates.is_empty() || !coordinates.len().is_multiple_of(2) || coordinates.len() > 512 { + return Err("provide 1 to 256 coordinate pairs".into()); + } + let points: Vec<(usize, usize)> = coordinates + .chunks_exact(2) + .map(|p| Ok((p.first().ok_or("x")?.parse()?, p.get(1).ok_or("y")?.parse()?))) + .collect::>>()?; + // Bound metadata, dimensions and block sizes before using the oracle reader. + let info = photocraft_codecs::exr_stream::Decoder::new(std::fs::File::open(path)?, &|| false)?.info(); + if points.iter().any(|&(x, y)| x >= info.width as usize || y >= info.height as usize) { + return Err("coordinate outside the data window".into()); + } + let reader = exr::block::read(BufReader::new(std::fs::File::open(path)?), false)?; + let mut blocks = reader + .filter_chunks(false, |_, _, block| points.iter().any(|&(_, y)| y >= block.pixel_position.1 && y < block.pixel_position.1 + block.pixel_size.1))? + .sequential_decompressor(false); + let channels = blocks.meta_data().headers.first().ok_or("missing header")?.channels.clone(); + let mut samples = vec![[0_f32, 0.0, 0.0, 1.0]; points.len()]; + let mut seen = vec![[false; 3]; points.len()]; + while let Some(block) = blocks.decompress_next_block() { + let block = block?; + for line in block.lines(&channels) { + let channel = channels.list.get(line.location.channel).ok_or("channel")?; + let name = channel.name.to_string(); + let component = match name.rsplit('.').next() { + Some("R") => 0, + Some("G") => 1, + Some("B") => 2, + _ => continue, + }; + for (index, &(x, y)) in points.iter().enumerate().filter(|(_, p)| p.1 == line.location.position.1) { + let value = match channel.sample_type { + SampleType::F16 => line.read_samples::().nth(x).ok_or("sample")??.to_f32(), + SampleType::F32 => line.read_samples::().nth(x).ok_or("sample")??, + SampleType::U32 => line.read_samples::().nth(x).ok_or("sample")?? as f32, + }; + if !value.is_finite() { + return Err("non-finite sample cannot be represented in JSON".into()); + } + *samples.get_mut(index).and_then(|s| s.get_mut(component)).ok_or("sample index")? = value; + *seen.get_mut(index).and_then(|s| s.get_mut(component)).ok_or("sample index")? = true; + let _ = y; + } + } + } + if seen.iter().flatten().any(|v| !v) { + return Err("missing RGB samples".into()); + } + println!("{{\"width\":{},\"height\":{},\"samples\":[", info.width, info.height); + for (index, (&(x, y), sample)) in points.iter().zip(samples).enumerate() { + println!("{}{{\"x\":{x},\"y\":{y},\"rgba\":{:?}}}", if index == 0 { "" } else { "," }, sample); + } + println!("]}}"); + Ok(()) +} + +fn main() -> std::process::ExitCode { + match run() { + Ok(()) => std::process::ExitCode::SUCCESS, + Err(error) => { + eprintln!("{error}"); + std::process::ExitCode::FAILURE + } + } +} diff --git a/crates/codecs/src/exr_stream.rs b/crates/codecs/src/exr_stream.rs new file mode 100644 index 0000000000..a1d5818a16 --- /dev/null +++ b/crates/codecs/src/exr_stream.rs @@ -0,0 +1,292 @@ +//! Bounded, seekable single-part scanline OpenEXR decoding. Total pixel count does not +//! size a decoded image buffer: only one compression block is returned at a time. +use std::io::{Cursor, Read, Seek, SeekFrom}; + +use crate::{CodecError, Format}; +use exr::{ + block::{ + UncompressedBlock, + chunk::{Chunk, CompressedBlock, CompressedScanLineBlock}, + lines::LineIndex, + }, + meta::{BlockDescription, MetaData, attribute::SampleType}, +}; + +const META_BYTES: usize = 8 << 20; +const BLOCK_BYTES: usize = 64 << 20; +const MAX_CHUNKS: usize = 1 << 20; + +fn invalid(e: impl std::fmt::Display) -> CodecError { + CodecError::malformed(Format::OpenExr, e) +} +fn limit(s: &str) -> CodecError { + CodecError::LimitExceeded(s.into()) +} +fn unsupported(s: &str) -> CodecError { + CodecError::unsupported(Format::OpenExr, s) +} + +fn read(r: &mut impl Read) -> Result<[u8; N], CodecError> { + let mut bytes = [0; N]; + r.read_exact(&mut bytes).map_err(invalid)?; + Ok(bytes) +} + +fn string(r: &mut impl Read, prefix: &mut Vec) -> Result { + for i in 0..256 { + let b = read::<1>(r)?; + prefix.extend(b); + if b == [0] { + return Ok(i != 0); + } + } + Err(limit("EXR attribute name exceeds 255 bytes")) +} + +fn metadata(r: &mut impl Read, cancel: &dyn Fn() -> bool) -> Result, CodecError> { + let start = read::<8>(r)?; + if start.get(..4) != Some(&[0x76, 0x2f, 0x31, 0x01]) { + return Err(invalid("EXR signature")); + } + let version = u32::from_le_bytes(start.get(4..).ok_or_else(|| invalid("version"))?.try_into().map_err(invalid)?); + if version & (0x200 | 0x800 | 0x1000) != 0 { + return Err(unsupported("streaming requires a single flat scanline part")); + } + let mut prefix = start.to_vec(); + let mut attributes = 0; + loop { + if cancel() { + return Err(invalid("EXR import cancelled")); + } + if !string(r, &mut prefix)? { + break; + } + attributes += 1; + if attributes > 4096 { + return Err(limit("EXR metadata exceeds 4096 attributes")); + } + string(r, &mut prefix)?; + let length = read::<4>(r)?; + prefix.extend(length); + let n = usize::try_from(i32::from_le_bytes(length)).map_err(invalid)?; + if n > META_BYTES || prefix.len().checked_add(n).is_none_or(|v| v > META_BYTES) { + return Err(limit("EXR metadata exceeds 8 MiB")); + } + let begin = prefix.len(); + prefix.try_reserve_exact(n).map_err(|_| limit("EXR metadata allocation"))?; + prefix.resize(begin + n, 0); + for bytes in prefix.get_mut(begin..).ok_or_else(|| invalid("attribute size"))?.chunks_mut(64 << 10) { + if cancel() { + return Err(invalid("EXR import cancelled")); + } + r.read_exact(bytes).map_err(invalid)?; + } + } + Ok(prefix) +} + +/// Header facts used by the document owner to admit tile and working memory. +#[derive(Clone, Copy, Debug)] +pub struct Info { + pub width: u32, + pub height: u32, + pub gray: bool, + pub has_alpha: bool, + pub all_half: bool, + pub working_bytes: u64, + pub blocks: usize, +} + +/// Interleaved little-endian F32, GrayA or RGBA, at source resolution. +pub struct Band { + pub x: u32, + pub y: u32, + pub width: u32, + pub height: u32, + pub data: Vec, +} + +pub struct Decoder { + source: R, + meta: MetaData, + info: Info, + offsets: Vec, + seen: Vec, + channels: Vec>, + next: usize, + file_len: u64, + chunks_start: u64, + raw_limit: usize, + rows: usize, +} + +impl Decoder { + /// Read only bounded metadata and offsets; allocate no full-image or channel buffer. + pub fn new(mut source: R, cancel: &dyn Fn() -> bool) -> Result { + let file_len = source.seek(SeekFrom::End(0)).map_err(invalid)?; + source.seek(SeekFrom::Start(0)).map_err(invalid)?; + let prefix = metadata(&mut source, cancel)?; + let meta = std::panic::catch_unwind(|| MetaData::read_from_buffered(Cursor::new(&prefix), false)) + .map_err(|_| invalid("invalid EXR metadata"))? + .map_err(invalid)?; + let header = meta.headers.first().ok_or_else(|| invalid("missing header"))?; + if meta.headers.len() != 1 || header.deep || !matches!(header.blocks, BlockDescription::ScanLines) { + return Err(unsupported("streaming requires a single flat scanline part")); + } + let (w, h) = (header.layer_size.0, header.layer_size.1); + if w == 0 || h == 0 || w > 1 << 18 || h > 1 << 18 { + return Err(limit("EXR dimensions exceed the streaming limit of 262144")); + } + let list = &header.channels.list; + if list.is_empty() || list.len() > 64 || list.iter().any(|c| c.sampling.0 != 1 || c.sampling.1 != 1) { + return Err(unsupported("EXR channel count or subsampling")); + } + let names: Vec = list.iter().map(|c| c.name.to_string()).collect(); + let find = |name| names.iter().position(|n| n.rsplit('.').next() == Some(name)); + let (selected, gray) = if let (Some(r), Some(g), Some(b)) = (find("R"), find("G"), find("B")) { + (vec![Some(r), Some(g), Some(b), find("A")], false) + } else if let Some(y) = find("Y").or_else(|| (list.len() == 1).then_some(0)) { + (vec![Some(y), find("A")], true) + } else { + return Err(unsupported("no R/G/B or Y channels")); + }; + // The document path currently tags Rec. 709 linear samples. Keep custom + // primaries on the existing decoder until their profiles can be mapped. + if !gray && header.shared_attributes.chromaticities.is_some() { + return Err(unsupported("streaming custom EXR chromaticities requires a color profile")); + } + let all_half = selected.iter().flatten().all(|i| list.get(*i).is_some_and(|c| c.sample_type == SampleType::F16)); + let has_alpha = selected.last().copied().flatten().is_some(); + let channels = (0..list.len()).map(|i| selected.iter().position(|s| *s == Some(i))).collect(); + let rows = header.max_block_pixel_size().1; + let expected = h.div_ceil(rows.max(1)); + if rows == 0 || header.chunk_count != expected || expected > MAX_CHUNKS { + return Err(limit("EXR block count exceeds the streaming budget")); + } + let raw_limit = w + .checked_mul(rows) + .and_then(|n| n.checked_mul(header.channels.bytes_per_pixel)) + .filter(|n| *n <= BLOCK_BYTES) + .ok_or_else(|| limit("EXR compression block exceeds 64 MiB"))?; + let output = (w as u64).saturating_mul(rows as u64).saturating_mul(selected.len() as u64 * 4); + let info = Info { + width: w as u32, + height: h as u32, + gray, + has_alpha, + all_half, + blocks: expected, + working_bytes: (raw_limit as u64).saturating_mul(8).saturating_add(output.saturating_mul(2)).saturating_add(1 << 20), + }; + let chunks_start = source + .stream_position() + .map_err(invalid)? + .checked_add(expected as u64 * 8) + .filter(|v| *v <= file_len) + .ok_or_else(|| invalid("truncated offset table"))?; + let mut offsets = Vec::new(); + offsets.try_reserve_exact(expected).map_err(|_| limit("EXR offset allocation"))?; + for _ in 0..expected { + if cancel() { + return Err(invalid("EXR import cancelled")); + } + let offset = u64::from_le_bytes(read(&mut source)?); + if offset < chunks_start || offset.checked_add(8).is_none_or(|v| v > file_len) { + return Err(invalid("EXR chunk offset")); + } + offsets.push(offset); + } + Ok(Self { source, meta, info, offsets, seen: vec![false; expected], channels, next: 0, file_len, chunks_start, raw_limit, rows }) + } + + pub fn info(&self) -> Info { + self.info + } + + /// Decode one block. Check cancellation while reading and between channel rows. + pub fn next_band(&mut self, cancel: &dyn Fn() -> bool) -> Result, CodecError> { + let Some(offset) = self.offsets.get(self.next).copied() else { + return Ok(None); + }; + if cancel() { + return Err(invalid("EXR import cancelled")); + } + self.source.seek(SeekFrom::Start(offset)).map_err(invalid)?; + let y = i32::from_le_bytes(read(&mut self.source)?); + let n = usize::try_from(i32::from_le_bytes(read(&mut self.source)?)).map_err(invalid)?; + if n > self.raw_limit.saturating_mul(2).saturating_add(64 << 10) + || offset < self.chunks_start + || offset.checked_add(8).and_then(|v| v.checked_add(n as u64)).is_none_or(|v| v > self.file_len) + { + return Err(invalid("EXR compressed block length")); + } + let header = self.meta.headers.first().ok_or_else(|| invalid("missing header"))?; + let row = i64::from(y) - i64::from(header.own_attributes.layer_position.1); + if row < 0 || row as u64 >= u64::from(self.info.height) || !(row as usize).is_multiple_of(self.rows) { + return Err(invalid("EXR block coordinate")); + } + let seen = self.seen.get_mut(row as usize / self.rows).ok_or_else(|| invalid("EXR block coordinate"))?; + if *seen { + return Err(invalid("duplicate EXR block")); + } + *seen = true; + let mut packed = Vec::new(); + packed.try_reserve_exact(n).map_err(|_| limit("EXR block allocation"))?; + packed.resize(n, 0); + for bytes in packed.chunks_mut(64 << 10) { + if cancel() { + return Err(invalid("EXR import cancelled")); + } + self.source.read_exact(bytes).map_err(invalid)?; + } + let chunk = + Chunk { layer_index: 0, compressed_block: CompressedBlock::ScanLine(CompressedScanLineBlock { y_coordinate: y, compressed_pixels_le: packed }) }; + let block = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| UncompressedBlock::decompress_chunk(chunk, &self.meta, false))) + .map_err(|_| invalid("invalid EXR compression block"))? + .map_err(invalid)?; + let (w, h) = (block.index.pixel_size.0, block.index.pixel_size.1); + if w != self.info.width as usize + || block.index.pixel_position.0 != 0 + || block.index.pixel_position.1 != row as usize + || h != self.rows.min(self.info.height as usize - row as usize) + { + return Err(invalid("EXR block shape")); + } + let expected = w.checked_mul(h).and_then(|n| n.checked_mul(header.channels.bytes_per_pixel)).ok_or_else(|| invalid("EXR block size"))?; + if block.data.len() != expected { + return Err(invalid("EXR block sample count")); + } + let nc = if self.info.gray { 2 } else { 4 }; + let count = w.checked_mul(h).and_then(|n| n.checked_mul(nc * 4)).ok_or_else(|| limit("EXR output block size"))?; + let mut data = Vec::new(); + data.try_reserve_exact(count).map_err(|_| limit("EXR output allocation"))?; + data.resize(count, 0); + if !self.info.has_alpha { + for pixel in data.chunks_exact_mut(nc * 4) { + pixel.get_mut((nc - 1) * 4..).ok_or_else(|| invalid("alpha sample"))?.copy_from_slice(&1_f32.to_le_bytes()); + } + } + for (range, line) in LineIndex::lines_in_block(block.index, &header.channels) { + if cancel() { + return Err(invalid("EXR import cancelled")); + } + let Some(channel) = self.channels.get(line.channel).copied().flatten() else { + continue; + }; + let sample = header.channels.list.get(line.channel).ok_or_else(|| invalid("EXR channel"))?.sample_type; + let input = block.data.get(range).ok_or_else(|| invalid("EXR row size"))?; + let y = line.position.1.checked_sub(block.index.pixel_position.1).ok_or_else(|| invalid("EXR row coordinate"))?; + let output = data.get_mut(y * w * nc * 4..(y + 1) * w * nc * 4).ok_or_else(|| invalid("EXR row coordinate"))?; + for (bytes, pixel) in input.chunks_exact(sample.bytes_per_sample()).zip(output.chunks_exact_mut(nc * 4)) { + let value = match sample { + SampleType::F16 => half::f16::from_bits(u16::from_ne_bytes(bytes.try_into().map_err(invalid)?)).to_f32(), + SampleType::F32 => f32::from_ne_bytes(bytes.try_into().map_err(invalid)?), + SampleType::U32 => u32::from_ne_bytes(bytes.try_into().map_err(invalid)?) as f32, + }; + pixel.get_mut(channel * 4..channel * 4 + 4).ok_or_else(|| invalid("EXR channel"))?.copy_from_slice(&value.to_le_bytes()); + } + } + self.next += 1; + Ok(Some(Band { x: 0, y: row as u32, width: w as u32, height: h as u32, data })) + } +} diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index 438e1857e4..2f5422e558 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -7,8 +7,9 @@ //! metadata (EXIF, XMP, DPI, text) are preserved where the format allows. //! * [`caps`] declares per-format capabilities and [`fidelity_warnings`] //! tells the UI exactly what an export will lose. -//! * The API is I/O free (`&[u8]` in, `Vec` out) and builds for -//! `wasm32-unknown-unknown`. Decoders enforce [`Limits`] before allocating. +//! * APIs use bytes or caller-provided readers, with no ambient filesystem access, +//! and build for `wasm32-unknown-unknown`. Byte decoders enforce [`Limits`]; +//! [`exr_stream`] instead bounds metadata and one decoded compression block. #![forbid(unsafe_code)] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] @@ -16,6 +17,7 @@ mod codecs; mod error; pub mod exif_color; +pub mod exr_stream; mod fidelity; mod format; mod image; diff --git a/crates/codecs/tests/exr_stream.rs b/crates/codecs/tests/exr_stream.rs new file mode 100644 index 0000000000..acf03e8ae1 --- /dev/null +++ b/crates/codecs/tests/exr_stream.rs @@ -0,0 +1,161 @@ +use photocraft_codecs::{exr_stream::Decoder, *}; +use std::io::Cursor; + +fn fixture(layout: ChannelLayout, sample: SampleType, compression: ExrCompression) -> Vec { + let n = 31 * 35 * layout.channels(); + let values: Vec = (0..n) + .map(|i| match i % 5 { + 0 => -0.25, + 1 => 3.5, + 2 => 0.0, + 3 => 0.0001, + _ => 0.5, + }) + .collect(); + let image = Image::from_f32(31, 35, layout, &values).unwrap().convert(layout, sample); + encode(&image, Format::OpenExr, &EncodeOptions { exr_compression: compression, ..Default::default() }).unwrap() +} + +#[test] +fn scanline_blocks_match_whole_decoder_at_each_layout_depth_and_compression() { + for layout in [ChannelLayout::Gray, ChannelLayout::GrayA, ChannelLayout::Rgb, ChannelLayout::Rgba] { + for sample in [SampleType::F16, SampleType::F32] { + for compression in [ExrCompression::None, ExrCompression::Rle, ExrCompression::Zip1, ExrCompression::Zip16, ExrCompression::Piz] { + let bytes = fixture(layout, sample, compression); + let reference = decode(&bytes).unwrap(); + let target = if layout.is_gray() { ChannelLayout::GrayA } else { ChannelLayout::Rgba }; + let reference = reference.convert(target, SampleType::F32); + let mut stream = Decoder::new(Cursor::new(&bytes), &|| false).unwrap(); + assert_eq!(stream.info().all_half, sample == SampleType::F16); + let row = 31 * target.channels() * 4; + let mut actual = vec![0; reference.data().len()]; + let mut blocks = 0; + while let Some(band) = stream.next_band(&|| false).unwrap() { + actual[band.y as usize * row..(band.y + band.height) as usize * row].copy_from_slice(&band.data); + blocks += 1; + } + assert_eq!(blocks, stream.info().blocks); + assert_eq!(actual, reference.data(), "{layout:?}/{sample:?}/{compression:?}"); + } + } + } +} + +fn change_window(bytes: &mut [u8], width: i32, height: i32) { + let needle = b"dataWindow\0box2i\0\x10\0\0\0"; + let at = bytes.windows(needle.len()).position(|v| v == needle).unwrap() + needle.len(); + bytes[at + 8..at + 12].copy_from_slice(&(width - 1).to_le_bytes()); + bytes[at + 12..at + 16].copy_from_slice(&(height - 1).to_le_bytes()); +} + +#[test] +fn enormous_header_is_not_a_whole_image_allocation() { + let mut bytes = fixture(ChannelLayout::Rgb, SampleType::F16, ExrCompression::Zip16); + change_window(&mut bytes, 65536, 32768); + assert!(matches!(decode(&bytes), Err(CodecError::LimitExceeded(ref message)) if message.contains("2147483648 pixels"))); + // Streaming gets as far as the now-truncated offset table, without a pixel allocation. + assert!(matches!(Decoder::new(Cursor::new(bytes), &|| false), Err(CodecError::Malformed { .. }))); +} + +#[test] +fn cancellation_truncation_duplicate_offsets_and_forged_lengths_fail() { + let bytes = fixture(ChannelLayout::Rgb, SampleType::F16, ExrCompression::Zip16); + assert!(Decoder::new(Cursor::new(&bytes), &|| true).is_err()); + let mut decoder = Decoder::new(Cursor::new(&bytes), &|| false).unwrap(); + assert!(decoder.next_band(&|| true).is_err()); + for end in [0, 8, bytes.len() / 2] { + assert!(Decoder::new(Cursor::new(&bytes[..end]), &|| false).and_then(|mut r| r.next_band(&|| false)).is_err()); + } + // Locate the offset table by finding the validated header end. + let mut input = Cursor::new(&bytes); + let _ = exr::meta::MetaData::read_from_buffered(&mut input, false).unwrap(); + let at = input.position() as usize; + let offset = u64::from_le_bytes(bytes[at..at + 8].try_into().unwrap()) as usize; + let mut corrupt = bytes.clone(); + corrupt[offset + 4..offset + 8].copy_from_slice(&i32::MAX.to_le_bytes()); + assert!(Decoder::new(Cursor::new(corrupt), &|| false).unwrap().next_band(&|| false).is_err()); + let mut duplicate = bytes.clone(); + duplicate[at + 8..at + 16].copy_from_slice(&bytes[at..at + 8]); + let mut decoder = Decoder::new(Cursor::new(duplicate), &|| false).unwrap(); + assert!(decoder.next_band(&|| false).unwrap().is_some()); + assert!(decoder.next_band(&|| false).is_err()); +} + +#[test] +fn hostile_metadata_is_bounded_and_mutated_inputs_never_panic() { + let bytes = fixture(ChannelLayout::Rgb, SampleType::F16, ExrCompression::Zip16); + let mut large = bytes.clone(); + let needle = b"channels\0chlist\0"; + let length = bytes.windows(needle.len()).position(|v| v == needle).unwrap() + needle.len(); + large[length..length + 4].copy_from_slice(&i32::MAX.to_le_bytes()); + assert!(matches!(Decoder::new(Cursor::new(large), &|| false), Err(CodecError::LimitExceeded(_)))); + let mut many = bytes[..8].to_vec(); + for _ in 0..4097 { + many.extend_from_slice(b"z\0string\0\0\0\0\0"); + } + many.push(0); + assert!(matches!(Decoder::new(Cursor::new(many), &|| false), Err(CodecError::LimitExceeded(_)))); + // Small deterministic mutations cover headers, offsets and compressed payloads. + for i in 0..256 { + let mut corrupt = bytes.clone(); + let at = i * (bytes.len() - 1) / 255; + corrupt[at] ^= 0xff; + assert!( + std::panic::catch_unwind(|| { + if let Ok(mut decoder) = Decoder::new(Cursor::new(corrupt), &|| false) { + while let Ok(Some(_)) = decoder.next_band(&|| false) {} + } + }) + .is_ok(), + "mutation at byte {at}" + ); + } +} + +#[test] +fn mixed_samples_and_offset_data_window_keep_coordinates_and_values() { + use exr::prelude::{AnyChannel, AnyChannels, Blocks, Compression, Encoding, FlatSamples, Layer, LayerAttributes, LineOrder, WritableImage}; + let channels = AnyChannels::sort( + vec![ + AnyChannel::new("R", FlatSamples::F16(vec![f16::from_f32(-0.25); 31 * 35])), + AnyChannel::new("G", FlatSamples::F32(vec![3.5; 31 * 35])), + AnyChannel::new("B", FlatSamples::U32(vec![42; 31 * 35])), + ] + .into_iter() + .collect(), + ); + let attributes = LayerAttributes { layer_position: exr::math::Vec2(-15, 40), ..Default::default() }; + let layer = + Layer::new((31, 35), attributes, Encoding { compression: Compression::ZIP16, blocks: Blocks::ScanLines, line_order: LineOrder::Decreasing }, channels); + let mut bytes = Cursor::new(Vec::new()); + exr::image::Image::from_layer(layer).write().to_buffered(&mut bytes).unwrap(); + let mut decoder = Decoder::new(bytes, &|| false).unwrap(); + assert!(!decoder.info().all_half); + let mut rows = 0; + while let Some(band) = decoder.next_band(&|| false).unwrap() { + assert_eq!(band.y, rows); + rows += band.height; + for p in band.data.chunks_exact(16) { + let v: Vec = p.chunks_exact(4).map(|s| f32::from_le_bytes(s.try_into().unwrap())).collect(); + assert_eq!(v, [-0.25, 3.5, 42.0, 1.0]); + } + } + assert_eq!(rows, 35); +} + +#[test] +fn unsupported_variants_stay_on_the_guarded_legacy_path() { + let bytes = fixture(ChannelLayout::Rgb, SampleType::F16, ExrCompression::Zip16); + for flag in [0x200_u32, 0x800, 0x1000] { + let mut changed = bytes.clone(); + changed[4..8].copy_from_slice(&(2 | flag).to_le_bytes()); + assert!(matches!(Decoder::new(Cursor::new(changed), &|| false), Err(CodecError::Unsupported { .. }))); + } + let mut custom = bytes[..8].to_vec(); + custom.extend_from_slice(b"chromaticities\0chromaticities\0\x20\0\0\0"); + for v in [0.64_f32, 0.33, 0.3, 0.6, 0.15, 0.06, 0.3127, 0.329] { + custom.extend_from_slice(&v.to_le_bytes()); + } + custom.extend_from_slice(&bytes[8..]); + assert!(matches!(Decoder::new(Cursor::new(custom), &|| false), Err(CodecError::Unsupported { .. }))); +} diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs index 7689f39679..58c4e6ca7f 100644 --- a/crates/engine/src/file_cmds.rs +++ b/crates/engine/src/file_cmds.rs @@ -357,9 +357,25 @@ pub(crate) fn encode(doc: &Document, path: &str, save: impl Into) -> R } pub(crate) fn save_doc(doc: &Document, path: &str, save: impl Into) -> Result> { - let (bytes, warnings) = encode(doc, path, save)?; - write_file(path, &bytes)?; - Ok(warnings) + #[cfg(not(target_arch = "wasm32"))] + { + let save = save.into(); + let mut opts = photocraft_io::ExportOptions { tiff_layers: save.tiff_layers, ..Default::default() }; + if let Some(q) = save.quality { + let q = (q.clamp(0.0, 12.0) / 12.0 * 99.0 + 1.0).round() as u8; + opts.encode.jpeg_quality = q; + opts.encode.webp_quality = q; + opts.encode.webp_lossless = false; + } + photocraft_io::export_path_with(doc, std::path::Path::new(path), &opts, &photocraft_raster::Interrupt::NONE) + .map_err(|e| EngineError::Other(format!("{path}: {e}"))) + } + #[cfg(target_arch = "wasm32")] + { + let (bytes, warnings) = encode(doc, path, save)?; + write_file(path, &bytes)?; + Ok(warnings) + } } pub(crate) fn str_param<'a>(p: &'a Value, key: &str, cmd: &str) -> Result<&'a str> { @@ -426,8 +442,12 @@ fn close_others(s: &mut Session, p: &Value) -> Result { /// "Revert" history state); the document is clean afterwards. fn revert(s: &mut Session) -> Result { let path = s.active().and_then(|d| d.path.clone()).ok_or(EngineError::Other("the document has never been saved".into()))?; - let bytes = read_file(&path)?; - let fresh = import(&file_name(&path), &bytes)?; + #[cfg(not(target_arch = "wasm32"))] + let fresh = photocraft_io::import_path_with(&file_name(&path), std::path::Path::new(&path), &photocraft_raster::Interrupt::NONE) + .map_err(|e| EngineError::Other(e.to_string()))? + .document; + #[cfg(target_arch = "wasm32")] + let fresh = import(&file_name(&path), &read_file(&path)?)?; s.edit("Revert", |doc, active| { let (id, name) = (doc.id, doc.name.clone()); *doc = fresh; diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index 1fc75ead29..374300ad06 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -258,17 +258,6 @@ fn independent_of_document(id: &str) -> bool { PREFIXES.iter().any(|p| id.starts_with(p)) || IDS.contains(&id) } -#[cfg(not(target_arch = "wasm32"))] -fn read_path(path: &str) -> Result> { - // Bounded reads, and a clear error for a file larger than memory (#375). - photocraft_format::read_file(std::path::Path::new(path)).map_err(|e| EngineError::Other(format!("{path}: {e}"))) -} - -#[cfg(target_arch = "wasm32")] -fn read_path(path: &str) -> Result> { - Err(EngineError::Other(format!("cannot open paths on the web: {path}"))) -} - /// Run a job-capable command's heavy part. `work` runs on a worker thread when the command was /// started with [`Session::start`] (inline otherwise, and always on wasm); `apply` then runs on /// the calling thread with its output and returns the command's result. With `lock_document`, @@ -436,17 +425,19 @@ impl Session { false, move |ctx| { ctx.progress(0.0, "Reading"); - let bytes = match source { - OpenSource::Bytes(b) => b, - OpenSource::Path(p) => Arc::new(read_path(&p)?), - }; ctx.check()?; ctx.progress(0.02, "Decoding"); - ctx.stage(0.02, 1.0, "Decoding", |ctl| photocraft_io::import_with_svg_group_depth_and_interrupt(&name_w, &bytes, max_svg_group_depth, ctl)) - .map_err(|e| match e { - photocraft_io::IoError::Cancelled => EngineError::Cancelled, - e => EngineError::Other(e.to_string()), - }) + ctx.stage(0.02, 1.0, "Decoding", |ctl| match source { + OpenSource::Bytes(b) => photocraft_io::import_with_svg_group_depth_and_interrupt(&name_w, &b, max_svg_group_depth, ctl), + #[cfg(not(target_arch = "wasm32"))] + OpenSource::Path(p) => photocraft_io::import_path_with_svg_group_depth(&name_w, std::path::Path::new(&p), max_svg_group_depth, ctl), + #[cfg(target_arch = "wasm32")] + OpenSource::Path(_) => Err(photocraft_io::IoError::Unsupported("paths cannot be opened on the web".into())), + }) + .map_err(|e| match e { + photocraft_io::IoError::Cancelled => EngineError::Cancelled, + e => EngineError::Other(e.to_string()), + }) }, move |s, r: photocraft_io::ImportResult| { // Ask When Opening: held back until the prompt is answered (`take_held_open`). A diff --git a/crates/format/src/atomic.rs b/crates/format/src/atomic.rs index bf39fe2761..fb86d9eb38 100644 --- a/crates/format/src/atomic.rs +++ b/crates/format/src/atomic.rs @@ -155,6 +155,66 @@ pub fn atomic_write_progress(path: &Path, bytes: &[u8], progress: &dyn Fn(f32)) atomic_write_with_progress(&RealFs, RenameRetry::platform(), path, bytes, progress) } +/// Stream into a new seekable temporary file and publish it only after successful encoding +/// and fsync. A failed encoder, cancellation or full disk leaves the destination intact. +#[cfg(not(target_arch = "wasm32"))] +pub fn atomic_write_stream(path: &Path, encode: impl FnOnce(&mut std::fs::File) -> io::Result<()>) -> io::Result<()> { + atomic_write_stream_with_commit(path, encode, || Ok(())) +} + +/// Like [`atomic_write_stream`], with a final approval immediately before replacement. +/// The encoder and flush remain cancellable; after `commit` succeeds publication runs to +/// completion. A rejected commit or an unwinding callback removes the temporary file. +#[cfg(not(target_arch = "wasm32"))] +pub fn atomic_write_stream_with_commit( + path: &Path, + encode: impl FnOnce(&mut std::fs::File) -> io::Result<()>, + commit: impl FnOnce() -> io::Result<()>, +) -> io::Result<()> { + struct Cleanup(PathBuf); + impl Drop for Cleanup { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.0); + } + } + let target = resolve_symlink(path); + let name = target.file_name().ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "not a file path"))?; + let dir = target.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new(".")); + std::fs::create_dir_all(dir).map_err(|e| write_error(e, path, dir))?; + let mut temp = None; + for _ in 0..8 { + let candidate = dir.join(temp_name(&name.to_string_lossy())); + match std::fs::OpenOptions::new().read(true).write(true).create_new(true).open(&candidate) { + Ok(file) => { + temp = Some((candidate, file)); + break; + } + Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue, + Err(e) => return Err(write_error(e, path, dir)), + } + } + let (tmp, file) = temp.ok_or_else(|| io::Error::new(io::ErrorKind::AlreadyExists, "cannot create a save temporary"))?; + let _cleanup = Cleanup(tmp.clone()); + // Close the handle before cleanup runs during unwinding, including on Windows. + let mut file = file; + let result = encode(&mut file).and_then(|()| sync_file(&file)); + drop(file); + if let Err(e) = result { + let _ = std::fs::remove_file(&tmp); + return Err(write_error(e, path, dir)); + } + if target.exists() { + let _ = RealFs.copy_permissions(&target, &tmp); + } + commit()?; + if let Err(e) = retry_rename(RenameRetry::platform(), || RealFs.rename(&tmp, &target)) { + let _ = RealFs.remove(&tmp); + return Err(rename_error(e, path)); + } + let _ = RealFs.sync_dir(dir); + Ok(()) +} + /// [`atomic_write`] through an explicit file system and retry policy (the failure-injection seam). pub fn atomic_write_with(fs: &dyn AtomicFs, retry: RenameRetry, path: &Path, bytes: &[u8]) -> io::Result<()> { atomic_write_with_progress(fs, retry, path, bytes, &|_| {}) diff --git a/crates/format/src/atomic/tests.rs b/crates/format/src/atomic/tests.rs index 8e71bc7ce3..eef149d476 100644 --- a/crates/format/src/atomic/tests.rs +++ b/crates/format/src/atomic/tests.rs @@ -31,6 +31,74 @@ fn assert_no_temp(dir: &Path) { const ORIGINAL: &[u8] = b"the user's precious original document"; +#[test] +fn streaming_commit_runs_after_encoding_and_before_replacement() { + let dir = TempDir::new("stream-commit"); + let path = dir.0.join("document.psb"); + std::fs::write(&path, ORIGINAL).unwrap(); + let encoded = Cell::new(false); + let result = atomic_write_stream_with_commit( + &path, + |file| { + file.write_all(b"complete")?; + encoded.set(true); + Ok(()) + }, + || { + assert!(encoded.get()); + assert_eq!(std::fs::read(&path).unwrap(), ORIGINAL); + Err(io::Error::new(io::ErrorKind::Interrupted, "cancelled before commit")) + }, + ); + assert_eq!(result.unwrap_err().kind(), io::ErrorKind::Interrupted); + assert_eq!(std::fs::read(&path).unwrap(), ORIGINAL); + assert_no_temp(&dir.0); + + atomic_write_stream_with_commit(&path, |file| file.write_all(b"complete"), || Ok(())).unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), b"complete"); + assert_no_temp(&dir.0); +} + +#[test] +fn streaming_commit_unwind_removes_temporary_and_keeps_original() { + let dir = TempDir::new("stream-commit-unwind"); + let path = dir.0.join("document.psb"); + std::fs::write(&path, ORIGINAL).unwrap(); + let result = std::panic::catch_unwind(|| atomic_write_stream_with_commit(&path, |file| file.write_all(b"complete"), || panic!("injected commit panic"))); + assert!(result.is_err()); + assert_eq!(std::fs::read(&path).unwrap(), ORIGINAL); + assert_no_temp(&dir.0); +} + +#[test] +fn seekable_encoder_failure_keeps_original_and_cleans_temp() { + use std::io::{Seek, SeekFrom}; + let dir = TempDir::new("stream"); + let path = dir.0.join("document.psb"); + std::fs::write(&path, ORIGINAL).unwrap(); + for kind in [io::ErrorKind::StorageFull, io::ErrorKind::Interrupted] { + assert!( + atomic_write_stream(&path, |file| { + file.write_all(b"partial")?; + file.seek(SeekFrom::Start(0))?; + file.write_all(b"PATCH")?; + Err(io::Error::new(kind, "injected encoder failure")) + }) + .is_err() + ); + assert_eq!(std::fs::read(&path).unwrap(), ORIGINAL); + assert_no_temp(&dir.0); + } + atomic_write_stream(&path, |file| { + file.write_all(b"0000payload")?; + file.seek(SeekFrom::Start(0))?; + file.write_all(b"0007") + }) + .unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), b"0007payload"); + assert_no_temp(&dir.0); +} + /// Real file system with failures injected at chosen steps. #[derive(Default)] struct Faulty { @@ -265,3 +333,19 @@ fn sync_file_flushes_a_local_file() { let file = std::fs::OpenOptions::new().write(true).create_new(true).open(&path).unwrap(); sync_file(&file).unwrap(); } + +#[test] +fn streaming_encoder_unwind_closes_and_removes_temporary_file() { + let d = TempDir::new("stream-unwind"); + let p = d.0.join("doc.psb"); + std::fs::write(&p, ORIGINAL).unwrap(); + let result = std::panic::catch_unwind(|| { + let _ = atomic_write_stream(&p, |file| { + file.write_all(b"partial output")?; + panic!("injected encoder unwind"); + }); + }); + assert!(result.is_err()); + assert_eq!(std::fs::read(&p).unwrap(), ORIGINAL); + assert_no_temp(&d.0); +} diff --git a/crates/io/examples/scratch_psb_probe.rs b/crates/io/examples/scratch_psb_probe.rs new file mode 100644 index 0000000000..99c210aae0 --- /dev/null +++ b/crates/io/examples/scratch_psb_probe.rs @@ -0,0 +1,36 @@ +//! Read-only large-document import probe. Never exports or changes the input file. +use photocraft_raster::spill; +use std::time::Instant; + +fn main() -> Result<(), String> { + let mut args = std::env::args().skip(1); + let path = args.next().ok_or("usage: scratch_psb_probe ")?; + let dir = args.next().ok_or("missing scratch directory")?; + spill::configure(Some(std::path::Path::new(&dir)), 1 << 30)?; + let start = Instant::now(); + let result = photocraft_io::import_path_with(&path, std::path::Path::new(&path), &photocraft_raster::Interrupt::NONE).map_err(|e| e.to_string())?; + spill::settle(); + let d = &result.document; + println!( + "import_seconds={:.3} size={}x{} depth={:?} mode={:?} warnings={:?}", + start.elapsed().as_secs_f64(), + d.size.width, + d.size.height, + d.depth, + d.mode, + result.warnings + ); + for (_, _, layer) in d.walk() { + if let Some(surface) = layer.surface() { + // Access source pixels directly, without creating a GPU canvas or flattening. + let bounds = surface.tile_bounds(); + let x = bounds.x0.saturating_add(bounds.width() as i32 / 2); + let y = bounds.y0.saturating_add(bounds.height() as i32 / 2); + println!("layer={:?} tiles={} bounds={bounds:?} sample={:?}", layer.name, surface.tile_count(), surface.pixel(x, y)); + } + } + spill::check_integrity()?; + println!("scratch={}", serde_json::to_string(&spill::stats()).map_err(|e| e.to_string())?); + drop(result); + spill::configure(None, 0) +} diff --git a/crates/io/src/exr_native.rs b/crates/io/src/exr_native.rs new file mode 100644 index 0000000000..0d7555bd7b --- /dev/null +++ b/crates/io/src/exr_native.rs @@ -0,0 +1,76 @@ +//! Native OpenEXR blocks go straight into original-depth tiled storage. +use crate::{ImportResult, IoError}; +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_doc::{Document, Layer, LayerContent}; +use photocraft_geom::{Rect, Size, TILE_SIZE}; +use photocraft_raster::{Interrupt, Surface}; +use std::io::{Read, Seek}; + +pub(crate) fn import(source: R, ctl: &Interrupt<'_>) -> Result, IoError> { + let _metadata = photocraft_raster::memory::try_reserve(32 << 20).map_err(IoError::Unsupported)?; + let mut decoder = match photocraft_codecs::exr_stream::Decoder::new(source, &|| ctl.cancelled()) { + Ok(decoder) => decoder, + Err(_) if ctl.cancelled() => return Err(IoError::Cancelled), + Err(photocraft_codecs::CodecError::Unsupported { .. }) => return Ok(None), + Err(e) => return Err(e.into()), + }; + let info = decoder.info(); + let mode = if info.gray { ColorMode::Grayscale } else { ColorMode::Rgb }; + let fmt = PixelFormat::new(mode, SampleType::F32, true); + let scratch_expected = photocraft_raster::spill::enabled(); + if !scratch_expected { + let budget = photocraft_raster::memory::refresh(); + let side = TILE_SIZE as u64; + let tiles = u64::from(info.width).div_ceil(side).saturating_mul(u64::from(info.height).div_ceil(side)); + crate::psd_stream::ram_tile_capacity( + false, + photocraft_raster::spill::stats().resident_bytes as u64, + budget.tile_bytes, + tiles.saturating_mul(side * side).saturating_mul(fmt.bytes_per_pixel() as u64), + )?; + } + let _working = photocraft_raster::memory::try_reserve(info.working_bytes).map_err(IoError::Unsupported)?; + let mut surface = Surface::new(fmt); + let mut done = 0; + loop { + ctl.check().map_err(|_| IoError::Cancelled)?; + crate::psd_stream::require_scratch(scratch_expected)?; + let band = decoder.next_band(&|| ctl.cancelled()).map_err(|e| if ctl.cancelled() { IoError::Cancelled } else { e.into() })?; + let Some(band) = band else { + break; + }; + let rect = Rect::from_xywh(band.x as i32, band.y as i32, band.width, band.height); + if !photocraft_raster::spill::enabled() { + let missing = rect.tiles().filter(|c| surface.tile(*c).is_none()).count() as u64; + crate::psd_stream::ram_tile_capacity( + false, + photocraft_raster::spill::stats().resident_bytes as u64, + photocraft_raster::memory::stats().1.tile_bytes, + missing.saturating_mul((TILE_SIZE as u64).pow(2)).saturating_mul(fmt.bytes_per_pixel() as u64), + )?; + } + surface.write_interleaved(rect, &band.data); + crate::psd_stream::require_scratch(scratch_expected)?; + done += 1; + ctl.progress(0.05 + 0.9 * done as f32 / info.blocks.max(1) as f32); + } + // Missing alpha is filled with one in every allocated tile. Pruning these opaque + // tiles cannot remove any and would unnecessarily reload the entire scratch store. + if info.has_alpha { + surface.prune(); + } + crate::psd_stream::require_scratch(scratch_expected)?; + ctl.check().map_err(|_| IoError::Cancelled)?; + let mut document = Document::new("", Size::new(info.width, info.height), mode, SampleType::F32); + let mut layer = Layer::new("Background", LayerContent::Raster(surface)); + if !info.has_alpha { + layer.locks.transparency = true; + layer.locks.position = true; + } + document.layers.push(layer); + document.icc_profile = + Some(if mode == ColorMode::Rgb { photocraft_cms::Builtin::LinearSrgb.profile().to_bytes() } else { photocraft_cms::builtin::linear_gray().to_bytes() }); + let warnings = if info.all_half { vec!["16-bit float samples are stored as 32-bit float".into()] } else { Vec::new() }; + ctl.progress(1.0); + Ok(Some(ImportResult { document, warnings, source_read_only: false, preview_only: false })) +} diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 936bab32b9..0cb2b481f9 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -41,6 +41,8 @@ pub mod camera_raw_xmp; mod channel_map; pub mod comps_map; pub mod effects_map; +#[cfg(not(target_arch = "wasm32"))] +mod exr_native; pub mod exr_parts; mod flat; mod gradient_bake; @@ -55,6 +57,8 @@ mod pdn; pub mod pixels; mod psd_export; mod psd_import; +#[cfg(not(target_arch = "wasm32"))] +mod psd_stream; pub mod psd_thumbnail; pub mod raw; pub mod slices_map; @@ -241,6 +245,120 @@ pub fn import(name: &str, bytes: &[u8]) -> Result { import_with_svg_group_depth(name, bytes, photocraft_doc::MAX_GROUP_DEPTH) } +/// Native seekable import. Layered PSD/PSB and flat scanline EXR decode bounded bands directly into tiles; +/// the bytes API remains available for web and for formats without a seekable decoder. +#[cfg(not(target_arch = "wasm32"))] +pub fn import_path_with(name: &str, path: &std::path::Path, ctl: &photocraft_raster::Interrupt<'_>) -> Result { + import_path_with_svg_group_depth(name, path, photocraft_doc::MAX_GROUP_DEPTH, ctl) +} + +/// Seekable import preserving the caller's SVG rasterization preference on fallback. +#[cfg(not(target_arch = "wasm32"))] +pub fn import_path_with_svg_group_depth( + name: &str, + path: &std::path::Path, + max_svg_group_depth: usize, + ctl: &photocraft_raster::Interrupt<'_>, +) -> Result { + use std::io::Read; + ctl.check().map_err(|_| IoError::Cancelled)?; + photocraft_compose::discard_unreadable_pixel_caches(); + photocraft_raster::spill::check_integrity().map_err(IoError::Unsupported)?; + let before = photocraft_raster::spill::read_error_generation(); + let mut source = std::fs::File::open(path).map_err(|e| IoError::Unsupported(e.to_string()))?; + let original = source.metadata().map_err(|e| IoError::Unsupported(e.to_string()))?; + let size = original.len(); + let mut magic = [0; 4]; + let n = source.read(&mut magic).map_err(|e| IoError::Unsupported(e.to_string()))?; + if n == 4 && is_psd(&magic) { + let budget = (photocraft_raster::memory::stats().1.transient_bytes / 8).min(128 << 20); + let _metadata = photocraft_raster::memory::try_reserve(budget.saturating_mul(3)).map_err(IoError::Unsupported)?; + let index = photocraft_psd::stream::Index::read(&mut source, budget, &|| ctl.cancelled()) + .map_err(|e| if ctl.cancelled() { IoError::Cancelled } else { e.into() })?; + if psd_stream::eligible(&index) { + if psd_import::group_depth(&index.metadata) > photocraft_doc::MAX_GROUP_DEPTH { + return Err(IoError::Unsupported("PSD layer nesting exceeds the document limit".into())); + } + let mut result = psd_stream::import(&|| std::fs::File::open(path), &index, *ctl)?; + let current = std::fs::metadata(path).map_err(|e| IoError::Unsupported(e.to_string()))?; + if current.len() != original.len() || current.modified().ok() != original.modified().ok() { + return Err(IoError::Unsupported("the source file changed while it was being opened; open it again".into())); + } + result.document.name = name.into(); + photocraft_raster::spill::check_since(before).map_err(IoError::Unsupported)?; + ctl.check().map_err(|_| IoError::Cancelled)?; + return Ok(result); + } + } + if n == 4 + && photocraft_codecs::detect(&magic) == Some(photocraft_codecs::Format::OpenExr) + && let Some(mut result) = exr_native::import(&mut source, ctl)? + { + let current = std::fs::metadata(path).map_err(|e| IoError::Unsupported(e.to_string()))?; + if current.len() != original.len() || current.modified().ok() != original.modified().ok() { + return Err(IoError::Unsupported("the source file changed while it was being opened; open it again".into())); + } + result.document.name = name.into(); + photocraft_raster::spill::check_since(before).map_err(IoError::Unsupported)?; + ctl.check().map_err(|_| IoError::Cancelled)?; + return Ok(result); + } + drop(source); + let _bytes = photocraft_raster::memory::try_reserve(size) + .map_err(|_| IoError::Unsupported("this format requires a whole-file decoder and exceeds the working-memory budget".into()))?; + let bytes = photocraft_format::read_file(path).map_err(|e| IoError::Unsupported(e.to_string()))?; + import_with_svg_group_depth_and_interrupt(name, &bytes, max_svg_group_depth, ctl) +} + +/// Read from handles already authorized by the caller. Independent opens prevent channel +/// readers sharing a seek cursor, and no ambient filesystem access occurs in this function. +#[cfg(not(target_arch = "wasm32"))] +pub fn import_seekable_with( + name: &str, + open: &dyn Fn() -> std::io::Result, + ctl: &photocraft_raster::Interrupt<'_>, +) -> Result { + use std::io::SeekFrom; + ctl.check().map_err(|_| IoError::Cancelled)?; + photocraft_compose::discard_unreadable_pixel_caches(); + photocraft_raster::spill::check_integrity().map_err(IoError::Unsupported)?; + let before = photocraft_raster::spill::read_error_generation(); + let mut source = open().map_err(|e| IoError::Unsupported(e.to_string()))?; + let size = source.seek(SeekFrom::End(0)).map_err(|e| IoError::Unsupported(e.to_string()))?; + source.seek(SeekFrom::Start(0)).map_err(|e| IoError::Unsupported(e.to_string()))?; + let mut magic = [0; 4]; + let n = source.read(&mut magic).map_err(|e| IoError::Unsupported(e.to_string()))?; + if n == 4 && is_psd(&magic) { + let budget = (photocraft_raster::memory::stats().1.transient_bytes / 8).min(128 << 20); + let _metadata = photocraft_raster::memory::try_reserve(budget.saturating_mul(3)).map_err(IoError::Unsupported)?; + let index = photocraft_psd::stream::Index::read(&mut source, budget, &|| ctl.cancelled()) + .map_err(|e| if ctl.cancelled() { IoError::Cancelled } else { e.into() })?; + if psd_stream::eligible(&index) { + if psd_import::group_depth(&index.metadata) > photocraft_doc::MAX_GROUP_DEPTH { + return Err(IoError::Unsupported("PSD layer nesting exceeds the document limit".into())); + } + let mut result = psd_stream::import(open, &index, *ctl)?; + result.document.name = name.into(); + photocraft_raster::spill::check_since(before).map_err(IoError::Unsupported)?; + ctl.check().map_err(|_| IoError::Cancelled)?; + return Ok(result); + } + } + if n == 4 + && photocraft_codecs::detect(&magic) == Some(photocraft_codecs::Format::OpenExr) + && let Some(mut result) = exr_native::import(&mut source, ctl)? + { + result.document.name = name.into(); + photocraft_raster::spill::check_since(before).map_err(IoError::Unsupported)?; + ctl.check().map_err(|_| IoError::Cancelled)?; + return Ok(result); + } + source.seek(SeekFrom::Start(0)).map_err(|e| IoError::Unsupported(e.to_string()))?; + let _working = photocraft_raster::memory::try_reserve_operation(size).map_err(IoError::Unsupported)?; + let bytes = photocraft_format::read::read_all(&mut source, size).map_err(|e| IoError::Unsupported(e.to_string()))?; + import_with(name, &bytes, ctl) +} + /// [`import`] for a background open: checks `ctl` between stages (and per layer for PSD/PSB) and /// reports progress. A cancelled import fails with [`IoError::Cancelled`]. pub fn import_with(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt) -> Result { @@ -359,6 +477,17 @@ pub fn export(doc: &Document, name_or_ext: &str, opts: &ExportOptions) -> Result /// export fails with [`IoError::Cancelled`]; otherwise the bytes are exactly [`export`]'s. pub fn export_with(doc: &Document, name_or_ext: &str, opts: &ExportOptions, ctl: &photocraft_raster::Interrupt) -> Result { ctl.check().map_err(|_| IoError::Cancelled)?; + // The byte API has to retain its complete output. Native large saves use the seekable + // path below; reject an inadmissible legacy working set before building channel arrays. + let tiles: u64 = doc + .walk() + .iter() + .filter_map(|(_, _, l)| l.surface()) + .map(|s| (s.tile_count() as u64).saturating_mul(256 * 256).saturating_mul(s.format().bytes_per_pixel() as u64)) + .sum(); + let estimate = doc.size.area().saturating_mul(32).saturating_add(tiles.saturating_mul(3)); + let _working = photocraft_raster::memory::try_reserve_operation(estimate) + .map_err(|_| IoError::Unsupported("export exceeds the working-memory budget; use native PSB streaming or export a smaller region".into()))?; photocraft_compose::discard_unreadable_pixel_caches(); photocraft_raster::spill::check_integrity().map_err(IoError::Unsupported)?; let scratch_generation = photocraft_raster::spill::read_error_generation(); @@ -369,6 +498,42 @@ pub fn export_with(doc: &Document, name_or_ext: &str, opts: &ExportOptions, ctl: Ok(r) } +/// Native atomic save. PSB writes metadata and bounded original-pixel bands directly to disk; +/// formats with only a bytes encoder use the checked working-set admission in [`export`]. +#[cfg(not(target_arch = "wasm32"))] +pub fn export_path_with(doc: &Document, path: &std::path::Path, opts: &ExportOptions, ctl: &photocraft_raster::Interrupt<'_>) -> Result, IoError> { + ctl.check().map_err(|_| IoError::Cancelled)?; + let ext = path.extension().and_then(|s| s.to_str()).unwrap_or("").to_ascii_lowercase(); + if ext == "psb" || ext == "psd" && (opts.force_psb || doc.size.width > 30_000 || doc.size.height > 30_000) { + photocraft_raster::spill::check_integrity().map_err(IoError::Unsupported)?; + let mut warnings = Vec::new(); + let result = photocraft_format::atomic::atomic_write_stream_with_commit( + path, + |out| { + warnings = psd_stream::export_into(doc, out, *ctl).map_err(|e| std::io::Error::other(e.to_string()))?; + Ok(()) + }, + || ctl.check().map_err(|e| std::io::Error::new(std::io::ErrorKind::Interrupted, e)), + ); + if result.is_err() && ctl.cancelled() { + return Err(IoError::Cancelled); + } + result.map_err(|e| IoError::Unsupported(e.to_string()))?; + return Ok(warnings); + } + let result = export(doc, &path.to_string_lossy(), opts)?; + ctl.check().map_err(|_| IoError::Cancelled)?; + photocraft_format::atomic_write(path, &result.bytes).map_err(|e| IoError::Unsupported(e.to_string()))?; + Ok(result.warnings) +} + +/// Write a native PSB to an already authorized seekable output. The caller owns atomic +/// publication and cleanup; this never opens an ambient path. +#[cfg(not(target_arch = "wasm32"))] +pub fn export_psb_into(doc: &Document, out: &mut W, ctl: &photocraft_raster::Interrupt<'_>) -> Result, IoError> { + psd_stream::export_into(doc, out, *ctl) +} + /// Run `f` with an interrupt whose progress `0..=1` fills `lo..=hi` of `ctl`'s (same cancellation). pub(crate) fn with_span(ctl: &photocraft_raster::Interrupt, lo: f32, hi: f32, f: impl FnOnce(&photocraft_raster::Interrupt) -> R) -> R { let cancelled = || ctl.cancelled(); diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index 9db3d4799f..e2117b237e 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -81,6 +81,8 @@ struct Ex { patterns: Vec, /// The layer written as Photoshop's Background (see [`psd_background`]). background: Option, + deferred: bool, + sources: StreamSources, } /// The layer Photoshop should open as its Background: the bottom top-level layer when it is a @@ -103,10 +105,14 @@ fn psd_background(doc: &Document) -> Option { return None; } let n = s.channels(); - let opaque = s.read_region(canvas).chunks_exact(n).all(|p| p.last().is_some_and(|a| *a >= 1.0)); + let opaque = canvas + .tiles() + .all(|tc| s.try_read_region(tc.rect().intersect(&canvas)).is_ok_and(|pixels| pixels.chunks_exact(n).all(|p| p.last().is_some_and(|a| *a >= 1.0)))); opaque.then_some(l.id) } +pub(crate) type StreamSources = std::collections::HashMap, Option)>; + /// How deep embedded documents are exported inside each other before a smart object is written /// as pixels instead. const MAX_NESTING: u32 = 8; @@ -336,10 +342,13 @@ impl Ex { } fn empty_channels(&self) -> Vec { + if self.deferred { + return (-1..self.cc as i16).map(|id| ChannelData { id, compression: Some(Compression::Raw), data: Vec::new() }).collect(); + } (-1..self.cc as i16).map(|id| self.encode(id, &[], 0, 0)).collect() } - fn pixel_channels(&self, s: &Surface, name: &str) -> (PsdRect, Vec, Vec) { + fn pixel_channels(&mut self, s: &Surface, name: &str) -> (PsdRect, Vec, Vec, Option) { let mut warnings = Vec::new(); let s = if s.format() != self.fmt { warnings.push(format!("layer \"{name}\": pixels converted from {:?} to {:?}", s.format(), self.fmt)); @@ -349,7 +358,11 @@ impl Ex { }; let r = s.content_bounds(); if r.is_empty() { - return (PsdRect::default(), self.empty_channels(), warnings); + return (PsdRect::default(), self.empty_channels(), warnings, None); + } + if self.deferred { + let channels = (-1..self.cc as i16).map(|id| ChannelData { id, compression: Some(Compression::Raw), data: Vec::new() }).collect(); + return (to_psd_rect(r), channels, warnings, Some(s)); } // The interleaved gather is the sequential pass for full-canvas layers, so it runs // in row bands on all cores and the band results concatenate in order. @@ -373,15 +386,15 @@ impl Ex { let order: Vec<(i16, &Vec)> = std::iter::once((-1, &planes[self.cc])).chain(planes.iter().take(self.cc).enumerate().map(|(c, p)| (c as i16, p))).collect(); let ch = crate::pixels::par_map(order, |(id, plane)| self.encode(id, plane, w, h)); - (to_psd_rect(r), ch, warnings) + (to_psd_rect(r), ch, warnings, None) } - fn mask(&self, m: &LayerMask, vector: Option<(f32, f32)>) -> (MaskData, Option) { + fn mask(&self, m: &LayerMask, vector: Option<(f32, f32)>) -> (MaskData, Option, Option) { let s = if m.surface.format() != self.mask_fmt { m.surface.convert(self.mask_fmt) } else { m.surface.clone() }; let default = s.default_pixel().first().copied().unwrap_or(0.0); let r = s.content_bounds(); let (w, h) = (r.width() as usize, r.height() as usize); - let plane = if r.is_empty() { Vec::new() } else { deinterleave(&s.to_interleaved(r), 1, self.mask_fmt.sample, &[false]).remove(0) }; + let plane = if self.deferred || r.is_empty() { Vec::new() } else { deinterleave(&s.to_interleaved(r), 1, self.mask_fmt.sample, &[false]).remove(0) }; let mut flags = 0u8; if !m.linked { flags |= PsdMask::FLAG_RELATIVE; @@ -402,7 +415,11 @@ impl Ex { real: None, real_first: true, }; - (MaskData::Mask(pm), Some(self.encode(-2, &plane, w, h))) + if self.deferred { + (MaskData::Mask(pm), Some(ChannelData { id: -2, compression: Some(Compression::Raw), data: Vec::new() }), Some(s)) + } else { + (MaskData::Mask(pm), Some(self.encode(-2, &plane, w, h)), None) + } } /// Prefer numbers above imported layer IDs, wrapping safely at u32::MAX. @@ -424,16 +441,18 @@ impl Ex { fn record(&mut self, l: &Layer, extra: Vec, pixels: Option<&Surface>) -> LayerRecord { let id = self.layer_id(l); - let (rect, mut channels, warnings) = match pixels { + let (rect, mut channels, warnings, source) = match pixels { Some(s) => self.pixel_channels(s, &l.name), - None => (PsdRect::default(), self.empty_channels(), Vec::new()), + None => (PsdRect::default(), self.empty_channels(), Vec::new(), None), }; self.warnings.extend(warnings); let mut mask = MaskData::None; + let mut mask_source = None; // Vector mask density/feather live in the mask parameters, with or without a user mask. let vector = l.vector_mask.as_ref().map(|vm| (vm.density, vm.feather)); if let Some(m) = &l.mask { - let (md, ch) = self.mask(m, vector); + let (md, ch, s) = self.mask(m, vector); + mask_source = s; mask = md; channels.extend(ch); } else if let Some(parameters) = mask_parameters(None, vector) { @@ -448,6 +467,9 @@ impl Ex { real_first: true, }); } + if self.deferred { + self.sources.insert(id, (source, mask_source)); + } // A Background carries no transparency channel: that is how Photoshop tells it apart. if self.background == Some(l.id) { channels.retain(|c| c.id != -1); @@ -978,7 +1000,7 @@ impl Ex { } let doc = photocraft_format::load_from_bytes(bytes).map_err(|e| format!("its contents can't be read: {e}"))?; let (file, warnings) = - document_to_psd_nested(&doc, &PsdExportOptions { force_psb: true, ..Default::default() }, self.smart.depth + 1, &Interrupt::NONE); + document_to_psd_nested(&doc, &PsdExportOptions { force_psb: true, ..Default::default() }, self.smart.depth + 1, &Interrupt::NONE, None); let data = file.to_bytes().map_err(|e| format!("its contents can't be written: {e}"))?; let stem = file_name.rsplit_once('.').map_or(file_name, |(a, _)| a); self.warnings.extend(warnings.into_iter().map(|w| format!("smart object {stem}: {w}"))); @@ -1343,14 +1365,21 @@ pub fn document_to_psd(doc: &Document) -> PsdFile { /// anything that could not be represented. The merged composite is rendered /// with `photocraft_compose::flatten`. pub fn document_to_psd_with(doc: &Document, opts: &PsdExportOptions) -> (PsdFile, Vec) { - document_to_psd_nested(doc, opts, 0, &Interrupt::NONE) + document_to_psd_nested(doc, opts, 0, &Interrupt::NONE, None) +} + +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn stream_metadata(doc: &Document, opts: &PsdExportOptions) -> (PsdFile, Vec, StreamSources) { + let mut sources = StreamSources::new(); + let (file, warnings) = document_to_psd_nested(doc, opts, 0, &Interrupt::NONE, Some(&mut sources)); + (file, warnings, sources) } /// [`document_to_psd_with`] for a background save (#3389): reports progress through `ctl` after /// each layer record and each band of the merged composite. Once `ctl` is cancelled it stops /// early and returns an **incomplete** model: callers check `ctl` and discard it. pub(crate) fn document_to_psd_interruptible(doc: &Document, opts: &PsdExportOptions, ctl: &Interrupt) -> (PsdFile, Vec) { - document_to_psd_nested(doc, opts, 0, ctl) + document_to_psd_nested(doc, opts, 0, ctl, None) } /// How much of a PSD conversion the merged composite weighs, in layer records: rendering it @@ -1480,7 +1509,13 @@ pub(crate) fn psd_size_exceeds_limit(doc: &Document) -> bool { } /// [`document_to_psd_with`] for a document embedded `depth` smart objects deep. -fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, ctl: &Interrupt) -> (PsdFile, Vec) { +fn document_to_psd_nested( + doc: &Document, + opts: &PsdExportOptions, + depth: u32, + ctl: &Interrupt, + deferred: Option<&mut StreamSources>, +) -> (PsdFile, Vec) { if doc.mode == ColorMode::Multichannel { return crate::multichannel_map::document_to_psd(doc, opts.force_psb); } @@ -1510,6 +1545,8 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c smart: SmartOut::new(doc, depth), patterns: doc.patterns.clone(), background: psd_background(doc), + deferred: deferred.is_some(), + sources: Default::default(), }; if big && !opts.force_psb { ex.warnings.push("document exceeds 30000 px; written as PSB".into()); @@ -1584,7 +1621,9 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c // isn't rendered at all. A document without layer records keeps the real image: its pixels // live nowhere else. let real_merged = opts.maximize_compatibility || ex.records.is_empty(); - let (mut planes, has_alpha) = if real_merged { + let (mut planes, has_alpha) = if ex.deferred { + (Vec::new(), true) + } else if real_merged { // Merged composite, rendered and encoded in bands (no full-size float composite). Matting // against white only changes pixels with alpha < 1; if some are slightly translucent but // all round to opaque (so no alpha channel is written), encode once more without the matte. @@ -1622,6 +1661,9 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c None => None, }; for a in &extra { + if ex.deferred { + continue; + } let s = if a.surface.format() != ex.mask_fmt { a.surface.convert(ex.mask_fmt) } else { a.surface.clone() }; let bytes = s.to_interleaved(canvas); planes.extend(deinterleave(&bytes, 1, sample, &[false]).remove(0)); @@ -1629,9 +1671,13 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c let channels = (cc + usize::from(has_alpha) + extra.len()) as u16; let header = Header::new(version, doc.size.width, doc.size.height, channels, psd_depth(sample), psd_mode(fmt.mode)); let mcomp = if sample == SampleType::F32 { Compression::Raw } else { opts.merged_compression }; - let image_data = ImageData::encode(mcomp, &planes, &header) - .or_else(|_| ImageData::encode(Compression::Raw, &planes, &header)) - .unwrap_or(ImageData { compression: Compression::Raw, data: planes }); + let image_data = if ex.deferred { + ImageData { compression: Compression::Raw, data: Vec::new() } + } else { + ImageData::encode(mcomp, &planes, &header) + .or_else(|_| ImageData::encode(Compression::Raw, &planes, &header)) + .unwrap_or(ImageData { compression: Compression::Raw, data: planes }) + }; ctl.progress(1.0); // Resources. @@ -1777,6 +1823,9 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c layer_mask_trailing: Vec::new(), image_data, }; + if let Some(out) = deferred { + *out = ex.sources; + } (file, ex.warnings) } diff --git a/crates/io/src/psd_import.rs b/crates/io/src/psd_import.rs index 444c2d6e5b..82814f3762 100644 --- a/crates/io/src/psd_import.rs +++ b/crates/io/src/psd_import.rs @@ -66,6 +66,7 @@ pub(crate) struct Ctx<'a> { /// Layer records decoded so far, out of `total` (progress). pub done: usize, pub total: usize, + pub streamed: Option<&'a std::collections::HashMap<(usize, i16), Surface>>, } fn doc_mode(m: PsdMode) -> Option { @@ -171,6 +172,9 @@ impl Ctx<'_> { } fn record_surface(&mut self, rec: &LayerRecord, name: &str) -> Surface { + if let Some(streamed) = self.streamed { + return streamed.get(&(rec as *const LayerRecord as usize, i16::MIN)).cloned().unwrap_or_else(|| Surface::new(self.fmt)); + } let r = rec.rect; if r.is_empty() || r.size().is_err() { return Surface::new(self.fmt); @@ -231,7 +235,11 @@ impl Ctx<'_> { } }; let mut surface = Surface::with_default(self.mask_fmt, &[f32::from(default) / 255.0]); - if !rect.is_empty() + if let Some(streamed) = self.streamed { + if let Some(decoded) = streamed.get(&(rec as *const LayerRecord as usize, id)) { + surface = decoded.clone(); + } + } else if !rect.is_empty() && let Ok((w, h)) = rect.size() && let Some(plane) = self.channel_plane(rec, id, name) { @@ -657,6 +665,14 @@ pub fn psd_to_document(file: &PsdFile) -> (Document, Vec) { /// [`psd_to_document`] for a background open: checks `ctl` per layer record and reports /// progress. `None` when cancelled. pub fn psd_to_document_with(file: &PsdFile, ctl: &photocraft_raster::Interrupt) -> Option<(Document, Vec)> { + psd_to_document_decoded(file, ctl, None) +} + +pub(crate) fn psd_to_document_decoded<'a>( + file: &'a PsdFile, + ctl: &photocraft_raster::Interrupt<'a>, + streamed: Option<&'a std::collections::HashMap<(usize, i16), Surface>>, +) -> Option<(Document, Vec)> { let h = &file.header; let mut warnings = Vec::new(); let mode = doc_mode(h.color_mode).unwrap_or_else(|| { @@ -754,6 +770,7 @@ pub fn psd_to_document_with(file: &PsdFile, ctl: &photocraft_raster::Interrupt) ctl: *ctl, done: 0, total: file.layers().len(), + streamed, }; for b in file.global_blocks.iter().filter(|b| matches!(&b.key, b"FEid" | b"FXid")) { // Smart-filter caches can be large: a cancelled open stops between blocks. diff --git a/crates/io/src/psd_stream.rs b/crates/io/src/psd_stream.rs new file mode 100644 index 0000000000..c11a9e593b --- /dev/null +++ b/crates/io/src/psd_stream.rs @@ -0,0 +1,395 @@ +//! Native import without a whole PSB byte buffer or whole decoded channel buffers. +use crate::{ + ImportResult, IoError, + pixels::{interleave, max_sample, zero_sample}, +}; +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_geom::Rect; +use photocraft_psd::{ + LayerRecord, + stream::{Index, Rows}, +}; +use photocraft_raster::{Interrupt, Surface}; +use std::{ + collections::HashMap, + io::{BufReader, Read, Seek, SeekFrom, Write}, +}; + +pub(crate) fn eligible(index: &Index) -> bool { + let file = &index.metadata; + matches!( + file.header.color_mode, + photocraft_psd::ColorMode::Rgb | photocraft_psd::ColorMode::Grayscale | photocraft_psd::ColorMode::Cmyk | photocraft_psd::ColorMode::Lab + ) && matches!(file.header.depth, 8 | 16 | 32) + && !file.layers().is_empty() + && usize::from(file.header.channels) <= usize::from(file.header.color_mode.color_channels().unwrap_or(0)) + usize::from(file.merged_has_alpha()) +} + +struct Decode<'a, R> { + open: &'a dyn Fn() -> std::io::Result, + index: &'a Index, + ctl: Interrupt<'a>, + done: u64, + total: u64, + scratch_expected: bool, +} + +fn tile_footprint(r: photocraft_psd::Rect, bytes_per_pixel: usize) -> u64 { + if r.is_empty() { + return 0; + } + let side = i64::from(photocraft_geom::TILE_SIZE); + let columns = ((i64::from(r.right) - 1).div_euclid(side) - i64::from(r.left).div_euclid(side) + 1) as u64; + let rows = ((i64::from(r.bottom) - 1).div_euclid(side) - i64::from(r.top).div_euclid(side) + 1) as u64; + columns.saturating_mul(rows).saturating_mul(side as u64 * side as u64).saturating_mul(bytes_per_pixel as u64) +} + +pub(super) fn ram_tile_capacity(scratch: bool, resident: u64, budget: u64, growth: u64) -> Result<(), IoError> { + if !scratch && resident.checked_add(growth).is_none_or(|required| required > budget) { + return Err(IoError::Unsupported("decoded image tiles exceed the available RAM budget; enable a scratch disk or close other documents".into())); + } + Ok(()) +} + +pub(super) fn require_scratch(expected: bool) -> Result<(), IoError> { + if expected && !photocraft_raster::spill::enabled() { + return Err(IoError::Unsupported( + photocraft_raster::spill::last_error().unwrap_or_else(|| "scratch storage was disabled while loading; retry the import".into()), + )); + } + Ok(()) +} + +impl Decode<'_, R> { + fn surface(&mut self, layer: usize, rec: &LayerRecord, ids: &[i16], fmt: PixelFormat, default: &[f32]) -> Result { + let r = if ids.len() == 1 { rec.channel_rect(*ids.first().ok_or_else(|| IoError::Unsupported("empty channel set".into()))?) } else { rec.rect }; + if r.is_empty() { + return Ok(Surface::with_default(fmt, default)); + } + let (width, height) = r.size()?; + let working = + (width as u64).saturating_mul(64).saturating_mul(fmt.bytes_per_pixel() as u64).saturating_mul(3).saturating_add(ids.len() as u64 * (3 << 20)); + let _working = photocraft_raster::memory::try_reserve(working).map_err(IoError::Unsupported)?; + let ranges = self.index.channels.get(layer).ok_or_else(|| IoError::Unsupported("missing indexed layer".into()))?; + let mut readers = Vec::new(); + for id in ids { + readers.push(if let Some(range) = ranges.iter().find(|c| c.id == *id) { + Some(Rows::new( + BufReader::with_capacity(1 << 20, (self.open)().map_err(|e| IoError::Unsupported(e.to_string()))?), + *range, + width, + height, + self.index.metadata.header.depth, + self.index.metadata.header.version, + )?) + } else { + None + }); + } + let mut out = Surface::with_default(fmt, default); + let fill: Vec> = ids.iter().map(|id| if *id == -1 { max_sample(fmt.sample) } else { zero_sample(fmt.sample) }).collect(); + let invert: Vec = ids.iter().map(|id| fmt.mode == ColorMode::Cmyk && *id >= 0).collect(); + let mut planes: Vec> = ids.iter().map(|_| Vec::new()).collect(); + let mut row = Vec::new(); + let mut y = 0usize; + while y < height { + self.ctl.check().map_err(|_| IoError::Cancelled)?; + let rows = (height - y).min(64); + for (reader, plane) in readers.iter_mut().zip(&mut planes) { + plane.clear(); + if let Some(reader) = reader { + for _ in 0..rows { + self.ctl.check().map_err(|_| IoError::Cancelled)?; + reader.read_row(&mut row)?; + plane.extend_from_slice(&row); + } + } + } + let refs: Vec> = readers.iter().zip(&planes).map(|(r, p)| r.as_ref().map(|_| p.as_slice())).collect(); + let mut bytes = interleave(&refs, &fill, width * rows, fmt.sample, &invert); + if fmt.mode == ColorMode::Lab && fmt.sample == SampleType::U16 { + crate::pixels::lab16_chroma(&mut bytes, ids.len(), true); + } + let top = i32::try_from(i64::from(r.top) + y as i64).map_err(|_| IoError::Unsupported("layer coordinates overflow".into()))?; + let bottom = top.checked_add(rows as i32).ok_or_else(|| IoError::Unsupported("layer coordinates overflow".into()))?; + let band = Rect::new(r.left, top, r.right, bottom); + let scratch = photocraft_raster::spill::enabled(); + require_scratch(self.scratch_expected)?; + if !scratch { + let missing = band.tiles().filter(|c| out.tile(*c).is_none()).count() as u64; + let growth = missing.saturating_mul((photocraft_geom::TILE_SIZE as u64).pow(2)).saturating_mul(fmt.bytes_per_pixel() as u64); + ram_tile_capacity(false, photocraft_raster::spill::stats().resident_bytes as u64, photocraft_raster::memory::stats().1.tile_bytes, growth)?; + } + out.write_interleaved(band, &bytes); + // A final band can trigger a scratch write failure too. + require_scratch(self.scratch_expected)?; + y += rows; + self.done = self.done.saturating_add(rows as u64 * ids.len() as u64); + self.ctl.progress(0.05 + 0.85 * self.done as f32 / self.total.max(1) as f32); + } + for reader in readers.into_iter().flatten() { + reader.finish()?; + } + out.prune(); + require_scratch(self.scratch_expected)?; + Ok(out) + } +} + +pub(crate) fn import(open: &dyn Fn() -> std::io::Result, index: &Index, ctl: Interrupt<'_>) -> Result { + let file = &index.metadata; + let mode = match file.header.color_mode { + photocraft_psd::ColorMode::Rgb => ColorMode::Rgb, + photocraft_psd::ColorMode::Grayscale => ColorMode::Grayscale, + photocraft_psd::ColorMode::Cmyk => ColorMode::Cmyk, + photocraft_psd::ColorMode::Lab => ColorMode::Lab, + _ => return Err(IoError::Unsupported("stream color mode".into())), + }; + let sample = crate::pixels::sample_for_depth(file.header.depth); + let fmt = PixelFormat::new(mode, sample, true); + let scratch_expected = photocraft_raster::spill::enabled(); + if !scratch_expected { + let budget = photocraft_raster::memory::refresh(); + let required = file.layers().iter().fold(0_u64, |sum, rec| { + let mut bytes = tile_footprint(rec.rect, fmt.bytes_per_pixel()); + for id in [-2, -3] { + if rec.channel(id).is_some() { + bytes = bytes.saturating_add(tile_footprint(rec.channel_rect(id), sample.bytes())); + } + } + sum.saturating_add(bytes) + }); + ram_tile_capacity(false, photocraft_raster::spill::stats().resident_bytes as u64, budget.tile_bytes, required)?; + } + let total = file.layers().iter().map(|r| r.channel_rect(0).size().map_or(0, |(_, h)| h as u64 * (mode.color_channels() as u64 + 1))).sum(); + let mut decoder = Decode { open, index, ctl, done: 0, total, scratch_expected }; + let mut surfaces = HashMap::new(); + for (i, rec) in file.layers().iter().enumerate() { + ctl.check().map_err(|_| IoError::Cancelled)?; + let key = rec as *const LayerRecord as usize; + let ids: Vec = (0..mode.color_channels() as i16).chain([-1]).collect(); + surfaces.insert((key, i16::MIN), decoder.surface(i, rec, &ids, fmt, &vec![0.0; fmt.channels()])?); + for id in [-2, -3] { + if rec.channel(id).is_some() { + let default = rec.layer_mask().map_or(0, |m| if id == -3 { m.real.map_or(m.default_color, |m| m.background) } else { m.default_color }); + let fmt = PixelFormat::new(ColorMode::Grayscale, sample, false); + surfaces.insert((key, id), decoder.surface(i, rec, &[id], fmt, &[f32::from(default) / 255.0])?); + } + } + } + let (document, warnings) = crate::psd_import::psd_to_document_decoded(file, &ctl, Some(&surfaces)).ok_or(IoError::Cancelled)?; + Ok(ImportResult { document, warnings, source_read_only: false, preview_only: false }) +} + +fn sink_error(e: std::io::Error) -> photocraft_psd::PsdError { + photocraft_psd::PsdError::Invalid(format!("stream output: {e}")) +} + +pub(crate) fn export_into(doc: &photocraft_doc::Document, out: &mut W, ctl: Interrupt<'_>) -> Result, IoError> { + if !matches!(doc.mode, ColorMode::Rgb | ColorMode::Grayscale | ColorMode::Cmyk | ColorMode::Lab) { + return Err(IoError::Unsupported("seekable PSB export requires RGB, Gray, CMYK or Lab".into())); + } + ctl.check().map_err(|_| IoError::Cancelled)?; + let before = photocraft_raster::spill::read_error_generation(); + let fmt = doc.pixel_format(); + let metadata: u64 = doc + .metadata + .psd_resources + .iter() + .map(|(_, _, bytes)| bytes.len() as u64) + .chain(doc.metadata.psd_global_blocks.iter().map(|(_, _, bytes)| bytes.len() as u64)) + .chain(doc.walk().iter().flat_map(|(_, _, l)| l.psd_blocks.iter().map(|(_, b)| b.len() as u64))) + .fold(0_u64, u64::saturating_add); + let embedded = doc + .walk() + .iter() + .filter_map(|(_, _, l)| match &l.content { + photocraft_doc::LayerContent::Smart(sm) => match &sm.source { + photocraft_doc::SmartSource::Embedded { bytes, .. } => Some(bytes.len() as u64), + _ => None, + }, + _ => None, + }) + .fold(0_u64, u64::saturating_add); + let working = u64::from(doc.size.width) + .saturating_mul(64) + .saturating_mul(128) + .saturating_add(64 << 20) + .saturating_add(metadata.saturating_mul(4)) + .saturating_add(embedded.saturating_mul(4)); + let _working = photocraft_raster::memory::try_reserve(working).map_err(IoError::Unsupported)?; + let opts = crate::psd_export::PsdExportOptions { force_psb: true, ..Default::default() }; + let (file, warnings, sources) = crate::psd_export::stream_metadata(doc, &opts); + let check = || -> photocraft_psd::Result<()> { + if ctl.cancelled() { + return Err(photocraft_psd::PsdError::Invalid("stream export cancelled".into())); + } + photocraft_raster::spill::check_since(before).map_err(photocraft_psd::PsdError::Invalid) + }; + let result = photocraft_psd::stream::write_layered( + &file, + out, + |i, id, out| { + check()?; + let rec = file.layers().get(i).ok_or_else(|| photocraft_psd::PsdError::Invalid("missing output record".into()))?; + let source = rec.layer_id().and_then(|id| sources.get(&id)).and_then(|(pixels, mask)| if id == -2 { mask.as_ref() } else { pixels.as_ref() }); + let r = rec.channel_rect(id); + if r.is_empty() { + return Ok(()); + } + let s = source.ok_or_else(|| photocraft_psd::PsdError::Invalid("missing output pixel source".into()))?; + let r = Rect::new(r.left, r.top, r.right, r.bottom); + let channels = s.format().channels(); + let channel = if id == -1 { channels.saturating_sub(1) } else { id.max(0) as usize }; + let mut invert = vec![s.format().mode == ColorMode::Cmyk; channels]; + if (id < 0 || s.format().alpha) + && let Some(a) = invert.last_mut() + { + *a = false; + } + let mut y = r.y0; + while y < r.y1 { + check()?; + let band = Rect::new(r.x0, y, r.x1, y.saturating_add(64).min(r.y1)); + let mut bytes = s.to_interleaved(band); + if s.format().mode == ColorMode::Lab && s.format().sample == SampleType::U16 { + crate::pixels::lab16_chroma(&mut bytes, channels, false); + } + let planes = crate::pixels::deinterleave(&bytes, channels, s.format().sample, &invert); + out.write_all(planes.get(channel).ok_or_else(|| photocraft_psd::PsdError::Invalid("invalid output channel".into()))?).map_err(sink_error)?; + y = band.y1; + } + Ok(()) + }, + |out| { + check()?; + let start = out.stream_position().map_err(sink_error)?; + let row_bytes = u64::from(doc.size.width) * fmt.sample.bytes() as u64; + let plane_bytes = + row_bytes.checked_mul(u64::from(doc.size.height)).ok_or_else(|| photocraft_psd::PsdError::Invalid("output size overflow".into()))?; + let cc = fmt.mode.color_channels(); + let white = photocraft_raster::from_rgba(&fmt, [1.0; 4]); + let space = photocraft_compose::cmyk_space(doc); + photocraft_compose::render_bands(doc, doc.bounds(), 64, |band| -> photocraft_psd::Result<()> { + check()?; + let mut planes: Vec> = (0..=cc).map(|_| Vec::with_capacity(band.px.len() * fmt.sample.bytes())).collect(); + photocraft_color::convert::with_cmyk_space(space.as_ref(), || { + let mut v = [0.0; 5]; + for p in &band.px { + photocraft_raster::from_rgba_into(&fmt, *p, &mut v); + for (c, plane) in planes.iter_mut().enumerate() { + let value = v.get(c).copied().unwrap_or(0.0); + let value = if c < cc { + crate::pixels::matte(value, v.get(cc).copied().unwrap_or(0.0), white.get(c).copied().unwrap_or(1.0)) + } else { + value + }; + let value = if fmt.mode == ColorMode::Cmyk && c < cc { 1.0 - value } else { value }; + let value = if fmt.mode == ColorMode::Lab && fmt.sample == SampleType::U16 && matches!(c, 1 | 2) { + value * crate::pixels::LAB16_CHROMA_MAX / 65535.0 + } else { + value + }; + crate::pixels::encode_be(value, fmt.sample, plane); + } + } + }); + for (c, bytes) in planes.iter().enumerate() { + let offset = start + .checked_add(c as u64 * plane_bytes) + .and_then(|v| v.checked_add(band.rect.y0 as u64 * row_bytes)) + .ok_or_else(|| photocraft_psd::PsdError::Invalid("output offset overflow".into()))?; + out.seek(SeekFrom::Start(offset)).map_err(sink_error)?; + out.write_all(bytes).map_err(sink_error)?; + } + ctl.progress(0.5 + 0.5 * band.rect.y1 as f32 / doc.size.height.max(1) as f32); + Ok(()) + })?; + for (i, channel) in doc.channels.iter().chain(doc.quick_mask.iter()).take(usize::from(file.header.channels).saturating_sub(cc + 1)).enumerate() { + let index = cc + 1 + i; + let mask_fmt = photocraft_color::PixelFormat::new(ColorMode::Grayscale, fmt.sample, false); + let converted = (channel.surface.format() != mask_fmt).then(|| channel.surface.convert(mask_fmt)); + let surface = converted.as_ref().unwrap_or(&channel.surface); + let mut y = 0; + while y < doc.size.height { + check()?; + let end = y.saturating_add(64).min(doc.size.height); + let r = Rect::from_xywh(0, y as i32, doc.size.width, end - y); + let bytes = surface.to_interleaved(r); + let plane = crate::pixels::deinterleave(&bytes, 1, surface.format().sample, &[false]); + out.seek(SeekFrom::Start(start + index as u64 * plane_bytes + u64::from(y) * row_bytes)).map_err(sink_error)?; + out.write_all(plane.first().ok_or_else(|| photocraft_psd::PsdError::Invalid("missing extra output plane".into()))?).map_err(sink_error)?; + y = end; + } + } + let end = start + .checked_add(u64::from(file.header.channels).saturating_mul(plane_bytes)) + .ok_or_else(|| photocraft_psd::PsdError::Invalid("output size overflow".into()))?; + out.seek(SeekFrom::Start(end)).map_err(sink_error)?; + check() + }, + ); + if ctl.cancelled() { + return Err(IoError::Cancelled); + } + result?; + photocraft_raster::spill::check_since(before).map_err(IoError::Unsupported)?; + Ok(warnings) +} + +#[cfg(test)] +mod memory_tests { + use super::*; + use photocraft_psd::{Compression, Version}; + use std::io::Cursor; + + #[test] + fn tile_footprint_counts_partial_negative_tiles_at_each_depth() { + let side = photocraft_geom::TILE_SIZE as u64; + for bpp in [1, 2, 4, 8, 16, 20] { + let rect = photocraft_psd::Rect::from_xywh(-1, -1, 2, 2); + assert_eq!(tile_footprint(rect, bpp), 4 * side * side * bpp as u64); + let aligned = photocraft_psd::Rect::from_xywh(-(side as i32), 0, side as u32, side as u32); + assert_eq!(tile_footprint(aligned, bpp), side * side * bpp as u64); + } + assert_eq!(tile_footprint(photocraft_psd::Rect::default(), 16), 0); + let hostile = photocraft_psd::Rect { top: i32::MIN, left: i32::MIN, bottom: i32::MAX, right: i32::MAX }; + assert_eq!(tile_footprint(hostile, 16), u64::MAX); + } + + #[test] + fn ram_only_admission_accounts_for_live_tiles_and_overflow() { + assert!(ram_tile_capacity(false, 60, 100, 40).is_ok()); + assert!(ram_tile_capacity(false, 60, 100, 41).is_err()); + assert!(ram_tile_capacity(false, u64::MAX, u64::MAX, 1).is_err()); + assert!(ram_tile_capacity(true, 60, 100, 1000).is_ok()); + } + + #[test] + fn oversized_ram_only_import_is_rejected_before_opening_channels() { + assert!(!photocraft_raster::spill::enabled()); + let file = photocraft_psd::testgen::layered(Version::Psb, photocraft_psd::ColorMode::Rgb, 32, Compression::Raw); + let bytes = file.to_bytes().unwrap(); + let mut index = Index::read(&mut Cursor::new(&bytes), 128 << 20, &|| false).unwrap(); + // Valid dimensions would need over a terabyte; the source remains a tiny fixture. + index.metadata.layers_mut()[0].rect = photocraft_psd::Rect::from_xywh(0, 0, 300_000, 300_000); + let opened = std::cell::Cell::new(0); + let result = import( + &|| { + opened.set(opened.get() + 1); + Ok(Cursor::new(&bytes)) + }, + &index, + Interrupt::NONE, + ); + assert!(matches!(result, Err(IoError::Unsupported(ref message)) if message.contains("enable a scratch disk"))); + assert_eq!(opened.get(), 0); + } + + #[test] + fn missing_scratch_is_not_silently_treated_as_ram_only() { + assert!(!photocraft_raster::spill::enabled()); + assert!(require_scratch(false).is_ok()); + assert!(require_scratch(true).is_err()); + } +} diff --git a/crates/io/tests/streaming_exr.rs b/crates/io/tests/streaming_exr.rs new file mode 100644 index 0000000000..c9a6dae4fe --- /dev/null +++ b/crates/io/tests/streaming_exr.rs @@ -0,0 +1,94 @@ +#![cfg(not(target_arch = "wasm32"))] +use photocraft_codecs::{ChannelLayout, EncodeOptions, ExrCompression, Format, Image, SampleType}; +use photocraft_doc::LayerContent; +use photocraft_raster::Interrupt; +use std::{ + io::Cursor, + sync::atomic::{AtomicBool, Ordering}, +}; + +fn fixture(layout: ChannelLayout, sample: SampleType, compression: ExrCompression) -> Vec { + let values: Vec = (0..31 * 35 * layout.channels()).map(|i| [-0.25, 4.0, 0.0, 0.0001, 0.5][i % 5]).collect(); + let image = Image::from_f32(31, 35, layout, &values).unwrap().convert(layout, sample); + photocraft_codecs::encode(&image, Format::OpenExr, &EncodeOptions { exr_compression: compression, ..Default::default() }).unwrap() +} + +// One test keeps these imports from competing for the global working allowance. +#[test] +fn native_scanline_import_preserves_hdr_document_and_cancels_without_publication() { + let path = std::env::temp_dir().join(format!("photocraft-exr-stream-{}.exr", std::process::id())); + for layout in [ChannelLayout::Gray, ChannelLayout::GrayA, ChannelLayout::Rgb, ChannelLayout::Rgba] { + for sample in [SampleType::F16, SampleType::F32] { + for compression in [ExrCompression::None, ExrCompression::Rle, ExrCompression::Zip1, ExrCompression::Zip16, ExrCompression::Piz] { + let bytes = fixture(layout, sample, compression); + std::fs::write(&path, &bytes).unwrap(); + let reference = photocraft_io::import("HDR", &bytes).unwrap(); + let native = photocraft_io::import_path_with("HDR", &path, &Interrupt::NONE).unwrap(); + let authorized = photocraft_io::import_seekable_with("HDR", &|| Ok(Cursor::new(&bytes)), &Interrupt::NONE).unwrap(); + for actual in [native, authorized] { + let a = &actual.document; + let r = &reference.document; + assert_eq!(actual.warnings, reference.warnings); + assert_eq!(a.name, r.name); + assert_eq!(a.size, r.size); + assert_eq!(a.mode, r.mode); + assert_eq!(a.depth, photocraft_color::SampleType::F32); + assert_eq!(a.icc_profile, r.icc_profile); + assert_eq!(a.layers[0].locks, r.layers[0].locks); + let (LayerContent::Raster(a), LayerContent::Raster(r)) = (&a.layers[0].content, &r.layers[0].content) else { + panic!("raster background required") + }; + assert_eq!( + a.to_interleaved(reference.document.bounds()), + r.to_interleaved(reference.document.bounds()), + "{layout:?}/{sample:?}/{compression:?}" + ); + } + } + } + } + let bytes = fixture(ChannelLayout::Rgb, SampleType::F16, ExrCompression::Zip16); + std::fs::write(&path, &bytes).unwrap(); + let cancelled = AtomicBool::new(false); + let cancel = || cancelled.load(Ordering::Relaxed); + let progress = |p| { + if p > 0.2 { + cancelled.store(true, Ordering::Relaxed); + } + }; + assert!(matches!(photocraft_io::import_path_with("HDR", &path, &Interrupt::new(&cancel, &progress)), Err(photocraft_io::IoError::Cancelled))); + std::fs::write(&path, &bytes[..bytes.len() / 2]).unwrap(); + assert!(photocraft_io::import_path_with("HDR", &path, &Interrupt::NONE).is_err()); + // A syntactically complete huge header/table must be refused before reading + // its pixel blocks when scratch is unavailable. No giant fixture is needed. + assert!(!photocraft_raster::spill::enabled()); + let mut header_end = 8; + loop { + if bytes[header_end] == 0 { + header_end += 1; + break; + } + for _ in 0..2 { + while bytes[header_end] != 0 { + header_end += 1; + } + header_end += 1; + } + let length = i32::from_le_bytes(bytes[header_end..header_end + 4].try_into().unwrap()) as usize; + header_end += 4 + length; + } + let mut huge = bytes[..header_end].to_vec(); + let needle = b"dataWindow\0box2i\0\x10\0\0\0"; + let at = huge.windows(needle.len()).position(|v| v == needle).unwrap() + needle.len(); + huge[at + 8..at + 12].copy_from_slice(&65535_i32.to_le_bytes()); + huge[at + 12..at + 16].copy_from_slice(&32767_i32.to_le_bytes()); + let offset = (header_end + 2048 * 8) as u64; + for _ in 0..2048 { + huge.extend_from_slice(&offset.to_le_bytes()); + } + huge.extend_from_slice(&[0; 8]); + assert!( + matches!(photocraft_io::import_seekable_with("huge", &|| Ok(Cursor::new(&huge)), &Interrupt::NONE), Err(photocraft_io::IoError::Unsupported(ref message)) if message.contains("decoded image tiles")) + ); + std::fs::remove_file(path).unwrap(); +} diff --git a/crates/io/tests/streaming_psd.rs b/crates/io/tests/streaming_psd.rs new file mode 100644 index 0000000000..0a8038b3ac --- /dev/null +++ b/crates/io/tests/streaming_psd.rs @@ -0,0 +1,136 @@ +#![cfg(not(target_arch = "wasm32"))] +use photocraft_psd::{ColorMode, Compression, Version}; +use photocraft_raster::Interrupt; + +// Native imports and exports reserve the same process-wide working-memory allowance. +// Keep fidelity fixtures from rejecting each other solely because the test runner is parallel. +static WORKING_MEMORY_TESTS: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +#[test] +fn seekable_import_matches_byte_import_pixels_masks_and_metadata() { + let _test = WORKING_MEMORY_TESTS.lock().unwrap(); + let path = std::env::temp_dir().join(format!("photocraft-stream-parity-{}.psb", std::process::id())); + for version in [Version::Psd, Version::Psb] { + for mode in [ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Cmyk, ColorMode::Lab] { + for depth in [8, 16, 32] { + for compression in Compression::ALL { + let mut file = photocraft_psd::testgen::layered(version, mode, depth, compression); + // The streaming layer path need not consume the stored merged image. + file.header.channels = mode.color_channels().unwrap() + u16::from(file.merged_has_alpha()); + let bytes = file.to_bytes().unwrap(); + std::fs::write(&path, &bytes).unwrap(); + let a = photocraft_io::import("d", &bytes).unwrap(); + let b = photocraft_io::import_path_with("d", &path, &Interrupt::NONE).unwrap(); + let authorized = photocraft_io::import_seekable_with("d", &|| std::fs::File::open(&path), &Interrupt::NONE).unwrap(); + assert_eq!( + photocraft_compose::render(&authorized.document, authorized.document.bounds()).px, + photocraft_compose::render(&b.document, b.document.bounds()).px + ); + assert_eq!(a.warnings, b.warnings, "{version:?}/{mode:?}/{depth}/{compression:?}"); + let aa = photocraft_compose::render(&a.document, a.document.bounds()); + let bb = photocraft_compose::render(&b.document, b.document.bounds()); + assert_eq!(aa.px, bb.px, "{version:?}/{mode:?}/{depth}/{compression:?}"); + let options = photocraft_io::PsdExportOptions::default(); + let (a, _) = photocraft_io::document_to_psd_with(&a.document, &options); + let (b, _) = photocraft_io::document_to_psd_with(&b.document, &options); + assert_eq!(a, b, "layer, mask and unknown-block round trip: {version:?}/{mode:?}/{depth}/{compression:?}"); + } + } + } + } + std::fs::remove_file(path).unwrap(); +} + +#[test] +fn cancelled_and_truncated_imports_publish_no_document() { + let _test = WORKING_MEMORY_TESTS.lock().unwrap(); + let path = std::env::temp_dir().join(format!("photocraft-stream-cancel-{}.psb", std::process::id())); + let mut file = photocraft_psd::testgen::layered(Version::Psb, ColorMode::Rgb, 8, Compression::Zip); + file.header.channels = 3 + u16::from(file.merged_has_alpha()); + let bytes = file.to_bytes().unwrap(); + std::fs::write(&path, &bytes).unwrap(); + assert!(matches!(photocraft_io::import_path_with("d", &path, &Interrupt::cancel_only(&|| true)), Err(photocraft_io::IoError::Cancelled))); + std::fs::write(&path, &bytes[..bytes.len() / 2]).unwrap(); + assert!(photocraft_io::import_path_with("d", &path, &Interrupt::NONE).is_err()); + std::fs::remove_file(path).unwrap(); +} + +#[test] +fn seekable_export_preserves_merged_layers_masks_and_unknown_metadata() { + let _test = WORKING_MEMORY_TESTS.lock().unwrap(); + let path = std::env::temp_dir().join(format!("photocraft-export-parity-{}.psb", std::process::id())); + for mode in [ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Cmyk, ColorMode::Lab] { + for depth in [8, 16, 32] { + let input = photocraft_psd::testgen::layered(Version::Psb, mode, depth, Compression::ZipPrediction); + let doc = photocraft_io::import("input", &input.to_bytes().unwrap()).unwrap().document; + let (reference, _) = photocraft_io::document_to_psd_with(&doc, &photocraft_io::PsdExportOptions { force_psb: true, ..Default::default() }); + photocraft_io::export_path_with(&doc, &path, &Default::default(), &Interrupt::NONE).unwrap(); + let output = photocraft_psd::PsdFile::from_bytes(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!(output.header, reference.header, "{mode:?}/{depth}"); + assert_eq!(output.resources, reference.resources); + let normalized_reference = photocraft_psd::PsdFile::from_bytes(&reference.to_bytes().unwrap()).unwrap(); + assert_eq!(output.global_blocks, normalized_reference.global_blocks); + assert_eq!(output.image_data.decode(&output.header).unwrap(), reference.image_data.decode(&reference.header).unwrap(), "merged {mode:?}/{depth}"); + let loaded = photocraft_io::import_path_with("output", &path, &Interrupt::NONE).unwrap(); + let (loaded, _) = photocraft_io::document_to_psd_with(&loaded.document, &photocraft_io::PsdExportOptions { force_psb: true, ..Default::default() }); + assert_eq!(loaded.to_bytes().unwrap(), reference.to_bytes().unwrap(), "layer/mask/metadata {mode:?}/{depth}"); + } + } + std::fs::remove_file(path).unwrap(); +} + +#[test] +fn cancelled_stream_save_keeps_destination_and_removes_temporary_output() { + let _test = WORKING_MEMORY_TESTS.lock().unwrap(); + let dir = std::env::temp_dir().join(format!("photocraft-export-cancel-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("kept.psb"); + std::fs::write(&path, b"precious original").unwrap(); + let input = photocraft_psd::testgen::layered(Version::Psb, ColorMode::Rgb, 8, Compression::Raw); + let doc = photocraft_io::import("input", &input.to_bytes().unwrap()).unwrap().document; + let calls = std::sync::atomic::AtomicUsize::new(0); + let cancel = || calls.fetch_add(1, std::sync::atomic::Ordering::Relaxed) > 5; + assert!(matches!( + photocraft_io::export_path_with(&doc, &path, &Default::default(), &Interrupt::cancel_only(&cancel)), + Err(photocraft_io::IoError::Cancelled) + )); + assert_eq!(std::fs::read(&path).unwrap(), b"precious original"); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1); + std::fs::remove_dir_all(dir).unwrap(); +} + +#[cfg(feature = "corpus")] +#[test] +fn native_path_import_matches_byte_import_across_real_psd_corpus() { + let _test = WORKING_MEMORY_TESTS.lock().unwrap(); + fn collect(p: &std::path::Path, out: &mut Vec) { + for e in std::fs::read_dir(p).unwrap().flatten() { + let p = e.path(); + if p.is_dir() { + collect(&p, out); + } else if p.extension().is_some_and(|x| x.eq_ignore_ascii_case("psd") || x.eq_ignore_ascii_case("psb")) { + out.push(p); + } + } + } + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/psd"); + assert!(root.is_dir(), "run cargo xtask corpus --all"); + let mut files = Vec::new(); + collect(&root, &mut files); + let mut compared = 0; + for path in files { + let bytes = std::fs::read(&path).unwrap(); + let Ok(a) = photocraft_io::import("d", &bytes) else { continue }; + let b = photocraft_io::import_path_with("d", &path, &Interrupt::NONE).unwrap_or_else(|e| panic!("{}: {e}", path.display())); + assert_eq!(a.warnings, b.warnings, "{}", path.display()); + assert_eq!( + photocraft_compose::render(&a.document, a.document.bounds()).px, + photocraft_compose::render(&b.document, b.document.bounds()).px, + "{}", + path.display() + ); + compared += 1; + } + assert!(compared >= 20, "{compared} corpus files compared"); + eprintln!("seekable corpus parity: {compared} files"); +} diff --git a/crates/io/tests/svg.rs b/crates/io/tests/svg.rs index d1baf418fa..fd56d35b86 100644 --- a/crates/io/tests/svg.rs +++ b/crates/io/tests/svg.rs @@ -93,6 +93,23 @@ fn svg_group_rasterization_threshold_is_configurable_and_clamped() { let LayerContent::Group(outer) = &unlimited.document.layers[0].content else { panic!("outer group should remain editable") }; assert!(outer.children.iter().any(|child| matches!(child.content, LayerContent::Group(_))), "zero disables the preference threshold"); + #[cfg(not(target_arch = "wasm32"))] + { + let path = std::env::temp_dir().join(format!("photocraft-svg-depth-{}.svg", std::process::id())); + std::fs::write(&path, source.as_bytes()).unwrap(); + for (depth, reference) in [(1, &shallow), (0, &unlimited)] { + let seekable = photocraft_io::import_path_with_svg_group_depth("nested.svg", &path, depth, &photocraft_raster::Interrupt::NONE).unwrap(); + assert_eq!(seekable.warnings, reference.warnings); + let LayerContent::Group(outer) = &seekable.document.layers[0].content else { panic!("outer group should remain editable") }; + assert!(outer.children.iter().any(|child| if depth == 1 { + matches!(child.content, LayerContent::Raster(_)) + } else { + matches!(child.content, LayerContent::Group(_)) + })); + } + std::fs::remove_file(path).unwrap(); + } + let clamped = import_with_svg_group_depth("nested.svg", source.as_bytes(), usize::MAX).unwrap(); assert!(matches!(clamped.document.layers[0].content, LayerContent::Group(_)), "out-of-range settings clamp to the document's hard group limit"); } diff --git a/crates/psd/src/layer.rs b/crates/psd/src/layer.rs index 5450ed7e67..42036a994b 100644 --- a/crates/psd/src/layer.rs +++ b/crates/psd/src/layer.rs @@ -544,7 +544,7 @@ impl LayerRecord { ch.decode(w, h, depth, version) } - fn read(r: &mut Reader<'_>, version: Version) -> Result<(Self, Vec)> { + pub(crate) fn read(r: &mut Reader<'_>, version: Version) -> Result<(Self, Vec)> { let rect = Rect::read(r)?; let nch = r.u16()?; if nch > MAX_LAYER_CHANNELS { @@ -586,7 +586,7 @@ impl LayerRecord { Ok((LayerRecord { rect, channels, blend_mode, opacity, clipping, flags, filler, mask, blending_ranges, name, blocks, extra_trailing }, lens)) } - fn write(&self, out: &mut Vec, version: Version) -> Result<()> { + pub(crate) fn write(&self, out: &mut Vec, version: Version) -> Result<()> { self.rect.write(out); let nch = u16::try_from(self.channels.len()).map_err(|_| PsdError::LimitExceeded("too many channels"))?; out.put_u16(nch); diff --git a/crates/psd/src/lib.rs b/crates/psd/src/lib.rs index 00d73c4003..32efb0267c 100644 --- a/crates/psd/src/lib.rs +++ b/crates/psd/src/lib.rs @@ -64,6 +64,7 @@ pub mod pixels; pub mod resources; pub mod shc; pub mod slices; +pub mod stream; pub mod tagged; #[cfg(any(test, feature = "testgen"))] // Test-data generator (tests and fuzz seeds only): failing loudly is the point. diff --git a/crates/psd/src/stream.rs b/crates/psd/src/stream.rs new file mode 100644 index 0000000000..3023b5750a --- /dev/null +++ b/crates/psd/src/stream.rs @@ -0,0 +1,499 @@ +//! Seekable PSD/PSB metadata and row decoding. Pixel ranges stay in the source; no whole-file +//! or whole-channel buffer is created. `metadata` deliberately contains no encoded pixels. +use std::io::{Read, Seek, SeekFrom, Take, Write}; + +use crate::image_data::ImageData; +use crate::io::Reader; +use crate::{Compression, GlobalLayerMask, Header, LayerInfo, LayerInfoPlacement, LayerRecord, PlaneLayout, PsdError, PsdFile, Result, TaggedBlock, Version}; + +fn io(e: std::io::Error) -> PsdError { + PsdError::invalid(format!("stream I/O: {e}")) +} +fn invalid() -> PsdError { + PsdError::invalid("invalid stream range") +} +fn array(r: &mut impl Read) -> Result<[u8; N]> { + let mut b = [0; N]; + r.read_exact(&mut b).map_err(io)?; + Ok(b) +} +fn length(r: &mut impl Read, long: bool) -> Result { + Ok(if long { u64::from_be_bytes(array(r)?) } else { u64::from(u32::from_be_bytes(array(r)?)) }) +} + +#[derive(Clone, Copy, Debug)] +/// One checked encoded layer-channel range, excluding its compression marker. +pub struct ChannelRange { + /// Photoshop channel id (negative for transparency and masks). + pub id: i16, + /// Absolute byte offset in the source. + pub offset: u64, + /// Encoded byte count. + pub length: u64, + /// Channel codec. + pub compression: Compression, +} + +/// Metadata and independent source ranges; encoded pixels are never retained in this model. +pub struct Index { + /// Parsed metadata only. Do not serialize this model: pixel channels are in `channels`. + pub metadata: PsdFile, + /// Ranges per layer record, in stored order. + pub channels: Vec>, +} + +struct Scan<'a, R> { + reader: &'a mut R, + allowance: u64, + cancelled: &'a dyn Fn() -> bool, +} +impl Scan<'_, R> { + fn position(&mut self) -> Result { + self.reader.stream_position().map_err(io) + } + fn end(&mut self, length: u64, boundary: u64) -> Result { + self.position()?.checked_add(length).filter(|end| *end <= boundary).ok_or_else(invalid) + } + fn bytes(&mut self, n: u64, boundary: u64) -> Result> { + self.end(n, boundary)?; + self.allowance = self.allowance.checked_sub(n).ok_or(PsdError::LimitExceeded("stream metadata exceeds working-memory budget"))?; + let n = usize::try_from(n).map_err(|_| invalid())?; + let mut out = Vec::new(); + out.try_reserve_exact(n).map_err(|_| PsdError::LimitExceeded("stream metadata allocation"))?; + out.resize(n, 0); + for chunk in out.chunks_mut(64 << 10) { + if (self.cancelled)() { + return Err(PsdError::invalid("stream cancelled")); + } + self.reader.read_exact(chunk).map_err(io)?; + } + Ok(out) + } + fn layers(&mut self, end: u64, version: Version) -> Result<(LayerInfo, Vec>)> { + let start = self.position()?; + let count = i16::from_be_bytes(array(self.reader)?); + let n = usize::from(count.unsigned_abs()); + if (n as u64).saturating_mul(34) > end.saturating_sub(self.position()?) { + return Err(invalid()); + } + let mut layers = Vec::with_capacity(n); + let mut lengths = Vec::with_capacity(n); + for _ in 0..n { + let mut record = self.bytes(18, end)?; + let nch = u16::from_be_bytes(record.get(16..18).ok_or_else(invalid)?.try_into().map_err(|_| invalid())?); + if nch > crate::layer::MAX_LAYER_CHANNELS { + return Err(PsdError::LimitExceeded("too many layer channels")); + } + let fixed = u64::from(nch) * if version.is_psb() { 10 } else { 6 } + 16; + record.extend(self.bytes(fixed, end)?); + let extra = record.get(record.len().saturating_sub(4)..).ok_or_else(invalid)?; + let extra = u32::from_be_bytes(extra.try_into().map_err(|_| invalid())?); + record.extend(self.bytes(u64::from(extra), end)?); + let (rec, lens) = LayerRecord::read(&mut Reader::new(&record), version)?; + layers.push(rec); + lengths.push(lens); + } + let mut ranges = Vec::with_capacity(n); + for (rec, lens) in layers.iter_mut().zip(lengths) { + let mut channels = Vec::new(); + for (ch, len) in rec.channels.iter_mut().zip(lens) { + if len == 0 { + continue; + } + if len < 2 { + return Err(invalid()); + } + let channel_end = self.end(len, end)?; + let compression = Compression::from_u16(u16::from_be_bytes(array(self.reader)?)); + ch.compression = Some(compression); + channels.push(ChannelRange { id: ch.id, offset: self.position()?, length: len - 2, compression }); + self.reader.seek(SeekFrom::Start(channel_end)).map_err(io)?; + } + ranges.push(channels); + } + let body = self.position()?.saturating_sub(start); + let rest = end.saturating_sub(self.position()?); + let pad = self.bytes(rest, end)?; + let padding = if pad.len() as u64 == body % 2 && pad.iter().all(|b| *b == 0) { None } else { Some(pad) }; + Ok((LayerInfo { merged_alpha: count < 0, layers, padding }, ranges)) + } +} + +impl Index { + /// Index a seekable file with an aggregate metadata cap and cooperative cancellation. + pub fn read(reader: &mut R, metadata_budget: u64, cancelled: &dyn Fn() -> bool) -> Result { + let end = reader.seek(SeekFrom::End(0)).map_err(io)?; + reader.seek(SeekFrom::Start(0)).map_err(io)?; + let header = Header::read(&mut Reader::new(&array::<26>(reader)?))?; + let mut s = Scan { reader, allowance: metadata_budget, cancelled }; + let n = length(s.reader, false)?; + let color_mode_data = s.bytes(n, end)?; + let n = length(s.reader, false)?; + let resources = s.bytes(n, end)?; + let mut resource_bytes = (n as u32).to_be_bytes().to_vec(); + resource_bytes.extend(resources); + let resources = crate::resources::read_section(&mut Reader::new(&resource_bytes))?; + drop(resource_bytes); + let lm_len = length(s.reader, header.version.is_psb())?; + let lm_end = s.end(lm_len, end)?; + let mut layer_info = None; + let mut channels = Vec::new(); + let mut global_layer_mask = None; + let mut global_blocks = Vec::new(); + let mut layer_mask_trailing = Vec::new(); + let mut layer_info_placement = LayerInfoPlacement::Section; + if lm_len > 0 { + let n = length(s.reader, header.version.is_psb())?; + let li_end = s.end(n, lm_end)?; + if n > 0 { + let (info, c) = s.layers(li_end, header.version)?; + layer_info = Some(info); + channels = c; + } + if lm_end.saturating_sub(s.position()?) >= 4 { + let n = length(s.reader, false)?; + global_layer_mask = Some(GlobalLayerMask { data: s.bytes(n, lm_end)? }); + while lm_end.saturating_sub(s.position()?) >= 12 { + let start = s.position()?; + let signature = array::<4>(s.reader)?; + if signature != *b"8BIM" && signature != *b"8B64" { + s.reader.seek(SeekFrom::Start(start)).map_err(io)?; + break; + } + let key = array::<4>(s.reader)?; + let n = length(s.reader, crate::tagged::uses_long_length(header.version, &key))?; + let block_end = s.end(n, lm_end)?; + let is_layers = layer_info.is_none() && matches!(&key, b"Lr16" | b"Lr32" | b"Layr"); + let data = if is_layers { + let (info, c) = s.layers(block_end, header.version)?; + layer_info = Some(info); + channels = c; + Vec::new() + } else { + s.bytes(n, lm_end)? + }; + // Same padding detector as the byte parser, without reading the next block. + let at = s.position()?; + let rest = lm_end.saturating_sub(at); + let peek = s.bytes(rest.min(7), lm_end)?; + s.reader.seek(SeekFrom::Start(at)).map_err(io)?; + let pad = (0..=3usize) + .find(|k| *k as u64 == rest || peek.get(*k..).is_some_and(|b| b.starts_with(b"8BIM") || b.starts_with(b"8B64"))) + .unwrap_or(0); + let bytes = s.bytes(pad as u64, lm_end)?; + let padding = if bytes.len() as u64 == (4 - n % 4) % 4 && bytes.iter().all(|b| *b == 0) { None } else { Some(bytes) }; + if is_layers { + layer_info_placement = LayerInfoPlacement::GlobalBlock { index: global_blocks.len(), signature, key, padding }; + } else { + global_blocks.push(TaggedBlock { signature, key, data, padding }); + } + } + } + let left = lm_end.saturating_sub(s.position()?); + layer_mask_trailing = s.bytes(left, lm_end)?; + } + // The merged image is intentionally not read: layered imports below consume only layers. + let compression = Compression::from_u16(u16::from_be_bytes(array(s.reader)?)); + Ok(Self { + metadata: PsdFile { + header, + color_mode_data, + resources, + layer_info, + layer_info_placement, + global_layer_mask, + global_blocks, + layer_mask_trailing, + image_data: ImageData { compression, data: Vec::new() }, + }, + channels, + }) + } +} + +enum Input { + Raw(Take), + Rle(Take, Vec), + Zip(flate2::read::ZlibDecoder>), +} +/// A sequential channel decoder with bounded row buffers, including ZIP prediction. +pub struct Rows { + input: Input, + layout: PlaneLayout, + prediction: bool, + row: usize, + encoded: Vec, +} +impl Rows { + /// Open a checked range on an independent reader. Clone file handles with shared seek + /// positions must not be used for concurrent channels. + pub fn new(mut reader: R, range: ChannelRange, width: usize, height: usize, depth: u16, version: Version) -> Result { + if width > 300_000 || height > 300_000 || !matches!(depth, 1 | 8 | 16 | 32) { + return Err(PsdError::LimitExceeded("stream channel dimensions")); + } + reader.seek(SeekFrom::Start(range.offset)).map_err(io)?; + let mut source = reader.take(range.length); + let layout = PlaneLayout { planes: 1, width, height, depth, version }; + let input = match range.compression { + Compression::Raw => Input::Raw(source), + Compression::Rle => { + let mut counts = Vec::with_capacity(height); + let mut total = (height as u64).checked_mul(if version.is_psb() { 4 } else { 2 }).ok_or_else(invalid)?; + for _ in 0..height { + let n = + if version.is_psb() { u32::from_be_bytes(array(&mut source)?) as usize } else { usize::from(u16::from_be_bytes(array(&mut source)?)) }; + if n > layout.row_bytes().saturating_mul(2).saturating_add(1024) { + return Err(PsdError::LimitExceeded("encoded RLE row")); + } + total = total.checked_add(n as u64).ok_or_else(invalid)?; + counts.push(n); + } + if total > range.length { + return Err(invalid()); + } + Input::Rle(source, counts) + } + Compression::Zip | Compression::ZipPrediction => Input::Zip(flate2::read::ZlibDecoder::new(source)), + Compression::Unknown(n) => return Err(PsdError::Unsupported(format!("stream compression {n}"))), + }; + Ok(Self { input, layout, prediction: range.compression == Compression::ZipPrediction, row: 0, encoded: Vec::new() }) + } + /// Decode the next native big-endian sample row, reusing `out`. + pub fn read_row(&mut self, out: &mut Vec) -> Result<()> { + if self.row >= self.layout.height { + return Err(invalid()); + } + let bytes = self.layout.row_bytes(); + out.resize(bytes, 0); + match &mut self.input { + Input::Raw(r) => r.read_exact(out).map_err(io)?, + Input::Zip(r) => r.read_exact(out).map_err(io)?, + Input::Rle(r, counts) => { + out.clear(); + self.encoded.resize(*counts.get(self.row).ok_or_else(invalid)?, 0); + r.read_exact(&mut self.encoded).map_err(io)?; + crate::compression::packbits::decode_into(&self.encoded, bytes, out)?; + } + } + if self.prediction { + let row = PlaneLayout { height: 1, ..self.layout }; + crate::compression::unpredict(out, &row)?; + } + self.row += 1; + Ok(()) + } + /// Checks the zlib checksum and rejects extra decoded samples after the expected last row. + pub fn finish(mut self) -> Result<()> { + if self.row != self.layout.height { + return Err(invalid()); + } + let mut extra = [0; 1]; + let n = match &mut self.input { + Input::Raw(r) | Input::Rle(r, _) => r.read(&mut extra), + Input::Zip(r) => r.read(&mut extra), + } + .map_err(io)?; + if n != 0 { + return Err(PsdError::invalid("extra stream channel samples")); + } + Ok(()) + } +} + +fn patch(out: &mut W, at: u64, value: u64) -> Result<()> { + let end = out.stream_position().map_err(io)?; + out.seek(SeekFrom::Start(at)).map_err(io)?; + out.write_all(&value.to_be_bytes()).map_err(io)?; + out.seek(SeekFrom::Start(end)).map_err(io)?; + Ok(()) +} + +fn write_layers(out: &mut W, info: &LayerInfo, pixels: &mut impl FnMut(usize, i16, &mut W) -> Result<()>) -> Result<()> { + let start = out.stream_position().map_err(io)?; + let count = i16::try_from(info.layers.len()).map_err(|_| PsdError::LimitExceeded("too many streamed layers"))?; + out.write_all(&(if info.merged_alpha { -count } else { count }).to_be_bytes()).map_err(io)?; + let mut records = Vec::with_capacity(info.layers.len()); + for rec in &info.layers { + records.push(out.stream_position().map_err(io)?); + let mut encoded = Vec::new(); + rec.write(&mut encoded, Version::Psb)?; + out.write_all(&encoded).map_err(io)?; + } + for (i, rec) in info.layers.iter().enumerate() { + for (c, channel) in rec.channels.iter().enumerate() { + let begin = out.stream_position().map_err(io)?; + if let Some(compression) = channel.compression { + out.write_all(&compression.as_u16().to_be_bytes()).map_err(io)?; + pixels(i, channel.id, out)?; + } + let len = out.stream_position().map_err(io)?.saturating_sub(begin); + let at = records.get(i).copied().ok_or_else(invalid)?.checked_add(20 + c as u64 * 10).ok_or_else(invalid)?; + patch(out, at, len)?; + } + } + let length = out.stream_position().map_err(io)?.saturating_sub(start); + let pad = (4 - length % 4) % 4; + for _ in 0..pad { + out.write_all(&[0]).map_err(io)?; + } + Ok(()) +} + +/// Write a PSB directly to a seekable sink, patching channel and section lengths after each +/// bounded encoded stream. Callbacks supply encoded layer channels and the merged image; this +/// function never buffers their data. Publication/atomic replacement belongs to the caller. +pub fn write_layered( + file: &PsdFile, + out: &mut W, + mut pixels: impl FnMut(usize, i16, &mut W) -> Result<()>, + merged: impl FnOnce(&mut W) -> Result<()>, +) -> Result<()> { + if file.header.version != Version::Psb { + return Err(PsdError::Unsupported("seekable writer requires PSB".into())); + } + file.header.validate()?; + let mut prefix = Vec::new(); + file.header.write(&mut prefix); + prefix.extend(u32::try_from(file.color_mode_data.len()).map_err(|_| invalid())?.to_be_bytes()); + prefix.extend(&file.color_mode_data); + crate::resources::write_section(&mut prefix, &file.resources)?; + out.write_all(&prefix).map_err(io)?; + let lm_at = out.stream_position().map_err(io)?; + out.write_all(&0u64.to_be_bytes()).map_err(io)?; + let start = out.stream_position().map_err(io)?; + let li_at = start; + out.write_all(&0u64.to_be_bytes()).map_err(io)?; + if let (Some(info), LayerInfoPlacement::Section) = (&file.layer_info, &file.layer_info_placement) { + let begin = out.stream_position().map_err(io)?; + write_layers(out, info, &mut pixels)?; + let n = out.stream_position().map_err(io)?.saturating_sub(begin); + patch(out, li_at, n)?; + } + let mask = file.global_layer_mask.as_ref().map_or(&[][..], |m| m.data.as_slice()); + out.write_all(&u32::try_from(mask.len()).map_err(|_| invalid())?.to_be_bytes()).map_err(io)?; + out.write_all(mask).map_err(io)?; + for i in 0..=file.global_blocks.len() { + if let (Some(info), LayerInfoPlacement::GlobalBlock { index, signature, key, padding }) = (&file.layer_info, &file.layer_info_placement) + && i == (*index).min(file.global_blocks.len()) + { + out.write_all(signature).map_err(io)?; + out.write_all(key).map_err(io)?; + let at = out.stream_position().map_err(io)?; + out.write_all(&0u64.to_be_bytes()).map_err(io)?; + let begin = out.stream_position().map_err(io)?; + write_layers(out, info, &mut pixels)?; + let n = out.stream_position().map_err(io)?.saturating_sub(begin); + patch(out, at, n)?; + match padding { + Some(bytes) => out.write_all(bytes).map_err(io)?, + None => out.write_all(&[0u8; 3][..((4 - n % 4) % 4) as usize]).map_err(io)?, + } + } + if let Some(block) = file.global_blocks.get(i) { + let mut bytes = Vec::new(); + crate::tagged::write_blocks(&mut bytes, std::slice::from_ref(block), Version::Psb, 4)?; + out.write_all(&bytes).map_err(io)?; + } + } + out.write_all(&file.layer_mask_trailing).map_err(io)?; + let n = out.stream_position().map_err(io)?.saturating_sub(start); + patch(out, lm_at, n)?; + out.write_all(&file.image_data.compression.as_u16().to_be_bytes()).map_err(io)?; + merged(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + + #[test] + fn indexed_channels_match_byte_decoder_at_every_depth_and_compression() { + for version in [Version::Psd, Version::Psb] { + for depth in [8, 16, 32] { + for compression in Compression::ALL { + let file = crate::testgen::layered(version, crate::ColorMode::Rgb, depth, compression); + let bytes = file.to_bytes().unwrap(); + let index = Index::read(&mut Cursor::new(&bytes), 32 << 20, &|| false).unwrap(); + assert_eq!(index.metadata.resources, file.resources); + assert_eq!(index.metadata.global_blocks, file.global_blocks); + for (rec, ranges) in index.metadata.layers().iter().zip(&index.channels) { + for range in ranges { + let (w, h) = rec.channel_rect(range.id).size().unwrap(); + let mut rows = Rows::new(Cursor::new(&bytes), *range, w, h, depth, version).unwrap(); + let mut decoded = Vec::new(); + let mut row = Vec::new(); + for _ in 0..h { + rows.read_row(&mut row).unwrap(); + decoded.extend_from_slice(&row); + } + rows.finish().unwrap(); + let original = file.layers().iter().find(|l| l.name == rec.name).unwrap(); + assert_eq!(decoded, original.decode_channel(range.id, depth, version).unwrap()); + } + } + } + } + } + } + + #[test] + fn bounded_index_and_cancel_do_not_allocate_declared_pixel_bytes() { + let file = crate::testgen::layered(Version::Psb, crate::ColorMode::Rgb, 8, Compression::Rle); + let bytes = file.to_bytes().unwrap(); + assert!(Index::read(&mut Cursor::new(&bytes), 2, &|| false).is_err()); + assert!(Index::read(&mut Cursor::new(&bytes), 32 << 20, &|| true).is_err()); + for n in [0, 25, 30, bytes.len() / 2] { + assert!(Index::read(&mut Cursor::new(&bytes[..n]), 32 << 20, &|| false).is_err()); + } + } + + #[test] + fn rows_reject_corrupt_checksum_and_truncated_data() { + let layout = PlaneLayout { planes: 1, width: 10, height: 4, depth: 8, version: Version::Psb }; + let encoded = crate::compression::encode_planes(Compression::Zip, &[1; 40], &layout).unwrap(); + for corrupt in [false, true] { + let mut bytes = encoded.clone(); + if corrupt { + let n = bytes.len(); + bytes[n - 1] ^= 0xff; + } else { + bytes.truncate(bytes.len() - 3); + } + let range = ChannelRange { id: 0, offset: 0, length: bytes.len() as u64, compression: Compression::Zip }; + let mut rows = Rows::new(Cursor::new(&bytes), range, 10, 4, 8, Version::Psb).unwrap(); + let mut row = Vec::new(); + let mut error = false; + for _ in 0..4 { + if rows.read_row(&mut row).is_err() { + error = true; + break; + } + } + assert!(error || rows.finish().is_err(), "truncated or corrupt zlib must fail"); + } + } + + #[test] + fn seekable_writer_patches_layer_channel_and_global_block_lengths() { + for depth in [8, 16, 32] { + for compression in Compression::ALL { + let file = crate::testgen::layered(Version::Psb, crate::ColorMode::Rgb, depth, compression); + let mut out = Cursor::new(Vec::new()); + write_layered( + &file, + &mut out, + |layer, id, out| out.write_all(&file.layers()[layer].channel(id).unwrap().data).map_err(io), + |out| out.write_all(&file.image_data.data).map_err(io), + ) + .unwrap(); + let read = PsdFile::from_bytes(&out.into_inner()).unwrap(); + assert_eq!(read.header, file.header); + assert_eq!(read.global_blocks, file.global_blocks); + assert_eq!(read.resources, file.resources); + assert_eq!(read.image_data, file.image_data); + assert_eq!(read.layers(), file.layers()); + } + } + } +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 64f9c31556..69dc5ddc34 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -1219,6 +1219,11 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) Err(e) => return err(e), } } + if matches!(path.rsplit('.').next().map(|s| s.to_ascii_lowercase()).as_deref(), Some("psd" | "psb" | "psdt" | "exr")) + && let Some(import) = app.services.automation_import_path.clone() + { + return run_waiting(app, wait, |app| crate::jobs_ui::open_authorized(app, path.to_string(), import)); + } let opened = match app.services.automation_read.as_mut() { Some(read) => read(path), None => Err("automation read authority is not configured".into()), @@ -2327,6 +2332,51 @@ mod tests { assert_eq!(written.borrow().len(), 3, "a bad tgaBits writes nothing"); } + #[test] + fn automation_exr_open_uses_authorized_seekable_import() { + use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }; + for source_read_only in [false, true] { + let reads = Arc::new(AtomicUsize::new(0)); + let imported = reads.clone(); + let services = crate::Services { + automation_import_path: Some(Arc::new(move |path, _| { + assert_eq!(path, "in/starmap.EXR"); + imported.fetch_add(1, Ordering::Relaxed); + Ok(photocraft_io::ImportResult { + document: photocraft_doc::Document::new( + "starmap.EXR", + photocraft_geom::Size::new(65536, 32768), + photocraft_color::ColorMode::Rgb, + photocraft_color::SampleType::F32, + ), + warnings: Vec::new(), + source_read_only, + preview_only: source_read_only, + }) + })), + automation_read: Some(Box::new(|_| Err("whole-file read must not run".into()))), + ..Default::default() + }; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), services); + let response = call(&mut app, &egui::Context::default(), "app.open", json!({"path": "in/starmap.EXR", "wait": false})); + assert_eq!(response["ok"], true, "{response}"); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + while !app.session.jobs().is_empty() && std::time::Instant::now() < deadline { + app.session.poll_jobs(); + std::thread::sleep(std::time::Duration::from_millis(1)); + } + assert!(app.session.jobs().is_empty(), "EXR import did not finish"); + assert_eq!(reads.load(Ordering::Relaxed), 1); + assert_eq!(app.session.active().unwrap().doc.size, photocraft_geom::Size::new(65536, 32768)); + let st = app.session.active().unwrap(); + assert_eq!(st.source_read_only, source_read_only); + assert_eq!(st.path.is_none(), source_read_only, "read-only imports must not keep a source save path"); + } + } + #[test] fn automation_open_and_save_use_the_roots_and_reply_with_warnings() { use photocraft_color::{ColorMode, SampleType}; diff --git a/crates/ui-egui/src/file_open.rs b/crates/ui-egui/src/file_open.rs index d67e56dbf0..1cec43e5df 100644 --- a/crates/ui-egui/src/file_open.rs +++ b/crates/ui-egui/src/file_open.rs @@ -112,8 +112,30 @@ impl PhotocraftApp { crate::jobs_ui::start_open(self, &display_name(path), Some(path.to_string()), photocraft_engine::jobs::OpenSource::Path(path.to_string()))?; return Ok(Vec::new()); } - let bytes = photocraft_format::read_file(std::path::Path::new(path)).map_err(|e| format!("{path}: {e}"))?; - self.open_file(path, &bytes) + let seekable = ext == "exr" || matches!(ext.as_str(), "psd" | "psb" | "psdt") && std::fs::metadata(path).is_ok_and(|m| m.len() >= 64 << 20); + if !seekable { + let bytes = photocraft_format::read_file(std::path::Path::new(path)).map_err(|e| format!("{path}: {e}"))?; + return self.open_file(path, &bytes); + } + let name = self.open_name(&display_name(path)); + let imported = photocraft_io::import_path_with(&name, std::path::Path::new(path), &photocraft_raster::Interrupt::NONE).map_err(|e| e.to_string())?; + let warnings = with_missing_fonts_of(&imported.document, imported.warnings); + let question = self.session.color.open_question(&imported.document); + crate::open_prompt::arrive( + self, + crate::open_prompt::Held { + doc: imported.document, + read_only: imported.source_read_only, + name, + path: Some(path.to_string()), + warnings: warnings.clone(), + bytes: None, + slot: None, + question, + policy_applied: false, + }, + ); + Ok(warnings) } #[cfg(target_arch = "wasm32")] diff --git a/crates/ui-egui/src/jobs_ui.rs b/crates/ui-egui/src/jobs_ui.rs index 29da41d481..5fc586fe5b 100644 --- a/crates/ui-egui/src/jobs_ui.rs +++ b/crates/ui-egui/src/jobs_ui.rs @@ -249,6 +249,51 @@ pub(crate) fn finish_open_tab(app: &mut PhotocraftApp, tab: &OpenTab, v: &Value) r } +pub(crate) fn open_authorized(app: &mut PhotocraftApp, path: String, import: crate::AutomationImportPathFn) -> Result { + let name = app.open_name(&crate::file_open::display_name(&path)); + let worker_path = path.clone(); + let apply_name = name.clone(); + let apply_path = path.clone(); + match app + .session + .start_job( + OPEN_JOB, + json!({"path": path}), + "Opening", + false, + move |ctx| { + ctx.stage(0.0, 1.0, "Decoding", |ctl| import(&worker_path, ctl)) + .map_err(|e| if ctx.cancelled() { photocraft_engine::EngineError::Cancelled } else { photocraft_engine::EngineError::Other(e) }) + }, + move |session, imported| { + let source_read_only = imported.source_read_only; + let (mut doc, warnings) = (imported.document, imported.warnings); + doc.name = apply_name.clone(); + let document_path = (!source_read_only && !photocraft_engine::file_cmds::is_template(&apply_path)).then(|| apply_path.clone()); + let (index, color) = session.open_document(doc, document_path); + if let Some(st) = session.active_mut() { + st.source_read_only = source_read_only; + } + Ok(json!({"document": index, "name": apply_name, "warnings": warnings, "color": color, "path": apply_path, "automation": true})) + }, + ) + .map_err(|e| e.to_string())? + { + Started::Done(v) => { + finish_open(app, &name, Some(&path), &v, None)?; + Ok(v) + } + Started::Job(job) => { + app.jobs.opens.push(OpenTab { job, name, path: Some(path), slot: None, order: None }); + app.jobs.focus = Some(job); + app.jobs.last_started = Some(job); + app.ui.chrome.home = None; + app.ui.status_error = false; + Ok(json!({"job": job.0, "pending": true})) + } + } +} + /// Save the active document to `path` in the background: `save` encodes and writes it on a /// worker while the window keeps drawing and shows the progress dialog. The document is locked /// until the job ends, which [`tick`] records like a synchronous save. `Ok(None)`: started; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 04027176b1..a62ab4c161 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -325,6 +325,8 @@ pub(crate) enum SaveKind { /// Automation: flat formats stay copies (#2579) and hooks never fire. Automation, } +/// Decode a document through handles beneath the desktop control session's authorized root. +pub type AutomationImportPathFn = std::sync::Arc) -> Result + Send + Sync>; /// Read bytes through the desktop control session's authorized read root. pub type AutomationReadFn = Box Result<(String, Vec), String>>; /// Write bytes through the desktop control session's authorized write root. @@ -415,6 +417,7 @@ pub struct Services { /// File access used only by control/MCP requests. Interactive dialogs keep /// using `file_dialog` and `write` with the user's authority. pub automation_read: Option, + pub automation_import_path: Option, pub automation_write: Option, pub automation_open_pcraft: Option, pub automation_command: Option, diff --git a/crates/ui-egui/tests/exr_open.rs b/crates/ui-egui/tests/exr_open.rs new file mode 100644 index 0000000000..ae3b737bec --- /dev/null +++ b/crates/ui-egui/tests/exr_open.rs @@ -0,0 +1,44 @@ +#![cfg(not(target_arch = "wasm32"))] +use photocraft_codecs::{ChannelLayout, EncodeOptions, Format, Image}; +use photocraft_ui_egui::PhotocraftApp; + +#[test] +fn synchronous_native_exr_open_uses_tile_admission_before_huge_pixel_decode() { + let image = Image::from_f32(1, 1, ChannelLayout::Rgb, &[0.5, 2.0, -0.25]).unwrap(); + let bytes = photocraft_codecs::encode(&image, Format::OpenExr, &EncodeOptions::default()).unwrap(); + let mut header_end = 8; + loop { + if bytes[header_end] == 0 { + header_end += 1; + break; + } + for _ in 0..2 { + while bytes[header_end] != 0 { + header_end += 1; + } + header_end += 1; + } + let length = i32::from_le_bytes(bytes[header_end..header_end + 4].try_into().unwrap()) as usize; + header_end += 4 + length; + } + let mut huge = bytes[..header_end].to_vec(); + let needle = b"dataWindow\0box2i\0\x10\0\0\0"; + let at = huge.windows(needle.len()).position(|v| v == needle).unwrap() + needle.len(); + huge[at + 8..at + 12].copy_from_slice(&65535_i32.to_le_bytes()); + huge[at + 12..at + 16].copy_from_slice(&32767_i32.to_le_bytes()); + let offset = (header_end + 2048 * 8) as u64; + for _ in 0..2048 { + huge.extend_from_slice(&offset.to_le_bytes()); + } + huge.extend_from_slice(&[0; 8]); + let path = std::env::temp_dir().join(format!("photocraft-exr-ui-{}.exr", std::process::id())); + std::fs::write(&path, huge).unwrap(); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.background_jobs = false; + photocraft_raster::spill::configure(None, 0).unwrap(); + photocraft_raster::memory::configure(photocraft_raster::memory::Policy { percent: None, absolute_bytes: 1 << 30 }); + let error = app.open_path(path.to_str().unwrap()).unwrap_err(); + assert!(error.contains("decoded image tiles"), "{error}"); + assert!(app.session.documents().is_empty()); + std::fs::remove_file(path).unwrap(); +} diff --git a/docs/roadmap.md b/docs/roadmap.md index ccdd5b8291..5b03188984 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -124,3 +124,5 @@ Each milestone has a **definition of done (DoD)** and must leave `main` green on | 2026-10-05 | major | Honest parity assessment added | 2026-10-13: native scratch storage pages current and history tiles through compressed, CRC-checked files. Fresh RAM budgets default to 70%; engine-owned memory supervision stops with its last session. Scratch errors block edit/save commits and permit retry. Unix files are private and stale files swept at startup; Windows files use delete-on-close. See [scratch disks](scratch-disks.md). Streaming import and viewport rendering follow separately. + +2026-10-13: seekable native PSD/PSB and supported scanline EXR imports decode bounded bands at the original resolution. Authorized opens retain read-only source protection. See [native streaming I/O](streaming-io.md). diff --git a/docs/scorecard.md b/docs/scorecard.md index 3b7b0f40e8..f7d7d5f29e 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -248,7 +248,7 @@ Never-crash attribute on 28 of 28 crate roots. | REL-2 | Every workspace crate carries the never-crash deny(clippy::unwrap_used, ...) attribute | done | | Counted automatically above (crate roots under crates/ and apps/); testkit gained it in #221 | | REL-3 | The app catches escaped panics around command dispatch and import/export | done | | a_panic_in_a_command_... runs the test-only `test.panic` spec through Session::execute and asserts Err plus an unchanged document (engine jobs_tests.rs); the worker guard is covered by a_panic_in_the_worker_...; crash_guard.rs guard_turns_a_panic_into_an_error covers import/export (apps/photocraft) | | REL-4 | Atomic save (temp, fsync, rename) for every document write | done | [#203](https://github.com/storytold/photocraft/issues/203) | photocraft_format::atomic_write (format/src/atomic.rs, #230): same-dir temp, sync_all, rename with Windows PermissionDenied retry, Unix dir fsync; every document write site routes through it and format/src/atomic/tests.rs failure-injects write, rename and read-only-dir errors | -| REL-212-1 | Fallible allocation for paste/erase/fill/transform/save/import, clear out-of-memory error | partial | [#212](https://github.com/storytold/photocraft/issues/212) | Raster region/tile buffers, paste/erase/fill/transform and warp output use fallible reservations; filter and lens working memory is admitted before edits, and current/history tiles spill to CRC-checked scratch. Save/import boundaries preserve state; remaining encoder/decoder allocations still need broader audit. | +| REL-212-1 | Fallible allocation for paste/erase/fill/transform/save/import, clear out-of-memory error | partial | [#212](https://github.com/storytold/photocraft/issues/212) | Raster region/tile buffers, paste/erase/fill/transform and warp output use fallible reservations; filter and lens working memory is admitted before edits, and current/history tiles spill to CRC-checked scratch. Native layered PSD/PSB and supported scanline EXR imports decode bounded bands; save/import boundaries preserve state; remaining encoder/decoder allocations still need broader audit. | | REL-212-2 | Export switches to PSB when the estimated file size exceeds 2 GB | done | [#212](https://github.com/storytold/photocraft/issues/212) | PSD export uses checked size estimates for raster, masks, channels, and retained metadata | | REL-212-3 | Tests: an allocation-limit injection makes each command Err with the document unchanged | partial | [#212](https://github.com/storytold/photocraft/issues/212) | Engine failpoint tests cover paste, erase, fill, transform, import and save transaction boundaries; codec-level allocator failure coverage remains open | diff --git a/docs/scratch-disks.md b/docs/scratch-disks.md index a99cc7b30d..f5cda32305 100644 --- a/docs/scratch-disks.md +++ b/docs/scratch-disks.md @@ -35,5 +35,6 @@ A failed read retains its backing extent and blocks editing, cache admission and until the tile is readable again. Recovery can retry the original bytes after the storage becomes available; zeros from a failed display read must never become saved pixels. -Scratch storage alone does not make every format, filter or renderer streamable. The native -PSD/EXR import and viewport streaming changes build on these guards and memory reservations. +Scratch storage alone does not make every format, filter or renderer streamable. See +[native PSD/EXR streaming](streaming-io.md) for the seekable import and output APIs. Viewport +rendering is a separate change built on these guards and memory reservations. diff --git a/docs/streaming-io.md b/docs/streaming-io.md new file mode 100644 index 0000000000..3cc4d3a674 --- /dev/null +++ b/docs/streaming-io.md @@ -0,0 +1,36 @@ +# Native PSD/EXR streaming + +Native path opens inspect a seekable source before allocating decoded pixels. Supported layered +PSD/PSB files and flat scanline EXR files decode bounded bands directly into guarded raster +tiles. Their document dimensions and samples remain at the source resolution; scratch residency +can move cold tiles to disk. The byte decoder remains available for web and unsupported layouts, +subject to working-memory admission rather than a file-sized allocation without a budget. + +PSD indexing retains bounded metadata and channel offsets without copying all channel data or +decoding the stored merged image. Independent seekable channel handles feed the existing document +mapping, preserving layers, masks, groups, metadata, colour models and depth. Unsupported nesting +and metadata limits produce errors. The native PSB writer uses the same metadata mapper with +deferred pixel sources and writes original-depth channel bands to a seekable output. + +EXR block decoding supports the flat scanline layouts accepted by the streaming decoder, including +the RGB HALF ZIP test image `65536 × 32768`. HALF samples are stored as F32, with a warning; this +does not reduce the document dimensions. Unsupported EXR layouts fall back to the normal codec +when its limits allow them. Chunk sizes, source bounds and transient buffers are checked before +allocation. Cancellation is checked between bands, and progress describes completed work. + +Control and CLI opens use the same importer. Authorized control opens create independent handles +under the held read-directory capability, including channel reopens. They do not gain ambient +filesystem access. Path opens check that file size and modification time remain unchanged. Read-only +and preview import flags survive the native and authorized routes, and templates keep an untitled +document without a source save path. + +The seekable output API accepts a caller-owned writer; atomic path output encodes into a temporary +file beside the destination, syncs it using the shared platform helper, then renames it. Encoder +failure, cancellation or unwinding removes the temporary and preserves the old destination. Its +integration with the desktop background-save service is a separate change, using upstream's +`SaveCtl` and document-lock/saved-revision contract. + +Synthetic tests compare streamed import with the byte decoder across PSD/PSB versions, depths, +colour modes and compression, and verify masks, metadata, cancellation, EXR chunk handling, +source protection and capability bounds. Large-file runtime checks use the public fixtures at +[vistaero.es/photocraft](https://vistaero.es/photocraft/). diff --git a/scorecard/reliability.toml b/scorecard/reliability.toml index d5fc1fb5a7..7bc708e548 100644 --- a/scorecard/reliability.toml +++ b/scorecard/reliability.toml @@ -35,7 +35,7 @@ id = "REL-212-1" target = "Fallible allocation for paste/erase/fill/transform/save/import, clear out-of-memory error" status = "partial" issue = 212 -note = "Raster region/tile buffers, paste/erase/fill/transform and warp output use fallible reservations; filter and lens working memory is admitted before edits, and current/history tiles spill to CRC-checked scratch. Save/import boundaries preserve state; remaining encoder/decoder allocations still need broader audit." +note = "Raster region/tile buffers, paste/erase/fill/transform and warp output use fallible reservations; filter and lens working memory is admitted before edits, and current/history tiles spill to CRC-checked scratch. Native layered PSD/PSB and supported scanline EXR imports decode bounded bands; save/import boundaries preserve state; remaining encoder/decoder allocations still need broader audit." [[item]] id = "REL-212-2" From 9750d8a455f36d58fcef2da326ca7ce6d088d03c Mon Sep 17 00:00:00 2001 From: Daniel Date: Tue, 20 Oct 2026 03:49:34 +0200 Subject: [PATCH 004/216] fix(linux): rebase font-free Wayland controls with original title bar defaults --- Cargo.lock | 12 +++++++ NOTICE | 31 +++++++++++++++++++ apps/photocraft/Cargo.toml | 3 +- docs/development.md | 2 ++ packaging/env.sh | 2 +- .../flatpak/ai.storyteller.photocraft.yml | 1 + packaging/linux/package.sh | 2 ++ 7 files changed, 51 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index aff38ac01b..b8c9b9f2e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6217,6 +6217,17 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sctk-adwaita" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" +dependencies = [ + "log", + "smithay-client-toolkit 0.19.2", + "tiny-skia", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -8433,6 +8444,7 @@ dependencies = [ "raw-window-handle", "redox_syscall 0.4.1", "rustix 0.38.44", + "sctk-adwaita", "smithay-client-toolkit 0.19.2", "smol_str", "tracing", diff --git a/NOTICE b/NOTICE index a7302a940c..821969da8a 100644 --- a/NOTICE +++ b/NOTICE @@ -22,3 +22,34 @@ Builds made with craft-fonts (CRAFT_FONTS_DIR; all official releases) also embed https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (today BIZ UDPGothic, Shippori Mincho and BIZ UDMincho; not the web build), under the SIL Open Font License 1.1. Their licence texts ship with the package as OFL-.txt. + +Linux Wayland window decorations +------------------------------- +sctk-adwaita 0.10.1 (https://github.com/PolyMeilex/sctk-adwaita, +commit d5f343c45c04d4a026db57f76760819b7325db00) is used by winit on Linux +for vector window controls. Its title-font features are disabled: no Cantarell +font is embedded by this dependency and no font file is added to this repo. + +sctk-adwaita's MIT licence: + +MIT License + +Copyright (c) 2022 Bartłomiej Maryńczak + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/apps/photocraft/Cargo.toml b/apps/photocraft/Cargo.toml index 505947e222..a0f2a75a5a 100644 --- a/apps/photocraft/Cargo.toml +++ b/apps/photocraft/Cargo.toml @@ -81,7 +81,8 @@ futures-lite = "=2.6.1" async-io = "=2.6.0" # Open the window through Xwayland for a pen on Wayland (`tablet.rs`, #639): eframe doesn't # re-export winit's `EventLoopBuilderExtX11`. Already in Cargo.lock (eframe); pinned exactly. -winit = { version = "=0.30.13", default-features = false, features = ["x11"] } +# Preserve XWayland support; enable font-free Adwaita controls for native Wayland. +winit = { version = "=0.30.13", default-features = false, features = ["wayland-csd-adwaita-notitle", "x11"] } # Safe Win32 wrappers on Windows: the pointer position for file drops (`cursor.rs`, # GetCursorPos) and the window frame for the custom title bar (`window_frame.rs`, window styles), diff --git a/docs/development.md b/docs/development.md index 6891e88a9c..fcf3339b7d 100644 --- a/docs/development.md +++ b/docs/development.md @@ -34,6 +34,8 @@ building locally; the downloaded app is still subject to Windows application-con ## Build and run +On GNOME/Wayland, **System Title Bar** uses winit's font-free Adwaita controls; existing custom-title-bar preferences and defaults are unchanged. + ```sh cargo run --release -p photocraft -- path/to/image.psd # desktop app cargo run --release -p photocraft -- --control 7878 --control-token-file .private/control.token \ diff --git a/packaging/env.sh b/packaging/env.sh index 0007227058..782c4721f0 100644 --- a/packaging/env.sh +++ b/packaging/env.sh @@ -47,7 +47,7 @@ warn() { # Copy licence and readme files that exist into a package directory. copy_docs() { local dest="$1" f - for f in README.md LICENSE LICENSE-MIT LICENSE-APACHE COPYRIGHT; do + for f in README.md LICENSE LICENSE-MIT LICENSE-APACHE COPYRIGHT NOTICE; do if [ -f "$ROOT/$f" ]; then cp "$ROOT/$f" "$dest/"; fi done copy_font_licences "$dest" diff --git a/packaging/linux/flatpak/ai.storyteller.photocraft.yml b/packaging/linux/flatpak/ai.storyteller.photocraft.yml index e3ac74cffd..2189374665 100644 --- a/packaging/linux/flatpak/ai.storyteller.photocraft.yml +++ b/packaging/linux/flatpak/ai.storyteller.photocraft.yml @@ -49,6 +49,7 @@ modules: build-commands: - cargo --offline build --release --locked -p photocraft --features heif - install -Dm755 target/release/photocraft /app/bin/photocraft + - install -Dm644 NOTICE /app/share/doc/photocraft/NOTICE - install -Dm644 packaging/linux/ai.storyteller.photocraft.desktop /app/share/applications/ai.storyteller.photocraft.desktop - install -Dm644 packaging/linux/ai.storyteller.photocraft.mime.xml /app/share/mime/packages/ai.storyteller.photocraft.xml - mkdir -p /app/share/icons && cp -R assets/app-icon/hicolor /app/share/icons/ diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index 615bef25bf..2091ace48a 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -108,6 +108,8 @@ if has appimage; then APPDIR="$WORK/PhotoCraft.AppDir" cp -R "$STAGE" "$APPDIR" mv "$APPDIR/usr/share/doc" "$WORK/doc-unused" + # Preserve the sctk-adwaita MIT notice in the packaged AppImage. + install -Dm644 "$ROOT/NOTICE" "$APPDIR/usr/share/doc/photocraft/NOTICE" # AppRun is a script, not a symlink: it installs the .desktop entry and icons into # $XDG_DATA_HOME before launching, so Wayland compositors can resolve the window's # app_id to our icon instead of the generic Wayland logo (#593; opt-out env in the From ec6a4c1a50327a3424daf7280496d5b3df31cd54 Mon Sep 17 00:00:00 2001 From: zuujiscom Date: Wed, 21 Oct 2026 08:09:59 +0530 Subject: [PATCH 005/216] Save: stop doubling the encoded file in memory (#6607) Saving needed about 4x the file's size in extra RAM: the whole encoded file was buffered in memory before it hit the disk. Now PSD/PSB and layered TIFF stream into the destination as they are encoded. - format: atomic_write_stream_progress hands the encoder a writer that goes through the temp file, so bytes are freed as they are written; ZipWriter reserves the estimate only where output can exceed it, and gives back the unused part of a reserved buffer after compressing. - psd: PsdFile::save streams layer by layer (ImageSourceData::take_from_psd moves rather than copies), and the TIFF encoder writes rows out as they are compressed instead of holding the uncompressed plane. - io/codecs: layered TIFF export streams the interleaved composite, the app's save and export paths pass a progress callback through the stream. A 24 MB PSD/PSB or layered TIFF now saves with a few MB of working set instead of a second copy of the file; on wasm32 the memory is returned, on Windows it is no longer counted as committed. Co-Authored-By: Claude Opus 5.5 --- apps/photocraft/src/services.rs | 60 +++++++-- crates/codecs/src/codecs/tiff.rs | 22 ++- crates/codecs/tests/roundtrip.rs | 14 ++ crates/format/src/atomic.rs | 96 +++++++++++++- crates/format/src/atomic/tests.rs | 93 +++++++++++++ crates/format/src/lib.rs | 2 +- crates/format/src/store.rs | 12 +- crates/format/src/zip.rs | 60 +++++++++ crates/io/src/lib.rs | 95 +++++++++++-- crates/io/src/psd_export.rs | 43 +++++- crates/io/src/tiff_layers.rs | 11 +- crates/io/tests/export_progress.rs | 21 +++ crates/psd/src/compression.rs | 118 ++++++++++------- crates/psd/src/error.rs | 17 +++ crates/psd/src/file.rs | 206 ++++++++++++++++++++++++----- crates/psd/src/io.rs | 117 ++++++++++++++++ crates/psd/src/layer.rs | 101 ++++++++++---- crates/psd/src/tagged.rs | 25 +++- crates/psd/src/tiff.rs | 128 ++++++++++++++++-- 19 files changed, 1087 insertions(+), 154 deletions(-) diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index 999952f186..0bf5d86d71 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -324,9 +324,15 @@ fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), String> { photocraft_format::atomic_write(path, bytes).map_err(|e| e.to_string()) } -/// [`write_atomic`] reporting the fraction written (a background save's progress, #3389). -fn write_atomic_progress(path: &Path, bytes: &[u8], progress: &dyn Fn(f32)) -> Result<(), String> { - photocraft_format::atomic_write_progress(path, bytes, progress).map_err(|e| e.to_string()) +/// [`write_atomic`] for an encoded save, reporting the fraction written (a background save's +/// progress, #3389). A PSD/PSB is serialized straight into the temporary file, so the whole file +/// is never held in memory next to its model (#6607). +fn write_encoded_atomic(path: &Path, encoded: &photocraft_io::Encoded, progress: &dyn Fn(f32)) -> Result<(), String> { + match encoded { + photocraft_io::Encoded::Bytes(bytes) => photocraft_format::atomic_write_progress(path, bytes, progress), + photocraft_io::Encoded::Psd(_) => photocraft_format::atomic_write_stream_progress(path, encoded.len_hint(), progress, &mut |w| encoded.write_to(w)), + } + .map_err(|e| e.to_string()) } fn export_options(settings: &photocraft_ui_egui::ExportSettings) -> photocraft_io::ExportOptions { @@ -338,26 +344,26 @@ fn export_options(settings: &photocraft_ui_egui::ExportSettings) -> photocraft_i const ENCODE_SHARE: f32 = 0.85; /// A save on a worker thread (`Services::save_file` and `Services::automation_save`, #2017): -/// encode, then write through `write`. Cancelling while encoding leaves the target untouched; -/// once writing starts the save can't be cancelled. The encoder's progress fills the first +/// encode, then write through `write` (a PSD/PSB is serialized as it is written). Cancelling +/// while encoding leaves the target untouched; once writing starts the save can't be cancelled. The encoder's progress fills the first /// [`ENCODE_SHARE`] of the job's progress, so the status bar and tab read "Saving NN%" (#3389). fn save_through( doc: &Document, path: &str, settings: &photocraft_ui_egui::ExportSettings, ctl: &photocraft_ui_egui::jobs_ui::SaveCtl, - write: impl Fn(&str, &[u8], &dyn Fn(f32)) -> Result<(), String>, + write: impl Fn(&str, &photocraft_io::Encoded, &dyn Fn(f32)) -> Result<(), String>, ) -> Result, String> { ctl.progress(0.0, "Encoding"); let opts = export_options(settings); let cancelled = || ctl.check().is_err(); let progress = |f: f32| ctl.progress(f * ENCODE_SHARE, ""); let interrupt = photocraft_raster::Interrupt::new(&cancelled, &progress); - let r = crate::crash_guard::guard("Save", || photocraft_io::export_with(doc, path, &opts, &interrupt).map_err(|e| e.to_string()))?; + let r = crate::crash_guard::guard("Save", || photocraft_io::encode_with(doc, path, &opts, &interrupt).map_err(|e| e.to_string()))?; ctl.commit()?; ctl.progress(ENCODE_SHARE, "Writing"); // The last bit is the flush and rename after the last chunk: 100% only once they're done. - write(path, &r.bytes, &|f| ctl.progress(ENCODE_SHARE + (0.99 - ENCODE_SHARE) * f, ""))?; + write(path, &r.encoded, &|f| ctl.progress(ENCODE_SHARE + (0.99 - ENCODE_SHARE) * f, ""))?; ctl.progress(1.0, ""); Ok(r.warnings) } @@ -368,7 +374,7 @@ fn save_file( settings: &photocraft_ui_egui::ExportSettings, ctl: &photocraft_ui_egui::jobs_ui::SaveCtl, ) -> Result, String> { - save_through(doc, path, settings, ctl, |p, b, progress| write_atomic_progress(Path::new(p), b, progress)) + save_through(doc, path, settings, ctl, |p, encoded, progress| write_encoded_atomic(Path::new(p), encoded, progress)) } type SharedRecovery = Rc>>; @@ -515,7 +521,7 @@ pub fn native(automation: Option) -> }); let automation_save = automation.clone().map(|workspace| { std::sync::Arc::new(move |doc: &Document, path: &str, settings: &photocraft_ui_egui::ExportSettings, ctl: &photocraft_ui_egui::jobs_ui::SaveCtl| { - save_through(doc, path, settings, ctl, |p, b, _| workspace.write(p, b).map_err(|e| e.to_string())) + save_through(doc, path, settings, ctl, |p, encoded, _| workspace.write(p, &encoded.bytes().map_err(|e| e.to_string())?).map_err(|e| e.to_string())) }) as photocraft_ui_egui::SaveFileFn }); let step: fn(&str, &serde_json::Value) -> photocraft_engine::Result<()> = photocraft_automation::workspace::authorize_desktop_engine_step; @@ -713,6 +719,40 @@ mod tests { use photocraft_ui_egui::{PhotocraftApp, prefs_ui}; use serde_json::{Value, json}; + /// #6607: a PSD/PSB save streams the file model into the temporary file, which then holds + /// exactly the exported bytes; a serializer error halfway leaves the old file and no + /// temporary file behind. + #[test] + fn a_psd_save_streams_the_exported_bytes_and_keeps_the_old_file_on_error() { + let dir = temp("stream"); + let mut doc = Document::new("streamed", Size::new(64, 48), ColorMode::Rgb, SampleType::U8); + let fmt = doc.pixel_format(); + let px: Vec = (0..64 * 48 * fmt.bytes_per_pixel()).map(|i| (i * 7 % 251) as u8).collect(); + doc.layers.push(Layer::new("A", photocraft_doc::LayerContent::Raster(photocraft_raster::Surface::from_interleaved(fmt, doc.bounds(), &px)))); + let opts = photocraft_io::ExportOptions::default(); + for name in ["a.psd", "a.psb"] { + let path = dir.join(name); + std::fs::write(&path, b"old").unwrap(); + let want = photocraft_io::export(&doc, name, &opts).unwrap().bytes; + let r = photocraft_io::encode_with(&doc, name, &opts, &photocraft_raster::Interrupt::NONE).unwrap(); + let photocraft_io::Encoded::Psd(mut file) = r.encoded else { panic!("{name}: not streamed") }; + let seen = std::cell::RefCell::new(Vec::new()); + write_encoded_atomic(&path, &photocraft_io::Encoded::Psd(file.clone()), &|f| seen.borrow_mut().push(f)).unwrap(); + assert!(std::fs::read(&path).unwrap() == want, "{name}: the saved file differs from the export"); + assert_eq!(seen.borrow().last().copied(), Some(1.0), "{name}"); + + let info = file.layer_info.as_mut().unwrap(); + let layer = info.layers[0].clone(); + info.layers = vec![layer; 32768]; + let e = write_encoded_atomic(&path, &photocraft_io::Encoded::Psd(file), &|_| {}).unwrap_err(); + assert!(e.contains("32767") && e.contains("not changed"), "{name}: {e}"); + assert!(std::fs::read(&path).unwrap() == want, "{name}: a failed save replaced the file"); + let temps: Vec<_> = std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.file_name()).filter(|n| n.to_string_lossy().ends_with(".tmp")).collect(); + assert!(temps.is_empty(), "{name}: left behind {temps:?}"); + } + let _ = std::fs::remove_dir_all(&dir); + } + /// #6121: the file type picked in the save dialog gives the name its extension where the /// dialog left the one it opened with, and only there. #[test] diff --git a/crates/codecs/src/codecs/tiff.rs b/crates/codecs/src/codecs/tiff.rs index 9528fec9c8..be096a35b8 100644 --- a/crates/codecs/src/codecs/tiff.rs +++ b/crates/codecs/src/codecs/tiff.rs @@ -1241,8 +1241,18 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result tiff::TiffResult> { - let mut cursor = Cursor::new(Vec::new()); + // An uncompressed file is allocated once at its estimated size: grown as written, the + // buffer would double near the end and copy the file (#6607). Compressed output is + // usually a fraction of the estimate, so it grows as written instead: reserving the + // estimate would hold a second full-size buffer while encoding, which wasm32 never + // returns and Windows counts as committed memory. + let mut out = Vec::new(); + if matches!(opts.tiff_compression, TiffCompression::None) { + let _ = out.try_reserve(usize::try_from(estimate).unwrap_or(usize::MAX)); + } + let mut cursor = Cursor::new(out); if big { let mut enc = TiffEncoder::new_big(&mut cursor)?.with_compression(compression).with_predictor(predictor); write_image::(&mut enc, &img, &tags)?; @@ -1250,9 +1260,15 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result(&mut enc, &img, &tags)?; } - Ok(cursor.into_inner()) + // Growth (or an estimate that was too high) can leave spare capacity: give it back + // instead of holding it while the file is saved. + let mut out = cursor.into_inner(); + if out.capacity() - out.len() > out.len() / 8 { + out.shrink_to_fit(); + } + Ok(out) }; - let big = opts.tiff_bigtiff || estimated_size(&img, &tags) > CLASSIC_LIMIT; + let big = opts.tiff_bigtiff || estimate > CLASSIC_LIMIT; match write(big) { Ok(v) => Ok(v), Err(tiff::TiffError::IntSizeError) if !big => write(true).map_err(|e| CodecError::encode(F, e)), diff --git a/crates/codecs/tests/roundtrip.rs b/crates/codecs/tests/roundtrip.rs index 9c6cb8cb6f..7338719e21 100644 --- a/crates/codecs/tests/roundtrip.rs +++ b/crates/codecs/tests/roundtrip.rs @@ -213,6 +213,20 @@ fn tiff_all_compressions_lossless() { } } +#[test] +fn compressed_tiff_does_not_keep_the_uncompressed_reservation() { + // The encoder reserves the uncompressed size up front (#6607). A smooth image compresses + // to a fraction of it, and the returned file must not hold the rest. + let (w, h) = (512u32, 512u32); + let px: Vec = (0..h).flat_map(|y| (0..w).flat_map(move |x| [(x / 2) as u8, (y / 2) as u8, 128])).collect(); + let img = Image::from_u8(w, h, ChannelLayout::Rgb, px).unwrap(); + for comp in [TiffCompression::Lzw, TiffCompression::Deflate] { + let v = encode(&img, Format::Tiff, &EncodeOptions { tiff_compression: comp, ..Default::default() }).unwrap(); + assert!(v.len() < img.data().len() / 2, "{comp:?}: the image should compress well ({} bytes)", v.len()); + assert!(v.capacity() - v.len() <= v.len() / 8, "{comp:?}: {} bytes kept for a {} byte file", v.capacity(), v.len()); + } +} + #[test] fn png_all_compressions_lossless() { let img = test_image(ChannelLayout::Rgba, SampleType::U16); diff --git a/crates/format/src/atomic.rs b/crates/format/src/atomic.rs index 3a4aeca6c0..4581b8486d 100644 --- a/crates/format/src/atomic.rs +++ b/crates/format/src/atomic.rs @@ -34,7 +34,7 @@ pub mod meta; -use std::io::{self, Write}; +use std::io::{self, Seek, Write}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; @@ -55,6 +55,15 @@ pub trait AtomicFs { let _ = progress; self.write_new(tmp, bytes) } + /// Create `tmp` (it must not exist), let `write` fill it as it serializes, and flush it + /// ([`sync_file`]). By default the bytes are collected in memory and handed to + /// [`write_new`](Self::write_new) (test doubles); [`RealFs`] streams them into the file, so the + /// whole file never sits in memory (#6607). + fn write_new_stream(&self, tmp: &Path, write: &mut StreamWriter<'_>) -> io::Result<()> { + let mut bytes = io::Cursor::new(Vec::new()); + write(&mut bytes)?; + self.write_new(tmp, bytes.get_ref()) + } /// Rename `from` over `to`, replacing it. fn rename(&self, from: &Path, to: &Path) -> io::Result<()>; /// Remove a (temporary) file. @@ -73,6 +82,13 @@ pub trait AtomicFs { } } +/// A stream [`atomic_write_stream`]'s writer fills: the temporary file (or, in tests, memory). +pub trait WriteSeek: Write + Seek {} +impl WriteSeek for T {} + +/// Fills the temporary file of a streaming atomic write, starting at its beginning. +pub type StreamWriter<'a> = dyn FnMut(&mut dyn WriteSeek) -> io::Result<()> + 'a; + /// The real file system. #[derive(Debug, Clone, Copy, Default)] pub struct RealFs; @@ -94,6 +110,13 @@ impl AtomicFs for RealFs { } sync_file(&f) } + fn write_new_stream(&self, tmp: &Path, write: &mut StreamWriter<'_>) -> io::Result<()> { + let f = std::fs::OpenOptions::new().write(true).create_new(true).open(tmp)?; + let mut out = io::BufWriter::new(f); + write(&mut out)?; + let f = out.into_inner().map_err(io::IntoInnerError::into_error)?; + sync_file(&f) + } fn rename(&self, from: &Path, to: &Path) -> io::Result<()> { std::fs::rename(from, to) } @@ -191,6 +214,75 @@ pub fn atomic_write_with(fs: &dyn AtomicFs, retry: RenameRetry, path: &Path, byt /// [`atomic_write_with`] reporting write progress (see [`atomic_write_progress`]). pub fn atomic_write_with_progress(fs: &dyn AtomicFs, retry: RenameRetry, path: &Path, bytes: &[u8], progress: &dyn Fn(f32)) -> io::Result<()> { + replace_with(fs, retry, path, &mut |tmp| fs.write_new_progress(tmp, bytes, progress)) +} + +/// [`atomic_write`] for a file serialized as it is written (#6607): `write` fills the temporary +/// file beside `path` from its start (it may seek back to patch lengths), then the file is +/// flushed and renamed over `path` with [`atomic_write`]'s retries and errors. If `write` fails, +/// the temporary file is removed and `path` is left as it was. `write` runs once (again only if +/// a temporary name collided before anything was written). +pub fn atomic_write_stream(path: &Path, write: &mut StreamWriter<'_>) -> io::Result<()> { + atomic_write_stream_with(&RealFs, RenameRetry::platform(), path, write) +} + +/// [`atomic_write_stream`] reporting the fraction written to `progress` as it goes: the furthest +/// position reached over `expected_len` (an estimate; capped below 1.0 until `write` is done). +pub fn atomic_write_stream_progress(path: &Path, expected_len: u64, progress: &dyn Fn(f32), write: &mut StreamWriter<'_>) -> io::Result<()> { + atomic_write_stream_with(&RealFs, RenameRetry::platform(), path, &mut |w| { + let mut counted = Progress { w, pos: 0, reached: 0, reported: 0, expected: expected_len.max(1), progress }; + write(&mut counted)?; + progress(1.0); + Ok(()) + }) +} + +/// [`atomic_write_stream`] through an explicit file system and retry policy. +pub fn atomic_write_stream_with(fs: &dyn AtomicFs, retry: RenameRetry, path: &Path, write: &mut StreamWriter<'_>) -> io::Result<()> { + replace_with(fs, retry, path, &mut |tmp| fs.write_new_stream(tmp, write)) +} + +/// A stream reporting how far it has got (see [`atomic_write_stream_progress`]), every +/// [`WRITE_CHUNK`] bytes. +struct Progress<'a, 'p> { + w: &'a mut dyn WriteSeek, + /// Tracked here: asking a buffered file for its position flushes it. + pos: u64, + reached: u64, + reported: u64, + expected: u64, + progress: &'p dyn Fn(f32), +} + +impl Write for Progress<'_, '_> { + fn write(&mut self, b: &[u8]) -> io::Result { + let n = self.w.write(b)?; + self.pos += n as u64; + let at = self.pos; + if at > self.reached { + self.reached = at; + if at - self.reported >= WRITE_CHUNK as u64 { + self.reported = at; + (self.progress)((at as f64 / self.expected as f64).min(0.999) as f32); + } + } + Ok(n) + } + fn flush(&mut self) -> io::Result<()> { + self.w.flush() + } +} + +impl Seek for Progress<'_, '_> { + fn seek(&mut self, to: io::SeekFrom) -> io::Result { + self.pos = self.w.seek(to)?; + Ok(self.pos) + } +} + +/// The steps every atomic write shares: `write_tmp` fills a fresh temporary file beside the +/// target, which is then renamed over it. +fn replace_with(fs: &dyn AtomicFs, retry: RenameRetry, path: &Path, write_tmp: &mut dyn FnMut(&Path) -> io::Result<()>) -> io::Result<()> { let target = resolve_symlink(path).map_err(|e| context(e, format!("cannot save {}", path.display())))?; let name = target.file_name().ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, format!("{}: not a file path", path.display())))?; let dir = match target.parent() { @@ -207,7 +299,7 @@ pub fn atomic_write_with_progress(fs: &dyn AtomicFs, retry: RenameRetry, path: & let mut tmp = None; for _ in 0..8 { let candidate = dir.join(temp_name(&name.to_string_lossy())); - match fs.write_new_progress(&candidate, bytes, progress) { + match write_tmp(&candidate) { Ok(()) => { tmp = Some(candidate); break; diff --git a/crates/format/src/atomic/tests.rs b/crates/format/src/atomic/tests.rs index d4381705a0..71af1deb00 100644 --- a/crates/format/src/atomic/tests.rs +++ b/crates/format/src/atomic/tests.rs @@ -105,6 +105,99 @@ fn reports_write_progress() { assert_no_temp(&d.0); } +/// Writes "head", a 4-byte placeholder, `len` bytes, then patches the placeholder by seeking back. +fn patched_writer(len: usize) -> impl FnMut(&mut dyn WriteSeek) -> io::Result<()> { + move |w| { + w.write_all(b"head")?; + let at = w.stream_position()?; + w.write_all(&[0; 4])?; + w.write_all(&vec![9u8; len])?; + w.seek(io::SeekFrom::Start(at))?; + w.write_all(&(len as u32).to_be_bytes())?; + w.seek(io::SeekFrom::End(0))?; + Ok(()) + } +} + +fn patched_bytes(len: usize) -> Vec { + let mut v = b"head".to_vec(); + v.extend_from_slice(&(len as u32).to_be_bytes()); + v.extend(std::iter::repeat_n(9u8, len)); + v +} + +/// #6607: a streamed save replaces the file with what its writer wrote, seeks included, and leaves +/// no temporary file. +#[test] +fn streamed_write_replaces_and_creates() { + let d = TempDir::new("stream"); + let p = d.0.join("doc.psd"); + atomic_write_stream(&p, &mut patched_writer(3)).unwrap(); + assert_eq!(std::fs::read(&p).unwrap(), patched_bytes(3)); + atomic_write_stream(&p, &mut patched_writer(100_000)).unwrap(); + assert!(std::fs::read(&p).unwrap() == patched_bytes(100_000)); + assert_no_temp(&d.0); +} + +/// #6607: a writer that fails halfway (a serializer error, a full disk) leaves the original as it +/// was, with the same message as a buffered save. +#[test] +fn streamed_write_failure_halfway_keeps_original() { + let d = TempDir::new("stream-fail"); + let p = d.0.join("doc.psd"); + std::fs::write(&p, ORIGINAL).unwrap(); + let e = atomic_write_stream(&p, &mut |w| { + w.write_all(&[1; 70_000])?; + Err(io::Error::new(io::ErrorKind::StorageFull, "injected: no space left on device")) + }) + .unwrap_err(); + assert_eq!(e.kind(), io::ErrorKind::StorageFull); + assert!(e.to_string().contains("the disk is full") && e.to_string().contains("not changed"), "{e}"); + assert_eq!(std::fs::read(&p).unwrap(), ORIGINAL); + assert_no_temp(&d.0); +} + +/// A streamed save renames like a buffered one: retried while locked, the original kept when the +/// lock outlasts the retries. +#[test] +fn streamed_write_retries_a_locked_rename() { + let d = TempDir::new("stream-locked"); + let p = d.0.join("doc.psd"); + std::fs::write(&p, ORIGINAL).unwrap(); + let fs = Faulty { locked_renames: Cell::new(2), ..Default::default() }; + atomic_write_stream_with(&fs, RenameRetry { attempts: 3, first_delay_ms: 1 }, &p, &mut patched_writer(10)).unwrap(); + assert_eq!(std::fs::read(&p).unwrap(), patched_bytes(10)); + assert_eq!(fs.renames.get(), 3); + let fs = Faulty { locked_renames: Cell::new(10), ..Default::default() }; + let e = atomic_write_stream_with(&fs, RenameRetry { attempts: 1, first_delay_ms: 1 }, &p, &mut patched_writer(20)).unwrap_err(); + assert!(e.to_string().contains("locked"), "{e}"); + assert_eq!(std::fs::read(&p).unwrap(), patched_bytes(10)); + assert_no_temp(&d.0); +} + +/// A streamed save reports the furthest position over the expected length, chunk by chunk, and +/// 1.0 once the writer is done. +#[test] +fn streamed_write_reports_progress() { + let d = TempDir::new("stream-progress"); + let p = d.0.join("big.psd"); + let len = WRITE_CHUNK * 2 + 123; + let seen = std::sync::Mutex::new(Vec::new()); + let mut write = |w: &mut dyn WriteSeek| { + for chunk in patched_bytes(len).chunks(WRITE_CHUNK / 4) { + w.write_all(chunk)?; + } + Ok(()) + }; + atomic_write_stream_progress(&p, len as u64 + 8, &|f| seen.lock().unwrap().push(f), &mut write).unwrap(); + let seen = seen.into_inner().unwrap(); + assert_eq!(seen.len(), 3, "two chunks, then done: {seen:?}"); + assert!(seen.windows(2).all(|w| w[0] < w[1]), "monotonic: {seen:?}"); + assert_eq!(seen.last().copied(), Some(1.0)); + assert!(std::fs::read(&p).unwrap() == patched_bytes(len)); + assert_no_temp(&d.0); +} + #[test] fn creates_missing_parent_folders() { let d = TempDir::new("parent"); diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 0c87720212..5f5187e8fb 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -37,7 +37,7 @@ use std::path::Path; use photocraft_doc::Document; use photocraft_raster::Rgba8Image; -pub use atomic::{atomic_write, atomic_write_progress}; +pub use atomic::{atomic_write, atomic_write_progress, atomic_write_stream, atomic_write_stream_progress}; #[cfg(not(target_arch = "wasm32"))] pub use autosave::RecoveryStore; pub use autosave::{Autosaver, RecoveryEntry, discard_recovery, list_recovery, recover}; diff --git a/crates/format/src/store.rs b/crates/format/src/store.rs index 7da5dd6b53..988e7d29e2 100644 --- a/crates/format/src/store.rs +++ b/crates/format/src/store.rs @@ -771,15 +771,19 @@ impl PcraftWriter { pub fn save_zip(&mut self, doc: &Document, opts: &SaveOptions) -> Result<(Vec, SaveStats)> { let p = self.prepare(doc, opts)?; let mut stats = p.stats; - let mut z = ZipWriter::new(); + // New objects are compressed on all cores first (zstd dominates a full save). + let todo: Vec<(&String, &Object)> = p.objects.iter().filter(|(path, _)| !self.compressed.contains_key(*path)).collect(); + let mut fresh: HashMap<&String, Arc>> = par_compress(&todo)?.into_iter().collect(); + // Every entry's size is known now: the archive is allocated once (#6607). + let sizes = p.objects.keys().map(|path| (path.as_str(), self.compressed.get(path).or_else(|| fresh.get(path)).map_or(0, |d| d.len()))); + let mut z = ZipWriter::for_entries( + std::iter::once((MANIFEST, p.manifest.len())).chain(p.previews.iter().map(|(name, data)| (*name, data.len()))).chain(sizes), + )?; z.add(MANIFEST, &p.manifest)?; for (name, data) in &p.previews { z.add(name, data)?; } let mut next = HashMap::with_capacity(p.objects.len()); - // New objects are compressed on all cores first (zstd dominates a full save). - let todo: Vec<(&String, &Object)> = p.objects.iter().filter(|(path, _)| !self.compressed.contains_key(*path)).collect(); - let mut fresh: HashMap<&String, Arc>> = par_compress(&todo)?.into_iter().collect(); for (path, obj) in &p.objects { let data = match self.compressed.get(path) { Some(d) => { diff --git a/crates/format/src/zip.rs b/crates/format/src/zip.rs index 9ec92c2fba..345194ffe4 100644 --- a/crates/format/src/zip.rs +++ b/crates/format/src/zip.rs @@ -11,6 +11,11 @@ use crate::FormatError; const LOCAL_SIG: u32 = 0x0403_4b50; const CENTRAL_SIG: u32 = 0x0201_4b50; const EOCD_SIG: u32 = 0x0605_4b50; +/// Fixed sizes of a local file header, a central directory entry (both before the name) and the +/// end-of-central-directory record. +const LOCAL_HEADER: usize = 30; +const CENTRAL_HEADER: usize = 46; +const END_RECORD: usize = 22; /// CRC-32 (IEEE), as stored in ZIP headers. pub fn crc32(data: &[u8]) -> u32 { @@ -35,6 +40,35 @@ impl ZipWriter { ZipWriter { out: Vec::new(), central: Vec::new(), count: 0 } } + /// A writer whose output is allocated once for `entries` (name, data length): growing it as + /// entries come in would double the buffer near the end, copying the whole archive (#6607). + /// Fails, before allocating, on entries that [`Self::add`] or [`Self::finish`] would refuse, + /// or when there is not enough memory. + pub fn for_entries<'a>(entries: impl IntoIterator) -> Result { + let (mut out, mut central, mut count) = (0usize, 0usize, 0usize); + for (name, len) in entries { + if u32::try_from(len).is_err() { + return Err(FormatError::Unsupported("zip entry over 4 GiB".into())); + } + out = out.saturating_add(LOCAL_HEADER + name.len()).saturating_add(len); + central = central.saturating_add(CENTRAL_HEADER + name.len()); + count += 1; + } + if count > usize::from(u16::MAX) { + return Err(FormatError::Unsupported("more than 65535 zip entries (ZIP64 not supported)".into())); + } + // The central directory starts where the entries end, at an offset that must fit 32 bits. + if u32::try_from(out).is_err() { + return Err(FormatError::Unsupported("zip over 4 GiB".into())); + } + let no_memory = |_| FormatError::LimitExceeded("not enough memory for the archive".into()); + let mut w = Self::new(); + // Room for the central directory and its end record too: `finish` appends them. + w.out.try_reserve_exact(out.saturating_add(central).saturating_add(END_RECORD)).map_err(no_memory)?; + w.central.try_reserve_exact(central).map_err(no_memory)?; + Ok(w) + } + pub fn add(&mut self, name: &str, data: &[u8]) -> Result<(), FormatError> { if self.count == u16::MAX { return Err(FormatError::Unsupported("more than 65535 zip entries (ZIP64 not supported)".into())); @@ -243,6 +277,32 @@ impl<'a> ZipReader<'a> { mod tests { use super::*; + /// #6607: an archive written through `for_entries` is never reallocated while written. + #[test] + fn an_archive_sized_for_its_entries_fills_its_buffer_exactly() { + let entries: Vec<(String, Vec)> = (0..50).map(|i| (format!("objects/{i:03}.zst"), vec![i as u8; i * 997])).collect(); + let mut z = ZipWriter::for_entries(entries.iter().map(|(n, d)| (n.as_str(), d.len()))).unwrap(); + for (n, d) in &entries { + z.add(n, d).unwrap(); + } + let bytes = z.finish().unwrap(); + assert_eq!(bytes.capacity(), bytes.len()); + let r = ZipReader::new(&bytes).unwrap(); + assert_eq!(r.entries.len(), entries.len()); + assert_eq!(r.read_by_name("objects/049.zst", usize::MAX).unwrap(), entries[49].1); + } + + /// Sizes `add` or `finish` would refuse fail up front, before anything is allocated. + #[test] + fn unsupported_sizes_are_refused_before_reserving() { + let big = u32::MAX as usize; + assert!(matches!(ZipWriter::for_entries([("x", usize::MAX)]), Err(FormatError::Unsupported(_)))); + assert!(matches!(ZipWriter::for_entries([("x", big + 1)]), Err(FormatError::Unsupported(_)))); + assert!(matches!(ZipWriter::for_entries([("a", big / 2), ("b", big / 2 + 1)]), Err(FormatError::Unsupported(_)))); + let names = vec!["n"; usize::from(u16::MAX) + 1]; + assert!(matches!(ZipWriter::for_entries(names.into_iter().map(|n| (n, 0))), Err(FormatError::Unsupported(_)))); + } + #[test] fn crc_known_value() { assert_eq!(crc32(b"123456789"), 0xCBF4_3926); diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 946abfbc75..6843432afb 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -179,6 +179,67 @@ pub struct ExportResult { pub warnings: Vec, } +/// What [`encode_with`] produced: the file's bytes, or for PSD/PSB the file model, which +/// [`Encoded::write_to`] serializes straight into the file, so a save never holds the whole +/// encoded file next to the model (#6607). Either way the bytes are exactly [`export`]'s. +#[derive(Debug, Clone)] +pub enum Encoded { + /// The encoded file. + Bytes(Vec), + /// A PSD/PSB file, serialized when written. + Psd(Box), +} + +impl Encoded { + /// The file's bytes, serializing a PSD/PSB model in memory. + pub fn into_bytes(self) -> Result, IoError> { + match self { + Encoded::Bytes(b) => Ok(b), + Encoded::Psd(file) => Ok(file.to_bytes()?), + } + } + + /// [`Self::into_bytes`] without giving up the model. + pub fn bytes(&self) -> Result, IoError> { + match self { + Encoded::Bytes(b) => Ok(std::borrow::Cow::Borrowed(b)), + Encoded::Psd(file) => Ok(std::borrow::Cow::Owned(file.to_bytes()?)), + } + } + + /// The written length for progress: exact for bytes, an upper bound for PSD/PSB. + pub fn len_hint(&self) -> u64 { + match self { + Encoded::Bytes(b) => b.len() as u64, + Encoded::Psd(file) => file.size_bound() as u64, + } + } + + /// Writes the file into `w` from its current position (PSD/PSB seeks back to patch lengths). + /// A PSD/PSB serializer error comes back as `InvalidData`. + pub fn write_to(&self, w: &mut W) -> std::io::Result<()> { + match self { + Encoded::Bytes(b) => w.write_all(b), + Encoded::Psd(file) => file.write_to(w), + } + } +} + +/// Result of [`encode_with`]. +#[derive(Debug, Clone)] +pub struct EncodedExport { + /// The file, ready to write. + pub encoded: Encoded, + /// Human-readable notes about anything approximated or dropped. + pub warnings: Vec, +} + +impl From for EncodedExport { + fn from(r: ExportResult) -> Self { + EncodedExport { encoded: Encoded::Bytes(r.bytes), warnings: r.warnings } + } +} + /// Which part of the document's XMP packet and free-form text a flat export embeds. /// PSD/PSB always keep XMP; `.pcraft` keeps both. Save As and conversions keep the whole packet, as /// Photoshop's Save As does; Export As starts at `None`, because the packet lists the text of @@ -396,6 +457,18 @@ pub fn export(doc: &Document, name_or_ext: &str, opts: &ExportOptions) -> Result /// layered TIFF, in coarse steps for the other formats) and checks it between stages. A cancelled /// export fails with [`IoError::Cancelled`]; otherwise the bytes are exactly [`export`]'s. pub fn export_with(doc: &Document, name_or_ext: &str, opts: &ExportOptions, ctl: &photocraft_raster::Interrupt) -> Result { + ctl.check().map_err(|_| IoError::Cancelled)?; + let r = export_stages(doc, name_or_ext, opts, ctl)?; + let bytes = r.encoded.into_bytes()?; + ctl.check().map_err(|_| IoError::Cancelled)?; + ctl.progress(1.0); + Ok(ExportResult { bytes, warnings: r.warnings }) +} + +/// [`export_with`] up to the point where the file can be written: PSD/PSB stop at the file model, +/// which [`Encoded::write_to`] serializes into the file as it goes (#6607); every other format is +/// encoded. Reports progress up to `1.0` once the result is ready. +pub fn encode_with(doc: &Document, name_or_ext: &str, opts: &ExportOptions, ctl: &photocraft_raster::Interrupt) -> Result { ctl.check().map_err(|_| IoError::Cancelled)?; let r = export_stages(doc, name_or_ext, opts, ctl)?; ctl.check().map_err(|_| IoError::Cancelled)?; @@ -418,7 +491,7 @@ const PCRAFT_PREVIEWS_DONE: f32 = 0.4; /// before its bytes are assembled: about 2.7 s of 3.0 s for a 24 MP, 5-layer RGB/8 document. const PSD_MODEL_DONE: f32 = 0.9; -fn export_stages(doc: &Document, name_or_ext: &str, opts: &ExportOptions, ctl: &photocraft_raster::Interrupt) -> Result { +fn export_stages(doc: &Document, name_or_ext: &str, opts: &ExportOptions, ctl: &photocraft_raster::Interrupt) -> Result { let ext = extension(name_or_ext); if ext == "pdn" { return Err(IoError::Unsupported("PDN is import-only; save as .pcraft to preserve all layers and blend modes".into())); @@ -430,10 +503,10 @@ fn export_stages(doc: &Document, name_or_ext: &str, opts: &ExportOptions, ctl: & return Err(IoError::Unsupported("Krita files are import-only; save as .ora to open the layers in Krita, or .pcraft to keep everything".into())); } if ext == "ora" { - return ora::export(doc); + return ora::export(doc).map(EncodedExport::from); } if ext == "pdf" { - return pdf::export(doc); + return pdf::export(doc).map(EncodedExport::from); } if ext == photocraft_format::EXTENSION { let doc = named_for_file(doc, name_or_ext); @@ -444,7 +517,7 @@ fn export_stages(doc: &Document, name_or_ext: &str, opts: &ExportOptions, ctl: & ctl.check().map_err(|_| IoError::Cancelled)?; ctl.progress(PCRAFT_PREVIEWS_DONE); let previews = photocraft_format::SaveOptions { thumbnail, composite }; - return Ok(ExportResult { bytes: photocraft_format::save_to_bytes(doc.as_ref(), &previews)?, warnings: Vec::new() }); + return Ok(ExportResult { bytes: photocraft_format::save_to_bytes(doc.as_ref(), &previews)?, warnings: Vec::new() }.into()); } if ext == "psd" || ext == "psb" { let o = PsdExportOptions { @@ -453,6 +526,9 @@ fn export_stages(doc: &Document, name_or_ext: &str, opts: &ExportOptions, ctl: & document_path: opts.document_path.clone(), ..Default::default() }; + // The preview is rendered first, while only the document is in memory: its full-size + // render would otherwise come on top of the finished file model (#6607). + let preview = opts.image_previews.then(|| psd_thumbnail::resource(doc)); let (mut file, mut warnings) = with_span(ctl, 0.0, PSD_MODEL_DONE, |ctl| psd_export::document_to_psd_interruptible(doc, &o, ctl)); // A cancelled conversion stops early with an incomplete model: never write it. ctl.check().map_err(|_| IoError::Cancelled)?; @@ -460,8 +536,8 @@ fn export_stages(doc: &Document, name_or_ext: &str, opts: &ExportOptions, ctl: & warnings.push("free-form text metadata is not supported by PSD/PSB; it will be dropped".into()); } warnings.extend(tiff_layers::strip_foreign_order_blocks(&mut file)); - if opts.image_previews { - match psd_thumbnail::resource(doc) { + if let Some(preview) = preview { + match preview { // Beside the other resources Photoshop writes ahead of the version info. Ok(r) => { let at = file.resources.iter().position(|r| r.id == photocraft_psd::resources::ids::VERSION_INFO).unwrap_or(file.resources.len()); @@ -472,14 +548,13 @@ fn export_stages(doc: &Document, name_or_ext: &str, opts: &ExportOptions, ctl: & } // Never write a header the reader would refuse (e.g. a zero-sized canvas). file.header.validate()?; - let bytes = file.to_bytes()?; - return Ok(ExportResult { bytes, warnings }); + return Ok(EncodedExport { encoded: Encoded::Psd(Box::new(file)), warnings }); } let format = photocraft_codecs::from_extension(&ext).ok_or_else(|| IoError::UnknownFormat(name_or_ext.to_string()))?; if format == photocraft_codecs::Format::Tiff && opts.tiff_layers && tiff_layers::would_write_layers(doc) { - return tiff_layers::export_layered(doc, opts, ctl); + return tiff_layers::export_layered(doc, opts, ctl).map(EncodedExport::from); } - flat::export_flat_with(doc, format, opts, ctl) + flat::export_flat_with(doc, format, opts, ctl).map(EncodedExport::from) } /// The PSD's merged composite as straight RGBA floats (row-major), converted diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index 9ad93bdf66..e4303edac9 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -1301,11 +1301,22 @@ fn cmyk_of(fmt: &PixelFormat) -> bool { fmt.mode == ColorMode::Cmyk } +/// Pixels per band of the merged composite: an eighth of [`photocraft_compose::BAND_PIXELS`]. The +/// planes it fills hold the whole image already, so a band's float pixels and their converted +/// parts are the save's peak on top of them (#6607); smaller bands cut that and render the same +/// pixels (bands are whole tiles). +const MERGED_BAND_PIXELS: u64 = photocraft_compose::BAND_PIXELS / 8; + /// The merged image's planes (colour channels, then alpha), big-endian, matted against white /// when `matte`; with whether any pixel needs the alpha channel and whether any is below 1. /// `ctl` gets the fraction of rows done after each band; once it is cancelled the rest of the /// image is left blank (the caller discards it). fn merged_planes(doc: &Document, fmt: &PixelFormat, cmyk: bool, matte: bool, ctl: &Interrupt) -> (Vec, bool, bool) { + merged_planes_in_bands(doc, fmt, cmyk, matte, MERGED_BAND_PIXELS, ctl) +} + +/// [`merged_planes`] rendered in bands of about `band_pixels`. +fn merged_planes_in_bands(doc: &Document, fmt: &PixelFormat, cmyk: bool, matte: bool, band_pixels: u64, ctl: &Interrupt) -> (Vec, bool, bool) { let sample = fmt.sample; let cc = fmt.mode.color_channels(); let bps = sample.bytes(); @@ -1319,7 +1330,8 @@ fn merged_planes(doc: &Document, fmt: &PixelFormat, cmyk: bool, matte: bool, ctl let space = photocraft_compose::cmyk_space(doc); let lab16 = fmt.mode == ColorMode::Lab && sample == SampleType::U16; let rows = canvas.height().max(1) as f32; - let _ = photocraft_compose::render_bands(doc, canvas, 0, |band| -> Result<(), ()> { + let band_rows = (band_pixels / u64::from(canvas.width().max(1))).max(1) as i32; + let _ = photocraft_compose::render_bands(doc, canvas, band_rows, |band| -> Result<(), ()> { if ctl.cancelled() { return Err(()); } @@ -1890,6 +1902,35 @@ mod tests { Document::new("size estimate", photocraft_geom::Size::new(width, height), ColorMode::Rgb, SampleType::U8) } + /// #6607: the merged composite is rendered in smaller bands than the default to keep a save's + /// peak down; the planes are the same however tall the bands are, effects that reach across a + /// band's edge (shadows, glows, strokes) and translucent pixels included. + #[test] + fn the_merged_image_is_the_same_in_any_band_height() { + use photocraft_doc::{Effect, Shadow}; + let mut doc = document(300, 1100); + let fmt = photocraft_color::PixelFormat::RGBA8; + for (i, y) in [240, 500, 760].into_iter().enumerate() { + let mut l = Layer::raster(format!("blob {i}"), fmt); + let (x, f) = (60 * i as i32, i as f32); + l.surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(30 + x, y, 150 + x, y + 40), &[0.8, 0.2 * f, 0.1, 0.6 + 0.2 * f]); + let shadow = |distance: f32, size: f32| match Effect::default_drop_shadow() { + Effect::DropShadow(s) => Effect::DropShadow(Shadow { distance, size, angle: 90.0, use_global_light: false, ..s }), + other => other, + }; + l.effects.items = vec![shadow(24.0, 18.0), shadow(9.0, 40.0)]; + doc.layers.push(l); + } + let dfmt = doc.pixel_format(); + let tall = merged_planes_in_bands(&doc, &dfmt, false, true, photocraft_compose::BAND_PIXELS, &Interrupt::NONE); + assert_eq!(photocraft_compose::band_rows_for(300, (photocraft_compose::BAND_PIXELS / 300) as i32), 28160, "one band"); + for band_pixels in [1, 300 * 256, 300 * 512, MERGED_BAND_PIXELS] { + let banded = merged_planes_in_bands(&doc, &dfmt, false, true, band_pixels, &Interrupt::NONE); + assert!(banded == tall, "{band_pixels} px bands changed the merged image"); + } + assert!(tall.1 && tall.2, "the test image has translucent pixels"); + } + /// Photoshop opens a PSD's bottom layer as its Background only when that layer has no /// transparency channel (measured on Photoshop 27.11: with the channel written, re-exported /// files reopened with the Background as an ordinary layer). diff --git a/crates/io/src/tiff_layers.rs b/crates/io/src/tiff_layers.rs index 91011c6eb1..c80c0bed5f 100644 --- a/crates/io/src/tiff_layers.rs +++ b/crates/io/src/tiff_layers.rs @@ -232,7 +232,10 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr ctl.check().map_err(|_| IoError::Cancelled)?; warnings.extend(w); let order = if codecs::tiff_writes_little_endian() { ByteOrder::Little } else { ByteOrder::Big }; - let (layers, w) = match ImageSourceData::from_psd(&file).to_bytes(order) { + let mut has_alpha = file.layer_info.as_ref().is_some_and(|l| l.merged_alpha); + // The layers move out of the model rather than being copied, and are freed once written + // (#6607). + let (layers, w) = match ImageSourceData::take_from_psd(&mut file).into_bytes(order) { Err(photocraft_psd::PsdError::LimitExceeded(_)) if !force_psb => { let psb_opts = PsdExportOptions { force_psb: true, ..psd_opts }; let (psb, w) = crate::with_span(ctl, 0.0, TIFF_MODEL_DONE, |ctl| crate::psd_export::document_to_psd_interruptible(doc, &psb_opts, ctl)); @@ -240,7 +243,8 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr warnings.extend(w); warnings.push("layer data exceeds 4 GB; written with PSB-sized lengths, which some readers do not open".into()); file = psb; - ImageSourceData::from_psd(&file).to_bytes(order)? + has_alpha = file.layer_info.as_ref().is_some_and(|l| l.merged_alpha); + ImageSourceData::take_from_psd(&mut file).into_bytes(order)? } r => r?, }; @@ -255,7 +259,6 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr let fmt = doc.pixel_format(); let cc = fmt.mode.color_channels(); - let has_alpha = file.layer_info.as_ref().is_some_and(|l| l.merged_alpha); let channels = cc + usize::from(has_alpha); let extra = usize::from(file.header.channels).saturating_sub(channels); if extra > 0 { @@ -265,6 +268,8 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr let (w, h) = (doc.size.width, doc.size.height); let cmyk = fmt.mode == ColorMode::Cmyk; let data = interleave(&file.image_data.data, w, h, channels, cc, sample, cmyk)?; + // Likewise the planar composite, now interleaved. + drop(file); ctl.check().map_err(|_| IoError::Cancelled)?; ctl.progress(TIFF_COMPOSITE_DONE); let layout = layout_for(fmt.mode, has_alpha); diff --git a/crates/io/tests/export_progress.rs b/crates/io/tests/export_progress.rs index 8f372db4b2..a474efc075 100644 --- a/crates/io/tests/export_progress.rs +++ b/crates/io/tests/export_progress.rs @@ -91,3 +91,24 @@ fn a_cancelled_export_stops_with_cancelled() { assert!(matches!(r, Err(IoError::Cancelled)), "{name} midway: {:?}", r.map(|r| r.bytes.len())); } } + +/// #6607: a PSD/PSB save stops at the file model, which is serialized into the file as it is +/// written; streamed into a file it holds exactly the exported bytes. Other formats come back as +/// their bytes. +#[test] +fn encoded_saves_write_exactly_the_exported_bytes() { + for depth in [SampleType::U8, SampleType::U16] { + let doc = layered(depth); + for name in ["a.psd", "a.psb", "a.png"] { + let opts = ExportOptions::default(); + let want = photocraft_io::export(&doc, name, &opts).unwrap().bytes; + let r = photocraft_io::encode_with(&doc, name, &opts, &Interrupt::NONE).unwrap(); + assert_eq!(matches!(r.encoded, photocraft_io::Encoded::Psd(_)), !name.ends_with(".png"), "{name}"); + assert!(r.encoded.len_hint() >= want.len() as u64, "{name} {depth:?}: the length hint is short"); + let mut file = std::io::Cursor::new(Vec::new()); + r.encoded.write_to(&mut file).unwrap(); + assert!(file.into_inner() == want, "{name} {depth:?}: the streamed file differs from the export"); + assert!(r.encoded.into_bytes().unwrap() == want, "{name} {depth:?}"); + } + } +} diff --git a/crates/psd/src/compression.rs b/crates/psd/src/compression.rs index 68782464ad..0c5ae79e0b 100644 --- a/crates/psd/src/compression.rs +++ b/crates/psd/src/compression.rs @@ -357,62 +357,69 @@ fn encode_rle(decoded: &[u8], layout: &PlaneLayout) -> Result> { if rows == 0 { return Ok(Vec::new()); } - // Rows are independent, so they pack in parallel chunks and concatenate in order. + // Rows are independent, so they pack in parallel chunks of rows. Two passes: the first only + // measures each packed row, so the output is allocated once at its exact size and the second + // packs every row straight into its place. Collecting packed chunks and then copying them into + // the output held the encoded image twice, with growth slack, at the save's peak (#6607). let threads = if cfg!(target_arch = "wasm32") { 1 } else { std::thread::available_parallelism().map_or(1, |v| v.get()).clamp(1, 32) }; let chunk_rows = rows.div_ceil(threads).max(1); - struct Chunk { - counts: Vec, - packed: Vec, - } - let pack_chunk = |first: usize, count: usize| -> Result { - let mut chunk = Chunk { counts: vec![0u8; count * cs], packed: Vec::new() }; - for row in first..first + count { - let start = chunk.packed.len(); - packbits::encode(&decoded[row * rb..(row + 1) * rb], &mut chunk.packed); - let n = chunk.packed.len() - start; - if cs == 2 { - let n = u16::try_from(n).map_err(|_| PsdError::LimitExceeded("RLE row exceeds 65535 bytes (use PSB)"))?; - chunk.counts[(row - first) * 2..(row - first) * 2 + 2].copy_from_slice(&n.to_be_bytes()); - } else { - let n = u32::try_from(n).map_err(|_| PsdError::LimitExceeded("RLE row too large"))?; - chunk.counts[(row - first) * 4..(row - first) * 4 + 4].copy_from_slice(&n.to_be_bytes()); - } + let row = |r: usize| &decoded[r * rb..(r + 1) * rb]; + let mut lens = vec![0usize; rows]; + for_each_chunk(threads, lens.chunks_mut(chunk_rows).enumerate().collect(), |(c, lens): (usize, &mut [usize])| { + let mut scratch = Vec::with_capacity(rb + rb.div_ceil(128)); + for (i, len) in lens.iter_mut().enumerate() { + scratch.clear(); + packbits::encode(row(c * chunk_rows + i), &mut scratch); + *len = scratch.len(); + } + })?; + let mut out = vec![0u8; rows * cs + lens.iter().sum::()]; + let (counts, mut packed) = out.split_at_mut(rows * cs); + for (count, &n) in counts.chunks_exact_mut(cs).zip(&lens) { + if cs == 2 { + let n = u16::try_from(n).map_err(|_| PsdError::LimitExceeded("RLE row exceeds 65535 bytes (use PSB)"))?; + count.copy_from_slice(&n.to_be_bytes()); + } else { + let n = u32::try_from(n).map_err(|_| PsdError::LimitExceeded("RLE row too large"))?; + count.copy_from_slice(&n.to_be_bytes()); } - Ok(chunk) - }; - let chunk_list: Vec = if threads == 1 { - vec![pack_chunk(0, rows)?] - } else { - std::thread::scope(|scope| -> Result> { - let handles: Vec<_> = (0..threads) - .map(|t| { - let first = t * chunk_rows; - let count = chunk_rows.min(rows.saturating_sub(first)); - scope.spawn(move || pack_chunk(first, count)) - }) - .collect(); - // Join every worker first, then surface the first error like the sequential - // path would instead of aborting the scope with a thread panic. - let mut all = Vec::with_capacity(threads); - for h in handles { - all.push(h.join().map_err(|_| PsdError::LimitExceeded("an RLE worker panicked"))??); - } - Ok(all) - })? - }; - let packed_total: usize = chunk_list.iter().map(|c| c.packed.len()).sum(); - let mut out = vec![0u8; rows * cs + packed_total]; - let mut counts_at = 0usize; - let mut packed_at = rows * cs; - for chunk in &chunk_list { - out[counts_at..counts_at + chunk.counts.len()].copy_from_slice(&chunk.counts); - counts_at += chunk.counts.len(); - out[packed_at..packed_at + chunk.packed.len()].copy_from_slice(&chunk.packed); - packed_at += chunk.packed.len(); } + let mut parts = Vec::with_capacity(threads); + for (c, lens) in lens.chunks(chunk_rows).enumerate() { + let (part, rest) = packed.split_at_mut(lens.iter().sum()); + parts.push((c, lens, part)); + packed = rest; + } + for_each_chunk(threads, parts, |(c, lens, mut part): (usize, &[usize], &mut [u8])| { + let mut scratch = Vec::with_capacity(rb + rb.div_ceil(128)); + for (i, &n) in lens.iter().enumerate() { + scratch.clear(); + packbits::encode(row(c * chunk_rows + i), &mut scratch); + let (dst, rest) = std::mem::take(&mut part).split_at_mut(n); + dst.copy_from_slice(&scratch); + part = rest; + } + })?; Ok(out) } +/// Runs `f` on every item, each on its own thread unless `threads` is 1 (wasm32). +fn for_each_chunk(threads: usize, items: Vec, f: impl Fn(T) + Sync) -> Result<()> { + if threads == 1 { + items.into_iter().for_each(f); + return Ok(()); + } + std::thread::scope(|scope| { + let handles: Vec<_> = items.into_iter().map(|item| scope.spawn(|| f(item))).collect(); + // Join every worker instead of aborting the scope with a thread panic. + let mut ok = true; + for h in handles { + ok &= h.join().is_ok(); + } + if ok { Ok(()) } else { Err(PsdError::LimitExceeded("an RLE worker panicked")) } + }) +} + /// zlib-compresses `data` at the default level. pub fn zip_compress(data: &[u8]) -> Vec { let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); @@ -944,9 +951,22 @@ mod parallel_rle_equivalence { let parallel = encode_rle(&data, &layout).expect("parallel encode"); let sequential = encode_rle_sequential(&data, &layout).expect("sequential encode"); assert_eq!(parallel, sequential, "rows {rows} rb {rb}"); + // #6607: measured first, then packed in place: one allocation, exactly the file's size. + assert_eq!(parallel.capacity(), parallel.len(), "rows {rows} rb {rb}: the output grew or was over-allocated"); } } + /// A row too long for a PSD's 16-bit RLE count is the same error from the two-pass encoder. + #[test] + fn rle_rows_past_sixteen_bits_are_refused_in_psd() { + let layout_for = |rows: usize, rb: usize, version: Version| PlaneLayout { planes: 1, width: rb, height: rows, depth: 8, version }; + let rb = 70_000; + let layout = layout_for(3, rb, Version::Psd); + let noise: Vec = (0..3 * rb).map(|i| (i * 31 % 251) as u8).collect(); + assert_eq!(encode_rle(&noise, &layout), Err(PsdError::LimitExceeded("RLE row exceeds 65535 bytes (use PSB)"))); + assert_eq!(encode_rle(&noise, &layout_for(3, rb, Version::Psb)), encode_rle_sequential(&noise, &layout_for(3, rb, Version::Psb))); + } + /// The pre-parallel encoder, kept as the oracle. fn encode_rle_sequential(decoded: &[u8], layout: &PlaneLayout) -> Result> { let rows = layout.rows()?; diff --git a/crates/psd/src/error.rs b/crates/psd/src/error.rs index da519e6698..58e4ad9e8a 100644 --- a/crates/psd/src/error.rs +++ b/crates/psd/src/error.rs @@ -46,6 +46,23 @@ impl PsdError { pub(crate) fn invalid(msg: impl Into) -> Self { PsdError::Invalid(msg.into()) } + + /// The error a streaming write ([`crate::PsdFile::write_to`]) failed with: the serializer's + /// own error when it was one, otherwise the I/O error's text. + pub(crate) fn from_io(e: std::io::Error) -> Self { + let text = e.to_string(); + match e.into_inner().map(|inner| inner.downcast::()) { + Some(Ok(e)) => *e, + _ => PsdError::Invalid(format!("write failed: {text}")), + } + } +} + +/// A serializer error inside a streaming write ([`crate::PsdFile::write_to`]), as `InvalidData`. +impl From for std::io::Error { + fn from(e: PsdError) -> Self { + std::io::Error::new(std::io::ErrorKind::InvalidData, e) + } } /// Convenience alias. diff --git a/crates/psd/src/file.rs b/crates/psd/src/file.rs index fab262732b..7dd145a3cb 100644 --- a/crates/psd/src/file.rs +++ b/crates/psd/src/file.rs @@ -1,13 +1,15 @@ //! The top-level [`PsdFile`] model, parser and writer. +use std::io::{Seek, Write}; + use crate::compression::{Compression, MAX_DECODED_BYTES, decode_planes, validate_planes}; use crate::error::{PsdError, Result}; use crate::header::{ColorMode, Header, Version}; use crate::image_data::ImageData; -use crate::io::{Reader, WriteExt}; +use crate::io::{Reader, Sink, WriteExt}; use crate::layer::{LayerInfo, LayerRecord}; use crate::resources::{self, ImageResource, ResourceData, ids}; -use crate::tagged::{TaggedBlock, read_blocks_with, uses_long_length, write_blocks}; +use crate::tagged::{TaggedBlock, read_blocks_with, uses_long_length, write_blocks_to}; /// Where the layer info was stored in the file. #[derive(Debug, Clone, PartialEq, Eq, Default)] @@ -231,17 +233,44 @@ impl PsdFile { /// Serializes the file. pub fn to_bytes(&self) -> Result> { - let v = self.header.version; - let psb = v.is_psb(); - // Reserve the pixel data up front: appending it to an empty Vec reallocates and copies - // repeatedly. Everything else (headers, resources, records) is small by comparison. - let hint = self.layer_info.as_ref().map_or(0, LayerInfo::channel_data_len).saturating_add(self.image_data.data.len()); + // Reserve the whole file up front: appending to an empty Vec reallocates and copies + // repeatedly, and a reserve that is a few bytes short makes the last append double the + // buffer, copying the whole file once more (2x the file's size, #6607). + let hint = self.size_bound(); let mut out = Vec::new(); out.try_reserve(hint).map_err(|_| PsdError::LimitExceeded("not enough memory to serialize the file"))?; - self.header.write(&mut out); - out.put_len(self.color_mode_data.len() as u64, false)?; - out.put(&self.color_mode_data); - resources::write_section(&mut out, &self.resources)?; + self.write_to(&mut std::io::Cursor::new(&mut out)).map_err(PsdError::from_io)?; + Ok(out) + } + + /// Serializes the file into `w` as it goes, starting at its current position: the bytes of + /// [`Self::to_bytes`] without ever holding them all (#6607). Length fields are back-patched + /// by seeking. A serializer error (a section too large for PSD, too many layers) comes back + /// as `InvalidData` carrying the [`PsdError`]; `w` then holds a partial file. + pub fn write_to(&self, w: &mut W) -> std::io::Result<()> { + let mut out = Sink::new(w)?; + self.write_staging(&mut out)?; + out.finish() + } + + /// [`Self::write_to`] writing out every `stage` staged bytes (tests: tiny stages make every + /// length field a back-patch through the stream). + #[cfg(test)] + pub(crate) fn write_to_staging(&self, w: &mut W, stage: usize) -> std::io::Result<()> { + let mut out = Sink::staging(w, stage)?; + self.write_staging(&mut out)?; + out.finish() + } + + fn write_staging(&self, out: &mut Sink<'_, W>) -> std::io::Result<()> { + let v = self.header.version; + let psb = v.is_psb(); + out.small(|o| { + self.header.write(o); + o.put_len(self.color_mode_data.len() as u64, false) + })?; + out.put(&self.color_mode_data)?; + out.small(|o| resources::write_section(o, &self.resources))?; let in_block = matches!(self.layer_info_placement, LayerInfoPlacement::GlobalBlock { .. }); // An entirely empty section is written as a zero length. (A section @@ -249,48 +278,71 @@ impl PsdFile { // the model and is normalized to this form.) let section_empty = self.layer_info.is_none() && self.global_layer_mask.is_none() && self.global_blocks.is_empty() && self.layer_mask_trailing.is_empty(); - let at = out.begin_len(psb); + let at = out.begin_len(psb)?; if !section_empty { - let li = out.begin_len(psb); + let li = out.begin_len(psb)?; if let (Some(info), false) = (&self.layer_info, in_block) { - info.write_body(&mut out, v)?; + info.write_body_to(out, v)?; } out.end_len(li, psb)?; let need_mask_field = self.global_layer_mask.is_some() || !self.global_blocks.is_empty() || (in_block && self.layer_info.is_some()); if need_mask_field { let gm = self.global_layer_mask.as_ref().map_or(&[][..], |g| &g.data[..]); - out.put_len(gm.len() as u64, false)?; - out.put(gm); + out.small(|o| o.put_len(gm.len() as u64, false))?; + out.put(gm)?; } match (&self.layer_info, &self.layer_info_placement) { (Some(info), LayerInfoPlacement::GlobalBlock { index, signature, key, padding }) => { let (before, after) = self.global_blocks.split_at((*index).min(self.global_blocks.len())); - write_blocks(&mut out, before, v, 4)?; - // Write into the final buffer: a temporary LayerInfo payload and a clone - // of all neighboring opaque blocks otherwise double the save's memory. - out.put(signature); - out.put(key); + write_blocks_to(out, before, v, 4)?; + // Written in place: a temporary LayerInfo payload and a clone of all + // neighboring opaque blocks otherwise double the save's memory. + out.put(signature)?; + out.put(key)?; let long = uses_long_length(v, key); - let at = out.begin_len(long); - let start = out.len(); - info.write_body(&mut out, v)?; - let len = out.len() - start; + let at = out.begin_len(long)?; + let start = out.pos(); + info.write_body_to(out, v)?; + let len = out.pos() - start; out.end_len(at, long)?; match padding { - Some(bytes) => out.put(bytes), - None => out.extend(std::iter::repeat_n(0u8, (4 - len % 4) % 4)), + Some(bytes) => out.put(bytes)?, + None => out.zeros(((4 - len % 4) % 4) as usize)?, } - write_blocks(&mut out, after, v, 4)?; + write_blocks_to(out, after, v, 4)?; } - _ => write_blocks(&mut out, &self.global_blocks, v, 4)?, + _ => write_blocks_to(out, &self.global_blocks, v, 4)?, } - out.put(&self.layer_mask_trailing); + out.put(&self.layer_mask_trailing)?; } out.end_len(at, psb)?; - out.put_u16(self.image_data.compression.as_u16()); - out.put(&self.image_data.data); - Ok(out) + out.small(|o| { + o.put_u16(self.image_data.compression.as_u16()); + Ok(()) + })?; + out.put(&self.image_data.data)?; + Ok(()) + } + + /// An upper bound of [`Self::to_bytes`]'s length: the pixel data plus every variable-length + /// part, each with generous room for its fixed fields, lengths and padding. Saturates. + pub fn size_bound(&self) -> usize { + // Per item: signature, key or id, length fields and padding fit easily. + const ITEM: usize = 32; + let blocks = |b: &[crate::tagged::TaggedBlock]| { + b.iter().fold(0usize, |n, t| n.saturating_add(t.data.len()).saturating_add(t.padding.as_ref().map_or(0, Vec::len)).saturating_add(ITEM)) + }; + let mut n: usize = 1024; // file header and section lengths + n = n.saturating_add(self.color_mode_data.len()); + n = self.resources.iter().fold(n, |n, r| n.saturating_add(r.data.len()).saturating_add(r.name.len()).saturating_add(ITEM)); + n = n.saturating_add(self.layer_info.as_ref().map_or(0, LayerInfo::size_bound)); + n = n.saturating_add(self.global_layer_mask.as_ref().map_or(0, |g| g.data.len())); + n = n.saturating_add(blocks(&self.global_blocks)).saturating_add(self.layer_mask_trailing.len()); + if let LayerInfoPlacement::GlobalBlock { padding: Some(p), .. } = &self.layer_info_placement { + n = n.saturating_add(p.len()).saturating_add(ITEM); + } + n.saturating_add(self.image_data.data.len()) } /// Layer records in file order (bottom-most first); empty if none. @@ -432,6 +484,94 @@ mod write_tests { use super::*; use crate::{Compression, testgen}; + /// #6607: the output is allocated once. A reserve a few bytes short of the file made the last + /// append double the buffer, copying the whole file once more at the save's peak. + #[test] + fn serializing_allocates_the_output_once() { + for case in testgen::all_cases() { + let bound = case.file.size_bound(); + let bytes = case.file.to_bytes().unwrap(); + assert!(bytes.len() <= bound, "{}: {} > bound {bound}", case.name, bytes.len()); + assert_eq!(bytes.capacity(), bound, "{}: the buffer grew past its first allocation", case.name); + } + } + + /// A stream that records the length of every write it gets. + struct Recorder { + inner: std::io::Cursor>, + writes: Vec, + } + + impl Write for Recorder { + fn write(&mut self, b: &[u8]) -> std::io::Result { + self.writes.push(b.len()); + self.inner.write(b) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + impl Seek for Recorder { + fn seek(&mut self, to: std::io::SeekFrom) -> std::io::Result { + self.inner.seek(to) + } + } + + /// #6607: streaming into a file writes exactly the bytes of `to_bytes`, wherever the stream + /// starts and however little is staged, so every length field is back-patched through the + /// stream (both placements of the layer info, PSD and PSB, every mode and depth). + #[test] + fn streaming_writes_the_same_bytes_with_lengths_patched_by_seeking() { + for case in testgen::all_cases() { + let want = case.file.to_bytes().unwrap(); + for stage in [0, 1, 7, 4096] { + let mut w = std::io::Cursor::new(b"pre".to_vec()); + w.set_position(3); + case.file.write_to_staging(&mut w, stage).unwrap(); + assert_eq!(&w.get_ref()[..3], b"pre", "{} stage {stage}", case.name); + assert!(w.get_ref()[3..] == want[..], "{} stage {stage}: streamed bytes differ", case.name); + assert_eq!(w.position(), 3 + want.len() as u64, "{} stage {stage}: the stream must end at the file's end", case.name); + } + let mut w = std::io::Cursor::new(Vec::new()); + case.file.write_to(&mut w).unwrap(); + assert!(w.into_inner() == want, "{}: write_to differs from to_bytes", case.name); + } + } + + /// #6607: pixel data goes to the stream as it is, never through a staging copy, so a save + /// holds the model and a short buffer, not a second copy of the file. + #[test] + fn streaming_passes_channel_and_merged_data_straight_through() { + let mut file = testgen::small(Version::Psd, Compression::Raw); + let big = 3 * crate::io::STAGE_FOR_TESTS; + file.image_data.data = vec![5; big]; + let info = file.layer_info.as_mut().unwrap(); + info.layers[0].channels[0] = crate::layer::ChannelData { id: 0, compression: Some(Compression::Raw), data: vec![6; big + 1] }; + let mut w = Recorder { inner: std::io::Cursor::new(Vec::new()), writes: Vec::new() }; + file.write_to(&mut w).unwrap(); + assert!(w.writes.contains(&big), "merged image data was staged: {:?}", w.writes); + assert!(w.writes.contains(&(big + 1)), "channel data was staged: {:?}", w.writes); + assert!(w.writes.iter().filter(|&&n| n != big && n != big + 1).all(|&n| n < crate::io::STAGE_FOR_TESTS * 2), "{:?}", w.writes); + let bytes = w.inner.into_inner(); + assert_eq!(PsdFile::from_bytes(&bytes).unwrap(), file); + assert!(bytes == file.to_bytes().unwrap()); + } + + /// A serializer error in the middle of a stream comes back as the same `PsdError` `to_bytes` + /// reports, inside an `InvalidData` I/O error. + #[test] + fn streaming_reports_serializer_errors() { + let mut file = testgen::small(Version::Psd, Compression::Raw); + let info = file.layer_info.as_mut().unwrap(); + let layer = info.layers[0].clone(); + info.layers = vec![layer; 32768]; + let want = file.to_bytes().unwrap_err(); + let e = file.write_to(&mut std::io::Cursor::new(Vec::new())).unwrap_err(); + assert_eq!(e.kind(), std::io::ErrorKind::InvalidData); + assert_eq!(PsdError::from_io(e), want); + } + #[test] fn global_blocks_default_to_four_byte_padding() { for version in [Version::Psd, Version::Psb] { diff --git a/crates/psd/src/io.rs b/crates/psd/src/io.rs index a7c7e3d8ee..95ed9c1f47 100644 --- a/crates/psd/src/io.rs +++ b/crates/psd/src/io.rs @@ -1,5 +1,7 @@ //! Bounds-checked big-endian reader and writer helpers. +use std::io::{Seek, SeekFrom, Write}; + use crate::error::{PsdError, Result}; /// A name as UTF-16 code units, capped at `max` **without ever splitting a surrogate pair**. @@ -209,6 +211,121 @@ impl WriteExt for Vec { } } +/// Staged bytes [`Sink`] collects before writing them out in one call. +const STAGE: usize = 64 << 10; +#[cfg(test)] +pub(crate) const STAGE_FOR_TESTS: usize = STAGE; + +/// Writes the file to a seekable stream as it is serialized (#6607): the small parts (headers, +/// records, lengths) are staged in a short buffer, while pixel data and other long slices go +/// straight to the stream, so the whole file never sits in memory. A length placeholder +/// ([`Sink::begin_len`]) that was already written out is back-patched by seeking. The bytes are +/// exactly the ones the `Vec` writers ([`WriteExt`]) produce. +pub(crate) struct Sink<'w, W: Write + Seek + ?Sized> { + w: &'w mut W, + /// Bytes not yet written; they start at stream position `base`. + buf: Vec, + base: u64, + /// Staged bytes that trigger a write ([`STAGE`]; tests use less). + stage: usize, +} + +impl<'w, W: Write + Seek + ?Sized> Sink<'w, W> { + /// A sink writing at `w`'s current position. + pub(crate) fn new(w: &'w mut W) -> std::io::Result { + Self::staging(w, STAGE) + } + + /// [`Self::new`] writing out once `stage` bytes are staged. + pub(crate) fn staging(w: &'w mut W, stage: usize) -> std::io::Result { + let base = w.stream_position()?; + Ok(Sink { w, buf: Vec::new(), base, stage }) + } + + /// The stream position of the next byte. + pub(crate) fn pos(&self) -> u64 { + self.base + self.buf.len() as u64 + } + + /// Appends a small part built by `f` with the `Vec` writers. Lengths `f` back-patches stay + /// inside what it appends. + pub(crate) fn small(&mut self, f: impl FnOnce(&mut Vec) -> Result<()>) -> std::io::Result<()> { + f(&mut self.buf)?; + if self.buf.len() >= self.stage { self.flush() } else { Ok(()) } + } + + /// Appends `b`, straight to the stream when it is long. + pub(crate) fn put(&mut self, b: &[u8]) -> std::io::Result<()> { + if self.buf.len() + b.len() < self.stage { + self.buf.extend_from_slice(b); + return Ok(()); + } + self.flush()?; + self.w.write_all(b)?; + self.base += b.len() as u64; + Ok(()) + } + + /// `n` zero bytes (padding, at most a few). + pub(crate) fn zeros(&mut self, n: usize) -> std::io::Result<()> { + self.small(|b| { + b.extend(std::iter::repeat_n(0u8, n)); + Ok(()) + }) + } + + /// Reserves a length placeholder (4 or 8 bytes), returning its stream position. + pub(crate) fn begin_len(&mut self, long: bool) -> std::io::Result { + let at = self.pos(); + self.zeros(if long { 8 } else { 4 })?; + Ok(at) + } + + /// Back-patches the placeholder at `at` with the number of bytes written since. + pub(crate) fn end_len(&mut self, at: u64, long: bool) -> std::io::Result<()> { + let hdr = if long { 8 } else { 4 }; + let n = self.pos() - at - hdr; + let bytes = if long { + n.to_be_bytes().to_vec() + } else { + u32::try_from(n).map_err(|_| PsdError::LimitExceeded("section too large for 32-bit length (use PSB)"))?.to_be_bytes().to_vec() + }; + if at >= self.base { + let i = (at - self.base) as usize; + self.buf[i..i + bytes.len()].copy_from_slice(&bytes); + return Ok(()); + } + self.flush()?; + self.w.seek(SeekFrom::Start(at))?; + self.w.write_all(&bytes)?; + self.w.seek(SeekFrom::Start(self.base))?; + Ok(()) + } + + fn flush(&mut self) -> std::io::Result<()> { + self.w.write_all(&self.buf)?; + self.base += self.buf.len() as u64; + self.buf.clear(); + Ok(()) + } + + /// Writes out what is still staged. Dropping a sink without it loses those bytes. + pub(crate) fn finish(mut self) -> std::io::Result<()> { + self.flush()?; + self.w.flush() + } +} + +/// Runs a streaming writer on the end of `out`, the `Vec` writers' way of sharing it. +pub(crate) fn append_with(out: &mut Vec, f: impl FnOnce(&mut Sink<'_, std::io::Cursor<&mut Vec>>) -> std::io::Result<()>) -> Result<()> { + let at = out.len() as u64; + let mut c = std::io::Cursor::new(out); + c.set_position(at); + let mut s = Sink::new(&mut c).map_err(PsdError::from_io)?; + f(&mut s).map_err(PsdError::from_io)?; + s.finish().map_err(PsdError::from_io) +} + /// Reads a Pascal string (length byte + bytes) whose total size is padded to /// a multiple of `align`. Returns the raw name bytes. pub(crate) fn read_pascal(r: &mut Reader<'_>, align: usize) -> Result> { diff --git a/crates/psd/src/layer.rs b/crates/psd/src/layer.rs index 5b11411dfb..39d591c558 100644 --- a/crates/psd/src/layer.rs +++ b/crates/psd/src/layer.rs @@ -4,7 +4,7 @@ use crate::blend::BlendMode; use crate::compression::{Compression, PlaneLayout, decode_planes, encode_planes}; use crate::error::{PsdError, Result}; use crate::header::{MAX_DIMENSION, Version}; -use crate::io::{Reader, WriteExt, decode_legacy_name, read_pascal, write_pascal}; +use crate::io::{Reader, Sink, WriteExt, append_with, decode_legacy_name, read_pascal, write_pascal}; use crate::tagged::{BlockData, SectionDivider, SectionType, TaggedBlock, read_blocks, uses_long_length, write_blocks}; /// Channel id of the transparency mask. @@ -598,6 +598,15 @@ impl LayerRecord { Ok((LayerRecord { rect, channels, blend_mode, opacity, clipping, flags, filler, mask, blending_ranges, name, blocks, extra_trailing }, lens)) } + /// Bytes of the mask data (without its length field). + fn mask_len(&self) -> u64 { + match &self.mask { + MaskData::None => 0, + MaskData::Mask(mask) => mask.encoded_len(), + MaskData::Raw(b) => b.len() as u64, + } + } + /// Bytes [`Self::write`] produces, computed without serializing. Errors where `write` would. fn encoded_len(&self, version: Version) -> Result { let too_big = || PsdError::LimitExceeded("section too large for 32-bit length (use PSB)"); @@ -606,11 +615,7 @@ impl LayerRecord { if !version.is_psb() && self.channels.iter().any(|c| u32::try_from(c.stored_len()).is_err()) { return Err(too_big()); } - let mask = match &self.mask { - MaskData::None => 0, - MaskData::Mask(mask) => mask.encoded_len(), - MaskData::Raw(b) => b.len() as u64, - }; + let mask = self.mask_len(); u32::try_from(mask).map_err(|_| too_big())?; let ranges = self.blending_ranges.data.len() as u64; u32::try_from(ranges).map_err(|_| too_big())?; @@ -743,37 +748,68 @@ impl LayerInfo { self.layers.iter().flat_map(|l| &l.channels).filter(|c| c.compression.is_some()).fold(0usize, |n, c| n.saturating_add(2).saturating_add(c.data.len())) } + /// An upper bound of the body's length: the channel data plus every record, each with + /// generous room for its fixed fields (rect, blend mode, flags, lengths). + /// Saturates. Reserving it keeps a writer from doubling its buffer at the end (#6607). + pub(crate) fn size_bound(&self) -> usize { + const RECORD: usize = 256; + const ITEM: usize = 32; + let n = self.channel_data_len().saturating_add(self.padding.as_ref().map_or(0, Vec::len)).saturating_add(ITEM); + self.layers.iter().fold(n, |n, l| { + let blocks = + l.blocks.iter().fold(0usize, |n, t| n.saturating_add(t.data.len()).saturating_add(t.padding.as_ref().map_or(0, Vec::len)).saturating_add(ITEM)); + n.saturating_add(RECORD) + .saturating_add(l.channels.len().saturating_mul(ITEM)) + .saturating_add(l.name.len()) + .saturating_add(l.blending_ranges.data.len()) + .saturating_add(blocks) + .saturating_add(l.extra_trailing.len()) + .saturating_add(usize::try_from(l.mask_len()).unwrap_or(usize::MAX)) + }) + } + /// Serializes the body without any trailing padding (the padding field is ignored). pub(crate) fn write_unpadded(&self, out: &mut Vec, version: Version) -> Result<()> { + append_with(out, |s| self.write_unpadded_to(s, version)) + } + + /// Serializes the body (without a length field). + #[cfg(test)] + pub(crate) fn write_body(&self, out: &mut Vec, version: Version) -> Result<()> { + append_with(out, |s| self.write_body_to(s, version)) + } + + /// [`Self::write_unpadded`] to a streaming [`Sink`]: the records are staged, the channel data + /// goes straight through. + pub(crate) fn write_unpadded_to(&self, out: &mut Sink<'_, W>, version: Version) -> std::io::Result<()> { let n = i16::try_from(self.layers.len()).map_err(|_| PsdError::LimitExceeded("more than 32767 layers"))?; - out.put_i16(if self.merged_alpha { -n } else { n }); + out.small(|o| { + o.put_i16(if self.merged_alpha { -n } else { n }); + Ok(()) + })?; for l in &self.layers { - l.write(out, version)?; + out.small(|o| l.write(o, version))?; } - for l in &self.layers { - for c in &l.channels { - if let Some(comp) = c.compression { - out.put_u16(comp.as_u16()); - out.put(&c.data); - } + for c in self.layers.iter().flat_map(|l| &l.channels) { + if let Some(comp) = c.compression { + out.small(|o| { + o.put_u16(comp.as_u16()); + Ok(()) + })?; + out.put(&c.data)?; } } Ok(()) } - /// Serializes the body (without a length field). - pub(crate) fn write_body(&self, out: &mut Vec, version: Version) -> Result<()> { - let start = out.len(); - self.write_unpadded(out, version)?; + /// [`Self::write_body`] to a streaming [`Sink`]. + pub(crate) fn write_body_to(&self, out: &mut Sink<'_, W>, version: Version) -> std::io::Result<()> { + let start = out.pos(); + self.write_unpadded_to(out, version)?; match &self.padding { Some(p) => out.put(p), - None => { - if (out.len() - start) % 2 == 1 { - out.put_u8(0); - } - } + None => out.zeros(((out.pos() - start) % 2) as usize), } - Ok(()) } } @@ -1115,6 +1151,23 @@ mod tests { } } + /// Large kept mask data (unparsed, or bytes after a parsed mask) is inside the size bound. + #[test] + fn size_bound_counts_mask_data() { + for version in [Version::Psd, Version::Psb] { + let mut mask = LayerMask::new(Rect::from_xywh(1, 2, 3, 4), 0, 0); + mask.trailing = vec![7; 4000]; + let layers = vec![ + LayerRecord { mask: MaskData::Raw(vec![5; 4000]), ..Default::default() }, + LayerRecord { mask: MaskData::Mask(mask), ..Default::default() }, + ]; + let info = LayerInfo { layers, ..Default::default() }; + let mut out = Vec::new(); + info.write_unpadded(&mut out, version).unwrap(); + assert!(info.size_bound() >= out.len(), "{version:?}: bound {} for {} bytes", info.size_bound(), out.len()); + } + } + /// Padding without cloning the layers writes the same bytes as `pad_to` on a clone, and the /// channel data length hint is exact. #[test] diff --git a/crates/psd/src/tagged.rs b/crates/psd/src/tagged.rs index 7728d53fd1..67006d4dcd 100644 --- a/crates/psd/src/tagged.rs +++ b/crates/psd/src/tagged.rs @@ -9,7 +9,7 @@ use crate::blend::BlendMode; use crate::descriptor::UnicodeString; use crate::error::{PsdError, Result}; use crate::header::Version; -use crate::io::{Reader, WriteExt}; +use crate::io::{Reader, Sink, WriteExt}; /// Keys whose length field is 8 bytes in PSB files: the thirteen the Adobe /// specification lists, plus keys Photoshop also writes with 8-byte lengths @@ -495,6 +495,29 @@ pub(crate) fn write_blocks(out: &mut Vec, blocks: &[TaggedBlock], version: V Ok(()) } +/// [`write_blocks`] to a streaming [`Sink`]: each block's data goes straight through, so a large +/// opaque block (an embedded smart object) is never copied whole. +pub(crate) fn write_blocks_to( + out: &mut Sink<'_, W>, + blocks: &[TaggedBlock], + version: Version, + alignment: usize, +) -> std::io::Result<()> { + for b in blocks { + out.small(|o| { + o.put(&b.signature); + o.put(&b.key); + o.put_len(b.data.len() as u64, uses_long_length(version, &b.key)) + })?; + out.put(&b.data)?; + match &b.padding { + Some(p) => out.put(p)?, + None => out.zeros(TaggedBlock::default_padding(b.data.len(), alignment))?, + } + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/psd/src/tiff.rs b/crates/psd/src/tiff.rs index 31c85d4885..74ecebcb4d 100644 --- a/crates/psd/src/tiff.rs +++ b/crates/psd/src/tiff.rs @@ -152,16 +152,49 @@ impl ImageSourceData { /// Serializes the data in `order`. Returns the bytes and warnings about blocks that were /// dropped because they could not be converted to a little-endian layout. pub fn to_bytes(&self, order: ByteOrder) -> Result<(Vec, Vec)> { + Self::finish(self.body()?, self.version, order) + } + + /// [`Self::to_bytes`] that frees the layers once their block is written, before the bytes + /// are converted to `order` (a copy of them all): a layered TIFF save holds them one time + /// fewer (#6607). + pub fn into_bytes(self, order: ByteOrder) -> Result<(Vec, Vec)> { + let (body, version) = (self.body()?, self.version); + drop(self); + Self::finish(body, version, order) + } + + /// The blocks in big-endian order, without the signature. + fn body(&self) -> Result> { let version = self.version; + // Reserved whole, as each buffer below holds all the layers' pixels: one that is a few + // bytes short doubles at its last append, copying them once more (#6607). + let blocks = self + .global_blocks + .iter() + .fold(0usize, |n, b| n.saturating_add(b.data.len()).saturating_add(b.padding.as_ref().map_or(0, Vec::len)).saturating_add(32)); let mut body = Vec::new(); + body.try_reserve( + self.layer_info + .as_ref() + .map_or(0, LayerInfo::size_bound) + .saturating_add(self.global_layer_mask.as_ref().map_or(0, |m| m.data.len())) + .saturating_add(blocks) + .saturating_add(self.trailing.len()) + .saturating_add(64), + ) + .map_err(|_| PsdError::LimitExceeded("not enough memory for the layer data"))?; if let Some(info) = &self.layer_info { - // Pad to 4 without cloning the layers (and all their pixel data) just to set `padding`. + // Written straight into the body, padded to 4 inside its length (as a padded copy + // would be): a separate buffer for it would hold all the pixels a second time. let pad = info.pad_len(version, 4)?; - let mut data = Vec::new(); - data.try_reserve(info.channel_data_len().saturating_add(pad)).map_err(|_| PsdError::LimitExceeded("not enough memory for the layer data"))?; - info.write_unpadded(&mut data, version)?; - data.resize(data.len() + pad, 0); - padded_block(self.layer_info_key, data).write(&mut body, version, 4)?; + let long = uses_long_length(version, &self.layer_info_key); + body.put(b"8BIM"); + body.put(&self.layer_info_key); + let at = body.begin_len(long); + info.write_unpadded(&mut body, version)?; + body.resize(body.len() + pad, 0); + body.end_len(at, long)?; } // Blocks without explicit padding are padded to 4 as they are written (alignment 4), so // the global mask and blocks are written from the model, without a copy of their data. @@ -173,12 +206,23 @@ impl ImageSourceData { b.write(&mut body, version, 4)?; } body.put(&self.trailing); - let (body, warnings) = - if order.is_little() { transcode::transcode_blocks(&body, version, ByteOrder::Big, ByteOrder::Little)? } else { (body, Vec::new()) }; - let mut out = Vec::with_capacity(36 + body.len()); - out.put(if version.is_psb() { SIGNATURE_PSB } else { SIGNATURE }); - out.put(&body); - Ok((out, warnings)) + Ok(body) + } + + /// The `body` converted to `order`, behind its signature. + fn finish(body: Vec, version: Version, order: ByteOrder) -> Result<(Vec, Vec)> { + let signature = if version.is_psb() { SIGNATURE_PSB } else { SIGNATURE }; + if order.is_little() { + // Converted behind the signature: the converted body fills the buffer it is given, + // so adding the signature afterwards would move (copy) it. + return transcode::transcode_blocks_after(signature, &body, version, ByteOrder::Big, ByteOrder::Little); + } + // The signature goes in front of the body in place, not by copying the body into a new + // buffer beside it. + let mut out = body; + out.try_reserve_exact(signature.len()).map_err(|_| PsdError::LimitExceeded("not enough memory for the layer data"))?; + out.splice(0..0, signature.iter().copied()); + Ok((out, Vec::new())) } /// The layers, global mask and global blocks of a PSD as image source data (byte order Big). @@ -198,6 +242,25 @@ impl ImageSourceData { } } + /// [`Self::from_psd`] that moves the layers, global mask and global blocks out of `file` + /// instead of copying them: a layered TIFF save would otherwise hold every layer's pixels + /// twice (#6607). `file` keeps its header, resources and merged image. + pub fn take_from_psd(file: &mut PsdFile) -> Self { + let key = match &file.layer_info_placement { + LayerInfoPlacement::GlobalBlock { key, .. } => *key, + LayerInfoPlacement::Section => layer_info_key(file.header.depth), + }; + ImageSourceData { + version: file.header.version, + byte_order: ByteOrder::Big, + layer_info: file.layer_info.take(), + layer_info_key: key, + global_layer_mask: file.global_layer_mask.take(), + global_blocks: std::mem::take(&mut file.global_blocks), + trailing: std::mem::take(&mut file.layer_mask_trailing), + } + } + /// Assembles a PSD model from this data plus the parts a TIFF stores natively: the header /// (dimensions, depth, colour mode, channel count), the image resources (tag 34377) and the /// merged image (the TIFF's pixels). @@ -222,6 +285,7 @@ impl ImageSourceData { } /// A block padded to a multiple of 4, as Photoshop writes them in TIFFs. +#[cfg(test)] fn padded_block(key: [u8; 4], data: Vec) -> TaggedBlock { let pad = (4 - data.len() % 4) % 4; TaggedBlock { signature: *b"8BIM", key, data, padding: Some(vec![0; pad]) } @@ -278,6 +342,15 @@ pub mod transcode { Ok(t.finish()) } + /// [`transcode_blocks`] written after `prefix`, in one buffer sized for both. + pub fn transcode_blocks_after(prefix: &[u8], data: &[u8], version: Version, from: ByteOrder, to: ByteOrder) -> Result<(Vec, Vec)> { + let mut out = Vec::with_capacity(prefix.len().saturating_add(data.len())); + out.extend_from_slice(prefix); + let mut t = Tx::appending(out, data, version, from, to); + t.blocks()?; + Ok(t.finish()) + } + /// Converts a layer-info body (the data of a `Layr` / `Lr16` / `Lr32` block) between orders. pub fn transcode_layer_info(data: &[u8], version: Version, from: ByteOrder, to: ByteOrder) -> Result<(Vec, Vec)> { let mut t = Tx::new(data, version, from, to); @@ -332,8 +405,13 @@ pub mod transcode { impl<'a> Tx<'a> { fn new(data: &'a [u8], version: Version, from: ByteOrder, to: ByteOrder) -> Self { + Self::appending(Vec::with_capacity(data.len()), data, version, from, to) + } + + /// A walker that appends to `out`. + fn appending(out: Vec, data: &'a [u8], version: Version, from: ByteOrder, to: ByteOrder) -> Self { let r = if from.is_little() { Reader::new_little(data) } else { Reader::new(data) }; - Tx { r, out: Vec::with_capacity(data.len()), from, to, version, warnings: Vec::new(), depth: 0 } + Tx { r, out, from, to, version, warnings: Vec::new(), depth: 0 } } fn finish(self) -> (Vec, Vec) { @@ -1631,6 +1709,25 @@ mod tests { /// Keys the generator plants that the transcoder (rightly) does not know. const UNKNOWN: [&[u8; 4]; 3] = [b"zOdd", b"zNop", b"Txt2"]; + /// #6607: moving the layers out of the model and freeing them before the conversion writes + /// the same bytes as copying them, in both byte orders, and leaves the rest of the model. + #[test] + fn taking_the_layers_writes_the_same_bytes_as_copying_them() { + for case in testgen::all_cases() { + for order in [ByteOrder::Big, ByteOrder::Little] { + let copied = ImageSourceData::from_psd(&case.file).to_bytes(order); + let mut file = case.file.clone(); + let taken = ImageSourceData::take_from_psd(&mut file).into_bytes(order); + match (copied, taken) { + (Ok(a), Ok(b)) => assert_eq!(a, b, "{} {order:?}", case.name), + (a, b) => assert_eq!(a.is_err(), b.is_err(), "{} {order:?}", case.name), + } + assert!(file.layer_info.is_none() && file.global_blocks.is_empty(), "{}", case.name); + assert_eq!(file.image_data, case.file.image_data, "{}", case.name); + } + } + } + /// `d` without the blocks a little-endian round trip drops, and without the layer-info /// padding (which follows the body length, so it changes when blocks are dropped). fn known(d: &ImageSourceData) -> ImageSourceData { @@ -2393,6 +2490,11 @@ mod tests { assert_eq!(d.to_bytes(ByteOrder::Big).unwrap().0, [signature, &body[..]].concat()); let (little, _) = transcode_blocks(&body, version, ByteOrder::Big, ByteOrder::Little).unwrap(); assert_eq!(d.to_bytes(ByteOrder::Little).unwrap().0, [signature, &little[..]].concat()); + // Converted behind the signature in the buffer it starts with: never grown (growing + // would at least double it), so the body is not moved to make room. + let (after, _) = transcode::transcode_blocks_after(signature, &body, version, ByteOrder::Big, ByteOrder::Little).unwrap(); + assert_eq!(after, [signature, &little[..]].concat()); + assert_eq!(after.capacity(), signature.len() + body.len()); } } From 1d70f5d6bf943474cbff61568cc290e0217627f8 Mon Sep 17 00:00:00 2001 From: Daniel Miguel Date: Wed, 21 Oct 2026 20:02:27 +0200 Subject: [PATCH 006/216] M0: default to the system title bar on Linux and preserve saved choices --- ROADMAP.md | 1 + apps/photocraft/src/main.rs | 18 ++++++++++-------- crates/engine/src/prefs.rs | 5 +++-- crates/engine/src/prefs/tests.rs | 16 ++++++++++++++++ crates/ui-egui/src/panels.rs | 5 +++-- crates/ui-egui/src/prefs_ui.rs | 7 ++++--- docs/development.md | 2 +- 7 files changed, 38 insertions(+), 16 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index f41b71bfc9..aa6b993ab0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -122,6 +122,7 @@ Detail and the next releases: [docs/roadmap.md](docs/roadmap.md). | Date | Entry | |---|---| +| 2026-10-21 | Linux defaults to System Title Bar so GNOME/Wayland has window controls. Saved choices and other platform defaults are preserved; startup and preference-dialog regressions cover both choices (#6266). | | 2026-10-10 | Full re-measure against Photoshop 2026 27.11.0 (installed bundle inspected): breadth ~76%, ready ~45%, alpha. Progress docs reorganized to the craftrules standard (`parity.md` → `parity-checklist.md`, `parity-estimate.md` merged into `target-app-parity.md`). 0.6.0 released. | | 2026-10-09 | Remove tool, Rotate View, crop rotation; OpenRaster and Paint.NET; 39 Affinity documents in the corpus | | 2026-10-08 | 0.5.0. Affinity import; Magnetic Lasso, Pattern Stamp, Mixer Brush, Pencil, Patch, Vertical Type on the toolbar | diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs index b0b00a865c..8a1f6d7351 100644 --- a/apps/photocraft/src/main.rs +++ b/apps/photocraft/src/main.rs @@ -83,14 +83,13 @@ use photocraft_ui_egui::PhotocraftApp; /// Matches the `.desktop` file and hicolor icon name, so Wayland docks pick up the icon. const APP_ID: &str = "ai.storyteller.photocraft"; -/// Windows and Linux: no OS title bar; the app's top bar is the title bar, with its own caption -/// buttons and edge resizing (`photocraft_ui_egui::titlebar`), as Photoshop does on Windows. macOS -/// keeps its traffic lights over the integrated title strip. +/// Windows and Linux can use the app's title bar, with its own caption buttons and edge resizing +/// (`photocraft_ui_egui::titlebar`); the preference below selects the mode. macOS always keeps its +/// system traffic lights over the integrated title strip. const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos"); -/// Whether this start draws its own title bar: Windows and Linux do, unless Preferences › -/// Interface › System Title Bar asks for the system's (#1271, #1316). Read from the saved -/// preferences before the window opens; a missing or unreadable file keeps the default. +/// Whether this start draws its own title bar. Linux defaults to the system frame; Windows +/// defaults to the in-app title bar. Saved preferences override those defaults (#1271, #1316). fn custom_titlebar(prefs_file: Option<&std::path::Path>) -> bool { let prefs: photocraft_engine::prefs::Preferences = prefs_file.and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default(); @@ -1027,9 +1026,12 @@ mod tests { let dir = std::env::temp_dir().join(format!("pc-titlebar-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let file = dir.join("prefs.json"); - assert_eq!(super::custom_titlebar(None), super::CUSTOM_TITLEBAR, "no preferences file: the default"); + let default_custom = super::CUSTOM_TITLEBAR && !cfg!(target_os = "linux"); + assert_eq!(super::custom_titlebar(None), default_custom, "no preferences file: the platform default"); std::fs::write(&file, "not json").unwrap(); - assert_eq!(super::custom_titlebar(Some(&file)), super::CUSTOM_TITLEBAR, "an unreadable file: the default"); + assert_eq!(super::custom_titlebar(Some(&file)), default_custom, "an unreadable file: the platform default"); + std::fs::write(&file, r#"{"interface":{}}"#).unwrap(); + assert_eq!(super::custom_titlebar(Some(&file)), default_custom, "an older preference file: the platform default"); std::fs::write(&file, r#"{"interface":{"systemTitleBar":true}}"#).unwrap(); assert!(!super::custom_titlebar(Some(&file))); assert_eq!(super::native_options(false).viewport.decorations, Some(true)); diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 77acd2e346..c2c06d767e 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -284,7 +284,8 @@ pub struct Interface { pub show_bounding_box_when_dragging_layer: bool, /// Windows and Linux: use the system's title bar and window buttons instead of PhotoCraft's /// own one-row title bar (tiling window managers, desktops that draw their own decorations; - /// #1271, #1316). Read when the app starts. macOS always uses the system's. + /// #1271, #1316). Enabled by default on Linux, where GNOME/Wayland needs the client frame. + /// Read when the app starts. macOS always uses the system's. pub system_title_bar: bool, /// Show the active document's file name in the title bar and the OS window title. The tabs /// already name the file, so this can be turned off for a plain "PhotoCraft" title (#5037). @@ -320,7 +321,7 @@ impl Default for Interface { show_tooltips: true, enable_animations: true, show_bounding_box_when_dragging_layer: false, - system_title_bar: false, + system_title_bar: cfg!(target_os = "linux"), show_file_name_in_title: true, notification_auto_hide: true, notification_duration_seconds: 6, diff --git a/crates/engine/src/prefs/tests.rs b/crates/engine/src/prefs/tests.rs index 55e41949b9..e9fedd4800 100644 --- a/crates/engine/src/prefs/tests.rs +++ b/crates/engine/src/prefs/tests.rs @@ -1,5 +1,21 @@ use super::*; +#[test] +fn system_title_bar_defaults_by_platform_and_preserves_saved_choices() { + let default = cfg!(target_os = "linux"); + assert_eq!(Interface::default().system_title_bar, default); + for saved in [json!({}), json!({"interface": {}})] { + let prefs: Preferences = serde_json::from_value(saved).unwrap(); + assert_eq!(prefs.interface.system_title_bar, default); + } + for saved in [false, true] { + let prefs: Preferences = serde_json::from_value(json!({"interface": {"systemTitleBar": saved}})).unwrap(); + assert_eq!(prefs.interface.system_title_bar, saved); + let restored: Preferences = serde_json::from_value(prefs.to_json()).unwrap(); + assert_eq!(restored.interface.system_title_bar, saved); + } +} + fn session() -> Session { let mut s = Session::new(); s.execute("file.new", json!({"width": 32, "height": 32})).unwrap(); diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 04e775439e..5af5b96183 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -801,8 +801,9 @@ mod title_tests { #[test] fn without_the_preference_the_mark_stays() { - // The default (e.g. the web build or the snapshot example): no custom bar, no preference. - let app = crate::PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + // An explicit choice to keep the app's title, independent of the platform default. + let mut app = crate::PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.edit_prefs(|p| p.interface.system_title_bar = false); assert!(!system_title_bar(&app)); } diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index d808ae32f4..5bf06fe971 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -3795,6 +3795,7 @@ mod tests { let (mut app, store) = app_with_store(); app.run("prefs.set", json!({"values": {"interface.language": "en"}})).unwrap(); + let initial = app.session.prefs().interface.system_title_bar; app.custom_titlebar = true; let id = open_preferences(&mut app, "interface"); let mut h = Harness::builder().with_size(vec2(1280.0, 800.0)).build_eframe(move |cc| { @@ -3805,7 +3806,7 @@ mod tests { h.get_by_label("System title bar").scroll_to_me(); h.run_steps(4); h.get_by_label("Applies at next launch."); - for desired in [true, false] { + for desired in [!initial, initial] { h.get_by_label("System title bar").click(); h.run_steps(4); assert_eq!(h.state().ui.dialogs.iter().find(|d| d.id == id).unwrap().fields["values"]["interface"]["systemTitleBar"], desired); @@ -3825,8 +3826,8 @@ mod tests { h.get_by_label("Cancel").click(); h.run_steps(4); assert!(h.state().ui.dialogs.iter().all(|d| d.id != id)); - assert!(!h.state().session.prefs().interface.system_title_bar); - assert_eq!(stored(&store)["interface"]["systemTitleBar"], false); + assert_eq!(h.state().session.prefs().interface.system_title_bar, initial); + assert_eq!(stored(&store)["interface"]["systemTitleBar"], initial); assert!(h.state().custom_titlebar); } diff --git a/docs/development.md b/docs/development.md index fcf3339b7d..fcab7b7fc1 100644 --- a/docs/development.md +++ b/docs/development.md @@ -34,7 +34,7 @@ building locally; the downloaded app is still subject to Windows application-con ## Build and run -On GNOME/Wayland, **System Title Bar** uses winit's font-free Adwaita controls; existing custom-title-bar preferences and defaults are unchanged. +On Linux, **System Title Bar** is enabled by default. GNOME/Wayland uses winit's font-free Adwaita controls; X11 uses the window manager's frame. Saved choices still override the default, and other platforms keep their existing defaults. Changes in Preferences apply at the next launch. ```sh cargo run --release -p photocraft -- path/to/image.psd # desktop app From 56c720105547c01e9b931457d48aa3de4f37b00e Mon Sep 17 00:00:00 2001 From: Daniel Date: Mon, 26 Oct 2026 11:57:34 +0100 Subject: [PATCH 007/216] fix(linux): retain integrated CSD as default title bar --- crates/engine/src/prefs.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 793bfc56e1..5aa97df4ef 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -293,8 +293,8 @@ pub struct Interface { pub show_bounding_box_when_dragging_layer: bool, /// Windows and Linux: use the system's title bar and window buttons instead of PhotoCraft's /// own one-row title bar (tiling window managers, desktops that draw their own decorations; - /// #1271, #1316). Enabled by default on Linux, where GNOME/Wayland needs the client frame. - /// Read when the app starts. macOS always uses the system's. + /// #1271, #1316). The integrated title bar remains the default on Linux and Windows; + /// the system frame is opt-in. Read when the app starts. macOS always uses the system's. pub system_title_bar: bool, /// Show the active document's file name in the title bar and the OS window title. The tabs /// already name the file, so this can be turned off for a plain "PhotoCraft" title (#5037). @@ -330,7 +330,7 @@ impl Default for Interface { show_tooltips: true, enable_animations: true, show_bounding_box_when_dragging_layer: false, - system_title_bar: cfg!(target_os = "linux"), + system_title_bar: false, show_file_name_in_title: true, notification_auto_hide: true, notification_duration_seconds: 6, From 5833b153e4a8c621f9b8a6bdc1a6143ff8b34687 Mon Sep 17 00:00:00 2001 From: Daniel Date: Mon, 26 Oct 2026 11:57:37 +0100 Subject: [PATCH 008/216] test(docs): preserve integrated Linux title bar and remove redundant NOTICE install --- crates/engine/src/prefs/tests.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/prefs/tests.rs b/crates/engine/src/prefs/tests.rs index c0378dafb2..3506487c80 100644 --- a/crates/engine/src/prefs/tests.rs +++ b/crates/engine/src/prefs/tests.rs @@ -1,8 +1,8 @@ use super::*; #[test] -fn system_title_bar_defaults_by_platform_and_preserves_saved_choices() { - let default = cfg!(target_os = "linux"); +fn system_title_bar_defaults_to_integrated_and_preserves_saved_choices() { + let default = false; assert_eq!(Interface::default().system_title_bar, default); for saved in [json!({}), json!({"interface": {}})] { let prefs: Preferences = serde_json::from_value(saved).unwrap(); From 6927d058fc0f9054302dfcbda37fa326f5d17a0e Mon Sep 17 00:00:00 2001 From: Daniel Date: Mon, 26 Oct 2026 11:57:40 +0100 Subject: [PATCH 009/216] test(docs): preserve integrated Linux title bar and remove redundant NOTICE install --- apps/photocraft/src/main.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs index 4e2c4aa7ac..96b2297328 100644 --- a/apps/photocraft/src/main.rs +++ b/apps/photocraft/src/main.rs @@ -83,8 +83,8 @@ const APP_ID: &str = "ai.storyteller.photocraft"; /// system traffic lights over the integrated title strip. const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos"); -/// Whether this start draws its own title bar. Linux defaults to the system frame; Windows -/// defaults to the in-app title bar. Saved preferences override those defaults (#1271, #1316). +/// Whether this start draws its own title bar. Linux and Windows default to the integrated +/// title bar, with optional system decorations selected by saved preferences (#1271, #1316). fn custom_titlebar(prefs_file: Option<&std::path::Path>) -> bool { let prefs: photocraft_engine::prefs::Preferences = prefs_file.and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default(); @@ -829,7 +829,7 @@ mod tests { let dir = std::env::temp_dir().join(format!("pc-titlebar-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let file = dir.join("prefs.json"); - let default_custom = super::CUSTOM_TITLEBAR && !cfg!(target_os = "linux"); + let default_custom = super::CUSTOM_TITLEBAR; assert_eq!(super::custom_titlebar(None), default_custom, "no preferences file: the platform default"); std::fs::write(&file, "not json").unwrap(); assert_eq!(super::custom_titlebar(Some(&file)), default_custom, "an unreadable file: the platform default"); From 17c5fb36af3152bf586cd37dfba60b8884fa8277 Mon Sep 17 00:00:00 2001 From: Daniel Date: Mon, 26 Oct 2026 11:57:44 +0100 Subject: [PATCH 010/216] test(docs): preserve integrated Linux title bar and remove redundant NOTICE install --- ROADMAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index ada399071a..27a3ac8bac 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -122,7 +122,7 @@ Detail and the next releases: [docs/roadmap.md](docs/roadmap.md). | Date | Entry | |---|---| -| 2026-10-21 | Linux defaults to System Title Bar so GNOME/Wayland has window controls. Saved choices and other platform defaults are preserved; startup and preference-dialog regressions cover both choices (#6266). | +| 2026-10-21 | Linux can use font-free Adwaita controls when System Title Bar is selected, while the integrated CSD title bar remains the default (as in #4439). Saved preferences are respected; startup and preference-dialog regressions cover both choices (#6266). | | 2026-10-17 | Object Finder classifies background by boundary coverage, retaining large enclosed foregrounds and rejecting surrounding backdrop (#6548); learned segmentation remains open (#2788) | | 2026-10-10 | Full re-measure against Photoshop 2026 27.11.0 (installed bundle inspected): breadth ~76%, ready ~45%, alpha. Progress docs reorganized to the craftrules standard (`parity.md` → `parity-checklist.md`, `parity-estimate.md` merged into `target-app-parity.md`). 0.6.0 released. | | 2026-10-09 | Remove tool, Rotate View, crop rotation; OpenRaster and Paint.NET; 39 Affinity documents in the corpus | From a0e1e4d836b7fab4a3f4cdedfea65181d73986ae Mon Sep 17 00:00:00 2001 From: Daniel Date: Mon, 26 Oct 2026 11:57:47 +0100 Subject: [PATCH 011/216] test(docs): preserve integrated Linux title bar and remove redundant NOTICE install --- docs/development.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/development.md b/docs/development.md index b8d2d6e636..f39e93e960 100644 --- a/docs/development.md +++ b/docs/development.md @@ -36,7 +36,7 @@ building locally; the downloaded app is still subject to Windows application-con ## Build and run -On Linux, **System Title Bar** is enabled by default. GNOME/Wayland uses winit's font-free Adwaita controls; X11 uses the window manager's frame. Saved choices still override the default, and other platforms keep their existing defaults. Changes in Preferences apply at the next launch. +On Linux, PhotoCraft keeps its integrated client-side decorated title bar by default (#4439), including desktop-matched buttons. Enabling **System Title Bar** in Preferences switches to the system frame at the next launch: on GNOME/Wayland, winit provides font-free Adwaita controls; on X11, the window manager supplies the frame. Explicit saved choices are respected and Windows/macOS defaults are unchanged. ```sh cargo run --release -p photocraft -- path/to/image.psd # desktop app From 7a9ee6256767e761e8c5fa89203527531f5c989a Mon Sep 17 00:00:00 2001 From: Daniel Date: Mon, 26 Oct 2026 11:57:50 +0100 Subject: [PATCH 012/216] test(docs): preserve integrated Linux title bar and remove redundant NOTICE install --- packaging/linux/flatpak/ai.storyteller.photocraft.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/packaging/linux/flatpak/ai.storyteller.photocraft.yml b/packaging/linux/flatpak/ai.storyteller.photocraft.yml index eb36821c23..2189374665 100644 --- a/packaging/linux/flatpak/ai.storyteller.photocraft.yml +++ b/packaging/linux/flatpak/ai.storyteller.photocraft.yml @@ -50,7 +50,6 @@ modules: - cargo --offline build --release --locked -p photocraft --features heif - install -Dm755 target/release/photocraft /app/bin/photocraft - install -Dm644 NOTICE /app/share/doc/photocraft/NOTICE - - install -Dm644 NOTICE /app/share/doc/photocraft/NOTICE - install -Dm644 packaging/linux/ai.storyteller.photocraft.desktop /app/share/applications/ai.storyteller.photocraft.desktop - install -Dm644 packaging/linux/ai.storyteller.photocraft.mime.xml /app/share/mime/packages/ai.storyteller.photocraft.xml - mkdir -p /app/share/icons && cp -R assets/app-icon/hicolor /app/share/icons/ From bef973a1c51268279dcd268ab699710b4c64b5ae Mon Sep 17 00:00:00 2001 From: Daniel Date: Mon, 26 Oct 2026 11:57:54 +0100 Subject: [PATCH 013/216] test(docs): preserve integrated Linux title bar and remove redundant NOTICE install --- crates/ui-egui/src/panels.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 2e2de02c06..56c19e380b 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -912,9 +912,8 @@ mod title_tests { #[test] fn without_the_preference_the_mark_stays() { - // An explicit choice to keep the app's title, independent of the platform default. - let mut app = crate::PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); - app.session.edit_prefs(|p| p.interface.system_title_bar = false); + // The default (e.g. the web build or snapshot example): the integrated title bar. + let app = crate::PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); assert!(!system_title_bar(&app)); } From b527b5062a64b87b7bd2130b4b4351fba35d40e6 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 27 Oct 2026 13:44:48 -0700 Subject: [PATCH 014/216] Save the flattened document at full size into .pcraft files as an option A .pcraft bundle holds the flattened document only as thumb.png (at most 256 px) and composite/preview.png (at most 1024 px). A new option also writes composite/full.png: the flattened document at its canvas size, an 8-bit RGBA PNG made like the two previews, with the ICC profile of its pixels embedded. The option is off by default, and a save without it writes the same bytes as before. - Preferences > File Handling > Save Full-Size Composite in .pcraft Files sets it for the app's Save, Save As and Save a Copy, in the desktop and web builds. - The engine's file.saveACopy and its batch and export commands take "pcraftFullComposite" (unset, the preference), the automation doc.save and MCP doc_save take "pcraftFullComposite": true, and photocraft-cli convert, run and batch take --pcraft-full-composite. The manifest names the image in a new optional member, full_composite; the format version stays 1, and bundles without it open as before. A canvas over 2^30 pixels, or one whose pixels cannot be allocated, is saved without it, and so is a ZIP bundle that it would take past 4 GiB or 65,535 entries. --- apps/photocraft-cli/src/args.rs | 4 + apps/photocraft-cli/src/output.rs | 2 +- apps/photocraft-cli/tests/it/cli.rs | 20 ++ book/src/formats/pcraft.md | 5 + crates/automation/src/files.rs | 17 +- crates/automation/src/rpc.rs | 8 +- crates/automation/src/server.rs | 8 +- crates/automation/tests/it/main.rs | 1 + .../tests/it/native_full_composite.rs | 45 +++++ crates/engine/src/cmd/file_cmds.rs | 2 +- crates/engine/src/cmd/file_cmds/save.rs | 12 +- crates/engine/src/cmd/file_cmds/tests.rs | 22 +++ crates/engine/src/prefs.rs | 4 + crates/format/src/lib.rs | 6 + crates/format/src/manifest.rs | 3 + crates/format/src/store.rs | 187 ++++++++++++++---- crates/format/tests/it/roundtrip.rs | 38 +++- crates/io/src/lib.rs | 67 ++++++- crates/io/src/psd_thumbnail.rs | 2 +- crates/io/tests/it/main.rs | 1 + crates/io/tests/it/pcraft_full_composite.rs | 131 ++++++++++++ crates/ui-egui/src/app/files.rs | 10 +- crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sr.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/prefs/prefs_ui/fields.rs | 3 + crates/ui-egui/src/save_identity_tests.rs | 23 +++ crates/ui-egui/src/services.rs | 5 + docs/control-protocol.md | 2 +- docs/development.md | 1 + docs/scorecard.md | 4 +- 48 files changed, 592 insertions(+), 61 deletions(-) create mode 100644 crates/automation/tests/it/native_full_composite.rs create mode 100644 crates/io/tests/it/pcraft_full_composite.rs diff --git a/apps/photocraft-cli/src/args.rs b/apps/photocraft-cli/src/args.rs index 91b99ef33b..66e2fbb3d7 100644 --- a/apps/photocraft-cli/src/args.rs +++ b/apps/photocraft-cli/src/args.rs @@ -74,6 +74,10 @@ pub(crate) struct ExportArgs { /// and CMYK shrink) or progressive #[arg(long, value_name = "baseline|optimized|progressive", value_parser = jpeg_format)] pub(crate) jpeg_format: Option, + /// Also write the flattened document at its canvas size into .pcraft output, as + /// composite/full.png + #[arg(long)] + pub(crate) pcraft_full_composite: bool, } /// Convert between formats (.pcraft, .psd, .png, .jpg, .tif, .webp, .exr, …) diff --git a/apps/photocraft-cli/src/output.rs b/apps/photocraft-cli/src/output.rs index 34101c9ec0..831258b315 100644 --- a/apps/photocraft-cli/src/output.rs +++ b/apps/photocraft-cli/src/output.rs @@ -8,7 +8,7 @@ use crate::{Result, args::ExportArgs}; impl From<&ExportArgs> for ExportOptions { fn from(a: &ExportArgs) -> Self { // TIFF output is flat unless asked: scripted conversions keep predictable sizes. - let mut o = ExportOptions { tiff_layers: a.tiff_layers, tga_bits: a.tga_bits, ..Default::default() }; + let mut o = ExportOptions { tiff_layers: a.tiff_layers, tga_bits: a.tga_bits, pcraft_full_composite: a.pcraft_full_composite, ..Default::default() }; if let Some(q) = a.quality { o.encode.jpeg_quality = q; // Asking for a quality asks for a lossy WebP; the default WebP stays lossless. diff --git a/apps/photocraft-cli/tests/it/cli.rs b/apps/photocraft-cli/tests/it/cli.rs index 2c06a488fd..348106b7e7 100644 --- a/apps/photocraft-cli/tests/it/cli.rs +++ b/apps/photocraft-cli/tests/it/cli.rs @@ -1025,6 +1025,26 @@ fn tiff_output_is_flat_unless_tiff_layers_is_given() { std::fs::remove_dir_all(d).unwrap(); } +#[test] +fn pcraft_output_has_no_full_composite_unless_asked() { + let d = tmp("pcraft-full-composite"); + let full = |path: &std::path::Path| photocraft_format::read_manifest(&std::fs::read(path).unwrap()).unwrap().full_composite.is_some(); + for (name, flag, expect) in [("plain.pcraft", None, false), ("full.pcraft", Some("--pcraft-full-composite"), true)] { + let out = d.join(name); + let mut cmd = bin(); + cmd.args(["run", "--new", r#"{"width":16,"height":16}"#]); + if let Some(flag) = flag { + cmd.arg(flag); + } + ok(cmd.arg("--out").arg(&out)); + assert_eq!(full(&out), expect, "{name}"); + } + let converted = d.join("converted.pcraft"); + ok(bin().arg("convert").arg(d.join("plain.pcraft")).arg(&converted).arg("--pcraft-full-composite")); + assert!(full(&converted)); + std::fs::remove_dir_all(d).unwrap(); +} + /// Issue #1108: a non-Unicode argument (a Latin-1 file name on Linux) used to panic in /// `std::env::args()` before anything was printed. clap reads the arguments as they are: a path /// takes one, and text (`--filter`) refuses it with a usage error. diff --git a/book/src/formats/pcraft.md b/book/src/formats/pcraft.md index 40ecbdd127..46a3dc553c 100644 --- a/book/src/formats/pcraft.md +++ b/book/src/formats/pcraft.md @@ -8,9 +8,14 @@ tiles/.zst blobs/.zst thumb.png composite/preview.png +composite/full.png save.lock ``` +`thumb.png` (at most 256 px on the longer side), `composite/preview.png` (at most 1024 px) and `composite/full.png` (the canvas size) are the flattened document as 8-bit RGBA PNGs, whatever the document's depth. The manifest names each one it holds, under `thumbnail`, `composite` and `full_composite`. + +A save writes `composite/full.png` only when it is asked to: *Save Full-Size Composite in .pcraft Files* in Preferences › File Handling for the app's saves, `"pcraftFullComposite": true` for the engine's `file.saveACopy` and its batch and export commands, and for the automation `doc.save` and MCP `doc_save`, and `--pcraft-full-composite` for `photocraft-cli convert`, `run` and `batch`. It is off by default. `composite/full.png` embeds the ICC profile of its pixels: an RGB document's own profile, a grayscale document's gray profile as RGB, and sRGB for CMYK, Lab and documents without a usable profile. A save leaves it out when the canvas is larger than 2^30 pixels, when its pixels cannot be allocated, or when a ZIP bundle would pass 4 GiB or 65,535 entries with it. A directory bundle saved without it has the file from an earlier save removed. + `save.lock` exists only in directory bundles: a save holds an OS file lock on it while it writes objects, publishes the manifest and removes unreferenced objects, so concurrent saves of one bundle take turns. It is empty, and readers ignore it. The manifest is versioned and describes the document tree. Tiles and blobs are content-addressed. Loaders verify ZIP entry bounds and CRCs; document content also uses stored hashes to detect mismatched or corrupted tile/blob content. Unsupported newer format versions fail explicitly. diff --git a/crates/automation/src/files.rs b/crates/automation/src/files.rs index 0b8ff103aa..5f509a692c 100644 --- a/crates/automation/src/files.rs +++ b/crates/automation/src/files.rs @@ -54,7 +54,18 @@ pub fn open(path: &Path) -> Result { /// Previews for a `.pcraft` bundle. pub fn previews(doc: &Document) -> SaveOptions { - SaveOptions { thumbnail: Some(photocraft_compose::thumbnail(doc, 256)), composite: Some(photocraft_compose::thumbnail(doc, 1024)) } + SaveOptions { thumbnail: Some(photocraft_compose::thumbnail(doc, 256)), composite: Some(photocraft_compose::thumbnail(doc, 1024)), ..Default::default() } +} + +/// [`previews`], plus the full-size composite when `opts` asks for it +/// ([`ExportOptions::pcraft_full_composite`]). +fn native_options(doc: &Document, opts: &ExportOptions) -> SaveOptions { + let mut out = previews(doc); + if opts.pcraft_full_composite { + out.full_composite = photocraft_io::pcraft_full_composite(doc); + out.full_composite_icc = out.full_composite.as_ref().map(|_| photocraft_io::pcraft_full_composite_icc(doc)); + } + out } /// Encode the name the saved native bundle will report on reopening. Save As updates @@ -86,7 +97,7 @@ pub fn save( let named = named_for_output(doc, &path.to_string_lossy()); let mut local = PcraftWriter::new(); let w = writer.unwrap_or(&mut local); - w.save_path(&named, path, &previews(&named))?; + w.save_path(&named, path, &native_options(&named, opts))?; return Ok(Vec::new()); } let r = photocraft_io::export(doc, &ext, opts)?; @@ -104,7 +115,7 @@ pub fn save_bytes(doc: &Document, name: &str, format_override: Option<&str>, opt .ok_or_else(|| AutomationError::BadRequest(format!("cannot tell the format of `{name}`; pass a format")))?; if ext == photocraft_format::EXTENSION { let named = named_for_output(doc, name); - return Ok((photocraft_format::save_to_bytes(&named, &previews(&named))?, Vec::new())); + return Ok((photocraft_format::save_to_bytes(&named, &native_options(&named, opts))?, Vec::new())); } let result = photocraft_io::export(doc, &ext, opts)?; Ok((result.bytes, result.warnings)) diff --git a/crates/automation/src/rpc.rs b/crates/automation/src/rpc.rs index 98ccc9a24a..db0807fb24 100644 --- a/crates/automation/src/rpc.rs +++ b/crates/automation/src/rpc.rs @@ -123,8 +123,12 @@ impl Headless { "doc.new" => self.command_run("file.new", p), "doc.save" => { let tga_bits = photocraft_engine::file_cmds::tga_bits_param(&p, "doc.save").map_err(|e| bad(e.to_string()))?; - let mut opts = - photocraft_io::ExportOptions { tiff_layers: p.get("tiffLayers").and_then(Value::as_bool).unwrap_or(false), tga_bits, ..Default::default() }; + let mut opts = photocraft_io::ExportOptions { + tiff_layers: p.get("tiffLayers").and_then(Value::as_bool).unwrap_or(false), + tga_bits, + pcraft_full_composite: p.get("pcraftFullComposite").and_then(Value::as_bool).unwrap_or(false), + ..Default::default() + }; opts.encode.dpx_bits = photocraft_engine::file_cmds::dpx_bits_param(&p, "doc.save").map_err(|e| bad(e.to_string()))?; opts.encode.dds = photocraft_engine::file_cmds::dds_param(&p, "doc.save").map_err(|e| bad(e.to_string()))?.unwrap_or_default(); match p.get("quality") { diff --git a/crates/automation/src/server.rs b/crates/automation/src/server.rs index 61a6454e79..11acba25d9 100644 --- a/crates/automation/src/server.rs +++ b/crates/automation/src/server.rs @@ -191,6 +191,10 @@ pub struct SaveParams { /// DDS: also write the full mip chain, each level a box filter of the one above. #[serde(default, rename = "ddsMipmaps")] pub dds_mipmaps: bool, + /// .pcraft: also write the flattened document at its canvas size as `composite/full.png`. + /// Off by default. + #[serde(default, rename = "pcraftFullComposite")] + pub pcraft_full_composite: bool, #[serde(default)] pub index: Option, } @@ -1077,6 +1081,7 @@ impl PhotocraftMcp { p.format.is_some().then_some("format"), p.quality.is_some().then_some("quality"), p.tiff_layers.then_some("tiffLayers"), + p.pcraft_full_composite.then_some("pcraftFullComposite"), dpx_bits.is_some().then_some("dpxBits"), p.index.is_some().then_some("index"), ] @@ -1103,7 +1108,8 @@ impl PhotocraftMcp { } let Some(r) = self .headless_op(move |h| { - let mut opts = photocraft_io::ExportOptions { tiff_layers: p.tiff_layers, tga_bits, ..Default::default() }; + let mut opts = + photocraft_io::ExportOptions { tiff_layers: p.tiff_layers, tga_bits, pcraft_full_composite: p.pcraft_full_composite, ..Default::default() }; opts.encode.dpx_bits = dpx_bits; opts.encode.dds = photocraft_io::DdsOptions { compression: dds_compression.unwrap_or_default(), mipmaps: p.dds_mipmaps }; if let Some(q) = p.quality { diff --git a/crates/automation/tests/it/main.rs b/crates/automation/tests/it/main.rs index f701bd705b..0fdd94a4a8 100644 --- a/crates/automation/tests/it/main.rs +++ b/crates/automation/tests/it/main.rs @@ -10,6 +10,7 @@ mod layered_save_dirty; mod mcp; mod mcp_wire; mod missing_font_warnings; +mod native_full_composite; mod native_save_name; mod openraster; mod raw_defaults; diff --git a/crates/automation/tests/it/native_full_composite.rs b/crates/automation/tests/it/native_full_composite.rs new file mode 100644 index 0000000000..fea554c19d --- /dev/null +++ b/crates/automation/tests/it/native_full_composite.rs @@ -0,0 +1,45 @@ +//! Scripted `.pcraft` saves write the full-size composite when they ask for it +//! (`pcraftFullComposite`), and leave it out by default, as the app's Save does. + +use photocraft_automation::{Headless, files}; +use photocraft_format::zip::ZipReader; +use serde_json::json; + +fn full_composite(bytes: &[u8]) -> ((u32, u32), Vec) { + let path = photocraft_format::read_manifest(bytes).unwrap().full_composite.expect("the manifest names the full-size composite"); + let png = ZipReader::new(bytes).unwrap().read_by_name(&path, usize::MAX).unwrap(); + let image = photocraft_codecs::decode(&png).unwrap(); + (image.dimensions(), image.to_rgba8()) +} + +fn has_full_composite(bytes: &[u8]) -> bool { + photocraft_format::read_manifest(bytes).unwrap().full_composite.is_some() || ZipReader::new(bytes).unwrap().find("composite/full.png").is_some() +} + +#[test] +fn scripted_native_saves_write_the_full_size_composite_when_asked() { + let dir = std::env::temp_dir().join(format!( + "pc-native-full-composite-{}-{}", + std::process::id(), + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + let mut headless = Headless::trusted_local(); + headless.handle("doc.new", json!({"width": 40, "height": 30, "name": "Full"})).unwrap(); + headless.handle("engine.execute", json!({"command":"image.adjustments.invert", "params":{}})).unwrap(); + let doc = headless.session.active().unwrap().doc.clone(); + let flattened = photocraft_compose::render(&doc, doc.bounds()).to_rgba8().pixels; + + let (bytes, _) = files::save_bytes(&doc, "Full.pcraft", None, &Default::default()).unwrap(); + assert!(!has_full_composite(&bytes), "off by default"); + let on = photocraft_io::ExportOptions { pcraft_full_composite: true, ..Default::default() }; + let (bytes, _) = files::save_bytes(&doc, "Full.pcraft", None, &on).unwrap(); + assert_eq!(full_composite(&bytes), ((40, 30), flattened.clone())); + + let path = dir.join("Full.pcraft"); + headless.handle("doc.save", json!({"path": path.to_str().unwrap()})).unwrap(); + assert!(!has_full_composite(&std::fs::read(&path).unwrap()), "off by default"); + headless.handle("doc.save", json!({"path": path.to_str().unwrap(), "pcraftFullComposite": true})).unwrap(); + assert_eq!(full_composite(&std::fs::read(&path).unwrap()), ((40, 30), flattened)); + std::fs::remove_dir_all(dir).unwrap(); +} diff --git a/crates/engine/src/cmd/file_cmds.rs b/crates/engine/src/cmd/file_cmds.rs index efdd3ad8bf..8291b061ff 100644 --- a/crates/engine/src/cmd/file_cmds.rs +++ b/crates/engine/src/cmd/file_cmds.rs @@ -241,7 +241,7 @@ pub fn specs() -> Vec { "Save a Copy…", &["File"], Some("Cmd+Alt+S"), - r##"{"path":str (format from the extension),"quality":0..12? (JPEG),"jpegFormat":"baseline|optimized|progressive"? (JPEG; default baseline),"layers":bool=true,"overwrite":bool=false (replace the document's own file),"tiffLayers":bool=false (TIFF: keep the layers; flat by default),"tiffCompression":"none|lzw|zip"? (TIFF: image compression; unset, none),"tgaBits":24|32? (TGA: 32 writes the alpha channel, or else the transparency, as the alpha; unset, 32 with an alpha channel or transparency, else 24),"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"ddsCompression":"none|bc1|bc2|bc3"="none" (DDS: uncompressed, or block compression; BC1 keeps alpha on or off),"ddsMipmaps":bool=false (DDS: also write the full mip chain)}"##, + r##"{"path":str (format from the extension),"quality":0..12? (JPEG),"jpegFormat":"baseline|optimized|progressive"? (JPEG; default baseline),"layers":bool=true,"overwrite":bool=false (replace the document's own file),"tiffLayers":bool=false (TIFF: keep the layers; flat by default),"tiffCompression":"none|lzw|zip"? (TIFF: image compression; unset, none),"tgaBits":24|32? (TGA: 32 writes the alpha channel, or else the transparency, as the alpha; unset, 32 with an alpha channel or transparency, else 24),"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"ddsCompression":"none|bc1|bc2|bc3"="none" (DDS: uncompressed, or block compression; BC1 keeps alpha on or off),"ddsMipmaps":bool=false (DDS: also write the full mip chain),"pcraftFullComposite":bool? (.pcraft: also write the flattened document at its canvas size as composite/full.png; unset, Preferences › File Handling)}"##, native_doc, save_a_copy ), diff --git a/crates/engine/src/cmd/file_cmds/save.rs b/crates/engine/src/cmd/file_cmds/save.rs index 25104a7b13..c117562465 100644 --- a/crates/engine/src/cmd/file_cmds/save.rs +++ b/crates/engine/src/cmd/file_cmds/save.rs @@ -9,7 +9,8 @@ pub fn tiff_would_write_layers(doc: &Document) -> bool { } /// What a headless save writes beyond the format: JPEG quality and format, TIFF layers, Targa -/// bits, DPX bits, DDS compression and mipmaps, PSD previews and the PSD merged image. +/// bits, DPX bits, DDS compression and mipmaps, PSD previews, the PSD merged image and the +/// `.pcraft` full-size composite. #[derive(Debug, Clone, Copy)] pub(crate) struct SaveOpts { /// Photoshop's 0–12 JPEG scale. @@ -33,6 +34,9 @@ pub(crate) struct SaveOpts { /// PSD/PSB: write the real merged image (Preferences › File Handling › Maximize PSD and PSB /// File Compatibility). pub psd_maximize_compatibility: bool, + /// `.pcraft`: also write the full-size composite (`"pcraftFullComposite"`; unset, Preferences › + /// File Handling › Save Full-Size Composite in .pcraft Files, off by default). + pub pcraft_full_composite: bool, } impl Default for SaveOpts { @@ -47,6 +51,7 @@ impl Default for SaveOpts { dds: photocraft_io::DdsOptions::default(), image_previews: true, psd_maximize_compatibility: true, + pcraft_full_composite: false, } } } @@ -56,7 +61,8 @@ impl SaveOpts { /// `jpegFormat` it doesn't name or a `tgaBits` other than 24 or 32 is an error); the image /// previews and the PSD merged image from the session's File Handling preferences (Ask When /// Saving has no dialog here, so it saves them, as Photoshop's Save As checkbox and its - /// Maximize Compatibility checkbox both start on). + /// Maximize Compatibility checkbox both start on). The `.pcraft` full-size composite comes from + /// `pcraftFullComposite`, or without it from the File Handling preference. pub(crate) fn from_params(s: &Session, p: &Value, cmd: &str) -> Result { let file_handling = &s.prefs().file_handling; Ok(SaveOpts { @@ -69,6 +75,7 @@ impl SaveOpts { dds: dds_param(p, cmd)?.unwrap_or_default(), image_previews: file_handling.image_previews != crate::prefs::Ask::Never, psd_maximize_compatibility: file_handling.maximize_psd_compatibility != crate::prefs::Ask::Never, + pcraft_full_composite: p.get("pcraftFullComposite").and_then(Value::as_bool).unwrap_or(file_handling.pcraft_full_composite), }) } @@ -178,6 +185,7 @@ pub(crate) fn encode(doc: &Document, path: &str, save: impl Into) -> R tga_bits: save.tga_bits, image_previews: save.image_previews, psd_maximize_compatibility: save.psd_maximize_compatibility, + pcraft_full_composite: save.pcraft_full_composite, // Linked files a PSD embeds are resolved against the document being worked on. document_path: crate::doc_path::current(), // An untrusted session's stored paths are document data: embed none of them. diff --git a/crates/engine/src/cmd/file_cmds/tests.rs b/crates/engine/src/cmd/file_cmds/tests.rs index 7eb770d844..eae4793d91 100644 --- a/crates/engine/src/cmd/file_cmds/tests.rs +++ b/crates/engine/src/cmd/file_cmds/tests.rs @@ -456,6 +456,28 @@ fn maximize_compatibility_preference_controls_the_psd_merged_image() { } } +/// Preferences › File Handling › Save Full-Size Composite in .pcraft Files decides whether a +/// `.pcraft` save writes `composite/full.png` (off by default); a command's +/// `"pcraftFullComposite"` overrides it. +#[test] +fn full_composite_preference_controls_the_pcraft_full_size_composite() { + let dir = tmp("full-composite"); + let mut s = session(300, 200, 16); + let full = |path: &str| photocraft_format::read_manifest(&std::fs::read(path).unwrap()).unwrap().full_composite.is_some(); + let out = join(&dir, "default.pcraft"); + s.execute("file.saveACopy", json!({"path": out})).unwrap(); + assert!(!full(&out), "off by default"); + for on in [true, false] { + s.edit_prefs(|p| p.file_handling.pcraft_full_composite = on); + let out = join(&dir, &format!("{on}.pcraft")); + s.execute("file.saveACopy", json!({"path": out})).unwrap(); + assert_eq!(full(&out), on, "preference {on}"); + let out = join(&dir, &format!("{on} param.pcraft")); + s.execute("file.saveACopy", json!({"path": out, "pcraftFullComposite": !on})).unwrap(); + assert_eq!(full(&out), !on, "preference {on}, param {}", !on); + } +} + #[test] fn open_as_forces_the_decoder() { let dir = tmp("openas"); diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 38401ff468..c73e4d7738 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -492,6 +492,9 @@ pub struct FileHandling { pub exr_aces_cg: bool, pub ask_before_saving_layered_tiff: bool, pub maximize_psd_compatibility: Ask, + /// A `.pcraft` save also writes the flattened document at its canvas size as + /// `composite/full.png`. Off by default. + pub pcraft_full_composite: bool, /// SVG groups deeper than this are rasterised on import; parser and document safety caps remain fixed. pub rasterize_svg_groups_deeper_than: u32, pub recent_file_count: u32, @@ -524,6 +527,7 @@ impl Default for FileHandling { exr_aces_cg: false, ask_before_saving_layered_tiff: true, maximize_psd_compatibility: Ask::Always, + pcraft_full_composite: false, rasterize_svg_groups_deeper_than: photocraft_doc::MAX_GROUP_DEPTH as u32, recent_file_count: 20, recent_files: Vec::new(), diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 992e925ae9..20bd4d95ae 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -8,6 +8,7 @@ //! blobs/.zst content-addressed binary data (ICC, EXIF, PSD blocks, smart objects) //! thumb.png optional thumbnail (caller-rendered) //! composite/preview.png optional flattened preview (caller-rendered) +//! composite/full.png optional flattened composite at the canvas size (caller-rendered) //! save.lock directory bundles only: held while a save runs (empty) //! ``` //! @@ -84,6 +85,11 @@ pub type Result = std::result::Result; pub struct SaveOptions { pub thumbnail: Option, pub composite: Option, + /// The flattened document at its canvas size, for other apps that read `.pcraft` files. A ZIP + /// save leaves it out when it would take the archive past 4 GiB or 65,535 entries. + pub full_composite: Option, + /// The ICC profile of `full_composite`'s pixels, embedded in its PNG. + pub full_composite_icc: Option>, } /// Limits applied while loading untrusted bundles. diff --git a/crates/format/src/manifest.rs b/crates/format/src/manifest.rs index 0b1e08b706..84ba3ac99b 100644 --- a/crates/format/src/manifest.rs +++ b/crates/format/src/manifest.rs @@ -31,6 +31,9 @@ pub struct Manifest { pub thumbnail: Option, #[serde(default)] pub composite: Option, + /// The flattened document at its canvas size (absent in older files and in saves without it). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub full_composite: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] diff --git a/crates/format/src/store.rs b/crates/format/src/store.rs index 97e285a573..20e8f33551 100644 --- a/crates/format/src/store.rs +++ b/crates/format/src/store.rs @@ -18,6 +18,10 @@ use crate::{FormatError, LoadOptions, Result, SaveOptions}; pub(crate) const MANIFEST: &str = "manifest.json"; pub(crate) const THUMB: &str = "thumb.png"; pub(crate) const COMPOSITE: &str = "composite/preview.png"; +pub(crate) const FULL_COMPOSITE: &str = "composite/full.png"; +/// Bytes a ZIP bundle may hold before its central directory, and entries it may hold: the +/// format's 32-bit offsets and 16-bit counts ([`ZipWriter`] has no ZIP64). +const ZIP_LIMIT: (u64, usize) = (u32::MAX as u64, u16::MAX as usize); /// The file a directory save holds an exclusive lock on for its whole transaction (#4957). pub(crate) const SAVE_LOCK: &str = "save.lock"; /// How long a directory save waits for another save of the same bundle to finish. @@ -59,16 +63,28 @@ fn hash_bytes(b: &[u8]) -> Hash { } fn png(img: &Rgba8Image) -> Result> { + png_tagged(img, None) +} + +/// [`png`] with `icc` embedded. +fn png_tagged(img: &Rgba8Image, icc: Option<&[u8]>) -> Result> { let image = photocraft_codecs::Image::from_u8(img.width, img.height, photocraft_codecs::ChannelLayout::Rgba, img.pixels.clone()) - .map_err(|e| FormatError::Unsupported(format!("preview: {e}")))?; + .map_err(|e| FormatError::Unsupported(format!("preview: {e}")))? + .with_icc(icc.map(<[u8]>::to_vec)); photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).map_err(|e| FormatError::Unsupported(format!("preview: {e}"))) } fn previews(opts: &SaveOptions) -> Result)>> { - let (composite, thumbnail) = beside( - opts.thumbnail.is_some() && opts.composite.is_some(), - || opts.composite.as_ref().map(png).transpose(), - || opts.thumbnail.as_ref().map(png).transpose(), + let (full, (composite, thumbnail)) = beside( + opts.full_composite.is_some() && (opts.thumbnail.is_some() || opts.composite.is_some()), + || opts.full_composite.as_ref().map(|img| png_tagged(img, opts.full_composite_icc.as_deref())).transpose(), + || { + beside( + opts.thumbnail.is_some() && opts.composite.is_some(), + || opts.composite.as_ref().map(png).transpose(), + || opts.thumbnail.as_ref().map(png).transpose(), + ) + }, ); let mut out = Vec::new(); if let Some(t) = thumbnail? { @@ -77,6 +93,9 @@ fn previews(opts: &SaveOptions) -> Result)>> { if let Some(t) = composite? { out.push((COMPOSITE, t)); } + if let Some(t) = full? { + out.push((FULL_COMPOSITE, t)); + } Ok(out) } @@ -160,6 +179,19 @@ const MAX_MANIFEST_DEPTH: usize = 3 * (convert::MAX_GROUP_DEPTH + 1) + 64; /// item, so this still admits manifests of about 80 MiB. const MAX_MANIFEST_ITEMS: usize = 4 << 20; +/// The manifest as a save writes it. +fn manifest_json(manifest: &Manifest) -> Result> { + // serde_json writes NaN and the infinities as `null`, which the manifest's float fields + // refuse on load (issue #1101): refuse the save instead, naming the value. + crate::finite::check(manifest).map_err(|e| match e { + crate::finite::Problem::NonFinite { path } => FormatError::NonFinite { path }, + crate::finite::Problem::Other(m) => FormatError::Unsupported(format!("manifest: {m}")), + })?; + let json = serde_json::to_vec_pretty(manifest)?; + check_manifest_depth(&json)?; + Ok(json) +} + /// Rejects JSON nested deeper than [`MAX_MANIFEST_DEPTH`] or holding more than /// [`MAX_MANIFEST_ITEMS`] items with a linear scan (no recursion), so parsing with serde_json's /// recursion limit disabled can't exhaust the stack, nor tiny values the memory, on hostile input. @@ -508,6 +540,8 @@ struct FileSignature { struct Prepared { manifest: Vec, + /// What `manifest` encodes, for a ZIP save that has to leave the full-size composite out. + model: Manifest, objects: BTreeMap, stats: SaveStats, } @@ -785,6 +819,7 @@ impl BundleGc { let (handle, name) = match relative { THUMB => (&self.root, THUMB), COMPOSITE => (&self.composite, "preview.png"), + FULL_COMPOSITE => (&self.composite, "full.png"), _ => match relative.split_once('/') { Some(("tiles", name)) if name.strip_suffix(".zst").is_some_and(is_valid_hash) => (&self.tiles, name), Some(("blobs", name)) if name.strip_suffix(".zst").is_some_and(is_valid_hash) => (&self.blobs, name), @@ -915,15 +950,10 @@ impl PcraftWriter { document, thumbnail: opts.thumbnail.as_ref().map(|_| THUMB.to_owned()), composite: opts.composite.as_ref().map(|_| COMPOSITE.to_owned()), + full_composite: opts.full_composite.as_ref().map(|_| FULL_COMPOSITE.to_owned()), }; - // serde_json writes NaN and the infinities as `null`, which the manifest's float fields - // refuse on load (issue #1101): refuse the save instead, naming the value. - crate::finite::check(&manifest).map_err(|e| match e { - crate::finite::Problem::NonFinite { path } => FormatError::NonFinite { path }, - crate::finite::Problem::Other(m) => FormatError::Unsupported(format!("manifest: {m}")), - })?; - let manifest = serde_json::to_vec_pretty(&manifest)?; - check_manifest_depth(&manifest)?; + let model = manifest; + let manifest = manifest_json(&model)?; let stats = SaveStats { tiles_total: c.tiles.len(), blobs_total: c.blobs.len(), manifest_bytes: manifest.len(), ..Default::default() }; let mut objects = BTreeMap::new(); for (h, (t, s)) in c.tiles { @@ -932,15 +962,21 @@ impl PcraftWriter { for (h, b) in c.blobs { objects.insert(blob_path(&h), Object::Blob(b)); } - Ok(Prepared { manifest, objects, stats }) + Ok(Prepared { manifest, model, objects, stats }) } /// Save as a ZIP bundle in memory. pub fn save_zip(&mut self, doc: &Document, opts: &SaveOptions) -> Result<(Vec, SaveStats)> { + self.save_zip_within(doc, opts, ZIP_LIMIT) + } + + /// [`Self::save_zip`] with `limit` (bytes, entries) in place of [`ZIP_LIMIT`]: an archive that + /// the full-size composite would take past either is saved without that composite. + fn save_zip_within(&mut self, doc: &Document, opts: &SaveOptions, limit: (u64, usize)) -> Result<(Vec, SaveStats)> { // Another thread encodes the previews while the document is collected and its new // objects are compressed on all cores (zstd dominates a full save). let (previews, prepared) = beside( - opts.thumbnail.is_some() || opts.composite.is_some(), + opts.thumbnail.is_some() || opts.composite.is_some() || opts.full_composite.is_some(), || previews(opts), || -> Result<_> { let p = self.prepare(doc, opts)?; @@ -950,22 +986,9 @@ impl PcraftWriter { }, ); let (p, mut fresh) = prepared?; - let previews = previews?; + let mut previews = previews?; let mut stats = p.stats; - let mut next = HashMap::with_capacity(p.objects.len()); - let mut cached_bytes = 0usize; - // The archive is sized for every entry up front (#7689): previews, then reused and fresh - // objects at their compressed sizes. - let entries = - std::iter::once((MANIFEST, p.manifest.len())).chain(previews.iter().map(|(name, data)| (*name, data.len()))).chain(p.objects.keys().map(|path| { - let size = self.compressed.get(path).or_else(|| fresh.get(path)).map_or(0, |data| data.len()); - (path.as_str(), size) - })); - let mut z = ZipWriter::with_entries(entries)?; - z.add(MANIFEST, &p.manifest)?; - for (name, data) in &previews { - z.add(name, data)?; - } + let mut entries = Vec::with_capacity(p.objects.len()); for (path, obj) in &p.objects { let (key, data) = match self.compressed.remove_entry(path) { Some((key, data)) => { @@ -982,6 +1005,37 @@ impl PcraftWriter { (None, fresh.remove(path).unwrap_or_else(|| Arc::new(obj.compressed()))) } }; + entries.push((path, key, data)); + } + // A STORE entry takes a 30-byte local header plus its name and data before the central + // directory. The full-size composite is left out rather than fail a save that fits without it. + let stored = |name: &str, len: usize| 30 + name.len() as u64 + len as u64; + let total = stored(MANIFEST, p.manifest.len()) + + previews.iter().map(|(name, data)| stored(name, data.len())).sum::() + + entries.iter().map(|(path, _, data)| stored(path, data.len())).sum::(); + let mut manifest = p.manifest; + if (total > limit.0 || 1 + previews.len() + entries.len() > limit.1) + && let Some(at) = previews.iter().position(|(name, _)| *name == FULL_COMPOSITE) + { + previews.remove(at); + let mut model = p.model; + model.full_composite = None; + manifest = manifest_json(&model)?; + stats.manifest_bytes = manifest.len(); + } + // The archive is sized for every entry up front (#7689): previews, then reused and fresh + // objects at their compressed sizes. + let sizes = std::iter::once((MANIFEST, manifest.len())) + .chain(previews.iter().map(|(name, data)| (*name, data.len()))) + .chain(entries.iter().map(|(path, _, data)| (path.as_str(), data.len()))); + let mut z = ZipWriter::with_entries(sizes)?; + z.add(MANIFEST, &manifest)?; + for (name, data) in &previews { + z.add(name, data)?; + } + let mut next = HashMap::with_capacity(entries.len()); + let mut cached_bytes = 0usize; + for (path, key, data) in entries { z.add(path, &data)?; if data.capacity() <= self.compressed_budget.saturating_sub(cached_bytes) { cached_bytes += data.capacity(); @@ -1009,7 +1063,7 @@ impl PcraftWriter { let existing = list_objects(dir)?; // Reject a hostile existing entry before writing any object or manifest. This also // covers entries that this save would otherwise leave untouched or garbage-collect. - for path in [MANIFEST, THUMB, COMPOSITE] { + for path in [MANIFEST, THUMB, COMPOSITE, FULL_COMPOSITE] { validate_bundle_file(dir, &dir.join(path))?; } let p = self.prepare(doc, opts)?; @@ -1099,7 +1153,7 @@ impl PcraftWriter { write_bundle_file(dir, &dir.join(MANIFEST), &p.manifest, fs)?; // Omitted previews belong to the previous manifest until the new one commits. // After commit, a locked leftover is only housekeeping, just like unused objects. - for stale in [THUMB, COMPOSITE] { + for stale in [THUMB, COMPOSITE, FULL_COMPOSITE] { if !previews.iter().any(|(n, _)| *n == stale) { let _ = remove_bundle_file(dir, &dir.join(stale), &gc); } @@ -1246,35 +1300,90 @@ fn previews_follow_the_manifest_with_the_bytes_of_a_serial_encode() { i.pixels.iter_mut().enumerate().for_each(|(j, p)| *p = (j as u8).wrapping_mul(k)); i }; - let (thumb, composite) = (image(4, 3, 7), image(9, 5, 13)); - for (t, c) in [(true, true), (true, false), (false, true), (false, false)] { - let opts = SaveOptions { thumbnail: t.then(|| thumb.clone()), composite: c.then(|| composite.clone()) }; + let (thumb, composite, full) = (image(4, 3, 7), image(9, 5, 13), image(600, 3, 5)); + for mask in 0..8u8 { + let (t, c, f) = (mask & 1 != 0, mask & 2 != 0, mask & 4 != 0); + let opts = SaveOptions { + thumbnail: t.then(|| thumb.clone()), + composite: c.then(|| composite.clone()), + full_composite: f.then(|| full.clone()), + ..Default::default() + }; let (bytes, _) = PcraftWriter::new().save_zip(&doc, &opts).unwrap(); let zip = ZipReader::new(&bytes).unwrap(); let names: Vec<&str> = zip.entries.iter().map(|e| e.name.as_str()).collect(); let mut want = vec![MANIFEST]; want.extend(t.then_some(THUMB)); want.extend(c.then_some(COMPOSITE)); + want.extend(f.then_some(FULL_COMPOSITE)); assert_eq!(names[..want.len()], want[..]); assert!(names[want.len()..].iter().all(|n| n.starts_with("tiles/") || n.starts_with("blobs/")), "{names:?}"); - for (name, img) in [(THUMB, opts.thumbnail.as_ref()), (COMPOSITE, opts.composite.as_ref())] { + for (name, img) in [(THUMB, opts.thumbnail.as_ref()), (COMPOSITE, opts.composite.as_ref()), (FULL_COMPOSITE, opts.full_composite.as_ref())] { assert_eq!(zip.read_by_name(name, usize::MAX).ok(), img.map(|i| png(i).unwrap()), "{name}"); } + assert_eq!(crate::read_manifest(&bytes).unwrap().full_composite.as_deref(), f.then_some(FULL_COMPOSITE)); } - let bad = SaveOptions { thumbnail: Some(Rgba8Image { width: 4, height: 3, pixels: vec![0; 5] }), composite: Some(composite.clone()) }; + let bad = SaveOptions { thumbnail: Some(Rgba8Image { width: 4, height: 3, pixels: vec![0; 5] }), composite: Some(composite.clone()), ..Default::default() }; assert!(matches!(PcraftWriter::new().save_zip(&doc, &bad), Err(FormatError::Unsupported(m)) if m.starts_with("preview"))); - let bad = SaveOptions { thumbnail: Some(thumb.clone()), composite: Some(Rgba8Image { width: 9, height: 5, pixels: vec![0; 5] }) }; + let bad = SaveOptions { thumbnail: Some(thumb.clone()), composite: Some(Rgba8Image { width: 9, height: 5, pixels: vec![0; 5] }), ..Default::default() }; + assert!(matches!(PcraftWriter::new().save_zip(&doc, &bad), Err(FormatError::Unsupported(m)) if m.starts_with("preview"))); + let bad = SaveOptions { full_composite: Some(Rgba8Image { width: 600, height: 3, pixels: vec![0; 5] }), ..Default::default() }; assert!(matches!(PcraftWriter::new().save_zip(&doc, &bad), Err(FormatError::Unsupported(m)) if m.starts_with("preview"))); let dir = std::env::temp_dir().join(format!("pcraft-previews-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); - let opts = SaveOptions { thumbnail: Some(thumb.clone()), composite: Some(composite.clone()) }; + let opts = SaveOptions { thumbnail: Some(thumb.clone()), composite: Some(composite.clone()), full_composite: Some(full.clone()), ..Default::default() }; PcraftWriter::new().save_dir(&doc, &dir, &opts).unwrap(); assert_eq!(std::fs::read(dir.join(THUMB)).unwrap(), png(&thumb).unwrap()); assert_eq!(std::fs::read(dir.join(COMPOSITE)).unwrap(), png(&composite).unwrap()); + assert_eq!(std::fs::read(dir.join(FULL_COMPOSITE)).unwrap(), png(&full).unwrap()); + // A later save without it removes the file once the new manifest is in place. + PcraftWriter::new().save_dir(&doc, &dir, &SaveOptions { full_composite: None, ..opts }).unwrap(); + assert!(!dir.join(FULL_COMPOSITE).exists()); + assert!(dir.join(COMPOSITE).exists()); std::fs::remove_dir_all(&dir).unwrap(); } +#[cfg(test)] +#[test] +fn zip_save_leaves_the_full_composite_out_past_the_zip_limit() { + use photocraft_doc::{Layer, LayerContent}; + use photocraft_geom::Size; + use photocraft_raster::Surface; + + let mut doc = Document::new("limit", Size::new(300, 200), photocraft_color::ColorMode::Rgb, SampleType::U8); + let mut surface = Surface::new(doc.pixel_format()); + surface.write_pixel(7, 9, &[0.2, 0.4, 0.6, 1.0]); + doc.layers.push(Layer::new("pixels", LayerContent::Raster(surface))); + let mut full = Rgba8Image::new(300, 200); + full.pixels.iter_mut().enumerate().for_each(|(i, p)| *p = (i % 251) as u8); + let opts = SaveOptions { + thumbnail: Some(Rgba8Image::new(4, 3)), + composite: Some(Rgba8Image::new(9, 6)), + full_composite: Some(full.clone()), + ..Default::default() + }; + let (whole, _) = PcraftWriter::new().save_zip(&doc, &opts).unwrap(); + // The archive's entries without the central directory, and how many there are: what the + // limit compares. + let archive = ZipReader::new(&whole).unwrap(); + let bytes = archive.entries.iter().map(|e| 30 + e.name.len() as u64 + archive.read(e, usize::MAX).unwrap().len() as u64).sum::(); + let count = archive.entries.len(); + let (fits, _) = PcraftWriter::new().save_zip_within(&doc, &opts, (bytes, count)).unwrap(); + assert_eq!(fits, whole, "an archive at the limit keeps the composite"); + for limit in [(bytes - 1, count), (bytes, count - 1)] { + let (saved, stats) = PcraftWriter::new().save_zip_within(&doc, &opts, limit).unwrap(); + let zip = ZipReader::new(&saved).unwrap(); + let names: Vec<&str> = zip.entries.iter().map(|e| e.name.as_str()).collect(); + assert!(!names.contains(&FULL_COMPOSITE), "{limit:?}: {names:?}"); + assert!(names.contains(&THUMB) && names.contains(&COMPOSITE), "{limit:?}: {names:?}"); + let manifest = zip.read_by_name(MANIFEST, usize::MAX).unwrap(); + assert_eq!(stats.manifest_bytes, manifest.len()); + assert!(!String::from_utf8(manifest).unwrap().contains("full_composite")); + assert_eq!(crate::load_from_bytes(&saved).unwrap(), doc); + } +} + #[cfg(all(test, unix))] #[test] fn gc_pinned_directory_handles_resist_subdirectory_swap() { diff --git a/crates/format/tests/it/roundtrip.rs b/crates/format/tests/it/roundtrip.rs index 32ef503139..02e8b1e8e8 100644 --- a/crates/format/tests/it/roundtrip.rs +++ b/crates/format/tests/it/roundtrip.rs @@ -490,7 +490,7 @@ fn previews_embedded_and_readable() { let doc = rich_doc(ColorMode::Rgb, SampleType::U8); let mut thumb = Rgba8Image::new(4, 3); thumb.pixels.iter_mut().enumerate().for_each(|(i, p)| *p = i as u8); - let opts = SaveOptions { thumbnail: Some(thumb.clone()), composite: Some(thumb.clone()) }; + let opts = SaveOptions { thumbnail: Some(thumb.clone()), composite: Some(thumb.clone()), ..Default::default() }; let bytes = save_to_bytes(&doc, &opts).unwrap(); let png = read_thumbnail(&bytes).unwrap().unwrap(); let img = photocraft_codecs::decode(&png).unwrap(); @@ -502,6 +502,36 @@ fn previews_embedded_and_readable() { assert!(read_thumbnail(&save_to_bytes(&doc, &SaveOptions::default()).unwrap()).unwrap().is_none()); } +/// The full-size composite is an optional manifest member: a bundle without it (every file saved +/// before it was written) opens as before, at the same format version. +#[test] +fn bundles_without_the_full_composite_still_open() { + let doc = rich_doc(ColorMode::Rgb, SampleType::U8); + let full = Rgba8Image::new(doc.size.width, doc.size.height); + let opts = SaveOptions { thumbnail: Some(Rgba8Image::new(2, 2)), composite: Some(Rgba8Image::new(4, 3)), full_composite: Some(full), ..Default::default() }; + let bytes = save_to_bytes(&doc, &opts).unwrap(); + let m = read_manifest(&bytes).unwrap(); + assert_eq!(m.full_composite.as_deref(), Some("composite/full.png")); + assert_eq!(m.format_version, 1); + // Rewrite the bundle as an older writer left it: no member in the manifest, no entry. + let r = zip::ZipReader::new(&bytes).unwrap(); + let mut manifest: serde_json::Value = serde_json::from_slice(&r.read_by_name("manifest.json", usize::MAX).unwrap()).unwrap(); + assert!(manifest.as_object_mut().unwrap().remove("full_composite").is_some()); + let mut z = zip::ZipWriter::new(); + z.add("manifest.json", &serde_json::to_vec_pretty(&manifest).unwrap()).unwrap(); + for e in r.entries.iter().filter(|e| e.name != "manifest.json" && e.name != "composite/full.png") { + z.add(&e.name, &r.read(e, usize::MAX).unwrap()).unwrap(); + } + let older = z.finish().unwrap(); + assert_eq!(read_manifest(&older).unwrap().full_composite, None); + assert_eq!(load_from_bytes(&older).unwrap(), load_from_bytes(&bytes).unwrap()); + assert_eq!(load_from_bytes(&older).unwrap(), doc); + // A save without it writes no member at all, as older writers did. + let plain = save_to_bytes(&doc, &SaveOptions::default()).unwrap(); + let text = zip::ZipReader::new(&plain).unwrap().read_by_name("manifest.json", usize::MAX).unwrap(); + assert!(!String::from_utf8(text).unwrap().contains("full_composite")); +} + #[test] fn manifest_is_human_readable_json() { let doc = rich_doc(ColorMode::Rgb, SampleType::U8); @@ -911,7 +941,7 @@ fn directory_save_without_previews_removes_stale_files_after_commit() { let mut doc = Document::new("before", photocraft_doc::Size::new(4, 3), ColorMode::Rgb, SampleType::U8); let dir = temp_dir("preview-cleanup"); let mut writer = PcraftWriter::new(); - let opts = SaveOptions { thumbnail: Some(Rgba8Image::new(2, 2)), composite: Some(Rgba8Image::new(4, 3)) }; + let opts = SaveOptions { thumbnail: Some(Rgba8Image::new(2, 2)), composite: Some(Rgba8Image::new(4, 3)), ..Default::default() }; writer.save_dir(&doc, &dir, &opts).unwrap(); doc.name = "after".into(); writer.save_dir(&doc, &dir, &SaveOptions::default()).unwrap(); @@ -931,7 +961,7 @@ fn failed_manifest_commit_preserves_omitted_previews() { let mut doc = Document::with_background("before", photocraft_doc::Size::new(4, 3), ColorMode::Rgb, depth, photocraft_color::Color::WHITE); let dir = temp_dir("locked-manifest-previews"); let mut writer = PcraftWriter::new(); - let opts = SaveOptions { thumbnail: Some(Rgba8Image::new(2, 2)), composite: Some(Rgba8Image::new(4, 3)) }; + let opts = SaveOptions { thumbnail: Some(Rgba8Image::new(2, 2)), composite: Some(Rgba8Image::new(4, 3)), ..Default::default() }; writer.save_dir(&doc, &dir, &opts).unwrap(); let old_doc = doc.clone(); let paths = ["manifest.json", "thumb.png", "composite/preview.png"]; @@ -962,7 +992,7 @@ fn locked_stale_preview_does_not_fail_committed_save() { let mut doc = Document::new("before", photocraft_doc::Size::new(4, 3), ColorMode::Rgb, SampleType::U8); let dir = temp_dir("locked-stale-preview"); let mut writer = PcraftWriter::new(); - let opts = SaveOptions { thumbnail: Some(Rgba8Image::new(2, 2)), composite: Some(Rgba8Image::new(4, 3)) }; + let opts = SaveOptions { thumbnail: Some(Rgba8Image::new(2, 2)), composite: Some(Rgba8Image::new(4, 3)), ..Default::default() }; writer.save_dir(&doc, &dir, &opts).unwrap(); let held = std::fs::OpenOptions::new().read(true).share_mode(3).open(dir.join("thumb.png")).unwrap(); doc.name = "after".into(); diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 612cd6dd3b..a5bd0c0956 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -240,6 +240,9 @@ pub struct ExportOptions { /// PSD/PSB and layered TIFF: read and embed the files linked smart objects name (see /// [`PsdExportOptions::read_linked_files`]). On by default; automation turns it off. pub read_linked_files: bool, + /// `.pcraft`: also write the flattened document at its canvas size as `composite/full.png` + /// (Preferences › File Handling › Save Full-Size Composite in .pcraft Files). Off by default. + pub pcraft_full_composite: bool, } impl Default for ExportOptions { @@ -255,6 +258,7 @@ impl Default for ExportOptions { document_path: None, pcraft_cache: None, read_linked_files: true, + pcraft_full_composite: false, } } } @@ -564,9 +568,11 @@ pub(crate) fn with_span(ctl: &photocraft_raster::Interrupt, lo: f32, hi: f32, } /// Where a .pcraft save's progress stands once its thumbnail, then its 1024 px composite preview, -/// are rendered (the bundle itself is written after them). +/// then its full-size composite when [`ExportOptions::pcraft_full_composite`] asks for one, are +/// rendered (the bundle itself is written after them). const PCRAFT_THUMBNAIL_DONE: f32 = 0.15; const PCRAFT_PREVIEWS_DONE: f32 = 0.4; +const PCRAFT_FULL_COMPOSITE_DONE: f32 = 0.6; /// The share of a PSD/PSB export spent converting the document (layers, then the composite) /// before its bytes are assembled: about 2.7 s of 3.0 s for a 24 MP, 5-layer RGB/8 document. const PSD_MODEL_DONE: f32 = 0.9; @@ -601,7 +607,14 @@ fn export_stages(doc: &Document, name_or_ext: &str, opts: &ExportOptions, ctl: & let composite = Some(photocraft_compose::thumbnail(doc.as_ref(), 1024)); ctl.check().map_err(|_| IoError::Cancelled)?; ctl.progress(PCRAFT_PREVIEWS_DONE); - let previews = photocraft_format::SaveOptions { thumbnail, composite }; + let mut full_composite = None; + if opts.pcraft_full_composite { + full_composite = with_span(ctl, PCRAFT_PREVIEWS_DONE, PCRAFT_FULL_COMPOSITE_DONE, |ctl| pcraft_full_composite_with(doc.as_ref(), ctl))?; + ctl.check().map_err(|_| IoError::Cancelled)?; + ctl.progress(PCRAFT_FULL_COMPOSITE_DONE); + } + let full_composite_icc = full_composite.as_ref().map(|_| pcraft_full_composite_icc(doc.as_ref())); + let previews = photocraft_format::SaveOptions { thumbnail, composite, full_composite, full_composite_icc }; let bytes = match &opts.pcraft_cache { Some(cache) => cache.encode(doc.as_ref(), &previews)?, None => photocraft_format::save_to_bytes(doc.as_ref(), &previews)?, @@ -645,6 +658,56 @@ fn export_stages(doc: &Document, name_or_ext: &str, opts: &ExportOptions, ctl: & flat::export_flat_with(doc, format, opts, ctl) } +/// The flattened document at its canvas size, in 8-bit RGBA like a `.pcraft` file's two previews, +/// for the bundle's `composite/full.png`. `None` for a canvas past +/// [`photocraft_compose::MAX_RENDER_PIXELS`], or when its pixels cannot be allocated (a 32-bit +/// target holds less than 2 GiB in one buffer). +pub fn pcraft_full_composite(doc: &Document) -> Option { + pcraft_full_composite_with(doc, &photocraft_raster::Interrupt::NONE).ok().flatten() +} + +/// The ICC profile of [`pcraft_full_composite`]'s pixels: an RGB document's own profile as stored, +/// a gray document's gray profile as RGB, and sRGB for CMYK, Lab and documents without a usable +/// profile ([`psd_thumbnail::composite_profile`]). +pub fn pcraft_full_composite_icc(doc: &Document) -> Vec { + let own = doc.icc_profile.as_ref().filter(|bytes| { + doc.mode == photocraft_color::ColorMode::Rgb && photocraft_cms::Profile::parse(bytes).is_ok_and(|p| p.color_space == photocraft_cms::ColorSpace::Rgb) + }); + match own { + Some(bytes) => bytes.to_vec(), + None => psd_thumbnail::composite_profile(doc).to_bytes().to_vec(), + } +} + +/// [`pcraft_full_composite`] rendered in bands: `ctl` gets the fraction of rows done, and a +/// cancelled render fails with [`IoError::Cancelled`]. +fn pcraft_full_composite_with(doc: &Document, ctl: &photocraft_raster::Interrupt) -> Result, IoError> { + let canvas = doc.bounds(); + if canvas.is_empty() || u64::from(canvas.width()) * u64::from(canvas.height()) > photocraft_compose::MAX_RENDER_PIXELS { + return Ok(None); + } + let Ok(len) = usize::try_from(u64::from(canvas.width()) * u64::from(canvas.height()) * 4) else { return Ok(None) }; + let mut pixels = Vec::new(); + if pixels.try_reserve_exact(len).is_err() { + return Ok(None); + } + pixels.resize(len, 0); + let mut image = photocraft_raster::Rgba8Image { width: canvas.width(), height: canvas.height(), pixels }; + let row = canvas.width() as usize * 4; + let rows = canvas.height() as f32; + photocraft_compose::render_bands(doc, canvas, 0, |band| -> Result<(), IoError> { + ctl.check().map_err(|_| IoError::Cancelled)?; + let start = (band.rect.y0 - canvas.y0) as usize * row; + let pixels = band.to_rgba8().pixels; + if let Some(dst) = image.pixels.get_mut(start..start + pixels.len()) { + dst.copy_from_slice(&pixels); + } + ctl.progress((band.rect.y1 - canvas.y0) as f32 / rows); + Ok(()) + })?; + Ok(Some(image)) +} + /// The PSD's merged composite as straight RGBA floats (row-major), converted /// with the document pixel model and un-matted from white (Photoshop mattes /// the merged image of transparent documents). Used as the compositing oracle. diff --git a/crates/io/src/psd_thumbnail.rs b/crates/io/src/psd_thumbnail.rs index beaccc5bda..f2165dbb87 100644 --- a/crates/io/src/psd_thumbnail.rs +++ b/crates/io/src/psd_thumbnail.rs @@ -73,7 +73,7 @@ fn thumbnail_bytes(w: u32, h: u32, jfif: &[u8]) -> Result, IoError> { /// The profile of the compositor's RGB output for `doc`: an RGB document's own (embedded when it /// parses, else sRGB), a gray document's gray profile seen as RGB, and sRGB for CMYK and Lab /// (which the compositor renders in sRGB). -fn composite_profile(doc: &Document) -> Profile { +pub(crate) fn composite_profile(doc: &Document) -> Profile { let space = match doc.mode { ColorMode::Grayscale | ColorMode::Bitmap | ColorMode::Duotone => ColorSpace::Gray, ColorMode::Cmyk | ColorMode::Lab => return Builtin::Srgb.profile().clone(), diff --git a/crates/io/tests/it/main.rs b/crates/io/tests/it/main.rs index 40154335d0..028baf09c3 100644 --- a/crates/io/tests/it/main.rs +++ b/crates/io/tests/it/main.rs @@ -40,6 +40,7 @@ mod modes; mod multichannel; mod orientation; mod pattern_fill_psd; +mod pcraft_full_composite; mod pcraft_manifest_name; mod pdn_corpus; mod png_text_metadata; diff --git a/crates/io/tests/it/pcraft_full_composite.rs b/crates/io/tests/it/pcraft_full_composite.rs new file mode 100644 index 0000000000..f0710f7878 --- /dev/null +++ b/crates/io/tests/it/pcraft_full_composite.rs @@ -0,0 +1,131 @@ +//! With [`ExportOptions::pcraft_full_composite`] on, a `.pcraft` save writes the flattened +//! document at its canvas size as `composite/full.png`, next to the 256 px thumbnail and the +//! 1024 px preview, so other apps can read the whole image without decoding tiles. Off (the +//! default), the save writes the bundle it wrote before the option existed. + +use photocraft_color::{BlendMode, ColorMode, SampleType}; +use photocraft_doc::{Document, Layer, LayerContent}; +use photocraft_format::zip::ZipReader; +use photocraft_geom::{Rect, Size}; +use photocraft_io::{ExportOptions, export}; +use photocraft_raster::Surface; + +/// A background, a soft Multiply layer at 60 % and a layer that leaves part of the canvas clear. +fn doc(w: u32, h: u32, depth: SampleType) -> Document { + let mut d = Document::new("full", Size::new(w, h), ColorMode::Rgb, depth); + let fmt = d.pixel_format(); + let layer = |name: &str, seed: usize, r: Rect| { + let n = r.width() as usize * r.height() as usize * fmt.bytes_per_pixel(); + let px: Vec = (0..n).map(|i| ((i * 31 + seed * 17 + i / 7) % 251) as u8).collect(); + Layer::new(name, LayerContent::Raster(Surface::from_interleaved(fmt, r, &px))) + }; + d.layers.push(layer("Back", 1, Rect::new(0, 0, w as i32, h as i32 / 2))); + let mut soft = layer("Soft", 2, Rect::new(w as i32 / 4, h as i32 / 4, w as i32, h as i32)); + soft.opacity = 0.6; + soft.blend = BlendMode::Multiply; + d.layers.push(soft); + d +} + +fn on() -> ExportOptions { + ExportOptions { pcraft_full_composite: true, ..Default::default() } +} + +/// The bundle's member named by the manifest's `full_composite`, decoded: size and RGBA8 pixels. +fn full_composite(bytes: &[u8]) -> ((u32, u32), Vec) { + let manifest = photocraft_format::read_manifest(bytes).expect("manifest"); + let path = manifest.full_composite.expect("the manifest names the full-size composite"); + assert_eq!(path, "composite/full.png"); + let png = ZipReader::new(bytes).expect("zip").read_by_name(&path, usize::MAX).expect("entry"); + let image = photocraft_codecs::decode(&png).expect("png"); + (image.dimensions(), image.to_rgba8()) +} + +fn flattened(doc: &Document) -> Vec { + photocraft_compose::render(doc, doc.bounds()).to_rgba8().pixels +} + +#[test] +fn a_default_save_writes_the_bundle_without_the_full_composite() { + for depth in SampleType::ALL { + let d = doc(1500, 700, depth); + let bytes = export(&d, "a.pcraft", &ExportOptions::default()).expect("export").bytes; + assert_eq!(photocraft_format::read_manifest(&bytes).expect("manifest").full_composite, None, "{depth:?}"); + assert!(ZipReader::new(&bytes).expect("zip").find("composite/full.png").is_none(), "{depth:?}"); + // The bytes a save wrote before the option: the bundle with its two previews alone. + let mut named = d.clone(); + named.name = "a.pcraft".into(); + let previews = photocraft_format::SaveOptions { + thumbnail: Some(photocraft_compose::thumbnail(&named, 256)), + composite: Some(photocraft_compose::thumbnail(&named, 1024)), + ..Default::default() + }; + assert!(bytes == photocraft_format::save_to_bytes(&named, &previews).expect("save"), "{depth:?}: the default save changed"); + } +} + +#[test] +fn a_pcraft_save_writes_the_flattened_document_at_its_canvas_size() { + for depth in SampleType::ALL { + let d = doc(300, 200, depth); + let bytes = export(&d, "a.pcraft", &on()).expect("export").bytes; + let (size, pixels) = full_composite(&bytes); + assert_eq!(size, (300, 200), "{depth:?}"); + assert!(pixels == flattened(&d), "{depth:?}: the full-size composite differs from the flattened document"); + assert!(pixels.as_chunks::<4>().0.iter().any(|p| p[3] == 0), "{depth:?}: transparent pixels stay transparent"); + let mut saved = d.clone(); + saved.name = "a.pcraft".into(); + assert_eq!(photocraft_format::load_from_bytes(&bytes).expect("load"), saved, "{depth:?}"); + } +} + +#[test] +fn a_document_larger_than_the_preview_keeps_its_full_size() { + let d = doc(1500, 700, SampleType::U8); + let bytes = export(&d, "a.pcraft", &on()).expect("export").bytes; + let (size, pixels) = full_composite(&bytes); + assert_eq!(size, (1500, 700)); + assert!(pixels == flattened(&d), "the full-size composite differs from the flattened document"); + let preview = ZipReader::new(&bytes).expect("zip").read_by_name("composite/preview.png", usize::MAX).expect("preview"); + assert_eq!(photocraft_codecs::decode(&preview).expect("png").dimensions(), (1024, 478)); +} + +#[test] +fn the_full_composite_carries_the_profile_of_its_pixels() { + use photocraft_cms::{Builtin, ColorSpace, Profile}; + let full_icc = |d: &Document| { + let bytes = export(d, "a.pcraft", &on()).expect("export").bytes; + let png = ZipReader::new(&bytes).expect("zip").read_by_name("composite/full.png", usize::MAX).expect("entry"); + photocraft_codecs::decode(&png).expect("png").icc.expect("an embedded profile") + }; + let srgb = Builtin::Srgb.profile().to_bytes().to_vec(); + let p3 = Builtin::DisplayP3.profile().to_bytes().to_vec(); + let mut d = doc(40, 30, SampleType::U8); + d.icc_profile = Some(std::sync::Arc::new(p3.clone())); + assert_eq!(full_icc(&d), p3, "the composite is in the document's RGB space"); + d.icc_profile = None; + assert_eq!(full_icc(&d), srgb); + // A CMYK document composites to sRGB: its CMYK profile does not describe the PNG. + let mut cmyk = Document::new("cmyk", Size::new(40, 30), ColorMode::Cmyk, SampleType::U8); + cmyk.icc_profile = Some(std::sync::Arc::new(Builtin::CoatedCmyk.profile().to_bytes().to_vec())); + assert_eq!(full_icc(&cmyk), srgb); + // A gray document's composite repeats its gray samples in R, G and B: the PNG carries its + // gray curve as an RGB profile. + let gray = Builtin::SGray.profile().clone(); + let mut g = Document::new("gray", Size::new(40, 30), ColorMode::Grayscale, SampleType::U8); + g.icc_profile = Some(std::sync::Arc::new(gray.to_bytes().to_vec())); + let tagged = Profile::parse(&full_icc(&g)).expect("profile"); + assert_eq!(tagged.color_space, ColorSpace::Rgb); + assert_eq!(tagged.to_bytes(), gray.gray_as_rgb().expect("gray as RGB").to_bytes()); +} + +#[test] +fn a_canvas_past_the_render_limit_is_saved_without_it() { + // 40000 x 30000 px is over the compositor's 2^30-pixel limit; the layers stay small. + let mut d = doc(64, 64, SampleType::U8); + d.size = Size::new(40_000, 30_000); + assert!(photocraft_io::pcraft_full_composite(&d).is_none()); + let bytes = export(&d, "a.pcraft", &on()).expect("export").bytes; + assert_eq!(photocraft_format::read_manifest(&bytes).expect("manifest").full_composite, None); + assert!(ZipReader::new(&bytes).expect("zip").find("composite/full.png").is_none()); +} diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index ebd30492de..c11d887fd9 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -346,14 +346,20 @@ impl PhotocraftApp { /// What a save writes by default under the preferences: File Handling › Image Previews /// decides whether a PSD/PSB gets its thumbnail, and Maximize PSD and PSB File Compatibility /// whether it gets its real merged image. Ask When Saving has no dialog here yet, so it saves - /// both, as Photoshop's checkboxes start on. A JPEG or WebP is written the way the active + /// both, as Photoshop's checkboxes start on. Save Full-Size Composite in .pcraft Files decides + /// whether a `.pcraft` save also writes `composite/full.png`. A JPEG or WebP is written the way the active /// document's file was (its JPEG quality, a WebP lossy at its quality or lossless), as /// Photoshop's Save and Save As options start there. pub(crate) fn save_settings(&self) -> ExportSettings { let file_handling = &self.session.prefs().file_handling; let previews = file_handling.image_previews != photocraft_engine::prefs::Ask::Never; let maximize = file_handling.maximize_psd_compatibility != photocraft_engine::prefs::Ask::Never; - let mut settings = ExportSettings { image_previews: previews, psd_maximize_compatibility: maximize, ..ExportSettings::default() }; + let mut settings = ExportSettings { + image_previews: previews, + psd_maximize_compatibility: maximize, + pcraft_full_composite: file_handling.pcraft_full_composite, + ..ExportSettings::default() + }; if let Some(st) = self.session.active() { let e = &st.doc.file_encoding; settings.jpeg_quality = e.jpeg_quality.map(|q| q.clamp(1, 100)); diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index ad024ccd97..34d3ad150c 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3942,6 +3942,7 @@ navigator Zoom Zvětšení Chisel Soft Měkké tesání Open color-less EXR as ACEScg Otevřít EXR bez údaje o barevném prostoru jako ACEScg Do not append "copy" to filename when saving a copy Nepřidávat „copy“ k názvu souboru při ukládání kopie + Save Full-Size Composite in .pcraft Files Ukládat složený obraz v plné velikosti do souborů .pcraft # View › Guides › right-click a guide Edit Selected Guides Upravit vybraná vodítka diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index c1dfc027ed..4c1264e342 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3346,6 +3346,7 @@ curves Smooth Glätten Chisel Soft Meißeln weich Open color-less EXR as ACEScg Farbangabenloses EXR als ACEScg öffnen Do not append "copy" to filename when saving a copy Beim Speichern einer Kopie „copy“ nicht an den Dateinamen anhängen + Save Full-Size Composite in .pcraft Files Kompositbild in voller Größe in .pcraft-Dateien speichern # Export As metadata and profile options Color Profile Farbprofil EXIF EXIF diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index b2b35dbbdc..f2315a7186 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -4028,6 +4028,7 @@ curves Smooth Εξομάλυνση Chisel Soft Μαλακή σμίλευση Open color-less EXR as ACEScg Άνοιγμα EXR χωρίς χρωματική δήλωση ως ACEScg Do not append "copy" to filename when saving a copy Να μην προστίθεται «copy» στο όνομα αρχείου κατά την αποθήκευση αντιγράφου + Save Full-Size Composite in .pcraft Files Αποθήκευση σύνθετης εικόνας πλήρους μεγέθους στα αρχεία .pcraft # Export As metadata and profile options EXIF EXIF diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index b42f3be02c..1f1c3d860e 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4291,6 +4291,7 @@ navigator Zoom Zoom Chisel Soft Cincelar suave Open color-less EXR as ACEScg Abrir EXR sin espacio de color como ACEScg Do not append "copy" to filename when saving a copy No añadir «copy» al nombre de archivo al guardar una copia + Save Full-Size Composite in .pcraft Files Guardar la imagen compuesta a tamaño completo en los archivos .pcraft # Export As metadata and profile options # View › Guides › right-click a guide diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 3bad30d474..6f5f9f1ee8 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3338,6 +3338,7 @@ curves Smooth Lisser Chisel Soft Ciselage doux Open color-less EXR as ACEScg Ouvrir les EXR sans espace colorimétrique en ACEScg Do not append "copy" to filename when saving a copy Ne pas ajouter « copy » au nom de fichier lors de l’enregistrement d’une copie + Save Full-Size Composite in .pcraft Files Enregistrer l’image composite en taille réelle dans les fichiers .pcraft # View › Guides › right-click a guide Edit Selected Guides Modifier les repères sélectionnés diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index d632cb52f1..1c501888a5 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4220,6 +4220,7 @@ cameraRaw Yellow Sárga Chisel Soft Lágy vésés Open color-less EXR as ACEScg Színinformáció nélküli EXR megnyitása ACEScg-ként Do not append "copy" to filename when saving a copy Ne fűzze a „copy” szót a fájlnévhez másolat mentésekor + Save Full-Size Composite in .pcraft Files Teljes méretű kompozit kép mentése a .pcraft fájlokba # View › Guides › right-click a guide Edit Selected Guides Kijelölt segédvonalak szerkesztése diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 771e04e80a..20d0676689 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3341,6 +3341,7 @@ curves Smooth Haluskan Chisel Soft Pahat Lembut Open color-less EXR as ACEScg Buka EXR tanpa informasi warna sebagai ACEScg Do not append "copy" to filename when saving a copy Jangan tambahkan "copy" ke nama file saat menyimpan salinan + Save Full-Size Composite in .pcraft Files Simpan gambar komposit ukuran penuh di file .pcraft # View › Guides › right-click a guide Edit Selected Guides Edit Panduan Terpilih diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index b1598ac237..d4c6b98ed9 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4293,6 +4293,7 @@ navigator Zoom Zoom Chisel Soft Cesello morbido Open color-less EXR as ACEScg Apri gli EXR senza spazio colore come ACEScg Do not append "copy" to filename when saving a copy Non aggiungere "copy" al nome del file quando si salva una copia + Save Full-Size Composite in .pcraft Files Salva l'immagine composita a dimensione piena nei file .pcraft # Color management, Liquify, glyph group, warp and Camera Raw labels the Serbian, Vietnamese and Japanese catalogs added Adobe-rgb-compat Adobe RGB (compatibile) diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 435813b0c7..efd5487e99 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4008,6 +4008,7 @@ curves Smooth スムーズ Chisel Soft チゼルソフト Open color-less EXR as ACEScg 色空間のないEXRをACEScgとして開く Do not append "copy" to filename when saving a copy コピーを保存するときにファイル名に「copy」を付けない + Save Full-Size Composite in .pcraft Files .pcraftファイルにフルサイズの合成画像を保存 # View › Guides › right-click a guide Edit Selected Guides 選択したガイドを編集 Clear Selected Guides 選択したガイドを消去 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 30ab8cb165..c73d457be4 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3533,6 +3533,7 @@ curves Smooth 매끄럽게 Chisel Soft 부드럽게 조각 Open color-less EXR as ACEScg 색 공간 정보 없는 EXR을 ACEScg로 열기 Do not append "copy" to filename when saving a copy 사본 저장 시 파일 이름에 "copy"를 붙이지 않음 + Save Full-Size Composite in .pcraft Files .pcraft 파일에 전체 크기 합성 이미지 저장 # Export As metadata and profile options Color Profile 색상 프로필 EXIF EXIF diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index d03260ecaf..1e01f7e6d8 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -4090,6 +4090,7 @@ curves Smooth Vloeiend maken Chisel Soft Beitel zacht Open color-less EXR as ACEScg EXR zonder kleurruimte als ACEScg openen Do not append "copy" to filename when saving a copy Geen "copy" aan de bestandsnaam toevoegen bij het opslaan van een kopie + Save Full-Size Composite in .pcraft Files Samengestelde afbeelding op volle grootte opslaan in .pcraft-bestanden # View › Guides › right-click a guide Edit Selected Guides Geselecteerde hulplijnen bewerken diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index ba41a4e34d..791ff3ff4a 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3945,6 +3945,7 @@ curves Smooth Wygładź Chisel Soft Dłuto miękkie Open color-less EXR as ACEScg Otwieraj EXR bez profilu koloru jako ACEScg Do not append "copy" to filename when saving a copy Nie dodawaj „copy” do nazwy pliku przy zapisywaniu kopii + Save Full-Size Composite in .pcraft Files Zapisuj obraz złożony w pełnym rozmiarze w plikach .pcraft # View › Guides › right-click a guide Edit Selected Guides Edytuj zaznaczone prowadnice diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index f3335cd9c6..3b53f6d3cd 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -4120,6 +4120,7 @@ layerLabel No Color Sem cor Chisel Soft Cinzel suave Open color-less EXR as ACEScg Abrir EXR sem espaço de cor como ACEScg Do not append "copy" to filename when saving a copy Não acrescentar "copy" ao nome do arquivo ao salvar uma cópia + Save Full-Size Composite in .pcraft Files Salvar a imagem composta em tamanho real nos arquivos .pcraft # View › Guides › right-click a guide Edit Selected Guides Editar guias selecionadas diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index b74c398312..b350b80f2c 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3304,6 +3304,7 @@ curves Smooth Сгладить Chisel Soft Мягкая огранка Open color-less EXR as ACEScg Открывать EXR без цветового профиля как ACEScg Do not append "copy" to filename when saving a copy Не добавлять «copy» к имени файла при сохранении копии + Save Full-Size Composite in .pcraft Files Сохранять сведённое изображение в полном размере в файлах .pcraft # View › Guides › right-click a guide Edit Selected Guides Изменить выбранные направляющие diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index a81eb06da2..fb92311de1 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -4360,6 +4360,7 @@ shape Appearance Izgled Chisel Soft Meko klesanje Open color-less EXR as ACEScg Otvori EXR bez podatka o bojama kao ACEScg Do not append "copy" to filename when saving a copy Ne dodaji „copy“ u ime fajla pri čuvanju kopije + Save Full-Size Composite in .pcraft Files Čuvaj složenu sliku u punoj veličini u .pcraft fajlovima Show home recent files Prikaži nedavne datoteke na početnom ekranu # View › Guides › right-click a guide diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index f5b9d13fd6..a497874054 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -4032,6 +4032,7 @@ cameraData Lens Lens Chisel Soft Yumuşak Keski Open color-less EXR as ACEScg Renk bilgisi olmayan EXR'yi ACEScg olarak aç Do not append "copy" to filename when saving a copy Kopya kaydederken dosya adına "copy" ekleme + Save Full-Size Composite in .pcraft Files .pcraft dosyalarına tam boyutlu bileşik görüntüyü kaydet # View › Guides › right-click a guide Edit Selected Guides Seçili Kılavuzları Düzenle diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 8d911b19a8..3ea991da28 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4030,6 +4030,7 @@ curves Smooth Згладити Chisel Soft М'яке різьблення Open color-less EXR as ACEScg Відкривати EXR без колірного профілю як ACEScg Do not append "copy" to filename when saving a copy Не додавати «copy» до імені файлу під час збереження копії + Save Full-Size Composite in .pcraft Files Зберігати зведене зображення в повному розмірі у файлах .pcraft # View › Guides › right-click a guide Edit Selected Guides Редагувати вибрані напрямні diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index f348662975..5091594d41 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3793,6 +3793,7 @@ curves Smooth Làm mượt Chisel Soft Đục mềm Open color-less EXR as ACEScg Mở EXR không khai báo màu sắc dưới dạng ACEScg Do not append "copy" to filename when saving a copy Không thêm "copy" vào tên tệp khi lưu bản sao + Save Full-Size Composite in .pcraft Files Lưu ảnh tổng hợp kích thước đầy đủ trong tệp .pcraft # View › Guides › right-click a guide Edit Selected Guides Sửa đường căn đã chọn diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index d9f437214a..112ab6f847 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2981,6 +2981,7 @@ curves Smooth 平滑 Chisel Soft 雕刻柔和 Open color-less EXR as ACEScg 将无色彩空间信息的 EXR 以 ACEScg 打开 Do not append "copy" to filename when saving a copy 存储副本时不在文件名后添加“copy” + Save Full-Size Composite in .pcraft Files 在 .pcraft 文件中存储全尺寸合成图像 # Export As metadata and profile options Color Profile 颜色配置文件 EXIF EXIF diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 9d46dcb21e..d5b9439cd0 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2945,6 +2945,7 @@ curves Smooth 平滑 Chisel Soft 雕刻柔和 Open color-less EXR as ACEScg 將無色彩空間資訊的 EXR 以 ACEScg 開啟 Do not append "copy" to filename when saving a copy 儲存副本時不在檔名後加上「copy」 + Save Full-Size Composite in .pcraft Files 在 .pcraft 檔案中儲存全尺寸合成影像 # Export As metadata and profile options Color Profile 色彩描述檔 EXIF EXIF diff --git a/crates/ui-egui/src/prefs/prefs_ui/fields.rs b/crates/ui-egui/src/prefs/prefs_ui/fields.rs index cd1c867521..36fe4d6627 100644 --- a/crates/ui-egui/src/prefs/prefs_ui/fields.rs +++ b/crates/ui-egui/src/prefs/prefs_ui/fields.rs @@ -63,6 +63,9 @@ pub(super) fn humanize(key: &str) -> String { if key == "doNotAppendCopy" { return "Do not append \"copy\" to filename when saving a copy".into(); } + if key == "pcraftFullComposite" { + return "Save Full-Size Composite in .pcraft Files".into(); + } // Plug-ins: Photoshop's wording (#9392). if key == "generatorEnabled" { return "Enable Generator".into(); diff --git a/crates/ui-egui/src/save_identity_tests.rs b/crates/ui-egui/src/save_identity_tests.rs index 97f252772f..f23a440d4d 100644 --- a/crates/ui-egui/src/save_identity_tests.rs +++ b/crates/ui-egui/src/save_identity_tests.rs @@ -248,6 +248,29 @@ fn maximize_compatibility_preference_reaches_the_exporter() { } } +/// Preferences › File Handling › Save Full-Size Composite in .pcraft Files reaches the exporter +/// of Save As, Save and automation saves, and is off by default. +#[test] +fn full_composite_preference_reaches_the_exporter() { + let seen = Rc::new(RefCell::new(Vec::new())); + let mut app = app(); + assert!(!app.save_settings().pcraft_full_composite, "off by default"); + let log = seen.clone(); + app.services.export = Some(Box::new(move |_, _, settings: &ExportSettings| { + log.borrow_mut().push(settings.io_options().pcraft_full_composite); + Ok((b"out".to_vec(), Vec::new())) + })); + let ctx = egui::Context::default(); + for value in [true, false] { + app.run("prefs.set", json!({"path": "fileHandling.pcraftFullComposite", "value": value})).unwrap(); + seen.borrow_mut().clear(); + menus::invoke(&mut app, &ctx, "file.saveAs", json!({"path": format!("{value}.pcraft")})).unwrap(); + menus::invoke(&mut app, &ctx, "file.save", json!({})).unwrap(); + app.save_automation(Some(format!("{value} agent.pcraft"))).unwrap(); + assert_eq!(*seen.borrow(), [value; 3], "{value}"); + } +} + /// #2399: Save and Save As start at the opened file's own encoding: a JPEG's estimated quality, /// a WebP lossy (at its quality) or lossless; a new document keeps the codec defaults. #[test] diff --git a/crates/ui-egui/src/services.rs b/crates/ui-egui/src/services.rs index d330884a0a..0ad7dd8b6a 100644 --- a/crates/ui-egui/src/services.rs +++ b/crates/ui-egui/src/services.rs @@ -89,6 +89,9 @@ pub struct ExportSettings { /// PSD/PSB: write the real merged image (Preferences › File Handling › Maximize PSD and PSB /// File Compatibility; see [`PhotocraftApp::save_settings`]). pub psd_maximize_compatibility: bool, + /// `.pcraft`: also write the full-size composite (Preferences › File Handling › Save Full-Size + /// Composite in .pcraft Files; see [`PhotocraftApp::save_settings`]). Off by default. + pub pcraft_full_composite: bool, /// Embed the document's ICC profile when the format supports it. On for Save As and, unless /// Export As turns it off, for export. pub embed_icc: bool, @@ -118,6 +121,7 @@ impl Default for ExportSettings { dds: photocraft_io::DdsOptions::default(), image_previews: true, psd_maximize_compatibility: true, + pcraft_full_composite: false, embed_icc: true, embed_metadata: true, document_path: None, @@ -148,6 +152,7 @@ impl ExportSettings { opts.encode.dds = self.dds; opts.image_previews = self.image_previews; opts.psd_maximize_compatibility = self.psd_maximize_compatibility; + opts.pcraft_full_composite = self.pcraft_full_composite; opts.document_path = self.document_path.clone(); opts.pcraft_cache = self.pcraft_cache.clone(); opts.read_linked_files = self.read_linked_files; diff --git a/docs/control-protocol.md b/docs/control-protocol.md index cd8f370ce6..750ef5d22b 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -418,7 +418,7 @@ stdio, and a value that is not a port is a usage error naming the variable. Impl | `doc.exportLayers` | `{dir, format? (png), prefix?, visibleOnly? (true), quality?, index?}` → `{files, skipped}` (plus `count` for workspace exports): one file per exported top-level layer, named like `file.export.layersToFiles` names them. `count` is present only for workspace exports and counts written files only. `skipped` is always an array (empty when nothing is omitted), in top-to-bottom order, of `{layer, name, reason}`: the session layer id, original unsanitized name, and `"hidden by visibleOnly"` or `"adjustment layer has no pixels of its own"`. Visibility takes precedence for hidden adjustments; `visibleOnly:false` exports hidden non-adjustment layers. Groups export merged; their children are not separate exports or skips. Existing filenames/numbering are unchanged. `dir` resolves beneath the write root, so the ambient command stays refused. The bytes come from the command's own core, so both paths write the same files; the write capability refuses to overwrite an existing file. Encode/write failures are errors, not skips | | `doc.exportDataSets` | `{dir, format? (png), dataSets? (all), naming? ("{name}\|{index}\|{document}"), index?}` → `{files, count}`: the capability-scoped `file.export.dataSetsAsFiles`. The batch is planned first and colliding names are refused before any file is written, as in the command | | `doc.importDataSets` | `{path, delimiter? (,), index?}` → `{imported}`: the capability-scoped `file.import.variableDataSets`; the CSV is read beneath the read root | -| `doc.save` | `{path?, format?, quality?, index?, tiffLayers?, tgaBits?, ddsCompression?, ddsMipmaps?}` (`.pcraft` native, else export by extension; set `tiffLayers: true` to preserve TIFF layers rather than exporting a flattened TIFF; `tgaBits` 24 or 32 sets a TGA's bits per pixel, and `ddsCompression`/`ddsMipmaps` a DDS texture's compression and mip chain, as `app.save` does). Without `path` only a PSD, PSB or `.pcraft` document is written back to its own file, in its own format; anything else is an error and the file is left unchanged. A successful layered save (PSD, PSB or `.pcraft`) records the current revision as saved and makes the file the document's path, so `session.list` reports `dirty: false`; a flat export is a copy and leaves the document dirty | +| `doc.save` | `{path?, format?, quality?, index?, tiffLayers?, tgaBits?, ddsCompression?, ddsMipmaps?, pcraftFullComposite?}` (`.pcraft` native, else export by extension; set `tiffLayers: true` to preserve TIFF layers rather than exporting a flattened TIFF; `tgaBits` 24 or 32 sets a TGA's bits per pixel, and `ddsCompression`/`ddsMipmaps` a DDS texture's compression and mip chain, as `app.save` does; `pcraftFullComposite: true` also writes the flattened document at its canvas size into a `.pcraft` file as `composite/full.png`). Without `path` only a PSD, PSB or `.pcraft` document is written back to its own file, in its own format; anything else is an error and the file is left unchanged. A successful layered save (PSD, PSB or `.pcraft`) records the current revision as saved and makes the file the document's path, so `session.list` reports `dirty: false`; a flat export is a copy and leaves the document dirty | | `doc.inspect` | `{index?}`: same JSON as `document.inspect` | | `doc.render` | `{index?, maxSide? (1024; 0 = full), path?}`: PNG to `path`, else `{mime, base64}` | | `doc.select` / `doc.close` | `{index}` / `{index?}` | diff --git a/docs/development.md b/docs/development.md index 337dcbaaf0..bf0bd70102 100644 --- a/docs/development.md +++ b/docs/development.md @@ -242,6 +242,7 @@ cargo run -p photocraft-cli --example man-and-completions -- out/ # man pa - `manifest.json`: the versioned document tree, with migrations in `format/src/migrate.rs`. - `tiles/.zst` and `blobs/.zst`: zstd-compressed objects, content-addressed by the BLAKE3 hash of their uncompressed bytes. - `thumb.png` and `composite/preview.png`: previews. +- `composite/full.png`: the flattened document at the canvas size, for other apps that read `.pcraft` files. Saves write it only when asked (`ExportOptions::pcraft_full_composite`, off by default). - `save.lock` (directory bundles only): an empty file a save holds an OS file lock on, so two writers or processes saving one bundle take turns (#4957). Keep one `PcraftWriter` per open document: re-saving then only compresses and writes tiles that changed. Directory bundles verify objects on first encounter in a folder; later saves reuse them while their file size and modification time are unchanged, and re-verify changed objects, repair missing or damaged objects, and garbage-collect unreferenced ones. `format::Autosaver` writes snapshots into a recovery directory on a background thread. `list_recovery` / `recover` / `discard_recovery` implement crash recovery, and `format::RecoveryStore` is the lifecycle the desktop app uses: new documents autosave under per-launch keys (document ids restart every launch, so they never overwrite an older entry), and a recovered document adopts the entry it came from. That entry is replaced in place by the next autosave and removed only when the document is saved or closed, never just because it was recovered, so a second crash loses nothing. The web build has no crash recovery (no autosave services). diff --git a/docs/scorecard.md b/docs/scorecard.md index 70b08b831b..59f66d1017 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -9,7 +9,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [Performance](#performance) | 3 | 22 | 31 | 65 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 27 not run, 4 not measurable yet (baseline 2026-10-05) | | [File compatibility](#file-compatibility) | 11 | 14 | 2 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 190/258, round trip ≥ 258/258 | | [Tools](#tools) | 36 | 9 | 0 | | -| [Workspace and UI](#workspace-and-ui) | 15 | 6 | 2 | settings that do nothing: 12 of 181 (target 0) | +| [Workspace and UI](#workspace-and-ui) | 15 | 6 | 2 | settings that do nothing: 12 of 182 (target 0) | | [Type](#type) | 5 | 5 | 0 | | | [Automation](#automation) | 5 | 2 | 4 | 631 of 631 menu items live (100.0%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 30 of 30 crates; panic_hunt present | @@ -187,7 +187,7 @@ Corpus floors (`crates/io/tests/it/corpus.rs`): the enforced minimums; the corpu ## Workspace and UI -**Settings that do nothing: 12 of 181** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. +**Settings that do nothing: 12 of 182** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. Unread: `general.color_picker`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `performance.gpu_adapter`, `performance.legacy_compositing`, `scratch_disks.disks`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `type_.text_engine`, `enhanced_controls.scrubby_slider_acceleration`. From ddd11aaa9a8de13dd6b7727508323c6c20834681 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 27 Oct 2026 22:02:30 -0700 Subject: [PATCH 015/216] Format: a full-size composite that fails is left out, and pcraftFullComposite takes only booleans - The PNG encoder gets a fallibly reserved copy of the composite's pixels instead of a clone, and a full-size composite that cannot be copied or encoded is left out of the ZIP or directory bundle and out of its manifest; the save goes on without it. - The ZIP size limit for keeping the full-size composite counts the central directory and end record as well as the entries. - `pcraftFullComposite` that is not true, false or null is a bad parameter of `file.saveACopy`, the other engine saves and the automation `doc.save`. - MCP `doc_save` in bridge mode refuses `pcraftFullComposite: false` as well as `true`. The app's `app.save` does not take the option. --- book/src/formats/pcraft.md | 2 +- crates/automation/src/rpc.rs | 4 +- crates/automation/src/server.rs | 12 ++-- crates/automation/tests/it/mcp.rs | 2 + .../tests/it/native_full_composite.rs | 5 ++ crates/engine/src/cmd/file_cmds.rs | 4 +- crates/engine/src/cmd/file_cmds/save.rs | 14 ++++- crates/engine/src/cmd/file_cmds/tests.rs | 9 +++ crates/format/src/lib.rs | 3 +- crates/format/src/store.rs | 62 ++++++++++++++----- 10 files changed, 93 insertions(+), 24 deletions(-) diff --git a/book/src/formats/pcraft.md b/book/src/formats/pcraft.md index 46a3dc553c..43abc19840 100644 --- a/book/src/formats/pcraft.md +++ b/book/src/formats/pcraft.md @@ -14,7 +14,7 @@ save.lock `thumb.png` (at most 256 px on the longer side), `composite/preview.png` (at most 1024 px) and `composite/full.png` (the canvas size) are the flattened document as 8-bit RGBA PNGs, whatever the document's depth. The manifest names each one it holds, under `thumbnail`, `composite` and `full_composite`. -A save writes `composite/full.png` only when it is asked to: *Save Full-Size Composite in .pcraft Files* in Preferences › File Handling for the app's saves, `"pcraftFullComposite": true` for the engine's `file.saveACopy` and its batch and export commands, and for the automation `doc.save` and MCP `doc_save`, and `--pcraft-full-composite` for `photocraft-cli convert`, `run` and `batch`. It is off by default. `composite/full.png` embeds the ICC profile of its pixels: an RGB document's own profile, a grayscale document's gray profile as RGB, and sRGB for CMYK, Lab and documents without a usable profile. A save leaves it out when the canvas is larger than 2^30 pixels, when its pixels cannot be allocated, or when a ZIP bundle would pass 4 GiB or 65,535 entries with it. A directory bundle saved without it has the file from an earlier save removed. +A save writes `composite/full.png` only when it is asked to: *Save Full-Size Composite in .pcraft Files* in Preferences › File Handling for the app's saves, `"pcraftFullComposite": true` for the engine's `file.saveACopy` and its batch and export commands, and for the automation `doc.save` and MCP `doc_save`, and `--pcraft-full-composite` for `photocraft-cli convert`, `run` and `batch`. It is off by default. `composite/full.png` embeds the ICC profile of its pixels: an RGB document's own profile, a grayscale document's gray profile as RGB, and sRGB for CMYK, Lab and documents without a usable profile. A save leaves it out when the canvas is larger than 2^30 pixels, when its pixels cannot be allocated or encoded, or when a ZIP bundle would pass 4 GiB or 65,535 entries with it. A directory bundle saved without it has the file from an earlier save removed. `save.lock` exists only in directory bundles: a save holds an OS file lock on it while it writes objects, publishes the manifest and removes unreferenced objects, so concurrent saves of one bundle take turns. It is empty, and readers ignore it. diff --git a/crates/automation/src/rpc.rs b/crates/automation/src/rpc.rs index db0807fb24..7fb840c981 100644 --- a/crates/automation/src/rpc.rs +++ b/crates/automation/src/rpc.rs @@ -126,7 +126,9 @@ impl Headless { let mut opts = photocraft_io::ExportOptions { tiff_layers: p.get("tiffLayers").and_then(Value::as_bool).unwrap_or(false), tga_bits, - pcraft_full_composite: p.get("pcraftFullComposite").and_then(Value::as_bool).unwrap_or(false), + pcraft_full_composite: photocraft_engine::file_cmds::pcraft_full_composite_param(&p, "doc.save") + .map_err(|e| bad(e.to_string()))? + .unwrap_or(false), ..Default::default() }; opts.encode.dpx_bits = photocraft_engine::file_cmds::dpx_bits_param(&p, "doc.save").map_err(|e| bad(e.to_string()))?; diff --git a/crates/automation/src/server.rs b/crates/automation/src/server.rs index 11acba25d9..b33ed713ad 100644 --- a/crates/automation/src/server.rs +++ b/crates/automation/src/server.rs @@ -194,7 +194,7 @@ pub struct SaveParams { /// .pcraft: also write the flattened document at its canvas size as `composite/full.png`. /// Off by default. #[serde(default, rename = "pcraftFullComposite")] - pub pcraft_full_composite: bool, + pub pcraft_full_composite: Option, #[serde(default)] pub index: Option, } @@ -1081,7 +1081,7 @@ impl PhotocraftMcp { p.format.is_some().then_some("format"), p.quality.is_some().then_some("quality"), p.tiff_layers.then_some("tiffLayers"), - p.pcraft_full_composite.then_some("pcraftFullComposite"), + p.pcraft_full_composite.is_some().then_some("pcraftFullComposite"), dpx_bits.is_some().then_some("dpxBits"), p.index.is_some().then_some("index"), ] @@ -1108,8 +1108,12 @@ impl PhotocraftMcp { } let Some(r) = self .headless_op(move |h| { - let mut opts = - photocraft_io::ExportOptions { tiff_layers: p.tiff_layers, tga_bits, pcraft_full_composite: p.pcraft_full_composite, ..Default::default() }; + let mut opts = photocraft_io::ExportOptions { + tiff_layers: p.tiff_layers, + tga_bits, + pcraft_full_composite: p.pcraft_full_composite.unwrap_or(false), + ..Default::default() + }; opts.encode.dpx_bits = dpx_bits; opts.encode.dds = photocraft_io::DdsOptions { compression: dds_compression.unwrap_or_default(), mipmaps: p.dds_mipmaps }; if let Some(q) = p.quality { diff --git a/crates/automation/tests/it/mcp.rs b/crates/automation/tests/it/mcp.rs index 0ac5c17a5f..449eb24179 100644 --- a/crates/automation/tests/it/mcp.rs +++ b/crates/automation/tests/it/mcp.rs @@ -356,6 +356,8 @@ async fn bridge_doc_save_rejects_headless_only_options_instead_of_saving_with_de json!({"path": "out.jpg", "quality": 50}), json!({"path": "out.png", "format": "png"}), json!({"path": "out.tif", "tiffLayers": true}), + json!({"path": "out.pcraft", "pcraftFullComposite": true}), + json!({"path": "out.pcraft", "pcraftFullComposite": false}), json!({"path": "out.pcraft", "index": 0}), ] { let r = call(&client, "doc_save", params.clone()).await; diff --git a/crates/automation/tests/it/native_full_composite.rs b/crates/automation/tests/it/native_full_composite.rs index fea554c19d..c3cff93874 100644 --- a/crates/automation/tests/it/native_full_composite.rs +++ b/crates/automation/tests/it/native_full_composite.rs @@ -41,5 +41,10 @@ fn scripted_native_saves_write_the_full_size_composite_when_asked() { assert!(!has_full_composite(&std::fs::read(&path).unwrap()), "off by default"); headless.handle("doc.save", json!({"path": path.to_str().unwrap(), "pcraftFullComposite": true})).unwrap(); assert_eq!(full_composite(&std::fs::read(&path).unwrap()), ((40, 30), flattened)); + for bad in [json!("true"), json!(1)] { + let err = headless.handle("doc.save", json!({"path": dir.join("Bad.pcraft").to_str().unwrap(), "pcraftFullComposite": bad})).unwrap_err(); + assert!(format!("{err:?}").contains("`pcraftFullComposite` must be true or false"), "{bad}: {err:?}"); + assert!(!dir.join("Bad.pcraft").exists(), "{bad}: nothing is written"); + } std::fs::remove_dir_all(dir).unwrap(); } diff --git a/crates/engine/src/cmd/file_cmds.rs b/crates/engine/src/cmd/file_cmds.rs index 8291b061ff..7637f70286 100644 --- a/crates/engine/src/cmd/file_cmds.rs +++ b/crates/engine/src/cmd/file_cmds.rs @@ -50,7 +50,9 @@ pub(crate) use paths::{file_name, forward_slashes, is_windows_device_name, join, use processing::{conditional_mode_change, fit_image, flatten_all_effects, flatten_all_masks, image_processor, load_files_into_stack}; use save::save_a_copy; pub(crate) use save::{SaveOpts, encode, save_doc}; -pub use save::{dds_param, dpx_bits_param, jpeg_format_param, jpeg_quality, tga_bits_param, tiff_compression_param, tiff_would_write_layers}; +pub use save::{ + dds_param, dpx_bits_param, jpeg_format_param, jpeg_quality, pcraft_full_composite_param, tga_bits_param, tiff_compression_param, tiff_would_write_layers, +}; mod batch_naming; diff --git a/crates/engine/src/cmd/file_cmds/save.rs b/crates/engine/src/cmd/file_cmds/save.rs index c117562465..89410a6700 100644 --- a/crates/engine/src/cmd/file_cmds/save.rs +++ b/crates/engine/src/cmd/file_cmds/save.rs @@ -75,7 +75,7 @@ impl SaveOpts { dds: dds_param(p, cmd)?.unwrap_or_default(), image_previews: file_handling.image_previews != crate::prefs::Ask::Never, psd_maximize_compatibility: file_handling.maximize_psd_compatibility != crate::prefs::Ask::Never, - pcraft_full_composite: p.get("pcraftFullComposite").and_then(Value::as_bool).unwrap_or(file_handling.pcraft_full_composite), + pcraft_full_composite: pcraft_full_composite_param(p, cmd)?.unwrap_or(file_handling.pcraft_full_composite), }) } @@ -105,6 +105,18 @@ pub fn tga_bits_param(p: &Value, cmd: &str) -> Result Result> { + match p.get("pcraftFullComposite") { + None | Some(Value::Null) => Ok(None), + Some(v) => v + .as_bool() + .map(Some) + .ok_or_else(|| EngineError::BadParams { cmd: cmd.into(), msg: format!("`pcraftFullComposite` must be true or false, got {v}") }), + } +} + /// The TIFF compression in `p` (`"tiffCompression": "none" | "lzw" | "zip"`, Photoshop's TIFF /// Options; absent or null: none). Anything else is a bad parameter of command `cmd` (#5301). pub fn tiff_compression_param(p: &Value, cmd: &str) -> Result> { diff --git a/crates/engine/src/cmd/file_cmds/tests.rs b/crates/engine/src/cmd/file_cmds/tests.rs index eae4793d91..7ff1023b2a 100644 --- a/crates/engine/src/cmd/file_cmds/tests.rs +++ b/crates/engine/src/cmd/file_cmds/tests.rs @@ -476,6 +476,15 @@ fn full_composite_preference_controls_the_pcraft_full_size_composite() { s.execute("file.saveACopy", json!({"path": out, "pcraftFullComposite": !on})).unwrap(); assert_eq!(full(&out), !on, "preference {on}, param {}", !on); } + let out = join(&dir, "null param.pcraft"); + s.execute("file.saveACopy", json!({"path": out, "pcraftFullComposite": null})).unwrap(); + assert!(!full(&out), "null takes the preference"); + for bad in [json!("true"), json!(1)] { + let out = join(&dir, "bad param.pcraft"); + let err = s.execute("file.saveACopy", json!({"path": out, "pcraftFullComposite": bad})).unwrap_err().to_string(); + assert!(err.contains("`pcraftFullComposite` must be true or false"), "{bad}: {err}"); + assert!(!std::path::Path::new(&out).exists(), "{bad}: nothing is written"); + } } #[test] diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 20bd4d95ae..6e492cdd82 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -86,7 +86,8 @@ pub struct SaveOptions { pub thumbnail: Option, pub composite: Option, /// The flattened document at its canvas size, for other apps that read `.pcraft` files. A ZIP - /// save leaves it out when it would take the archive past 4 GiB or 65,535 entries. + /// save leaves it out when it would take the archive past 4 GiB or 65,535 entries, and every + /// save leaves it out when it cannot be encoded. pub full_composite: Option, /// The ICC profile of `full_composite`'s pixels, embedded in its PNG. pub full_composite_icc: Option>, diff --git a/crates/format/src/store.rs b/crates/format/src/store.rs index 20e8f33551..4c750afd98 100644 --- a/crates/format/src/store.rs +++ b/crates/format/src/store.rs @@ -19,7 +19,7 @@ pub(crate) const MANIFEST: &str = "manifest.json"; pub(crate) const THUMB: &str = "thumb.png"; pub(crate) const COMPOSITE: &str = "composite/preview.png"; pub(crate) const FULL_COMPOSITE: &str = "composite/full.png"; -/// Bytes a ZIP bundle may hold before its central directory, and entries it may hold: the +/// Bytes a ZIP bundle may hold, central directory included, and entries it may hold: the /// format's 32-bit offsets and 16-bit counts ([`ZipWriter`] has no ZIP64). const ZIP_LIMIT: (u64, usize) = (u32::MAX as u64, u16::MAX as usize); /// The file a directory save holds an exclusive lock on for its whole transaction (#4957). @@ -68,7 +68,10 @@ fn png(img: &Rgba8Image) -> Result> { /// [`png`] with `icc` embedded. fn png_tagged(img: &Rgba8Image, icc: Option<&[u8]>) -> Result> { - let image = photocraft_codecs::Image::from_u8(img.width, img.height, photocraft_codecs::ChannelLayout::Rgba, img.pixels.clone()) + let mut pixels = Vec::new(); + pixels.try_reserve_exact(img.pixels.len()).map_err(|_| FormatError::LimitExceeded("not enough memory for preview".into()))?; + pixels.extend_from_slice(&img.pixels); + let image = photocraft_codecs::Image::from_u8(img.width, img.height, photocraft_codecs::ChannelLayout::Rgba, pixels) .map_err(|e| FormatError::Unsupported(format!("preview: {e}")))? .with_icc(icc.map(<[u8]>::to_vec)); photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).map_err(|e| FormatError::Unsupported(format!("preview: {e}"))) @@ -93,7 +96,8 @@ fn previews(opts: &SaveOptions) -> Result)>> { if let Some(t) = composite? { out.push((COMPOSITE, t)); } - if let Some(t) = full? { + // A full-size composite that cannot be encoded is left out; the save goes on without it. + if let Ok(Some(t)) = full { out.push((FULL_COMPOSITE, t)); } Ok(out) @@ -546,6 +550,18 @@ struct Prepared { stats: SaveStats, } +impl Prepared { + /// Takes the full-size composite out of the manifest when `previews` does not hold it. + fn match_previews(&mut self, previews: &[(&str, Vec)]) -> Result<()> { + if self.model.full_composite.is_some() && !previews.iter().any(|(name, _)| *name == FULL_COMPOSITE) { + self.model.full_composite = None; + self.manifest = manifest_json(&self.model)?; + self.stats.manifest_bytes = self.manifest.len(); + } + Ok(()) + } +} + enum Object { Tile(Arc, SampleType), Blob(Arc>), @@ -985,8 +1001,9 @@ impl PcraftWriter { Ok((p, fresh)) }, ); - let (p, mut fresh) = prepared?; + let (mut p, mut fresh) = prepared?; let mut previews = previews?; + p.match_previews(&previews)?; let mut stats = p.stats; let mut entries = Vec::with_capacity(p.objects.len()); for (path, obj) in &p.objects { @@ -1007,10 +1024,12 @@ impl PcraftWriter { }; entries.push((path, key, data)); } - // A STORE entry takes a 30-byte local header plus its name and data before the central - // directory. The full-size composite is left out rather than fail a save that fits without it. - let stored = |name: &str, len: usize| 30 + name.len() as u64 + len as u64; - let total = stored(MANIFEST, p.manifest.len()) + // A STORE entry takes a 30-byte local header plus its name and data, and a 46-byte central + // directory record plus its name again; the archive ends with a 22-byte end record. The + // full-size composite is left out rather than fail a save that fits without it. + let stored = |name: &str, len: usize| 76 + 2 * name.len() as u64 + len as u64; + let total = 22 + + stored(MANIFEST, p.manifest.len()) + previews.iter().map(|(name, data)| stored(name, data.len())).sum::() + entries.iter().map(|(path, _, data)| stored(path, data.len())).sum::(); let mut manifest = p.manifest; @@ -1066,8 +1085,9 @@ impl PcraftWriter { for path in [MANIFEST, THUMB, COMPOSITE, FULL_COMPOSITE] { validate_bundle_file(dir, &dir.join(path))?; } - let p = self.prepare(doc, opts)?; + let mut p = self.prepare(doc, opts)?; let previews = previews(opts)?; + p.match_previews(&previews)?; let mut stats = p.stats; if self.verified_directory.as_ref().is_none_or(|cache| cache.directory != canonical_dir) { self.verified_directory = Some(DirectoryVerificationCache { directory: canonical_dir.clone(), ..Default::default() }); @@ -1327,8 +1347,21 @@ fn previews_follow_the_manifest_with_the_bytes_of_a_serial_encode() { assert!(matches!(PcraftWriter::new().save_zip(&doc, &bad), Err(FormatError::Unsupported(m)) if m.starts_with("preview"))); let bad = SaveOptions { thumbnail: Some(thumb.clone()), composite: Some(Rgba8Image { width: 9, height: 5, pixels: vec![0; 5] }), ..Default::default() }; assert!(matches!(PcraftWriter::new().save_zip(&doc, &bad), Err(FormatError::Unsupported(m)) if m.starts_with("preview"))); - let bad = SaveOptions { full_composite: Some(Rgba8Image { width: 600, height: 3, pixels: vec![0; 5] }), ..Default::default() }; - assert!(matches!(PcraftWriter::new().save_zip(&doc, &bad), Err(FormatError::Unsupported(m)) if m.starts_with("preview"))); + // A full-size composite that cannot be encoded is left out, and the save goes on without it. + let bad = + SaveOptions { thumbnail: Some(thumb.clone()), full_composite: Some(Rgba8Image { width: 600, height: 3, pixels: vec![0; 5] }), ..Default::default() }; + let (bytes, stats) = PcraftWriter::new().save_zip(&doc, &bad).unwrap(); + let zip = ZipReader::new(&bytes).unwrap(); + assert!(zip.read_by_name(FULL_COMPOSITE, usize::MAX).is_err()); + assert_eq!(zip.read_by_name(THUMB, usize::MAX).unwrap(), png(&thumb).unwrap()); + assert_eq!(crate::read_manifest(&bytes).unwrap().full_composite, None); + assert_eq!(stats.manifest_bytes, zip.read_by_name(MANIFEST, usize::MAX).unwrap().len()); + let dir = std::env::temp_dir().join(format!("pcraft-bad-full-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + PcraftWriter::new().save_dir(&doc, &dir, &bad).unwrap(); + assert!(!dir.join(FULL_COMPOSITE).exists()); + assert!(!std::fs::read_to_string(dir.join(MANIFEST)).unwrap().contains("full_composite")); + std::fs::remove_dir_all(&dir).unwrap(); let dir = std::env::temp_dir().join(format!("pcraft-previews-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); @@ -1364,11 +1397,10 @@ fn zip_save_leaves_the_full_composite_out_past_the_zip_limit() { ..Default::default() }; let (whole, _) = PcraftWriter::new().save_zip(&doc, &opts).unwrap(); - // The archive's entries without the central directory, and how many there are: what the + // The whole archive, central directory included, and how many entries it holds: what the // limit compares. - let archive = ZipReader::new(&whole).unwrap(); - let bytes = archive.entries.iter().map(|e| 30 + e.name.len() as u64 + archive.read(e, usize::MAX).unwrap().len() as u64).sum::(); - let count = archive.entries.len(); + let bytes = whole.len() as u64; + let count = ZipReader::new(&whole).unwrap().entries.len(); let (fits, _) = PcraftWriter::new().save_zip_within(&doc, &opts, (bytes, count)).unwrap(); assert_eq!(fits, whole, "an archive at the limit keeps the composite"); for limit in [(bytes - 1, count), (bytes, count - 1)] { From 78982a38e212288ee82b62ad1ac08641d25372e9 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 30 Oct 2026 06:29:05 -0700 Subject: [PATCH 016/216] Scorecard: count the full-size composite preference after the merge of main --- docs/scorecard.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/scorecard.md b/docs/scorecard.md index 73e1bca325..233167b956 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -9,7 +9,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [Performance](#performance) | 3 | 22 | 45 | 79 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 41 not run, 4 not measurable yet (baseline 2026-10-05) | | [File compatibility](#file-compatibility) | 11 | 14 | 2 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 190/258, round trip ≥ 258/258 | | [Tools](#tools) | 36 | 9 | 0 | | -| [Workspace and UI](#workspace-and-ui) | 15 | 6 | 2 | settings that do nothing: 12 of 182 (target 0) | +| [Workspace and UI](#workspace-and-ui) | 15 | 6 | 2 | settings that do nothing: 12 of 183 (target 0) | | [Type](#type) | 5 | 5 | 0 | | | [Automation](#automation) | 5 | 2 | 4 | 631 of 631 menu items live (100.0%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 30 of 30 crates; panic_hunt present | @@ -203,7 +203,7 @@ Corpus floors (`crates/io/tests/it/corpus.rs`): the enforced minimums; the corpu ## Workspace and UI -**Settings that do nothing: 12 of 182** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. +**Settings that do nothing: 12 of 183** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. Unread: `general.color_picker`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `performance.gpu_adapter`, `performance.legacy_compositing`, `scratch_disks.disks`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `type_.text_engine`, `enhanced_controls.scrubby_slider_acceleration`. From 69912e603575616748a0b67d8f518b6d8c6231b2 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 30 Oct 2026 06:46:33 -0700 Subject: [PATCH 017/216] Format: the full-size composite stays within 2^26 pixels, and running out of memory leaves it out A canvas over 2^26 pixels (256 MiB of RGBA8) is saved without composite/full.png. The PNG encoder's buffers are reserved fallibly, so an allocation failure reports an encode error, and a ZIP bundle that cannot be reserved with the composite is written without it. --- book/src/formats/pcraft.md | 2 +- crates/codecs/src/codecs/png.rs | 39 ++++++++++++++++----- crates/format/src/store.rs | 19 ++++++++-- crates/io/src/lib.rs | 7 ++-- crates/io/tests/it/pcraft_full_composite.rs | 11 ++++++ 5 files changed, 63 insertions(+), 15 deletions(-) diff --git a/book/src/formats/pcraft.md b/book/src/formats/pcraft.md index 43abc19840..8a0e40a5a2 100644 --- a/book/src/formats/pcraft.md +++ b/book/src/formats/pcraft.md @@ -14,7 +14,7 @@ save.lock `thumb.png` (at most 256 px on the longer side), `composite/preview.png` (at most 1024 px) and `composite/full.png` (the canvas size) are the flattened document as 8-bit RGBA PNGs, whatever the document's depth. The manifest names each one it holds, under `thumbnail`, `composite` and `full_composite`. -A save writes `composite/full.png` only when it is asked to: *Save Full-Size Composite in .pcraft Files* in Preferences › File Handling for the app's saves, `"pcraftFullComposite": true` for the engine's `file.saveACopy` and its batch and export commands, and for the automation `doc.save` and MCP `doc_save`, and `--pcraft-full-composite` for `photocraft-cli convert`, `run` and `batch`. It is off by default. `composite/full.png` embeds the ICC profile of its pixels: an RGB document's own profile, a grayscale document's gray profile as RGB, and sRGB for CMYK, Lab and documents without a usable profile. A save leaves it out when the canvas is larger than 2^30 pixels, when its pixels cannot be allocated or encoded, or when a ZIP bundle would pass 4 GiB or 65,535 entries with it. A directory bundle saved without it has the file from an earlier save removed. +A save writes `composite/full.png` only when it is asked to: *Save Full-Size Composite in .pcraft Files* in Preferences › File Handling for the app's saves, `"pcraftFullComposite": true` for the engine's `file.saveACopy` and its batch and export commands, and for the automation `doc.save` and MCP `doc_save`, and `--pcraft-full-composite` for `photocraft-cli convert`, `run` and `batch`. It is off by default. `composite/full.png` embeds the ICC profile of its pixels: an RGB document's own profile, a grayscale document's gray profile as RGB, and sRGB for CMYK, Lab and documents without a usable profile. A save leaves it out when the canvas is larger than 2^26 pixels, when its pixels cannot be allocated or encoded, or when a ZIP bundle would pass 4 GiB or 65,535 entries with it. A directory bundle saved without it has the file from an earlier save removed. `save.lock` exists only in directory bundles: a save holds an OS file lock on it while it writes objects, publishes the manifest and removes unreferenced objects, so concurrent saves of one bundle take turns. It is empty, and readers ignore it. diff --git a/crates/codecs/src/codecs/png.rs b/crates/codecs/src/codecs/png.rs index e119e22548..1d91604abf 100644 --- a/crates/codecs/src/codecs/png.rs +++ b/crates/codecs/src/codecs/png.rs @@ -1,7 +1,7 @@ //! PNG via the `png` crate directly: 8/16-bit, Adam7 read (and write), //! iCCP, eXIf, tEXt/zTXt/iTXt (XMP in `XML:com.adobe.xmp`), pHYs. -use std::io::{Cursor, Write}; +use std::io::{self, Cursor, Write}; use crate::Format; use crate::error::CodecError; @@ -303,6 +303,21 @@ fn is_latin1_keyword(k: &str) -> bool { !k.is_empty() && k.len() <= 79 && k.chars().all(|c| (' '..='~').contains(&c) || ('\u{a1}'..='\u{ff}').contains(&c)) } +/// Makes the PNG writer's output growth report an encode error on allocation failure. +struct FallibleOutput<'a>(&'a mut Vec); + +impl Write for FallibleOutput<'_> { + fn write(&mut self, bytes: &[u8]) -> io::Result { + self.0.try_reserve(bytes.len()).map_err(|_| io::Error::other("not enough memory for PNG output"))?; + self.0.extend_from_slice(bytes); + Ok(bytes.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result, CodecError> { let img = src.converted(plan.layout, plan.sample); let (w, h) = img.dimensions(); @@ -314,7 +329,8 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result PngRows { let prev = (!self.prev.is_empty()).then_some(self.prev.as_slice()); let parts = deflate_rows(rows, prev, stride, bpp, per_band, deflate_level(self.level), finish, encode_threads()) .ok_or_else(|| CodecError::encode(F, "a band of rows could not be compressed"))?; - let mut zlib = Vec::with_capacity(parts.iter().map(|p| p.z.len()).sum::() + 6); + let mut zlib = Vec::new(); + zlib.try_reserve_exact(parts.iter().map(|p| p.z.len()).sum::() + 6).map_err(|_| CodecError::encode(F, "not enough memory for PNG data"))?; if !self.started { zlib.extend_from_slice(&zlib_header(self.level)); self.started = true; @@ -530,7 +547,8 @@ impl PngRows { fn adam7_idat(data: &[u8], w: usize, h: usize, bpp: usize, level: PngCompression) -> Result, CodecError> { // (x0, y0, dx, dy) per pass. const PASSES: [(usize, usize, usize, usize); 7] = [(0, 0, 8, 8), (4, 0, 8, 8), (0, 4, 4, 8), (2, 0, 4, 4), (0, 2, 2, 4), (1, 0, 2, 2), (0, 1, 1, 2)]; - let mut raw = Vec::with_capacity(data.len() + h * 7); + let mut raw = Vec::new(); + raw.try_reserve_exact(data.len() + h * 7).map_err(|_| CodecError::encode(F, "not enough memory for PNG rows"))?; for (x0, y0, dx, dy) in PASSES { if x0 >= w || y0 >= h { continue; @@ -611,7 +629,8 @@ fn encode_threads() -> usize { fn banded_idat(data: &[u8], w: usize, h: usize, bpp: usize, level: PngCompression, threads: usize) -> Option> { let stride = w * bpp; let parts = deflate_rows(data.get(..h * stride)?, None, stride, bpp, band_rows(stride), deflate_level(level), true, threads)?; - let mut out = Vec::with_capacity(parts.iter().map(|p| p.z.len()).sum::() + 6); + let mut out = Vec::new(); + out.try_reserve_exact(parts.iter().map(|p| p.z.len()).sum::() + 6).ok()?; out.extend_from_slice(&zlib_header(level)); let mut adler = 1u32; for p in &parts { @@ -648,8 +667,11 @@ fn deflate_rows( let h = rows.len() / stride.max(1); let bands: Vec<(usize, usize)> = (0..h).step_by(band_rows.max(1)).map(|y| (y, (y + band_rows).min(h))).collect(); let band = |&(y0, y1): &(usize, usize)| -> Option { - let mut filtered = Vec::with_capacity((y1 - y0) * (stride + 1)); - let mut scratch = vec![0u8; stride]; + let mut filtered = Vec::new(); + filtered.try_reserve_exact((y1 - y0) * (stride + 1)).ok()?; + let mut scratch = Vec::new(); + scratch.try_reserve_exact(stride).ok()?; + scratch.resize(stride, 0); for y in y0..y1 { let row = rows.get(y * stride..(y + 1) * stride)?; let above = if y > 0 { rows.get((y - 1) * stride..y * stride) } else { prev }; @@ -696,7 +718,8 @@ fn deflate_rows( fn deflate_band(filtered: &[u8], lvl: flate2::Compression, last: bool) -> Option> { let mut c = flate2::Compress::new(lvl, false); // Stored blocks cost 5 bytes per 64 KiB, the flush an empty stored block: 1/8 is ample. - let mut z = Vec::with_capacity(filtered.len() + filtered.len() / 8 + 1024); + let mut z = Vec::new(); + z.try_reserve_exact(filtered.len() + filtered.len() / 8 + 1024).ok()?; let flush = if last { flate2::FlushCompress::Finish } else { flate2::FlushCompress::Sync }; let status = c.compress_vec(filtered, &mut z, flush).ok()?; let done = if last { status == flate2::Status::StreamEnd } else { c.total_in() as usize == filtered.len() && z.len() < z.capacity() }; diff --git a/crates/format/src/store.rs b/crates/format/src/store.rs index 4c750afd98..cb2fcc5b35 100644 --- a/crates/format/src/store.rs +++ b/crates/format/src/store.rs @@ -69,7 +69,7 @@ fn png(img: &Rgba8Image) -> Result> { /// [`png`] with `icc` embedded. fn png_tagged(img: &Rgba8Image, icc: Option<&[u8]>) -> Result> { let mut pixels = Vec::new(); - pixels.try_reserve_exact(img.pixels.len()).map_err(|_| FormatError::LimitExceeded("not enough memory for preview".into()))?; + pixels.try_reserve_exact(img.pixels.len()).map_err(|_| FormatError::LimitExceeded("not enough memory for PNG pixels".into()))?; pixels.extend_from_slice(&img.pixels); let image = photocraft_codecs::Image::from_u8(img.width, img.height, photocraft_codecs::ChannelLayout::Rgba, pixels) .map_err(|e| FormatError::Unsupported(format!("preview: {e}")))? @@ -1033,11 +1033,11 @@ impl PcraftWriter { + previews.iter().map(|(name, data)| stored(name, data.len())).sum::() + entries.iter().map(|(path, _, data)| stored(path, data.len())).sum::(); let mut manifest = p.manifest; + let mut model = p.model; if (total > limit.0 || 1 + previews.len() + entries.len() > limit.1) && let Some(at) = previews.iter().position(|(name, _)| *name == FULL_COMPOSITE) { previews.remove(at); - let mut model = p.model; model.full_composite = None; manifest = manifest_json(&model)?; stats.manifest_bytes = manifest.len(); @@ -1047,7 +1047,20 @@ impl PcraftWriter { let sizes = std::iter::once((MANIFEST, manifest.len())) .chain(previews.iter().map(|(name, data)| (*name, data.len()))) .chain(entries.iter().map(|(path, _, data)| (path.as_str(), data.len()))); - let mut z = ZipWriter::with_entries(sizes)?; + let mut z = match ZipWriter::with_entries(sizes) { + Ok(z) => z, + Err(FormatError::LimitExceeded(_)) if previews.iter().any(|(name, _)| *name == FULL_COMPOSITE) => { + previews.retain(|(name, _)| *name != FULL_COMPOSITE); + model.full_composite = None; + manifest = manifest_json(&model)?; + stats.manifest_bytes = manifest.len(); + let sizes = std::iter::once((MANIFEST, manifest.len())) + .chain(previews.iter().map(|(name, data)| (*name, data.len()))) + .chain(entries.iter().map(|(path, _, data)| (path.as_str(), data.len()))); + ZipWriter::with_entries(sizes)? + } + Err(e) => return Err(e), + }; z.add(MANIFEST, &manifest)?; for (name, data) in &previews { z.add(name, data)?; diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 5f0941fab1..a7cf4c1edd 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -573,6 +573,8 @@ pub(crate) fn with_span(ctl: &photocraft_raster::Interrupt, lo: f32, hi: f32, const PCRAFT_THUMBNAIL_DONE: f32 = 0.15; const PCRAFT_PREVIEWS_DONE: f32 = 0.4; const PCRAFT_FULL_COMPOSITE_DONE: f32 = 0.6; +/// Bounds the optional full-size RGBA8 composite to 256 MiB before PNG and ZIP encoding. +const PCRAFT_FULL_COMPOSITE_PIXELS: u64 = 1 << 26; /// The share of a PSD/PSB export spent converting the document (layers, then the composite) /// before its bytes are assembled: about 2.7 s of 3.0 s for a 24 MP, 5-layer RGB/8 document. const PSD_MODEL_DONE: f32 = 0.9; @@ -660,8 +662,7 @@ fn export_stages(doc: &Document, name_or_ext: &str, opts: &ExportOptions, ctl: & /// The flattened document at its canvas size, in 8-bit RGBA like a `.pcraft` file's two previews, /// for the bundle's `composite/full.png`. `None` for a canvas past -/// [`photocraft_compose::MAX_RENDER_PIXELS`], or when its pixels cannot be allocated (a 32-bit -/// target holds less than 2 GiB in one buffer). +/// 2^26 pixels, or when its pixels cannot be allocated. pub fn pcraft_full_composite(doc: &Document) -> Option { pcraft_full_composite_with(doc, &photocraft_raster::Interrupt::NONE).ok().flatten() } @@ -683,7 +684,7 @@ pub fn pcraft_full_composite_icc(doc: &Document) -> Vec { /// cancelled render fails with [`IoError::Cancelled`]. fn pcraft_full_composite_with(doc: &Document, ctl: &photocraft_raster::Interrupt) -> Result, IoError> { let canvas = doc.bounds(); - if canvas.is_empty() || u64::from(canvas.width()) * u64::from(canvas.height()) > photocraft_compose::MAX_RENDER_PIXELS { + if canvas.is_empty() || u64::from(canvas.width()) * u64::from(canvas.height()) > PCRAFT_FULL_COMPOSITE_PIXELS { return Ok(None); } let Ok(len) = usize::try_from(u64::from(canvas.width()) * u64::from(canvas.height()) * 4) else { return Ok(None) }; diff --git a/crates/io/tests/it/pcraft_full_composite.rs b/crates/io/tests/it/pcraft_full_composite.rs index f0710f7878..260cf893b9 100644 --- a/crates/io/tests/it/pcraft_full_composite.rs +++ b/crates/io/tests/it/pcraft_full_composite.rs @@ -129,3 +129,14 @@ fn a_canvas_past_the_render_limit_is_saved_without_it() { assert_eq!(photocraft_format::read_manifest(&bytes).expect("manifest").full_composite, None); assert!(ZipReader::new(&bytes).expect("zip").find("composite/full.png").is_none()); } + +#[test] +fn a_sparse_canvas_past_the_composite_memory_budget_is_saved_without_it() { + // The layer data stays tiny; an 8193 x 8192 RGBA8 composite exceeds 256 MiB. + let mut d = doc(64, 64, SampleType::U8); + d.size = Size::new(8193, 8192); + assert!(photocraft_io::pcraft_full_composite(&d).is_none()); + let bytes = export(&d, "a.pcraft", &on()).expect("export").bytes; + assert_eq!(photocraft_format::read_manifest(&bytes).expect("manifest").full_composite, None); + assert!(ZipReader::new(&bytes).expect("zip").find("composite/full.png").is_none()); +} From f121865ffaf8778f9c349bbc622fb059b1ada381 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 30 Oct 2026 08:20:23 -0700 Subject: [PATCH 018/216] Docs: a .pcraft save can leave the full-size composite out The doc.save row, docs/development.md, the MCP doc_save description, the file.saveACopy params text, the --pcraft-full-composite help and the option's doc comments said the option writes composite/full.png. A save leaves it out when the canvas is past 2^26 pixels, when its pixels cannot be allocated or encoded, or when a ZIP bundle would pass its limits. They now say so and point to the manifest's full_composite member, which names the entry when it was written. --- apps/photocraft-cli/src/args.rs | 4 ++-- crates/automation/src/files.rs | 2 +- crates/automation/src/server.rs | 6 +++--- crates/engine/src/cmd/file_cmds.rs | 2 +- crates/engine/src/cmd/file_cmds/save.rs | 5 +++-- crates/io/src/lib.rs | 5 +++-- docs/control-protocol.md | 2 +- docs/development.md | 2 +- 8 files changed, 15 insertions(+), 13 deletions(-) diff --git a/apps/photocraft-cli/src/args.rs b/apps/photocraft-cli/src/args.rs index 66e2fbb3d7..8c7b708b7d 100644 --- a/apps/photocraft-cli/src/args.rs +++ b/apps/photocraft-cli/src/args.rs @@ -74,8 +74,8 @@ pub(crate) struct ExportArgs { /// and CMYK shrink) or progressive #[arg(long, value_name = "baseline|optimized|progressive", value_parser = jpeg_format)] pub(crate) jpeg_format: Option, - /// Also write the flattened document at its canvas size into .pcraft output, as - /// composite/full.png + /// Request the flattened document at its canvas size in .pcraft output as + /// composite/full.png; it may be omitted. Check the manifest's full_composite member. #[arg(long)] pub(crate) pcraft_full_composite: bool, } diff --git a/crates/automation/src/files.rs b/crates/automation/src/files.rs index 5f509a692c..a74af99241 100644 --- a/crates/automation/src/files.rs +++ b/crates/automation/src/files.rs @@ -57,7 +57,7 @@ pub fn previews(doc: &Document) -> SaveOptions { SaveOptions { thumbnail: Some(photocraft_compose::thumbnail(doc, 256)), composite: Some(photocraft_compose::thumbnail(doc, 1024)), ..Default::default() } } -/// [`previews`], plus the full-size composite when `opts` asks for it +/// [`previews`], plus the full-size composite when `opts` asks for it and it can be rendered /// ([`ExportOptions::pcraft_full_composite`]). fn native_options(doc: &Document, opts: &ExportOptions) -> SaveOptions { let mut out = previews(doc); diff --git a/crates/automation/src/server.rs b/crates/automation/src/server.rs index 3ac8796cdf..0b7c3fa753 100644 --- a/crates/automation/src/server.rs +++ b/crates/automation/src/server.rs @@ -191,8 +191,8 @@ pub struct SaveParams { /// DDS: also write the full mip chain, each level a box filter of the one above. #[serde(default, rename = "ddsMipmaps")] pub dds_mipmaps: bool, - /// .pcraft: also write the flattened document at its canvas size as `composite/full.png`. - /// Off by default. + /// .pcraft: request the flattened document at its canvas size as `composite/full.png`. + /// The save may omit it; check the manifest's `full_composite` member. Off by default. #[serde(default, rename = "pcraftFullComposite")] pub pcraft_full_composite: Option, #[serde(default)] @@ -796,7 +796,7 @@ impl PhotocraftMcp { } #[tool(description = "Save the document. `.pcraft` is the lossless native format (incremental); other extensions \ - (psd, png, jpg, tif, webp, exr, …) export. Without `path` only a PSD, PSB or .pcraft document is written back to its own file. Returns warnings about anything the format cannot hold.")] + (psd, png, jpg, tif, webp, exr, …) export. Without `path` only a PSD, PSB or .pcraft document is written back to its own file. `pcraftFullComposite` requests `composite/full.png`, which may be omitted; check the manifest's `full_composite` member. Returns warnings about anything the format cannot hold.")] async fn doc_save(&self, Parameters(p): Parameters) -> Result { self.save_impl(p).await } diff --git a/crates/engine/src/cmd/file_cmds.rs b/crates/engine/src/cmd/file_cmds.rs index 7ad6492dce..ec411fee51 100644 --- a/crates/engine/src/cmd/file_cmds.rs +++ b/crates/engine/src/cmd/file_cmds.rs @@ -243,7 +243,7 @@ pub fn specs() -> Vec { "Save a Copy…", &["File"], Some("Cmd+Alt+S"), - r##"{"path":str (format from the extension),"quality":0..12? (JPEG),"jpegFormat":"baseline|optimized|progressive"? (JPEG; default baseline),"layers":bool=true,"overwrite":bool=false (replace the document's own file),"tiffLayers":bool=false (TIFF: keep the layers; flat by default),"tiffCompression":"none|lzw|zip"? (TIFF: image compression; unset, none),"tgaBits":24|32? (TGA: 32 writes the alpha channel, or else the transparency, as the alpha; unset, 32 with an alpha channel or transparency, else 24),"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"ddsCompression":"none|bc1|bc2|bc3"="none" (DDS: uncompressed, or block compression; BC1 keeps alpha on or off),"ddsMipmaps":bool=false (DDS: also write the full mip chain),"pcraftFullComposite":bool? (.pcraft: also write the flattened document at its canvas size as composite/full.png; unset, Preferences › File Handling)}"##, + r##"{"path":str (format from the extension),"quality":0..12? (JPEG),"jpegFormat":"baseline|optimized|progressive"? (JPEG; default baseline),"layers":bool=true,"overwrite":bool=false (replace the document's own file),"tiffLayers":bool=false (TIFF: keep the layers; flat by default),"tiffCompression":"none|lzw|zip"? (TIFF: image compression; unset, none),"tgaBits":24|32? (TGA: 32 writes the alpha channel, or else the transparency, as the alpha; unset, 32 with an alpha channel or transparency, else 24),"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"ddsCompression":"none|bc1|bc2|bc3"="none" (DDS: uncompressed, or block compression; BC1 keeps alpha on or off),"ddsMipmaps":bool=false (DDS: also write the full mip chain),"pcraftFullComposite":bool? (.pcraft: request the flattened document at its canvas size as composite/full.png; it may be omitted; check the manifest's full_composite member; unset, Preferences › File Handling)}"##, native_doc, save_a_copy ), diff --git a/crates/engine/src/cmd/file_cmds/save.rs b/crates/engine/src/cmd/file_cmds/save.rs index 96f0483689..9c073df890 100644 --- a/crates/engine/src/cmd/file_cmds/save.rs +++ b/crates/engine/src/cmd/file_cmds/save.rs @@ -34,8 +34,9 @@ pub(crate) struct SaveOpts { /// PSD/PSB: write the real merged image (Preferences › File Handling › Maximize PSD and PSB /// File Compatibility). pub psd_maximize_compatibility: bool, - /// `.pcraft`: also write the full-size composite (`"pcraftFullComposite"`; unset, Preferences › - /// File Handling › Save Full-Size Composite in .pcraft Files, off by default). + /// `.pcraft`: request the full-size composite (`"pcraftFullComposite"`; unset, Preferences › + /// File Handling › Save Full-Size Composite in .pcraft Files, off by default). The save may + /// omit it; check the manifest's `full_composite` member. pub pcraft_full_composite: bool, } diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index a7cf4c1edd..37c10b601a 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -240,8 +240,9 @@ pub struct ExportOptions { /// PSD/PSB and layered TIFF: read and embed the files linked smart objects name (see /// [`PsdExportOptions::read_linked_files`]). On by default; automation turns it off. pub read_linked_files: bool, - /// `.pcraft`: also write the flattened document at its canvas size as `composite/full.png` - /// (Preferences › File Handling › Save Full-Size Composite in .pcraft Files). Off by default. + /// `.pcraft`: request the flattened document at its canvas size as `composite/full.png` + /// (Preferences › File Handling › Save Full-Size Composite in .pcraft Files). The save may + /// omit it; check the manifest's `full_composite` member. Off by default. pub pcraft_full_composite: bool, } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 257fab8686..b31337ca62 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -428,7 +428,7 @@ stdio, and a value that is not a port is a usage error naming the variable. Impl | `doc.exportLayers` | `{dir, format? (png), prefix?, visibleOnly? (true), quality?, index?}` → `{files, skipped}` (plus `count` for workspace exports): one file per exported top-level layer, named like `file.export.layersToFiles` names them. `count` is present only for workspace exports and counts written files only. `skipped` is always an array (empty when nothing is omitted), in top-to-bottom order, of `{layer, name, reason}`: the session layer id, original unsanitized name, and `"hidden by visibleOnly"` or `"adjustment layer has no pixels of its own"`. Visibility takes precedence for hidden adjustments; `visibleOnly:false` exports hidden non-adjustment layers. Groups export merged; their children are not separate exports or skips. Existing filenames/numbering are unchanged. `dir` resolves beneath the write root, so the ambient command stays refused. The bytes come from the command's own core, so both paths write the same files; the write capability refuses to overwrite an existing file. Encode/write failures are errors, not skips | | `doc.exportDataSets` | `{dir, format? (png), dataSets? (all), naming? ("{name}\|{index}\|{document}"), index?}` → `{files, count}`: the capability-scoped `file.export.dataSetsAsFiles`. The batch is planned first and colliding names are refused before any file is written, as in the command | | `doc.importDataSets` | `{path, delimiter? (,), index?}` → `{imported}`: the capability-scoped `file.import.variableDataSets`; the CSV is read beneath the read root | -| `doc.save` | `{path?, format?, quality?, index?, tiffLayers?, tgaBits?, ddsCompression?, ddsMipmaps?, pcraftFullComposite?}` (`.pcraft` native, else export by extension; set `tiffLayers: true` to preserve TIFF layers rather than exporting a flattened TIFF; `tgaBits` 24 or 32 sets a TGA's bits per pixel, and `ddsCompression`/`ddsMipmaps` a DDS texture's compression and mip chain, as `app.save` does; `pcraftFullComposite: true` also writes the flattened document at its canvas size into a `.pcraft` file as `composite/full.png`). Without `path` only a PSD, PSB or `.pcraft` document is written back to its own file, in its own format; anything else is an error and the file is left unchanged. A successful layered save (PSD, PSB or `.pcraft`) records the current revision as saved and makes the file the document's path, so `session.list` reports `dirty: false`; a flat export is a copy and leaves the document dirty | +| `doc.save` | `{path?, format?, quality?, index?, tiffLayers?, tgaBits?, ddsCompression?, ddsMipmaps?, pcraftFullComposite?}` (`.pcraft` native, else export by extension; set `tiffLayers: true` to preserve TIFF layers rather than exporting a flattened TIFF; `tgaBits` 24 or 32 sets a TGA's bits per pixel, and `ddsCompression`/`ddsMipmaps` a DDS texture's compression and mip chain, as `app.save` does; `pcraftFullComposite: true` also writes the flattened document at its canvas size into a `.pcraft` file as `composite/full.png`; the save may leave it out in the cases listed in [the format guide](../book/src/formats/pcraft.md), and the manifest's `full_composite` member names it when written). Without `path` only a PSD, PSB or `.pcraft` document is written back to its own file, in its own format; anything else is an error and the file is left unchanged. A successful layered save (PSD, PSB or `.pcraft`) records the current revision as saved and makes the file the document's path, so `session.list` reports `dirty: false`; a flat export is a copy and leaves the document dirty | | `doc.inspect` | `{index?}`: same JSON as `document.inspect` | | `doc.render` | `{index?, maxSide? (1024; 0 = full), path?}`: PNG to `path`, else `{mime, base64}` | | `doc.select` / `doc.close` | `{index}` / `{index?}` | diff --git a/docs/development.md b/docs/development.md index 2e67b98283..5d5a921760 100644 --- a/docs/development.md +++ b/docs/development.md @@ -271,7 +271,7 @@ The `--cli` selector is removed; all remaining arguments, stdin, stdout, stderr - `manifest.json`: the versioned document tree, with migrations in `format/src/migrate.rs`. - `tiles/.zst` and `blobs/.zst`: zstd-compressed objects, content-addressed by the BLAKE3 hash of their uncompressed bytes. - `thumb.png` and `composite/preview.png`: previews. -- `composite/full.png`: the flattened document at the canvas size, for other apps that read `.pcraft` files. Saves write it only when asked (`ExportOptions::pcraft_full_composite`, off by default). +- `composite/full.png`: the flattened document at the canvas size, for other apps that read `.pcraft` files. Saves write it only when asked (`ExportOptions::pcraft_full_composite`, off by default), and may leave it out in the cases listed in [the format guide](../book/src/formats/pcraft.md). Check the manifest's `full_composite` member to confirm it was written. - `save.lock` (directory bundles only): an empty file a save holds an OS file lock on, so two writers or processes saving one bundle take turns (#4957). Keep one `PcraftWriter` per open document: re-saving then only compresses and writes tiles that changed. Directory bundles verify objects on first encounter in a folder; later saves reuse them while their file size and modification time are unchanged, and re-verify changed objects, repair missing or damaged objects, and garbage-collect unreferenced ones. `format::Autosaver` writes snapshots into a recovery directory on a background thread. `list_recovery` / `recover` / `discard_recovery` implement crash recovery, and `format::RecoveryStore` is the lifecycle the desktop app uses: new documents autosave under per-launch keys (document ids restart every launch, so they never overwrite an older entry), and a recovered document adopts the entry it came from. That entry is replaced in place by the next autosave and removed only when the document is saved or closed, never just because it was recovered, so a second crash loses nothing. The web build has no crash recovery (no autosave services). From 9b1be2f52693dbdc432be96f97cfa9ac5c45eacb Mon Sep 17 00:00:00 2001 From: yoriswind Date: Sat, 31 Oct 2026 16:51:48 -0700 Subject: [PATCH 019/216] fix: load every selected Actions file --- crates/ui-egui/src/panels/actions.rs | 58 +++++++++++++++- crates/ui-egui/src/panels/actions/tests.rs | 79 ++++++++++++++++++++++ 2 files changed, 135 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/actions.rs b/crates/ui-egui/src/panels/actions.rs index c1ba436d22..2a1b1d6e4c 100644 --- a/crates/ui-egui/src/panels/actions.rs +++ b/crates/ui-egui/src/panels/actions.rs @@ -11,6 +11,7 @@ use serde_json::{Value, json}; use photocraft_engine::actions_cmds::{self, Action}; use crate::PhotocraftApp; +use crate::file_dialog::{FileDialogAnswer, FileDialogRequest}; use crate::icons; use crate::theme::Tokens; @@ -372,7 +373,12 @@ fn source(name: &str, bytes: &[u8]) -> Value { /// Load a `.atn` file's bytes into the Actions panel (the panel menu's Load Actions…). pub fn load_bytes(app: &mut PhotocraftApp, name: &str, bytes: &[u8]) -> Result { - let r = app.run("actions.load", source(name, bytes))?; + load_source(app, name, source(name, bytes)) +} + +/// Import one set and reveal its first action, for both native paths and browser contents. +fn load_source(app: &mut PhotocraftApp, name: &str, params: Value) -> Result { + let r = app.run("actions.load", params)?; let n = r["actions"].as_array().map_or(0, Vec::len) as u64; let unsupported = r["unsupported"].as_array().map_or(0, Vec::len); let shown = crate::file_open::display_name(name); @@ -407,8 +413,56 @@ pub fn load_bytes(app: &mut PhotocraftApp, name: &str, bytes: &[u8]) -> Result Result { - app.pick_file_bytes_filtered(&["atn"], |app, name, bytes| load_bytes(app, &name, &bytes)) + let initial_dir = crate::file_dialog::last_used_dir(&app.ui.recent_files); + app.ask_file(FileDialogRequest::Open { multiple: true, initial_dir, extensions: Some(vec!["atn".into()]) }, |app, answer| { + let files: Vec<_> = match answer { + FileDialogAnswer::Paths(paths) => paths.into_iter().map(|name| (name, None)).collect(), + FileDialogAnswer::Contents(name, bytes) => vec![(name, Some(bytes))], + FileDialogAnswer::ContentsMany(files) => files.into_iter().map(|(name, bytes)| (name, Some(bytes))).collect(), + _ => return Err("the file dialog gave an unexpected answer".into()), + }; + let count = files.len(); + if count == 0 { + return Err(crate::file_dialog::CANCELLED.into()); + } + let (mut loaded, mut actions, mut unsupported) = (0usize, 0usize, 0usize); + let mut errors = Vec::new(); + let mut last = Value::Null; + for (name, bytes) in files { + // Native paths go through the engine's size check before reading the file. + let params = match bytes { + Some(bytes) => source(&name, &bytes), + None => json!({"path": name}), + }; + match load_source(app, &name, params) { + Ok(r) => { + loaded += 1; + actions = actions.saturating_add(r["actions"].as_array().map_or(0, Vec::len)); + unsupported = unsupported.saturating_add(r["unsupported"].as_array().map_or(0, Vec::len)); + last = r; + } + Err(e) => errors.push(format!("{}: {e}", crate::file_open::display_name(&name))), + } + } + if count == 1 { + return match errors.pop() { + Some(e) => Err(e), + None => Ok(last), + }; + } + let template = "Action files loaded: {files}; actions: {actions}; unsupported steps: {unsupported}"; + let summary = + crate::i18n::fmt(tl!(template), &[("files", &loaded.to_string()), ("actions", &actions.to_string()), ("unsupported", &unsupported.to_string())]); + if !errors.is_empty() { + let template = "{summary}; files failed: {count}. {errors}"; + return Err(crate::i18n::fmt(tl!(template), &[("summary", &summary), ("count", &errors.len().to_string()), ("errors", &errors.join("; "))])); + } + app.ui.status = summary; + app.ui.status_error = false; + Ok(json!({"files": loaded, "actions": actions, "unsupported": unsupported})) + }) } /// Save Actions…: the selected action's set (every action of it), or every action when none diff --git a/crates/ui-egui/src/panels/actions/tests.rs b/crates/ui-egui/src/panels/actions/tests.rs index a2f7629513..5270662206 100644 --- a/crates/ui-egui/src/panels/actions/tests.rs +++ b/crates/ui-egui/src/panels/actions/tests.rs @@ -1,5 +1,84 @@ use super::*; +/// A small valid Photoshop set, generated without external action files. +fn action_set_bytes(set: &str, action: &str) -> Vec { + use photocraft_psd::atn::{AtnAction, AtnEvent, AtnFile, AtnItem}; + use photocraft_psd::descriptor::UnicodeString; + photocraft_psd::atn::write(&AtnFile { + name: UnicodeString::new_nul(set), + actions: vec![AtnAction { + name: UnicodeString::new_nul(action), + items: vec![AtnItem { event: AtnEvent::Code(*b"Invr"), dictionary: "Invert".into(), ..Default::default() }], + ..Default::default() + }], + ..Default::default() + }) + .unwrap() +} + +#[test] +/// Browser multi-selection must not be truncated to the first file. +fn bulk_load_imports_every_chosen_action_file() { + use crate::file_dialog::{FileDialogAnswer, FileDialogRequest}; + let files = vec![("first.atn".into(), action_set_bytes("First set", "First")), ("second.atn".into(), action_set_bytes("Second set", "Second"))]; + let (show, asked) = crate::file_dialog::fake(vec![Some(FileDialogAnswer::ContentsMany(files))]); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services { file_dialog: Some(show), ..Default::default() }); + load_dialog(&mut app).unwrap(); + app.poll_file_dialog(&egui::Context::default(), None); + assert_eq!(app.session.actions.list.iter().map(|a| a.name.as_str()).collect::>(), ["First", "Second"]); + assert!(matches!(asked.borrow().as_slice(), [FileDialogRequest::Open { multiple: true, extensions: Some(ext), .. }] if ext == &["atn"])); + assert!(!app.file_dialog_open()); + assert!(!app.ui.status_error, "{}", app.ui.status); +} + +#[test] +/// A corrupt middle file cannot hide warnings or prevent a later set replacement. +fn bulk_load_keeps_selection_order_and_earlier_warnings_and_errors() { + use crate::file_dialog::FileDialogAnswer; + let dir = tempfile::tempdir().unwrap(); + let first = dir.path().join("z-first.atn"); + let broken = dir.path().join("broken.atn"); + let last = dir.path().join("a-last.atn"); + let mut unsupported = photocraft_psd::atn::parse(&action_set_bytes("Same set", "Old")).unwrap(); + unsupported.actions[0].items[0].event = photocraft_psd::atn::AtnEvent::Code(*b"Xxxx"); + std::fs::write(&first, photocraft_psd::atn::write(&unsupported).unwrap()).unwrap(); + std::fs::write(&broken, b"not an action file").unwrap(); + std::fs::write(&last, action_set_bytes("Same set", "Replacement")).unwrap(); + let paths = [first, broken, last].map(|p| p.to_string_lossy().into_owned()).to_vec(); + let mut app = disk_app(vec![Some(FileDialogAnswer::Paths(paths))]); + load_dialog(&mut app).unwrap(); + app.poll_file_dialog(&egui::Context::default(), None); + assert_eq!(app.session.actions.list.iter().map(|a| a.name.as_str()).collect::>(), ["Replacement"]); + assert_eq!(app.ui.actions.selected, Some(0)); + assert!(app.ui.status_error, "a later success must not hide an earlier failure"); + for part in ["files loaded: 2", "unsupported steps: 1", "files failed: 1", "broken.atn"] { + assert!(app.ui.status.contains(part), "{part}: {}", app.ui.status); + } +} + +#[test] +/// Dismissing the picker is inert; one selected file keeps its existing status message. +fn bulk_load_keeps_cancel_empty_and_single_file_behavior() { + use crate::file_dialog::FileDialogAnswer; + for answer in [None, Some(FileDialogAnswer::Paths(Vec::new())), Some(FileDialogAnswer::ContentsMany(Vec::new()))] { + let mut app = disk_app(vec![answer]); + app.session.actions.list.push(Action::new("Existing", vec![])); + app.ui.actions.selected = Some(0); + app.ui.status = "Unchanged".into(); + load_dialog(&mut app).unwrap(); + app.poll_file_dialog(&egui::Context::default(), None); + assert_eq!(app.session.actions.list[0].name, "Existing"); + assert_eq!(app.ui.actions.selected, Some(0)); + assert_eq!(app.ui.status, "Unchanged"); + } + let mut app = disk_app(vec![Some(FileDialogAnswer::Contents("single.atn".into(), action_set_bytes("Single", "One")))]); + load_dialog(&mut app).unwrap(); + app.poll_file_dialog(&egui::Context::default(), None); + assert_eq!(app.session.actions.list[0].name, "One"); + assert!(app.ui.status.contains("single.atn")); + assert!(!app.ui.status_error); +} + #[test] fn panel_menu_lists_load_save_and_existing_action_commands() { use egui_kittest::{Harness, kittest::Queryable}; From b408d4166d82629c61204d374f14f11c1bba18df Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 1 Nov 2026 02:17:06 +0100 Subject: [PATCH 020/216] Docs: .atn event reference with the supported events and measured specs of unmapped ones docs/atn.md lists the 117 events PhotoCraft maps today and, for 101 events it does not map yet, every key the step carries in recorded order, with type, unit, factory default, exact range and accepted enum values, measured in the reference application. An implementation needs no further checks there. Linked from file-format-parity.md. Co-Authored-By: Claude Opus 5.5 --- docs/atn.md | 1403 ++++++++++++++++++++++++++++++++++++ docs/file-format-parity.md | 5 +- 2 files changed, 1406 insertions(+), 2 deletions(-) create mode 100644 docs/atn.md diff --git a/docs/atn.md b/docs/atn.md new file mode 100644 index 0000000000..bcacabbb44 --- /dev/null +++ b/docs/atn.md @@ -0,0 +1,1403 @@ +# Actions files (`.atn`) + +PhotoCraft loads and saves action sets in the reference application's `.atn` format: Load Actions and +Save Actions in the Actions panel menu, `actions.load` / `actions.save` for agents, and +`photocraft batch --actions set.atn` on the command line. Reading and writing the file lives in +`photocraft-psd` (`crates/psd/src/atn.rs`, version 16 sets, exact round trip); the mapping between the +application's events and PhotoCraft commands lives in `photocraft-engine` +(`crates/engine/src/cmd/atn_cmds/`, table `EVENTS` in `events.rs`). + +A step whose event is in the table below loads as the PhotoCraft command it names, plays, and saves back +as the same event. Any other step loads as a kept step (`actions.photoshopStep`): Save Actions writes it +back unchanged, and playback stops at it with its name, so an action never silently skips work. + +This page has two parts: the events PhotoCraft maps today, and a measured reference for the events it +does not map yet, with everything needed to implement them without the reference application. + +## Supported events (117) + +| Code | String ID | Label | PhotoCraft command(s) | +|---|---|---|---| +| `AdNs` | addNoise | Add Noise | `filter.noise.addNoise` | +| `Algn` | align | Align | `layer.align.leftEdges`, `layer.align.horizontalCenters`, `layer.align.rightEdges`, `layer.align.topEdges`, `layer.align.verticalCenters`, `layer.align.bottomEdges` | +| `AppI` | applyImageEvent | Apply Image | `image.applyImage` | +| (string ID only) | applyComp | Apply Layer Comp | `layerComp.apply` | +| (string ID only) | applyLocking | Apply Locking | `layer.lockLayers` | +| (string ID only) | assignProfile | Assign Profile | `edit.assignProfile` | +| (string ID only) | autoColor | Auto Color | `image.autoColor` | +| (string ID only) | BanW | blackAndWhite | `Black & White` | +| `Blr ` | blur | Blur | `filter.blur.blur` | +| `BlrM` | blurMore | Blur More | `filter.blur.blurMore` | +| `Brdr` | border | Border | `select.modify.border` | +| (string ID only) | boxblur | Box Blur | `filter.blur.boxBlur` | +| `BrgC` | BrgC | brightnessEvent | `Brightness/Contrast` | +| `CnvS` | canvasSize | Canvas Size | `image.canvasSize` | +| `ChnM` | ChnM | channelMixer | `Channel Mixer` | +| (string ID only) | clearEvent | Clear | `select.editInQuickMaskMode`, `history.clear` | +| `Cls ` | close | Close | `file.close` | +| (string ID only) | collapseAllGroupsEvent | Collapse All Groups | `layer.setExpanded` | +| `ClrR` | colorRange | Color Range | `select.colorRange` | +| `ClrB` | ClrB | colorBalance | `Color Balance` | +| `Cntc` | contract | Contract | `select.modify.contract` | +| `CnvM` | convertMode | Convert Mode | `image.mode.rgb`, `image.mode.grayscale`, `image.mode.cmyk`, `image.mode.lab`, `image.mode.multichannel`, `image.mode.bits8`, `image.mode.bits16`, `image.mode.bits32`, `image.mode.bitmap` | +| (string ID only) | convertToProfile | Convert to Profile | `edit.convertToProfile` | +| (string ID only) | newPlacedLayer | Convert to Smart Object | `layer.smartObjects.convertToSmartObject` | +| `copy` | copyEvent | Copy | `edit.copy` | +| (string ID only) | copyEffects | Copy Layer Style | `layer.layerStyle.copyLayerStyle` | +| `CpyM` | copyMerged | Copy Merged | `edit.copyMerged` | +| `GrpL` | groupEvent | Create Clipping Mask | `layer.createClippingMask` | +| `Crop` | crop | Crop | `image.crop` | +| `Crst` | crystallize | Crystallize | `filter.pixelate.crystallize` | +| `Crvs` | Crvs | curves | `Curves` | +| `cut ` | cut | Cut | `edit.cut` | +| `Dfrg` | defringe | Defringe | `layer.matting.defringe` | +| `Dlt ` | delete | Delete | `layer.delete`, `edit.clear`, `layer.layerMask.delete`, `layer.smartFilter.deleteFilterMask` | +| `Dstt` | desaturate | Desaturate | `image.adjustments.desaturate` | +| (string ID only) | selectNoLayers | Deselect Layers | `select.deselectLayers` | +| `Dspc` | despeckle | Despeckle | `filter.noise.despeckle` | +| `Dfs ` | diffuse | Diffuse | `filter.stylize.diffuse` | +| `Dplc` | duplicate | Duplicate | `layer.duplicate`, `image.duplicate` | +| `DstS` | dustAndScratches | Dust & Scratches | `filter.noise.dustAndScratches` | +| (string ID only) | placedLayerEditContents | Edit Contents | `layer.smartObjects.editContents` | +| `Embs` | emboss | Emboss | `filter.stylize.emboss` | +| `Eqlz` | equalize | Equalize | `image.adjustments.equalize` | +| `Exch` | exchange | Exchange | `tools.swapColors` | +| `Expn` | expand | Expand | `select.modify.expand` | +| `Expr` | export | Export | `file.export.saveForWebLegacy` | +| `Exps` | Exps | exposure | `Exposure` | +| `Fthr` | feather | Feather | `select.modify.feather` | +| `Fl ` | fill | Fill | `edit.fill`, `paint.bucket` | +| `GEfc` | filterGallery | Filter Gallery | `image.adjustments.shadowsHighlights` | +| `FltI` | flattenImage | Flatten Image | `layer.flattenImage` | +| `Flip` | flip | Flip | `image.imageRotation.flipCanvasHorizontal`, `image.imageRotation.flipCanvasVertical`, `edit.transform.flipHorizontal`, `edit.transform.flipVertical` | +| `GsnB` | gaussianBlur | Gaussian Blur | `filter.blur.gaussianBlur` | +| `GdMp` | GdMp | gradientMapEvent | `Gradient Map` | +| `Grow` | grow | Grow | `select.grow` | +| `Hd ` | hide | Hide | `layer.hideLayers` | +| `HghP` | highPass | High Pass | `filter.other.highPass` | +| `HStr` | HStr | hueSaturation | `Hue/Saturation` | +| (string ID only) | conditional | If | `actions.conditional` | +| `ImgS` | imageSize | Image Size | `image.imageSize` | +| `Invs` | inverse | Inverse | `select.inverse` | +| `Invr` | Invr | invert | `Invert` | +| `CpTL` | copyToLayer | Layer Via Copy | `layer.new.layerViaCopy` | +| `CtTL` | cutToLayer | Layer Via Cut | `layer.new.layerViaCut` | +| `Lvls` | levels | Levels | `image.adjustments.levels`, `image.autoTone`, `image.autoContrast` | +| `Mk ` | make | Make | | +| (string ID only) | animationFramesFromLayers | Make Frames from Layers | | +| `Mxm ` | maximum | Maximum | `filter.other.maximum` | +| `Mdn ` | median | Median | `filter.noise.median` | +| `Mrg2` | mergeLayersNew | Merge Layers | `layer.mergeLayers` | +| `MrgV` | mergeVisible | Merge Visible | `layer.mergeVisible`, `layer.stampVisible` | +| `Mnm ` | minimum | Minimum | `filter.other.minimum` | +| `MtnB` | motionBlur | Motion Blur | `filter.blur.motionBlur` | +| `move` | move | Move | `layer.translate`, `layer.arrange.bringToFront`, `layer.arrange.sendToBack`, `layer.arrange.bringForward`, `layer.arrange.sendBackward` | +| `Ofst` | offset | Offset | `filter.other.offset` | +| `Opn ` | open | Open | `file.openAs` | +| `past` | paste | Paste | `edit.paste`, `edit.pasteSpecial.pasteInPlace` | +| (string ID only) | pasteEffects | Paste Layer Style | `layer.layerStyle.pasteLayerStyle` | +| (string ID only) | PhtF | photoFilter | `Photo Filter` | +| `Plc ` | placeEvent | Place | `file.placeEmbedded`, `file.placeLinked` | +| `Ply ` | play | Play | `actions.play` | +| `Pstr` | Pstr | posterize | `Posterize` | +| `Prge` | purge | Purge | `edit.purge.all`, `edit.purge.clipboard`, `edit.purge.histories`, `edit.purge.undo` | +| `RdlB` | radialBlur | Radial Blur | `filter.blur.radialBlur` | +| (string ID only) | rasterizeLayer | Rasterize | `layer.rasterize.layer` | +| `Ungr` | ungroup | Release Clipping Mask | `layer.releaseClippingMask` | +| `Rset` | reset | Reset | `tools.defaultColors` | +| `RvlA` | revealAll | Reveal All | `image.revealAll` | +| `Rvrt` | revert | Revert | `file.revert` | +| `Rple` | ripple | Ripple | `filter.distort.ripple` | +| `Rtte` | rotateEventEnum | Rotate | `image.imageRotation.90cw`, `image.imageRotation.90ccw`, `image.imageRotation.180`, `image.rotation.arbitrary`, `edit.transform.rotate90Cw`, `edit.transform.rotate90Ccw`, `edit.transform.rotate180` | +| `save` | save | Save | `file.save`, `file.saveAs`, `file.saveACopy` | +| `slct` | select | Select | `layer.select`, `layer.selectAbove`, `layer.selectBelow`, `layer.addAboveToSelection`, `layer.addBelowToSelection`, `layer.selectTop`, `layer.selectBottom`, `channel.target`, `edit.undo`, `edit.redo`, `view.fitOnScreen`, `view.actualPixels`, `view.zoomIn`, `view.zoomOut`, `document.activate` | +| (string ID only) | selectAllLayers | Select All Layers | `select.allLayers` | +| (string ID only) | refineSelectionEdge | Select and Mask | `select.refineEdge` | +| (string ID only) | smartBrushWorkspace | Select and Mask | `select.refineEdge` | +| (string ID only) | autoCutout | Select Subject | `select.subject` | +| `SlcC` | SlcC | selectiveColor | `Selective Color` | +| `setd` | set | Set | `select.all`, `select.deselect`, `select.reselect`, `select.loadSelection`, `select.rect`, `select.lasso`, `select.editInQuickMaskMode`, `tools.setColors`, `layer.setProps`, `layer.renameLayer`, `layer.setLabelColor`, `layer.setAdjustment`, `layer.fill.pattern.set`, `layer.new.layerFromBackground`, `layer.layerStyle.blendingOptions`, `layer.smartFilter.blendingOptions` | +| (string ID only) | adaptCorrect | Shadows/Highlights | `image.adjustments.shadowsHighlights` | +| `Shrp` | sharpen | Sharpen | `filter.sharpen.sharpen` | +| `ShrE` | sharpenEdges | Sharpen Edges | `filter.sharpen.sharpenEdges` | +| `ShrM` | sharpenMore | Sharpen More | `filter.sharpen.sharpenMore` | +| `Shw ` | show | Show | `layer.showLayers` | +| `Smlr` | similar | Similar | `select.similar` | +| `SmrB` | smartBlur | Smart Blur | `filter.blur.smartBlur` | +| `Smth` | smoothness | Smooth | `select.modify.smooth` | +| `Stop` | stop | Stop | `STOP_MESSAGE` | +| `Strk` | stroke | Stroke | `edit.stroke` | +| (string ID only) | surfaceBlur | Surface Blur | `filter.blur.surfaceBlur` | +| `Thrs` | Thrs | threshold | `Threshold` | +| `Trnf` | transform | Transform | `edit.transform`, `select.transformSelection` | +| (string ID only) | trim | Trim | `image.trim` | +| (string ID only) | ungroupLayersEvent | Ungroup Layers | `layer.ungroupLayers` | +| `UnsM` | unsharpMask | Unsharp Mask | `filter.sharpen.unsharpMask` | +| (string ID only) | updatePlacedLayer | Update Smart Objects | `layer.smartObjects.updateModifiedContent` | +| `Vbrn` | Vbrn | vibrance | `Vibrance` | + +## Event reference: events not mapped yet + +Each entry below is an event the reference application records that PhotoCraft does not map yet: its +4-character code and string ID, the PhotoCraft command it should map to, and every key the step carries, +in the order it is recorded, with type, unit, factory default and allowed values. When an event is +implemented, move it to the table above in the same change. + +### How these values were measured + +- **Keys, order, types, units, defaults:** each event was run in the reference application without a + dialog on an RGB 8-bit document (with a selection, the clipboard or several layers where the step + needs them), and the descriptor it returned was read back. That descriptor is what an action records. + Settings were reset first, so the defaults are factory defaults. +- **Ranges:** every number was set far above and below its range. The application clamps instead of + refusing, so the values it returned are the exact limits. "Out-of-range values are clamped" in a table + means exactly that; implement the same clamping on import. +- **Enum values:** every known enum value was tried; only values the application accepted and returned + unchanged are listed. A value outside the list should keep the step as a kept step. +- **Names:** keys are stored by their 4-character code. The string ID column is a label; where one code + has two names in the application, the one that fits the setting is shown. +- **Filter Gallery effects:** the "PhotoCraft today" column compares each value with PhotoCraft's own + effect parameters (`crates/algo/src/artistic.rs`). All ranges and defaults already match; 26 parameters + have a different name in PhotoCraft, given in that column. + +### What an implementation needs to do + +1. **Import:** `actions.load` maps the step to the command and params listed, using every key in the + table; missing keys take the default from the table. +2. **Out-of-range values:** clamped to the allowed range, as the reference application does. Enum values + outside the allowed list keep the step as a kept step, never a silent default. +3. **Export:** `actions.save` writes the same event with all keys in the order of the table, defaults + included, with the units shown. +4. **Round trip:** load, then save, gives identical descriptors, for a step with defaults and for one + with every value changed. +5. **Tests:** add the event to `every_mapped_command_exports_and_imports_back` and test the params that + reach the command. + +### Group A: The 47 Filter Gallery effects + +Each Filter Gallery effect is recorded as its own event (one per effect), carrying a `GEfk` key with the effect id plus the effect's settings. Today they all load as kept steps that stop at playback. PhotoCraft already has every effect (`crates/algo/src/artistic.rs`) behind `filter.filterGallery`; this issue only maps them. Where our own range or default differs from the measured one, fix ours to match (marked in each table). + +#### Accented Edges + +- Event: `AccE` / `accentedEdges` +- PhotoCraft command: `filter.filterGallery effect "accentedEdges"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `EdgW` | edgeWidth | integer | | 2 | 1 .. 14 (out-of-range values are clamped) | same | +| `EdgB` | edgeBrightness | integer | | 38 | 0 .. 50 (out-of-range values are clamped) | same | +| `Smth` | smoothness | integer | | 5 | 1 .. 15 (out-of-range values are clamped) | same | + +#### Angled Strokes + +- Event: `AngS` / `angledStrokes` +- PhotoCraft command: `filter.filterGallery effect "angledStrokes"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `DrcB` | directionBalance | integer | | 50 | 0 .. 100 (out-of-range values are clamped) | same | +| `StrL` | strokeLength | integer | | 15 | 3 .. 50 (out-of-range values are clamped) | same | +| `Shrp` | sharpness | integer | | 3 | 0 .. 10 (out-of-range values are clamped) | maps to our `sharpness` (same range and default) | + +#### Bas Relief + +- Event: `BsRl` / `basRelief` +- PhotoCraft command: `filter.filterGallery effect "basRelief"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `Dtl ` | detail | integer | | 13 | 1 .. 15 (out-of-range values are clamped) | same | +| `Smth` | smoothness | integer | | 3 | 1 .. 15 (out-of-range values are clamped) | same | +| `LghD` | lightDirection | enumerated | | `LDBt` lightDirBottom | `LDBL` lightDirBottomLeft, `LDBR` lightDirBottomRight, `LDBt` lightDirBottom, `LDLf` lightDirLeft, `LDRg` lightDirRight, `LDTL` lightDirTopLeft, `LDTR` lightDirTopRight, `LDTp` lightDirTop | | + +#### Chalk Charcoal + +- Event: `ChlC` / `chalkCharcoal` +- PhotoCraft command: `filter.filterGallery effect "chalkCharcoal"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `ChrA` | charcoalArea | integer | | 6 | 0 .. 20 (out-of-range values are clamped) | same | +| `ChlA` | chalkArea | integer | | 6 | 0 .. 20 (out-of-range values are clamped) | same | +| `StrP` | strokePressure | integer | | 1 | 0 .. 5 (out-of-range values are clamped) | same | + +#### Charcoal + +- Event: `Chrc` / `charcoal` +- PhotoCraft command: `filter.filterGallery effect "charcoal"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `ChAm` | charcoalAmount | integer | | 1 | 1 .. 7 (out-of-range values are clamped) | maps to our `charcoalThickness` (same range and default) | +| `Dtl ` | detail | integer | | 5 | 0 .. 5 (out-of-range values are clamped) | same | +| `LgDr` | lightDark | integer | | 50 | 0 .. 100 (out-of-range values are clamped) | maps to our `lightDarkBalance` (same range and default) | + +#### Chrome + +- Event: `Chrm` / `chrome` +- PhotoCraft command: `filter.filterGallery effect "chrome"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `Dtl ` | detail | integer | | 4 | 0 .. 10 (out-of-range values are clamped) | same | +| `Smth` | smoothness | integer | | 7 | 0 .. 10 (out-of-range values are clamped) | same | + +#### Colored Pencil + +- Event: `ClrP` / `coloredPencil` +- PhotoCraft command: `filter.filterGallery effect "coloredPencil"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `Pncl` | pencilWidth | integer | | 4 | 1 .. 24 (out-of-range values are clamped) | maps to our `pencilWidth` (same range and default) | +| `StrP` | strokePressure | integer | | 8 | 0 .. 15 (out-of-range values are clamped) | same | +| `PprB` | paperBrightness | integer | | 25 | 0 .. 50 (out-of-range values are clamped) | same | + +#### Conte Crayon + +- Event: `CntC` / `conteCrayon` +- PhotoCraft command: `filter.filterGallery effect "conteCrayon"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `FrgL` | foregroundLevel | integer | | 11 | 1 .. 15 (out-of-range values are clamped) | same | +| `BckL` | backgroundLevel | integer | | 7 | 1 .. 15 (out-of-range values are clamped) | maps to our `backgroundLevel` (same range and default) | +| `TxtT` | textureType | enumerated | | `TxCa` texTypeCanvas | `TxBr` texTypeBrick, `TxBu` texTypeBurlap, `TxCa` texTypeCanvas, `TxSt` texTypeSandstone | | +| `Scln` | scaling | integer | | 100 | 50 .. 200 (out-of-range values are clamped) | same | +| `Rlf ` | relief | integer | | 4 | 0 .. 50 (out-of-range values are clamped) | same | +| `LghD` | lightDirection | enumerated | | `LDTp` lightDirTop | `LDBL` lightDirBottomLeft, `LDBR` lightDirBottomRight, `LDBt` lightDirBottom, `LDLf` lightDirLeft, `LDRg` lightDirRight, `LDTL` lightDirTopLeft, `LDTR` lightDirTopRight, `LDTp` lightDirTop | | +| `InvT` | invertTexture | boolean | | false | | | + +#### Craquelure + +- Event: `Crql` / `craquelure` +- PhotoCraft command: `filter.filterGallery effect "craquelure"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `CrcS` | crackSpacing | integer | | 15 | 2 .. 100 (out-of-range values are clamped) | same | +| `CrcD` | crackDepth | integer | | 6 | 0 .. 10 (out-of-range values are clamped) | same | +| `CrcB` | crackBrightness | integer | | 9 | 0 .. 10 (out-of-range values are clamped) | same | + +#### Crosshatch + +- Event: `Crsh` / `crosshatch` +- PhotoCraft command: `filter.filterGallery effect "crosshatch"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `StrL` | strokeLength | integer | | 9 | 3 .. 50 (out-of-range values are clamped) | same | +| `Shrp` | sharpness | integer | | 6 | 0 .. 20 (out-of-range values are clamped) | maps to our `sharpness` (same range and default) | +| `Strg` | strength | integer | | 1 | 1 .. 3 (out-of-range values are clamped) | maps to our `strength` (same range and default) | + +#### Cutout + +- Event: `Ct ` / `cutout` +- PhotoCraft command: `filter.filterGallery effect "cutout"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `NmbL` | numberOfLayers | integer | | 4 | 2 .. 8 (out-of-range values are clamped) | maps to our `numberOfLevels` (same range and default) | +| `EdgS` | edgeSimplicity | integer | | 4 | 0 .. 10 (out-of-range values are clamped) | same | +| `EdgF` | edgeFidelity | integer | | 2 | 1 .. 3 (out-of-range values are clamped) | same | + +#### Dark Strokes + +- Event: `DrkS` / `darkStrokes` +- PhotoCraft command: `filter.filterGallery effect "darkStrokes"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `Blnc` | balance | integer | | 5 | 0 .. 10 (out-of-range values are clamped) | same | +| `BlcI` | blackIntensity | integer | | 6 | 0 .. 10 (out-of-range values are clamped) | same | +| `WhtI` | whiteIntensity | integer | | 2 | 0 .. 10 (out-of-range values are clamped) | same | + +#### Diffuse Glow + +- Event: `DfsG` / `diffuseGlow` +- PhotoCraft command: `filter.filterGallery effect "diffuseGlow"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `Grns` | graininess | integer | | 6 | 0 .. 10 (out-of-range values are clamped) | same | +| `GlwA` | glowAmount | integer | | 10 | 0 .. 20 (out-of-range values are clamped) | same | +| `ClrA` | clearAmount | integer | | 15 | 0 .. 20 (out-of-range values are clamped) | same | + +#### Dry Brush + +- Event: `DryB` / `dryBrush` +- PhotoCraft command: `filter.filterGallery effect "dryBrush"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `BrsS` | brushSize | integer | | 2 | 0 .. 10 (out-of-range values are clamped) | same | +| `BrsD` | brushDetail | integer | | 8 | 0 .. 10 (out-of-range values are clamped) | same | +| `Txtr` | texture | integer | | 1 | 1 .. 3 (out-of-range values are clamped) | same | + +#### Film Grain + +- Event: `FlmG` / `filmGrain` +- PhotoCraft command: `filter.filterGallery effect "filmGrain"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `Grn ` | grain | integer | | 4 | 0 .. 20 (out-of-range values are clamped) | same | +| `HghA` | highlightArea | integer | | 0 | 0 .. 20 (out-of-range values are clamped) | same | +| `Intn` | intensity | integer | | 10 | 0 .. 10 (out-of-range values are clamped) | same | + +#### Fresco + +- Event: `Frsc` / `fresco` +- PhotoCraft command: `filter.filterGallery effect "fresco"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `BrsS` | brushSize | integer | | 2 | 0 .. 10 (out-of-range values are clamped) | same | +| `BrsD` | brushDetail | integer | | 8 | 0 .. 10 (out-of-range values are clamped) | same | +| `Txtr` | texture | integer | | 1 | 1 .. 3 (out-of-range values are clamped) | same | + +#### Glass + +- Event: `Gls ` / `glass` +- PhotoCraft command: `filter.filterGallery effect "glass"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `Dstr` | distortion | integer | | 5 | 0 .. 20 (out-of-range values are clamped) | maps to our `distortion` (same range and default) | +| `Smth` | smoothness | integer | | 3 | 1 .. 15 (out-of-range values are clamped) | same | +| `TxtT` | textureType | enumerated | | `TxFr` texTypeFrosted | `TxBr` texTypeBrick, `TxBu` texTypeBurlap, `TxCa` texTypeCanvas, `TxSt` texTypeSandstone | | +| `Scln` | scaling | integer | | 100 | 50 .. 200 (out-of-range values are clamped) | same | +| `InvT` | invertTexture | boolean | | false | | | + +#### Glowing Edges + +- Event: `GlwE` / `glowingEdges` +- PhotoCraft command: `filter.filterGallery effect "glowingEdges"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `EdgW` | edgeWidth | integer | | 2 | 1 .. 14 (out-of-range values are clamped) | same | +| `EdgB` | edgeBrightness | integer | | 6 | 0 .. 20 (out-of-range values are clamped) | same | +| `Smth` | smoothness | integer | | 5 | 1 .. 15 (out-of-range values are clamped) | same | + +#### Graphic Pen + +- Event: `GraP` / `graphicPen` +- PhotoCraft command: `filter.filterGallery effect "graphicPen"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `StrL` | strokeLength | integer | | 15 | 1 .. 15 (out-of-range values are clamped) | same | +| `LgDr` | lightDark | integer | | 50 | 0 .. 100 (out-of-range values are clamped) | maps to our `lightDarkBalance` (same range and default) | +| `SDir` | strokeDirection | enumerated | | `SDRD` strokeDirRightDiag | `SDHz` strokeDirHorizontal, `SDLD` strokeDirLeftDiag, `SDRD` strokeDirRightDiag, `SDVt` strokeDirVertical | | + +#### Halftone Screen + +- Event: `HlfS` / `halftoneScreen` +- PhotoCraft command: `filter.filterGallery effect "halftonePattern"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `HlSz` | halftoneSize | integer | | 1 | 1 .. 12 (out-of-range values are clamped) | maps to our `size` (same range and default) | +| `Cntr` | contrast | integer | | 5 | 0 .. 50 (out-of-range values are clamped) | maps to our `contrast` (same range and default) | +| `ScrT` | screenType | enumerated | | `ScrD` screenDot | `ScrC` screenCircle, `ScrD` screenDot, `ScrL` screenLine | | + +#### Ink Outlines + +- Event: `InkO` / `inkOutlines` +- PhotoCraft command: `filter.filterGallery effect "inkOutlines"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `StrL` | strokeLength | integer | | 4 | 1 .. 50 (out-of-range values are clamped) | same | +| `DrkI` | darkIntensity | integer | | 20 | 0 .. 50 (out-of-range values are clamped) | same | +| `LghI` | lightIntensity | integer | | 10 | 0 .. 50 (out-of-range values are clamped) | same | + +#### Mosaic Tiles + +- Event: `MscT` (no string ID) +- PhotoCraft command: `filter.filterGallery effect "mosaicTiles"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `TlSz` | tileSize | integer | | 12 | 2 .. 100 (out-of-range values are clamped) | same | +| `GrtW` | groutWidth | integer | | 3 | 1 .. 15 (out-of-range values are clamped) | same | +| `LghG` | lightenGrout | integer | | 9 | 0 .. 10 (out-of-range values are clamped) | same | + +#### Neon Glow + +- Event: `NGlw` / `neonGlow` +- PhotoCraft command: `filter.filterGallery effect "neonGlow"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `Sz ` | size | integer | | 5 | -24 .. 24 (out-of-range values are clamped) | maps to our `glowSize` (same range and default) | +| `Brgh` | brightness | integer | | 15 | 0 .. 50 (out-of-range values are clamped) | maps to our `glowBrightness` (same range and default) | +| `Clr ` | color | object | | obj 'RGBC' RGBColor | | | + +Nested values as recorded (defaults): +``` + 'Rd ' red : DOUBLETYPE = 0 + 'Grn ' green : DOUBLETYPE = 0 + 'Bl ' blue : DOUBLETYPE = 255 +``` + +#### Note Paper + +- Event: `NtPr` / `notePaper` +- PhotoCraft command: `filter.filterGallery effect "notePaper"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `ImgB` | imageBalance | integer | | 25 | 0 .. 50 (out-of-range values are clamped) | same | +| `Grns` | graininess | integer | | 10 | 0 .. 20 (out-of-range values are clamped) | same | +| `Rlf ` | relief | integer | | 11 | 0 .. 25 (out-of-range values are clamped) | same | + +#### Ocean Ripple + +- Event: `OcnR` / `oceanRipple` +- PhotoCraft command: `filter.filterGallery effect "oceanRipple"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `RplS` | rippleSize | integer | | 9 | 1 .. 15 (out-of-range values are clamped) | same | +| `RplM` | rippleMagnitude | integer | | 9 | 0 .. 20 (out-of-range values are clamped) | same | + +#### Paint Daubs + +- Event: `PntD` / `paintDaubs` +- PhotoCraft command: `filter.filterGallery effect "paintDaubs"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `Sz ` | size | integer | | 8 | 1 .. 50 (out-of-range values are clamped) | maps to our `brushSize` (same range and default) | +| `Shrp` | sharpness | integer | | 7 | 0 .. 40 (out-of-range values are clamped) | maps to our `sharpness` (same range and default) | +| `BrsT` | brushType | enumerated | | `BrSm` brushSimple | `BrDR` brushDarkRough, `BrSm` brushSimple, `BrSp` brushSparkle, `BrbW` brushWideBlurry, `BrsL` brushLightRough, `BrsW` brushWideSharp | | + +#### Palette Knife + +- Event: `PltK` / `paletteKnife` +- PhotoCraft command: `filter.filterGallery effect "paletteKnife"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `StrS` | strokeSize | integer | | 25 | 1 .. 50 (out-of-range values are clamped) | same | +| `StDt` | strokeDetail | integer | | 3 | 1 .. 3 (out-of-range values are clamped) | same | +| `Sftn` | softness | integer | | 0 | 0 .. 10 (out-of-range values are clamped) | same | + +#### Patchwork + +- Event: `Ptch` / `patchwork` +- PhotoCraft command: `filter.filterGallery effect "patchwork"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `SqrS` | squareSize | integer | | 4 | 0 .. 10 (out-of-range values are clamped) | same | +| `Rlf ` | relief | integer | | 8 | 0 .. 25 (out-of-range values are clamped) | same | + +#### Photocopy + +- Event: `Phtc` / `photocopy` +- PhotoCraft command: `filter.filterGallery effect "photocopy"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `Dtl ` | detail | integer | | 7 | 1 .. 24 (out-of-range values are clamped) | same | +| `Drkn` | darkness | integer | | 8 | 1 .. 50 (out-of-range values are clamped) | maps to our `darkness` (same range and default) | + +#### Plaster + +- Event: `Plst` / `plaster` +- PhotoCraft command: `filter.filterGallery effect "plaster"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `ImgB` | imageBalance | integer | | 20 | 0 .. 50 (out-of-range values are clamped) | same | +| `Smth` | smoothness | integer | | 2 | 1 .. 15 (out-of-range values are clamped) | same | +| `LghP` | lightPosition | enumerated | | `LPTp` lightPosTop | `LPBL` lightPosBottomLeft, `LPBr` lightPosBottomRight, `LPBt` lightPosBottom, `LPLf` lightPosLeft, `LPRg` lightPosRight, `LPTL` lightPosTopLeft, `LPTR` lightPosTopRight, `LPTp` lightPosTop | | + +#### Plastic Wrap + +- Event: `PlsW` / `plasticWrap` +- PhotoCraft command: `filter.filterGallery effect "plasticWrap"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `HghS` | highlightStrength | integer | | 15 | 0 .. 20 (out-of-range values are clamped) | same | +| `Dtl ` | detail | integer | | 9 | 1 .. 15 (out-of-range values are clamped) | same | +| `Smth` | smoothness | integer | | 7 | 1 .. 15 (out-of-range values are clamped) | same | + +#### Poster Edges + +- Event: `PstE` / `posterEdges` +- PhotoCraft command: `filter.filterGallery effect "posterEdges"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `EdgT` | edgeThickness | integer | | 2 | 0 .. 10 (out-of-range values are clamped) | same | +| `EdgI` | edgeIntensity | integer | | 1 | 0 .. 10 (out-of-range values are clamped) | same | +| `Pstr` | posterization | integer | | 2 | 0 .. 6 (out-of-range values are clamped) | same | + +#### Reticulation + +- Event: `Rtcl` / `reticulation` +- PhotoCraft command: `filter.filterGallery effect "reticulation"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `Dnst` | density | integer | | 12 | 0 .. 50 (out-of-range values are clamped) | same | +| `BlcL` | blackLevel | integer | | 40 | 0 .. 50 (out-of-range values are clamped) | maps to our `foregroundLevel` (same range and default) | +| `WhtL` | whiteLevel | integer | | 5 | 0 .. 50 (out-of-range values are clamped) | maps to our `backgroundLevel` (same range and default) | + +#### Rough Pastels + +- Event: `RghP` / `roughPastels` +- PhotoCraft command: `filter.filterGallery effect "roughPastels"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `StrL` | strokeLength | integer | | 6 | 0 .. 40 (out-of-range values are clamped) | same | +| `StDt` | strokeDetail | integer | | 4 | 1 .. 20 (out-of-range values are clamped) | same | +| `TxtT` | textureType | enumerated | | `TxCa` texTypeCanvas | `TxBr` texTypeBrick, `TxBu` texTypeBurlap, `TxCa` texTypeCanvas, `TxSt` texTypeSandstone | | +| `Scln` | scaling | integer | | 100 | 50 .. 200 (out-of-range values are clamped) | same | +| `Rlf ` | relief | integer | | 20 | 0 .. 50 (out-of-range values are clamped) | same | +| `LghD` | lightDirection | enumerated | | `LDBt` lightDirBottom | `LDBL` lightDirBottomLeft, `LDBR` lightDirBottomRight, `LDBt` lightDirBottom, `LDLf` lightDirLeft, `LDRg` lightDirRight, `LDTL` lightDirTopLeft, `LDTR` lightDirTopRight, `LDTp` lightDirTop | | +| `InvT` | invertTexture | boolean | | false | | | + +#### Smudge Stick + +- Event: `SmdS` / `smudgeStick` +- PhotoCraft command: `filter.filterGallery effect "smudgeStick"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `StrL` | strokeLength | integer | | 2 | 0 .. 10 (out-of-range values are clamped) | same | +| `HghA` | highlightArea | integer | | 0 | 0 .. 20 (out-of-range values are clamped) | same | +| `Intn` | intensity | integer | | 10 | 0 .. 10 (out-of-range values are clamped) | same | + +#### Spatter + +- Event: `Spt ` / `spatter` +- PhotoCraft command: `filter.filterGallery effect "spatter"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `SprR` | sprayRadius | integer | | 10 | 0 .. 25 (out-of-range values are clamped) | same | +| `Smth` | smoothness | integer | | 5 | 1 .. 15 (out-of-range values are clamped) | same | + +#### Sponge + +- Event: `Spng` / `sponge` +- PhotoCraft command: `filter.filterGallery effect "sponge"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `BrsS` | brushSize | integer | | 2 | 0 .. 10 (out-of-range values are clamped) | same | +| `Dfnt` | definition | integer | | 12 | 0 .. 25 (out-of-range values are clamped) | same | +| `Smth` | smoothness | integer | | 5 | 1 .. 15 (out-of-range values are clamped) | same | + +#### Sprayed Strokes + +- Event: `SprS` / `sprayedStrokes` +- PhotoCraft command: `filter.filterGallery effect "sprayedStrokes"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `StrL` | strokeLength | integer | | 12 | 0 .. 20 (out-of-range values are clamped) | same | +| `SprR` | sprayRadius | integer | | 7 | 0 .. 25 (out-of-range values are clamped) | same | +| `SDir` | strokeDirection | enumerated | | `SDRD` strokeDirRightDiag | `SDHz` strokeDirHorizontal, `SDLD` strokeDirLeftDiag, `SDRD` strokeDirRightDiag, `SDVt` strokeDirVertical | | + +#### Stained Glass + +- Event: `StnG` / `stainedGlass` +- PhotoCraft command: `filter.filterGallery effect "stainedGlass"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `ClSz` | cellSize | integer | | 10 | 2 .. 50 (out-of-range values are clamped) | same | +| `BrdT` | borderThickness | integer | | 4 | 1 .. 20 (out-of-range values are clamped) | same | +| `LghI` | lightIntensity | integer | | 3 | 0 .. 10 (out-of-range values are clamped) | same | + +#### Stamp + +- Event: `Stmp` / `stamp` +- PhotoCraft command: `filter.filterGallery effect "stamp"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `LgDr` | lightDark | integer | | 25 | 0 .. 50 (out-of-range values are clamped) | maps to our `lightDarkBalance` (same range and default) | +| `Smth` | smoothness | integer | | 5 | 1 .. 50 (out-of-range values are clamped) | same | + +#### Sumie + +- Event: `Smie` / `sumie` +- PhotoCraft command: `filter.filterGallery effect "sumiE"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `StrW` | strokeWidth | integer | | 10 | 3 .. 15 (out-of-range values are clamped) | same | +| `StrP` | strokePressure | integer | | 2 | 0 .. 15 (out-of-range values are clamped) | same | +| `Cntr` | contrast | integer | | 16 | 0 .. 40 (out-of-range values are clamped) | maps to our `contrast` (same range and default) | + +#### Texturizer + +- Event: `Txtz` / `texturizer` +- PhotoCraft command: `filter.filterGallery effect "texturizer"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `TxtT` | textureType | enumerated | | `TxCa` texTypeCanvas | `TxBr` texTypeBrick, `TxBu` texTypeBurlap, `TxCa` texTypeCanvas, `TxSt` texTypeSandstone | | +| `Scln` | scaling | integer | | 100 | 50 .. 200 (out-of-range values are clamped) | same | +| `Rlf ` | relief | integer | | 4 | 0 .. 50 (out-of-range values are clamped) | same | +| `LghD` | lightDirection | enumerated | | `LDTp` lightDirTop | `LDBL` lightDirBottomLeft, `LDBR` lightDirBottomRight, `LDBt` lightDirBottom, `LDLf` lightDirLeft, `LDRg` lightDirRight, `LDTL` lightDirTopLeft, `LDTR` lightDirTopRight, `LDTp` lightDirTop | | +| `InvT` | invertTexture | boolean | | false | | | + +#### Torn Edges + +- Event: `TrnE` / `tornEdges` +- PhotoCraft command: `filter.filterGallery effect "tornEdges"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `ImgB` | imageBalance | integer | | 25 | 0 .. 50 (out-of-range values are clamped) | same | +| `Smth` | smoothness | integer | | 11 | 1 .. 15 (out-of-range values are clamped) | same | +| `Cntr` | contrast | integer | | 17 | 1 .. 25 (out-of-range values are clamped) | maps to our `contrast` (same range and default) | + +#### Underpainting + +- Event: `Undr` / `underpainting` +- PhotoCraft command: `filter.filterGallery effect "underpainting"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `BrsS` | brushSize | integer | | 6 | 0 .. 40 (out-of-range values are clamped) | same | +| `TxtC` | textureCoverage | integer | | 16 | 0 .. 40 (out-of-range values are clamped) | maps to our `textureCoverage` (same range and default) | +| `TxtT` | textureType | enumerated | | `TxCa` texTypeCanvas | `TxBr` texTypeBrick, `TxBu` texTypeBurlap, `TxCa` texTypeCanvas, `TxSt` texTypeSandstone | | +| `Scln` | scaling | integer | | 100 | 50 .. 200 (out-of-range values are clamped) | same | +| `Rlf ` | relief | integer | | 4 | 0 .. 50 (out-of-range values are clamped) | same | +| `LghD` | lightDirection | enumerated | | `LDTp` lightDirTop | `LDBL` lightDirBottomLeft, `LDBR` lightDirBottomRight, `LDBt` lightDirBottom, `LDLf` lightDirLeft, `LDRg` lightDirRight, `LDTL` lightDirTopLeft, `LDTR` lightDirTopRight, `LDTp` lightDirTop | | +| `InvT` | invertTexture | boolean | | false | | | + +#### Water Paper + +- Event: `WtrP` / `waterPaper` +- PhotoCraft command: `filter.filterGallery effect "waterPaper"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `FbrL` | fiberLength | integer | | 15 | 3 .. 50 (out-of-range values are clamped) | same | +| `Brgh` | brightness | integer | | 60 | 0 .. 100 (out-of-range values are clamped) | same | +| `Cntr` | contrast | integer | | 80 | 0 .. 100 (out-of-range values are clamped) | maps to our `contrast` (same range and default) | + +#### Watercolor + +- Event: `Wtrc` / `watercolor` +- PhotoCraft command: `filter.filterGallery effect "watercolor"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `BrsD` | brushDetail | integer | | 9 | 1 .. 14 (out-of-range values are clamped) | same | +| `ShdI` | shadingIntensity | integer | | 1 | 0 .. 10 (out-of-range values are clamped) | maps to our `shadowIntensity` (same range and default) | +| `Txtr` | texture | integer | | 1 | 1 .. 3 (out-of-range values are clamped) | same | + +#### Grain + +- Event: `Grn ` / `grain` +- PhotoCraft command: `filter.filterGallery effect "grain"` + +| Key | String ID | Type | Unit | Default | Allowed | PhotoCraft today | +|---|---|---|---|---|---|---| +| `Intn` | intensity | integer | | 40 | 0 .. 100 (out-of-range values are clamped) | same | +| `Cntr` | contrast | integer | | 50 | 0 .. 100 (out-of-range values are clamped) | maps to our `contrast` (same range and default) | +| `Grnt` | grainType | enumerated | | `GrnR` grainRegular | `GrCn` grainContrasty, `GrSf` grainSoft, `GrSp` grainSpeckle, `GrSr` grainSprinkles, `GrSt` grainStippled, `GrnC` grainClumped, `GrnE` grainEnlarged, `GrnH` grainHorizontal, `GrnR` grainRegular, `GrnV` grainVertical | | + +### Group B: The Render filters Clouds, Difference Clouds, Fibers and Lens Flare + +All four commands exist in PhotoCraft; only the `.atn` mapping is missing. Lighting Effects is covered with the events that need a recorded step (its lights are only recorded through the dialog). + +#### Clouds + +- Event: `Clds` / `clouds` +- PhotoCraft command: `filter.render.clouds` +- Uses the current foreground and background colours; the step itself carries no colours. + +No parameters: the step carries an empty descriptor. + +#### Difference Clouds + +- Event: `DfrC` / `differenceClouds` +- PhotoCraft command: `filter.render.differenceClouds` +- Same as Clouds, blended with Difference. + +No parameters: the step carries an empty descriptor. + +#### Fibers + +- Event: `Fbrs` (no string ID) +- PhotoCraft command: `filter.render.fibers` +- `randomSeed` makes playback reproduce the recorded result; keep it. + +| Key | String ID | Type | Unit | Default | Allowed | +|---|---|---|---|---|---| +| `Vrnc` | (none) | integer | | 16 | not clamped in the step (values are returned unchanged); limits pending, see "Events that need a step recorded through their dialog" | +| `Strg` | strengthPlugin | integer | | 4 | not clamped in the step (values are returned unchanged); limits pending, see "Events that need a step recorded through their dialog" | +| `RndS` | randomSeed | integer | | random | any 32-bit integer; a new random value per recording | + +#### Lens Flare + +- Event: `LnsF` / `lensFlare` +- PhotoCraft command: `filter.render.lensFlare` +- Lens types: `Zm ` 50-300 mm Zoom, `Nkn ` 35 mm Prime, `Nkn1` 105 mm Prime, `PnVs` Movie Prime. + +| Key | String ID | Type | Unit | Default | Allowed | +|---|---|---|---|---|---| +| `Brgh` | brightness | integer | | 100 | 10 .. 300 (out-of-range values are clamped) | +| `FlrC` | flareCenter | object | | obj 'Pnt ' point | `Hrzn` / `Vrtc` doubles, 0.4 / 0.4 by default; not clamped: (5, -5) is kept as given and plays | +| `Lns ` | lens | enumerated | | `Zm ` zoom | `Nkn ` nikon, `Nkn1` nikon105, `PnVs` panaVision, `Zm ` zoom | + +Nested values as recorded (defaults): +``` + 'Hrzn' horizontal : DOUBLETYPE = 0.40000000596046 + 'Vrtc' vertical : DOUBLETYPE = 0.40000000596046 +``` + +### Group C: The Distort filters (Pinch, Polar Coordinates, Shear, Spherize, Twirl, Wave, ZigZag) + +All commands exist in PhotoCraft (`filter.distort.*`); only the `.atn` mapping is missing. Displace needs a displacement map file and is listed separately at the end. + +#### Pinch + +- Event: `Pnch` / `pinch` +- PhotoCraft command: `filter.distort.pinch` + +| Key | String ID | Type | Unit | Default | Allowed | +|---|---|---|---|---|---| +| `Amnt` | amount | integer | | 50 | -100 .. 100 (out-of-range values are clamped) | + +#### Polar Coordinates + +- Event: `Plr ` / `polar` +- PhotoCraft command: `filter.distort.polarCoordinates` + +| Key | String ID | Type | Unit | Default | Allowed | +|---|---|---|---|---|---| +| `Cnvr` | convert | enumerated | | `RctP` rectToPolar | `PlrR` polarToRect, `RctP` rectToPolar | + +#### Shear + +- Event: `Shr ` / `shear` +- PhotoCraft command: `filter.distort.shear` +- `shearPoints` is a list of curve points; keep their order and count. + +| Key | String ID | Type | Unit | Default | Allowed | +|---|---|---|---|---|---| +| `ShrP` | shearPoints | list | | list[2] | list of `Pnt ` points (`Hrzn`, `Vrtc`); default two points (0, 1) and (0, 128); limits pending | +| `UndA` | undefinedArea | enumerated | | `WrpA` wrapAround | `RptE` repeatEdgePixels, `WrpA` wrapAround | +| `ShrS` | shearStart | integer | | 0 | limits pending, see "Events that need a step recorded through their dialog" | +| `ShrE` | shearEnd | integer | | 1 | limits pending, see "Events that need a step recorded through their dialog" | + +Nested values as recorded (defaults): +``` + [0] OBJECTTYPE = obj 'Pnt ' point + 'Hrzn' horizontal : DOUBLETYPE = 0 + 'Vrtc' vertical : DOUBLETYPE = 1 + [1] OBJECTTYPE = obj 'Pnt ' point + 'Hrzn' horizontal : DOUBLETYPE = 0 + 'Vrtc' vertical : DOUBLETYPE = 128 +``` + +#### Spherize + +- Event: `Sphr` / `spherize` +- PhotoCraft command: `filter.distort.spherize` + +| Key | String ID | Type | Unit | Default | Allowed | +|---|---|---|---|---|---| +| `Amnt` | amount | integer | | 100 | -100 .. 100 (out-of-range values are clamped) | +| `SphM` | spherizeMode | enumerated | | `Nrml` normal | `HrzO` horizontalOnly, `Nrml` normal, `VrtO` verticalOnly | + +#### Twirl + +- Event: `Twrl` / `twirl` +- PhotoCraft command: `filter.distort.twirl` + +| Key | String ID | Type | Unit | Default | Allowed | +|---|---|---|---|---|---| +| `Angl` | angle | integer | | 50 | -999 .. 999 (out-of-range values are clamped) | + +#### Wave + +- Event: `Wave` / `wave` +- PhotoCraft command: `filter.distort.wave` +- `randomSeed`: keep it so playback reproduces the recorded result. + +| Key | String ID | Type | Unit | Default | Allowed | +|---|---|---|---|---|---| +| `Wvtp` | waveType | enumerated | | `WvSn` waveSine | `WvSn` waveSine, `WvSq` waveSquare, `WvTr` waveTriangle | +| `NmbG` | numberOfGenerators | integer | | 5 | 1 .. 999 (out-of-range values are clamped) | +| `WLMn` | wavelengthMin | integer | | 10 | 1 .. 999 (out-of-range values are clamped) | +| `WLMx` | wavelengthMax | integer | | 120 | 1 .. 999 (out-of-range values are clamped) | +| `AmMn` | amplitudeMin | integer | | 5 | 1 .. 999 (out-of-range values are clamped) | +| `AmMx` | amplitudeMax | integer | | 35 | 1 .. 999 (out-of-range values are clamped) | +| `SclH` | scaleHorizontal | integer | | 100 | 1 .. 100 (out-of-range values are clamped) | +| `SclV` | scaleVertical | integer | | 100 | 1 .. 100 (out-of-range values are clamped) | +| `UndA` | undefinedArea | enumerated | | `RptE` repeatEdgePixels | `RptE` repeatEdgePixels, `WrpA` wrapAround | +| `RndS` | randomSeed | integer | | random | any 32-bit integer; a new random value per recording | + +#### ZigZag + +- Event: `ZgZg` / `zigZag` +- PhotoCraft command: `filter.distort.zigZag` + +| Key | String ID | Type | Unit | Default | Allowed | +|---|---|---|---|---|---| +| `Amnt` | amount | integer | | 10 | -100 .. 100 (out-of-range values are clamped) | +| `NmbR` | numberOfRipples | integer | | 5 | 0 .. 20 (out-of-range values are clamped) | +| `ZZTy` | zigZagType | enumerated | | `PndR` pondRipples | `ArnC` aroundCenter, `OtFr` outFromCenter, `PndR` pondRipples | + +#### Displace + +Displace reads a displacement map from a file chosen in a dialog; record one step and add its keys (file path form) before implementing. + +### Group D: The Stylize and Pixelate filters + +All commands exist in PhotoCraft (`filter.stylize.*`, `filter.pixelate.*`); only the `.atn` mapping is missing. + +#### Extrude + +- Event: `Extr` / `extrude` +- PhotoCraft command: `filter.stylize.extrude` + +| Key | String ID | Type | Unit | Default | Allowed | +|---|---|---|---|---|---| +| `ExtS` | extrudeSize | integer | | 30 | 2 .. 255 (out-of-range values are clamped) | +| `ExtD` | extrudeDepth | integer | | 30 | 1 .. 255 (out-of-range values are clamped) | +| `ExtF` | extrudeSolidFace | boolean | | false | | +| `ExtM` | extrudeMaskIncomplete | boolean | | false | | +| `ExtT` | extrudeType | enumerated | | `Blks` blacks | `Blks` blacks, `Pyrm` pyramids | +| `ExtR` | extrudeRandom | enumerated | | `Rndm` random | `LvlB` levelBased, `Rndm` random | + +#### Find Edges + +- Event: `FndE` / `findEdges` +- PhotoCraft command: `filter.stylize.findEdges` + +No parameters: the step carries an empty descriptor. + +#### Solarize + +- Event: `Slrz` / `solarize` +- PhotoCraft command: `filter.stylize.solarize` + +No parameters: the step carries an empty descriptor. + +#### Trace Contour + +- Event: `TrcC` / `traceContour` +- PhotoCraft command: `filter.stylize.traceContour` + +| Key | String ID | Type | Unit | Default | Allowed | +|---|---|---|---|---|---| +| `Lvl ` | level | integer | | 128 | applied, but not echoed: the returned step always shows 128. Levels <= 0 and >= 255 give the same image. Dialog range pending. | +| `Edg ` | edge | enumerated | | `Upr ` upper | `Lwr ` lower, `Upr ` upper (applied; the returned step always shows upper) | + +#### Wind + +- Event: `Wnd ` / `wind` +- PhotoCraft command: `filter.stylize.wind` + +| Key | String ID | Type | Unit | Default | Allowed | +|---|---|---|---|---|---| +| `WndM` | windMethod | enumerated | | `Wnd ` wind | `Blst` blast, `Stgr` stagger, `Wnd ` wind | +| `Drct` | direction | enumerated | | `Left` left | `Left` left, `Rght` right | + +#### Tiles + +- Event: `Tls ` / `tiles` +- PhotoCraft command: `filter.stylize.tiles` + +| Key | String ID | Type | Unit | Default | Allowed | +|---|---|---|---|---|---| +| `TlNm` | tileNumber | integer | | 10 | 1 .. 99 (out-of-range values are clamped) | +| `TlOf` | tileOffset | integer | | 10 | 1 .. 99 (out-of-range values are clamped) | +| `FlCl` | fillColor | enumerated | | `FlBc` fillBack | `FlBc` fillBack, `FlFr` fillFore, `FlIn` fillInverse, `FlSm` fillSame | + +#### Color Halftone + +- Event: `ClrH` / `colorHalftone` +- PhotoCraft command: `filter.pixelate.colorHalftone` + +| Key | String ID | Type | Unit | Default | Allowed | +|---|---|---|---|---|---| +| `Rds ` | radius | integer | | 8 | 4 .. 127 (out-of-range values are clamped) | +| `Ang1` | angle1 | integer | | 108 | -10000 .. 10000 (out-of-range values are clamped) | +| `Ang2` | angle2 | integer | | 162 | -10000 .. 10000 (out-of-range values are clamped) | +| `Ang3` | angle3 | integer | | 90 | -10000 .. 10000 (out-of-range values are clamped) | +| `Ang4` | angle4 | integer | | 45 | -10000 .. 10000 (out-of-range values are clamped) | + +#### Facet + +- Event: `Fct ` / `facet` +- PhotoCraft command: `filter.pixelate.facet` + +No parameters: the step carries an empty descriptor. + +#### Fragment + +- Event: `Frgm` / `fragment` +- PhotoCraft command: `filter.pixelate.fragment` + +No parameters: the step carries an empty descriptor. + +#### Mezzotint + +- Event: `Mztn` / `mezzotint` +- PhotoCraft command: `filter.pixelate.mezzotint` + +| Key | String ID | Type | Unit | Default | Allowed | +|---|---|---|---|---|---| +| `MztT` | mezzotintType | enumerated | | `FnDt` fineDots | `CrsD` coarseDots, `FnDt` fineDots, `GrnD` grainyDots, `LngL` longLines, `LngS` longStrokes, `MdmD` mediumDots, `MdmL` mediumLines, `MdmS` mediumStrokes, `ShSt` shortStrokes, `ShrL` shortLines | + +#### Mosaic + +- Event: `Msc ` / `mosaic` +- PhotoCraft command: `filter.pixelate.mosaic` + +| Key | String ID | Type | Unit | Default | Allowed | +|---|---|---|---|---|---| +| `ClSz` | cellSize | unitdouble | | 2 (unit `#Pxl`) | 2 .. 200 (out-of-range values are clamped); the unit is `#Pxl`. The application returns the default without a unit, but accepts and returns `#Pxl`. | + +#### Pointillize + +- Event: `Pntl` / `pointillize` +- PhotoCraft command: `filter.pixelate.pointillize` + +| Key | String ID | Type | Unit | Default | Allowed | +|---|---|---|---|---|---| +| `ClSz` | cellSize | integer | | 5 | 3 .. 300 (out-of-range values are clamped) | + +### Group E: Video, Other and matting filters and the direct Gradient Map / Replace Color adjustments + +All commands exist in PhotoCraft; only the `.atn` mapping is missing. Gradient Map and Replace Color here are the direct adjustments (Image > Adjustments), not adjustment layers (those are already mapped). + +#### De-Interlace + +- Event: `Dntr` / `deInterlace` +- PhotoCraft command: `filter.video.deInterlace` + +| Key | String ID | Type | Unit | Default | Allowed | +|---|---|---|---|---|---| +| `IntE` | interlaceEliminateType | enumerated | | `ElmO` eliminateOddFields | `ElmE` eliminateEvenFields, `ElmO` eliminateOddFields | +| `IntC` | interlaceCreateType | enumerated | | `CrtI` createInterpolation | `CrtD` createDroplet, `CrtI` createInterpolation | + +#### NTSC Colors + +- Event: `NTSC` (no string ID) +- PhotoCraft command: `filter.video.ntscColors` + +No parameters: the step carries an empty descriptor. + +#### HSB/HSL + +- Event: `HsbP` (no string ID) +- PhotoCraft command: `filter.other.hsbHsl` + +| Key | String ID | Type | Unit | Default | Allowed | +|---|---|---|---|---|---| +| `Inpt` | input | enumerated | | `RGBC` RGBColor | `HSBl` HSBColorEnum, `HSLC` HSLColor, `RGBC` RGBColor | +| `Otpt` | output | enumerated | | `HSBl` HSBColorEnum | `HSBl` HSBColorEnum, `HSLC` HSLColor, `RGBC` RGBColor | + +#### Remove Black Matte + +- Event: `RmvB` / `removeBlackMatte` +- PhotoCraft command: `layer.matting.removeBlackMatte` + +No parameters: the step carries an empty descriptor. + +#### Remove White Matte + +- Event: `RmvW` / `removeWhiteMatte` +- PhotoCraft command: `layer.matting.removeWhiteMatte` + +No parameters: the step carries an empty descriptor. + +#### Gradient Map (direct) + +- Event: `GrMp` (no string ID) +- PhotoCraft command: `image.adjustments.gradientMap` +- The `gradient` object is the same gradient form the adjustment layer already maps; reuse that code. + +| Key | String ID | Type | Unit | Default | Allowed | +|---|---|---|---|---|---| +| `` | gradientsInterpolationMethod | enumerated | | gradientInterpolationMethodType.'Gcls' classic | | +| `Grad` | gradient | object | | obj 'Grdn' gradientClassEvent | | + +Nested values as recorded (defaults): +``` + 'Nm ' name : STRINGTYPE = "Vorder- zu Hintergrundfarbe" + 'GrdF' gradientForm : ENUMERATEDTYPE = 'GrdF' gradientForm.'CstS' customStops + 'Intr' interfaceIconFrameDimmed : DOUBLETYPE = 4096 + 'Clrs' colors : LISTTYPE = list[2] + [0] OBJECTTYPE = obj 'Clrt' colorStop + 'Clr ' color : OBJECTTYPE = obj 'RGBC' RGBColor + 'Rd ' red : DOUBLETYPE = 0 + 'Grn ' green : DOUBLETYPE = 0 + 'Bl ' blue : DOUBLETYPE = 0 + 'Type' type : ENUMERATEDTYPE = 'Clry' colorStopType.'UsrS' userStop + 'Lctn' location : INTEGERTYPE = 0 + 'Mdpn' midpoint : INTEGERTYPE = 50 + [1] OBJECTTYPE = obj 'Clrt' colorStop + 'Clr ' color : OBJECTTYPE = obj 'RGBC' RGBColor + 'Rd ' red : DOUBLETYPE = 255 + 'Grn ' green : DOUBLETYPE = 255 + 'Bl ' blue : DOUBLETYPE = 255 + 'Type' type : ENUMERATEDTYPE = 'Clry' colorStopType.'UsrS' userStop + 'Lctn' location : INTEGERTYPE = 4096 + 'Mdpn' midpoint : INTEGERTYPE = 50 + 'Trns' transparency : LISTTYPE = list[2] + [0] OBJECTTYPE = obj 'TrnS' transferSpec + 'Opct' opacity : UNITDOUBLE = 100 '#Prc' percentUnit + 'Lctn' location : INTEGERTYPE = 0 + 'Mdpn' midpoint : INTEGERTYPE = 50 + [1] OBJECTTYPE = obj 'TrnS' transferSpec + 'Opct' opacity : UNITDOUBLE = 100 '#Prc' percentUnit + 'Lctn' location : INTEGERTYPE = 4096 + 'Mdpn' midpoint : INTEGERTYPE = 50 +``` + +#### Average + +- Event: `Avrg` (no string ID) +- PhotoCraft command: `filter.blur.average` + +No parameters: the step carries an empty descriptor. + +#### Custom + +- Event: `Cstm` / `custom` +- PhotoCraft command: `filter.other.custom` + +| Key | String ID | Type | Unit | Default | Allowed | +|---|---|---|---|---|---| +| `Scl ` | scale | integer | | 1 | ignored without the dialog; see "Events that need a step recorded through their dialog" | +| `Ofst` | offset | integer | | 0 | ignored without the dialog; see "Events that need a step recorded through their dialog" | +| `Mtrx` | matrix | list | | list[25] | exactly 25 integers (5 x 5, row by row); a 3 x 3 list makes the application fail | + +Nested values as recorded (defaults): +``` + [0] 0 + [1] 0 + [2] 0 + [3] 0 + [4] 0 + [5] 0 +``` + +#### Replace Color + +- Event: `RplC` / `replaceColor` +- PhotoCraft command: `image.adjustments.replaceColor` + +| Key | String ID | Type | Unit | Default | Allowed | +|---|---|---|---|---|---| +| `Fzns` | fuzziness | integer | | 40 | 0 .. 200 (out-of-range values are clamped) | +| `Mnm ` | minimum | object | | obj 'LbCl' labColor | | +| `Mxm ` | maximum | object | | obj 'LbCl' labColor | | +| `H ` | hue | integer | | 30 | -180 .. 180 (out-of-range values are clamped) | +| `Strt` | saturation | integer | | 0 | -100 .. 100 (out-of-range values are clamped) | +| `Lght` | lightness | integer | | 0 | -100 .. 100 (out-of-range values are clamped) | +| `` | colorModel | integer | | 0 | | + +Nested values as recorded (defaults): +``` + 'Lmnc' luminance : DOUBLETYPE = 40 + 'A ' a : DOUBLETYPE = 30 + 'B ' b : DOUBLETYPE = 40 + 'Lmnc' luminance : DOUBLETYPE = 40 + 'A ' a : DOUBLETYPE = 30 + 'B ' b : DOUBLETYPE = 40 +``` + +### Group F: Selection combinations, Paste Into/Outside, Fade, Link, Distribute, Define Pattern and older merge/mask events + +Steps recorded in an ordinary editing session. The values below were measured in a document set up for each step (a selection, the clipboard, linked or several selected layers). Align, Copy/Paste Layer Style and Rename are already mapped under their current events and are not repeated here. + +#### Add to Selection (rectangle) + +- Event: `AddT` / `addTo` +- PhotoCraft command: `select.rect {"mode":"add"}` +- Shift-drag with the Rectangular Marquee. + +| Key | String ID | Type | Unit | Default | Allowed | +|---|---|---|---|---|---| +| `null` | null | reference | | ref 'Chnl' channel prop 'fsel' selection | | +| `T ` | to | object | | obj 'Rctn' rectangle | | + +Nested values as recorded (defaults): +``` + 'Top ' top : UNITDOUBLE = 30 '#Pxl' pixelsUnit + 'Left' left : UNITDOUBLE = 30 '#Pxl' pixelsUnit + 'Btom' bottom : UNITDOUBLE = 90 '#Pxl' pixelsUnit + 'Rght' right : UNITDOUBLE = 90 '#Pxl' pixelsUnit +``` + +#### Add to Selection (ellipse) + +- Event: `AddT` / `addTo` +- PhotoCraft command: `select.rect {"mode":"add","ellipse":true}` +- Same event with an `Elps` target and anti-aliasing. + +| Key | String ID | Type | Unit | Default | Allowed | +|---|---|---|---|---|---| +| `null` | null | reference | | ref 'Chnl' channel prop 'fsel' selection | | +| `T ` | to | object | | obj 'Elps' ellipse | | +| `AntA` | antiAlias | boolean | | true | | + +Nested values as recorded (defaults): +``` + 'Top ' top : UNITDOUBLE = 30 '#Pxl' pixelsUnit + 'Left' left : UNITDOUBLE = 30 '#Pxl' pixelsUnit + 'Btom' bottom : UNITDOUBLE = 90 '#Pxl' pixelsUnit + 'Rght' right : UNITDOUBLE = 90 '#Pxl' pixelsUnit +``` + +#### Subtract from Selection + +- Event: `SbtF` / `subtractFrom` +- PhotoCraft command: `select.rect {"mode":"subtract"}` + +| Key | String ID | Type | Unit | Default | Allowed | +|---|---|---|---|---|---| +| `null` | null | reference | | ref 'Chnl' channel prop 'fsel' selection | | +| `T ` | to | object | | obj 'Rctn' rectangle | | + +Nested values as recorded (defaults): +``` + 'Top ' top : UNITDOUBLE = 30 '#Pxl' pixelsUnit + 'Left' left : UNITDOUBLE = 30 '#Pxl' pixelsUnit + 'Btom' bottom : UNITDOUBLE = 60 '#Pxl' pixelsUnit + 'Rght' right : UNITDOUBLE = 60 '#Pxl' pixelsUnit +``` + +#### Intersect with Selection + +- Event: `IntW` / `interfaceWhite` +- PhotoCraft command: `select.rect {"mode":"intersect"}` + +| Key | String ID | Type | Unit | Default | Allowed | +|---|---|---|---|---|---| +| `null` | null | reference | | ref 'Chnl' channel prop 'fsel' selection | | +| `T ` | to | object | | obj 'Rctn' rectangle | | + +Nested values as recorded (defaults): +``` + 'Top ' top : UNITDOUBLE = 30 '#Pxl' pixelsUnit + 'Left' left : UNITDOUBLE = 30 '#Pxl' pixelsUnit + 'Btom' bottom : UNITDOUBLE = 120 '#Pxl' pixelsUnit + 'Rght' right : UNITDOUBLE = 120 '#Pxl' pixelsUnit +``` + +#### Paste Into + +- Event: `PstI` / `pasteInto` +- PhotoCraft command: `edit.pasteSpecial.pasteInto` + +| Key | String ID | Type | Unit | Default | Allowed | +|---|---|---|---|---|---| +| `AntA` | antiAlias | enumerated | | `Anno` antiAliasNone | not measured yet | +| `As ` | as | class | | class 'Pxel' pixel | | + +#### Paste Outside + +- Event: `PstO` / `pasteOutside` +- PhotoCraft command: `edit.pasteSpecial.pasteOutside` + +| Key | String ID | Type | Unit | Default | Allowed | +|---|---|---|---|---|---| +| `AntA` | antiAlias | enumerated | | `Anno` antiAliasNone | not measured yet | +| `As ` | as | class | | class 'Pxel' pixel | | + +#### Fade + +- Event: `Fade` / `fade` +- PhotoCraft command: `edit.fade` +- Recorded right after the step it fades. + +| Key | String ID | Type | Unit | Default | Allowed | +|---|---|---|---|---|---| +| `Opct` | opacity | unitdouble | `#Prc` | 50 | 0 .. 100 (out-of-range values are clamped) | +| `Md ` | mode | enumerated | | `Mltp` multiply | not measured yet | + +#### Link Layers + +- Event: `` / `linkSelectedLayers` +- PhotoCraft command: `layer.linkLayers` + +| Key | String ID | Type | Unit | Default | Allowed | +|---|---|---|---|---|---| +| `null` | null | reference | | ref 'Lyr ' layer enum 'Ordn' ordinal.'Trgt' targetEnum | | + +#### Distribute + +- Event: `Dstr` / `distort` +- PhotoCraft command: `layer.distribute.leftEdges (one command per edge)` +- `Usng` selects the edge or centre; one enum value per Distribute menu item. + +| Key | String ID | Type | Unit | Default | Allowed | +|---|---|---|---|---|---| +| `null` | null | reference | | ref 'Lyr ' layer enum 'Ordn' ordinal.'Trgt' targetEnum | | +| `Usng` | using | enumerated | | `AdLf` ADSLefts | not measured yet | + +#### Remove Layer Mask (older files) + +- Event: `RmvL` / `removeLayerMask` +- PhotoCraft command: `layer.layerMask.delete` +- The reference application asks "Apply mask before deleting?" when this step plays without the apply choice; see the table for the key it records. + +No parameters: the step carries an empty descriptor. + +#### Define Pattern + +- Event: `DfnP` / `definePattern` +- PhotoCraft command: `edit.definePattern` + +No parameters: the step carries an empty descriptor. + +#### Merge Layers (older files) + +- Event: `MrgL` (no string ID) +- PhotoCraft command: `layer.mergeLayers` + +No parameters: the step carries an empty descriptor. + +### Group G: Split Channels, Merge Spot Channel, Trap and Match Color + +Channel and colour steps. Trap was measured in a CMYK document (it only exists there). + +#### Split Channels + +- Event: `SplC` / `splitChannels` +- PhotoCraft command: `channel.split` + +| Key | String ID | Type | Unit | Default | Allowed | +|---|---|---|---|---|---| +| `` | documentIDs | list | | list[3] | | + +Nested values as recorded (defaults): +``` + [0] 182 + [1] 185 + [2] 188 +``` + +#### Merge Spot Channel + +- Event: `MSpt` / `mergeSpotChannel` +- PhotoCraft command: `channel.mergeSpot` + +No parameters: the step carries an empty descriptor. + +#### Trap + +- Event: `Trap` / `trap` +- PhotoCraft command: `image.trap` + +| Key | String ID | Type | Unit | Default | Allowed | +|---|---|---|---|---|---| +| `Wdth` | width | unitdouble | `#Pxl` | 2 | up to 32767 (clamped); the minimum was not measured (a huge negative value ran out of memory) | + +#### Match Color + +- Event: `` / `matchColor` +- PhotoCraft command: `image.adjustments.matchColor` + +| Key | String ID | Type | Unit | Default | Allowed | +|---|---|---|---|---|---| +| `Lght` | lightness | integer | | 100 | | +| `ClrR` | colorRange | integer | | 100 | | +| `Fade` | fade | integer | | 0 | | +| `` | noReference | boolean | | true | | + +### Group I: Newer features (Focus Area, Remove Background, Select Sky, Oil Paint, Smart Sharpen) + +These events only have a string ID (no 4-character code). Steps recorded in newer versions use them. + +#### Select > Focus Area + +- Event: `` / `focusMask` +- PhotoCraft command: `select.focusArea` + +No parameters: the step carries an empty descriptor. + +#### Remove Background (quick action) + +- Event: `` / `removeBackground` +- PhotoCraft command: `layer.removeBackground` + +No parameters: the step carries an empty descriptor. + +#### Select > Sky + +- Event: `` / `selectSky` +- PhotoCraft command: `select.sky` + +| Key | String ID | Type | Unit | Default | Allowed | +|---|---|---|---|---|---| +| `` | sampleAllLayers | boolean | | false | | + +#### Filter > Stylize > Oil Paint + +- Event: `` / `oilPaint` +- PhotoCraft command: `filter.stylize.oilPaint` + +| Key | String ID | Type | Unit | Default | Allowed | +|---|---|---|---|---|---| +| `` | lightingOn | boolean | | true | | +| `` | stylization | double | | 4 | | +| `` | cleanliness | double | | 2.29999995231628 | | +| `` | brushScale | double | | 0.80000001192093 | | +| `` | microBrush | double | | 10 | | +| `LghD` | lightDirection | integer | | -60 | | +| `` | specularity | double | | 1.29999995231628 | | + +#### Filter > Sharpen > Smart Sharpen + +- Event: `` / `smartSharpen` +- PhotoCraft command: `filter.sharpen.smartSharpen` + +| Key | String ID | Type | Unit | Default | Allowed | +|---|---|---|---|---|---| +| `` | presetKind | enumerated | | presetKindType. presetKindCustom | | +| `` | useLegacy | boolean | | true | | +| `Amnt` | amount | unitdouble | `#Prc` | 200 | | +| `Rds ` | radius | unitdouble | `#Pxl` | 1 | | +| `` | moreAccurate | boolean | | false | | +| `blur` | blur | enumerated | | blurType. lensBlur | | + +### Events that need a step recorded through their dialog + +These carry settings that only exist once their dialog was used, so a dialog-less run returns no +values or ignores the ones it was given. They are listed so nobody measures them again from scratch; +their tables will be added once a recorded step was read. + +| Event | Why | +|---|---| +| Lighting Effects | The light list only appears after the dialog. | +| Displace | Reads a displacement map file chosen in the dialog. | +| Fibers (`Vrnc`, `Strg` ranges) | Values outside the dialog's range are returned unchanged, not clamped; the limits come from the dialog. | +| Custom | Without the dialog, scale and offset are ignored, and a matrix that is not 5 x 5 makes the application fail. | +| Shear | Probing the point list made the application fail; the limits come from the dialog. | +| Trace Contour | The step's level and edge are applied, but the returned descriptor always shows the defaults (level 128, upper). Lower (`Lwr `) is valid; levels at or below 0 and at or above 255 give the same image. | +| Gradient tool | The drag needs a full gradient object (see Gradient Map in group E for its form). | +| Batch, Create Droplet, Print | File and printer settings come from their dialogs; Batch's file naming is still unknown (a step written with PhotoCraft's current naming rules is refused). | +| Sky Replacement, Smart Object commands (embed, link, relink, replace, export contents), Convert Stroke to Fill, Rasterize All Layers | Known to the application but need a matching document (a sky, a smart object, a shape with a stroke). | +| Generative Fill (`syntheticFill`) | AI feature: in PhotoCraft this goes through the shared AI layer; until then such steps stay kept steps. | + +### Recorded under another event + +Some older events are no longer what the application records; PhotoCraft already maps the current one: +Clear (recorded as Delete), Rasterize Type (`rasterizeLayer`), Convert to Profile (`convertToProfile`), +Copy and Paste Layer Style (`copyEffects`, `pasteEffects`), Align (`align`), Rename Layer (Set of the +layer's name), Deselect (Set of the selection to None). Steps recorded with Backlight, Fill Flash, Color +Cast, Variations, Texture Fill and the old Snapshot events come only from very old versions and stay kept +steps. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 5ef09a2e7e..1fc994a94e 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-26 · **Change:** minor (TIFFs up to 300,000 px a side open, decoded a group of rows at a time into the layer, #8169; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; PSD Filter Gallery: Halftone Pattern dot/circle/line settings survive PSD/PSB saves (#8940); Save for Web writes WebP, lossy or lossless, #6391; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency, #8877; layered TIFF writes the TySh text version and descriptor in PSD big-endian order while the header, matrix, warp and bounds keep container order, #9098; layered TIFF writes Photoshop's 14-byte global layer mask, so Photoshop opens the layers instead of only the composite, #9095; legacy PSD point text placed from the layout ink box after font resolution, #6094; `.atn`: 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; `.asl` layer style libraries read and written, #1051; JPEG Options on File › Save, Save As and Save a Copy, FILE-215-19 partial (no Matte, progressive Scans or size preview); 32-bit Hue/Saturation unclipped and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; JPEG Options on Save As and Save a Copy, FILE-215-19; TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; Cineon write: 10-bit gray/RGB; animated WebP frames load into video layers, #1198; Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 53 filters (39 more: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag, Invert, Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding; Amiga IFF write: 24-plane ILBM, Save As `.iff`; PSD stores spot channels as Photoshop does, ink 0 = solid, and numbers them in 1053, #7415 split; TIFF writes and reads alpha and spot channels as extra samples with Photoshop's channel resources, PSD output unchanged, FILE-215-7, FILE-215-17's TIFF part, #7415; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-11-01 · **Change:** minor (`.atn`: event reference [atn.md](atn.md) with the supported events and the measured keys, defaults, ranges and enum values of 101 unmapped events; TIFFs up to 300,000 px a side open, decoded a group of rows at a time into the layer, #8169; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; PSD Filter Gallery: Halftone Pattern dot/circle/line settings survive PSD/PSB saves (#8940); Save for Web writes WebP, lossy or lossless, #6391; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency, #8877; layered TIFF writes the TySh text version and descriptor in PSD big-endian order while the header, matrix, warp and bounds keep container order, #9098; layered TIFF writes Photoshop's 14-byte global layer mask, so Photoshop opens the layers instead of only the composite, #9095; legacy PSD point text placed from the layout ink box after font resolution, #6094; `.atn`: 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; `.asl` layer style libraries read and written, #1051; JPEG Options on File › Save, Save As and Save a Copy, FILE-215-19 partial (no Matte, progressive Scans or size preview); 32-bit Hue/Saturation unclipped and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; JPEG Options on Save As and Save a Copy, FILE-215-19; TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; Cineon write: 10-bit gray/RGB; animated WebP frames load into video layers, #1198; Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 53 filters (39 more: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag, Invert, Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding; Amiga IFF write: 24-plane ILBM, Save As `.iff`; PSD stores spot channels as Photoshop does, ink 0 = solid, and numbers them in 1053, #7415 split; TIFF writes and reads alpha and spot channels as extra samples with Photoshop's channel resources, PSD output unchanged, FILE-215-7, FILE-215-17's TIFF part, #7415; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency) · **Target:** Adobe Photoshop 2026 **PSD Filter Gallery (#8940, 2026-10-23):** Halftone Pattern maps its size, contrast and dot/circle/line pattern as editable settings on import and export, with the reference application's descriptor keys (`HlSz`, `Cntr`, `ScrT`: `ScrD`/`ScrC`/`ScrL`). PSD/PSB round trips pass at 8/16/32 bits. This does not claim pixel equivalence for the existing renderer. Unknown or malformed settings remain verbatim on import. @@ -133,7 +133,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | LUTs `.cube` / `.3dl` / `.look` | R/W (Export Color Lookup) | R/W / R / R | `cms/src/lutfile.rs` | | Keyboard shortcuts `.kys` | R/W | R/W | `engine/src/kys.rs` (`write`: Edit › Keyboard Shortcuts › Export…, rows carry our id; Photoshop reading our files is unverified) | | ICC profiles | R | R | Own CMS | -| Actions `.atn` | R/W | R/W | `psd/src/atn.rs` (version 16 sets, exact round trip), `engine/src/atn_cmds.rs` (Photoshop events ↔ commands, including Apply Layer Comp by name, #7101; other steps kept verbatim; Save As maps to `file.saveAs`/`file.saveACopy` when its destination is a file, #7102). Panel menu, CLI `batch --actions set.atn`, `actions.load`/`actions.save`. Photoshop 5-era version 12 files unsupported (AUTO-219-2) | +| Actions `.atn` ([reference](atn.md)) | R/W | R/W | `psd/src/atn.rs` (version 16 sets, exact round trip), `engine/src/atn_cmds.rs` (Photoshop events ↔ commands, including Apply Layer Comp by name, #7101; other steps kept verbatim; Save As maps to `file.saveAs`/`file.saveACopy` when its destination is a file, #7102). Panel menu, CLI `batch --actions set.atn`, `actions.load`/`actions.save`. Photoshop 5-era version 12 files unsupported (AUTO-219-2) | | Styles `.asl` | R/W | R/W | `psd/src/asl.rs`, `io/src/asl_map.rs`; Styles panel Import/Export Styles…, `style.presets.importAsl`/`exportAsl` (#1051) | | Contours `.shc` | R/W | R/W | `psd/src/shc.rs`; Contour Editor Load…/Save… (FILE-215-15). No user contour library; not yet checked against a Photoshop-written file | | Custom shapes `.csh`, tool presets `.tpl` | R/W | no | | @@ -148,6 +148,7 @@ Embedded SVG and PDF-compatible AI/PDF/EPS payloads can use an explicit VectorCr | Date | Change | Summary | |---|---|---| +| 2026-11-01 | minor | `.atn`: event reference [atn.md](atn.md): the 117 supported events, and measured keys, defaults, ranges and enum values for 101 events not mapped yet | | 2026-10-26 | minor | Embedded AI/PDF/EPS save-back retains native format and a separate source-bound display preview; original sources remain undoable. Native Adobe interoperability and standalone EPS/DCS remain unverified; no corpus floor or percentage changed. | | 2026-10-22 | minor | Embedded PSD vectors gain explicit bounded SVG editor/save-back; standalone EPS/DCS and native-format fidelity remain open. | | 2026-10-22 | minor | TIFF: flat pages up to 300,000 px a side open (was 2^30 px, about 32,768²): `codecs::TiffBands` decodes a group of rows at a time into the layer's tiles. The 117,504 × 22,464 px banner from #8456 opens in 3.3 s at 11.45 GB peak for a 10.56 GB layer; 30,000² 6.8 s / 6.43 GB → 1.1 s / 3.86 GB; 36 MP 220 → 52 ms. Layered TIFFs still decode whole (#8169) | From 47287016d3b062ea91296286b8a6296a038d60a6 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 1 Nov 2026 02:31:47 +0100 Subject: [PATCH 021/216] Docs (atn): fix the Filter Gallery row, mark example values, settle two specs - The supported table read the command list of the next entry for an event whose list is a constant: Filter Gallery (`GEfc`) maps to `filter.gallery.spatter`, not Shadows/Highlights. The table now resolves constants and reads each entry alone. - Steps measured in a prepared document (Fade, Replace Color, Trap, selection combinations, ...) show the example values that were sent, labelled as such, not as factory defaults. - Mosaic cell size: write `#Pxl`, read a value without a unit as pixels. - Custom: no factory matrix; the test matrix is no longer shown as defaults. - Intro: kept steps with no document effect are imported switched off and skipped. Co-Authored-By: Claude Opus 5.5 --- docs/atn.md | 82 ++++++++++++++++++++++++++++++----------------------- 1 file changed, 46 insertions(+), 36 deletions(-) diff --git a/docs/atn.md b/docs/atn.md index bcacabbb44..0d280e147c 100644 --- a/docs/atn.md +++ b/docs/atn.md @@ -8,8 +8,10 @@ application's events and PhotoCraft commands lives in `photocraft-engine` (`crates/engine/src/cmd/atn_cmds/`, table `EVENTS` in `events.rs`). A step whose event is in the table below loads as the PhotoCraft command it names, plays, and saves back -as the same event. Any other step loads as a kept step (`actions.photoshopStep`): Save Actions writes it -back unchanged, and playback stops at it with its name, so an action never silently skips work. +as the same event. Any other step loads as a kept step (`actions.photoshopStep`), and Save Actions writes +it back unchanged. Playback stops at an enabled kept step with its name, so an action never silently skips +work. Kept steps with no effect on the document (tool selections and the like) are imported switched off, +so playback skips them. This page has two parts: the events PhotoCraft maps today, and a measured reference for the events it does not map yet, with everything needed to implement them without the reference application. @@ -67,7 +69,7 @@ does not map yet, with everything needed to implement them without the reference | `Exps` | Exps | exposure | `Exposure` | | `Fthr` | feather | Feather | `select.modify.feather` | | `Fl ` | fill | Fill | `edit.fill`, `paint.bucket` | -| `GEfc` | filterGallery | Filter Gallery | `image.adjustments.shadowsHighlights` | +| `GEfc` | filterGallery | Filter Gallery | `filter.gallery.spatter` | | `FltI` | flattenImage | Flatten Image | `layer.flattenImage` | | `Flip` | flip | Flip | `image.imageRotation.flipCanvasHorizontal`, `image.imageRotation.flipCanvasVertical`, `edit.transform.flipHorizontal`, `edit.transform.flipVertical` | | `GsnB` | gaussianBlur | Gaussian Blur | `filter.blur.gaussianBlur` | @@ -83,7 +85,7 @@ does not map yet, with everything needed to implement them without the reference | `CpTL` | copyToLayer | Layer Via Copy | `layer.new.layerViaCopy` | | `CtTL` | cutToLayer | Layer Via Cut | `layer.new.layerViaCut` | | `Lvls` | levels | Levels | `image.adjustments.levels`, `image.autoTone`, `image.autoContrast` | -| `Mk ` | make | Make | | +| `Mk ` | make | Make | `layer.new.layer`, `layer.new.group`, `layer.groupLayers`, `layer.duplicate`, `layer.new.layerViaCopy`, `layer.new.layerViaCut`, `layer.newFillLayer.solidColor`, `layer.newFillLayer.pattern`, `layer.newAdjustmentLayer.levels`, `layer.newAdjustmentLayer.curves`, `layer.newAdjustmentLayer.hueSaturation`, `layer.newAdjustmentLayer.brightnessContrast`, `layer.newAdjustmentLayer.threshold`, `layer.newAdjustmentLayer.posterize`, `layer.newAdjustmentLayer.invert`, `layer.newAdjustmentLayer.colorBalance`, `layer.newAdjustmentLayer.vibrance`, `layer.newAdjustmentLayer.exposure`, `layer.newAdjustmentLayer.photoFilter`, `layer.newAdjustmentLayer.blackWhite`, `layer.newAdjustmentLayer.channelMixer`, `layer.newAdjustmentLayer.selectiveColor`, `layer.newAdjustmentLayer.gradientMap`, `layer.layerMask.revealAll`, `layer.layerMask.hideAll`, `layer.layerMask.revealSelection`, `layer.layerMask.hideSelection`, `file.new`, `file.newFromClipboard`, `history.newSnapshot` | | (string ID only) | animationFramesFromLayers | Make Frames from Layers | | | `Mxm ` | maximum | Maximum | `filter.other.maximum` | | `Mdn ` | median | Median | `filter.noise.median` | @@ -148,7 +150,9 @@ implemented, move it to the table above in the same change. - **Keys, order, types, units, defaults:** each event was run in the reference application without a dialog on an RGB 8-bit document (with a selection, the clipboard or several layers where the step needs them), and the descriptor it returned was read back. That descriptor is what an action records. - Settings were reset first, so the defaults are factory defaults. + Settings were reset first, so the defaults are factory defaults. Steps that need a prepared document + (a selection, the clipboard, several layers) were run with example values; their tables say so and show + those values instead of defaults. - **Ranges:** every number was set far above and below its range. The application clamps instead of refusing, so the values it returned are the exact limits. "Out-of-range values are clamped" in a table means exactly that; implement the same clamping on import. @@ -965,7 +969,7 @@ No parameters: the step carries an empty descriptor. | Key | String ID | Type | Unit | Default | Allowed | |---|---|---|---|---|---| -| `ClSz` | cellSize | unitdouble | | 2 (unit `#Pxl`) | 2 .. 200 (out-of-range values are clamped); the unit is `#Pxl`. The application returns the default without a unit, but accepts and returns `#Pxl`. | +| `ClSz` | cellSize | unitdouble | | 2 | 2 .. 200 pixels (out-of-range values are clamped). Write it as a unit double in `#Pxl`: the application accepts that and returns it unchanged. Read a value without a unit as pixels (the application returns its own default that way). | #### Pointillize @@ -1069,6 +1073,7 @@ Nested values as recorded (defaults): - Event: `Avrg` (no string ID) - PhotoCraft command: `filter.blur.average` +- Measured with example values in a prepared document: the Value column shows what was sent and recorded, not a factory default (a step always carries the values the user set). No parameters: the step carries an empty descriptor. @@ -1076,29 +1081,21 @@ No parameters: the step carries an empty descriptor. - Event: `Cstm` / `custom` - PhotoCraft command: `filter.other.custom` +- Measured with example values in a prepared document: the Value column shows what was sent and recorded, not a factory default (a step always carries the values the user set). -| Key | String ID | Type | Unit | Default | Allowed | +| Key | String ID | Type | Unit | Example value | Allowed | |---|---|---|---|---|---| | `Scl ` | scale | integer | | 1 | ignored without the dialog; see "Events that need a step recorded through their dialog" | | `Ofst` | offset | integer | | 0 | ignored without the dialog; see "Events that need a step recorded through their dialog" | -| `Mtrx` | matrix | list | | list[25] | exactly 25 integers (5 x 5, row by row); a 3 x 3 list makes the application fail | - -Nested values as recorded (defaults): -``` - [0] 0 - [1] 0 - [2] 0 - [3] 0 - [4] 0 - [5] 0 -``` +| `Mtrx` | matrix | list | | none: the 25 cells as set in the dialog | exactly 25 integers (5 x 5, row by row); a 3 x 3 list makes the application fail | #### Replace Color - Event: `RplC` / `replaceColor` - PhotoCraft command: `image.adjustments.replaceColor` +- Measured with example values in a prepared document: the Value column shows what was sent and recorded, not a factory default (a step always carries the values the user set). -| Key | String ID | Type | Unit | Default | Allowed | +| Key | String ID | Type | Unit | Example value | Allowed | |---|---|---|---|---|---| | `Fzns` | fuzziness | integer | | 40 | 0 .. 200 (out-of-range values are clamped) | | `Mnm ` | minimum | object | | obj 'LbCl' labColor | | @@ -1108,7 +1105,7 @@ Nested values as recorded (defaults): | `Lght` | lightness | integer | | 0 | -100 .. 100 (out-of-range values are clamped) | | `` | colorModel | integer | | 0 | | -Nested values as recorded (defaults): +Nested values as recorded (example values): ``` 'Lmnc' luminance : DOUBLETYPE = 40 'A ' a : DOUBLETYPE = 30 @@ -1127,13 +1124,14 @@ Steps recorded in an ordinary editing session. The values below were measured in - Event: `AddT` / `addTo` - PhotoCraft command: `select.rect {"mode":"add"}` - Shift-drag with the Rectangular Marquee. +- Measured with example values in a prepared document: the Value column shows what was sent and recorded, not a factory default (a step always carries the values the user set). -| Key | String ID | Type | Unit | Default | Allowed | +| Key | String ID | Type | Unit | Example value | Allowed | |---|---|---|---|---|---| | `null` | null | reference | | ref 'Chnl' channel prop 'fsel' selection | | | `T ` | to | object | | obj 'Rctn' rectangle | | -Nested values as recorded (defaults): +Nested values as recorded (example values): ``` 'Top ' top : UNITDOUBLE = 30 '#Pxl' pixelsUnit 'Left' left : UNITDOUBLE = 30 '#Pxl' pixelsUnit @@ -1146,14 +1144,15 @@ Nested values as recorded (defaults): - Event: `AddT` / `addTo` - PhotoCraft command: `select.rect {"mode":"add","ellipse":true}` - Same event with an `Elps` target and anti-aliasing. +- Measured with example values in a prepared document: the Value column shows what was sent and recorded, not a factory default (a step always carries the values the user set). -| Key | String ID | Type | Unit | Default | Allowed | +| Key | String ID | Type | Unit | Example value | Allowed | |---|---|---|---|---|---| | `null` | null | reference | | ref 'Chnl' channel prop 'fsel' selection | | | `T ` | to | object | | obj 'Elps' ellipse | | | `AntA` | antiAlias | boolean | | true | | -Nested values as recorded (defaults): +Nested values as recorded (example values): ``` 'Top ' top : UNITDOUBLE = 30 '#Pxl' pixelsUnit 'Left' left : UNITDOUBLE = 30 '#Pxl' pixelsUnit @@ -1165,13 +1164,14 @@ Nested values as recorded (defaults): - Event: `SbtF` / `subtractFrom` - PhotoCraft command: `select.rect {"mode":"subtract"}` +- Measured with example values in a prepared document: the Value column shows what was sent and recorded, not a factory default (a step always carries the values the user set). -| Key | String ID | Type | Unit | Default | Allowed | +| Key | String ID | Type | Unit | Example value | Allowed | |---|---|---|---|---|---| | `null` | null | reference | | ref 'Chnl' channel prop 'fsel' selection | | | `T ` | to | object | | obj 'Rctn' rectangle | | -Nested values as recorded (defaults): +Nested values as recorded (example values): ``` 'Top ' top : UNITDOUBLE = 30 '#Pxl' pixelsUnit 'Left' left : UNITDOUBLE = 30 '#Pxl' pixelsUnit @@ -1183,13 +1183,14 @@ Nested values as recorded (defaults): - Event: `IntW` / `interfaceWhite` - PhotoCraft command: `select.rect {"mode":"intersect"}` +- Measured with example values in a prepared document: the Value column shows what was sent and recorded, not a factory default (a step always carries the values the user set). -| Key | String ID | Type | Unit | Default | Allowed | +| Key | String ID | Type | Unit | Example value | Allowed | |---|---|---|---|---|---| | `null` | null | reference | | ref 'Chnl' channel prop 'fsel' selection | | | `T ` | to | object | | obj 'Rctn' rectangle | | -Nested values as recorded (defaults): +Nested values as recorded (example values): ``` 'Top ' top : UNITDOUBLE = 30 '#Pxl' pixelsUnit 'Left' left : UNITDOUBLE = 30 '#Pxl' pixelsUnit @@ -1201,8 +1202,9 @@ Nested values as recorded (defaults): - Event: `PstI` / `pasteInto` - PhotoCraft command: `edit.pasteSpecial.pasteInto` +- Measured with example values in a prepared document: the Value column shows what was sent and recorded, not a factory default (a step always carries the values the user set). -| Key | String ID | Type | Unit | Default | Allowed | +| Key | String ID | Type | Unit | Example value | Allowed | |---|---|---|---|---|---| | `AntA` | antiAlias | enumerated | | `Anno` antiAliasNone | not measured yet | | `As ` | as | class | | class 'Pxel' pixel | | @@ -1211,8 +1213,9 @@ Nested values as recorded (defaults): - Event: `PstO` / `pasteOutside` - PhotoCraft command: `edit.pasteSpecial.pasteOutside` +- Measured with example values in a prepared document: the Value column shows what was sent and recorded, not a factory default (a step always carries the values the user set). -| Key | String ID | Type | Unit | Default | Allowed | +| Key | String ID | Type | Unit | Example value | Allowed | |---|---|---|---|---|---| | `AntA` | antiAlias | enumerated | | `Anno` antiAliasNone | not measured yet | | `As ` | as | class | | class 'Pxel' pixel | | @@ -1222,8 +1225,9 @@ Nested values as recorded (defaults): - Event: `Fade` / `fade` - PhotoCraft command: `edit.fade` - Recorded right after the step it fades. +- Measured with example values in a prepared document: the Value column shows what was sent and recorded, not a factory default (a step always carries the values the user set). -| Key | String ID | Type | Unit | Default | Allowed | +| Key | String ID | Type | Unit | Example value | Allowed | |---|---|---|---|---|---| | `Opct` | opacity | unitdouble | `#Prc` | 50 | 0 .. 100 (out-of-range values are clamped) | | `Md ` | mode | enumerated | | `Mltp` multiply | not measured yet | @@ -1232,8 +1236,9 @@ Nested values as recorded (defaults): - Event: `` / `linkSelectedLayers` - PhotoCraft command: `layer.linkLayers` +- Measured with example values in a prepared document: the Value column shows what was sent and recorded, not a factory default (a step always carries the values the user set). -| Key | String ID | Type | Unit | Default | Allowed | +| Key | String ID | Type | Unit | Example value | Allowed | |---|---|---|---|---|---| | `null` | null | reference | | ref 'Lyr ' layer enum 'Ordn' ordinal.'Trgt' targetEnum | | @@ -1242,8 +1247,9 @@ Nested values as recorded (defaults): - Event: `Dstr` / `distort` - PhotoCraft command: `layer.distribute.leftEdges (one command per edge)` - `Usng` selects the edge or centre; one enum value per Distribute menu item. +- Measured with example values in a prepared document: the Value column shows what was sent and recorded, not a factory default (a step always carries the values the user set). -| Key | String ID | Type | Unit | Default | Allowed | +| Key | String ID | Type | Unit | Example value | Allowed | |---|---|---|---|---|---| | `null` | null | reference | | ref 'Lyr ' layer enum 'Ordn' ordinal.'Trgt' targetEnum | | | `Usng` | using | enumerated | | `AdLf` ADSLefts | not measured yet | @@ -1253,6 +1259,7 @@ Nested values as recorded (defaults): - Event: `RmvL` / `removeLayerMask` - PhotoCraft command: `layer.layerMask.delete` - The reference application asks "Apply mask before deleting?" when this step plays without the apply choice; see the table for the key it records. +- Measured with example values in a prepared document: the Value column shows what was sent and recorded, not a factory default (a step always carries the values the user set). No parameters: the step carries an empty descriptor. @@ -1278,12 +1285,13 @@ Channel and colour steps. Trap was measured in a CMYK document (it only exists t - Event: `SplC` / `splitChannels` - PhotoCraft command: `channel.split` +- Measured with example values in a prepared document: the Value column shows what was sent and recorded, not a factory default (a step always carries the values the user set). -| Key | String ID | Type | Unit | Default | Allowed | +| Key | String ID | Type | Unit | Example value | Allowed | |---|---|---|---|---|---| | `` | documentIDs | list | | list[3] | | -Nested values as recorded (defaults): +Nested values as recorded (example values): ``` [0] 182 [1] 185 @@ -1294,6 +1302,7 @@ Nested values as recorded (defaults): - Event: `MSpt` / `mergeSpotChannel` - PhotoCraft command: `channel.mergeSpot` +- Measured with example values in a prepared document: the Value column shows what was sent and recorded, not a factory default (a step always carries the values the user set). No parameters: the step carries an empty descriptor. @@ -1301,8 +1310,9 @@ No parameters: the step carries an empty descriptor. - Event: `Trap` / `trap` - PhotoCraft command: `image.trap` +- Measured with example values in a prepared document: the Value column shows what was sent and recorded, not a factory default (a step always carries the values the user set). -| Key | String ID | Type | Unit | Default | Allowed | +| Key | String ID | Type | Unit | Example value | Allowed | |---|---|---|---|---|---| | `Wdth` | width | unitdouble | `#Pxl` | 2 | up to 32767 (clamped); the minimum was not measured (a huge negative value ran out of memory) | From d7a2a3362eac54fa1e1ced90583d5ab71afbc329 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Sat, 31 Oct 2026 18:56:53 -0700 Subject: [PATCH 022/216] Import native color component selections from Photoshop actions --- crates/engine/src/cmd/atn_cmds/steps_layer.rs | 25 ++++-- crates/engine/src/cmd/atn_cmds/tests.rs | 90 +++++++++++++++++++ crates/engine/src/cmd/channel_cmds.rs | 2 +- crates/engine/src/cmd/channel_cmds/refs.rs | 7 +- 4 files changed, 117 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/atn_cmds/steps_layer.rs b/crates/engine/src/cmd/atn_cmds/steps_layer.rs index 9d9bf5f3f7..e486cb3d44 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_layer.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_layer.rs @@ -6,9 +6,10 @@ use super::descriptors::{ use super::events::pixel_target; use super::fields::{F, Kind, Mapped, fields_to, none, step, steps}; use super::steps_make::is_tool_class; -use super::tables::{FILL_CONTENTS, INTERPOLATION, MENU_ITEMS, STROKE_LOCATION, blend_label, blend_ps, lookup, lookup_back}; +use super::tables::{CHANNELS, FILL_CONTENTS, INTERPOLATION, MENU_ITEMS, STROKE_LOCATION, blend_label, blend_ps, channel_of, lookup, lookup_back}; use super::{D, Descriptor, Id, Map, ReferenceItem, UnicodeString, Value, json}; +/// Translate supported selection targets, retaining unknown references as Photoshop steps. pub(super) fn select_from(d: &Descriptor) -> Mapped { let Some(r) = get_ref(d, "null") else { return Mapped::Unsupported { harmless: false } }; match (ref_class(r).as_deref(), target(r)) { @@ -29,6 +30,12 @@ pub(super) fn select_from(d: &Descriptor) -> Mapped { "RGB" | "CMYK" | "Lab" | "Gry" => step("channel.target", json!({"channel": "composite"})), // Re-selecting the channel that is already targeted changes nothing. "Trgt" => Mapped::Unsupported { harmless: true }, + _ if r.len() == 1 + && d.get("selectionModifier").is_none() + && matches!(r.first(), Some(ReferenceItem::Enumerated { type_id, .. }) if type_id.is("Chnl")) => + { + channel_of(&o).map_or(Mapped::Unsupported { harmless: false }, |channel| step("channel.target", json!({"channel": {"color": channel}}))) + } _ => Mapped::Unsupported { harmless: false }, }, // History › Step Backward / Step Forward. @@ -60,6 +67,7 @@ pub(super) fn select_from(d: &Descriptor) -> Mapped { } } +/// Encode selections whose target and selection mode can be represented faithfully in ATN. pub(super) fn select_to(id: &str, p: &Value) -> Option { let ord = |o: &str| D::Reference(vec![ReferenceItem::Enumerated { class: class("Lyr "), type_id: code("Ordn"), value: code(o) }]); let enumerated = |cls: &str, ty: &str, v: &str| D::Reference(vec![ReferenceItem::Enumerated { class: class(cls), type_id: code(ty), value: code(v) }]); @@ -80,10 +88,17 @@ pub(super) fn select_to(id: &str, p: &Value) -> Option { d.with("MkVs", D::Boolean(false)) } "channel.target" => { - let v = match jstr(p, "channel") { - Some("mask") => "Msk ", - None | Some("composite") => "RGB ", - Some(_) => return None, + if p.get("colors").is_some() || p.get("extend").is_some_and(|v| v != &Value::Bool(false)) { + return None; + } + let v = match p.get("channel") { + None => "RGB ", + Some(Value::String(channel)) => match channel.as_str() { + "mask" => "Msk ", + "composite" => "RGB ", + _ => return None, + }, + Some(channel) => lookup_back(CHANNELS, &channel.get("color")?.as_str()?.to_ascii_lowercase())?, }; desc("null").with("null", enumerated("Chnl", "Chnl", v)).with("MkVs", D::Boolean(false)) } diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index f9e633244c..1e7abf35be 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -3175,3 +3175,93 @@ fn long_names_are_clipped_instead_of_copied_into_every_diagnostic() { assert_eq!(imp.unsupported.len(), 50); assert!(imp.unsupported.iter().all(|u| u.action.chars().count() == MAX_NAME_CHARS)); } + +/// Native component references import, select the same target as the command and export again. +#[test] +fn select_color_components_import_play_and_round_trip() { + for (mode, channels) in [ + ("rgb", &["red", "green", "blue"][..]), + ("cmyk", &["cyan", "magenta", "yellow", "black"][..]), + ("lab", &["lightness", "a", "b"][..]), + ("gray", &["gray"][..]), + ] { + for depth in [8, 16, 32] { + for channel in channels { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 2, "height": 1, "mode": mode, "depth": depth})).unwrap(); + let expected = s.execute("channel.target", json!({"channel": channel})).unwrap(); + s.execute("channel.target", json!({"channel": "composite"})).unwrap(); + let selection = desc("null").with("null", channel_ref(Some(channel))); + let data = set_of("Components", vec![("Pick", vec![item("select", "Select", Some(selection))])]); + let loaded = s.execute("actions.load", json!({"data": data})).unwrap(); + assert_eq!(loaded["unsupported"], json!([]), "{mode}/{depth}/{channel}"); + let played = s.execute("actions.play", json!({"action": "Pick"})).unwrap(); + assert!(played.get("failed").is_none(), "{played}"); + assert_eq!(s.execute("channel.list", json!({})).unwrap(), expected, "{mode}/{depth}/{channel}"); + let saved = s.execute("actions.save", json!({"set": "Components"})).unwrap(); + let bytes = photocraft_paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap(); + assert_eq!(import(&bytes).unwrap().actions[0].steps, s.actions.list[0].steps); + } + } + } +} + +/// Unknown targets and selection modes retain their failure instead of selecting another channel. +#[test] +fn select_color_components_preserve_unsupported_selection_shapes() { + let chained = vec![ + ReferenceItem::Enumerated { class: class("Chnl"), type_id: code("Chnl"), value: code("Cyn") }, + ReferenceItem::Identifier { class: class("Dcmn"), id: 7 }, + ]; + for selection in [ + desc("null").with("null", channel_ord("Xxxx")), + desc("null").with("null", channel_ref(Some("cyan"))).with("selectionModifier", enm("selectionModifierType", "addToSelection")), + desc("null").with("null", D::Reference(chained)), + desc("null").with("null", D::Reference(vec![ReferenceItem::Enumerated { class: class("Chnl"), type_id: code("Ordn"), value: code("Cyn") }])), + ] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 2, "height": 1, "mode": "cmyk"})).unwrap(); + let data = set_of("Unknown", vec![("Pick", vec![item("select", "Select", Some(selection.clone()))])]); + let loaded = s.execute("actions.load", json!({"data": data})).unwrap(); + assert_eq!(loaded["unsupported"].as_array().unwrap().len(), 1); + assert_eq!(s.actions.list[0].steps[0].0, PHOTOSHOP_STEP); + let target = s.execute("channel.list", json!({})).unwrap(); + let played = s.execute("actions.play", json!({"action": "Pick"})).unwrap(); + assert_eq!(played["failed"]["id"], PHOTOSHOP_STEP); + assert_eq!(s.execute("channel.list", json!({})).unwrap(), target); + let saved = s.execute("actions.save", json!({"set": "Unknown"})).unwrap(); + let bytes = photocraft_paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap(); + assert_eq!(atn::parse(&bytes).unwrap().actions[0].items[0].descriptor.as_ref(), Some(&selection)); + } + for params in [ + json!({"channel": {"color": "cyan"}, "extend": true}), + json!({"channel": {"color": "cyan"}, "colors": [0, 1]}), + json!({"channel": 0}), + json!({"channel": null}), + json!({"channel": {"alpha": 0}}), + json!({"channel": "cyan"}), + ] { + assert!(super::steps_layer::select_to("channel.target", ¶ms).is_none(), "{params}"); + } + assert!(super::steps_layer::select_to("channel.target", &json!({"channel": {"color": "CYAN"}, "extend": false})).is_some()); + let gray = set_of("Gray", vec![("Pick", vec![item("select", "Select", Some(desc("null").with("null", channel_ord("Gry"))))])]); + let bytes = photocraft_paint::tile::b64_decode(&gray).unwrap(); + assert_eq!(import(&bytes).unwrap().actions[0].steps[0], ("channel.target".into(), json!({"channel": "composite"}))); +} + +/// A CMYK component cannot retarget a same-named alpha channel when replayed on RGB. +#[test] +fn select_color_components_do_not_fall_back_to_alpha_names() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 2, "height": 1, "mode": "rgb"})).unwrap(); + s.execute("channel.new", json!({"name": "cyan"})).unwrap(); + for channel in [json!("cyan"), json!(0), json!({"alpha": 0})] { + assert_eq!(s.execute("channel.target", json!({"channel": channel})).unwrap()["target"]["kind"], "alpha"); + } + s.execute("channel.target", json!({"channel": "composite"})).unwrap(); + let data = set_of("Components", vec![("Pick", vec![item("select", "Select", Some(desc("null").with("null", channel_ref(Some("cyan")))))])]); + assert_eq!(s.execute("actions.load", json!({"data": data})).unwrap()["unsupported"], json!([])); + let played = s.execute("actions.play", json!({"action": "Pick"})).unwrap(); + assert_eq!(played["failed"]["id"], "channel.target"); + assert_eq!(s.execute("channel.list", json!({})).unwrap()["target"]["kind"], "composite"); +} diff --git a/crates/engine/src/cmd/channel_cmds.rs b/crates/engine/src/cmd/channel_cmds.rs index 49a45cba6a..f909fab5cd 100644 --- a/crates/engine/src/cmd/channel_cmds.rs +++ b/crates/engine/src/cmd/channel_cmds.rs @@ -206,7 +206,7 @@ pub fn specs() -> Vec { "Target Channel", [], None, - r##"{"channel":"composite"|"mask"|"vectorMask"|"red|green|blue|…"|index|name="composite","colors":[index,…]?,"extend":bool=false} (colors selects native colour components only; extend toggles one colour channel, as Shift-click; all components select the composite)"##, + r##"{"channel":"composite"|"mask"|"vectorMask"|"red|green|blue|…"|index|name|{"color":index|name}|{"alpha":index}="composite","colors":[index,…]?,"extend":bool=false} (a color object selects only a native component, never a named alpha channel; colors selects native colour components only; extend toggles one colour channel, as Shift-click; all components select the composite)"##, has_doc, target_cmd ), diff --git a/crates/engine/src/cmd/channel_cmds/refs.rs b/crates/engine/src/cmd/channel_cmds/refs.rs index 58708475da..cee0461f59 100644 --- a/crates/engine/src/cmd/channel_cmds/refs.rs +++ b/crates/engine/src/cmd/channel_cmds/refs.rs @@ -20,12 +20,17 @@ pub enum ChanRef { VectorMask, } +/// Resolve channel references; a color-name object cannot fall back to a named alpha channel. pub fn parse_ref(v: &Value, doc: &Document) -> Option { let mode = edit_mode(doc); match v { Value::Number(n) => n.as_u64().map(|i| ChanRef::Alpha(i as usize)), Value::Object(o) => { - if let Some(k) = o.get("color").and_then(Value::as_u64) { + if let Some(name) = o.get("color").and_then(Value::as_str) { + let names = color_names(mode); + let k = names.iter().position(|n| n.eq_ignore_ascii_case(name))?; + Some(if names.len() == 1 { ChanRef::Composite } else { ChanRef::Color(k) }) + } else if let Some(k) = o.get("color").and_then(Value::as_u64) { Some(ChanRef::Color(k as usize)) } else { o.get("alpha").and_then(Value::as_u64).map(|i| ChanRef::Alpha(i as usize)) From 7906cd8bc25bde59a55b2edd42d4ef67d87be305 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Sat, 31 Oct 2026 18:58:08 -0700 Subject: [PATCH 023/216] Start Stylize and Pixelate ATN adapters with parameterless events --- crates/engine/src/cmd/atn_cmds.rs | 4 +++ crates/engine/src/cmd/atn_cmds/events.rs | 17 +++++++++++ .../engine/src/cmd/atn_cmds/steps_stylize.rs | 15 ++++++++++ .../engine/src/cmd/atn_cmds/stylize_tests.rs | 29 +++++++++++++++++++ crates/engine/src/cmd/atn_cmds/tests.rs | 4 +++ 5 files changed, 69 insertions(+) create mode 100644 crates/engine/src/cmd/atn_cmds/steps_stylize.rs create mode 100644 crates/engine/src/cmd/atn_cmds/stylize_tests.rs diff --git a/crates/engine/src/cmd/atn_cmds.rs b/crates/engine/src/cmd/atn_cmds.rs index 42a4722479..1a305dbc92 100644 --- a/crates/engine/src/cmd/atn_cmds.rs +++ b/crates/engine/src/cmd/atn_cmds.rs @@ -36,6 +36,7 @@ mod steps_doc; mod steps_image; mod steps_layer; mod steps_make; +mod steps_stylize; mod tables; #[cfg(test)] @@ -145,3 +146,6 @@ mod tests; #[cfg(test)] mod layer_props_tests; + +#[cfg(test)] +mod stylize_tests; diff --git a/crates/engine/src/cmd/atn_cmds/events.rs b/crates/engine/src/cmd/atn_cmds/events.rs index 5723c93c59..beaa2d6f31 100644 --- a/crates/engine/src/cmd/atn_cmds/events.rs +++ b/crates/engine/src/cmd/atn_cmds/events.rs @@ -52,6 +52,19 @@ macro_rules! ev { }; } +macro_rules! stylize_event { + ($code:literal, $name:literal, $label:literal, $id:literal) => { + Event { + code: $code, + name: $name, + label: $label, + ids: &[$id], + from: |d| super::steps_stylize::from($id, d), + to: |_, p| if pixel_target(p) { super::steps_stylize::to($id, p) } else { None }, + } + }; +} + /// Minimum / Maximum with Preserve: Roundness. The descriptor here maps the radius only, and /// Photoshop's encoding of the shape isn't verified, so such a step is not exported: it would /// replay as the default Squareness (#7233). @@ -185,6 +198,10 @@ macro_rules! clipping_event { /// Every Photoshop event PhotoCraft maps. The order matters only for the panel label. pub(super) const EVENTS: &[Event] = &[ // Filters. + stylize_event!("FndE", "findEdges", "Find Edges", "filter.stylize.findEdges"), + stylize_event!("Slrz", "solarize", "Solarize", "filter.stylize.solarize"), + stylize_event!("Fct ", "facet", "Facet", "filter.pixelate.facet"), + stylize_event!("Frgm", "fragment", "Fragment", "filter.pixelate.fragment"), ev!("GsnB", "gaussianBlur", "Gaussian Blur", "filter.blur.gaussianBlur", &[F("Rds ", "radius", Kind::Px)]), ev!( "UnsM", diff --git a/crates/engine/src/cmd/atn_cmds/steps_stylize.rs b/crates/engine/src/cmd/atn_cmds/steps_stylize.rs new file mode 100644 index 0000000000..65b859a301 --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/steps_stylize.rs @@ -0,0 +1,15 @@ +//! Descriptor adapters for the measured Stylize and Pixelate action events. + +use super::descriptors::desc; +use super::fields::{Mapped, step}; +use super::{Descriptor, Value, json}; + +pub(super) fn from(id: &str, d: &Descriptor) -> Mapped { + if d.items.is_empty() { step(id, json!({})) } else { Mapped::Unsupported { harmless: false } } +} + +pub(super) fn to(_id: &str, p: &Value) -> Option { + let params = p.as_object()?; + // These events have no fields for a layer reference or any native filter settings. + params.keys().all(|key| key == "target").then(|| desc("null")) +} diff --git a/crates/engine/src/cmd/atn_cmds/stylize_tests.rs b/crates/engine/src/cmd/atn_cmds/stylize_tests.rs new file mode 100644 index 0000000000..68cc61c02e --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/stylize_tests.rs @@ -0,0 +1,29 @@ +use super::*; + +#[test] +fn parameterless_stylize_and_pixelate_events_use_both_event_id_forms() { + for (code, name, id) in [ + (*b"FndE", "findEdges", "filter.stylize.findEdges"), + (*b"Slrz", "solarize", "filter.stylize.solarize"), + (*b"Fct ", "facet", "filter.pixelate.facet"), + (*b"Frgm", "fragment", "filter.pixelate.fragment"), + ] { + for event in [AtnEvent::Code(code), AtnEvent::name(name)] { + let file = AtnFile { + name: UnicodeString::new_nul("Group D"), + actions: vec![AtnAction { + name: UnicodeString::new_nul("A"), + items: vec![AtnItem { event, descriptor: Some(desc("null")), ..Default::default() }], + ..Default::default() + }], + ..Default::default() + }; + let imported = import(&atn::write(&file).unwrap()).unwrap(); + assert!(imported.unsupported.is_empty()); + assert_eq!(imported.actions[0].steps, vec![(id.into(), json!({}))]); + assert_eq!(export_item(id, &json!({}), "Group D").unwrap().descriptor, Some(desc("null"))); + assert!(export_item(id, &json!({"layer": 17}), "Group D").is_none()); + assert!(export_item(id, &json!({"target": "mask"}), "Group D").is_none()); + } + } +} diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index f9e633244c..e57edacb52 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -476,6 +476,10 @@ fn settings_at_the_value_the_event_plays_still_export() { #[test] fn every_mapped_command_exports_and_imports_back() { let cases: Vec<(&str, Value)> = vec![ + ("filter.stylize.findEdges", json!({})), + ("filter.stylize.solarize", json!({})), + ("filter.pixelate.facet", json!({})), + ("filter.pixelate.fragment", json!({})), ("filter.blur.gaussianBlur", json!({"radius": 3.5})), ("filter.sharpen.unsharpMask", json!({"amount": 120, "radius": 1.5, "threshold": 3})), ("filter.noise.addNoise", json!({"amount": 12.5, "distribution": "gaussian", "monochromatic": true, "seed": 7})), From ab80116d32b22d947fb070934ed009a938cc2ae5 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Sat, 31 Oct 2026 19:03:42 -0700 Subject: [PATCH 024/216] Start Render action mappings with Clouds events --- crates/engine/src/cmd/atn_cmds.rs | 4 +++ crates/engine/src/cmd/atn_cmds/events.rs | 16 ++++++++++++ .../src/cmd/atn_cmds/layer_props_tests.rs | 4 +-- .../engine/src/cmd/atn_cmds/render_tests.rs | 25 +++++++++++++++++++ .../engine/src/cmd/atn_cmds/steps_render.rs | 16 ++++++++++++ crates/engine/src/cmd/atn_cmds/tests.rs | 8 +++--- 6 files changed, 68 insertions(+), 5 deletions(-) create mode 100644 crates/engine/src/cmd/atn_cmds/render_tests.rs create mode 100644 crates/engine/src/cmd/atn_cmds/steps_render.rs diff --git a/crates/engine/src/cmd/atn_cmds.rs b/crates/engine/src/cmd/atn_cmds.rs index 42a4722479..6cb4b57932 100644 --- a/crates/engine/src/cmd/atn_cmds.rs +++ b/crates/engine/src/cmd/atn_cmds.rs @@ -36,6 +36,7 @@ mod steps_doc; mod steps_image; mod steps_layer; mod steps_make; +mod steps_render; mod tables; #[cfg(test)] @@ -145,3 +146,6 @@ mod tests; #[cfg(test)] mod layer_props_tests; + +#[cfg(test)] +mod render_tests; diff --git a/crates/engine/src/cmd/atn_cmds/events.rs b/crates/engine/src/cmd/atn_cmds/events.rs index 5723c93c59..cca15afc1e 100644 --- a/crates/engine/src/cmd/atn_cmds/events.rs +++ b/crates/engine/src/cmd/atn_cmds/events.rs @@ -185,6 +185,22 @@ macro_rules! clipping_event { /// Every Photoshop event PhotoCraft maps. The order matters only for the panel label. pub(super) const EVENTS: &[Event] = &[ // Filters. + Event { + code: "Clds", + name: "clouds", + label: "Clouds", + ids: &["filter.render.clouds"], + from: |d| super::steps_render::clouds_from("filter.render.clouds", d), + to: super::steps_render::clouds_to, + }, + Event { + code: "DfrC", + name: "differenceClouds", + label: "Difference Clouds", + ids: &["filter.render.differenceClouds"], + from: |d| super::steps_render::clouds_from("filter.render.differenceClouds", d), + to: super::steps_render::clouds_to, + }, ev!("GsnB", "gaussianBlur", "Gaussian Blur", "filter.blur.gaussianBlur", &[F("Rds ", "radius", Kind::Px)]), ev!( "UnsM", diff --git a/crates/engine/src/cmd/atn_cmds/layer_props_tests.rs b/crates/engine/src/cmd/atn_cmds/layer_props_tests.rs index 155729923a..e4ebcd0ea0 100644 --- a/crates/engine/src/cmd/atn_cmds/layer_props_tests.rs +++ b/crates/engine/src/cmd/atn_cmds/layer_props_tests.rs @@ -34,13 +34,13 @@ fn expanded_properties_keep_flags_and_original_skip_indices() { vec![ ("layer.setProps".into(), json!({"name": "Renamed", "opacity": 0.37, "blend": "Multiply"})), ("filter.blur.gaussianBlur".into(), json!({"radius": 2})), - ("filter.render.clouds".into(), json!({})), + ("unsupported.forTest".into(), json!({})), ], ); action.disabled.insert(0); action.dialogs.insert(0); let (bytes, skipped) = export("Set", &[&action], &[], true).unwrap(); - assert_eq!(skipped, [Skipped { action: "Flags".into(), step: 2, id: "filter.render.clouds".into() }]); + assert_eq!(skipped, [Skipped { action: "Flags".into(), step: 2, id: "unsupported.forTest".into() }]); let file = atn::parse(&bytes).unwrap(); let items = &file.actions[0].items; assert_eq!(items.len(), 4); diff --git a/crates/engine/src/cmd/atn_cmds/render_tests.rs b/crates/engine/src/cmd/atn_cmds/render_tests.rs new file mode 100644 index 0000000000..619d9ca294 --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/render_tests.rs @@ -0,0 +1,25 @@ +use super::*; + +/// Both recorded event IDs map empty Clouds descriptors without inventing frozen colors. +#[test] +fn clouds_events_map_and_refuse_unrepresentable_native_settings() { + for (code, name, id) in [(*b"Clds", "clouds", "filter.render.clouds"), (*b"DfrC", "differenceClouds", "filter.render.differenceClouds")] { + for event in [AtnEvent::Code(code), AtnEvent::name(name)] { + let file = AtnFile { + actions: vec![AtnAction { + name: UnicodeString::new_nul("Render"), + items: vec![AtnItem { event, descriptor: Some(desc("null")), ..Default::default() }], + ..Default::default() + }], + ..Default::default() + }; + let loaded = import(&atn::write(&file).unwrap()).unwrap(); + assert!(loaded.unsupported.is_empty()); + assert_eq!(loaded.actions[0].steps, vec![(id.into(), json!({}))]); + assert_eq!(export_item(id, &json!({}), "Render").unwrap().descriptor, Some(desc("null"))); + } + for p in [json!({"seed": 7}), json!({"foreground": "#ff0000"}), json!({"background": "#0000ff"}), json!({"layer": 7}), json!({"target": "mask"})] { + assert!(export_item(id, &p, "Render").is_none(), "{id}: {p}"); + } + } +} diff --git a/crates/engine/src/cmd/atn_cmds/steps_render.rs b/crates/engine/src/cmd/atn_cmds/steps_render.rs new file mode 100644 index 0000000000..fbd2477237 --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/steps_render.rs @@ -0,0 +1,16 @@ +//! Recorded descriptors for the Render filters. + +use super::descriptors::desc; +use super::fields::{Mapped, step}; +use super::{Descriptor, Value, json}; + +/// Clouds events have no fields; tool colors are read when the step plays. +pub(super) fn clouds_from(id: &str, d: &Descriptor) -> Mapped { + if d.items.is_empty() { step(id, json!({})) } else { Mapped::Unsupported { harmless: false } } +} + +/// Explicit seeds, colors and layer references have no fields in these ATN events. +pub(super) fn clouds_to(_: &str, p: &Value) -> Option { + let params = p.as_object()?; + (super::events::pixel_target(p) && params.keys().all(|key| key == "target")).then(|| desc("null")) +} diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index f9e633244c..7d7f1cf8a7 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -374,11 +374,11 @@ fn save_round_trips_through_photoshop_events() { assert_eq!(t.actions.list, first); // A step with no Photoshop equivalent fails unless skipped. - s.actions.list.push(Action::new("Ours", vec![("filter.render.clouds".into(), json!({}))])); + s.actions.list.push(Action::new("Ours", vec![("unsupported.forTest".into(), json!({}))])); let e = s.execute("actions.save", json!({"action": "Ours"})).unwrap_err().to_string(); - assert!(e.contains("filter.render.clouds"), "{e}"); + assert!(e.contains("unsupported.forTest"), "{e}"); let r = s.execute("actions.save", json!({"action": "Ours", "skipUnsupported": true})).unwrap(); - assert_eq!(r["skipped"], json!([{"action": "Ours", "step": 0, "id": "filter.render.clouds"}])); + assert_eq!(r["skipped"], json!([{"action": "Ours", "step": 0, "id": "unsupported.forTest"}])); assert_eq!(r["set"], "Ours"); } @@ -476,6 +476,8 @@ fn settings_at_the_value_the_event_plays_still_export() { #[test] fn every_mapped_command_exports_and_imports_back() { let cases: Vec<(&str, Value)> = vec![ + ("filter.render.clouds", json!({})), + ("filter.render.differenceClouds", json!({})), ("filter.blur.gaussianBlur", json!({"radius": 3.5})), ("filter.sharpen.unsharpMask", json!({"amount": 120, "radius": 1.5, "threshold": 3})), ("filter.noise.addNoise", json!({"amount": 12.5, "distribution": "gaussian", "monochromatic": true, "seed": 7})), From 8d47e4efef2d03c40936d37c9d6289dbf44069c8 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Sat, 31 Oct 2026 19:12:01 -0700 Subject: [PATCH 025/216] Complete measured Stylize and Pixelate ATN mappings --- crates/engine/src/cmd/atn_cmds/events.rs | 17 +- .../engine/src/cmd/atn_cmds/steps_stylize.rs | 185 +++++++- .../engine/src/cmd/atn_cmds/stylize_tests.rs | 402 +++++++++++++++++- crates/engine/src/cmd/atn_cmds/tests.rs | 8 + docs/atn.md | 263 +++++++----- 5 files changed, 730 insertions(+), 145 deletions(-) diff --git a/crates/engine/src/cmd/atn_cmds/events.rs b/crates/engine/src/cmd/atn_cmds/events.rs index beaa2d6f31..2a4bccc915 100644 --- a/crates/engine/src/cmd/atn_cmds/events.rs +++ b/crates/engine/src/cmd/atn_cmds/events.rs @@ -54,14 +54,7 @@ macro_rules! ev { macro_rules! stylize_event { ($code:literal, $name:literal, $label:literal, $id:literal) => { - Event { - code: $code, - name: $name, - label: $label, - ids: &[$id], - from: |d| super::steps_stylize::from($id, d), - to: |_, p| if pixel_target(p) { super::steps_stylize::to($id, p) } else { None }, - } + Event { code: $code, name: $name, label: $label, ids: &[$id], from: |d| super::steps_stylize::from($id, d), to: super::steps_stylize::to } }; } @@ -202,6 +195,14 @@ pub(super) const EVENTS: &[Event] = &[ stylize_event!("Slrz", "solarize", "Solarize", "filter.stylize.solarize"), stylize_event!("Fct ", "facet", "Facet", "filter.pixelate.facet"), stylize_event!("Frgm", "fragment", "Fragment", "filter.pixelate.fragment"), + stylize_event!("Extr", "extrude", "Extrude", "filter.stylize.extrude"), + stylize_event!("TrcC", "traceContour", "Trace Contour", "filter.stylize.traceContour"), + stylize_event!("Wnd ", "wind", "Wind", "filter.stylize.wind"), + stylize_event!("Tls ", "tiles", "Tiles", "filter.stylize.tiles"), + stylize_event!("ClrH", "colorHalftone", "Color Halftone", "filter.pixelate.colorHalftone"), + stylize_event!("Mztn", "mezzotint", "Mezzotint", "filter.pixelate.mezzotint"), + stylize_event!("Msc ", "mosaic", "Mosaic", "filter.pixelate.mosaic"), + stylize_event!("Pntl", "pointillize", "Pointillize", "filter.pixelate.pointillize"), ev!("GsnB", "gaussianBlur", "Gaussian Blur", "filter.blur.gaussianBlur", &[F("Rds ", "radius", Kind::Px)]), ev!( "UnsM", diff --git a/crates/engine/src/cmd/atn_cmds/steps_stylize.rs b/crates/engine/src/cmd/atn_cmds/steps_stylize.rs index 65b859a301..1c5bc2ba32 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_stylize.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_stylize.rs @@ -1,15 +1,188 @@ //! Descriptor adapters for the measured Stylize and Pixelate action events. -use super::descriptors::desc; +use super::descriptors::{desc, enm, px}; use super::fields::{Mapped, step}; -use super::{Descriptor, Value, json}; +use super::{D, Descriptor, Map, Value, json}; +enum Kind { + Long(i32, i32, i32), + Pixels(f64, f64, f64), + Bool(bool), + Enum(&'static str, &'static [(&'static str, &'static str)], &'static str), +} + +// Descriptor key, command parameter, and measured type/default/range. +struct Field(&'static str, &'static str, Kind); + +const EXTRUDE: &[Field] = &[ + Field("ExtS", "size", Kind::Long(30, 2, 255)), + Field("ExtD", "depth", Kind::Long(30, 1, 255)), + Field("ExtF", "solidFrontFaces", Kind::Bool(false)), + Field("ExtM", "maskIncompleteBlocks", Kind::Bool(false)), + Field("ExtT", "type", Kind::Enum("ExtT", &[("Blks", "blocks"), ("Pyrm", "pyramids")], "blocks")), + Field("ExtR", "depthMode", Kind::Enum("ExtR", &[("Rndm", "random"), ("LvlB", "levelBased")], "random")), +]; +const TRACE: &[Field] = &[ + // The dialog range is not measured. These bounds are only the descriptor's integer type. + Field("Lvl ", "level", Kind::Long(128, i32::MIN, i32::MAX)), + Field("Edg ", "edge", Kind::Enum("CntE", &[("Lwr ", "lower"), ("Upr ", "upper")], "upper")), +]; +const WIND: &[Field] = &[ + Field("WndM", "method", Kind::Enum("WndM", &[("Wnd ", "wind"), ("Blst", "blast"), ("Stgr", "stagger")], "wind")), + // Photoshop names the direction the streaks extend, rather than their source. + Field("Drct", "direction", Kind::Enum("Drct", &[("Left", "fromRight"), ("Rght", "fromLeft")], "fromRight")), +]; +const TILES: &[Field] = &[ + Field("TlNm", "count", Kind::Long(10, 1, 99)), + Field("TlOf", "maxOffset", Kind::Long(10, 1, 99)), + Field("FlCl", "fill", Kind::Enum("FlCl", &[("FlBc", "background"), ("FlFr", "foreground"), ("FlIn", "inverse"), ("FlSm", "unaltered")], "background")), +]; +const HALFTONE: &[Field] = &[ + Field("Rds ", "maxRadius", Kind::Long(8, 4, 127)), + Field("Ang1", "channel1", Kind::Long(108, -10000, 10000)), + Field("Ang2", "channel2", Kind::Long(162, -10000, 10000)), + Field("Ang3", "channel3", Kind::Long(90, -10000, 10000)), + Field("Ang4", "channel4", Kind::Long(45, -10000, 10000)), +]; +const MEZZOTINT: &[Field] = &[Field( + "MztT", + "type", + Kind::Enum( + "MztT", + &[ + ("FnDt", "fineDots"), + ("CrsD", "coarseDots"), + ("GrnD", "grainyDots"), + ("LngL", "longLines"), + ("LngS", "longStrokes"), + ("MdmD", "mediumDots"), + ("MdmL", "mediumLines"), + ("MdmS", "mediumStrokes"), + ("ShSt", "shortStrokes"), + ("ShrL", "shortLines"), + ], + "fineDots", + ), +)]; +const MOSAIC: &[Field] = &[Field("ClSz", "cellSize", Kind::Pixels(2.0, 2.0, 200.0))]; +const POINTILLIZE: &[Field] = &[Field("ClSz", "cellSize", Kind::Long(5, 3, 300))]; + +/// The event's fields in recorded order; other filter families keep their own adapters. +fn fields(id: &str) -> Option<&'static [Field]> { + Some(match id { + "filter.stylize.extrude" => EXTRUDE, + "filter.stylize.traceContour" => TRACE, + "filter.stylize.wind" => WIND, + "filter.stylize.tiles" => TILES, + "filter.pixelate.colorHalftone" => HALFTONE, + "filter.pixelate.mezzotint" => MEZZOTINT, + "filter.pixelate.mosaic" => MOSAIC, + "filter.pixelate.pointillize" => POINTILLIZE, + "filter.stylize.findEdges" | "filter.stylize.solarize" | "filter.pixelate.facet" | "filter.pixelate.fragment" => &[], + _ => return None, + }) +} + +impl Kind { + /// Missing ATN keys take the measured default, which can differ from the native command. + fn default(&self) -> Value { + match self { + Self::Long(n, ..) => json!(n), + Self::Pixels(n, ..) => json!(n), + Self::Bool(b) => json!(b), + Self::Enum(_, _, value) => json!(value), + } + } + + /// Reject incompatible types and enum identities; clamp only measured numeric ranges. + fn read(&self, value: &D) -> Option { + Some(match (self, value) { + (Self::Long(_, min, max), D::Integer(n)) => json!(n.clamp(min, max)), + (Self::Pixels(_, min, max), D::UnitFloat { unit, value: n }) if unit == b"#Pxl" && n.is_finite() => json!(n.clamp(*min, *max)), + (Self::Pixels(_, min, max), D::Double(n)) if n.is_finite() => json!(n.clamp(*min, *max)), + (Self::Pixels(_, min, max), D::Integer(n)) => json!(f64::from(*n).clamp(*min, *max)), + (Self::Bool(_), D::Boolean(b)) => json!(b), + (Self::Enum(ty, values, _), D::Enumerated { type_id, value }) if type_id.is(ty) => { + json!(values.iter().find(|(code, _)| value.is(code))?.1) + } + _ => return None, + }) + } + + /// Refuse native values the field cannot represent instead of changing the saved edit. + fn write(&self, value: &Value) -> Option { + Some(match self { + Self::Long(_, min, max) => { + let n = value.as_f64()?; + let integer = n as i32; + if f64::from(integer) != n || !(*min..=*max).contains(&integer) { + return None; + } + D::Integer(integer) + } + Self::Pixels(_, min, max) => { + let n = value.as_f64()?; + if !n.is_finite() || !(*min..=*max).contains(&n) { + return None; + } + px(n) + } + Self::Bool(_) => D::Boolean(value.as_bool()?), + Self::Enum(ty, values, _) => enm(ty, values.iter().find(|(_, label)| Some(*label) == value.as_str())?.0), + }) + } +} + +/// Unknown or duplicate keys keep the original step intact rather than lose settings. +fn params_from(id: &str, d: &Descriptor) -> Option> { + let fields = fields(id)?; + if d.items.iter().any(|(key, _)| !fields.iter().any(|f| key.is(f.0))) { + return None; + } + let mut params = Map::new(); + for Field(key, param, kind) in fields { + if d.items.iter().filter(|(k, _)| k.is(key)).count() > 1 { + return None; + } + let value = match d.get(key) { + Some(value) => kind.read(value)?, + None => kind.default(), + }; + params.insert((*param).into(), value); + } + Some(params) +} + +/// Map a complete measured descriptor or leave it as an unsupported Photoshop step. pub(super) fn from(id: &str, d: &Descriptor) -> Mapped { - if d.items.is_empty() { step(id, json!({})) } else { Mapped::Unsupported { harmless: false } } + match params_from(id, d) { + Some(params) => step(id, Value::Object(params)), + None => Mapped::Unsupported { harmless: false }, + } } -pub(super) fn to(_id: &str, p: &Value) -> Option { +/// Write every measured key, preserving native defaults and rejecting native-only settings. +pub(super) fn to(id: &str, p: &Value) -> Option { + let fields = fields(id)?; let params = p.as_object()?; - // These events have no fields for a layer reference or any native filter settings. - params.keys().all(|key| key == "target").then(|| desc("null")) + let seeded = + matches!(id, "filter.stylize.extrude" | "filter.stylize.wind" | "filter.stylize.tiles" | "filter.pixelate.mezzotint" | "filter.pixelate.pointillize"); + if !super::events::pixel_target(p) + || params.iter().any(|(key, value)| { + !fields.iter().any(|f| f.1 == key.as_str()) && key != "target" && !(key == "seed" && seeded && (value.is_null() || value.as_f64() == Some(0.0))) + }) + { + return None; + } + let mut d = desc("null"); + for Field(key, param, kind) in fields { + // Imports materialize Photoshop's defaults. Native commands retain their own defaults. + let default = match (id, *param) { + ("filter.pixelate.mosaic", "cellSize") => json!(10), + ("filter.stylize.traceContour", "edge") => json!("lower"), + _ => kind.default(), + }; + d = d.with(key, kind.write(p.get(param).filter(|v| !v.is_null()).unwrap_or(&default))?); + } + Some(d) } diff --git a/crates/engine/src/cmd/atn_cmds/stylize_tests.rs b/crates/engine/src/cmd/atn_cmds/stylize_tests.rs index 68cc61c02e..02eebda8ca 100644 --- a/crates/engine/src/cmd/atn_cmds/stylize_tests.rs +++ b/crates/engine/src/cmd/atn_cmds/stylize_tests.rs @@ -1,5 +1,6 @@ use super::*; +/// The parameterless events use an empty descriptor and accept both event identifiers. #[test] fn parameterless_stylize_and_pixelate_events_use_both_event_id_forms() { for (code, name, id) in [ @@ -9,21 +10,396 @@ fn parameterless_stylize_and_pixelate_events_use_both_event_id_forms() { (*b"Frgm", "fragment", "filter.pixelate.fragment"), ] { for event in [AtnEvent::Code(code), AtnEvent::name(name)] { - let file = AtnFile { - name: UnicodeString::new_nul("Group D"), - actions: vec![AtnAction { - name: UnicodeString::new_nul("A"), - items: vec![AtnItem { event, descriptor: Some(desc("null")), ..Default::default() }], - ..Default::default() - }], - ..Default::default() - }; - let imported = import(&atn::write(&file).unwrap()).unwrap(); - assert!(imported.unsupported.is_empty()); - assert_eq!(imported.actions[0].steps, vec![(id.into(), json!({}))]); - assert_eq!(export_item(id, &json!({}), "Group D").unwrap().descriptor, Some(desc("null"))); + let mut s = load_items(vec![AtnItem { event, descriptor: Some(desc("null")), ..Default::default() }]); + assert_eq!(s.actions.list[0].steps, vec![(id.into(), json!({}))]); + assert_eq!(saved_items(&mut s)[0].descriptor, Some(desc("null"))); assert!(export_item(id, &json!({"layer": 17}), "Group D").is_none()); assert!(export_item(id, &json!({"target": "mask"}), "Group D").is_none()); } } } + +struct Fixture { + code: &'static str, + name: &'static str, + id: &'static str, + default: Descriptor, + defaults: Value, + changed: Descriptor, + changes: Value, +} + +/// Independent descriptor fixtures spell out measured key order, types, values and defaults. +fn fixtures() -> Vec { + vec![ + Fixture { + code: "Extr", + name: "extrude", + id: "filter.stylize.extrude", + default: desc("null") + .with("ExtS", D::Integer(30)) + .with("ExtD", D::Integer(30)) + .with("ExtF", D::Boolean(false)) + .with("ExtM", D::Boolean(false)) + .with("ExtT", enm("ExtT", "Blks")) + .with("ExtR", enm("ExtR", "Rndm")), + defaults: json!({"size":30,"depth":30,"solidFrontFaces":false,"maskIncompleteBlocks":false,"type":"blocks","depthMode":"random"}), + changed: desc("null") + .with("ExtS", D::Integer(42)) + .with("ExtD", D::Integer(91)) + .with("ExtF", D::Boolean(true)) + .with("ExtM", D::Boolean(true)) + .with("ExtT", enm("ExtT", "Pyrm")) + .with("ExtR", enm("ExtR", "LvlB")), + changes: json!({"size":42,"depth":91,"solidFrontFaces":true,"maskIncompleteBlocks":true,"type":"pyramids","depthMode":"levelBased"}), + }, + Fixture { + code: "TrcC", + name: "traceContour", + id: "filter.stylize.traceContour", + default: desc("null").with("Lvl ", D::Integer(128)).with("Edg ", enm("CntE", "Upr ")), + defaults: json!({"level":128,"edge":"upper"}), + changed: desc("null").with("Lvl ", D::Integer(43)).with("Edg ", enm("CntE", "Lwr ")), + changes: json!({"level":43,"edge":"lower"}), + }, + Fixture { + code: "Wnd ", + name: "wind", + id: "filter.stylize.wind", + default: desc("null").with("WndM", enm("WndM", "Wnd ")).with("Drct", enm("Drct", "Left")), + defaults: json!({"method":"wind","direction":"fromRight"}), + changed: desc("null").with("WndM", enm("WndM", "Stgr")).with("Drct", enm("Drct", "Rght")), + changes: json!({"method":"stagger","direction":"fromLeft"}), + }, + Fixture { + code: "Tls ", + name: "tiles", + id: "filter.stylize.tiles", + default: desc("null").with("TlNm", D::Integer(10)).with("TlOf", D::Integer(10)).with("FlCl", enm("FlCl", "FlBc")), + defaults: json!({"count":10,"maxOffset":10,"fill":"background"}), + changed: desc("null").with("TlNm", D::Integer(17)).with("TlOf", D::Integer(23)).with("FlCl", enm("FlCl", "FlFr")), + changes: json!({"count":17,"maxOffset":23,"fill":"foreground"}), + }, + Fixture { + code: "ClrH", + name: "colorHalftone", + id: "filter.pixelate.colorHalftone", + default: desc("null") + .with("Rds ", D::Integer(8)) + .with("Ang1", D::Integer(108)) + .with("Ang2", D::Integer(162)) + .with("Ang3", D::Integer(90)) + .with("Ang4", D::Integer(45)), + defaults: json!({"maxRadius":8,"channel1":108,"channel2":162,"channel3":90,"channel4":45}), + changed: desc("null") + .with("Rds ", D::Integer(12)) + .with("Ang1", D::Integer(-10000)) + .with("Ang2", D::Integer(10000)) + .with("Ang3", D::Integer(-721)) + .with("Ang4", D::Integer(1001)), + changes: json!({"maxRadius":12,"channel1":-10000,"channel2":10000,"channel3":-721,"channel4":1001}), + }, + Fixture { + code: "Mztn", + name: "mezzotint", + id: "filter.pixelate.mezzotint", + default: desc("null").with("MztT", enm("MztT", "FnDt")), + defaults: json!({"type":"fineDots"}), + changed: desc("null").with("MztT", enm("MztT", "LngS")), + changes: json!({"type":"longStrokes"}), + }, + Fixture { + code: "Msc ", + name: "mosaic", + id: "filter.pixelate.mosaic", + default: desc("null").with("ClSz", px(2.0)), + defaults: json!({"cellSize":2}), + changed: desc("null").with("ClSz", px(31.5)), + changes: json!({"cellSize":31.5}), + }, + Fixture { + code: "Pntl", + name: "pointillize", + id: "filter.pixelate.pointillize", + default: desc("null").with("ClSz", D::Integer(5)), + defaults: json!({"cellSize":5}), + changed: desc("null").with("ClSz", D::Integer(19)), + changes: json!({"cellSize":19}), + }, + ] +} + +/// Load an actual action file through the command boundary, including flag and name handling. +fn load_items(items: Vec) -> Session { + let file = AtnFile { + name: UnicodeString::new_nul("Group D"), + actions: vec![AtnAction { name: UnicodeString::new_nul("A"), items, ..Default::default() }], + ..Default::default() + }; + let bytes = atn::write(&file).unwrap(); + let mut s = Session::new(); + s.execute("actions.load", json!({"data":photocraft_paint::tile::b64_encode(&bytes)})).unwrap(); + s +} + +/// Save through the real command and parse its bytes so descriptor order and types stay visible. +fn saved_items(s: &mut Session) -> Vec { + let result = s.execute("actions.save", json!({"action":"A"})).unwrap(); + assert_eq!(result["skipped"], json!([])); + let bytes = photocraft_paint::tile::b64_decode(result["data"].as_str().unwrap()).unwrap(); + atn::parse(&bytes).unwrap().actions.remove(0).items +} + +/// A measured four-character event with the supplied independent descriptor. +fn code_item(code: &str, descriptor: Descriptor) -> AtnItem { + AtnItem { event: AtnEvent::Code(code.as_bytes().try_into().unwrap()), descriptor: Some(descriptor), ..Default::default() } +} + +/// Check exact independent descriptors through real load/save commands, not a codec against itself. +#[test] +fn measured_defaults_and_every_changed_value_round_trip_in_order() { + for fixture in fixtures() { + for (descriptor, params) in [(&fixture.default, &fixture.defaults), (&fixture.changed, &fixture.changes)] { + for named in [false, true] { + let mut item = code_item(fixture.code, descriptor.clone()); + if named { + item.event = AtnEvent::name(fixture.name); + } + let mut s = load_items(vec![item]); + assert_eq!(s.actions.list[0].steps, vec![(fixture.id.into(), params.clone())], "{} named={named}", fixture.code); + let saved = saved_items(&mut s); + assert_eq!(saved[0].event, AtnEvent::Code(fixture.code.as_bytes().try_into().unwrap())); + assert_eq!(saved[0].descriptor.as_ref(), Some(descriptor), "{} named={named}", fixture.code); + } + } + let mut s = load_items(vec![code_item(fixture.code, desc("null"))]); + assert_eq!(s.actions.list[0].steps[0], (fixture.id.into(), fixture.defaults), "{} missing keys", fixture.code); + assert_eq!(saved_items(&mut s)[0].descriptor, Some(fixture.default)); + } +} + +/// Every measured numeric bound is checked independently, including angles beyond 360 degrees. +#[test] +fn numeric_imports_clamp_measured_ranges_and_keep_unmeasured_trace_levels() { + for (code, key, param, min, max) in [ + ("Extr", "ExtS", "size", 2, 255), + ("Extr", "ExtD", "depth", 1, 255), + ("Tls ", "TlNm", "count", 1, 99), + ("Tls ", "TlOf", "maxOffset", 1, 99), + ("ClrH", "Rds ", "maxRadius", 4, 127), + ("ClrH", "Ang1", "channel1", -10000, 10000), + ("ClrH", "Ang2", "channel2", -10000, 10000), + ("ClrH", "Ang3", "channel3", -10000, 10000), + ("ClrH", "Ang4", "channel4", -10000, 10000), + ("Msc ", "ClSz", "cellSize", 2, 200), + ("Pntl", "ClSz", "cellSize", 3, 300), + ] { + for (input, expected) in [(i32::MIN, min), (min - 1, min), (min, min), (max, max), (max + 1, max), (i32::MAX, max)] { + let value = if code == "Msc " { px(f64::from(input)) } else { D::Integer(input) }; + let mut s = load_items(vec![code_item(code, desc("null").with(key, value))]); + assert_ne!(s.actions.list[0].steps[0].0, PHOTOSHOP_STEP, "{code}/{key}"); + assert_eq!(s.actions.list[0].steps[0].1[param], expected, "{code}/{key}/{input}"); + assert!(saved_items(&mut s)[0].descriptor.is_some()); + } + } + for level in [i32::MIN, -1, 0, 255, 300, i32::MAX] { + let mut s = load_items(vec![code_item("TrcC", desc("null").with("Lvl ", D::Integer(level)))]); + assert_eq!(s.actions.list[0].steps[0].1["level"], level); + assert_eq!(saved_items(&mut s)[0].descriptor.as_ref().unwrap().get("Lvl "), Some(&D::Integer(level))); + } + for unitless in [D::Integer(2), D::Double(2.0)] { + let mut s = load_items(vec![code_item("Msc ", desc("null").with("ClSz", unitless))]); + assert_eq!(saved_items(&mut s)[0].descriptor, Some(desc("null").with("ClSz", px(2.0)))); + } +} + +/// Every enumerator is explicit, and unknown values or enum types retain the original flagged step. +#[test] +fn enum_values_map_exactly_and_unknown_descriptors_stay_kept() { + for (code, key, param, ty, values) in [ + ("Extr", "ExtT", "type", "ExtT", vec![("Blks", "blocks"), ("Pyrm", "pyramids")]), + ("Extr", "ExtR", "depthMode", "ExtR", vec![("Rndm", "random"), ("LvlB", "levelBased")]), + ("TrcC", "Edg ", "edge", "CntE", vec![("Lwr ", "lower"), ("Upr ", "upper")]), + ("Wnd ", "WndM", "method", "WndM", vec![("Wnd ", "wind"), ("Blst", "blast"), ("Stgr", "stagger")]), + ("Wnd ", "Drct", "direction", "Drct", vec![("Left", "fromRight"), ("Rght", "fromLeft")]), + ("Tls ", "FlCl", "fill", "FlCl", vec![("FlBc", "background"), ("FlFr", "foreground"), ("FlIn", "inverse"), ("FlSm", "unaltered")]), + ( + "Mztn", + "MztT", + "type", + "MztT", + vec![ + ("FnDt", "fineDots"), + ("CrsD", "coarseDots"), + ("GrnD", "grainyDots"), + ("LngL", "longLines"), + ("LngS", "longStrokes"), + ("MdmD", "mediumDots"), + ("MdmL", "mediumLines"), + ("MdmS", "mediumStrokes"), + ("ShSt", "shortStrokes"), + ("ShrL", "shortLines"), + ], + ), + ] { + for (value, expected) in &values { + let mut s = load_items(vec![code_item(code, desc("null").with(key, enm(ty, value)))]); + assert_eq!(s.actions.list[0].steps[0].1[param], *expected, "{code}/{value}"); + assert_eq!(saved_items(&mut s)[0].descriptor.as_ref().unwrap().get(key), Some(&enm(ty, value))); + } + for value in [enm(ty, "????"), enm("Bad!", values[0].0), D::Boolean(true)] { + let descriptor = desc("null").with(key, value); + let mut item = code_item(code, descriptor.clone()); + item.enabled = false; + item.with_dialog = true; + item.dialog_options = 2; + let mut s = load_items(vec![item]); + assert_eq!(s.actions.list[0].steps[0].0, PHOTOSHOP_STEP, "{code}/{key}"); + assert!(s.actions.list[0].disabled.contains(&0)); + assert!(s.actions.list[0].dialogs.contains(&0)); + let saved = saved_items(&mut s); + assert_eq!(saved[0].descriptor, Some(descriptor)); + assert!(!saved[0].enabled); + assert!(saved[0].with_dialog); + } + } + for (code, descriptor) in [ + ("Msc ", desc("null").with("ClSz", D::UnitFloat { unit: *b"#Prc", value: 3.0 })), + ("Msc ", desc("null").with("ClSz", D::Double(f64::INFINITY))), + ("Extr", desc("null").with("ExtS", D::Double(30.5))), + ("Extr", desc("null").with("ExtS", D::Integer(30)).with("ExtS", D::Integer(31))), + ("FndE", desc("null").with("unknown", D::Boolean(true))), + ] { + let mut s = load_items(vec![code_item(code, descriptor.clone())]); + assert_eq!(s.actions.list[0].steps[0].0, PHOTOSHOP_STEP); + assert_eq!(saved_items(&mut s)[0].descriptor, Some(descriptor)); + } +} + +/// Native defaults stay native; seeds, frozen colors, targets and lossy values are not discarded. +#[test] +fn exports_materialize_native_defaults_and_reject_unrepresentable_settings() { + for fixture in fixtures() { + let item = export_item(fixture.id, &json!({}), "Group D").unwrap(); + let mut expected = fixture.default.clone(); + if fixture.code == "Msc " { + expected = desc("null").with("ClSz", px(10.0)); + } + if fixture.code == "TrcC" { + expected = desc("null").with("Lvl ", D::Integer(128)).with("Edg ", enm("CntE", "Lwr ")); + } + assert_eq!(item.descriptor, Some(expected), "{}", fixture.id); + for (key, value) in [ + ("seed", json!(1)), + ("foreground", json!("#000000")), + ("background", json!([1, 1, 1, 1])), + ("layer", json!(17)), + ("target", json!("mask")), + ("target", json!("channel:red")), + ("unknown", json!(true)), + ] { + let mut params = fixture.defaults.clone(); + params[key] = value; + let mut s = Session::new(); + s.actions.list.push(Action::new("A", vec![(fixture.id.into(), params)])); + assert!(s.execute("actions.save", json!({"action":"A"})).is_err(), "{}/{key}", fixture.id); + } + let mut params = fixture.defaults.clone(); + params["target"] = json!("pixels"); + assert!(export_item(fixture.id, ¶ms, "Group D").is_some()); + } + for id in ["filter.stylize.extrude", "filter.stylize.wind", "filter.stylize.tiles", "filter.pixelate.mezzotint", "filter.pixelate.pointillize"] { + assert!(export_item(id, &json!({"seed":0}), "Group D").is_some(), "{id}"); + } + for (id, params) in [ + ("filter.stylize.extrude", json!({"size":30.5})), + ("filter.stylize.wind", json!({"direction":"unknown"})), + ("filter.stylize.tiles", json!({"fill":"unknown"})), + ("filter.stylize.traceContour", json!({"edge":"unknown"})), + ("filter.pixelate.colorHalftone", json!({"channel1":10001})), + ("filter.pixelate.pointillize", json!({"cellSize":5.5})), + ("filter.pixelate.mosaic", json!({"cellSize":201})), + ("filter.pixelate.mezzotint", json!({"type":"unknown"})), + ] { + assert!(export_item(id, ¶ms, "Group D").is_none(), "{id}/{params}"); + } +} + +/// Imported actions produce the same pixels and Undo history as the specified native commands. +#[test] +fn imported_group_d_playback_matches_direct_commands_across_modes_and_depths() { + let items: Vec<_> = fixtures() + .into_iter() + .map(|f| code_item(f.code, f.changed)) + .chain(["FndE", "Slrz", "Fct ", "Frgm"].map(|code| code_item(code, desc("null")))) + .collect(); + for (depth, mode) in [(8, "rgb"), (16, "rgb"), (32, "rgb"), (8, "gray"), (8, "cmyk"), (16, "lab")] { + let mut imported = load_items(items.clone()); + imported.actions.list[0].disabled.insert(10); + imported.actions.list[0].dialogs.insert(0); + let steps = imported.actions.list[0].steps.clone(); + let mut direct = Session::new(); + for s in [&mut imported, &mut direct] { + prepare_pattern(s, depth, mode); + } + let before = pixels(&imported); + let history = imported.active().unwrap().history.past_len(); + let result = imported.execute("actions.play", json!({"action":"A"})).unwrap(); + assert!(result.get("failed").is_none(), "{depth}/{mode}: {result}"); + assert_eq!(result["ran"], 11); + for (index, (id, params)) in steps.into_iter().enumerate() { + if index != 10 { + direct.execute(&id, params).unwrap(); + } + } + assert_eq!(pixels(&imported), pixels(&direct), "{depth}/{mode}"); + assert_eq!(imported.active().unwrap().history.past_len(), direct.active().unwrap().history.past_len()); + let added = imported.active().unwrap().history.past_len() - history; + assert!(added > 0); + for _ in 0..added { + imported.execute("edit.undo", json!({})).unwrap(); + } + assert_eq!(pixels(&imported), before, "{depth}/{mode} undo"); + let saved = saved_items(&mut imported); + assert!(!saved[10].enabled); + assert!(saved[0].with_dialog); + } +} + +/// A nonuniform image and nondefault tool colors expose differences hidden by flat inputs. +fn prepare_pattern(s: &mut Session, depth: u32, mode: &str) { + s.execute("file.new", json!({"width":32,"height":24,"depth":depth,"mode":mode,"background":"#102030"})).unwrap(); + s.execute("select.rect", json!({"x":3,"y":2,"width":14,"height":11})).unwrap(); + s.execute("edit.fill", json!({"color":"#ee9922"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + s.execute("tools.setColors", json!({"foreground":"#e83a91","background":"#148fca"})).unwrap(); +} + +fn pixels(s: &Session) -> Vec<[f32; 4]> { + photocraft_compose::render(&s.active().unwrap().doc, photocraft_geom::Rect::new(0, 0, 32, 24)).px +} + +/// Saving omitted native params preserves their actual command behavior, not Photoshop's defaults. +#[test] +fn native_and_measured_defaults_stay_distinct_through_playback() { + use photocraft_algo::FilterParams; + let mosaic = load_items(vec![code_item("Msc ", desc("null"))]); + let measured = &mosaic.actions.list[0].steps[0].1; + assert!(matches!(crate::filters::params_for("filter.pixelate.mosaic",&json!({})),Some(FilterParams::Mosaic{cell_size}) if cell_size==10.0)); + assert!(matches!(crate::filters::params_for("filter.pixelate.mosaic",measured),Some(FilterParams::Mosaic{cell_size}) if cell_size==2.0)); + let trace = load_items(vec![code_item("TrcC", desc("null"))]); + let measured = &trace.actions.list[0].steps[0].1; + assert!(matches!(crate::filters_ext::params_for("filter.stylize.traceContour", &json!({})), Some(FilterParams::TraceContour { upper: false, .. }))); + assert!(matches!(crate::filters_ext::params_for("filter.stylize.traceContour", measured), Some(FilterParams::TraceContour { upper: true, .. }))); + for id in ["filter.pixelate.mosaic", "filter.stylize.traceContour"] { + let item = export_item(id, &json!({}), "Group D").unwrap(); + let mut imported = load_items(vec![item]); + let mut native = Session::new(); + prepare_pattern(&mut imported, 8, "rgb"); + prepare_pattern(&mut native, 8, "rgb"); + native.execute(id, json!({})).unwrap(); + let result = imported.execute("actions.play", json!({"action":"A"})).unwrap(); + assert!(result.get("failed").is_none(), "{id}: {result}"); + assert_eq!(pixels(&imported), pixels(&native), "{id}"); + } +} diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index e57edacb52..3ffc060631 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -480,6 +480,14 @@ fn every_mapped_command_exports_and_imports_back() { ("filter.stylize.solarize", json!({})), ("filter.pixelate.facet", json!({})), ("filter.pixelate.fragment", json!({})), + ("filter.stylize.extrude", json!({"size":42,"depth":91,"solidFrontFaces":true,"maskIncompleteBlocks":true,"type":"pyramids","depthMode":"levelBased"})), + ("filter.stylize.traceContour", json!({"level":43,"edge":"lower"})), + ("filter.stylize.wind", json!({"method":"stagger","direction":"fromLeft"})), + ("filter.stylize.tiles", json!({"count":17,"maxOffset":23,"fill":"foreground"})), + ("filter.pixelate.colorHalftone", json!({"maxRadius":12,"channel1":-10000,"channel2":10000,"channel3":-721,"channel4":1001})), + ("filter.pixelate.mezzotint", json!({"type":"longStrokes"})), + ("filter.pixelate.mosaic", json!({"cellSize":31.5})), + ("filter.pixelate.pointillize", json!({"cellSize":19})), ("filter.blur.gaussianBlur", json!({"radius": 3.5})), ("filter.sharpen.unsharpMask", json!({"amount": 120, "radius": 1.5, "threshold": 3})), ("filter.noise.addNoise", json!({"amount": 12.5, "distribution": "gaussian", "monochromatic": true, "seed": 7})), diff --git a/docs/atn.md b/docs/atn.md index 0d280e147c..5230e707d9 100644 --- a/docs/atn.md +++ b/docs/atn.md @@ -16,7 +16,7 @@ so playback skips them. This page has two parts: the events PhotoCraft maps today, and a measured reference for the events it does not map yet, with everything needed to implement them without the reference application. -## Supported events (117) +## Supported events (129) | Code | String ID | Label | PhotoCraft command(s) | |---|---|---|---| @@ -137,6 +137,150 @@ does not map yet, with everything needed to implement them without the reference | `UnsM` | unsharpMask | Unsharp Mask | `filter.sharpen.unsharpMask` | | (string ID only) | updatePlacedLayer | Update Smart Objects | `layer.smartObjects.updateModifiedContent` | | `Vbrn` | Vbrn | vibrance | `Vibrance` | +| `Extr` | extrude | Extrude | `filter.stylize.extrude` | +| `FndE` | findEdges | Find Edges | `filter.stylize.findEdges` | +| `Slrz` | solarize | Solarize | `filter.stylize.solarize` | +| `TrcC` | traceContour | Trace Contour | `filter.stylize.traceContour` | +| `Wnd ` | wind | Wind | `filter.stylize.wind` | +| `Tls ` | tiles | Tiles | `filter.stylize.tiles` | +| `ClrH` | colorHalftone | Color Halftone | `filter.pixelate.colorHalftone` | +| `Fct ` | facet | Facet | `filter.pixelate.facet` | +| `Frgm` | fragment | Fragment | `filter.pixelate.fragment` | +| `Mztn` | mezzotint | Mezzotint | `filter.pixelate.mezzotint` | +| `Msc ` | mosaic | Mosaic | `filter.pixelate.mosaic` | +| `Pntl` | pointillize | Pointillize | `filter.pixelate.pointillize` | + +### Group D: The Stylize and Pixelate filters + +These twelve events are mapped. The tables preserve their measured descriptor order, types, defaults and ranges. + +Missing descriptor keys load with the measured defaults below. Mosaic therefore imports +with cell size 2, and Trace Contour imports with its upper edge. Saving a native command +that omits these parameters preserves its native behavior: Mosaic cell size 10 and +Trace Contour lower edge. Explicit imported values stay explicit, including changed +Trace Contour levels and edges that the reference application applies but does not echo. + +Mosaic accepts a unitless pixel value on import and writes a `#Pxl` unit double. +Other fields keep their documented integer, boolean and enum types. Unknown keys, +wrong types or unknown enum values keep the original step intact. A native setting +without a descriptor field, such as a nonzero seed, frozen foreground/background +colors, an explicit layer or a non-pixel target, refuses export instead of being +silently dropped. Seed 0 and the ordinary pixel target remain representable. +These mappings reuse the existing filters; they do not establish new pixel-parity +measurements against the reference application. + +#### Extrude + +- Event: `Extr` / `extrude` +- PhotoCraft command: `filter.stylize.extrude` + +| Key | String ID | Type | Unit | Default | Allowed | +|---|---|---|---|---|---| +| `ExtS` | extrudeSize | integer | | 30 | 2 .. 255 (out-of-range values are clamped) | +| `ExtD` | extrudeDepth | integer | | 30 | 1 .. 255 (out-of-range values are clamped) | +| `ExtF` | extrudeSolidFace | boolean | | false | | +| `ExtM` | extrudeMaskIncomplete | boolean | | false | | +| `ExtT` | extrudeType | enumerated | | `Blks` blacks | `Blks` blacks, `Pyrm` pyramids | +| `ExtR` | extrudeRandom | enumerated | | `Rndm` random | `LvlB` levelBased, `Rndm` random | + +#### Find Edges + +- Event: `FndE` / `findEdges` +- PhotoCraft command: `filter.stylize.findEdges` + +No parameters: the step carries an empty descriptor. + +#### Solarize + +- Event: `Slrz` / `solarize` +- PhotoCraft command: `filter.stylize.solarize` + +No parameters: the step carries an empty descriptor. + +#### Trace Contour + +- Event: `TrcC` / `traceContour` +- PhotoCraft command: `filter.stylize.traceContour` + +| Key | String ID | Type | Unit | Default | Allowed | +|---|---|---|---|---|---| +| `Lvl ` | level | integer | | 128 | applied, but not echoed: the returned step always shows 128. Levels <= 0 and >= 255 give the same image. Dialog range pending. | +| `Edg ` | edge | enumerated | | `Upr ` upper | `Lwr ` lower, `Upr ` upper (applied; the returned step always shows upper) | + +#### Wind + +- Event: `Wnd ` / `wind` +- PhotoCraft command: `filter.stylize.wind` + +| Key | String ID | Type | Unit | Default | Allowed | +|---|---|---|---|---|---| +| `WndM` | windMethod | enumerated | | `Wnd ` wind | `Blst` blast, `Stgr` stagger, `Wnd ` wind | +| `Drct` | direction | enumerated | | `Left` left | `Left` left, `Rght` right | + +#### Tiles + +- Event: `Tls ` / `tiles` +- PhotoCraft command: `filter.stylize.tiles` + +| Key | String ID | Type | Unit | Default | Allowed | +|---|---|---|---|---|---| +| `TlNm` | tileNumber | integer | | 10 | 1 .. 99 (out-of-range values are clamped) | +| `TlOf` | tileOffset | integer | | 10 | 1 .. 99 (out-of-range values are clamped) | +| `FlCl` | fillColor | enumerated | | `FlBc` fillBack | `FlBc` fillBack, `FlFr` fillFore, `FlIn` fillInverse, `FlSm` fillSame | + +#### Color Halftone + +- Event: `ClrH` / `colorHalftone` +- PhotoCraft command: `filter.pixelate.colorHalftone` + +| Key | String ID | Type | Unit | Default | Allowed | +|---|---|---|---|---|---| +| `Rds ` | radius | integer | | 8 | 4 .. 127 (out-of-range values are clamped) | +| `Ang1` | angle1 | integer | | 108 | -10000 .. 10000 (out-of-range values are clamped) | +| `Ang2` | angle2 | integer | | 162 | -10000 .. 10000 (out-of-range values are clamped) | +| `Ang3` | angle3 | integer | | 90 | -10000 .. 10000 (out-of-range values are clamped) | +| `Ang4` | angle4 | integer | | 45 | -10000 .. 10000 (out-of-range values are clamped) | + +#### Facet + +- Event: `Fct ` / `facet` +- PhotoCraft command: `filter.pixelate.facet` + +No parameters: the step carries an empty descriptor. + +#### Fragment + +- Event: `Frgm` / `fragment` +- PhotoCraft command: `filter.pixelate.fragment` + +No parameters: the step carries an empty descriptor. + +#### Mezzotint + +- Event: `Mztn` / `mezzotint` +- PhotoCraft command: `filter.pixelate.mezzotint` + +| Key | String ID | Type | Unit | Default | Allowed | +|---|---|---|---|---|---| +| `MztT` | mezzotintType | enumerated | | `FnDt` fineDots | `CrsD` coarseDots, `FnDt` fineDots, `GrnD` grainyDots, `LngL` longLines, `LngS` longStrokes, `MdmD` mediumDots, `MdmL` mediumLines, `MdmS` mediumStrokes, `ShSt` shortStrokes, `ShrL` shortLines | + +#### Mosaic + +- Event: `Msc ` / `mosaic` +- PhotoCraft command: `filter.pixelate.mosaic` + +| Key | String ID | Type | Unit | Default | Allowed | +|---|---|---|---|---|---| +| `ClSz` | cellSize | unitdouble | | 2 | 2 .. 200 pixels (out-of-range values are clamped). Write it as a unit double in `#Pxl`: the application accepts that and returns it unchanged. Read a value without a unit as pixels (the application returns its own default that way). | + +#### Pointillize + +- Event: `Pntl` / `pointillize` +- PhotoCraft command: `filter.pixelate.pointillize` + +| Key | String ID | Type | Unit | Default | Allowed | +|---|---|---|---|---|---| +| `ClSz` | cellSize | integer | | 5 | 3 .. 300 (out-of-range values are clamped) | ## Event reference: events not mapped yet @@ -863,123 +1007,6 @@ Nested values as recorded (defaults): Displace reads a displacement map from a file chosen in a dialog; record one step and add its keys (file path form) before implementing. -### Group D: The Stylize and Pixelate filters - -All commands exist in PhotoCraft (`filter.stylize.*`, `filter.pixelate.*`); only the `.atn` mapping is missing. - -#### Extrude - -- Event: `Extr` / `extrude` -- PhotoCraft command: `filter.stylize.extrude` - -| Key | String ID | Type | Unit | Default | Allowed | -|---|---|---|---|---|---| -| `ExtS` | extrudeSize | integer | | 30 | 2 .. 255 (out-of-range values are clamped) | -| `ExtD` | extrudeDepth | integer | | 30 | 1 .. 255 (out-of-range values are clamped) | -| `ExtF` | extrudeSolidFace | boolean | | false | | -| `ExtM` | extrudeMaskIncomplete | boolean | | false | | -| `ExtT` | extrudeType | enumerated | | `Blks` blacks | `Blks` blacks, `Pyrm` pyramids | -| `ExtR` | extrudeRandom | enumerated | | `Rndm` random | `LvlB` levelBased, `Rndm` random | - -#### Find Edges - -- Event: `FndE` / `findEdges` -- PhotoCraft command: `filter.stylize.findEdges` - -No parameters: the step carries an empty descriptor. - -#### Solarize - -- Event: `Slrz` / `solarize` -- PhotoCraft command: `filter.stylize.solarize` - -No parameters: the step carries an empty descriptor. - -#### Trace Contour - -- Event: `TrcC` / `traceContour` -- PhotoCraft command: `filter.stylize.traceContour` - -| Key | String ID | Type | Unit | Default | Allowed | -|---|---|---|---|---|---| -| `Lvl ` | level | integer | | 128 | applied, but not echoed: the returned step always shows 128. Levels <= 0 and >= 255 give the same image. Dialog range pending. | -| `Edg ` | edge | enumerated | | `Upr ` upper | `Lwr ` lower, `Upr ` upper (applied; the returned step always shows upper) | - -#### Wind - -- Event: `Wnd ` / `wind` -- PhotoCraft command: `filter.stylize.wind` - -| Key | String ID | Type | Unit | Default | Allowed | -|---|---|---|---|---|---| -| `WndM` | windMethod | enumerated | | `Wnd ` wind | `Blst` blast, `Stgr` stagger, `Wnd ` wind | -| `Drct` | direction | enumerated | | `Left` left | `Left` left, `Rght` right | - -#### Tiles - -- Event: `Tls ` / `tiles` -- PhotoCraft command: `filter.stylize.tiles` - -| Key | String ID | Type | Unit | Default | Allowed | -|---|---|---|---|---|---| -| `TlNm` | tileNumber | integer | | 10 | 1 .. 99 (out-of-range values are clamped) | -| `TlOf` | tileOffset | integer | | 10 | 1 .. 99 (out-of-range values are clamped) | -| `FlCl` | fillColor | enumerated | | `FlBc` fillBack | `FlBc` fillBack, `FlFr` fillFore, `FlIn` fillInverse, `FlSm` fillSame | - -#### Color Halftone - -- Event: `ClrH` / `colorHalftone` -- PhotoCraft command: `filter.pixelate.colorHalftone` - -| Key | String ID | Type | Unit | Default | Allowed | -|---|---|---|---|---|---| -| `Rds ` | radius | integer | | 8 | 4 .. 127 (out-of-range values are clamped) | -| `Ang1` | angle1 | integer | | 108 | -10000 .. 10000 (out-of-range values are clamped) | -| `Ang2` | angle2 | integer | | 162 | -10000 .. 10000 (out-of-range values are clamped) | -| `Ang3` | angle3 | integer | | 90 | -10000 .. 10000 (out-of-range values are clamped) | -| `Ang4` | angle4 | integer | | 45 | -10000 .. 10000 (out-of-range values are clamped) | - -#### Facet - -- Event: `Fct ` / `facet` -- PhotoCraft command: `filter.pixelate.facet` - -No parameters: the step carries an empty descriptor. - -#### Fragment - -- Event: `Frgm` / `fragment` -- PhotoCraft command: `filter.pixelate.fragment` - -No parameters: the step carries an empty descriptor. - -#### Mezzotint - -- Event: `Mztn` / `mezzotint` -- PhotoCraft command: `filter.pixelate.mezzotint` - -| Key | String ID | Type | Unit | Default | Allowed | -|---|---|---|---|---|---| -| `MztT` | mezzotintType | enumerated | | `FnDt` fineDots | `CrsD` coarseDots, `FnDt` fineDots, `GrnD` grainyDots, `LngL` longLines, `LngS` longStrokes, `MdmD` mediumDots, `MdmL` mediumLines, `MdmS` mediumStrokes, `ShSt` shortStrokes, `ShrL` shortLines | - -#### Mosaic - -- Event: `Msc ` / `mosaic` -- PhotoCraft command: `filter.pixelate.mosaic` - -| Key | String ID | Type | Unit | Default | Allowed | -|---|---|---|---|---|---| -| `ClSz` | cellSize | unitdouble | | 2 | 2 .. 200 pixels (out-of-range values are clamped). Write it as a unit double in `#Pxl`: the application accepts that and returns it unchanged. Read a value without a unit as pixels (the application returns its own default that way). | - -#### Pointillize - -- Event: `Pntl` / `pointillize` -- PhotoCraft command: `filter.pixelate.pointillize` - -| Key | String ID | Type | Unit | Default | Allowed | -|---|---|---|---|---|---| -| `ClSz` | cellSize | integer | | 5 | 3 .. 300 (out-of-range values are clamped) | - ### Group E: Video, Other and matting filters and the direct Gradient Map / Replace Color adjustments All commands exist in PhotoCraft; only the `.atn` mapping is missing. Gradient Map and Replace Color here are the direct adjustments (Image > Adjustments), not adjustment layers (those are already mapped). From 7246b53ac318a21c4ba3422bd97b65e2b3ac0a51 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Sat, 31 Oct 2026 19:19:45 -0700 Subject: [PATCH 026/216] Complete Render action descriptors and playback checks --- crates/engine/src/cmd/atn_cmds/events.rs | 18 +- .../engine/src/cmd/atn_cmds/render_tests.rs | 156 ++++++++++++++++++ .../engine/src/cmd/atn_cmds/steps_render.rs | 126 +++++++++++++- crates/engine/src/cmd/atn_cmds/tests.rs | 2 + crates/engine/src/filters_ext.rs | 6 +- crates/engine/src/filters_ext/tests.rs | 22 +++ docs/atn.md | 109 ++++++------ 7 files changed, 380 insertions(+), 59 deletions(-) diff --git a/crates/engine/src/cmd/atn_cmds/events.rs b/crates/engine/src/cmd/atn_cmds/events.rs index cca15afc1e..d3f62b3a2d 100644 --- a/crates/engine/src/cmd/atn_cmds/events.rs +++ b/crates/engine/src/cmd/atn_cmds/events.rs @@ -201,6 +201,22 @@ pub(super) const EVENTS: &[Event] = &[ from: |d| super::steps_render::clouds_from("filter.render.differenceClouds", d), to: super::steps_render::clouds_to, }, + Event { + code: "Fbrs", + name: "", + label: "Fibers", + ids: &["filter.render.fibers"], + from: super::steps_render::fibers_from, + to: super::steps_render::fibers_to, + }, + Event { + code: "LnsF", + name: "lensFlare", + label: "Lens Flare", + ids: &["filter.render.lensFlare"], + from: super::steps_render::flare_from, + to: super::steps_render::flare_to, + }, ev!("GsnB", "gaussianBlur", "Gaussian Blur", "filter.blur.gaussianBlur", &[F("Rds ", "radius", Kind::Px)]), ev!( "UnsM", @@ -633,7 +649,7 @@ const LEGACY_CODES: &[(&str, &str)] = pub(super) fn event_for(item: &AtnEvent) -> Option<&'static Event> { let id = item.id(); - let find = |id: &str| EVENTS.iter().find(|e| (!e.code.is_empty() && e.code.trim_end() == id.trim_end()) || e.name == id); + let find = |id: &str| EVENTS.iter().find(|e| (!e.code.is_empty() && e.code.trim_end() == id.trim_end()) || (!e.name.is_empty() && e.name == id)); find(&id).or_else(|| match item { AtnEvent::Code(_) => LEGACY_CODES.iter().find(|(code, _)| *code == id).and_then(|(_, name)| find(name)), AtnEvent::Name(_) => None, diff --git a/crates/engine/src/cmd/atn_cmds/render_tests.rs b/crates/engine/src/cmd/atn_cmds/render_tests.rs index 619d9ca294..f187618bb6 100644 --- a/crates/engine/src/cmd/atn_cmds/render_tests.rs +++ b/crates/engine/src/cmd/atn_cmds/render_tests.rs @@ -1,5 +1,32 @@ use super::*; +/// A real action file with one step, used by both descriptor and playback checks. +fn file(event: AtnEvent, descriptor: Descriptor) -> Vec { + atn::write(&AtnFile { + name: UnicodeString::new_nul("Render set"), + actions: vec![AtnAction { + name: UnicodeString::new_nul("Render"), + items: vec![AtnItem { event, dictionary: b"Render".to_vec(), descriptor: Some(descriptor), ..Default::default() }], + ..Default::default() + }], + ..Default::default() + }) + .unwrap() +} + +/// Measured descriptor order and types, independent of the converter's field construction. +fn fibers(variance: i32, strength: i32, seed: i32) -> Descriptor { + desc("null").with("Vrnc", D::Integer(variance)).with("Strg", D::Integer(strength)).with("RndS", D::Integer(seed)) +} + +/// The measured center is a Point object with two doubles, not pixel or percent units. +fn flare(brightness: i32, center: [f64; 2], lens: &str) -> Descriptor { + desc("null") + .with("Brgh", D::Integer(brightness)) + .with("FlrC", D::Descriptor(desc("Pnt ").with("Hrzn", D::Double(center[0])).with("Vrtc", D::Double(center[1])))) + .with("Lns ", enm("Lns ", lens)) +} + /// Both recorded event IDs map empty Clouds descriptors without inventing frozen colors. #[test] fn clouds_events_map_and_refuse_unrepresentable_native_settings() { @@ -23,3 +50,132 @@ fn clouds_events_map_and_refuse_unrepresentable_native_settings() { } } } + +/// Defaults and changed descriptors survive the actual load/save commands, including key order. +#[test] +fn render_descriptors_load_and_save_without_losing_values() { + let center = [0.40000000596046; 2]; + let mut cases = vec![("Fbrs", None, fibers(16, 4, 123)), ("LnsF", Some("lensFlare"), flare(100, center, "Zm"))]; + for seed in [i32::MIN, -1, 0, i32::MAX] { + cases.push(("Fbrs", None, fibers(-23, i32::MAX, seed))); + } + for lens in ["Zm", "Nkn", "Nkn1", "PnVs"] { + cases.push(("LnsF", Some("lensFlare"), flare(280, [5.0, -5.0], lens))); + } + for (code_id, name, descriptor) in cases { + let mut events = vec![AtnEvent::Code(code_id.as_bytes().try_into().unwrap())]; + if let Some(name) = name { + events.push(AtnEvent::name(name)); + } + for event in events { + let mut s = Session::new(); + let loaded = s.execute("actions.load", json!({"data": photocraft_paint::tile::b64_encode(&file(event, descriptor.clone()))})).unwrap(); + assert_eq!(loaded["unsupported"], json!([])); + let saved = s.execute("actions.save", json!({"action": "Render"})).unwrap(); + let bytes = photocraft_paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap(); + let back = atn::parse(&bytes).unwrap(); + assert_eq!(back.actions[0].items[0].event.id(), code_id); + assert_eq!(back.actions[0].items[0].descriptor, Some(descriptor.clone())); + } + } +} + +/// Missing import fields use measured defaults; native export keeps the command's own defaults. +#[test] +fn render_defaults_seeds_and_brightness_bounds_are_explicit() { + let bytes = file(AtnEvent::Code(*b"Fbrs"), desc("null")); + let action = import(&bytes).unwrap().actions.remove(0); + let p = &action.steps[0].1; + assert_eq!(p["variance"], 16); + assert_eq!(p["strength"], 4); + assert!(i32::try_from(p["seed"].as_i64().unwrap()).is_ok()); + let saved = export("S", &[&action], &[], false).unwrap().0; + assert_eq!(saved, export("S", &[&action], &[], false).unwrap().0, "the captured seed is stable"); + assert_eq!(import(&saved).unwrap().actions[0].steps, action.steps); + for (brightness, expected) in [(-500, 10), (100, 100), (500, 300)] { + let action = import(&file(AtnEvent::Code(*b"LnsF"), desc("null").with("Brgh", D::Integer(brightness)))).unwrap().actions.remove(0); + assert_eq!(action.steps[0].1, json!({"brightness": expected, "centerX": 0.40000000596046, "centerY": 0.40000000596046, "lens": "zoom"})); + } + let native = export_item("filter.render.lensFlare", &json!({}), "S").unwrap(); + let back = import(&file(native.event, native.descriptor.unwrap())).unwrap().actions.remove(0); + assert_eq!(back.steps[0].1["centerX"], 0.5); + assert_eq!( + crate::filters_ext::params_for("filter.render.lensFlare", &back.steps[0].1), + crate::filters_ext::params_for("filter.render.lensFlare", &json!({})) + ); + let unsigned = export_item("filter.render.fibers", &json!({"seed": u32::MAX}), "S").unwrap(); + assert_eq!(unsigned.descriptor.unwrap().get("RndS"), Some(&D::Integer(-1))); +} + +/// Unknown enums, malformed types and unmeasured extra fields stay visibly unsupported. +#[test] +fn unsupported_render_descriptors_keep_their_bytes_and_flags() { + for descriptor in [ + desc("null").with("Lns ", enm("Lns ", "Bad!")), + desc("null").with("Lns ", enm("Other", "Zm")), + desc("null").with("Brgh", D::Double(100.0)), + desc("null").with("FlrC", D::Descriptor(desc("Other"))), + desc("null").with("FlrC", D::Descriptor(desc("Pnt ").with("Hrzn", px(0.4)))), + desc("null").with("futureSetting", D::Boolean(true)), + ] { + let mut original = atn::parse(&file(AtnEvent::Code(*b"LnsF"), descriptor.clone())).unwrap(); + original.actions[0].items[0].enabled = false; + original.actions[0].items[0].with_dialog = true; + original.actions[0].items[0].dialog_options = 2; + let loaded = import(&atn::write(&original).unwrap()).unwrap(); + assert_eq!(loaded.unsupported.len(), 1); + assert_eq!(loaded.actions[0].steps[0].0, PHOTOSHOP_STEP); + let bytes = export("Render set", &[&loaded.actions[0]], &[], false).unwrap().0; + assert_eq!(atn::parse(&bytes).unwrap().actions[0].items[0], original.actions[0].items[0]); + } + for (event, descriptor) in [(*b"Fbrs", desc("null").with("RndS", D::Double(1.0))), (*b"Clds", desc("null").with("FlRs", D::Integer(1)))] { + assert_eq!(import(&file(AtnEvent::Code(event), descriptor)).unwrap().unsupported.len(), 1); + } + for id in ["filter.render.fibers", "filter.render.lensFlare"] { + for p in [json!({"target": "mask"}), json!({"foreground": "#ff0000"}), json!({"layer": "Other"}), json!({"unknown": true})] { + assert!(export_item(id, &p, "S").is_none(), "{id}: {p}"); + } + } + for p in [json!({"seed": 4294967296u64}), json!({"seed": -2147483649i64}), json!({"variance": 1.5})] { + assert!(export_item("filter.render.fibers", &p, "S").is_none(), "{p}"); + } + assert!(export_item("filter.render.lensFlare", &json!({"lens": "unknown"}), "S").is_none()); +} + +/// Imported playback uses current tool colors and the same filter path as direct commands. +#[test] +fn render_actions_match_direct_filters_and_undo() { + let cases = [ + (*b"Clds", desc("null"), "filter.render.clouds", json!({})), + (*b"DfrC", desc("null"), "filter.render.differenceClouds", json!({})), + (*b"Fbrs", fibers(23, 6, -7), "filter.render.fibers", json!({"variance": 23, "strength": 6, "seed": -7})), + (*b"LnsF", flare(240, [5.0, -5.0], "Nkn1"), "filter.render.lensFlare", json!({"brightness": 240, "centerX": 5.0, "centerY": -5.0, "lens": "prime105"})), + ]; + for mode in ["rgb", "gray"] { + for depth in [8, 16, 32] { + for (event, descriptor, id, params) in &cases { + let setup = || { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 6, "mode": mode, "depth": depth, "background": "transparent"})).unwrap(); + s.execute("edit.fill", json!({"contents": "color", "color": "#304050"})).unwrap(); + s.execute("tools.setColors", json!({"foreground": "#fe1020", "background": "#1050e0"})).unwrap(); + s + }; + let mut action = setup(); + let mut direct = setup(); + let before = action.execute("document.pixel", json!({"x": 3, "y": 2})).unwrap(); + action.execute("actions.load", json!({"data": photocraft_paint::tile::b64_encode(&file(AtnEvent::Code(*event), descriptor.clone()))})).unwrap(); + let played = action.execute("actions.play", json!({"action": "Render"})).unwrap(); + assert!(played.get("failed").is_none(), "{played}"); + direct.execute(id, params.clone()).unwrap(); + let surface = |s: &Session| { + let st = s.active().unwrap(); + st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().read_region(st.doc.bounds()) + }; + assert_eq!(surface(&action), surface(&direct), "{mode}/{depth}/{id}"); + action.execute("edit.undo", json!({})).unwrap(); + assert_eq!(action.execute("document.pixel", json!({"x": 3, "y": 2})).unwrap(), before); + } + } + } +} diff --git a/crates/engine/src/cmd/atn_cmds/steps_render.rs b/crates/engine/src/cmd/atn_cmds/steps_render.rs index fbd2477237..1932543ae2 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_render.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_render.rs @@ -1,8 +1,40 @@ //! Recorded descriptors for the Render filters. -use super::descriptors::desc; +use std::hash::BuildHasher; + +use super::descriptors::{desc, enm}; use super::fields::{Mapped, step}; -use super::{Descriptor, Value, json}; +use super::tables::{lookup, lookup_back}; +use super::{D, Descriptor, Value, json}; + +const CENTER: f64 = 0.40000000596046; +const LENSES: &[(&str, &str)] = &[("Zm", "zoom"), ("Nkn", "prime35"), ("Nkn1", "prime105"), ("PnVs", "moviePrime")]; + +/// Only measured descriptor keys are translated; other settings remain a kept step. +fn keys(d: &Descriptor, allowed: &[&str]) -> bool { + d.items.iter().all(|(key, _)| allowed.iter().any(|name| key.is(name))) +} + +/// Read a recorded integer without accepting another descriptor type as its default. +fn integer(d: &Descriptor, key: &str, default: i32) -> Option { + match d.get(key) { + None => Some(default), + Some(D::Integer(n)) => Some(*n), + _ => None, + } +} + +/// Native numeric parameters must fit the descriptor integer without rounding. +fn param_integer(p: &Value, key: &str, default: i32) -> Option { + let Some(v) = p.get(key) else { return Some(default) }; + let n = v.as_f64()?; + (n.is_finite() && n.fract() == 0.0 && (f64::from(i32::MIN)..=f64::from(i32::MAX)).contains(&n)).then_some(n as i32) +} + +/// Pixel-only export cannot discard an explicit native layer, seed or color setting. +fn params(p: &Value, allowed: &[&str]) -> bool { + super::events::pixel_target(p) && p.as_object().is_some_and(|p| p.keys().all(|k| k == "target" || allowed.contains(&k.as_str()))) +} /// Clouds events have no fields; tool colors are read when the step plays. pub(super) fn clouds_from(id: &str, d: &Descriptor) -> Mapped { @@ -11,6 +43,92 @@ pub(super) fn clouds_from(id: &str, d: &Descriptor) -> Mapped { /// Explicit seeds, colors and layer references have no fields in these ATN events. pub(super) fn clouds_to(_: &str, p: &Value) -> Option { - let params = p.as_object()?; - (super::events::pixel_target(p) && params.keys().all(|key| key == "target")).then(|| desc("null")) + params(p, &[]).then(|| desc("null")) +} + +/// Capture a missing recorded seed once; later playback and export reuse the stored bits. +fn new_seed() -> i32 { + std::collections::hash_map::RandomState::new().hash_one(0u8) as i32 +} + +/// Fibers keeps raw variance/strength until their dialog limits are measured. +pub(super) fn fibers_from(d: &Descriptor) -> Mapped { + let mapped = || { + if !keys(d, &["Vrnc", "Strg", "RndS"]) { + return None; + } + let seed = if d.get("RndS").is_none() { new_seed() } else { integer(d, "RndS", 0)? }; + Some(json!({"variance": integer(d, "Vrnc", 16)?, "strength": integer(d, "Strg", 4)?, "seed": seed})) + }; + mapped().map_or(Mapped::Unsupported { harmless: false }, |p| step("filter.render.fibers", p)) +} + +/// Write signed seed bits even when the native command used the equivalent unsigned value. +pub(super) fn fibers_to(_: &str, p: &Value) -> Option { + if !params(p, &["variance", "strength", "seed"]) { + return None; + } + let seed = match p.get("seed").and_then(Value::as_u64) { + Some(n) => u32::try_from(n).ok()? as i32, + None => param_integer(p, "seed", 0)?, + }; + Some( + desc("null") + .with("Vrnc", D::Integer(param_integer(p, "variance", 16)?)) + .with("Strg", D::Integer(param_integer(p, "strength", 4)?)) + .with("RndS", D::Integer(seed)), + ) +} + +/// Read the point's doubles without changing the recorded descriptor precision. +fn coordinate(d: &Descriptor, key: &str) -> Option { + match d.get(key) { + None => Some(CENTER), + Some(D::Double(n)) if n.is_finite() => Some(*n), + _ => None, + } +} + +/// Lens Flare materializes the measured defaults and retains valid off-canvas centers. +pub(super) fn flare_from(d: &Descriptor) -> Mapped { + let mapped = || { + if !keys(d, &["Brgh", "FlrC", "Lns "]) { + return None; + } + let center = match d.get("FlrC") { + None => [CENTER; 2], + Some(D::Descriptor(point)) if point.class_id.is("Pnt ") && keys(point, &["Hrzn", "Vrtc"]) => { + [coordinate(point, "Hrzn")?, coordinate(point, "Vrtc")?] + } + _ => return None, + }; + let lens = match d.get("Lns ") { + None => "zoom", + Some(D::Enumerated { type_id, value }) if type_id.is("Lns ") => lookup(LENSES, &super::descriptors::trimmed(value))?, + _ => return None, + }; + Some(json!({"brightness": integer(d, "Brgh", 100)?.clamp(10, 300), "centerX": center[0], "centerY": center[1], "lens": lens})) + }; + mapped().map_or(Mapped::Unsupported { harmless: false }, |p| step("filter.render.lensFlare", p)) +} + +/// Native omitted centers mean 0.5; imported steps already carry the measured 0.4 values. +pub(super) fn flare_to(_: &str, p: &Value) -> Option { + if !params(p, &["brightness", "centerX", "centerY", "lens"]) { + return None; + } + let number = |key| match p.get(key) { + None => Some(0.5), + Some(n) => n.as_f64().filter(|n| n.is_finite()), + }; + let lens = match p.get("lens") { + None => "zoom", + Some(v) => v.as_str()?, + }; + Some( + desc("null") + .with("Brgh", D::Integer(param_integer(p, "brightness", 100)?.clamp(10, 300))) + .with("FlrC", D::Descriptor(desc("Pnt ").with("Hrzn", D::Double(number("centerX")?)).with("Vrtc", D::Double(number("centerY")?)))) + .with("Lns ", enm("Lns ", lookup_back(LENSES, lens)?)), + ) } diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index 7d7f1cf8a7..2c6740dc92 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -478,6 +478,8 @@ fn every_mapped_command_exports_and_imports_back() { let cases: Vec<(&str, Value)> = vec![ ("filter.render.clouds", json!({})), ("filter.render.differenceClouds", json!({})), + ("filter.render.fibers", json!({"variance": 20, "strength": 7, "seed": -123})), + ("filter.render.lensFlare", json!({"brightness": 180, "centerX": 5, "centerY": -5, "lens": "moviePrime"})), ("filter.blur.gaussianBlur", json!({"radius": 3.5})), ("filter.sharpen.unsharpMask", json!({"amount": 120, "radius": 1.5, "threshold": 3})), ("filter.noise.addNoise", json!({"amount": 12.5, "distribution": "gaussian", "monochromatic": true, "seed": 7})), diff --git a/crates/engine/src/filters_ext.rs b/crates/engine/src/filters_ext.rs index 1afc11220e..a15004481f 100644 --- a/crates/engine/src/filters_ext.rs +++ b/crates/engine/src/filters_ext.rs @@ -307,8 +307,8 @@ fn shear(p: &Value) -> FilterParams { fn lens_flare(p: &Value) -> FilterParams { FilterParams::LensFlare { brightness: f(p, "brightness", 100.0).clamp(10.0, 300.0), - center_x: f(p, "centerX", 0.5).clamp(-0.5, 1.5), - center_y: f(p, "centerY", 0.5).clamp(-0.5, 1.5), + center_x: f(p, "centerX", 0.5), + center_y: f(p, "centerY", 0.5), lens: choice(p, "lens", "zoom", LENS_TYPES, LensType::Zoom), } } @@ -725,7 +725,7 @@ pub fn specs() -> Vec { "filter.render.lensFlare", "Lens Flare…", ["Filter", "Render"], - r##"{"brightness":10..300=100,"centerX":0..1=0.5,"centerY":0..1=0.5,"lens":"zoom|prime35|prime105|moviePrime"}"## + r##"{"brightness":10..300=100,"centerX":number=0.5,"centerY":number=0.5,"lens":"zoom|prime35|prime105|moviePrime"} (normalized center coordinates; positions outside the image are supported)"## ), cmd!( "filter.render.lightingEffects", diff --git a/crates/engine/src/filters_ext/tests.rs b/crates/engine/src/filters_ext/tests.rs index 490568e87c..c5a30dc155 100644 --- a/crates/engine/src/filters_ext/tests.rs +++ b/crates/engine/src/filters_ext/tests.rs @@ -19,6 +19,28 @@ fn session() -> Session { s } +/// Recorded Lens Flare centers can be beyond the canvas, without clipping to its edge. +#[test] +fn lens_flare_keeps_off_canvas_centers_and_native_defaults() { + let Some(FilterParams::LensFlare { center_x, center_y, .. }) = params_for("filter.render.lensFlare", &json!({"centerX": 5.0, "centerY": -5.0})) else { + panic!("Lens Flare") + }; + assert_eq!((center_x, center_y), (5.0, -5.0)); + let Some(FilterParams::LensFlare { center_x, center_y, .. }) = params_for("filter.render.lensFlare", &json!({})) else { panic!("Lens Flare") }; + assert_eq!((center_x, center_y), (0.5, 0.5)); +} + +/// Extreme centers and malformed inputs remain bounded command executions with finite pixels. +#[test] +fn lens_flare_extreme_centers_do_not_panic_or_corrupt_pixels() { + for p in [json!({"centerX": 1e300, "centerY": -1e300}), json!({"centerX": null, "centerY": []}), json!({"centerX": 5, "centerY": -5, "brightness": -100})] { + let mut s = session(); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| s.execute("filter.render.lensFlare", p))); + assert!(result.is_ok()); + assert!(pixels(&s).iter().all(|v| v.is_finite())); + } +} + fn pixels(s: &Session) -> Vec { let d = s.active().unwrap(); d.doc.layer(d.active_layer.unwrap()).unwrap().surface().unwrap().read_region(Rect::new(0, 0, 48, 32)) diff --git a/docs/atn.md b/docs/atn.md index 0d280e147c..14612a4428 100644 --- a/docs/atn.md +++ b/docs/atn.md @@ -16,7 +16,7 @@ so playback skips them. This page has two parts: the events PhotoCraft maps today, and a measured reference for the events it does not map yet, with everything needed to implement them without the reference application. -## Supported events (117) +## Supported events (121) | Code | String ID | Label | PhotoCraft command(s) | |---|---|---|---| @@ -72,6 +72,10 @@ does not map yet, with everything needed to implement them without the reference | `GEfc` | filterGallery | Filter Gallery | `filter.gallery.spatter` | | `FltI` | flattenImage | Flatten Image | `layer.flattenImage` | | `Flip` | flip | Flip | `image.imageRotation.flipCanvasHorizontal`, `image.imageRotation.flipCanvasVertical`, `edit.transform.flipHorizontal`, `edit.transform.flipVertical` | +| `Clds` | clouds | Clouds | `filter.render.clouds` | +| `DfrC` | differenceClouds | Difference Clouds | `filter.render.differenceClouds` | +| `Fbrs` | (code only) | Fibers | `filter.render.fibers` | +| `LnsF` | lensFlare | Lens Flare | `filter.render.lensFlare` | | `GsnB` | gaussianBlur | Gaussian Blur | `filter.blur.gaussianBlur` | | `GdMp` | GdMp | gradientMapEvent | `Gradient Map` | | `Grow` | grow | Grow | `select.grow` | @@ -138,6 +142,59 @@ does not map yet, with everything needed to implement them without the reference | (string ID only) | updatePlacedLayer | Update Smart Objects | `layer.smartObjects.updateModifiedContent` | | `Vbrn` | Vbrn | vibrance | `Vibrance` | +### Group B: The Render filters Clouds, Difference Clouds, Fibers and Lens Flare + +These four events load, play and save through the existing Render commands. Lighting Effects still needs a recorded step through its dialog. + +#### Clouds + +- Event: `Clds` / `clouds` +- PhotoCraft command: `filter.render.clouds` +- Uses the current foreground and background colours; the step itself carries no colours. + +No parameters: the step carries an empty descriptor. + +#### Difference Clouds + +- Event: `DfrC` / `differenceClouds` +- PhotoCraft command: `filter.render.differenceClouds` +- Same as Clouds, blended with Difference. + +No parameters: the step carries an empty descriptor. + +#### Fibers + +- Event: `Fbrs` (no string ID) +- PhotoCraft command: `filter.render.fibers` +- `randomSeed` makes playback reproduce the recorded result; keep it. + +| Key | String ID | Type | Unit | Default | Allowed | +|---|---|---|---|---|---| +| `Vrnc` | (none) | integer | | 16 | not clamped in the step (values are returned unchanged); limits pending, see "Events that need a step recorded through their dialog" | +| `Strg` | strengthPlugin | integer | | 4 | not clamped in the step (values are returned unchanged); limits pending, see "Events that need a step recorded through their dialog" | +| `RndS` | randomSeed | integer | | random | any 32-bit integer; a new random value per recording | + +#### Lens Flare + +- Event: `LnsF` / `lensFlare` +- PhotoCraft command: `filter.render.lensFlare` +- Lens types: `Zm ` 50-300 mm Zoom, `Nkn ` 35 mm Prime, `Nkn1` 105 mm Prime, `PnVs` Movie Prime. + +| Key | String ID | Type | Unit | Default | Allowed | +|---|---|---|---|---|---| +| `Brgh` | brightness | integer | | 100 | 10 .. 300 (out-of-range values are clamped) | +| `FlrC` | flareCenter | object | | obj 'Pnt ' point | `Hrzn` / `Vrtc` doubles, 0.4 / 0.4 by default; not clamped: (5, -5) is kept as given and plays | +| `Lns ` | lens | enumerated | | `Zm ` zoom | `Nkn ` nikon, `Nkn1` nikon105, `PnVs` panaVision, `Zm ` zoom | + +Nested values as recorded (defaults): +``` + 'Hrzn' horizontal : DOUBLETYPE = 0.40000000596046 + 'Vrtc' vertical : DOUBLETYPE = 0.40000000596046 +``` + +Native commands without an explicit Lens Flare center still use 0.5 / 0.5. Imported steps materialize the measured center above. A missing Fibers seed is captured once at import and preserved for later playback and export. Native color overrides and other settings without ATN fields are refused during strict export. + + ## Event reference: events not mapped yet Each entry below is an event the reference application records that PhotoCraft does not map yet: its @@ -715,56 +772,6 @@ Nested values as recorded (defaults): | `Cntr` | contrast | integer | | 50 | 0 .. 100 (out-of-range values are clamped) | maps to our `contrast` (same range and default) | | `Grnt` | grainType | enumerated | | `GrnR` grainRegular | `GrCn` grainContrasty, `GrSf` grainSoft, `GrSp` grainSpeckle, `GrSr` grainSprinkles, `GrSt` grainStippled, `GrnC` grainClumped, `GrnE` grainEnlarged, `GrnH` grainHorizontal, `GrnR` grainRegular, `GrnV` grainVertical | | -### Group B: The Render filters Clouds, Difference Clouds, Fibers and Lens Flare - -All four commands exist in PhotoCraft; only the `.atn` mapping is missing. Lighting Effects is covered with the events that need a recorded step (its lights are only recorded through the dialog). - -#### Clouds - -- Event: `Clds` / `clouds` -- PhotoCraft command: `filter.render.clouds` -- Uses the current foreground and background colours; the step itself carries no colours. - -No parameters: the step carries an empty descriptor. - -#### Difference Clouds - -- Event: `DfrC` / `differenceClouds` -- PhotoCraft command: `filter.render.differenceClouds` -- Same as Clouds, blended with Difference. - -No parameters: the step carries an empty descriptor. - -#### Fibers - -- Event: `Fbrs` (no string ID) -- PhotoCraft command: `filter.render.fibers` -- `randomSeed` makes playback reproduce the recorded result; keep it. - -| Key | String ID | Type | Unit | Default | Allowed | -|---|---|---|---|---|---| -| `Vrnc` | (none) | integer | | 16 | not clamped in the step (values are returned unchanged); limits pending, see "Events that need a step recorded through their dialog" | -| `Strg` | strengthPlugin | integer | | 4 | not clamped in the step (values are returned unchanged); limits pending, see "Events that need a step recorded through their dialog" | -| `RndS` | randomSeed | integer | | random | any 32-bit integer; a new random value per recording | - -#### Lens Flare - -- Event: `LnsF` / `lensFlare` -- PhotoCraft command: `filter.render.lensFlare` -- Lens types: `Zm ` 50-300 mm Zoom, `Nkn ` 35 mm Prime, `Nkn1` 105 mm Prime, `PnVs` Movie Prime. - -| Key | String ID | Type | Unit | Default | Allowed | -|---|---|---|---|---|---| -| `Brgh` | brightness | integer | | 100 | 10 .. 300 (out-of-range values are clamped) | -| `FlrC` | flareCenter | object | | obj 'Pnt ' point | `Hrzn` / `Vrtc` doubles, 0.4 / 0.4 by default; not clamped: (5, -5) is kept as given and plays | -| `Lns ` | lens | enumerated | | `Zm ` zoom | `Nkn ` nikon, `Nkn1` nikon105, `PnVs` panaVision, `Zm ` zoom | - -Nested values as recorded (defaults): -``` - 'Hrzn' horizontal : DOUBLETYPE = 0.40000000596046 - 'Vrtc' vertical : DOUBLETYPE = 0.40000000596046 -``` - ### Group C: The Distort filters (Pinch, Polar Coordinates, Shear, Spherize, Twirl, Wave, ZigZag) All commands exist in PhotoCraft (`filter.distort.*`); only the `.atn` mapping is missing. Displace needs a displacement map file and is listed separately at the end. From 25b7ac59f320928a6d347b2e460cb7e2a7f02696 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Sat, 31 Oct 2026 19:34:25 -0700 Subject: [PATCH 027/216] Reject channel selections that cannot round-trip through ATN --- crates/engine/src/cmd/atn_cmds/steps_layer.rs | 14 ++++- crates/engine/src/cmd/atn_cmds/tests.rs | 57 +++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/atn_cmds/steps_layer.rs b/crates/engine/src/cmd/atn_cmds/steps_layer.rs index e486cb3d44..a54c08f4cd 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_layer.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_layer.rs @@ -88,7 +88,10 @@ pub(super) fn select_to(id: &str, p: &Value) -> Option { d.with("MkVs", D::Boolean(false)) } "channel.target" => { - if p.get("colors").is_some() || p.get("extend").is_some_and(|v| v != &Value::Bool(false)) { + if p.get("colors").is_some() + || p.get("channel").and_then(|channel| channel.get("colors")).is_some() + || p.get("extend").is_some_and(|v| v != &Value::Bool(false)) + { return None; } let v = match p.get("channel") { @@ -98,7 +101,14 @@ pub(super) fn select_to(id: &str, p: &Value) -> Option { "composite" => "RGB ", _ => return None, }, - Some(channel) => lookup_back(CHANNELS, &channel.get("color")?.as_str()?.to_ascii_lowercase())?, + Some(channel) => { + let color = channel.get("color")?.as_str()?.to_ascii_lowercase(); + // Gry imports as portable composite, unlike a mode-specific Gray object. + if color == "gray" { + return None; + } + lookup_back(CHANNELS, &color)? + } }; desc("null").with("null", enumerated("Chnl", "Chnl", v)).with("MkVs", D::Boolean(false)) } diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index 1e7abf35be..205d7efd26 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -3265,3 +3265,60 @@ fn select_color_components_do_not_fall_back_to_alpha_names() { assert_eq!(played["failed"]["id"], "channel.target"); assert_eq!(s.execute("channel.list", json!({})).unwrap()["target"]["kind"], "composite"); } + +/// Nested color indices take precedence over the object's single-color fallback at runtime. +#[test] +fn select_channel_export_rejects_nested_color_selections() { + for colors in [vec![0, 1], vec![1]] { + let params = json!({"channel":{"color":"cyan","colors":colors}}); + let mut s = Session::new(); + s.execute("file.new", json!({"width":2,"height":1,"mode":"cmyk"})).unwrap(); + s.execute("channel.target", params.clone()).unwrap(); + assert_eq!(s.active().unwrap().channel_view.selected_colors, colors); + s.actions.list.push(Action::new("Nested", vec![("channel.target".into(), params.clone())])); + assert!(s.execute("actions.save", json!({"action":"Nested"})).is_err(), "{params} must not become a single Cyan selection"); + let skipped = s.execute("actions.save", json!({"action":"Nested","skipUnsupported":true})).unwrap(); + assert_eq!(skipped["skipped"], json!([{"action":"Nested","step":0,"id":"channel.target"}])); + let bytes = photocraft_paint::tile::b64_decode(skipped["data"].as_str().unwrap()).unwrap(); + assert!(atn::parse(&bytes).unwrap().actions[0].items.is_empty()); + assert_eq!(s.actions.list[0].steps[0].1, params); + } + let mut s = Session::new(); + s.actions.list.push(Action::new("Cyan", vec![("channel.target".into(), json!({"channel":{"color":"CYAN"},"extend":false}))])); + let saved = s.execute("actions.save", json!({"action":"Cyan"})).unwrap(); + assert_eq!(saved["skipped"], json!([])); + let bytes = photocraft_paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap(); + assert_eq!(import(&bytes).unwrap().actions[0].steps, vec![("channel.target".into(), json!({"channel":{"color":"cyan"}}))]); +} + +/// A typed Gray target must retain its failure on RGB instead of becoming portable composite. +#[test] +fn select_channel_export_rejects_mode_specific_gray_objects() { + for color in ["gray", "GRAY", "GrAy"] { + for mode in ["gray", "rgb"] { + let params = json!({"channel":{"color":color}}); + let mut s = Session::new(); + s.execute("file.new", json!({"width":2,"height":1,"mode":mode})).unwrap(); + assert_eq!(s.execute("channel.target", params.clone()).is_ok(), mode == "gray"); + s.actions.list.push(Action::new("Gray object", vec![("channel.target".into(), params.clone())])); + assert!(s.execute("actions.save", json!({"action":"Gray object"})).is_err(), "{mode}/{color} must not become composite"); + let skipped = s.execute("actions.save", json!({"action":"Gray object","skipUnsupported":true})).unwrap(); + assert_eq!(skipped["skipped"], json!([{"action":"Gray object","step":0,"id":"channel.target"}])); + let bytes = photocraft_paint::tile::b64_decode(skipped["data"].as_str().unwrap()).unwrap(); + assert!(atn::parse(&bytes).unwrap().actions[0].items.is_empty()); + assert_eq!(s.actions.list[0].steps[0].1, params); + } + } + let mut s = Session::new(); + s.actions.list.push(Action::new("Portable", vec![("channel.target".into(), json!({"channel":"composite"}))])); + let saved = s.execute("actions.save", json!({"action":"Portable"})).unwrap(); + assert_eq!(saved["skipped"], json!([])); + let bytes = photocraft_paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap(); + assert_eq!(import(&bytes).unwrap().actions[0].steps, s.actions.list[0].steps); + let gray = set_of("Gray", vec![("Pick", vec![item("select", "Select", Some(desc("null").with("null", channel_ord("Gry"))))])]); + let mut rgb = Session::new(); + rgb.execute("file.new", json!({"width":2,"height":1,"mode":"rgb"})).unwrap(); + rgb.execute("actions.load", json!({"data":gray})).unwrap(); + assert_eq!(rgb.actions.list[0].steps[0], ("channel.target".into(), json!({"channel":"composite"}))); + assert!(rgb.execute("actions.play", json!({"action":"Pick"})).unwrap().get("failed").is_none()); +} From 53098879c03ed1c6e07e8dc2032c62465529e5df Mon Sep 17 00:00:00 2001 From: yoriswind Date: Sat, 31 Oct 2026 19:42:20 -0700 Subject: [PATCH 028/216] Correct ATN mapping table and current parity notes --- docs/atn.md | 32 ++++++++++++++++---------------- docs/file-format-parity.md | 3 ++- docs/gaps.md | 4 +++- 3 files changed, 21 insertions(+), 18 deletions(-) diff --git a/docs/atn.md b/docs/atn.md index 5230e707d9..9830b75fa7 100644 --- a/docs/atn.md +++ b/docs/atn.md @@ -27,19 +27,19 @@ does not map yet, with everything needed to implement them without the reference | (string ID only) | applyLocking | Apply Locking | `layer.lockLayers` | | (string ID only) | assignProfile | Assign Profile | `edit.assignProfile` | | (string ID only) | autoColor | Auto Color | `image.autoColor` | -| (string ID only) | BanW | blackAndWhite | `Black & White` | +| (string ID only) | blackAndWhite | Black & White | `image.adjustments.blackWhite` | | `Blr ` | blur | Blur | `filter.blur.blur` | | `BlrM` | blurMore | Blur More | `filter.blur.blurMore` | | `Brdr` | border | Border | `select.modify.border` | | (string ID only) | boxblur | Box Blur | `filter.blur.boxBlur` | -| `BrgC` | BrgC | brightnessEvent | `Brightness/Contrast` | +| `BrgC` | brightnessEvent | Brightness/Contrast | `image.adjustments.brightnessContrast` | | `CnvS` | canvasSize | Canvas Size | `image.canvasSize` | -| `ChnM` | ChnM | channelMixer | `Channel Mixer` | +| `ChnM` | channelMixer | Channel Mixer | `image.adjustments.channelMixer` | | (string ID only) | clearEvent | Clear | `select.editInQuickMaskMode`, `history.clear` | | `Cls ` | close | Close | `file.close` | | (string ID only) | collapseAllGroupsEvent | Collapse All Groups | `layer.setExpanded` | | `ClrR` | colorRange | Color Range | `select.colorRange` | -| `ClrB` | ClrB | colorBalance | `Color Balance` | +| `ClrB` | colorBalance | Color Balance | `image.adjustments.colorBalance` | | `Cntc` | contract | Contract | `select.modify.contract` | | `CnvM` | convertMode | Convert Mode | `image.mode.rgb`, `image.mode.grayscale`, `image.mode.cmyk`, `image.mode.lab`, `image.mode.multichannel`, `image.mode.bits8`, `image.mode.bits16`, `image.mode.bits32`, `image.mode.bitmap` | | (string ID only) | convertToProfile | Convert to Profile | `edit.convertToProfile` | @@ -50,7 +50,7 @@ does not map yet, with everything needed to implement them without the reference | `GrpL` | groupEvent | Create Clipping Mask | `layer.createClippingMask` | | `Crop` | crop | Crop | `image.crop` | | `Crst` | crystallize | Crystallize | `filter.pixelate.crystallize` | -| `Crvs` | Crvs | curves | `Curves` | +| `Crvs` | curves | Curves | `image.adjustments.curves` | | `cut ` | cut | Cut | `edit.cut` | | `Dfrg` | defringe | Defringe | `layer.matting.defringe` | | `Dlt ` | delete | Delete | `layer.delete`, `edit.clear`, `layer.layerMask.delete`, `layer.smartFilter.deleteFilterMask` | @@ -66,27 +66,27 @@ does not map yet, with everything needed to implement them without the reference | `Exch` | exchange | Exchange | `tools.swapColors` | | `Expn` | expand | Expand | `select.modify.expand` | | `Expr` | export | Export | `file.export.saveForWebLegacy` | -| `Exps` | Exps | exposure | `Exposure` | +| `Exps` | exposure | Exposure | `image.adjustments.exposure` | | `Fthr` | feather | Feather | `select.modify.feather` | | `Fl ` | fill | Fill | `edit.fill`, `paint.bucket` | | `GEfc` | filterGallery | Filter Gallery | `filter.gallery.spatter` | | `FltI` | flattenImage | Flatten Image | `layer.flattenImage` | | `Flip` | flip | Flip | `image.imageRotation.flipCanvasHorizontal`, `image.imageRotation.flipCanvasVertical`, `edit.transform.flipHorizontal`, `edit.transform.flipVertical` | | `GsnB` | gaussianBlur | Gaussian Blur | `filter.blur.gaussianBlur` | -| `GdMp` | GdMp | gradientMapEvent | `Gradient Map` | +| `GdMp` | gradientMapEvent | Gradient Map | `image.adjustments.gradientMap` | | `Grow` | grow | Grow | `select.grow` | | `Hd ` | hide | Hide | `layer.hideLayers` | | `HghP` | highPass | High Pass | `filter.other.highPass` | -| `HStr` | HStr | hueSaturation | `Hue/Saturation` | +| `HStr` | hueSaturation | Hue/Saturation | `image.adjustments.hueSaturation` | | (string ID only) | conditional | If | `actions.conditional` | | `ImgS` | imageSize | Image Size | `image.imageSize` | | `Invs` | inverse | Inverse | `select.inverse` | -| `Invr` | Invr | invert | `Invert` | +| `Invr` | invert | Invert | `image.adjustments.invert` | | `CpTL` | copyToLayer | Layer Via Copy | `layer.new.layerViaCopy` | | `CtTL` | cutToLayer | Layer Via Cut | `layer.new.layerViaCut` | | `Lvls` | levels | Levels | `image.adjustments.levels`, `image.autoTone`, `image.autoContrast` | | `Mk ` | make | Make | `layer.new.layer`, `layer.new.group`, `layer.groupLayers`, `layer.duplicate`, `layer.new.layerViaCopy`, `layer.new.layerViaCut`, `layer.newFillLayer.solidColor`, `layer.newFillLayer.pattern`, `layer.newAdjustmentLayer.levels`, `layer.newAdjustmentLayer.curves`, `layer.newAdjustmentLayer.hueSaturation`, `layer.newAdjustmentLayer.brightnessContrast`, `layer.newAdjustmentLayer.threshold`, `layer.newAdjustmentLayer.posterize`, `layer.newAdjustmentLayer.invert`, `layer.newAdjustmentLayer.colorBalance`, `layer.newAdjustmentLayer.vibrance`, `layer.newAdjustmentLayer.exposure`, `layer.newAdjustmentLayer.photoFilter`, `layer.newAdjustmentLayer.blackWhite`, `layer.newAdjustmentLayer.channelMixer`, `layer.newAdjustmentLayer.selectiveColor`, `layer.newAdjustmentLayer.gradientMap`, `layer.layerMask.revealAll`, `layer.layerMask.hideAll`, `layer.layerMask.revealSelection`, `layer.layerMask.hideSelection`, `file.new`, `file.newFromClipboard`, `history.newSnapshot` | -| (string ID only) | animationFramesFromLayers | Make Frames from Layers | | +| (string ID only) | animationFramesFromLayers | Make Frames from Layers | Kept step (disabled) | | `Mxm ` | maximum | Maximum | `filter.other.maximum` | | `Mdn ` | median | Median | `filter.noise.median` | | `Mrg2` | mergeLayersNew | Merge Layers | `layer.mergeLayers` | @@ -98,10 +98,10 @@ does not map yet, with everything needed to implement them without the reference | `Opn ` | open | Open | `file.openAs` | | `past` | paste | Paste | `edit.paste`, `edit.pasteSpecial.pasteInPlace` | | (string ID only) | pasteEffects | Paste Layer Style | `layer.layerStyle.pasteLayerStyle` | -| (string ID only) | PhtF | photoFilter | `Photo Filter` | +| (string ID only) | photoFilter | Photo Filter | `image.adjustments.photoFilter` | | `Plc ` | placeEvent | Place | `file.placeEmbedded`, `file.placeLinked` | | `Ply ` | play | Play | `actions.play` | -| `Pstr` | Pstr | posterize | `Posterize` | +| `Pstr` | posterize | Posterize | `image.adjustments.posterize` | | `Prge` | purge | Purge | `edit.purge.all`, `edit.purge.clipboard`, `edit.purge.histories`, `edit.purge.undo` | | `RdlB` | radialBlur | Radial Blur | `filter.blur.radialBlur` | | (string ID only) | rasterizeLayer | Rasterize | `layer.rasterize.layer` | @@ -117,7 +117,7 @@ does not map yet, with everything needed to implement them without the reference | (string ID only) | refineSelectionEdge | Select and Mask | `select.refineEdge` | | (string ID only) | smartBrushWorkspace | Select and Mask | `select.refineEdge` | | (string ID only) | autoCutout | Select Subject | `select.subject` | -| `SlcC` | SlcC | selectiveColor | `Selective Color` | +| `SlcC` | selectiveColor | Selective Color | `image.adjustments.selectiveColor` | | `setd` | set | Set | `select.all`, `select.deselect`, `select.reselect`, `select.loadSelection`, `select.rect`, `select.lasso`, `select.editInQuickMaskMode`, `tools.setColors`, `layer.setProps`, `layer.renameLayer`, `layer.setLabelColor`, `layer.setAdjustment`, `layer.fill.pattern.set`, `layer.new.layerFromBackground`, `layer.layerStyle.blendingOptions`, `layer.smartFilter.blendingOptions` | | (string ID only) | adaptCorrect | Shadows/Highlights | `image.adjustments.shadowsHighlights` | | `Shrp` | sharpen | Sharpen | `filter.sharpen.sharpen` | @@ -127,16 +127,16 @@ does not map yet, with everything needed to implement them without the reference | `Smlr` | similar | Similar | `select.similar` | | `SmrB` | smartBlur | Smart Blur | `filter.blur.smartBlur` | | `Smth` | smoothness | Smooth | `select.modify.smooth` | -| `Stop` | stop | Stop | `STOP_MESSAGE` | +| `Stop` | stop | Stop | `actions.stopMessage` | | `Strk` | stroke | Stroke | `edit.stroke` | | (string ID only) | surfaceBlur | Surface Blur | `filter.blur.surfaceBlur` | -| `Thrs` | Thrs | threshold | `Threshold` | +| `Thrs` | threshold | Threshold | `image.adjustments.threshold` | | `Trnf` | transform | Transform | `edit.transform`, `select.transformSelection` | | (string ID only) | trim | Trim | `image.trim` | | (string ID only) | ungroupLayersEvent | Ungroup Layers | `layer.ungroupLayers` | | `UnsM` | unsharpMask | Unsharp Mask | `filter.sharpen.unsharpMask` | | (string ID only) | updatePlacedLayer | Update Smart Objects | `layer.smartObjects.updateModifiedContent` | -| `Vbrn` | Vbrn | vibrance | `Vibrance` | +| `Vbrn` | vibrance | Vibrance | `image.adjustments.vibrance` | | `Extr` | extrude | Extrude | `filter.stylize.extrude` | | `FndE` | findEdges | Find Edges | `filter.stylize.findEdges` | | `Slrz` | solarize | Solarize | `filter.stylize.solarize` | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 1fc994a94e..471d975b65 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-11-01 · **Change:** minor (`.atn`: event reference [atn.md](atn.md) with the supported events and the measured keys, defaults, ranges and enum values of 101 unmapped events; TIFFs up to 300,000 px a side open, decoded a group of rows at a time into the layer, #8169; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; PSD Filter Gallery: Halftone Pattern dot/circle/line settings survive PSD/PSB saves (#8940); Save for Web writes WebP, lossy or lossless, #6391; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency, #8877; layered TIFF writes the TySh text version and descriptor in PSD big-endian order while the header, matrix, warp and bounds keep container order, #9098; layered TIFF writes Photoshop's 14-byte global layer mask, so Photoshop opens the layers instead of only the composite, #9095; legacy PSD point text placed from the layout ink box after font resolution, #6094; `.atn`: 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; `.asl` layer style libraries read and written, #1051; JPEG Options on File › Save, Save As and Save a Copy, FILE-215-19 partial (no Matte, progressive Scans or size preview); 32-bit Hue/Saturation unclipped and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; JPEG Options on Save As and Save a Copy, FILE-215-19; TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; Cineon write: 10-bit gray/RGB; animated WebP frames load into video layers, #1198; Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 53 filters (39 more: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag, Invert, Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding; Amiga IFF write: 24-plane ILBM, Save As `.iff`; PSD stores spot channels as Photoshop does, ink 0 = solid, and numbers them in 1053, #7415 split; TIFF writes and reads alpha and spot channels as extra samples with Photoshop's channel resources, PSD output unchanged, FILE-215-7, FILE-215-17's TIFF part, #7415; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-31 · **Change:** minor (`.atn`: 129 supported and 89 unmapped events after the twelve Stylize/Pixelate mappings (Group D, #10606); the supported table and measured reference are in [atn.md](atn.md); TIFFs up to 300,000 px a side open, decoded a group of rows at a time into the layer, #8169; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; PSD Filter Gallery: Halftone Pattern dot/circle/line settings survive PSD/PSB saves (#8940); Save for Web writes WebP, lossy or lossless, #6391; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency, #8877; layered TIFF writes the TySh text version and descriptor in PSD big-endian order while the header, matrix, warp and bounds keep container order, #9098; layered TIFF writes Photoshop's 14-byte global layer mask, so Photoshop opens the layers instead of only the composite, #9095; legacy PSD point text placed from the layout ink box after font resolution, #6094; `.atn`: 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; `.asl` layer style libraries read and written, #1051; JPEG Options on File › Save, Save As and Save a Copy, FILE-215-19 partial (no Matte, progressive Scans or size preview); 32-bit Hue/Saturation unclipped and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; JPEG Options on Save As and Save a Copy, FILE-215-19; TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; Cineon write: 10-bit gray/RGB; animated WebP frames load into video layers, #1198; Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 53 filters (39 more: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag, Invert, Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding; Amiga IFF write: 24-plane ILBM, Save As `.iff`; PSD stores spot channels as Photoshop does, ink 0 = solid, and numbers them in 1053, #7415 split; TIFF writes and reads alpha and spot channels as extra samples with Photoshop's channel resources, PSD output unchanged, FILE-215-7, FILE-215-17's TIFF part, #7415; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency) · **Target:** Adobe Photoshop 2026 **PSD Filter Gallery (#8940, 2026-10-23):** Halftone Pattern maps its size, contrast and dot/circle/line pattern as editable settings on import and export, with the reference application's descriptor keys (`HlSz`, `Cntr`, `ScrT`: `ScrD`/`ScrC`/`ScrL`). PSD/PSB round trips pass at 8/16/32 bits. This does not claim pixel equivalence for the existing renderer. Unknown or malformed settings remain verbatim on import. @@ -148,6 +148,7 @@ Embedded SVG and PDF-compatible AI/PDF/EPS payloads can use an explicit VectorCr | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | `.atn` Group D: twelve Stylize/Pixelate events load, play and save; 129 supported and 89 unmapped. Supported-table columns corrected against the event registry; the original 117/101 reference entry remains historical. | | 2026-11-01 | minor | `.atn`: event reference [atn.md](atn.md): the 117 supported events, and measured keys, defaults, ranges and enum values for 101 events not mapped yet | | 2026-10-26 | minor | Embedded AI/PDF/EPS save-back retains native format and a separate source-bound display preview; original sources remain undoable. Native Adobe interoperability and standalone EPS/DCS remain unverified; no corpus floor or percentage changed. | | 2026-10-22 | minor | Embedded PSD vectors gain explicit bounded SVG editor/save-back; standalone EPS/DCS and native-format fidelity remain open. | diff --git a/docs/gaps.md b/docs/gaps.md index 2537355b5b..cb9472f7c1 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (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; G9: 12 of 181 preferences do nothing, as the scorecard prefs audit counts them; G6: Art History Brush landed as partial, TOOL-213-20, #1610, so no toolbar tool is missing; G4: five hard-round built-in presets use 5% spacing to remove stacked-tip outlines and reduce pressure-opacity bands, #8842; G9: Crop tool canvas menu, #6561; G2: Content-Aware Scale seam carving 1.65–3.6× faster with unchanged output, P28 row 3.3 → 1.6 s on a 4-core VM, #5236; G9: UI scale presets done, 105–120% steps, #3222; G14: lossy DNG, raw sidecars, Camera Raw Defaults live, Calibration and Auto, CR3 assessed as not clean-room feasible; G8: PSD type on a path opens on its path and saves back into the file's `Txt2`, #7962; G11: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; adjustment clipping stays visible in compact Properties, #5231; G9: Color panel modes, ramp and copy commands, the same in every theme, UI-217-17, #6000; G17: Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; G10: `.asl` style libraries landed, #1051; G8: area type (TYPE-218-10); G1: smart objects keep Photoshop's Cylinder warp, #4112; G8: paragraph direction buttons in the Paragraph panel and options bar, TYPE-218-2 partial; G1: 32-bit Hue/Saturation and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; G11: compound layer properties export as separate Photoshop Set events, rename last, #8304; G11: Batch and droplets take the result at the action's Save step, so actions ending in Save As and Close work; G11: `.atn` steps that switch to another open document import, play and save back, #7103; G14: the Nikon D4 white level follows the sample depth, so 12-bit D4 NEFs no longer open 2.6 stops dark, #5978; G14: a Nikon NEF's Adobe RGB preview is read as Adobe RGB (maker-note ColorSpace 0x001E), #5987; Apply Layer Comp action mapping, #7101; G10: JPEG Options dialog landed, FILE-215-19 partial (no Matte, progressive Scans or size preview); G10: Cineon write; G10: DPX read and write, #1049, Cineon and Amiga IFF read; Classical Object Finder improvement; learned segmentation remains open; G4: named, stateful section locks and visible keyboard focus in Brush Settings; G4: independent Brush Pose tilt-axis overrides, #3825 partial; G4: Transfer Mixer-only controls, #3825 partial; G9: Preferences › File Handling › Save in Background takes effect, #6207, 24 of 169 preferences do nothing; G7: Content-Aware Move previews every selection the command takes, coarse then exact, TOOL-213-4 done, #6680; G4: Pattern Stamp and Background Eraser draw live, TOOL-213-7; G9: Animated Zoom pref read: holding the Zoom tool zooms continuously, #6210, prefs that do nothing 25 of 166; G2: a cancelled 24 MP Gaussian Blur job stops in at most 44 ms, P25 under its 200 ms budget in local runs, baseline not re-run, #6205; G1: generated large-file fixtures, a 4.38 GB PSB opens on Windows through io, FILE-216-2 done, #5107; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed; G10: Amiga IFF write; G10: PSD spot channels in Photoshop's polarity, earlier PhotoCraft PSDs keep their ink, #7415 split; G10: TIFF alpha and spot channels written and read as extra samples, FILE-215-7 done, FILE-215-17's TIFF part, #7415) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (G11: twelve Stylize/Pixelate ATN events mapped, 129 supported/89 unmapped; 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; G9: 12 of 181 preferences do nothing, as the scorecard prefs audit counts them; G6: Art History Brush landed as partial, TOOL-213-20, #1610, so no toolbar tool is missing; G4: five hard-round built-in presets use 5% spacing to remove stacked-tip outlines and reduce pressure-opacity bands, #8842; G9: Crop tool canvas menu, #6561; G2: Content-Aware Scale seam carving 1.65–3.6× faster with unchanged output, P28 row 3.3 → 1.6 s on a 4-core VM, #5236; G9: UI scale presets done, 105–120% steps, #3222; G14: lossy DNG, raw sidecars, Camera Raw Defaults live, Calibration and Auto, CR3 assessed as not clean-room feasible; G8: PSD type on a path opens on its path and saves back into the file's `Txt2`, #7962; G11: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; adjustment clipping stays visible in compact Properties, #5231; G9: Color panel modes, ramp and copy commands, the same in every theme, UI-217-17, #6000; G17: Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; G10: `.asl` style libraries landed, #1051; G8: area type (TYPE-218-10); G1: smart objects keep Photoshop's Cylinder warp, #4112; G8: paragraph direction buttons in the Paragraph panel and options bar, TYPE-218-2 partial; G1: 32-bit Hue/Saturation and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; G11: compound layer properties export as separate Photoshop Set events, rename last, #8304; G11: Batch and droplets take the result at the action's Save step, so actions ending in Save As and Close work; G11: `.atn` steps that switch to another open document import, play and save back, #7103; G14: the Nikon D4 white level follows the sample depth, so 12-bit D4 NEFs no longer open 2.6 stops dark, #5978; G14: a Nikon NEF's Adobe RGB preview is read as Adobe RGB (maker-note ColorSpace 0x001E), #5987; Apply Layer Comp action mapping, #7101; G10: JPEG Options dialog landed, FILE-215-19 partial (no Matte, progressive Scans or size preview); G10: Cineon write; G10: DPX read and write, #1049, Cineon and Amiga IFF read; Classical Object Finder improvement; learned segmentation remains open; G4: named, stateful section locks and visible keyboard focus in Brush Settings; G4: independent Brush Pose tilt-axis overrides, #3825 partial; G4: Transfer Mixer-only controls, #3825 partial; G9: Preferences › File Handling › Save in Background takes effect, #6207, 24 of 169 preferences do nothing; G7: Content-Aware Move previews every selection the command takes, coarse then exact, TOOL-213-4 done, #6680; G4: Pattern Stamp and Background Eraser draw live, TOOL-213-7; G9: Animated Zoom pref read: holding the Zoom tool zooms continuously, #6210, prefs that do nothing 25 of 166; G2: a cancelled 24 MP Gaussian Blur job stops in at most 44 ms, P25 under its 200 ms budget in local runs, baseline not re-run, #6205; G1: generated large-file fixtures, a 4.38 GB PSB opens on Windows through io, FILE-216-2 done, #5107; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed; G10: Amiga IFF write; G10: PSD spot channels in Photoshop's polarity, earlier PhotoCraft PSDs keep their ink, #7415 split; G10: TIFF alpha and spot channels written and read as extra samples, FILE-215-7 done, FILE-215-17's TIFF part, #7415) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -111,6 +111,7 @@ what an agent can't finish alone. - **Estimate:** 120–200 h. ### G11. Actions and scripting +- **Done 2026-10-31 (#10606):** all twelve Stylize/Pixelate ATN events (Group D) load, play and save with measured defaults, documented clamping and strict export checks. The [event reference](atn.md) now lists 129 supported and 89 unmapped events. Other Actions/scripting gaps remain; no overall readiness percentage or estimate changed. - **2026-10-21 evidence (#8304):** Save Actions expands compound layer-property edits into separate Photoshop Set events, with rename last so named layer references remain valid. Exported descriptors and native playback at RGB 8/16/32-bit have [regression coverage](../crates/engine/src/atn_cmds/layer_props_tests.rs); @@ -424,6 +425,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | G11: twelve Stylize/Pixelate ATN events mapped; current counts 129 supported/89 unmapped. Other Actions gaps, readiness percentages and estimates unchanged. | | 2026-10-27 | minor | Smooth/Feather command reads and replacement use selection bounds plus blur reach; full-canvas reference and release comparisons extended; percentages and scorecard budgets unchanged | | 2026-10-27 | minor | G9: 12 of 181 preferences do nothing, the scorecard's count with integration batch 9 (#9775) on main; no estimate changed | | 2026-10-26 | minor | Expand/Contract/Border distance work, mask reads and replacement cropped to nonzero bounds; bounded distances for radii ≤5; 36 MP exact-output comparisons improve 1.54–37.82× across eight cases; percentages, estimates and scorecard budgets unchanged | From 1d22ffc1ff46ad0b88e00b1297dd15b309f708a0 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 31 Oct 2026 20:51:05 -0600 Subject: [PATCH 029/216] Actions: map Photoshop's newer-feature steps (#10610) Oil Paint (oilPaint), Smart Sharpen (smartSharpen), Focus Area (focusMask), Select Sky (selectSky) and Remove Background (removeBackground), recorded by string ID, load as their commands and save back with the keys and defaults Photoshop records. Oil Paint's and Select Sky's Photoshop defaults are written out where they differ from the commands'. Smart Sharpen maps the legacy Lens Blur form with no noise reduction; other forms stay Photoshop steps. Settings the events cannot carry refuse the step on export. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/atn_cmds/events.rs | 18 ++- crates/engine/src/cmd/atn_cmds/steps_image.rs | 142 ++++++++++++++++++ crates/engine/src/cmd/atn_cmds/tests.rs | 103 +++++++++++++ docs/file-format-parity.md | 3 +- 4 files changed, 263 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/atn_cmds/events.rs b/crates/engine/src/cmd/atn_cmds/events.rs index 5723c93c59..910cbc1464 100644 --- a/crates/engine/src/cmd/atn_cmds/events.rs +++ b/crates/engine/src/cmd/atn_cmds/events.rs @@ -8,8 +8,9 @@ use super::steps_doc::{ place_from, place_to, profile_from, profile_to, purge_from, purge_to, reset_from, reset_to, save_from, save_to, stop_from, stop_to, }; use super::steps_image::{ - GALLERY_IDS, REFINE_EDGE, RIPPLE_SIZE, SMART_BRUSH, apply_image_from, apply_image_to, color_range_from, color_range_to, gallery_from, gallery_to, - has_refine_strokes, radial_blur_from, radial_blur_to, refine_edge_to, transform_from, transform_to, + GALLERY_IDS, REFINE_EDGE, RIPPLE_SIZE, SMART_BRUSH, apply_image_from, apply_image_to, bare_from, bare_to, color_range_from, color_range_to, gallery_from, + gallery_to, has_refine_strokes, oil_paint_from, oil_paint_to, radial_blur_from, radial_blur_to, refine_edge_to, select_sky_from, select_sky_to, + smart_sharpen_from, smart_sharpen_to, transform_from, transform_to, }; use super::steps_layer::{ STROKE, apply_comp_from, apply_comp_to, clear_from, clear_to, delete_from, delete_to, duplicate_from, duplicate_to, fill_from, fill_to, flip_from, flip_to, @@ -251,6 +252,19 @@ pub(super) const EVENTS: &[Event] = &[ ev!("Crst", "crystallize", "Crystallize", "filter.pixelate.crystallize", &[F("ClSz", "cellSize", Kind::ExactLong)]), ev!("Dfs ", "diffuse", "Diffuse", "filter.stylize.diffuse", &[F("Md ", "mode", Kind::Enum("DfsM", DIFFUSE_MODE)), F("FlRs", "seed", Kind::Long)]), Event { code: "GEfc", name: "filterGallery", label: "Filter Gallery", ids: GALLERY_IDS, from: gallery_from, to: gallery_to }, + // Newer features (#10610): Photoshop records these by string ID only. + Event { code: "", name: "oilPaint", label: "Oil Paint", ids: &["filter.stylize.oilPaint"], from: oil_paint_from, to: oil_paint_to }, + Event { code: "", name: "smartSharpen", label: "Smart Sharpen", ids: &["filter.sharpen.smartSharpen"], from: smart_sharpen_from, to: smart_sharpen_to }, + Event { code: "", name: "focusMask", label: "Focus Area", ids: &["select.focusArea"], from: |d| bare_from(d, "select.focusArea"), to: bare_to }, + Event { code: "", name: "selectSky", label: "Sky", ids: &["select.sky"], from: select_sky_from, to: select_sky_to }, + Event { + code: "", + name: "removeBackground", + label: "Remove Background", + ids: &["layer.removeBackground"], + from: |d| bare_from(d, "layer.removeBackground"), + to: bare_to, + }, // Adjustments (Image › Adjustments). Event { code: "", diff --git a/crates/engine/src/cmd/atn_cmds/steps_image.rs b/crates/engine/src/cmd/atn_cmds/steps_image.rs index a52b9fbf5d..2a318f2fb6 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_image.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_image.rs @@ -453,3 +453,145 @@ pub(super) fn radial_blur_to(_: &str, p: &Value) -> Option { } fields_to(p, RADIAL_BLUR_FIELDS, desc("null")) } + +// ------------------------------------------------------------------ newer features (#10610) + +/// Whether every key of `d` is one of `keys` (codes compare without their space padding). +fn only_keys(d: &Descriptor, keys: &[&str]) -> bool { + d.items.iter().all(|(k, _)| keys.contains(&trimmed(k).as_str())) +} + +/// Whether `p` sets nothing but `allowed` (a `null` counts as unset). +fn sets_only(p: &Value, allowed: &[&str]) -> bool { + p.as_object().is_none_or(|m| m.iter().all(|(k, v)| v.is_null() || allowed.contains(&k.as_str()))) +} + +/// Focus Area (`focusMask`) and Remove Background (`removeBackground`): Photoshop runs both with +/// an empty descriptor. A step that carries settings keeps them as a Photoshop step. +pub(super) fn bare_from(d: &Descriptor, id: &str) -> Mapped { + if d.items.is_empty() { step(id, json!({})) } else { Mapped::Unsupported { harmless: false } } +} + +/// Only a step with no settings writes the empty descriptor; any setting would be lost. +pub(super) fn bare_to(_: &str, p: &Value) -> Option { + sets_only(p, &[]).then(|| desc("null")) +} + +/// Select Sky (`selectSky`): `sampleAllLayers` only, false when the step leaves it out +/// (the command's own default is true). +pub(super) fn select_sky_from(d: &Descriptor) -> Mapped { + if !only_keys(d, &["sampleAllLayers"]) { + return Mapped::Unsupported { harmless: false }; + } + step("select.sky", json!({"sampleAllLayers": get_bool(d, "sampleAllLayers").unwrap_or(false)})) +} + +/// The event has no mode, threshold or softness: only their replay values survive. +pub(super) fn select_sky_to(_: &str, p: &Value) -> Option { + let at = |k: &str, v: Value| p.get(k).is_none_or(|x| x.is_null() || *x == v || (x.as_f64().is_some() && x.as_f64() == v.as_f64())); + if !sets_only(p, &["sampleAllLayers", "mode", "threshold", "softness"]) + || !at("mode", json!("replace")) + || !at("threshold", json!(50)) + || !at("softness", json!(50)) + { + return None; + } + let all = match p.get("sampleAllLayers") { + None | Some(Value::Null) => true, + Some(v) => v.as_bool()?, + }; + Some(desc("null").with("sampleAllLayers", D::Boolean(all))) +} + +/// Oil Paint's keys in recorded order: (key, param, Photoshop's default, the command's default). +const OIL_PAINT: &[(&str, &str, f64, f64)] = &[ + ("stylization", "stylization", 4.0, 4.0), + ("cleanliness", "cleanliness", 2.299_999_952_316_28, 5.0), + ("brushScale", "scale", 0.800_000_011_920_93, 1.0), + ("microBrush", "bristleDetail", 10.0, 5.0), +]; + +/// Oil Paint (`oilPaint`). Missing keys take Photoshop's defaults, which differ from the +/// command's for cleanliness, scale, bristle detail and shine, so they are written out. +pub(super) fn oil_paint_from(d: &Descriptor) -> Mapped { + let keys = ["lightingOn", "stylization", "cleanliness", "brushScale", "microBrush", "LghD", "specularity"]; + if !only_keys(d, &keys) { + return Mapped::Unsupported { harmless: false }; + } + let mut p = Map::new(); + p.insert("lighting".into(), get_bool(d, "lightingOn").unwrap_or(true).into()); + for (key, param, ps, _) in OIL_PAINT { + p.insert((*param).into(), get_num(d, key).unwrap_or(*ps).into()); + } + p.insert("angle".into(), get_num(d, "LghD").unwrap_or(-60.0).into()); + p.insert("shine".into(), get_num(d, "specularity").unwrap_or(1.299_999_952_316_28).into()); + step("filter.stylize.oilPaint", Value::Object(p)) +} + +/// Every key in recorded order, the command's defaults for what the step leaves out. The light +/// direction is an integer: a fractional angle would replay rounded, so the step is refused. +pub(super) fn oil_paint_to(_: &str, p: &Value) -> Option { + if !pixel_target(p) || !sets_only(p, &["target", "lighting", "stylization", "cleanliness", "scale", "bristleDetail", "angle", "shine"]) { + return None; + } + let num = |k: &str, default: f64| match p.get(k) { + None | Some(Value::Null) => Some(default), + Some(v) => v.as_f64().filter(|n| n.is_finite()), + }; + let lighting = match p.get("lighting") { + None | Some(Value::Null) => true, + Some(v) => v.as_bool()?, + }; + let angle = num("angle", -60.0)?; + if angle.fract() != 0.0 || !(f64::from(i32::MIN)..=f64::from(i32::MAX)).contains(&angle) { + return None; + } + let mut d = desc("null").with("lightingOn", D::Boolean(lighting)); + for (key, param, _, ours) in OIL_PAINT { + d = d.with(key, D::Double(num(param, *ours)?)); + } + Some(d.with("LghD", D::Integer(angle as i32)).with("specularity", D::Double(num("shine", 1.0)?))) +} + +/// Smart Sharpen (`smartSharpen`), as Photoshop records it without its dialog: the legacy +/// filter, removing Lens Blur, with no noise reduction (so `reduceNoise` is 0). A step that +/// asks for what the command does not do (another blur, More Accurate, the newer filter, a +/// preset) keeps it as a Photoshop step. +pub(super) fn smart_sharpen_from(d: &Descriptor) -> Mapped { + let keys = ["presetKind", "useLegacy", "Amnt", "Rds", "moreAccurate", "blur"]; + let as_recorded = only_keys(d, &keys) + && get_enum(d, "presetKind").is_none_or(|k| k == "presetKindCustom") + && get_bool(d, "useLegacy") != Some(false) + && get_bool(d, "moreAccurate") != Some(true) + && get_enum(d, "blur").is_none_or(|b| b == "lensBlur"); + if !as_recorded { + return Mapped::Unsupported { harmless: false }; + } + let mut p = Map::new(); + for (key, param, default) in [("Amnt", "amount", 200.0), ("Rds ", "radius", 1.0)] { + p.insert(param.into(), get_num(d, key).unwrap_or(default).into()); + } + p.insert("reduceNoise".into(), 0.into()); + step("filter.sharpen.smartSharpen", Value::Object(p)) +} + +/// Every key in recorded order. The legacy filter has no noise reduction, so only a step that +/// sets `reduceNoise` to 0 is written (the command reduces noise by 10 when it is left out). +pub(super) fn smart_sharpen_to(_: &str, p: &Value) -> Option { + if !pixel_target(p) || !sets_only(p, &["target", "amount", "radius", "reduceNoise"]) || p.get("reduceNoise").and_then(Value::as_f64) != Some(0.0) { + return None; + } + let num = |k: &str, default: f64| match p.get(k) { + None | Some(Value::Null) => Some(default), + Some(v) => v.as_f64().filter(|n| n.is_finite()), + }; + Some( + desc("null") + .with("presetKind", enm("presetKindType", "presetKindCustom")) + .with("useLegacy", D::Boolean(true)) + .with("Amnt", pct(num("amount", 100.0)?)) + .with("Rds ", px(num("radius", 1.0)?)) + .with("moreAccurate", D::Boolean(false)) + .with("blur", enm("blurType", "lensBlur")), + ) +} diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index f9e633244c..bb277c9f00 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -485,6 +485,11 @@ fn every_mapped_command_exports_and_imports_back() { ("filter.blur.smartBlur", json!({"radius": 2.5, "threshold": 40, "quality": "medium", "mode": "overlayEdge"})), ("filter.other.offset", json!({"horizontal": 10, "vertical": -5, "undefinedAreas": "wrap"})), ("filter.blur.blur", json!({})), + ("filter.stylize.oilPaint", json!({"lighting": false, "stylization": 6.5, "cleanliness": 3, "scale": 2, "bristleDetail": 7, "angle": 45, "shine": 2})), + ("filter.sharpen.smartSharpen", json!({"amount": 150, "radius": 2.5, "reduceNoise": 0})), + ("select.focusArea", json!({})), + ("select.sky", json!({"sampleAllLayers": false})), + ("layer.removeBackground", json!({})), ( "image.adjustments.levels", json!({"inBlack": 5, "inWhite": 250, "gamma": 0.9, "outBlack": 0, "outWhite": 255, "red": {"inBlack": 2, "inWhite": 255, "gamma": 1, "outBlack": 0, "outWhite": 255}}), @@ -3175,3 +3180,101 @@ fn long_names_are_clipped_instead_of_copied_into_every_diagnostic() { assert_eq!(imp.unsupported.len(), 50); assert!(imp.unsupported.iter().all(|u| u.action.chars().count() == MAX_NAME_CHARS)); } + +/// A Photoshop step of one of the newer features, as its event reference records it (#10610). +fn newer(name: &str, d: Descriptor) -> Mapped { + (events::event_for(&AtnEvent::name(name)).unwrap_or_else(|| panic!("{name} is unknown")).from)(&d) +} + +fn mapped(m: Mapped) -> (String, Value) { + match m { + Mapped::Steps(mut s) if s.len() == 1 => s.remove(0), + _ => panic!("not one step"), + } +} + +/// Oil Paint, Smart Sharpen, Focus Area, Select Sky and Remove Background load with the keys and +/// defaults Photoshop records; Oil Paint's and Select Sky's defaults differ from the commands' +/// and are written out (#10610). +#[test] +fn newer_feature_steps_load_with_photoshops_defaults() { + let oil = desc("null") + .with("lightingOn", D::Boolean(true)) + .with("stylization", D::Double(4.0)) + .with("cleanliness", D::Double(2.299_999_952_316_28)) + .with("brushScale", D::Double(0.800_000_011_920_93)) + .with("microBrush", D::Double(10.0)) + .with("LghD", D::Integer(-60)) + .with("specularity", D::Double(1.299_999_952_316_28)); + let expected_oil = json!({"lighting": true, "stylization": 4.0, "cleanliness": 2.299_999_952_316_28, "scale": 0.800_000_011_920_93, + "bristleDetail": 10.0, "angle": -60.0, "shine": 1.299_999_952_316_28}); + assert_eq!(mapped(newer("oilPaint", oil.clone())), ("filter.stylize.oilPaint".into(), expected_oil.clone())); + assert_eq!(mapped(newer("oilPaint", desc("null"))).1, expected_oil, "a missing key takes Photoshop's default"); + let sharpen = desc("null") + .with("presetKind", enm("presetKindType", "presetKindCustom")) + .with("useLegacy", D::Boolean(true)) + .with("Amnt", pct(200.0)) + .with("Rds ", px(1.0)) + .with("moreAccurate", D::Boolean(false)) + .with("blur", enm("blurType", "lensBlur")); + assert_eq!( + mapped(newer("smartSharpen", sharpen.clone())), + ("filter.sharpen.smartSharpen".into(), json!({"amount": 200.0, "radius": 1.0, "reduceNoise": 0})) + ); + assert_eq!(mapped(newer("selectSky", desc("null"))), ("select.sky".into(), json!({"sampleAllLayers": false}))); + assert_eq!(mapped(newer("focusMask", desc("null"))), ("select.focusArea".into(), json!({}))); + assert_eq!(mapped(newer("removeBackground", desc("null"))), ("layer.removeBackground".into(), json!({}))); + + // Written back, they carry the same keys in the same order. + let back = |id: &str, p: Value| export_item(id, &p, "S").unwrap_or_else(|| panic!("{id} does not export")).descriptor.unwrap(); + assert_eq!(back("filter.stylize.oilPaint", expected_oil).items, oil.items); + assert_eq!(back("filter.sharpen.smartSharpen", json!({"amount": 200, "radius": 1, "reduceNoise": 0})).items, sharpen.items); + + // Oil Paint and Smart Sharpen play. + let mut s = Session::new(); + s.execute("file.new", json!({"width": 24, "height": 24})).unwrap(); + s.execute("filter.render.clouds", json!({})).unwrap(); + let action = Action::new( + "Paint", + vec![ + ("filter.stylize.oilPaint".into(), mapped(newer("oilPaint", desc("null"))).1), + ("filter.sharpen.smartSharpen".into(), mapped(newer("smartSharpen", desc("null"))).1), + ], + ); + s.actions.list.push(action); + let played = s.execute("actions.play", json!({"action": "Paint"})).unwrap(); + assert_eq!(played["ran"], 2, "{played}"); +} + +/// What the commands cannot do keeps the Photoshop step instead of playing something else +/// (#10610). +#[test] +fn newer_feature_steps_the_commands_cannot_play_are_kept() { + let kept = |m: Mapped| matches!(m, Mapped::Unsupported { harmless: false }); + assert!(kept(newer("smartSharpen", desc("null").with("blur", enm("blurType", "motionBlur"))))); + assert!(kept(newer("smartSharpen", desc("null").with("moreAccurate", D::Boolean(true))))); + assert!(kept(newer("smartSharpen", desc("null").with("useLegacy", D::Boolean(false))))); + assert!(kept(newer("smartSharpen", desc("null").with("presetKind", enm("presetKindType", "presetKindDefault"))))); + assert!(kept(newer("focusMask", desc("null").with("Rng ", D::Double(3.0))))); + assert!(kept(newer("removeBackground", desc("null").with("Lyr ", D::Integer(1))))); + assert!(kept(newer("selectSky", desc("null").with("smartRadius", D::Boolean(true))))); + assert!(kept(newer("oilPaint", desc("null").with("Angl", D::Integer(3))))); +} + +/// Settings the events have no key for refuse the step rather than replay differently (#10610). +#[test] +fn newer_feature_steps_that_cannot_be_written_are_refused() { + for (id, params) in [ + ("filter.sharpen.smartSharpen", json!({"amount": 150})), + ("filter.sharpen.smartSharpen", json!({"amount": 150, "reduceNoise": 10})), + ("filter.stylize.oilPaint", json!({"angle": 10.5})), + ("filter.stylize.oilPaint", json!({"target": "mask"})), + ("select.focusArea", json!({"range": 0.3})), + ("select.sky", json!({"mode": "add"})), + ("select.sky", json!({"threshold": 70})), + ("layer.removeBackground", json!({"layer": 3})), + ] { + assert!(export_item(id, ¶ms, "S").is_none(), "{id} {params} exported"); + } + assert!(export_item("select.sky", &json!({"mode": "replace", "threshold": 50, "softness": 50}), "S").is_some()); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 5ef09a2e7e..093f8ee93f 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-26 · **Change:** minor (TIFFs up to 300,000 px a side open, decoded a group of rows at a time into the layer, #8169; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; PSD Filter Gallery: Halftone Pattern dot/circle/line settings survive PSD/PSB saves (#8940); Save for Web writes WebP, lossy or lossless, #6391; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency, #8877; layered TIFF writes the TySh text version and descriptor in PSD big-endian order while the header, matrix, warp and bounds keep container order, #9098; layered TIFF writes Photoshop's 14-byte global layer mask, so Photoshop opens the layers instead of only the composite, #9095; legacy PSD point text placed from the layout ink box after font resolution, #6094; `.atn`: 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; `.asl` layer style libraries read and written, #1051; JPEG Options on File › Save, Save As and Save a Copy, FILE-215-19 partial (no Matte, progressive Scans or size preview); 32-bit Hue/Saturation unclipped and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; JPEG Options on Save As and Save a Copy, FILE-215-19; TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; Cineon write: 10-bit gray/RGB; animated WebP frames load into video layers, #1198; Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 53 filters (39 more: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag, Invert, Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding; Amiga IFF write: 24-plane ILBM, Save As `.iff`; PSD stores spot channels as Photoshop does, ink 0 = solid, and numbers them in 1053, #7415 split; TIFF writes and reads alpha and spot channels as extra samples with Photoshop's channel resources, PSD output unchanged, FILE-215-7, FILE-215-17's TIFF part, #7415; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-31 · **Change:** minor (`.atn`: Oil Paint, Smart Sharpen, Focus Area, Select Sky and Remove Background steps load, play and save back by string ID, #10610; TIFFs up to 300,000 px a side open, decoded a group of rows at a time into the layer, #8169; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; PSD Filter Gallery: Halftone Pattern dot/circle/line settings survive PSD/PSB saves (#8940); Save for Web writes WebP, lossy or lossless, #6391; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency, #8877; layered TIFF writes the TySh text version and descriptor in PSD big-endian order while the header, matrix, warp and bounds keep container order, #9098; layered TIFF writes Photoshop's 14-byte global layer mask, so Photoshop opens the layers instead of only the composite, #9095; legacy PSD point text placed from the layout ink box after font resolution, #6094; `.atn`: 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; `.asl` layer style libraries read and written, #1051; JPEG Options on File › Save, Save As and Save a Copy, FILE-215-19 partial (no Matte, progressive Scans or size preview); 32-bit Hue/Saturation unclipped and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; JPEG Options on Save As and Save a Copy, FILE-215-19; TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; Cineon write: 10-bit gray/RGB; animated WebP frames load into video layers, #1198; Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 53 filters (39 more: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag, Invert, Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding; Amiga IFF write: 24-plane ILBM, Save As `.iff`; PSD stores spot channels as Photoshop does, ink 0 = solid, and numbers them in 1053, #7415 split; TIFF writes and reads alpha and spot channels as extra samples with Photoshop's channel resources, PSD output unchanged, FILE-215-7, FILE-215-17's TIFF part, #7415; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency) · **Target:** Adobe Photoshop 2026 **PSD Filter Gallery (#8940, 2026-10-23):** Halftone Pattern maps its size, contrast and dot/circle/line pattern as editable settings on import and export, with the reference application's descriptor keys (`HlSz`, `Cntr`, `ScrT`: `ScrD`/`ScrC`/`ScrL`). PSD/PSB round trips pass at 8/16/32 bits. This does not claim pixel equivalence for the existing renderer. Unknown or malformed settings remain verbatim on import. @@ -148,6 +148,7 @@ Embedded SVG and PDF-compatible AI/PDF/EPS payloads can use an explicit VectorCr | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | `.atn`: Oil Paint (`oilPaint`), Smart Sharpen (`smartSharpen`), Focus Area (`focusMask`), Select Sky (`selectSky`) and Remove Background (`removeBackground`) map to their commands with the keys and defaults Photoshop records (Oil Paint's and Select Sky's defaults written out where they differ from the commands'); Smart Sharpen maps the legacy Lens Blur form and keeps the others as Photoshop steps (#10610) | | 2026-10-26 | minor | Embedded AI/PDF/EPS save-back retains native format and a separate source-bound display preview; original sources remain undoable. Native Adobe interoperability and standalone EPS/DCS remain unverified; no corpus floor or percentage changed. | | 2026-10-22 | minor | Embedded PSD vectors gain explicit bounded SVG editor/save-back; standalone EPS/DCS and native-format fidelity remain open. | | 2026-10-22 | minor | TIFF: flat pages up to 300,000 px a side open (was 2^30 px, about 32,768²): `codecs::TiffBands` decodes a group of rows at a time into the layer's tiles. The 117,504 × 22,464 px banner from #8456 opens in 3.3 s at 11.45 GB peak for a 10.56 GB layer; 30,000² 6.8 s / 6.43 GB → 1.1 s / 3.86 GB; 36 MP 220 → 52 ms. Layered TIFFs still decode whole (#8169) | From 34ba5d0122405a8e7055be7c42d94a80eb6aad82 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Sat, 31 Oct 2026 19:56:58 -0700 Subject: [PATCH 030/216] Preserve duplicate Render descriptor keys and correct ATN docs --- .../engine/src/cmd/atn_cmds/render_tests.rs | 37 +++++++++++++++++++ .../engine/src/cmd/atn_cmds/steps_render.rs | 3 +- docs/atn.md | 32 ++++++++-------- docs/file-format-parity.md | 3 +- docs/gaps.md | 4 +- 5 files changed, 60 insertions(+), 19 deletions(-) diff --git a/crates/engine/src/cmd/atn_cmds/render_tests.rs b/crates/engine/src/cmd/atn_cmds/render_tests.rs index f187618bb6..e040245c4e 100644 --- a/crates/engine/src/cmd/atn_cmds/render_tests.rs +++ b/crates/engine/src/cmd/atn_cmds/render_tests.rs @@ -142,6 +142,43 @@ fn unsupported_render_descriptors_keep_their_bytes_and_flags() { assert!(export_item("filter.render.lensFlare", &json!({"lens": "unknown"}), "S").is_none()); } +/// Duplicate keys must remain a kept step, rather than losing later values on save. +#[test] +fn duplicate_render_keys_keep_their_bytes_and_flags() { + let mut cases = Vec::new(); + for key in ["Vrnc", "Strg", "RndS"] { + cases.push(("Fbrs", key, fibers(16, 4, 123).with(key, D::Integer(99)))); + } + for key in ["Brgh", "FlrC", "Lns "] { + let descriptor = flare(100, [0.4; 2], "Zm"); + let duplicate = descriptor.get(key).unwrap().clone(); + cases.push(("LnsF", key, descriptor.with(key, duplicate))); + } + for key in ["Hrzn", "Vrtc"] { + let point = desc("Pnt ").with("Hrzn", D::Double(0.4)).with("Vrtc", D::Double(0.4)).with(key, D::Double(0.8)); + cases.push(("LnsF", key, desc("null").with("FlrC", D::Descriptor(point)))); + } + for (event, key, descriptor) in cases { + let mut original = atn::parse(&file(AtnEvent::Code(event.as_bytes().try_into().unwrap()), descriptor)).unwrap(); + original.actions[0].items[0].enabled = false; + original.actions[0].items[0].with_dialog = true; + original.actions[0].items[0].dialog_options = 2; + let bytes = atn::write(&original).unwrap(); + let mut s = Session::new(); + let loaded = s.execute("actions.load", json!({"data": photocraft_paint::tile::b64_encode(&bytes)})).unwrap(); + assert_eq!(loaded["unsupported"].as_array().unwrap().len(), 1, "{event}/{key}"); + assert_eq!(s.actions.list[0].steps[0].0, PHOTOSHOP_STEP); + let saved = s.execute("actions.save", json!({"action": "Render"})).unwrap(); + assert_eq!(saved["skipped"], json!([])); + let back = atn::parse(&photocraft_paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap()).unwrap(); + assert_eq!(back.actions[0].items, original.actions[0].items, "{event}/{key}"); + // Keep the original set/action metadata to compare the encoded step bytes. + let mut compared = original.clone(); + compared.actions[0].items = back.actions[0].items.clone(); + assert_eq!(atn::write(&compared).unwrap(), bytes, "{event}/{key}"); + } +} + /// Imported playback uses current tool colors and the same filter path as direct commands. #[test] fn render_actions_match_direct_filters_and_undo() { diff --git a/crates/engine/src/cmd/atn_cmds/steps_render.rs b/crates/engine/src/cmd/atn_cmds/steps_render.rs index 1932543ae2..567e6ccb6d 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_render.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_render.rs @@ -10,9 +10,10 @@ use super::{D, Descriptor, Value, json}; const CENTER: f64 = 0.40000000596046; const LENSES: &[(&str, &str)] = &[("Zm", "zoom"), ("Nkn", "prime35"), ("Nkn1", "prime105"), ("PnVs", "moviePrime")]; -/// Only measured descriptor keys are translated; other settings remain a kept step. +/// Translate only unique measured keys so later values cannot be lost on save. fn keys(d: &Descriptor, allowed: &[&str]) -> bool { d.items.iter().all(|(key, _)| allowed.iter().any(|name| key.is(name))) + && allowed.iter().all(|name| d.items.iter().filter(|(key, _)| key.is(name)).count() <= 1) } /// Read a recorded integer without accepting another descriptor type as its default. diff --git a/docs/atn.md b/docs/atn.md index 14612a4428..90f7cd549f 100644 --- a/docs/atn.md +++ b/docs/atn.md @@ -27,19 +27,19 @@ does not map yet, with everything needed to implement them without the reference | (string ID only) | applyLocking | Apply Locking | `layer.lockLayers` | | (string ID only) | assignProfile | Assign Profile | `edit.assignProfile` | | (string ID only) | autoColor | Auto Color | `image.autoColor` | -| (string ID only) | BanW | blackAndWhite | `Black & White` | +| (string ID only) | blackAndWhite | Black & White | `image.adjustments.blackWhite` | | `Blr ` | blur | Blur | `filter.blur.blur` | | `BlrM` | blurMore | Blur More | `filter.blur.blurMore` | | `Brdr` | border | Border | `select.modify.border` | | (string ID only) | boxblur | Box Blur | `filter.blur.boxBlur` | -| `BrgC` | BrgC | brightnessEvent | `Brightness/Contrast` | +| `BrgC` | brightnessEvent | Brightness/Contrast | `image.adjustments.brightnessContrast` | | `CnvS` | canvasSize | Canvas Size | `image.canvasSize` | -| `ChnM` | ChnM | channelMixer | `Channel Mixer` | +| `ChnM` | channelMixer | Channel Mixer | `image.adjustments.channelMixer` | | (string ID only) | clearEvent | Clear | `select.editInQuickMaskMode`, `history.clear` | | `Cls ` | close | Close | `file.close` | | (string ID only) | collapseAllGroupsEvent | Collapse All Groups | `layer.setExpanded` | | `ClrR` | colorRange | Color Range | `select.colorRange` | -| `ClrB` | ClrB | colorBalance | `Color Balance` | +| `ClrB` | colorBalance | Color Balance | `image.adjustments.colorBalance` | | `Cntc` | contract | Contract | `select.modify.contract` | | `CnvM` | convertMode | Convert Mode | `image.mode.rgb`, `image.mode.grayscale`, `image.mode.cmyk`, `image.mode.lab`, `image.mode.multichannel`, `image.mode.bits8`, `image.mode.bits16`, `image.mode.bits32`, `image.mode.bitmap` | | (string ID only) | convertToProfile | Convert to Profile | `edit.convertToProfile` | @@ -50,7 +50,7 @@ does not map yet, with everything needed to implement them without the reference | `GrpL` | groupEvent | Create Clipping Mask | `layer.createClippingMask` | | `Crop` | crop | Crop | `image.crop` | | `Crst` | crystallize | Crystallize | `filter.pixelate.crystallize` | -| `Crvs` | Crvs | curves | `Curves` | +| `Crvs` | curves | Curves | `image.adjustments.curves` | | `cut ` | cut | Cut | `edit.cut` | | `Dfrg` | defringe | Defringe | `layer.matting.defringe` | | `Dlt ` | delete | Delete | `layer.delete`, `edit.clear`, `layer.layerMask.delete`, `layer.smartFilter.deleteFilterMask` | @@ -66,7 +66,7 @@ does not map yet, with everything needed to implement them without the reference | `Exch` | exchange | Exchange | `tools.swapColors` | | `Expn` | expand | Expand | `select.modify.expand` | | `Expr` | export | Export | `file.export.saveForWebLegacy` | -| `Exps` | Exps | exposure | `Exposure` | +| `Exps` | exposure | Exposure | `image.adjustments.exposure` | | `Fthr` | feather | Feather | `select.modify.feather` | | `Fl ` | fill | Fill | `edit.fill`, `paint.bucket` | | `GEfc` | filterGallery | Filter Gallery | `filter.gallery.spatter` | @@ -77,20 +77,20 @@ does not map yet, with everything needed to implement them without the reference | `Fbrs` | (code only) | Fibers | `filter.render.fibers` | | `LnsF` | lensFlare | Lens Flare | `filter.render.lensFlare` | | `GsnB` | gaussianBlur | Gaussian Blur | `filter.blur.gaussianBlur` | -| `GdMp` | GdMp | gradientMapEvent | `Gradient Map` | +| `GdMp` | gradientMapEvent | Gradient Map | `image.adjustments.gradientMap` | | `Grow` | grow | Grow | `select.grow` | | `Hd ` | hide | Hide | `layer.hideLayers` | | `HghP` | highPass | High Pass | `filter.other.highPass` | -| `HStr` | HStr | hueSaturation | `Hue/Saturation` | +| `HStr` | hueSaturation | Hue/Saturation | `image.adjustments.hueSaturation` | | (string ID only) | conditional | If | `actions.conditional` | | `ImgS` | imageSize | Image Size | `image.imageSize` | | `Invs` | inverse | Inverse | `select.inverse` | -| `Invr` | Invr | invert | `Invert` | +| `Invr` | invert | Invert | `image.adjustments.invert` | | `CpTL` | copyToLayer | Layer Via Copy | `layer.new.layerViaCopy` | | `CtTL` | cutToLayer | Layer Via Cut | `layer.new.layerViaCut` | | `Lvls` | levels | Levels | `image.adjustments.levels`, `image.autoTone`, `image.autoContrast` | | `Mk ` | make | Make | `layer.new.layer`, `layer.new.group`, `layer.groupLayers`, `layer.duplicate`, `layer.new.layerViaCopy`, `layer.new.layerViaCut`, `layer.newFillLayer.solidColor`, `layer.newFillLayer.pattern`, `layer.newAdjustmentLayer.levels`, `layer.newAdjustmentLayer.curves`, `layer.newAdjustmentLayer.hueSaturation`, `layer.newAdjustmentLayer.brightnessContrast`, `layer.newAdjustmentLayer.threshold`, `layer.newAdjustmentLayer.posterize`, `layer.newAdjustmentLayer.invert`, `layer.newAdjustmentLayer.colorBalance`, `layer.newAdjustmentLayer.vibrance`, `layer.newAdjustmentLayer.exposure`, `layer.newAdjustmentLayer.photoFilter`, `layer.newAdjustmentLayer.blackWhite`, `layer.newAdjustmentLayer.channelMixer`, `layer.newAdjustmentLayer.selectiveColor`, `layer.newAdjustmentLayer.gradientMap`, `layer.layerMask.revealAll`, `layer.layerMask.hideAll`, `layer.layerMask.revealSelection`, `layer.layerMask.hideSelection`, `file.new`, `file.newFromClipboard`, `history.newSnapshot` | -| (string ID only) | animationFramesFromLayers | Make Frames from Layers | | +| (string ID only) | animationFramesFromLayers | Make Frames from Layers | Kept step (disabled) | | `Mxm ` | maximum | Maximum | `filter.other.maximum` | | `Mdn ` | median | Median | `filter.noise.median` | | `Mrg2` | mergeLayersNew | Merge Layers | `layer.mergeLayers` | @@ -102,10 +102,10 @@ does not map yet, with everything needed to implement them without the reference | `Opn ` | open | Open | `file.openAs` | | `past` | paste | Paste | `edit.paste`, `edit.pasteSpecial.pasteInPlace` | | (string ID only) | pasteEffects | Paste Layer Style | `layer.layerStyle.pasteLayerStyle` | -| (string ID only) | PhtF | photoFilter | `Photo Filter` | +| (string ID only) | photoFilter | Photo Filter | `image.adjustments.photoFilter` | | `Plc ` | placeEvent | Place | `file.placeEmbedded`, `file.placeLinked` | | `Ply ` | play | Play | `actions.play` | -| `Pstr` | Pstr | posterize | `Posterize` | +| `Pstr` | posterize | Posterize | `image.adjustments.posterize` | | `Prge` | purge | Purge | `edit.purge.all`, `edit.purge.clipboard`, `edit.purge.histories`, `edit.purge.undo` | | `RdlB` | radialBlur | Radial Blur | `filter.blur.radialBlur` | | (string ID only) | rasterizeLayer | Rasterize | `layer.rasterize.layer` | @@ -121,7 +121,7 @@ does not map yet, with everything needed to implement them without the reference | (string ID only) | refineSelectionEdge | Select and Mask | `select.refineEdge` | | (string ID only) | smartBrushWorkspace | Select and Mask | `select.refineEdge` | | (string ID only) | autoCutout | Select Subject | `select.subject` | -| `SlcC` | SlcC | selectiveColor | `Selective Color` | +| `SlcC` | selectiveColor | Selective Color | `image.adjustments.selectiveColor` | | `setd` | set | Set | `select.all`, `select.deselect`, `select.reselect`, `select.loadSelection`, `select.rect`, `select.lasso`, `select.editInQuickMaskMode`, `tools.setColors`, `layer.setProps`, `layer.renameLayer`, `layer.setLabelColor`, `layer.setAdjustment`, `layer.fill.pattern.set`, `layer.new.layerFromBackground`, `layer.layerStyle.blendingOptions`, `layer.smartFilter.blendingOptions` | | (string ID only) | adaptCorrect | Shadows/Highlights | `image.adjustments.shadowsHighlights` | | `Shrp` | sharpen | Sharpen | `filter.sharpen.sharpen` | @@ -131,16 +131,16 @@ does not map yet, with everything needed to implement them without the reference | `Smlr` | similar | Similar | `select.similar` | | `SmrB` | smartBlur | Smart Blur | `filter.blur.smartBlur` | | `Smth` | smoothness | Smooth | `select.modify.smooth` | -| `Stop` | stop | Stop | `STOP_MESSAGE` | +| `Stop` | stop | Stop | `actions.stopMessage` | | `Strk` | stroke | Stroke | `edit.stroke` | | (string ID only) | surfaceBlur | Surface Blur | `filter.blur.surfaceBlur` | -| `Thrs` | Thrs | threshold | `Threshold` | +| `Thrs` | threshold | Threshold | `image.adjustments.threshold` | | `Trnf` | transform | Transform | `edit.transform`, `select.transformSelection` | | (string ID only) | trim | Trim | `image.trim` | | (string ID only) | ungroupLayersEvent | Ungroup Layers | `layer.ungroupLayers` | | `UnsM` | unsharpMask | Unsharp Mask | `filter.sharpen.unsharpMask` | | (string ID only) | updatePlacedLayer | Update Smart Objects | `layer.smartObjects.updateModifiedContent` | -| `Vbrn` | Vbrn | vibrance | `Vibrance` | +| `Vbrn` | vibrance | Vibrance | `image.adjustments.vibrance` | ### Group B: The Render filters Clouds, Difference Clouds, Fibers and Lens Flare diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 1fc994a94e..55e9b309a9 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-11-01 · **Change:** minor (`.atn`: event reference [atn.md](atn.md) with the supported events and the measured keys, defaults, ranges and enum values of 101 unmapped events; TIFFs up to 300,000 px a side open, decoded a group of rows at a time into the layer, #8169; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; PSD Filter Gallery: Halftone Pattern dot/circle/line settings survive PSD/PSB saves (#8940); Save for Web writes WebP, lossy or lossless, #6391; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency, #8877; layered TIFF writes the TySh text version and descriptor in PSD big-endian order while the header, matrix, warp and bounds keep container order, #9098; layered TIFF writes Photoshop's 14-byte global layer mask, so Photoshop opens the layers instead of only the composite, #9095; legacy PSD point text placed from the layout ink box after font resolution, #6094; `.atn`: 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; `.asl` layer style libraries read and written, #1051; JPEG Options on File › Save, Save As and Save a Copy, FILE-215-19 partial (no Matte, progressive Scans or size preview); 32-bit Hue/Saturation unclipped and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; JPEG Options on Save As and Save a Copy, FILE-215-19; TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; Cineon write: 10-bit gray/RGB; animated WebP frames load into video layers, #1198; Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 53 filters (39 more: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag, Invert, Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding; Amiga IFF write: 24-plane ILBM, Save As `.iff`; PSD stores spot channels as Photoshop does, ink 0 = solid, and numbers them in 1053, #7415 split; TIFF writes and reads alpha and spot channels as extra samples with Photoshop's channel resources, PSD output unchanged, FILE-215-7, FILE-215-17's TIFF part, #7415; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-31 · **Change:** minor (`.atn`: 121 supported and 97 unmapped events after the four Render mappings (Group B, #10604); the supported table and measured reference are in [atn.md](atn.md); TIFFs up to 300,000 px a side open, decoded a group of rows at a time into the layer, #8169; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; PSD Filter Gallery: Halftone Pattern dot/circle/line settings survive PSD/PSB saves (#8940); Save for Web writes WebP, lossy or lossless, #6391; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency, #8877; layered TIFF writes the TySh text version and descriptor in PSD big-endian order while the header, matrix, warp and bounds keep container order, #9098; layered TIFF writes Photoshop's 14-byte global layer mask, so Photoshop opens the layers instead of only the composite, #9095; legacy PSD point text placed from the layout ink box after font resolution, #6094; `.atn`: 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; `.asl` layer style libraries read and written, #1051; JPEG Options on File › Save, Save As and Save a Copy, FILE-215-19 partial (no Matte, progressive Scans or size preview); 32-bit Hue/Saturation unclipped and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; JPEG Options on Save As and Save a Copy, FILE-215-19; TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; Cineon write: 10-bit gray/RGB; animated WebP frames load into video layers, #1198; Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 53 filters (39 more: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag, Invert, Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding; Amiga IFF write: 24-plane ILBM, Save As `.iff`; PSD stores spot channels as Photoshop does, ink 0 = solid, and numbers them in 1053, #7415 split; TIFF writes and reads alpha and spot channels as extra samples with Photoshop's channel resources, PSD output unchanged, FILE-215-7, FILE-215-17's TIFF part, #7415; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency) · **Target:** Adobe Photoshop 2026 **PSD Filter Gallery (#8940, 2026-10-23):** Halftone Pattern maps its size, contrast and dot/circle/line pattern as editable settings on import and export, with the reference application's descriptor keys (`HlSz`, `Cntr`, `ScrT`: `ScrD`/`ScrC`/`ScrL`). PSD/PSB round trips pass at 8/16/32 bits. This does not claim pixel equivalence for the existing renderer. Unknown or malformed settings remain verbatim on import. @@ -148,6 +148,7 @@ Embedded SVG and PDF-compatible AI/PDF/EPS payloads can use an explicit VectorCr | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | `.atn` Group B: four Render events load, play and save; 121 supported and 97 unmapped. Supported-table columns corrected against the event registry; the original 117/101 reference entry remains historical. | | 2026-11-01 | minor | `.atn`: event reference [atn.md](atn.md): the 117 supported events, and measured keys, defaults, ranges and enum values for 101 events not mapped yet | | 2026-10-26 | minor | Embedded AI/PDF/EPS save-back retains native format and a separate source-bound display preview; original sources remain undoable. Native Adobe interoperability and standalone EPS/DCS remain unverified; no corpus floor or percentage changed. | | 2026-10-22 | minor | Embedded PSD vectors gain explicit bounded SVG editor/save-back; standalone EPS/DCS and native-format fidelity remain open. | diff --git a/docs/gaps.md b/docs/gaps.md index 2537355b5b..8ef613ea82 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (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; G9: 12 of 181 preferences do nothing, as the scorecard prefs audit counts them; G6: Art History Brush landed as partial, TOOL-213-20, #1610, so no toolbar tool is missing; G4: five hard-round built-in presets use 5% spacing to remove stacked-tip outlines and reduce pressure-opacity bands, #8842; G9: Crop tool canvas menu, #6561; G2: Content-Aware Scale seam carving 1.65–3.6× faster with unchanged output, P28 row 3.3 → 1.6 s on a 4-core VM, #5236; G9: UI scale presets done, 105–120% steps, #3222; G14: lossy DNG, raw sidecars, Camera Raw Defaults live, Calibration and Auto, CR3 assessed as not clean-room feasible; G8: PSD type on a path opens on its path and saves back into the file's `Txt2`, #7962; G11: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; adjustment clipping stays visible in compact Properties, #5231; G9: Color panel modes, ramp and copy commands, the same in every theme, UI-217-17, #6000; G17: Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; G10: `.asl` style libraries landed, #1051; G8: area type (TYPE-218-10); G1: smart objects keep Photoshop's Cylinder warp, #4112; G8: paragraph direction buttons in the Paragraph panel and options bar, TYPE-218-2 partial; G1: 32-bit Hue/Saturation and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; G11: compound layer properties export as separate Photoshop Set events, rename last, #8304; G11: Batch and droplets take the result at the action's Save step, so actions ending in Save As and Close work; G11: `.atn` steps that switch to another open document import, play and save back, #7103; G14: the Nikon D4 white level follows the sample depth, so 12-bit D4 NEFs no longer open 2.6 stops dark, #5978; G14: a Nikon NEF's Adobe RGB preview is read as Adobe RGB (maker-note ColorSpace 0x001E), #5987; Apply Layer Comp action mapping, #7101; G10: JPEG Options dialog landed, FILE-215-19 partial (no Matte, progressive Scans or size preview); G10: Cineon write; G10: DPX read and write, #1049, Cineon and Amiga IFF read; Classical Object Finder improvement; learned segmentation remains open; G4: named, stateful section locks and visible keyboard focus in Brush Settings; G4: independent Brush Pose tilt-axis overrides, #3825 partial; G4: Transfer Mixer-only controls, #3825 partial; G9: Preferences › File Handling › Save in Background takes effect, #6207, 24 of 169 preferences do nothing; G7: Content-Aware Move previews every selection the command takes, coarse then exact, TOOL-213-4 done, #6680; G4: Pattern Stamp and Background Eraser draw live, TOOL-213-7; G9: Animated Zoom pref read: holding the Zoom tool zooms continuously, #6210, prefs that do nothing 25 of 166; G2: a cancelled 24 MP Gaussian Blur job stops in at most 44 ms, P25 under its 200 ms budget in local runs, baseline not re-run, #6205; G1: generated large-file fixtures, a 4.38 GB PSB opens on Windows through io, FILE-216-2 done, #5107; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed; G10: Amiga IFF write; G10: PSD spot channels in Photoshop's polarity, earlier PhotoCraft PSDs keep their ink, #7415 split; G10: TIFF alpha and spot channels written and read as extra samples, FILE-215-7 done, FILE-215-17's TIFF part, #7415) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (G11: four Render ATN events mapped, 121 supported/97 unmapped; 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; G9: 12 of 181 preferences do nothing, as the scorecard prefs audit counts them; G6: Art History Brush landed as partial, TOOL-213-20, #1610, so no toolbar tool is missing; G4: five hard-round built-in presets use 5% spacing to remove stacked-tip outlines and reduce pressure-opacity bands, #8842; G9: Crop tool canvas menu, #6561; G2: Content-Aware Scale seam carving 1.65–3.6× faster with unchanged output, P28 row 3.3 → 1.6 s on a 4-core VM, #5236; G9: UI scale presets done, 105–120% steps, #3222; G14: lossy DNG, raw sidecars, Camera Raw Defaults live, Calibration and Auto, CR3 assessed as not clean-room feasible; G8: PSD type on a path opens on its path and saves back into the file's `Txt2`, #7962; G11: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; adjustment clipping stays visible in compact Properties, #5231; G9: Color panel modes, ramp and copy commands, the same in every theme, UI-217-17, #6000; G17: Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; G10: `.asl` style libraries landed, #1051; G8: area type (TYPE-218-10); G1: smart objects keep Photoshop's Cylinder warp, #4112; G8: paragraph direction buttons in the Paragraph panel and options bar, TYPE-218-2 partial; G1: 32-bit Hue/Saturation and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; G11: compound layer properties export as separate Photoshop Set events, rename last, #8304; G11: Batch and droplets take the result at the action's Save step, so actions ending in Save As and Close work; G11: `.atn` steps that switch to another open document import, play and save back, #7103; G14: the Nikon D4 white level follows the sample depth, so 12-bit D4 NEFs no longer open 2.6 stops dark, #5978; G14: a Nikon NEF's Adobe RGB preview is read as Adobe RGB (maker-note ColorSpace 0x001E), #5987; Apply Layer Comp action mapping, #7101; G10: JPEG Options dialog landed, FILE-215-19 partial (no Matte, progressive Scans or size preview); G10: Cineon write; G10: DPX read and write, #1049, Cineon and Amiga IFF read; Classical Object Finder improvement; learned segmentation remains open; G4: named, stateful section locks and visible keyboard focus in Brush Settings; G4: independent Brush Pose tilt-axis overrides, #3825 partial; G4: Transfer Mixer-only controls, #3825 partial; G9: Preferences › File Handling › Save in Background takes effect, #6207, 24 of 169 preferences do nothing; G7: Content-Aware Move previews every selection the command takes, coarse then exact, TOOL-213-4 done, #6680; G4: Pattern Stamp and Background Eraser draw live, TOOL-213-7; G9: Animated Zoom pref read: holding the Zoom tool zooms continuously, #6210, prefs that do nothing 25 of 166; G2: a cancelled 24 MP Gaussian Blur job stops in at most 44 ms, P25 under its 200 ms budget in local runs, baseline not re-run, #6205; G1: generated large-file fixtures, a 4.38 GB PSB opens on Windows through io, FILE-216-2 done, #5107; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed; G10: Amiga IFF write; G10: PSD spot channels in Photoshop's polarity, earlier PhotoCraft PSDs keep their ink, #7415 split; G10: TIFF alpha and spot channels written and read as extra samples, FILE-215-7 done, FILE-215-17's TIFF part, #7415) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -111,6 +111,7 @@ what an agent can't finish alone. - **Estimate:** 120–200 h. ### G11. Actions and scripting +- **Done 2026-10-31 (#10604):** all four Render ATN events (Group B) load, play and save with measured defaults, documented clamping and strict export checks. The [event reference](atn.md) now lists 121 supported and 97 unmapped events. Other Actions/scripting gaps remain; no overall readiness percentage or estimate changed. - **2026-10-21 evidence (#8304):** Save Actions expands compound layer-property edits into separate Photoshop Set events, with rename last so named layer references remain valid. Exported descriptors and native playback at RGB 8/16/32-bit have [regression coverage](../crates/engine/src/atn_cmds/layer_props_tests.rs); @@ -424,6 +425,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | G11: four Render ATN events mapped; current counts 121 supported/97 unmapped. Other Actions gaps, readiness percentages and estimates unchanged. | | 2026-10-27 | minor | Smooth/Feather command reads and replacement use selection bounds plus blur reach; full-canvas reference and release comparisons extended; percentages and scorecard budgets unchanged | | 2026-10-27 | minor | G9: 12 of 181 preferences do nothing, the scorecard's count with integration batch 9 (#9775) on main; no estimate changed | | 2026-10-26 | minor | Expand/Contract/Border distance work, mask reads and replacement cropped to nonzero bounds; bounded distances for radii ≤5; 36 MP exact-output comparisons improve 1.54–37.82× across eight cases; percentages, estimates and scorecard budgets unchanged | From ec44d1711f122b616c7ccc5d7a0a2413822d42b1 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Sat, 31 Oct 2026 19:59:52 -0700 Subject: [PATCH 031/216] Map ATN selection and layer-operation events --- crates/engine/src/cmd/atn_cmds.rs | 4 + crates/engine/src/cmd/atn_cmds/events.rs | 102 ++++- .../src/cmd/atn_cmds/selection_tests.rs | 336 ++++++++++++++++ .../src/cmd/atn_cmds/steps_selection.rs | 214 ++++++++++ crates/engine/src/cmd/atn_cmds/tests.rs | 46 ++- docs/atn.md | 372 +++++++++--------- docs/file-format-parity.md | 3 +- docs/gaps.md | 2 + 8 files changed, 884 insertions(+), 195 deletions(-) create mode 100644 crates/engine/src/cmd/atn_cmds/selection_tests.rs create mode 100644 crates/engine/src/cmd/atn_cmds/steps_selection.rs diff --git a/crates/engine/src/cmd/atn_cmds.rs b/crates/engine/src/cmd/atn_cmds.rs index 42a4722479..2322d6fa1b 100644 --- a/crates/engine/src/cmd/atn_cmds.rs +++ b/crates/engine/src/cmd/atn_cmds.rs @@ -36,6 +36,7 @@ mod steps_doc; mod steps_image; mod steps_layer; mod steps_make; +mod steps_selection; mod tables; #[cfg(test)] @@ -145,3 +146,6 @@ mod tests; #[cfg(test)] mod layer_props_tests; + +#[cfg(test)] +mod selection_tests; diff --git a/crates/engine/src/cmd/atn_cmds/events.rs b/crates/engine/src/cmd/atn_cmds/events.rs index 5723c93c59..601c6b9f0b 100644 --- a/crates/engine/src/cmd/atn_cmds/events.rs +++ b/crates/engine/src/cmd/atn_cmds/events.rs @@ -184,6 +184,94 @@ macro_rules! clipping_event { /// Every Photoshop event PhotoCraft maps. The order matters only for the panel label. pub(super) const EVENTS: &[Event] = &[ + Event { + code: "AddT", + name: "addTo", + label: "Add to Selection", + ids: &["select.rect"], + from: |d| super::steps_selection::selection_from(d, "add"), + to: |_, p| super::steps_selection::selection_to(p, "add"), + }, + Event { + code: "SbtF", + name: "subtractFrom", + label: "Subtract from Selection", + ids: &["select.rect"], + from: |d| super::steps_selection::selection_from(d, "subtract"), + to: |_, p| super::steps_selection::selection_to(p, "subtract"), + }, + Event { + code: "IntW", + name: "interfaceWhite", + label: "Intersect with Selection", + ids: &["select.rect"], + from: |d| super::steps_selection::selection_from(d, "intersect"), + to: |_, p| super::steps_selection::selection_to(p, "intersect"), + }, + Event { + code: "PstI", + name: "pasteInto", + label: "Paste Into", + ids: &["edit.pasteSpecial.pasteInto"], + from: |d| super::steps_selection::paste_from(d, "edit.pasteSpecial.pasteInto"), + to: super::steps_selection::paste_to, + }, + Event { + code: "PstO", + name: "pasteOutside", + label: "Paste Outside", + ids: &["edit.pasteSpecial.pasteOutside"], + from: |d| super::steps_selection::paste_from(d, "edit.pasteSpecial.pasteOutside"), + to: super::steps_selection::paste_to, + }, + Event { code: "Fade", name: "fade", label: "Fade", ids: &["edit.fade"], from: super::steps_selection::fade_from, to: super::steps_selection::fade_to }, + Event { + code: "", + name: "linkSelectedLayers", + label: "Link Layers", + ids: &["layer.linkLayers"], + from: super::steps_selection::link_from, + to: super::steps_selection::link_to, + }, + Event { + code: "Dstr", + name: "distort", + label: "Distribute", + ids: &[ + "layer.distribute.leftEdges", + "layer.distribute.horizontalCenters", + "layer.distribute.rightEdges", + "layer.distribute.topEdges", + "layer.distribute.verticalCenters", + "layer.distribute.bottomEdges", + ], + from: super::steps_selection::distribute_from, + to: super::steps_selection::distribute_to, + }, + Event { + code: "RmvL", + name: "removeLayerMask", + label: "Remove Layer Mask", + ids: &["layer.layerMask.delete"], + from: |d| super::steps_selection::empty_from(d, "layer.layerMask.delete", Some("RmvL")), + to: super::steps_selection::empty_to, + }, + Event { + code: "DfnP", + name: "definePattern", + label: "Define Pattern", + ids: &["edit.definePattern"], + from: |d| super::steps_selection::empty_from(d, "edit.definePattern", None), + to: super::steps_selection::empty_to, + }, + Event { + code: "MrgL", + name: "", + label: "Merge Layers", + ids: &["layer.mergeLayers"], + from: |d| super::steps_selection::empty_from(d, "layer.mergeLayers", Some("MrgL")), + to: super::steps_selection::empty_to, + }, // Filters. ev!("GsnB", "gaussianBlur", "Gaussian Blur", "filter.blur.gaussianBlur", &[F("Rds ", "radius", Kind::Px)]), ev!( @@ -617,7 +705,7 @@ const LEGACY_CODES: &[(&str, &str)] = pub(super) fn event_for(item: &AtnEvent) -> Option<&'static Event> { let id = item.id(); - let find = |id: &str| EVENTS.iter().find(|e| (!e.code.is_empty() && e.code.trim_end() == id.trim_end()) || e.name == id); + let find = |id: &str| EVENTS.iter().find(|e| (!e.code.is_empty() && e.code.trim_end() == id.trim_end()) || (!e.name.is_empty() && e.name == id)); find(&id).or_else(|| match item { AtnEvent::Code(_) => LEGACY_CODES.iter().find(|(code, _)| *code == id).and_then(|(_, name)| find(name)), AtnEvent::Name(_) => None, @@ -627,5 +715,17 @@ pub(super) fn event_for(item: &AtnEvent) -> Option<&'static Event> { /// The event `id` exports as: the first event listing it whose converter accepts the params /// (Quick Mask on is a `set`, off a `clearEvent`). pub(super) fn event_and_descriptor(id: &str, params: &Value) -> Option<(&'static Event, Descriptor)> { + if params.get("_atnBounds").is_some() + && (id != "select.rect" || !matches!(params.get("mode").and_then(Value::as_str), Some("add" | "subtract" | "intersect"))) + { + return None; + } + if let Some(origin) = params.get("_atnEvent") { + let origin = origin.as_str()?; + if !matches!((id, origin), ("layer.layerMask.delete", "RmvL") | ("layer.mergeLayers", "MrgL")) { + return None; + } + return EVENTS.iter().find(|e| e.code == origin).and_then(|e| (e.to)(id, params).map(|d| (e, d))); + } EVENTS.iter().filter(|e| e.ids.contains(&id)).find_map(|e| (e.to)(id, params).map(|d| (e, d))) } diff --git a/crates/engine/src/cmd/atn_cmds/selection_tests.rs b/crates/engine/src/cmd/atn_cmds/selection_tests.rs new file mode 100644 index 0000000000..8e207b0255 --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/selection_tests.rs @@ -0,0 +1,336 @@ +use super::*; + +/// An independently constructed selection-property reference, as recorded in Group F. +fn selection() -> D { + D::Reference(vec![ReferenceItem::Property { class: class("Chnl"), key: code("fsel") }]) +} + +/// Bounds stay in their recorded Top/Left/Bottom/Right order and pixel-double units. +fn rectangle(bounds: [f64; 4], ellipse: bool) -> Descriptor { + let mut shape = desc(if ellipse { "Elps" } else { "Rctn" }); + for (key, value) in ["Top ", "Left", "Btom", "Rght"].into_iter().zip(bounds) { + shape = shape.with(key, px(value)); + } + let mut d = desc("null").with("null", selection()).with("T ", D::Descriptor(shape)); + if ellipse { + d = d.with("AntA", D::Boolean(true)); + } + d +} + +/// One real action file, shared by descriptor and engine playback witnesses. +fn file(event: AtnEvent, descriptor: Descriptor) -> Vec { + atn::write(&AtnFile { + name: UnicodeString::new_nul("Group F"), + actions: vec![AtnAction { + name: UnicodeString::new_nul("Step"), + items: vec![AtnItem { event, dictionary: b"Step".to_vec(), descriptor: Some(descriptor), ..Default::default() }], + ..Default::default() + }], + ..Default::default() + }) + .unwrap() +} + +/// Both recorded event forms retain explicit example settings and descriptor key/type order. +#[test] +fn group_f_load_save_preserves_recorded_descriptors() { + let mut cases = vec![ + ("AddT", Some("addTo"), rectangle([30.0, 30.0, 90.0, 90.0], false)), + ("AddT", Some("addTo"), rectangle([30.0, 30.0, 90.0, 90.0], true)), + ("SbtF", Some("subtractFrom"), rectangle([30.0, 30.0, 60.0, 60.0], false)), + ("IntW", Some("interfaceWhite"), rectangle([30.0, 30.0, 120.0, 120.0], false)), + ("PstI", Some("pasteInto"), desc("null").with("AntA", enm("Annt", "Anno")).with("As ", D::Class(class("Pxel")))), + ("PstO", Some("pasteOutside"), desc("null").with("AntA", enm("Annt", "Anno")).with("As ", D::Class(class("Pxel")))), + ("Fade", Some("fade"), desc("null").with("Opct", pct(50.0)).with("Md ", enm("BlnM", "Mltp"))), + ("", Some("linkSelectedLayers"), desc("null").with("null", target_ref("Lyr "))), + ("Dstr", Some("distort"), desc("null").with("null", target_ref("Lyr ")).with("Usng", enm("ADSt", "AdLf"))), + ("RmvL", Some("removeLayerMask"), desc("null")), + ("DfnP", Some("definePattern"), desc("null")), + ("MrgL", None, desc("null")), + ]; + // Opposite-signed fractions can lose an endpoint through x+(right-x); preserve it explicitly. + for mode in ["AddT", "SbtF", "IntW"] { + cases.push((mode, None, rectangle([-0.1, -0.1, 0.3, 0.2], false))); + } + for (id, name, d) in cases { + let mut events = Vec::new(); + if !id.is_empty() { + events.push(AtnEvent::Code(id.as_bytes().try_into().unwrap())); + } + if let Some(name) = name { + events.push(AtnEvent::name(name)); + } + for event in events { + let mut s = Session::new(); + let loaded = s.execute("actions.load", json!({"data":photocraft_paint::tile::b64_encode(&file(event.clone(),d.clone()))})).unwrap(); + assert_eq!(loaded["unsupported"], json!([]), "{id}"); + let saved = s.execute("actions.save", json!({"action":"Step"})).unwrap(); + let bytes = photocraft_paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap(); + let back = atn::parse(&bytes).unwrap(); + assert_eq!(back.actions[0].items[0].descriptor, Some(d.clone()), "{id}"); + assert_eq!(back.actions[0].items[0].event.id(), if id.is_empty() { name.unwrap() } else { id }); + } + } +} + +/// Native exports keep their current event; only imported old mask/merge steps select old IDs. +#[test] +fn group_f_legacy_origin_is_allowlisted_and_does_not_replace_native_events() { + for (id, old, current) in [("layer.layerMask.delete", "RmvL", "Dlt "), ("layer.mergeLayers", "MrgL", "Mrg2")] { + assert_eq!(export_item(id, &json!({}), "S").unwrap().event.id(), current); + let params = json!({"_atnEvent":old}); + assert_eq!(export_item(id, ¶ms, "S").unwrap().event.id(), old); + assert!(export_item(id, &json!({"_atnEvent":"arbitraryEvent"}), "S").is_none()); + } + assert!(export_item("edit.definePattern", &json!({"_atnEvent":"MrgL"}), "S").is_none()); + assert!(export_item("select.rect", &json!({"x":0,"y":0,"width":1,"height":1,"mode":"replace","_atnBounds":[0,0,1,1]}), "S").is_none()); +} + +/// Missing settings, unknown enums, repeated keys and wrong targets stay kept with their flags. +#[test] +fn group_f_unsupported_settings_are_not_silently_replaced() { + let mut wrong_ref = rectangle([1.0, 2.0, 3.0, 4.0], false); + wrong_ref.items[0].1 = target_ref("Lyr "); + let mut cases = vec![ + (*b"AddT", desc("null")), + (*b"AddT", wrong_ref), + (*b"PstI", desc("null").with("AntA", enm("Annt", "Unknown")).with("As ", D::Class(class("Pxel")))), + (*b"PstO", desc("null").with("AntA", enm("Annt", "Anno")).with("As ", D::Class(class("Other")))), + (*b"Fade", desc("null").with("Opct", pct(50.0))), + (*b"Fade", desc("null").with("Opct", px(50.0)).with("Md ", enm("BlnM", "Nrml"))), + (*b"Dstr", desc("null").with("null", target_ref("Lyr ")).with("Usng", enm("ADSt", "Unknown"))), + (*b"RmvL", desc("null").with("apply", D::Boolean(true))), + ]; + for (event, d) in [ + (*b"AddT", rectangle([1.0, 2.0, 3.0, 4.0], true)), + (*b"PstI", desc("null").with("AntA", enm("Annt", "Anno")).with("As ", D::Class(class("Pxel")))), + (*b"Fade", desc("null").with("Opct", pct(50.0)).with("Md ", enm("BlnM", "Nrml"))), + (*b"Dstr", desc("null").with("null", target_ref("Lyr ")).with("Usng", enm("ADSt", "AdLf"))), + ] { + for item in &d.items { + let mut repeated = d.clone(); + repeated.items.push(item.clone()); + cases.push((event, repeated)); + } + } + for key in ["Top ", "Left", "Btom", "Rght"] { + let mut d = rectangle([1.0, 2.0, 3.0, 4.0], false); + let D::Descriptor(shape) = &mut d.items[1].1 else { unreachable!() }; + shape.items.push((code(key), px(9.0))); + cases.push((*b"AddT", d)); + } + for (event, d) in cases { + let mut original = atn::parse(&file(AtnEvent::Code(event), d)).unwrap(); + original.actions[0].items[0].enabled = false; + original.actions[0].items[0].with_dialog = true; + original.actions[0].items[0].dialog_options = 2; + let loaded = import(&atn::write(&original).unwrap()).unwrap(); + assert_eq!(loaded.unsupported.len(), 1); + assert_eq!(loaded.actions[0].steps[0].0, PHOTOSHOP_STEP); + let saved = export("Group F", &[&loaded.actions[0]], &[], false).unwrap().0; + assert_eq!(atn::parse(&saved).unwrap().actions[0].items[0], original.actions[0].items[0]); + } + for (id, p) in [ + ("edit.pasteSpecial.pasteInto", json!({"center":[1,2]})), + ("edit.pasteSpecial.pasteOutside", json!({"target":"mask"})), + ("layer.linkLayers", json!({"layer":"Other"})), + ("layer.distribute.leftEdges", json!({"layer":7})), + ("edit.definePattern", json!({"name":"Frozen name"})), + ("edit.fade", json!({"mode":"unknown"})), + ("select.rect", json!({"x":1,"y":2,"width":3,"height":4,"mode":"add","feather":2})), + ] { + assert!(export_item(id, &p, "S").is_none(), "{id}: {p}"); + } +} + +/// Execute the action name assigned by the loader, including collision-safe renaming. +fn play(s: &mut Session, event: AtnEvent, d: Descriptor) -> Value { + let loaded = s.execute("actions.load", json!({"data":photocraft_paint::tile::b64_encode(&file(event,d))})).unwrap(); + assert_eq!(loaded["unsupported"], json!([])); + s.execute("actions.play", json!({"action":loaded["actions"][0]})).unwrap() +} + +/// A small image with real pixels; all witnesses operate through the public command boundary. +fn image(depth: u8) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width":8,"height":8,"depth":depth,"background":"transparent"})).unwrap(); + s.execute("edit.fill", json!({"contents":"color","color":"#4080c0"})).unwrap(); + s +} + +/// Compare the actual stored selection, rather than only the translated command params. +#[test] +fn selection_combinations_play_on_an_existing_mask_and_undo() { + for depth in [8, 16, 32] { + for (event, mode, ellipse) in [(*b"AddT", "add", false), (*b"AddT", "add", true), (*b"SbtF", "subtract", false), (*b"IntW", "intersect", false)] { + let mut actual = image(depth); + let mut direct = image(depth); + for s in [&mut actual, &mut direct] { + s.execute("select.rect", json!({"x":1,"y":1,"width":4,"height":4})).unwrap(); + } + let mask = |s: &Session| s.active().unwrap().doc.selection.as_ref().map(|m| m.read_region(s.active().unwrap().doc.bounds())); + let before = mask(&actual); + let result = play(&mut actual, AtnEvent::Code(event), rectangle([3.0, 3.0, 7.0, 7.0], ellipse)); + assert!(result.get("failed").is_none(), "{result}"); + let mut p = json!({"x":3,"y":3,"width":4,"height":4,"mode":mode}); + if ellipse { + p["ellipse"] = json!(true); + p["antiAlias"] = json!(true); + } + direct.execute("select.rect", p).unwrap(); + assert_eq!(mask(&actual), mask(&direct), "{depth}/{mode}/{ellipse}"); + assert_ne!(mask(&actual), before, "the action changes the existing mask"); + actual.execute("edit.undo", json!({})).unwrap(); + assert_eq!(mask(&actual), before); + } + } +} + +/// Clipboard-driven paste creates the same layer and mask through imported and direct commands. +#[test] +fn paste_actions_use_the_clipboard_and_selection_and_undo() { + for (event, id) in [(*b"PstI", "edit.pasteSpecial.pasteInto"), (*b"PstO", "edit.pasteSpecial.pasteOutside")] { + let setup = || { + let mut s = image(32); + s.execute("select.all", json!({})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + s.execute("select.rect", json!({"x":2,"y":2,"width":3,"height":3})).unwrap(); + s + }; + let mut actual = setup(); + let mut direct = setup(); + let before = actual.active().unwrap().doc.layers.len(); + let d = desc("null").with("AntA", enm("Annt", "Anno")).with("As ", D::Class(class("Pxel"))); + let result = play(&mut actual, AtnEvent::Code(event), d); + assert!(result.get("failed").is_none(), "{result}"); + direct.execute(id, json!({})).unwrap(); + let state = |s: &Session| { + let st = s.active().unwrap(); + let layer = st.doc.layer(st.active_layer.unwrap()).unwrap(); + (st.doc.layers.len(), layer.surface().unwrap().read_region(st.doc.bounds()), layer.mask.as_ref().unwrap().surface.read_region(st.doc.bounds())) + }; + assert_eq!(actual.active().unwrap().doc.layers.len(), before + 1); + assert_eq!(state(&actual), state(&direct)); + actual.execute("edit.undo", json!({})).unwrap(); + assert_eq!(actual.active().unwrap().doc.layers.len(), before); + } +} + +/// Zero-opacity Fade restores the preceding pixels; no previous edit fails without mutation. +#[test] +fn fade_action_consumes_the_previous_edit_and_fails_without_it() { + for depth in [8, 16, 32] { + let mut s = image(depth); + let before = s.execute("document.pixel", json!({"x":3,"y":3})).unwrap(); + s.execute("image.adjustments.invert", json!({})).unwrap(); + assert_ne!(s.execute("document.pixel", json!({"x":3,"y":3})).unwrap(), before); + let result = play(&mut s, AtnEvent::Code(*b"Fade"), desc("null").with("Opct", pct(0.0)).with("Md ", enm("BlnM", "Nrml"))); + assert!(result.get("failed").is_none(), "{result}"); + assert_eq!(s.execute("document.pixel", json!({"x":3,"y":3})).unwrap(), before); + } + let mut s = Session::new(); + s.execute("file.new", json!({"width":8,"height":8})).unwrap(); + let before = s.active().unwrap().doc.clone(); + let result = play(&mut s, AtnEvent::Code(*b"Fade"), desc("null").with("Opct", pct(50.0)).with("Md ", enm("BlnM", "Mltp"))); + assert_eq!(result["failed"]["id"], "edit.fade"); + assert!(std::sync::Arc::ptr_eq(&before, &s.active().unwrap().doc)); +} + +/// The Link event uses the specified existing toggle, including a second playback and Undo. +#[test] +fn link_action_targets_the_selected_layers() { + let mut s = image(8); + s.execute("layer.new.layer", json!({"name":"One"})).unwrap(); + s.execute("layer.new.layer", json!({"name":"Two"})).unwrap(); + s.execute("layer.select", json!({"layer":"One"})).unwrap(); + s.execute("layer.select", json!({"layer":"Two","mode":"add"})).unwrap(); + let linked = |s: &Session| s.active().unwrap().doc.layers.iter().filter(|l| l.link_group.is_some()).count(); + for expected in [2, 0] { + let result = play(&mut s, AtnEvent::name("linkSelectedLayers"), desc("null").with("null", target_ref("Lyr "))); + assert!(result.get("failed").is_none(), "{result}"); + assert_eq!(linked(&s), expected); + } + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(linked(&s), 2); +} + +/// Distinct sized/placed layers exercise each recorded distribution edge and center. +#[test] +fn distribute_actions_match_the_requested_native_direction_and_undo() { + for (value, edge) in [ + ("AdLf", "leftEdges"), + ("AdCH", "horizontalCenters"), + ("AdRg", "rightEdges"), + ("AdTp", "topEdges"), + ("AdCV", "verticalCenters"), + ("AdBt", "bottomEdges"), + ] { + let setup = || { + let mut s = image(8); + for (name, x, y, size) in [("One", 0, 0, 1), ("Two", 4, 3, 2), ("Three", 6, 6, 1)] { + s.execute("layer.new.layer", json!({"name":name})).unwrap(); + s.execute("select.rect", json!({"x":x,"y":y,"width":size,"height":size})).unwrap(); + s.execute("edit.fill", json!({"color":"#ff0000"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + } + s.execute("layer.select", json!({"layer":"One"})).unwrap(); + for name in ["Two", "Three"] { + s.execute("layer.select", json!({"layer":name,"mode":"add"})).unwrap(); + } + s + }; + let mut actual = setup(); + let mut direct = setup(); + let bounds = |s: &Session| s.active().unwrap().doc.layers.iter().map(|l| (l.name.clone(), l.surface().map(|v| v.content_bounds()))).collect::>(); + let before = bounds(&actual); + let result = play(&mut actual, AtnEvent::Code(*b"Dstr"), desc("null").with("null", target_ref("Lyr ")).with("Usng", enm("ADSt", value))); + assert!(result.get("failed").is_none(), "{result}"); + direct.execute(&format!("layer.distribute.{edge}"), json!({})).unwrap(); + assert_eq!(bounds(&actual), bounds(&direct), "{edge}"); + actual.execute("edit.undo", json!({})).unwrap(); + assert_eq!(bounds(&actual), before); + } +} + +/// Legacy steps perform real edits and keep their native Undo behavior. +#[test] +fn old_mask_and_merge_actions_execute_their_mapped_commands() { + let mut s = image(8); + s.execute("layer.layerMask.revealAll", json!({})).unwrap(); + let has_mask = |s: &Session| { + let st = s.active().unwrap(); + st.doc.layer(st.active_layer.unwrap()).unwrap().mask.is_some() + }; + assert!(has_mask(&s)); + let result = play(&mut s, AtnEvent::Code(*b"RmvL"), desc("null")); + assert!(result.get("failed").is_none(), "{result}"); + assert!(!has_mask(&s)); + s.execute("edit.undo", json!({})).unwrap(); + assert!(has_mask(&s)); + s.execute("layer.new.layer", json!({"name":"Overlay"})).unwrap(); + s.execute("edit.fill", json!({"color":"#ff0000"})).unwrap(); + s.execute("select.allLayers", json!({})).unwrap(); + let before = s.active().unwrap().doc.layers.len(); + let result = play(&mut s, AtnEvent::Code(*b"MrgL"), desc("null")); + assert!(result.get("failed").is_none(), "{result}"); + assert_eq!(s.active().unwrap().doc.layers.len(), 1); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.active().unwrap().doc.layers.len(), before); +} + +/// The empty Define Pattern event creates the selected region without changing canvas pixels. +#[test] +fn define_pattern_action_uses_the_selected_region() { + let mut s = image(8); + s.execute("select.rect", json!({"x":1,"y":2,"width":3,"height":2})).unwrap(); + let before = s.execute("document.pixel", json!({"x":3,"y":3})).unwrap(); + let count = s.patterns.items.len(); + let result = play(&mut s, AtnEvent::Code(*b"DfnP"), desc("null")); + assert!(result.get("failed").is_none(), "{result}"); + assert_eq!(s.patterns.items.len(), count + 1); + let p = s.patterns.items.last().unwrap(); + assert_eq!((p.width, p.height), (3, 2)); + assert!(!p.surface.format().alpha); + assert_eq!(s.execute("document.pixel", json!({"x":3,"y":3})).unwrap(), before); +} diff --git a/crates/engine/src/cmd/atn_cmds/steps_selection.rs b/crates/engine/src/cmd/atn_cmds/steps_selection.rs new file mode 100644 index 0000000000..03e6064f78 --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/steps_selection.rs @@ -0,0 +1,214 @@ +//! Recorded selection, clipboard and layer-operation descriptors (Group F). + +use super::descriptors::{class, code, desc, enm, px, trimmed}; +use super::fields::{Mapped, step}; +use super::tables::{ALIGN, blend_label, blend_ps, lookup, lookup_back}; +use super::{D, Descriptor, ReferenceItem, Value, json}; + +/// Unknown or repeated descriptor keys must remain visible instead of disappearing on save. +fn keys(d: &Descriptor, allowed: &[&str]) -> bool { + d.items.iter().all(|(k, _)| allowed.iter().any(|name| k.is(name))) && allowed.iter().all(|name| d.items.iter().filter(|(k, _)| k.is(name)).count() <= 1) +} + +/// Native settings without a recorded representation cannot be exported faithfully. +fn params(p: &Value, allowed: &[&str]) -> bool { + p.as_object().is_some_and(|p| p.keys().all(|k| allowed.contains(&k.as_str()))) +} + +/// Measured numeric fields retain their units and finite double precision. +fn unit(d: &Descriptor, key: &str, expected: &[u8; 4]) -> Option { + match d.get(key)? { + D::UnitFloat { unit, value } if unit == expected && value.is_finite() => Some(*value), + _ => None, + } +} + +/// Validate an enum's type as well as its value. +fn enumeration(d: &Descriptor, key: &str, expected: &str) -> Option { + match d.get(key)? { + D::Enumerated { type_id, value } if type_id.is(expected) => Some(trimmed(value)), + _ => None, + } +} + +/// The recorded reference identifies either the selection property or the targeted layers. +fn target(selection: bool) -> D { + D::Reference(vec![if selection { + ReferenceItem::Property { class: class("Chnl"), key: code("fsel") } + } else { + ReferenceItem::Enumerated { class: class("Lyr "), type_id: code("Ordn"), value: code("Trgt") } + }]) +} + +/// Preserve recorded rectangle endpoints when subtracting/adding widths would lose a low bit. +pub(super) fn selection_from(d: &Descriptor, mode: &str) -> Mapped { + let mapped = || { + if !keys(d, &["null", "T ", "AntA"]) || d.get("null") != Some(&target(true)) { + return None; + } + let D::Descriptor(shape) = d.get("T ")? else { return None }; + let ellipse = shape.class_id.is("Elps"); + if !(ellipse || shape.class_id.is("Rctn")) || (ellipse && mode != "add") || !keys(shape, &["Top ", "Left", "Btom", "Rght"]) { + return None; + } + let [top, left, bottom, right] = + [unit(shape, "Top ", b"#Pxl")?, unit(shape, "Left", b"#Pxl")?, unit(shape, "Btom", b"#Pxl")?, unit(shape, "Rght", b"#Pxl")?]; + let (width, height) = (right - left, bottom - top); + if !(width.is_finite() && height.is_finite() && width >= 0.0 && height >= 0.0) { + return None; + } + let mut p = json!({"x": left, "y": top, "width": width, "height": height, "mode": mode}); + if ellipse { + let D::Boolean(aa) = d.get("AntA")? else { return None }; + p["ellipse"] = json!(true); + p["antiAlias"] = json!(aa); + } else if d.get("AntA").is_some() { + return None; + } + if left + width != right || top + height != bottom { + p["_atnBounds"] = json!([top, left, bottom, right]); + } + Some(p) + }; + mapped().map_or(Mapped::Unsupported { harmless: false }, |p| step("select.rect", p)) +} + +/// Each combination event exports only its measured shape and settings. +pub(super) fn selection_to(p: &Value, mode: &str) -> Option { + if !params(p, &["x", "y", "width", "height", "mode", "ellipse", "antiAlias", "feather", "_atnBounds"]) || p.get("mode")?.as_str()? != mode { + return None; + } + if p.get("feather").is_some_and(|v| v.as_f64() != Some(0.0)) { + return None; + } + let ellipse = match p.get("ellipse") { + None => false, + Some(v) => v.as_bool()?, + }; + if ellipse && mode != "add" { + return None; + } + let number = |key| p.get(key)?.as_f64().filter(|v| v.is_finite()); + let (left, top, width, height) = (number("x")?, number("y")?, number("width")?, number("height")?); + if width < 0.0 || height < 0.0 { + return None; + } + let (mut bottom, mut right) = (top + height, left + width); + if let Some(bounds) = p.get("_atnBounds") { + let a = bounds.as_array()?; + let [t, l, b, r] = a.as_slice() else { return None }; + let (t, l, b, r) = (t.as_f64()?, l.as_f64()?, b.as_f64()?, r.as_f64()?); + if [t, l, b, r].iter().any(|v| !v.is_finite()) || t != top || l != left || b - t != height || r - l != width { + return None; + } + (bottom, right) = (b, r); + } + if !bottom.is_finite() || !right.is_finite() { + return None; + } + let shape = desc(if ellipse { "Elps" } else { "Rctn" }).with("Top ", px(top)).with("Left", px(left)).with("Btom", px(bottom)).with("Rght", px(right)); + let mut d = desc("null").with("null", target(true)).with("T ", D::Descriptor(shape)); + if ellipse { + let aa = match p.get("antiAlias") { + None => true, + Some(v) => v.as_bool()?, + }; + d = d.with("AntA", D::Boolean(aa)); + } else if p.get("antiAlias").is_some_and(|v| !v.is_boolean()) { + return None; + } + Some(d) +} + +/// Paste accepts the recorded no-antialias pixel form; other choices stay kept. +pub(super) fn paste_from(d: &Descriptor, id: &str) -> Mapped { + if keys(d, &["AntA", "As "]) && enumeration(d, "AntA", "Annt").as_deref() == Some("Anno") && d.get("As ") == Some(&D::Class(class("Pxel"))) { + step(id, json!({})) + } else { + Mapped::Unsupported { harmless: false } + } +} + +/// Pixel clipboard placement has no recorded layer, center or channel override. +pub(super) fn paste_to(_: &str, p: &Value) -> Option { + if !super::events::pixel_target(p) || !params(p, &["target"]) { + return None; + } + Some(desc("null").with("AntA", enm("Annt", "Anno")).with("As ", D::Class(class("Pxel")))) +} + +/// Fade requires the settings recorded after the edit it fades, not guessed factory values. +pub(super) fn fade_from(d: &Descriptor) -> Mapped { + let mapped = || { + if !keys(d, &["Opct", "Md "]) { + return None; + } + Some(json!({"opacity":unit(d,"Opct",b"#Prc")?.clamp(0.0,100.0),"mode":blend_label(&enumeration(d,"Md ","BlnM")?)?})) + }; + mapped().map_or(Mapped::Unsupported { harmless: false }, |p| step("edit.fade", p)) +} + +/// Native omitted Fade settings retain the command's 100% Normal behavior. +pub(super) fn fade_to(_: &str, p: &Value) -> Option { + if !params(p, &["opacity", "mode"]) { + return None; + } + let opacity = match p.get("opacity") { + None => 100.0, + Some(v) => v.as_f64().filter(|v| v.is_finite())?, + }; + let mode = match p.get("mode") { + None => "Normal", + Some(v) => v.as_str()?, + }; + Some(desc("null").with("Opct", super::descriptors::pct(opacity.clamp(0.0, 100.0))).with("Md ", enm("BlnM", blend_ps(mode)?))) +} + +/// Link acts on the currently targeted layers through the existing toggle command. +pub(super) fn link_from(d: &Descriptor) -> Mapped { + if keys(d, &["null"]) && d.get("null") == Some(&target(false)) { step("layer.linkLayers", json!({})) } else { Mapped::Unsupported { harmless: false } } +} + +/// A targeted-layer event cannot represent explicit native layer lists. +pub(super) fn link_to(_: &str, p: &Value) -> Option { + params(p, &[]).then(|| desc("null").with("null", target(false))) +} + +/// Distribute shares the established ADSt edge/center codes used by Align. +pub(super) fn distribute_from(d: &Descriptor) -> Mapped { + let mapped = || { + if !keys(d, &["null", "Usng"]) || d.get("null") != Some(&target(false)) { + return None; + } + let edge = lookup(ALIGN, &enumeration(d, "Usng", "ADSt")?)?; + Some(format!("layer.distribute.{edge}")) + }; + mapped().map_or(Mapped::Unsupported { harmless: false }, |id| step(&id, json!({}))) +} + +/// Equal-gap distribution is a different command and has no measured field here. +pub(super) fn distribute_to(id: &str, p: &Value) -> Option { + if !params(p, &[]) { + return None; + } + Some(desc("null").with("null", target(false)).with("Usng", enm("ADSt", lookup_back(ALIGN, id.strip_prefix("layer.distribute.")?)?))) +} + +/// Empty legacy events keep only their allowlisted origin, leaving native exports unchanged. +pub(super) fn empty_from(d: &Descriptor, id: &str, origin: Option<&str>) -> Mapped { + if !d.items.is_empty() { + return Mapped::Unsupported { harmless: false }; + } + step(id, origin.map_or(json!({}), |event| json!({"_atnEvent":event}))) +} + +/// Refuse native options or a mismatched legacy origin that the empty descriptor cannot carry. +pub(super) fn empty_to(id: &str, p: &Value) -> Option { + let allowed = match id { + "layer.layerMask.delete" => p.get("_atnEvent").and_then(Value::as_str) == Some("RmvL") && params(p, &["_atnEvent"]), + "layer.mergeLayers" => p.get("_atnEvent").and_then(Value::as_str) == Some("MrgL") && params(p, &["_atnEvent"]), + "edit.definePattern" => params(p, &[]), + _ => false, + }; + allowed.then(|| desc("null")) +} diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index f9e633244c..18968db1c9 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -476,6 +476,23 @@ fn settings_at_the_value_the_event_plays_still_export() { #[test] fn every_mapped_command_exports_and_imports_back() { let cases: Vec<(&str, Value)> = vec![ + ("select.rect", json!({"x":1,"y":2,"width":3,"height":4,"mode":"add"})), + ("select.rect", json!({"x":1,"y":2,"width":3,"height":4,"mode":"add","ellipse":true,"antiAlias":false})), + ("select.rect", json!({"x":1,"y":2,"width":3,"height":4,"mode":"subtract"})), + ("select.rect", json!({"x":1,"y":2,"width":3,"height":4,"mode":"intersect"})), + ("edit.pasteSpecial.pasteInto", json!({})), + ("edit.pasteSpecial.pasteOutside", json!({})), + ("edit.fade", json!({"opacity":50,"mode":"Multiply"})), + ("layer.linkLayers", json!({})), + ("layer.distribute.leftEdges", json!({})), + ("layer.distribute.horizontalCenters", json!({})), + ("layer.distribute.rightEdges", json!({})), + ("layer.distribute.topEdges", json!({})), + ("layer.distribute.verticalCenters", json!({})), + ("layer.distribute.bottomEdges", json!({})), + ("edit.definePattern", json!({})), + ("layer.layerMask.delete", json!({"_atnEvent":"RmvL"})), + ("layer.mergeLayers", json!({"_atnEvent":"MrgL"})), ("filter.blur.gaussianBlur", json!({"radius": 3.5})), ("filter.sharpen.unsharpMask", json!({"amount": 120, "radius": 1.5, "threshold": 3})), ("filter.noise.addNoise", json!({"amount": 12.5, "distribution": "gaussian", "monochromatic": true, "seed": 7})), @@ -2891,7 +2908,7 @@ fn recorded_layer_select_exports_to_atn() { } #[test] -fn select_rect_mode_strict_export_rejects_unrepresentable_modes() { +fn select_rect_modes_export_without_becoming_replace() { let mut s = Session::new(); s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); @@ -2900,22 +2917,23 @@ fn select_rect_mode_strict_export_rejects_unrepresentable_modes() { let saved = s.execute("actions.save", json!({"action": "ReplaceAct"})).unwrap(); assert_eq!(saved["steps"], 1); - // "add" mode cannot be silently converted to replace (set) in strict export - s.actions.list.push(Action::new("AddAct", vec![("select.rect".into(), json!({"x": 4, "y": 4, "width": 2, "height": 2, "mode": "add"}))])); - let before_actions = s.actions.list.clone(); - let err = s.execute("actions.save", json!({"action": "AddAct"})); - assert!(err.is_err(), "strict export must reject select.rect with add mode rather than converting to replace"); - assert_eq!(s.actions.list, before_actions, "actions list must remain unchanged after rejected save"); + for (mode, event) in [("add", "AddT"), ("subtract", "SbtF"), ("intersect", "IntW")] { + s.actions.list.push(Action::new(mode, vec![("select.rect".into(), json!({"x":4,"y":4,"width":2,"height":2,"mode":mode}))])); + let before = s.actions.list.clone(); + let saved = s.execute("actions.save", json!({"action":mode})).unwrap(); + let bytes = photocraft_paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap(); + assert_eq!(atn::parse(&bytes).unwrap().actions[0].items[0].event.id(), event); + assert_eq!(import(&bytes).unwrap().actions[0].steps[0].1["mode"], mode); + assert_eq!(s.actions.list, before); + } - // "subtract" mode cannot be silently converted to replace either - s.actions.list.push(Action::new("SubtractAct", vec![("select.rect".into(), json!({"x": 4, "y": 4, "width": 2, "height": 2, "mode": "subtract"}))])); - let before_actions_sub = s.actions.list.clone(); - let err_subtract = s.execute("actions.save", json!({"action": "SubtractAct"})); - assert!(err_subtract.is_err(), "strict export must reject select.rect with subtract mode rather than converting to replace"); - assert_eq!(s.actions.list, before_actions_sub, "actions list must remain unchanged after rejected save"); + s.actions.list.push(Action::new("Unsupported", vec![("select.rect".into(), json!({"x":4,"y":4,"width":2,"height":2,"mode":"xor"}))])); + let before = s.actions.list.clone(); + assert!(s.execute("actions.save", json!({"action":"Unsupported"})).is_err()); + assert_eq!(s.actions.list, before, "rejected export must leave actions unchanged"); // With skipUnsupported: true, unrepresentable modes are reported in skipped - let skipped = s.execute("actions.save", json!({"action": "AddAct", "skipUnsupported": true})).unwrap(); + let skipped = s.execute("actions.save", json!({"action": "Unsupported", "skipUnsupported": true})).unwrap(); assert_eq!(skipped["steps"], 0); assert_eq!(skipped["skipped"][0]["id"], "select.rect"); } diff --git a/docs/atn.md b/docs/atn.md index 0d280e147c..99bdc44fae 100644 --- a/docs/atn.md +++ b/docs/atn.md @@ -16,10 +16,21 @@ so playback skips them. This page has two parts: the events PhotoCraft maps today, and a measured reference for the events it does not map yet, with everything needed to implement them without the reference application. -## Supported events (117) +## Supported events (128) | Code | String ID | Label | PhotoCraft command(s) | |---|---|---|---| +| `AddT` | addTo | Add to Selection | `select.rect` (add rectangle or ellipse) | +| `SbtF` | subtractFrom | Subtract from Selection | `select.rect` (subtract rectangle) | +| `IntW` | interfaceWhite | Intersect with Selection | `select.rect` (intersect rectangle) | +| `PstI` | pasteInto | Paste Into | `edit.pasteSpecial.pasteInto` | +| `PstO` | pasteOutside | Paste Outside | `edit.pasteSpecial.pasteOutside` | +| `Fade` | fade | Fade | `edit.fade` | +| (string ID only) | linkSelectedLayers | Link Layers | `layer.linkLayers` | +| `Dstr` | distort | Distribute | `layer.distribute.leftEdges`, `layer.distribute.horizontalCenters`, `layer.distribute.rightEdges`, `layer.distribute.topEdges`, `layer.distribute.verticalCenters`, `layer.distribute.bottomEdges` | +| `RmvL` | removeLayerMask | Remove Layer Mask (older files) | `layer.layerMask.delete` | +| `DfnP` | definePattern | Define Pattern | `edit.definePattern` | +| `MrgL` | (code only) | Merge Layers (older files) | `layer.mergeLayers` | | `AdNs` | addNoise | Add Noise | `filter.noise.addNoise` | | `Algn` | align | Align | `layer.align.leftEdges`, `layer.align.horizontalCenters`, `layer.align.rightEdges`, `layer.align.topEdges`, `layer.align.verticalCenters`, `layer.align.bottomEdges` | | `AppI` | applyImageEvent | Apply Image | `image.applyImage` | @@ -27,19 +38,19 @@ does not map yet, with everything needed to implement them without the reference | (string ID only) | applyLocking | Apply Locking | `layer.lockLayers` | | (string ID only) | assignProfile | Assign Profile | `edit.assignProfile` | | (string ID only) | autoColor | Auto Color | `image.autoColor` | -| (string ID only) | BanW | blackAndWhite | `Black & White` | +| (string ID only) | blackAndWhite | Black & White | `image.adjustments.blackWhite` | | `Blr ` | blur | Blur | `filter.blur.blur` | | `BlrM` | blurMore | Blur More | `filter.blur.blurMore` | | `Brdr` | border | Border | `select.modify.border` | | (string ID only) | boxblur | Box Blur | `filter.blur.boxBlur` | -| `BrgC` | BrgC | brightnessEvent | `Brightness/Contrast` | +| `BrgC` | brightnessEvent | Brightness/Contrast | `image.adjustments.brightnessContrast` | | `CnvS` | canvasSize | Canvas Size | `image.canvasSize` | -| `ChnM` | ChnM | channelMixer | `Channel Mixer` | +| `ChnM` | channelMixer | Channel Mixer | `image.adjustments.channelMixer` | | (string ID only) | clearEvent | Clear | `select.editInQuickMaskMode`, `history.clear` | | `Cls ` | close | Close | `file.close` | | (string ID only) | collapseAllGroupsEvent | Collapse All Groups | `layer.setExpanded` | | `ClrR` | colorRange | Color Range | `select.colorRange` | -| `ClrB` | ClrB | colorBalance | `Color Balance` | +| `ClrB` | colorBalance | Color Balance | `image.adjustments.colorBalance` | | `Cntc` | contract | Contract | `select.modify.contract` | | `CnvM` | convertMode | Convert Mode | `image.mode.rgb`, `image.mode.grayscale`, `image.mode.cmyk`, `image.mode.lab`, `image.mode.multichannel`, `image.mode.bits8`, `image.mode.bits16`, `image.mode.bits32`, `image.mode.bitmap` | | (string ID only) | convertToProfile | Convert to Profile | `edit.convertToProfile` | @@ -50,7 +61,7 @@ does not map yet, with everything needed to implement them without the reference | `GrpL` | groupEvent | Create Clipping Mask | `layer.createClippingMask` | | `Crop` | crop | Crop | `image.crop` | | `Crst` | crystallize | Crystallize | `filter.pixelate.crystallize` | -| `Crvs` | Crvs | curves | `Curves` | +| `Crvs` | curves | Curves | `image.adjustments.curves` | | `cut ` | cut | Cut | `edit.cut` | | `Dfrg` | defringe | Defringe | `layer.matting.defringe` | | `Dlt ` | delete | Delete | `layer.delete`, `edit.clear`, `layer.layerMask.delete`, `layer.smartFilter.deleteFilterMask` | @@ -66,27 +77,27 @@ does not map yet, with everything needed to implement them without the reference | `Exch` | exchange | Exchange | `tools.swapColors` | | `Expn` | expand | Expand | `select.modify.expand` | | `Expr` | export | Export | `file.export.saveForWebLegacy` | -| `Exps` | Exps | exposure | `Exposure` | +| `Exps` | exposure | Exposure | `image.adjustments.exposure` | | `Fthr` | feather | Feather | `select.modify.feather` | | `Fl ` | fill | Fill | `edit.fill`, `paint.bucket` | | `GEfc` | filterGallery | Filter Gallery | `filter.gallery.spatter` | | `FltI` | flattenImage | Flatten Image | `layer.flattenImage` | | `Flip` | flip | Flip | `image.imageRotation.flipCanvasHorizontal`, `image.imageRotation.flipCanvasVertical`, `edit.transform.flipHorizontal`, `edit.transform.flipVertical` | | `GsnB` | gaussianBlur | Gaussian Blur | `filter.blur.gaussianBlur` | -| `GdMp` | GdMp | gradientMapEvent | `Gradient Map` | +| `GdMp` | gradientMapEvent | Gradient Map | `image.adjustments.gradientMap` | | `Grow` | grow | Grow | `select.grow` | | `Hd ` | hide | Hide | `layer.hideLayers` | | `HghP` | highPass | High Pass | `filter.other.highPass` | -| `HStr` | HStr | hueSaturation | `Hue/Saturation` | +| `HStr` | hueSaturation | Hue/Saturation | `image.adjustments.hueSaturation` | | (string ID only) | conditional | If | `actions.conditional` | | `ImgS` | imageSize | Image Size | `image.imageSize` | | `Invs` | inverse | Inverse | `select.inverse` | -| `Invr` | Invr | invert | `Invert` | +| `Invr` | invert | Invert | `image.adjustments.invert` | | `CpTL` | copyToLayer | Layer Via Copy | `layer.new.layerViaCopy` | | `CtTL` | cutToLayer | Layer Via Cut | `layer.new.layerViaCut` | | `Lvls` | levels | Levels | `image.adjustments.levels`, `image.autoTone`, `image.autoContrast` | | `Mk ` | make | Make | `layer.new.layer`, `layer.new.group`, `layer.groupLayers`, `layer.duplicate`, `layer.new.layerViaCopy`, `layer.new.layerViaCut`, `layer.newFillLayer.solidColor`, `layer.newFillLayer.pattern`, `layer.newAdjustmentLayer.levels`, `layer.newAdjustmentLayer.curves`, `layer.newAdjustmentLayer.hueSaturation`, `layer.newAdjustmentLayer.brightnessContrast`, `layer.newAdjustmentLayer.threshold`, `layer.newAdjustmentLayer.posterize`, `layer.newAdjustmentLayer.invert`, `layer.newAdjustmentLayer.colorBalance`, `layer.newAdjustmentLayer.vibrance`, `layer.newAdjustmentLayer.exposure`, `layer.newAdjustmentLayer.photoFilter`, `layer.newAdjustmentLayer.blackWhite`, `layer.newAdjustmentLayer.channelMixer`, `layer.newAdjustmentLayer.selectiveColor`, `layer.newAdjustmentLayer.gradientMap`, `layer.layerMask.revealAll`, `layer.layerMask.hideAll`, `layer.layerMask.revealSelection`, `layer.layerMask.hideSelection`, `file.new`, `file.newFromClipboard`, `history.newSnapshot` | -| (string ID only) | animationFramesFromLayers | Make Frames from Layers | | +| (string ID only) | animationFramesFromLayers | Make Frames from Layers | Kept, disabled (no frame-animation command) | | `Mxm ` | maximum | Maximum | `filter.other.maximum` | | `Mdn ` | median | Median | `filter.noise.median` | | `Mrg2` | mergeLayersNew | Merge Layers | `layer.mergeLayers` | @@ -98,10 +109,10 @@ does not map yet, with everything needed to implement them without the reference | `Opn ` | open | Open | `file.openAs` | | `past` | paste | Paste | `edit.paste`, `edit.pasteSpecial.pasteInPlace` | | (string ID only) | pasteEffects | Paste Layer Style | `layer.layerStyle.pasteLayerStyle` | -| (string ID only) | PhtF | photoFilter | `Photo Filter` | +| (string ID only) | photoFilter | Photo Filter | `image.adjustments.photoFilter` | | `Plc ` | placeEvent | Place | `file.placeEmbedded`, `file.placeLinked` | | `Ply ` | play | Play | `actions.play` | -| `Pstr` | Pstr | posterize | `Posterize` | +| `Pstr` | posterize | Posterize | `image.adjustments.posterize` | | `Prge` | purge | Purge | `edit.purge.all`, `edit.purge.clipboard`, `edit.purge.histories`, `edit.purge.undo` | | `RdlB` | radialBlur | Radial Blur | `filter.blur.radialBlur` | | (string ID only) | rasterizeLayer | Rasterize | `layer.rasterize.layer` | @@ -117,7 +128,7 @@ does not map yet, with everything needed to implement them without the reference | (string ID only) | refineSelectionEdge | Select and Mask | `select.refineEdge` | | (string ID only) | smartBrushWorkspace | Select and Mask | `select.refineEdge` | | (string ID only) | autoCutout | Select Subject | `select.subject` | -| `SlcC` | SlcC | selectiveColor | `Selective Color` | +| `SlcC` | selectiveColor | Selective Color | `image.adjustments.selectiveColor` | | `setd` | set | Set | `select.all`, `select.deselect`, `select.reselect`, `select.loadSelection`, `select.rect`, `select.lasso`, `select.editInQuickMaskMode`, `tools.setColors`, `layer.setProps`, `layer.renameLayer`, `layer.setLabelColor`, `layer.setAdjustment`, `layer.fill.pattern.set`, `layer.new.layerFromBackground`, `layer.layerStyle.blendingOptions`, `layer.smartFilter.blendingOptions` | | (string ID only) | adaptCorrect | Shadows/Highlights | `image.adjustments.shadowsHighlights` | | `Shrp` | sharpen | Sharpen | `filter.sharpen.sharpen` | @@ -127,16 +138,181 @@ does not map yet, with everything needed to implement them without the reference | `Smlr` | similar | Similar | `select.similar` | | `SmrB` | smartBlur | Smart Blur | `filter.blur.smartBlur` | | `Smth` | smoothness | Smooth | `select.modify.smooth` | -| `Stop` | stop | Stop | `STOP_MESSAGE` | +| `Stop` | stop | Stop | `actions.stopMessage` | | `Strk` | stroke | Stroke | `edit.stroke` | | (string ID only) | surfaceBlur | Surface Blur | `filter.blur.surfaceBlur` | -| `Thrs` | Thrs | threshold | `Threshold` | +| `Thrs` | threshold | Threshold | `image.adjustments.threshold` | | `Trnf` | transform | Transform | `edit.transform`, `select.transformSelection` | | (string ID only) | trim | Trim | `image.trim` | | (string ID only) | ungroupLayersEvent | Ungroup Layers | `layer.ungroupLayers` | | `UnsM` | unsharpMask | Unsharp Mask | `filter.sharpen.unsharpMask` | | (string ID only) | updatePlacedLayer | Update Smart Objects | `layer.smartObjects.updateModifiedContent` | -| `Vbrn` | Vbrn | vibrance | `Vibrance` | +| `Vbrn` | vibrance | Vibrance | `image.adjustments.vibrance` | + +### Group F: Selection combinations, Paste Into/Outside, Fade, Link, Distribute, Define Pattern and older merge/mask events + +Steps recorded in an ordinary editing session. The values below were measured in a document set up for each step (a selection, the clipboard, linked or several selected layers). Align, Copy/Paste Layer Style and Rename are already mapped under their current events and are not repeated here. + +#### Add to Selection (rectangle) + +- Event: `AddT` / `addTo` +- PhotoCraft command: `select.rect {"mode":"add"}` +- Shift-drag with the Rectangular Marquee. +- Measured with example values in a prepared document: the Value column shows what was sent and recorded, not a factory default (a step always carries the values the user set). + +| Key | String ID | Type | Unit | Example value | Allowed | +|---|---|---|---|---|---| +| `null` | null | reference | | ref 'Chnl' channel prop 'fsel' selection | | +| `T ` | to | object | | obj 'Rctn' rectangle | | + +Nested values as recorded (example values): +``` + 'Top ' top : UNITDOUBLE = 30 '#Pxl' pixelsUnit + 'Left' left : UNITDOUBLE = 30 '#Pxl' pixelsUnit + 'Btom' bottom : UNITDOUBLE = 90 '#Pxl' pixelsUnit + 'Rght' right : UNITDOUBLE = 90 '#Pxl' pixelsUnit +``` + +#### Add to Selection (ellipse) + +- Event: `AddT` / `addTo` +- PhotoCraft command: `select.rect {"mode":"add","ellipse":true}` +- Same event with an `Elps` target and anti-aliasing. +- Measured with example values in a prepared document: the Value column shows what was sent and recorded, not a factory default (a step always carries the values the user set). + +| Key | String ID | Type | Unit | Example value | Allowed | +|---|---|---|---|---|---| +| `null` | null | reference | | ref 'Chnl' channel prop 'fsel' selection | | +| `T ` | to | object | | obj 'Elps' ellipse | | +| `AntA` | antiAlias | boolean | | true | | + +Nested values as recorded (example values): +``` + 'Top ' top : UNITDOUBLE = 30 '#Pxl' pixelsUnit + 'Left' left : UNITDOUBLE = 30 '#Pxl' pixelsUnit + 'Btom' bottom : UNITDOUBLE = 90 '#Pxl' pixelsUnit + 'Rght' right : UNITDOUBLE = 90 '#Pxl' pixelsUnit +``` + +#### Subtract from Selection + +- Event: `SbtF` / `subtractFrom` +- PhotoCraft command: `select.rect {"mode":"subtract"}` +- Measured with example values in a prepared document: the Value column shows what was sent and recorded, not a factory default (a step always carries the values the user set). + +| Key | String ID | Type | Unit | Example value | Allowed | +|---|---|---|---|---|---| +| `null` | null | reference | | ref 'Chnl' channel prop 'fsel' selection | | +| `T ` | to | object | | obj 'Rctn' rectangle | | + +Nested values as recorded (example values): +``` + 'Top ' top : UNITDOUBLE = 30 '#Pxl' pixelsUnit + 'Left' left : UNITDOUBLE = 30 '#Pxl' pixelsUnit + 'Btom' bottom : UNITDOUBLE = 60 '#Pxl' pixelsUnit + 'Rght' right : UNITDOUBLE = 60 '#Pxl' pixelsUnit +``` + +#### Intersect with Selection + +- Event: `IntW` / `interfaceWhite` +- PhotoCraft command: `select.rect {"mode":"intersect"}` +- Measured with example values in a prepared document: the Value column shows what was sent and recorded, not a factory default (a step always carries the values the user set). + +| Key | String ID | Type | Unit | Example value | Allowed | +|---|---|---|---|---|---| +| `null` | null | reference | | ref 'Chnl' channel prop 'fsel' selection | | +| `T ` | to | object | | obj 'Rctn' rectangle | | + +Nested values as recorded (example values): +``` + 'Top ' top : UNITDOUBLE = 30 '#Pxl' pixelsUnit + 'Left' left : UNITDOUBLE = 30 '#Pxl' pixelsUnit + 'Btom' bottom : UNITDOUBLE = 120 '#Pxl' pixelsUnit + 'Rght' right : UNITDOUBLE = 120 '#Pxl' pixelsUnit +``` + +#### Paste Into + +- Event: `PstI` / `pasteInto` +- PhotoCraft command: `edit.pasteSpecial.pasteInto` +- Measured with example values in a prepared document: the Value column shows what was sent and recorded, not a factory default (a step always carries the values the user set). + +| Key | String ID | Type | Unit | Example value | Allowed | +|---|---|---|---|---|---| +| `AntA` | antiAlias | enumerated | | `Anno` antiAliasNone | not measured yet | +| `As ` | as | class | | class 'Pxel' pixel | | + +#### Paste Outside + +- Event: `PstO` / `pasteOutside` +- PhotoCraft command: `edit.pasteSpecial.pasteOutside` +- Measured with example values in a prepared document: the Value column shows what was sent and recorded, not a factory default (a step always carries the values the user set). + +| Key | String ID | Type | Unit | Example value | Allowed | +|---|---|---|---|---|---| +| `AntA` | antiAlias | enumerated | | `Anno` antiAliasNone | not measured yet | +| `As ` | as | class | | class 'Pxel' pixel | | + +#### Fade + +- Event: `Fade` / `fade` +- PhotoCraft command: `edit.fade` +- Recorded right after the step it fades. +- Measured with example values in a prepared document: the Value column shows what was sent and recorded, not a factory default (a step always carries the values the user set). + +| Key | String ID | Type | Unit | Example value | Allowed | +|---|---|---|---|---|---| +| `Opct` | opacity | unitdouble | `#Prc` | 50 | 0 .. 100 (out-of-range values are clamped) | +| `Md ` | mode | enumerated | | `Mltp` multiply | not measured yet | + +#### Link Layers + +- Event: `` / `linkSelectedLayers` +- PhotoCraft command: `layer.linkLayers` +- Measured with example values in a prepared document: the Value column shows what was sent and recorded, not a factory default (a step always carries the values the user set). + +| Key | String ID | Type | Unit | Example value | Allowed | +|---|---|---|---|---|---| +| `null` | null | reference | | ref 'Lyr ' layer enum 'Ordn' ordinal.'Trgt' targetEnum | | + +#### Distribute + +- Event: `Dstr` / `distort` +- PhotoCraft command: `layer.distribute.leftEdges (one command per edge)` +- `Usng` selects the edge or centre; one enum value per Distribute menu item. +- Measured with example values in a prepared document: the Value column shows what was sent and recorded, not a factory default (a step always carries the values the user set). + +| Key | String ID | Type | Unit | Example value | Allowed | +|---|---|---|---|---|---| +| `null` | null | reference | | ref 'Lyr ' layer enum 'Ordn' ordinal.'Trgt' targetEnum | | +| `Usng` | using | enumerated | | `AdLf` ADSLefts | not measured yet | + +#### Remove Layer Mask (older files) + +- Event: `RmvL` / `removeLayerMask` +- PhotoCraft command: `layer.layerMask.delete` +- The reference application asks "Apply mask before deleting?" when this step plays without the apply choice; see the table for the key it records. +- Measured with example values in a prepared document: the Value column shows what was sent and recorded, not a factory default (a step always carries the values the user set). + +No parameters: the step carries an empty descriptor. + +#### Define Pattern + +- Event: `DfnP` / `definePattern` +- PhotoCraft command: `edit.definePattern` + +No parameters: the step carries an empty descriptor. + +#### Merge Layers (older files) + +- Event: `MrgL` (no string ID) +- PhotoCraft command: `layer.mergeLayers` + +No parameters: the step carries an empty descriptor. + +The prepared-document values in this group are required inputs, rather than factory defaults. Unknown settings stay kept steps. Paste uses the current clipboard and selection; Fade uses the preceding edit. Link Layers uses the existing selected-layer toggle. Native exports retain their current Delete Mask and Merge Layers events; imported older events retain their original IDs. + ## Event reference: events not mapped yet @@ -1115,168 +1291,6 @@ Nested values as recorded (example values): 'B ' b : DOUBLETYPE = 40 ``` -### Group F: Selection combinations, Paste Into/Outside, Fade, Link, Distribute, Define Pattern and older merge/mask events - -Steps recorded in an ordinary editing session. The values below were measured in a document set up for each step (a selection, the clipboard, linked or several selected layers). Align, Copy/Paste Layer Style and Rename are already mapped under their current events and are not repeated here. - -#### Add to Selection (rectangle) - -- Event: `AddT` / `addTo` -- PhotoCraft command: `select.rect {"mode":"add"}` -- Shift-drag with the Rectangular Marquee. -- Measured with example values in a prepared document: the Value column shows what was sent and recorded, not a factory default (a step always carries the values the user set). - -| Key | String ID | Type | Unit | Example value | Allowed | -|---|---|---|---|---|---| -| `null` | null | reference | | ref 'Chnl' channel prop 'fsel' selection | | -| `T ` | to | object | | obj 'Rctn' rectangle | | - -Nested values as recorded (example values): -``` - 'Top ' top : UNITDOUBLE = 30 '#Pxl' pixelsUnit - 'Left' left : UNITDOUBLE = 30 '#Pxl' pixelsUnit - 'Btom' bottom : UNITDOUBLE = 90 '#Pxl' pixelsUnit - 'Rght' right : UNITDOUBLE = 90 '#Pxl' pixelsUnit -``` - -#### Add to Selection (ellipse) - -- Event: `AddT` / `addTo` -- PhotoCraft command: `select.rect {"mode":"add","ellipse":true}` -- Same event with an `Elps` target and anti-aliasing. -- Measured with example values in a prepared document: the Value column shows what was sent and recorded, not a factory default (a step always carries the values the user set). - -| Key | String ID | Type | Unit | Example value | Allowed | -|---|---|---|---|---|---| -| `null` | null | reference | | ref 'Chnl' channel prop 'fsel' selection | | -| `T ` | to | object | | obj 'Elps' ellipse | | -| `AntA` | antiAlias | boolean | | true | | - -Nested values as recorded (example values): -``` - 'Top ' top : UNITDOUBLE = 30 '#Pxl' pixelsUnit - 'Left' left : UNITDOUBLE = 30 '#Pxl' pixelsUnit - 'Btom' bottom : UNITDOUBLE = 90 '#Pxl' pixelsUnit - 'Rght' right : UNITDOUBLE = 90 '#Pxl' pixelsUnit -``` - -#### Subtract from Selection - -- Event: `SbtF` / `subtractFrom` -- PhotoCraft command: `select.rect {"mode":"subtract"}` -- Measured with example values in a prepared document: the Value column shows what was sent and recorded, not a factory default (a step always carries the values the user set). - -| Key | String ID | Type | Unit | Example value | Allowed | -|---|---|---|---|---|---| -| `null` | null | reference | | ref 'Chnl' channel prop 'fsel' selection | | -| `T ` | to | object | | obj 'Rctn' rectangle | | - -Nested values as recorded (example values): -``` - 'Top ' top : UNITDOUBLE = 30 '#Pxl' pixelsUnit - 'Left' left : UNITDOUBLE = 30 '#Pxl' pixelsUnit - 'Btom' bottom : UNITDOUBLE = 60 '#Pxl' pixelsUnit - 'Rght' right : UNITDOUBLE = 60 '#Pxl' pixelsUnit -``` - -#### Intersect with Selection - -- Event: `IntW` / `interfaceWhite` -- PhotoCraft command: `select.rect {"mode":"intersect"}` -- Measured with example values in a prepared document: the Value column shows what was sent and recorded, not a factory default (a step always carries the values the user set). - -| Key | String ID | Type | Unit | Example value | Allowed | -|---|---|---|---|---|---| -| `null` | null | reference | | ref 'Chnl' channel prop 'fsel' selection | | -| `T ` | to | object | | obj 'Rctn' rectangle | | - -Nested values as recorded (example values): -``` - 'Top ' top : UNITDOUBLE = 30 '#Pxl' pixelsUnit - 'Left' left : UNITDOUBLE = 30 '#Pxl' pixelsUnit - 'Btom' bottom : UNITDOUBLE = 120 '#Pxl' pixelsUnit - 'Rght' right : UNITDOUBLE = 120 '#Pxl' pixelsUnit -``` - -#### Paste Into - -- Event: `PstI` / `pasteInto` -- PhotoCraft command: `edit.pasteSpecial.pasteInto` -- Measured with example values in a prepared document: the Value column shows what was sent and recorded, not a factory default (a step always carries the values the user set). - -| Key | String ID | Type | Unit | Example value | Allowed | -|---|---|---|---|---|---| -| `AntA` | antiAlias | enumerated | | `Anno` antiAliasNone | not measured yet | -| `As ` | as | class | | class 'Pxel' pixel | | - -#### Paste Outside - -- Event: `PstO` / `pasteOutside` -- PhotoCraft command: `edit.pasteSpecial.pasteOutside` -- Measured with example values in a prepared document: the Value column shows what was sent and recorded, not a factory default (a step always carries the values the user set). - -| Key | String ID | Type | Unit | Example value | Allowed | -|---|---|---|---|---|---| -| `AntA` | antiAlias | enumerated | | `Anno` antiAliasNone | not measured yet | -| `As ` | as | class | | class 'Pxel' pixel | | - -#### Fade - -- Event: `Fade` / `fade` -- PhotoCraft command: `edit.fade` -- Recorded right after the step it fades. -- Measured with example values in a prepared document: the Value column shows what was sent and recorded, not a factory default (a step always carries the values the user set). - -| Key | String ID | Type | Unit | Example value | Allowed | -|---|---|---|---|---|---| -| `Opct` | opacity | unitdouble | `#Prc` | 50 | 0 .. 100 (out-of-range values are clamped) | -| `Md ` | mode | enumerated | | `Mltp` multiply | not measured yet | - -#### Link Layers - -- Event: `` / `linkSelectedLayers` -- PhotoCraft command: `layer.linkLayers` -- Measured with example values in a prepared document: the Value column shows what was sent and recorded, not a factory default (a step always carries the values the user set). - -| Key | String ID | Type | Unit | Example value | Allowed | -|---|---|---|---|---|---| -| `null` | null | reference | | ref 'Lyr ' layer enum 'Ordn' ordinal.'Trgt' targetEnum | | - -#### Distribute - -- Event: `Dstr` / `distort` -- PhotoCraft command: `layer.distribute.leftEdges (one command per edge)` -- `Usng` selects the edge or centre; one enum value per Distribute menu item. -- Measured with example values in a prepared document: the Value column shows what was sent and recorded, not a factory default (a step always carries the values the user set). - -| Key | String ID | Type | Unit | Example value | Allowed | -|---|---|---|---|---|---| -| `null` | null | reference | | ref 'Lyr ' layer enum 'Ordn' ordinal.'Trgt' targetEnum | | -| `Usng` | using | enumerated | | `AdLf` ADSLefts | not measured yet | - -#### Remove Layer Mask (older files) - -- Event: `RmvL` / `removeLayerMask` -- PhotoCraft command: `layer.layerMask.delete` -- The reference application asks "Apply mask before deleting?" when this step plays without the apply choice; see the table for the key it records. -- Measured with example values in a prepared document: the Value column shows what was sent and recorded, not a factory default (a step always carries the values the user set). - -No parameters: the step carries an empty descriptor. - -#### Define Pattern - -- Event: `DfnP` / `definePattern` -- PhotoCraft command: `edit.definePattern` - -No parameters: the step carries an empty descriptor. - -#### Merge Layers (older files) - -- Event: `MrgL` (no string ID) -- PhotoCraft command: `layer.mergeLayers` - -No parameters: the step carries an empty descriptor. - ### Group G: Split Channels, Merge Spot Channel, Trap and Match Color Channel and colour steps. Trap was measured in a CMYK document (it only exists there). diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 1fc994a94e..b91880b173 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-11-01 · **Change:** minor (`.atn`: event reference [atn.md](atn.md) with the supported events and the measured keys, defaults, ranges and enum values of 101 unmapped events; TIFFs up to 300,000 px a side open, decoded a group of rows at a time into the layer, #8169; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; PSD Filter Gallery: Halftone Pattern dot/circle/line settings survive PSD/PSB saves (#8940); Save for Web writes WebP, lossy or lossless, #6391; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency, #8877; layered TIFF writes the TySh text version and descriptor in PSD big-endian order while the header, matrix, warp and bounds keep container order, #9098; layered TIFF writes Photoshop's 14-byte global layer mask, so Photoshop opens the layers instead of only the composite, #9095; legacy PSD point text placed from the layout ink box after font resolution, #6094; `.atn`: 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; `.asl` layer style libraries read and written, #1051; JPEG Options on File › Save, Save As and Save a Copy, FILE-215-19 partial (no Matte, progressive Scans or size preview); 32-bit Hue/Saturation unclipped and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; JPEG Options on Save As and Save a Copy, FILE-215-19; TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; Cineon write: 10-bit gray/RGB; animated WebP frames load into video layers, #1198; Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 53 filters (39 more: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag, Invert, Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding; Amiga IFF write: 24-plane ILBM, Save As `.iff`; PSD stores spot channels as Photoshop does, ink 0 = solid, and numbers them in 1053, #7415 split; TIFF writes and reads alpha and spot channels as extra samples with Photoshop's channel resources, PSD output unchanged, FILE-215-7, FILE-215-17's TIFF part, #7415; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-31 · **Change:** minor (`.atn`: event reference [atn.md](atn.md) with 128 supported events and the measured keys, defaults, ranges and enum values of 89 unmapped reference entries; TIFFs up to 300,000 px a side open, decoded a group of rows at a time into the layer, #8169; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; PSD Filter Gallery: Halftone Pattern dot/circle/line settings survive PSD/PSB saves (#8940); Save for Web writes WebP, lossy or lossless, #6391; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency, #8877; layered TIFF writes the TySh text version and descriptor in PSD big-endian order while the header, matrix, warp and bounds keep container order, #9098; layered TIFF writes Photoshop's 14-byte global layer mask, so Photoshop opens the layers instead of only the composite, #9095; legacy PSD point text placed from the layout ink box after font resolution, #6094; `.atn`: 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; `.asl` layer style libraries read and written, #1051; JPEG Options on File › Save, Save As and Save a Copy, FILE-215-19 partial (no Matte, progressive Scans or size preview); 32-bit Hue/Saturation unclipped and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; JPEG Options on Save As and Save a Copy, FILE-215-19; TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; Cineon write: 10-bit gray/RGB; animated WebP frames load into video layers, #1198; Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 53 filters (39 more: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag, Invert, Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding; Amiga IFF write: 24-plane ILBM, Save As `.iff`; PSD stores spot channels as Photoshop does, ink 0 = solid, and numbers them in 1053, #7415 split; TIFF writes and reads alpha and spot channels as extra samples with Photoshop's channel resources, PSD output unchanged, FILE-215-7, FILE-215-17's TIFF part, #7415; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency) · **Target:** Adobe Photoshop 2026 **PSD Filter Gallery (#8940, 2026-10-23):** Halftone Pattern maps its size, contrast and dot/circle/line pattern as editable settings on import and export, with the reference application's descriptor keys (`HlSz`, `Cntr`, `ScrT`: `ScrD`/`ScrC`/`ScrL`). PSD/PSB round trips pass at 8/16/32 bits. This does not claim pixel equivalence for the existing renderer. Unknown or malformed settings remain verbatim on import. @@ -147,6 +147,7 @@ Embedded SVG and PDF-compatible AI/PDF/EPS payloads can use an explicit VectorCr ## Revision history | Date | Change | Summary | +| 2026-10-31 | minor | `.atn` Group F: selection combinations, Paste Into/Outside, Fade, Link, Distribute, Define Pattern and older mask/merge events load, play and save; 128 supported event IDs, 89 unmapped reference entries; AUTO-219-2 remains partial | |---|---|---| | 2026-11-01 | minor | `.atn`: event reference [atn.md](atn.md): the 117 supported events, and measured keys, defaults, ranges and enum values for 101 events not mapped yet | | 2026-10-26 | minor | Embedded AI/PDF/EPS save-back retains native format and a separate source-bound display preview; original sources remain undoable. Native Adobe interoperability and standalone EPS/DCS remain unverified; no corpus floor or percentage changed. | diff --git a/docs/gaps.md b/docs/gaps.md index 2537355b5b..77cd674d79 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -111,6 +111,7 @@ what an agent can't finish alone. - **Estimate:** 120–200 h. ### G11. Actions and scripting +- **2026-10-31 evidence (#10608):** Group F selection, paste, Fade, Link, Distribute, pattern and older mask/merge events load, play and save through existing commands. Unsupported settings remain kept; AUTO-219-2 remains partial. - **2026-10-21 evidence (#8304):** Save Actions expands compound layer-property edits into separate Photoshop Set events, with rename last so named layer references remain valid. Exported descriptors and native playback at RGB 8/16/32-bit have [regression coverage](../crates/engine/src/atn_cmds/layer_props_tests.rs); @@ -423,6 +424,7 @@ what an agent can't finish alone. ## Revision history | Date | Change | Summary | +| 2026-10-31 | minor | G11: Group F action steps map to existing commands; 128 supported event IDs; action-file support remains partial; estimates unchanged | |---|---|---| | 2026-10-27 | minor | Smooth/Feather command reads and replacement use selection bounds plus blur reach; full-canvas reference and release comparisons extended; percentages and scorecard budgets unchanged | | 2026-10-27 | minor | G9: 12 of 181 preferences do nothing, the scorecard's count with integration batch 9 (#9775) on main; no estimate changed | From 0c508e558fbb029c59da812e17d4ef006aa9f92d Mon Sep 17 00:00:00 2001 From: yoriswind Date: Sat, 31 Oct 2026 20:03:50 -0700 Subject: [PATCH 032/216] Use a supported assertion in the selection fixture test --- crates/engine/src/cmd/atn_cmds/selection_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/atn_cmds/selection_tests.rs b/crates/engine/src/cmd/atn_cmds/selection_tests.rs index 8e207b0255..8d851ce987 100644 --- a/crates/engine/src/cmd/atn_cmds/selection_tests.rs +++ b/crates/engine/src/cmd/atn_cmds/selection_tests.rs @@ -116,7 +116,7 @@ fn group_f_unsupported_settings_are_not_silently_replaced() { } for key in ["Top ", "Left", "Btom", "Rght"] { let mut d = rectangle([1.0, 2.0, 3.0, 4.0], false); - let D::Descriptor(shape) = &mut d.items[1].1 else { unreachable!() }; + let D::Descriptor(shape) = &mut d.items[1].1 else { panic!("rectangle fixture must contain a shape") }; shape.items.push((code(key), px(9.0))); cases.push((*b"AddT", d)); } From a03120e8b481f8a7572e2fca0a8ccb398387eb28 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 31 Oct 2026 21:04:42 -0600 Subject: [PATCH 033/216] Actions: map Photoshop's Distort filter steps (#10605) Pinch, Polar Coordinates, Spherize, Twirl, Wave and ZigZag steps load as filter.distort.*, play, and save back with every key in recorded order. Out-of-range values are clamped as Photoshop clamps them and missing keys take Photoshop's defaults (Wave's undefined areas are Repeat Edge Pixels). A Wave scaled below 100 % stays a Photoshop step, as the command has no scale. Shear is still kept verbatim: its curve points need a step recorded through its dialog. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/atn_cmds.rs | 1 + crates/engine/src/cmd/atn_cmds/events.rs | 43 +++++ .../engine/src/cmd/atn_cmds/steps_distort.rs | 168 ++++++++++++++++++ crates/engine/src/cmd/atn_cmds/tests.rs | 115 ++++++++++++ docs/file-format-parity.md | 3 +- 5 files changed, 329 insertions(+), 1 deletion(-) create mode 100644 crates/engine/src/cmd/atn_cmds/steps_distort.rs diff --git a/crates/engine/src/cmd/atn_cmds.rs b/crates/engine/src/cmd/atn_cmds.rs index 42a4722479..e2c37009e8 100644 --- a/crates/engine/src/cmd/atn_cmds.rs +++ b/crates/engine/src/cmd/atn_cmds.rs @@ -32,6 +32,7 @@ mod descriptors; mod events; mod fields; mod files; +mod steps_distort; mod steps_doc; mod steps_image; mod steps_layer; diff --git a/crates/engine/src/cmd/atn_cmds/events.rs b/crates/engine/src/cmd/atn_cmds/events.rs index 5723c93c59..b64e33c05b 100644 --- a/crates/engine/src/cmd/atn_cmds/events.rs +++ b/crates/engine/src/cmd/atn_cmds/events.rs @@ -2,6 +2,7 @@ const DIFFUSE_MODE: &[(&str, &str)] = &[("Nrml", "normal"), ("DrkO", "darkenOnly use super::adjustments::{adjustment_by_class, adjustment_steps, levels_from, levels_to, shadows_highlights_from, shadows_highlights_to}; use super::descriptors::{desc, get_bool, get_ref, jbool, layer_ref, layer_target, ref_class}; use super::fields::{F, Kind, Mapped, fields_from, fields_to, none, step}; +use super::steps_distort::{PINCH, POLAR, SPHERIZE, TWIRL, ZIGZAG, keys_from, keys_to, wave_from, wave_to}; use super::steps_doc::{ align_from, align_to, canvas_size_from, canvas_size_to, close_from, close_to, conditional_from, conditional_to, convert_mode_from, convert_mode_to, crop_from, crop_to, export_from, export_to, image_size_from, image_size_to, merge_visible_from, merge_visible_to, open_from, open_to, paste_from, paste_to, @@ -247,6 +248,48 @@ pub(super) const EVENTS: &[Event] = &[ "filter.other.offset", &[F("Hrzn", "horizontal", Kind::Long), F("Vrtc", "vertical", Kind::Long), F("Fl ", "undefinedAreas", Kind::Enum("FlMd", OFFSET_FILL))] ), + // Distort (#10605): every key in recorded order, Photoshop's limits and defaults. + Event { + code: "Pnch", + name: "pinch", + label: "Pinch", + ids: &["filter.distort.pinch"], + from: |d| keys_from(d, "filter.distort.pinch", PINCH), + to: |_, p| keys_to(p, PINCH, &[("amount", json!(50))], &[]), + }, + Event { + code: "Plr ", + name: "polar", + label: "Polar Coordinates", + ids: &["filter.distort.polarCoordinates"], + from: |d| keys_from(d, "filter.distort.polarCoordinates", POLAR), + to: |_, p| keys_to(p, POLAR, &[("mode", json!("rectangularToPolar"))], &[]), + }, + Event { + code: "Sphr", + name: "spherize", + label: "Spherize", + ids: &["filter.distort.spherize"], + from: |d| keys_from(d, "filter.distort.spherize", SPHERIZE), + to: |_, p| keys_to(p, SPHERIZE, &[("amount", json!(100)), ("mode", json!("normal"))], &[]), + }, + Event { + code: "Twrl", + name: "twirl", + label: "Twirl", + ids: &["filter.distort.twirl"], + from: |d| keys_from(d, "filter.distort.twirl", TWIRL), + to: |_, p| keys_to(p, TWIRL, &[("angle", json!(50))], &[]), + }, + Event { code: "Wave", name: "wave", label: "Wave", ids: &["filter.distort.wave"], from: wave_from, to: wave_to }, + Event { + code: "ZgZg", + name: "zigZag", + label: "ZigZag", + ids: &["filter.distort.zigZag"], + from: |d| keys_from(d, "filter.distort.zigZag", ZIGZAG), + to: |_, p| keys_to(p, ZIGZAG, &[("amount", json!(10)), ("ridges", json!(5)), ("style", json!("pondRipples"))], &[]), + }, ev!("Rple", "ripple", "Ripple", "filter.distort.ripple", &[F("Amnt", "amount", Kind::ExactLong), F("RplS", "size", Kind::Enum("RplS", RIPPLE_SIZE))]), ev!("Crst", "crystallize", "Crystallize", "filter.pixelate.crystallize", &[F("ClSz", "cellSize", Kind::ExactLong)]), ev!("Dfs ", "diffuse", "Diffuse", "filter.stylize.diffuse", &[F("Md ", "mode", Kind::Enum("DfsM", DIFFUSE_MODE)), F("FlRs", "seed", Kind::Long)]), diff --git a/crates/engine/src/cmd/atn_cmds/steps_distort.rs b/crates/engine/src/cmd/atn_cmds/steps_distort.rs new file mode 100644 index 0000000000..b1ad578cc8 --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/steps_distort.rs @@ -0,0 +1,168 @@ +//! Photoshop's Distort filter steps (#10605): Pinch, Polar Coordinates, Spherize, Twirl, Wave and +//! ZigZag, each key in recorded order with the limits and defaults Photoshop measures. + +use super::descriptors::{desc, enm, get_enum, get_num, trimmed}; +use super::events::pixel_target; +use super::fields::{Mapped, step}; +use super::tables::{lookup, lookup_back}; +use super::{D, Descriptor, Map, Value, json}; + +/// Whether every key of `d` is one of `keys` (codes compare without their space padding). +fn only_keys(d: &Descriptor, keys: &[&str]) -> bool { + d.items.iter().all(|(k, _)| keys.contains(&trimmed(k).as_str())) +} + +/// Whether `p` sets nothing but `allowed` (a `null` counts as unset). +fn sets_only(p: &Value, allowed: &[&str]) -> bool { + p.as_object().is_none_or(|m| m.iter().all(|(k, v)| v.is_null() || allowed.contains(&k.as_str()))) +} + +/// One key of a Distort filter's step, in recorded order. +pub(super) enum Key { + /// An integer clamped to `min..=max` on import, as Photoshop clamps it; `default` when the + /// step leaves it out. + Int { key: &'static str, param: &'static str, min: i32, max: i32, default: i32 }, + /// An enumeration (`ty`, Photoshop ↔ PhotoCraft values); `default` is Photoshop's value when + /// the step leaves it out. An unknown value keeps the step. + Enum { key: &'static str, param: &'static str, ty: &'static str, table: &'static [(&'static str, &'static str)], default: &'static str }, +} + +impl Key { + fn param(&self) -> &'static str { + match self { + Key::Int { param, .. } | Key::Enum { param, .. } => param, + } + } + + fn key(&self) -> &'static str { + match self { + Key::Int { key, .. } | Key::Enum { key, .. } => key, + } + } +} + +/// A step with exactly `keys` (any missing): each value clamped or looked up, Photoshop's +/// default for what it leaves out. Any other key keeps the step. +pub(super) fn keys_from(d: &Descriptor, id: &str, keys: &[Key]) -> Mapped { + keys_map(d, keys, &[]).map_or(Mapped::Unsupported { harmless: false }, |m| step(id, Value::Object(m))) +} + +fn keys_map(d: &Descriptor, keys: &[Key], extra: &[&'static str]) -> Option> { + let known: Vec<&str> = keys.iter().map(Key::key).chain(extra.iter().copied()).map(str::trim_end).collect(); + if !only_keys(d, &known) { + return None; + } + let mut m = Map::new(); + for k in keys { + let v = match k { + Key::Int { key, min, max, default, .. } => { + Value::from(get_num(d, key).map_or(*default, |n| n.round().clamp(f64::from(*min), f64::from(*max)) as i32)) + } + Key::Enum { key, table, default, .. } => Value::from(lookup(table, &get_enum(d, key).unwrap_or_else(|| (*default).to_string()))?), + }; + m.insert(k.param().into(), v); + } + Some(m) +} + +/// Every key in recorded order, the command's default for what the step leaves out +/// (`ours`, by param). An integer key refuses a fractional value, which would replay rounded. +pub(super) fn keys_to(p: &Value, keys: &[Key], ours: &[(&str, Value)], extra: &[&'static str]) -> Option { + let params: Vec<&str> = keys.iter().map(Key::param).chain(extra.iter().copied()).chain(["target"]).collect(); + if !pixel_target(p) || !sets_only(p, ¶ms) { + return None; + } + let value = |param: &str| match p.get(param) { + None | Some(Value::Null) => ours.iter().find(|(k, _)| *k == param).map(|(_, v)| v.clone()), + Some(v) => Some(v.clone()), + }; + let mut d = desc("null"); + for k in keys { + let v = value(k.param())?; + d = match k { + Key::Int { key, .. } => { + let n = v.as_f64()?; + if n.fract() != 0.0 || !(f64::from(i32::MIN)..=f64::from(i32::MAX)).contains(&n) { + return None; + } + d.with(key, D::Integer(n as i32)) + } + Key::Enum { key, ty, table, .. } => d.with(key, enm(ty, lookup_back(table, v.as_str()?)?)), + }; + } + Some(d) +} + +pub(super) const PINCH: &[Key] = &[Key::Int { key: "Amnt", param: "amount", min: -100, max: 100, default: 50 }]; + +pub(super) const TWIRL: &[Key] = &[Key::Int { key: "Angl", param: "angle", min: -999, max: 999, default: 50 }]; + +pub(super) const POLAR: &[Key] = + &[Key::Enum { key: "Cnvr", param: "mode", ty: "Cnvr", table: &[("RctP", "rectangularToPolar"), ("PlrR", "polarToRectangular")], default: "RctP" }]; + +pub(super) const SPHERIZE: &[Key] = &[ + Key::Int { key: "Amnt", param: "amount", min: -100, max: 100, default: 100 }, + Key::Enum { key: "SphM", param: "mode", ty: "SphM", table: &[("Nrml", "normal"), ("HrzO", "horizontalOnly"), ("VrtO", "verticalOnly")], default: "Nrml" }, +]; + +pub(super) const ZIGZAG: &[Key] = &[ + Key::Int { key: "Amnt", param: "amount", min: -100, max: 100, default: 10 }, + Key::Int { key: "NmbR", param: "ridges", min: 0, max: 20, default: 5 }, + Key::Enum { + key: "ZZTy", + param: "style", + ty: "ZZTy", + table: &[("PndR", "pondRipples"), ("OtFr", "outFromCenter"), ("ArnC", "aroundCenter")], + default: "PndR", + }, +]; + +/// Wave's keys before its scale, undefined areas and seed (handled in `wave_from` / `wave_to`). +const WAVE: &[Key] = &[ + Key::Enum { key: "Wvtp", param: "type", ty: "Wvtp", table: &[("WvSn", "sine"), ("WvSq", "square"), ("WvTr", "triangle")], default: "WvSn" }, + Key::Int { key: "NmbG", param: "generators", min: 1, max: 999, default: 5 }, + Key::Int { key: "WLMn", param: "wavelengthMin", min: 1, max: 999, default: 10 }, + Key::Int { key: "WLMx", param: "wavelengthMax", min: 1, max: 999, default: 120 }, + Key::Int { key: "AmMn", param: "amplitudeMin", min: 1, max: 999, default: 5 }, + Key::Int { key: "AmMx", param: "amplitudeMax", min: 1, max: 999, default: 35 }, +]; + +const WAVE_UNDEFINED: &[(&str, &str)] = &[("RptE", "repeat"), ("WrpA", "wrap")]; + +/// Wave (`Wave`). The command has no horizontal or vertical scale, so a step that scales the +/// waves below 100 % keeps the Photoshop step. Missing undefined areas are Photoshop's Repeat +/// Edge Pixels (the command's own default is wrap); `RndS` keeps its 32 bits as the seed. +pub(super) fn wave_from(d: &Descriptor) -> Mapped { + let unsupported = Mapped::Unsupported { harmless: false }; + let Some(mut m) = keys_map(d, WAVE, &["SclH", "SclV", "UndA", "RndS"]) else { return unsupported }; + if ["SclH", "SclV"].iter().any(|k| get_num(d, k).is_some_and(|n| n.round().clamp(1.0, 100.0) != 100.0)) { + return unsupported; + } + let Some(undefined) = lookup(WAVE_UNDEFINED, &get_enum(d, "UndA").unwrap_or_else(|| "RptE".into())) else { return unsupported }; + m.insert("undefinedAreas".into(), undefined.into()); + if let Some(seed) = get_num(d, "RndS") { + m.insert("seed".into(), Value::from(seed as i32 as u32)); + } + step("filter.distort.wave", Value::Object(m)) +} + +pub(super) fn wave_to(_: &str, p: &Value) -> Option { + let ours = [ + ("type", json!("sine")), + ("generators", json!(5)), + ("wavelengthMin", json!(10)), + ("wavelengthMax", json!(120)), + ("amplitudeMin", json!(5)), + ("amplitudeMax", json!(35)), + ]; + let d = keys_to(p, WAVE, &ours, &["undefinedAreas", "seed"])?; + let undefined = match p.get("undefinedAreas") { + None | Some(Value::Null) => "WrpA", + Some(v) => lookup_back(WAVE_UNDEFINED, v.as_str()?)?, + }; + let seed = match p.get("seed") { + None | Some(Value::Null) => 0, + Some(v) => u32::try_from(v.as_u64()?).ok()? as i32, + }; + Some(d.with("SclH", D::Integer(100)).with("SclV", D::Integer(100)).with("UndA", enm("UndA", undefined)).with("RndS", D::Integer(seed))) +} diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index f9e633244c..8a1c3e2e1c 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -485,6 +485,16 @@ fn every_mapped_command_exports_and_imports_back() { ("filter.blur.smartBlur", json!({"radius": 2.5, "threshold": 40, "quality": "medium", "mode": "overlayEdge"})), ("filter.other.offset", json!({"horizontal": 10, "vertical": -5, "undefinedAreas": "wrap"})), ("filter.blur.blur", json!({})), + ("filter.distort.pinch", json!({"amount": -30})), + ("filter.distort.polarCoordinates", json!({"mode": "polarToRectangular"})), + ("filter.distort.spherize", json!({"amount": -50, "mode": "verticalOnly"})), + ("filter.distort.twirl", json!({"angle": -200})), + ( + "filter.distort.wave", + json!({"type": "square", "generators": 3, "wavelengthMin": 20, "wavelengthMax": 200, "amplitudeMin": 4, "amplitudeMax": 40, + "undefinedAreas": "wrap", "seed": 4_000_000_000_u32}), + ), + ("filter.distort.zigZag", json!({"amount": -20, "ridges": 12, "style": "aroundCenter"})), ( "image.adjustments.levels", json!({"inBlack": 5, "inWhite": 250, "gamma": 0.9, "outBlack": 0, "outWhite": 255, "red": {"inBlack": 2, "inWhite": 255, "gamma": 1, "outBlack": 0, "outWhite": 255}}), @@ -3175,3 +3185,108 @@ fn long_names_are_clipped_instead_of_copied_into_every_diagnostic() { assert_eq!(imp.unsupported.len(), 50); assert!(imp.unsupported.iter().all(|u| u.action.chars().count() == MAX_NAME_CHARS)); } + +/// The step `d` of Photoshop event `code`, as PhotoCraft loads it. +fn distort(code: &str, d: Descriptor) -> Mapped { + let event = AtnEvent::Code(code.as_bytes().try_into().unwrap()); + (events::event_for(&event).unwrap_or_else(|| panic!("{code} is unknown")).from)(&d) +} + +fn one_step(m: Mapped) -> (String, Value) { + match m { + Mapped::Steps(mut s) if s.len() == 1 => s.remove(0), + _ => panic!("not one step"), + } +} + +/// Photoshop's Distort filter steps load with its defaults for missing keys and its clamping for +/// out-of-range values, play, and save back with every key in recorded order (#10605). +#[test] +fn distort_filter_steps_load_clamp_play_and_save_back() { + // Photoshop's defaults, as its steps record them. + let wave = desc("null") + .with("Wvtp", enm("Wvtp", "WvSn")) + .with("NmbG", D::Integer(5)) + .with("WLMn", D::Integer(10)) + .with("WLMx", D::Integer(120)) + .with("AmMn", D::Integer(5)) + .with("AmMx", D::Integer(35)) + .with("SclH", D::Integer(100)) + .with("SclV", D::Integer(100)) + .with("UndA", enm("UndA", "RptE")) + .with("RndS", D::Integer(-7)); + let zigzag = desc("null").with("Amnt", D::Integer(10)).with("NmbR", D::Integer(5)).with("ZZTy", enm("ZZTy", "PndR")); + let cases = [ + ("Pnch", desc("null").with("Amnt", D::Integer(50)), "filter.distort.pinch", json!({"amount": 50})), + ("Plr ", desc("null").with("Cnvr", enm("Cnvr", "RctP")), "filter.distort.polarCoordinates", json!({"mode": "rectangularToPolar"})), + ( + "Sphr", + desc("null").with("Amnt", D::Integer(100)).with("SphM", enm("SphM", "Nrml")), + "filter.distort.spherize", + json!({"amount": 100, "mode": "normal"}), + ), + ("Twrl", desc("null").with("Angl", D::Integer(50)), "filter.distort.twirl", json!({"angle": 50})), + ( + "Wave", + wave, + "filter.distort.wave", + json!({"type": "sine", "generators": 5, "wavelengthMin": 10, "wavelengthMax": 120, "amplitudeMin": 5, "amplitudeMax": 35, + "undefinedAreas": "repeat", "seed": 4_294_967_289_u32}), + ), + ("ZgZg", zigzag, "filter.distort.zigZag", json!({"amount": 10, "ridges": 5, "style": "pondRipples"})), + ]; + let mut s = Session::new(); + s.execute("file.new", json!({"width": 24, "height": 24})).unwrap(); + s.execute("filter.render.clouds", json!({})).unwrap(); + for (code, d, id, params) in cases { + let (got, p) = one_step(distort(code, d.clone())); + assert_eq!((got.as_str(), &p), (id, ¶ms), "{code}"); + // Every key, in the order Photoshop records them. + assert_eq!(export_item(id, &p, "S").unwrap().descriptor.unwrap().items, d.items, "{code}"); + s.actions.list.push(Action::new(code, vec![(id.to_string(), p)])); + let played = s.execute("actions.play", json!({"action": code})).unwrap(); + assert_eq!(played["ran"], 1, "{code}: {played}"); + } + // A step that leaves its keys out takes Photoshop's defaults: Wave's undefined areas are + // Repeat Edge Pixels, where the command's own default is wrap. + assert_eq!(one_step(distort("Wave", desc("null"))).1["undefinedAreas"], "repeat"); + assert_eq!(one_step(distort("Sphr", desc("null"))).1, json!({"amount": 100, "mode": "normal"})); + // Out-of-range values are clamped, as Photoshop clamps them. + assert_eq!(one_step(distort("Pnch", desc("null").with("Amnt", D::Integer(500)))).1, json!({"amount": 100})); + assert_eq!(one_step(distort("Twrl", desc("null").with("Angl", D::Integer(-5000)))).1, json!({"angle": -999})); + assert_eq!(one_step(distort("ZgZg", desc("null").with("NmbR", D::Integer(40)))).1["ridges"], 20); + assert_eq!(one_step(distort("Wave", desc("null").with("NmbG", D::Integer(0)))).1["generators"], 1); +} + +/// What the commands cannot play keeps the Photoshop step: an unknown enumeration value, a Wave +/// scaled below 100 % (the command has no scale), or a key the event does not have (#10605). +#[test] +fn distort_filter_steps_the_commands_cannot_play_are_kept() { + let kept = |m: Mapped| matches!(m, Mapped::Unsupported { harmless: false }); + assert!(kept(distort("Sphr", desc("null").with("SphM", enm("SphM", "Dgnl"))))); + assert!(kept(distort("Plr ", desc("null").with("Cnvr", enm("Cnvr", "Sprl"))))); + assert!(kept(distort("Wave", desc("null").with("SclH", D::Integer(50))))); + assert!(kept(distort("Wave", desc("null").with("UndA", enm("UndA", "Trns"))))); + assert!(kept(distort("Pnch", desc("null").with("Rds ", D::Integer(3))))); + // A scale of 100 % (or above, which Photoshop clamps to 100) plays. + assert!(!kept(distort("Wave", desc("null").with("SclH", D::Integer(100)).with("SclV", D::Integer(250))))); +} + +/// A setting the events cannot hold refuses the step instead of replaying differently: a +/// fractional amount (the keys are integers), a mask target, a seed past 32 bits (#10605). +#[test] +fn distort_filter_steps_that_cannot_be_written_are_refused() { + for (id, params) in [ + ("filter.distort.pinch", json!({"amount": 12.5})), + ("filter.distort.twirl", json!({"angle": 10, "target": "mask"})), + ("filter.distort.zigZag", json!({"ridges": 2.5})), + ("filter.distort.wave", json!({"seed": 4_294_967_296_u64})), + ("filter.distort.wave", json!({"undefinedAreas": "transparent"})), + ("filter.distort.spherize", json!({"mode": "diagonal"})), + ] { + assert!(export_item(id, ¶ms, "S").is_none(), "{id} {params} exported"); + } + // Left out, a setting is written as the command replays it: Wave wraps. + let wave = export_item("filter.distort.wave", &json!({}), "S").unwrap().descriptor.unwrap(); + assert_eq!(get_enum(&wave, "UndA").as_deref(), Some("WrpA")); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 5ef09a2e7e..b0a3c346d7 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-26 · **Change:** minor (TIFFs up to 300,000 px a side open, decoded a group of rows at a time into the layer, #8169; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; PSD Filter Gallery: Halftone Pattern dot/circle/line settings survive PSD/PSB saves (#8940); Save for Web writes WebP, lossy or lossless, #6391; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency, #8877; layered TIFF writes the TySh text version and descriptor in PSD big-endian order while the header, matrix, warp and bounds keep container order, #9098; layered TIFF writes Photoshop's 14-byte global layer mask, so Photoshop opens the layers instead of only the composite, #9095; legacy PSD point text placed from the layout ink box after font resolution, #6094; `.atn`: 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; `.asl` layer style libraries read and written, #1051; JPEG Options on File › Save, Save As and Save a Copy, FILE-215-19 partial (no Matte, progressive Scans or size preview); 32-bit Hue/Saturation unclipped and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; JPEG Options on Save As and Save a Copy, FILE-215-19; TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; Cineon write: 10-bit gray/RGB; animated WebP frames load into video layers, #1198; Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 53 filters (39 more: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag, Invert, Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding; Amiga IFF write: 24-plane ILBM, Save As `.iff`; PSD stores spot channels as Photoshop does, ink 0 = solid, and numbers them in 1053, #7415 split; TIFF writes and reads alpha and spot channels as extra samples with Photoshop's channel resources, PSD output unchanged, FILE-215-7, FILE-215-17's TIFF part, #7415; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-31 · **Change:** minor (`.atn`: Pinch, Polar Coordinates, Spherize, Twirl, Wave and ZigZag steps load, play and save back with Photoshop's limits and defaults, #10605; TIFFs up to 300,000 px a side open, decoded a group of rows at a time into the layer, #8169; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; PSD Filter Gallery: Halftone Pattern dot/circle/line settings survive PSD/PSB saves (#8940); Save for Web writes WebP, lossy or lossless, #6391; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency, #8877; layered TIFF writes the TySh text version and descriptor in PSD big-endian order while the header, matrix, warp and bounds keep container order, #9098; layered TIFF writes Photoshop's 14-byte global layer mask, so Photoshop opens the layers instead of only the composite, #9095; legacy PSD point text placed from the layout ink box after font resolution, #6094; `.atn`: 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; `.asl` layer style libraries read and written, #1051; JPEG Options on File › Save, Save As and Save a Copy, FILE-215-19 partial (no Matte, progressive Scans or size preview); 32-bit Hue/Saturation unclipped and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; JPEG Options on Save As and Save a Copy, FILE-215-19; TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; Cineon write: 10-bit gray/RGB; animated WebP frames load into video layers, #1198; Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 53 filters (39 more: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag, Invert, Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding; Amiga IFF write: 24-plane ILBM, Save As `.iff`; PSD stores spot channels as Photoshop does, ink 0 = solid, and numbers them in 1053, #7415 split; TIFF writes and reads alpha and spot channels as extra samples with Photoshop's channel resources, PSD output unchanged, FILE-215-7, FILE-215-17's TIFF part, #7415; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency) · **Target:** Adobe Photoshop 2026 **PSD Filter Gallery (#8940, 2026-10-23):** Halftone Pattern maps its size, contrast and dot/circle/line pattern as editable settings on import and export, with the reference application's descriptor keys (`HlSz`, `Cntr`, `ScrT`: `ScrD`/`ScrC`/`ScrL`). PSD/PSB round trips pass at 8/16/32 bits. This does not claim pixel equivalence for the existing renderer. Unknown or malformed settings remain verbatim on import. @@ -148,6 +148,7 @@ Embedded SVG and PDF-compatible AI/PDF/EPS payloads can use an explicit VectorCr | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | `.atn`: Pinch (`Pnch`), Polar Coordinates (`Plr `), Spherize (`Sphr`), Twirl (`Twrl`), Wave (`Wave`) and ZigZag (`ZgZg`) map to `filter.distort.*` with every key in recorded order; out-of-range values clamped and missing keys given Photoshop's defaults on import; a Wave scaled below 100 % stays a Photoshop step (the command has no scale); Shear still kept verbatim (#10605) | | 2026-10-26 | minor | Embedded AI/PDF/EPS save-back retains native format and a separate source-bound display preview; original sources remain undoable. Native Adobe interoperability and standalone EPS/DCS remain unverified; no corpus floor or percentage changed. | | 2026-10-22 | minor | Embedded PSD vectors gain explicit bounded SVG editor/save-back; standalone EPS/DCS and native-format fidelity remain open. | | 2026-10-22 | minor | TIFF: flat pages up to 300,000 px a side open (was 2^30 px, about 32,768²): `codecs::TiffBands` decodes a group of rows at a time into the layer's tiles. The 117,504 × 22,464 px banner from #8456 opens in 3.3 s at 11.45 GB peak for a 10.56 GB layer; 30,000² 6.8 s / 6.43 GB → 1.1 s / 3.86 GB; 36 MP 220 → 52 ms. Layered TIFFs still decode whole (#8169) | From 1f8ea3b61ad2e41f945164228800e1a6e4568640 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Sat, 31 Oct 2026 20:13:23 -0700 Subject: [PATCH 034/216] Keep Trace Contour levels outside native support unchanged --- .../engine/src/cmd/atn_cmds/steps_stylize.rs | 6 ++- .../engine/src/cmd/atn_cmds/stylize_tests.rs | 40 ++++++++++++++++--- docs/atn.md | 4 ++ 3 files changed, 42 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/atn_cmds/steps_stylize.rs b/crates/engine/src/cmd/atn_cmds/steps_stylize.rs index 1c5bc2ba32..a61046d95e 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_stylize.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_stylize.rs @@ -23,8 +23,8 @@ const EXTRUDE: &[Field] = &[ Field("ExtR", "depthMode", Kind::Enum("ExtR", &[("Rndm", "random"), ("LvlB", "levelBased")], "random")), ]; const TRACE: &[Field] = &[ - // The dialog range is not measured. These bounds are only the descriptor's integer type. - Field("Lvl ", "level", Kind::Long(128, i32::MIN, i32::MAX)), + // PhotoCraft's native range; the reference application's dialog range is not measured. + Field("Lvl ", "level", Kind::Long(128, 0, 255)), Field("Edg ", "edge", Kind::Enum("CntE", &[("Lwr ", "lower"), ("Upr ", "upper")], "upper")), ]; const WIND: &[Field] = &[ @@ -145,6 +145,8 @@ fn params_from(id: &str, d: &Descriptor) -> Option> { return None; } let value = match d.get(key) { + // Keep unmeasured Trace levels instead of changing them to the native limit. + Some(D::Integer(n)) if id == "filter.stylize.traceContour" && !(0..=255).contains(n) => return None, Some(value) => kind.read(value)?, None => kind.default(), }; diff --git a/crates/engine/src/cmd/atn_cmds/stylize_tests.rs b/crates/engine/src/cmd/atn_cmds/stylize_tests.rs index 02eebda8ca..156b856b72 100644 --- a/crates/engine/src/cmd/atn_cmds/stylize_tests.rs +++ b/crates/engine/src/cmd/atn_cmds/stylize_tests.rs @@ -180,7 +180,7 @@ fn measured_defaults_and_every_changed_value_round_trip_in_order() { /// Every measured numeric bound is checked independently, including angles beyond 360 degrees. #[test] -fn numeric_imports_clamp_measured_ranges_and_keep_unmeasured_trace_levels() { +fn numeric_imports_clamp_measured_ranges() { for (code, key, param, min, max) in [ ("Extr", "ExtS", "size", 2, 255), ("Extr", "ExtD", "depth", 1, 255), @@ -202,17 +202,45 @@ fn numeric_imports_clamp_measured_ranges_and_keep_unmeasured_trace_levels() { assert!(saved_items(&mut s)[0].descriptor.is_some()); } } - for level in [i32::MIN, -1, 0, 255, 300, i32::MAX] { - let mut s = load_items(vec![code_item("TrcC", desc("null").with("Lvl ", D::Integer(level)))]); - assert_eq!(s.actions.list[0].steps[0].1["level"], level); - assert_eq!(saved_items(&mut s)[0].descriptor.as_ref().unwrap().get("Lvl "), Some(&D::Integer(level))); - } for unitless in [D::Integer(2), D::Double(2.0)] { let mut s = load_items(vec![code_item("Msc ", desc("null").with("ClSz", unitless))]); assert_eq!(saved_items(&mut s)[0].descriptor, Some(desc("null").with("ClSz", px(2.0)))); } } +/// Map native-supported Trace levels exactly; keep other recorded values without changing them. +#[test] +fn trace_levels_outside_native_range_keep_their_bytes_and_flags() { + for level in [0, 128, 255] { + let mut s = load_items(vec![code_item("TrcC", desc("null").with("Lvl ", D::Integer(level)))]); + let params = &s.actions.list[0].steps[0].1; + assert_eq!(s.actions.list[0].steps[0].0, "filter.stylize.traceContour"); + assert_eq!(params["level"], level); + assert_eq!( + crate::filters_ext::params_for("filter.stylize.traceContour", params), + crate::filters_ext::params_for("filter.stylize.traceContour", &json!({"level":level,"edge":"upper"})) + ); + assert_eq!(saved_items(&mut s)[0].descriptor.as_ref().unwrap().get("Lvl "), Some(&D::Integer(level))); + assert!(export_item("filter.stylize.traceContour", &json!({"level":level}), "Group D").is_some()); + } + for level in [i32::MIN, -1, 256, 300, i32::MAX] { + let descriptor = desc("null").with("Lvl ", D::Integer(level)).with("Edg ", enm("CntE", "Lwr ")); + let mut item = code_item("TrcC", descriptor.clone()); + item.dictionary = b"Trace Contour".to_vec(); + item.enabled = false; + item.with_dialog = true; + item.dialog_options = 2; + let mut s = load_items(vec![item.clone()]); + assert_eq!(s.actions.list[0].steps[0].0, PHOTOSHOP_STEP, "Trace Contour/{level}"); + assert!(s.actions.list[0].disabled.contains(&0)); + assert!(s.actions.list[0].dialogs.contains(&0)); + let saved = saved_items(&mut s); + assert_eq!(saved, vec![item]); + assert_eq!(saved[0].descriptor.as_ref().unwrap().to_bytes(), descriptor.to_bytes()); + assert!(export_item("filter.stylize.traceContour", &json!({"level":level}), "Group D").is_none()); + } +} + /// Every enumerator is explicit, and unknown values or enum types retain the original flagged step. #[test] fn enum_values_map_exactly_and_unknown_descriptors_stay_kept() { diff --git a/docs/atn.md b/docs/atn.md index 9830b75fa7..d7d1743692 100644 --- a/docs/atn.md +++ b/docs/atn.md @@ -159,6 +159,10 @@ with cell size 2, and Trace Contour imports with its upper edge. Saving a native that omits these parameters preserves its native behavior: Mosaic cell size 10 and Trace Contour lower edge. Explicit imported values stay explicit, including changed Trace Contour levels and edges that the reference application applies but does not echo. +Trace Contour maps levels within PhotoCraft's native `0..255` range unchanged. +Other recorded levels remain kept steps with their descriptors and flags intact; +native commands outside that range refuse export. This is a native support limit, +not a measurement of the reference application's dialog range, which remains pending. Mosaic accepts a unitless pixel value on import and writes a `#Pxl` unit double. Other fields keep their documented integer, boolean and enum types. Unknown keys, From 36c90925e9eae7fb5486f4b9f0e92d8192c2d35d Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 31 Oct 2026 21:15:26 -0600 Subject: [PATCH 035/216] Actions: map Photoshop's channel and colour steps (#10609) Split Channels (SplC), Merge Spot Channel (MSpt), Trap (Trap) and Match Color without a source (matchColor, noReference) load as channel.split, channel.mergeSpot, image.trap and image.adjustments.matchColor and save back with the keys Photoshop records. The split's recorded documentIDs belong to its run and are neither replayed nor written; Trap's width is clamped to 32767 px; Match Color takes Photoshop's defaults. Steps the events cannot carry (a split keeping its original, a named spot channel, a Match Color source or option) are refused on export. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/atn_cmds.rs | 1 + crates/engine/src/cmd/atn_cmds/events.rs | 6 + .../engine/src/cmd/atn_cmds/steps_channels.rs | 108 ++++++++++++++++++ crates/engine/src/cmd/atn_cmds/tests.rs | 91 +++++++++++++++ docs/file-format-parity.md | 3 +- 5 files changed, 208 insertions(+), 1 deletion(-) create mode 100644 crates/engine/src/cmd/atn_cmds/steps_channels.rs diff --git a/crates/engine/src/cmd/atn_cmds.rs b/crates/engine/src/cmd/atn_cmds.rs index 42a4722479..f17a1a980a 100644 --- a/crates/engine/src/cmd/atn_cmds.rs +++ b/crates/engine/src/cmd/atn_cmds.rs @@ -32,6 +32,7 @@ mod descriptors; mod events; mod fields; mod files; +mod steps_channels; mod steps_doc; mod steps_image; mod steps_layer; diff --git a/crates/engine/src/cmd/atn_cmds/events.rs b/crates/engine/src/cmd/atn_cmds/events.rs index 5723c93c59..cb4d4ab35e 100644 --- a/crates/engine/src/cmd/atn_cmds/events.rs +++ b/crates/engine/src/cmd/atn_cmds/events.rs @@ -2,6 +2,7 @@ const DIFFUSE_MODE: &[(&str, &str)] = &[("Nrml", "normal"), ("DrkO", "darkenOnly use super::adjustments::{adjustment_by_class, adjustment_steps, levels_from, levels_to, shadows_highlights_from, shadows_highlights_to}; use super::descriptors::{desc, get_bool, get_ref, jbool, layer_ref, layer_target, ref_class}; use super::fields::{F, Kind, Mapped, fields_from, fields_to, none, step}; +use super::steps_channels::{match_color_from, match_color_to, merge_spot_from, merge_spot_to, split_from, split_to, trap_from, trap_to}; use super::steps_doc::{ align_from, align_to, canvas_size_from, canvas_size_to, close_from, close_to, conditional_from, conditional_to, convert_mode_from, convert_mode_to, crop_from, crop_to, export_from, export_to, image_size_from, image_size_to, merge_visible_from, merge_visible_to, open_from, open_to, paste_from, paste_to, @@ -283,9 +284,14 @@ pub(super) const EVENTS: &[Event] = &[ adjustment_event!("ChnM", "ChnM", "channelMixer", "Channel Mixer"), adjustment_event!("GdMp", "GdMp", "gradientMapEvent", "Gradient Map", "image.adjustments.gradientMap"), adjustment_event!("SlcC", "SlcC", "selectiveColor", "Selective Color"), + Event { code: "", name: "matchColor", label: "Match Color", ids: &["image.adjustments.matchColor"], from: match_color_from, to: match_color_to }, ev!("Dstt", "desaturate", "Desaturate", "image.adjustments.desaturate", &[]), ev!("Eqlz", "equalize", "Equalize", "image.adjustments.equalize", &[]), ev!("", "autoColor", "Auto Color", "image.autoColor", &[]), + // Channels and Trap (#10609). + Event { code: "SplC", name: "splitChannels", label: "Split Channels", ids: &["channel.split"], from: split_from, to: split_to }, + Event { code: "MSpt", name: "mergeSpotChannel", label: "Merge Spot Channel", ids: &["channel.mergeSpot"], from: merge_spot_from, to: merge_spot_to }, + Event { code: "Trap", name: "trap", label: "Trap", ids: &["image.trap"], from: trap_from, to: trap_to }, // Image. Event { code: "ImgS", name: "imageSize", label: "Image Size", ids: &["image.imageSize"], from: image_size_from, to: image_size_to }, Event { code: "CnvS", name: "canvasSize", label: "Canvas Size", ids: &["image.canvasSize"], from: canvas_size_from, to: canvas_size_to }, diff --git a/crates/engine/src/cmd/atn_cmds/steps_channels.rs b/crates/engine/src/cmd/atn_cmds/steps_channels.rs new file mode 100644 index 0000000000..88860567e3 --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/steps_channels.rs @@ -0,0 +1,108 @@ +//! Photoshop's channel and colour steps (#10609): Split Channels, Merge Spot Channel, Trap and +//! Match Color, with the keys Photoshop records. + +use super::descriptors::{desc, get_bool, get_num, px, trimmed}; +use super::events::pixel_target; +use super::fields::{Mapped, step}; +use super::{D, Descriptor, Value, json}; + +/// Whether every key of `d` is one of `keys` (codes compare without their space padding). +fn only_keys(d: &Descriptor, keys: &[&str]) -> bool { + d.items.iter().all(|(k, _)| keys.contains(&trimmed(k).as_str())) +} + +/// Whether `p` sets nothing but `allowed` (a `null` counts as unset). +fn sets_only(p: &Value, allowed: &[&str]) -> bool { + p.as_object().is_none_or(|m| m.iter().all(|(k, v)| v.is_null() || allowed.contains(&k.as_str()))) +} + +/// Split Channels (`splitChannels`). Its `documentIDs` are the documents the split made when it +/// was recorded, so they mean nothing on replay: the step splits the active document and closes +/// it, as Photoshop's does. +pub(super) fn split_from(d: &Descriptor) -> Mapped { + if only_keys(d, &["documentIDs"]) { step("channel.split", json!({})) } else { Mapped::Unsupported { harmless: false } } +} + +/// Written without `documentIDs`, which only a run of the split can know. Keeping the original +/// open has no Photoshop counterpart. +pub(super) fn split_to(_: &str, p: &Value) -> Option { + let closes = p.get("closeOriginal").is_none_or(|v| v.is_null() || *v == Value::Bool(true)); + (sets_only(p, &["closeOriginal"]) && closes).then(|| desc("null")) +} + +/// Merge Spot Channel (`mergeSpotChannel`): an empty step merges the targeted spot channel. +pub(super) fn merge_spot_from(d: &Descriptor) -> Mapped { + if d.items.is_empty() { step("channel.mergeSpot", json!({})) } else { Mapped::Unsupported { harmless: false } } +} + +/// A step naming its channel cannot be written: the event has no key for it. +pub(super) fn merge_spot_to(_: &str, p: &Value) -> Option { + sets_only(p, &[]).then(|| desc("null")) +} + +/// Trap's widest width: Photoshop clamps larger ones to it. +const MAX_TRAP: f64 = 32767.0; + +/// Trap (`Trap`): `Wdth` in pixels. +pub(super) fn trap_from(d: &Descriptor) -> Mapped { + if !only_keys(d, &["Wdth"]) { + return Mapped::Unsupported { harmless: false }; + } + match get_num(d, "Wdth") { + Some(w) => step("image.trap", json!({"width": w.min(MAX_TRAP)})), + None => step("image.trap", json!({})), + } +} + +/// `Wdth` is always written: the command traps 1 px when the step leaves the width out. +pub(super) fn trap_to(_: &str, p: &Value) -> Option { + if !sets_only(p, &["width"]) { + return None; + } + let width = match p.get("width") { + None | Some(Value::Null) => 1.0, + Some(v) => v.as_f64().filter(|n| n.is_finite())?, + }; + Some(desc("null").with("Wdth", px(width))) +} + +/// Match Color's integer keys in recorded order: (key, param, default). +const MATCH_COLOR: &[(&str, &str, i32)] = &[("Lght", "luminance", 100), ("ClrR", "intensity", 100), ("Fade", "fade", 0)]; + +/// Match Color (`matchColor`) without a source, as Photoshop records it (`noReference`). A +/// step that matches another image (`noReference` false) names no source here, so it keeps +/// the Photoshop step. +pub(super) fn match_color_from(d: &Descriptor) -> Mapped { + if !only_keys(d, &["Lght", "ClrR", "Fade", "noReference"]) || get_bool(d, "noReference") == Some(false) { + return Mapped::Unsupported { harmless: false }; + } + let mut p = serde_json::Map::new(); + for (key, param, default) in MATCH_COLOR { + p.insert((*param).into(), get_num(d, key).map_or(Value::from(*default), Value::from)); + } + step("image.adjustments.matchColor", Value::Object(p)) +} + +/// Every key in recorded order. A source document or layer, Neutralize or a selection option +/// has no key in the event, so such a step is refused; the integers refuse a fraction. +pub(super) fn match_color_to(_: &str, p: &Value) -> Option { + let off = |k: &str| p.get(k).is_none_or(|v| v.is_null() || *v == Value::Bool(false)); + if !pixel_target(p) + || !sets_only(p, &["target", "luminance", "intensity", "fade", "neutralize", "useSelectionInSource", "useSelectionInTarget"]) + || !["neutralize", "useSelectionInSource", "useSelectionInTarget"].iter().all(|k| off(k)) + { + return None; + } + let mut d = desc("null"); + for (key, param, default) in MATCH_COLOR { + let n = match p.get(*param) { + None | Some(Value::Null) => f64::from(*default), + Some(v) => v.as_f64()?, + }; + if n.fract() != 0.0 || !(f64::from(i32::MIN)..=f64::from(i32::MAX)).contains(&n) { + return None; + } + d = d.with(key, D::Integer(n as i32)); + } + Some(d.with("noReference", D::Boolean(true))) +} diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index f9e633244c..d648a38216 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -485,6 +485,10 @@ fn every_mapped_command_exports_and_imports_back() { ("filter.blur.smartBlur", json!({"radius": 2.5, "threshold": 40, "quality": "medium", "mode": "overlayEdge"})), ("filter.other.offset", json!({"horizontal": 10, "vertical": -5, "undefinedAreas": "wrap"})), ("filter.blur.blur", json!({})), + ("channel.split", json!({})), + ("channel.mergeSpot", json!({})), + ("image.trap", json!({"width": 3})), + ("image.adjustments.matchColor", json!({"luminance": 120, "intensity": 80, "fade": 25})), ( "image.adjustments.levels", json!({"inBlack": 5, "inWhite": 250, "gamma": 0.9, "outBlack": 0, "outWhite": 255, "red": {"inBlack": 2, "inWhite": 255, "gamma": 1, "outBlack": 0, "outWhite": 255}}), @@ -3175,3 +3179,90 @@ fn long_names_are_clipped_instead_of_copied_into_every_diagnostic() { assert_eq!(imp.unsupported.len(), 50); assert!(imp.unsupported.iter().all(|u| u.action.chars().count() == MAX_NAME_CHARS)); } + +/// Photoshop's Split Channels, Merge Spot Channel, Trap and Match Color steps load as their +/// commands and save back with the keys Photoshop records (#10609). +#[test] +fn channel_and_color_steps_load_and_save_back() { + let ids = D::List(vec![D::Integer(182), D::Integer(185), D::Integer(188)]); + let matched = desc("null").with("Lght", D::Integer(120)).with("ClrR", D::Integer(80)).with("Fade", D::Integer(25)).with("noReference", D::Boolean(true)); + let items = vec![ + item("SplC", "Split Channels", Some(desc("null").with("documentIDs", ids))), + item("MSpt", "Merge Spot Channel", Some(desc("null"))), + item("Trap", "Trap", Some(desc("null").with("Wdth", px(2.0)))), + item("matchColor", "Match Color", Some(matched.clone())), + ]; + let file = AtnFile { + name: UnicodeString::new_nul("Prepress"), + actions: vec![AtnAction { name: UnicodeString::new_nul("Inks"), items: items.clone(), ..Default::default() }], + ..Default::default() + }; + let mut s = Session::new(); + let r = s.execute("actions.load", json!({"data": b64(&atn::write(&file).unwrap())})).unwrap(); + assert_eq!(r["unsupported"], json!([])); + assert_eq!( + s.actions.list[0].steps, + vec![ + ("channel.split".to_string(), json!({})), + ("channel.mergeSpot".to_string(), json!({})), + ("image.trap".to_string(), json!({"width": 2})), + ("image.adjustments.matchColor".to_string(), json!({"luminance": 120, "intensity": 80, "fade": 25})), + ] + ); + let saved = s.execute("actions.save", json!({"action": "Inks"})).unwrap(); + let back = atn::parse(&photocraft_paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap()).unwrap(); + let back = &back.actions[0].items; + let events: Vec = back.iter().map(|i| i.event.id()).collect(); + assert_eq!(events, ["SplC", "MSpt", "Trap", "matchColor"]); + // The split's document IDs belong to the run that recorded it, so none are written. + assert!(back[0].descriptor.as_ref().unwrap().items.is_empty()); + for (b, original) in back.iter().zip(&items).skip(1) { + assert_eq!(b.descriptor.as_ref().unwrap().items, original.descriptor.as_ref().unwrap().items, "{}", original.event.id()); + } + // Match Color with no reference plays on the image itself. + s.execute("file.new", json!({"width": 16, "height": 16})).unwrap(); + s.execute("filter.render.clouds", json!({})).unwrap(); + s.actions.list.push(Action::new("Match", vec![s.actions.list[0].steps[3].clone()])); + let played = s.execute("actions.play", json!({"action": "Match"})).unwrap(); + assert_eq!(played["ran"], 1, "{played}"); +} + +/// Missing Match Color keys take Photoshop's defaults and Trap's width is clamped to its +/// 32767 px; a step matching another image, or with a key the event lacks, stays a Photoshop +/// step (#10609). +#[test] +fn channel_and_color_steps_default_clamp_and_keep() { + let from = |name: &str, d: Descriptor| { + let event = if name.len() == 4 { AtnEvent::Code(name.as_bytes().try_into().unwrap()) } else { AtnEvent::name(name) }; + (events::event_for(&event).unwrap_or_else(|| panic!("{name} is unknown")).from)(&d) + }; + let params = |m: Mapped| match m { + Mapped::Steps(s) => s[0].1.clone(), + Mapped::Unsupported { .. } => panic!("kept"), + }; + assert_eq!(params(from("matchColor", desc("null"))), json!({"luminance": 100, "intensity": 100, "fade": 0})); + assert_eq!(params(from("Trap", desc("null").with("Wdth", px(99_999.0)))), json!({"width": 32767.0})); + let kept = |m: Mapped| matches!(m, Mapped::Unsupported { harmless: false }); + assert!(kept(from("matchColor", desc("null").with("noReference", D::Boolean(false))))); + assert!(kept(from("SplC", desc("null").with("Chnl", D::Integer(1))))); + assert!(kept(from("MSpt", desc("null").with("Chnl", D::Integer(1))))); + assert!(kept(from("Trap", desc("null").with("Hght", px(2.0))))); +} + +/// Settings the events have no key for refuse the step instead of replaying differently: a +/// split that keeps the original, a named spot channel, a Match Color source or option, a +/// fractional luminance (#10609). +#[test] +fn channel_and_color_steps_that_cannot_be_written_are_refused() { + for (id, params) in [ + ("channel.split", json!({"closeOriginal": false})), + ("channel.mergeSpot", json!({"channel": 1})), + ("image.trap", json!({"width": 2, "layer": 3})), + ("image.adjustments.matchColor", json!({"source": 0})), + ("image.adjustments.matchColor", json!({"neutralize": true})), + ("image.adjustments.matchColor", json!({"luminance": 99.5})), + ] { + assert!(export_item(id, ¶ms, "S").is_none(), "{id} {params} exported"); + } + assert!(export_item("image.adjustments.matchColor", &json!({"neutralize": false}), "S").is_some()); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 5ef09a2e7e..9a72cf69f1 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-26 · **Change:** minor (TIFFs up to 300,000 px a side open, decoded a group of rows at a time into the layer, #8169; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; PSD Filter Gallery: Halftone Pattern dot/circle/line settings survive PSD/PSB saves (#8940); Save for Web writes WebP, lossy or lossless, #6391; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency, #8877; layered TIFF writes the TySh text version and descriptor in PSD big-endian order while the header, matrix, warp and bounds keep container order, #9098; layered TIFF writes Photoshop's 14-byte global layer mask, so Photoshop opens the layers instead of only the composite, #9095; legacy PSD point text placed from the layout ink box after font resolution, #6094; `.atn`: 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; `.asl` layer style libraries read and written, #1051; JPEG Options on File › Save, Save As and Save a Copy, FILE-215-19 partial (no Matte, progressive Scans or size preview); 32-bit Hue/Saturation unclipped and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; JPEG Options on Save As and Save a Copy, FILE-215-19; TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; Cineon write: 10-bit gray/RGB; animated WebP frames load into video layers, #1198; Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 53 filters (39 more: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag, Invert, Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding; Amiga IFF write: 24-plane ILBM, Save As `.iff`; PSD stores spot channels as Photoshop does, ink 0 = solid, and numbers them in 1053, #7415 split; TIFF writes and reads alpha and spot channels as extra samples with Photoshop's channel resources, PSD output unchanged, FILE-215-7, FILE-215-17's TIFF part, #7415; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-31 · **Change:** minor (`.atn`: Split Channels, Merge Spot Channel, Trap and Match Color steps load, play and save back, #10609; TIFFs up to 300,000 px a side open, decoded a group of rows at a time into the layer, #8169; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; PSD Filter Gallery: Halftone Pattern dot/circle/line settings survive PSD/PSB saves (#8940); Save for Web writes WebP, lossy or lossless, #6391; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency, #8877; layered TIFF writes the TySh text version and descriptor in PSD big-endian order while the header, matrix, warp and bounds keep container order, #9098; layered TIFF writes Photoshop's 14-byte global layer mask, so Photoshop opens the layers instead of only the composite, #9095; legacy PSD point text placed from the layout ink box after font resolution, #6094; `.atn`: 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; `.asl` layer style libraries read and written, #1051; JPEG Options on File › Save, Save As and Save a Copy, FILE-215-19 partial (no Matte, progressive Scans or size preview); 32-bit Hue/Saturation unclipped and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; JPEG Options on Save As and Save a Copy, FILE-215-19; TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; Cineon write: 10-bit gray/RGB; animated WebP frames load into video layers, #1198; Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 53 filters (39 more: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag, Invert, Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding; Amiga IFF write: 24-plane ILBM, Save As `.iff`; PSD stores spot channels as Photoshop does, ink 0 = solid, and numbers them in 1053, #7415 split; TIFF writes and reads alpha and spot channels as extra samples with Photoshop's channel resources, PSD output unchanged, FILE-215-7, FILE-215-17's TIFF part, #7415; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency) · **Target:** Adobe Photoshop 2026 **PSD Filter Gallery (#8940, 2026-10-23):** Halftone Pattern maps its size, contrast and dot/circle/line pattern as editable settings on import and export, with the reference application's descriptor keys (`HlSz`, `Cntr`, `ScrT`: `ScrD`/`ScrC`/`ScrL`). PSD/PSB round trips pass at 8/16/32 bits. This does not claim pixel equivalence for the existing renderer. Unknown or malformed settings remain verbatim on import. @@ -148,6 +148,7 @@ Embedded SVG and PDF-compatible AI/PDF/EPS payloads can use an explicit VectorCr | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | `.atn`: Split Channels (`SplC`, its recorded `documentIDs` not replayed or written), Merge Spot Channel (`MSpt`), Trap (`Trap`, `Wdth` clamped to 32767 px) and Match Color without a source (`matchColor`, `noReference`) map to `channel.split`, `channel.mergeSpot`, `image.trap` and `image.adjustments.matchColor`; a Match Color step that matches another image stays a Photoshop step (#10609) | | 2026-10-26 | minor | Embedded AI/PDF/EPS save-back retains native format and a separate source-bound display preview; original sources remain undoable. Native Adobe interoperability and standalone EPS/DCS remain unverified; no corpus floor or percentage changed. | | 2026-10-22 | minor | Embedded PSD vectors gain explicit bounded SVG editor/save-back; standalone EPS/DCS and native-format fidelity remain open. | | 2026-10-22 | minor | TIFF: flat pages up to 300,000 px a side open (was 2^30 px, about 32,768²): `codecs::TiffBands` decodes a group of rows at a time into the layer's tiles. The 117,504 × 22,464 px banner from #8456 opens in 3.3 s at 11.45 GB peak for a 10.56 GB layer; 30,000² 6.8 s / 6.43 GB → 1.1 s / 3.86 GB; 36 MP 220 → 52 ms. Layered TIFFs still decode whole (#8169) | From b4a066e34e3e4ff12ff0400f5528b020c53b4d76 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Sat, 31 Oct 2026 20:15:50 -0700 Subject: [PATCH 036/216] Cover changed ATN settings and Fade opacity boundaries --- .../src/cmd/atn_cmds/selection_tests.rs | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/crates/engine/src/cmd/atn_cmds/selection_tests.rs b/crates/engine/src/cmd/atn_cmds/selection_tests.rs index 8d851ce987..9b57b7a74a 100644 --- a/crates/engine/src/cmd/atn_cmds/selection_tests.rs +++ b/crates/engine/src/cmd/atn_cmds/selection_tests.rs @@ -53,6 +53,13 @@ fn group_f_load_save_preserves_recorded_descriptors() { for mode in ["AddT", "SbtF", "IntW"] { cases.push((mode, None, rectangle([-0.1, -0.1, 0.3, 0.2], false))); } + let mut ellipse = rectangle([1.5, 2.5, 8.5, 10.5], true); + ellipse.items.last_mut().unwrap().1 = D::Boolean(false); + cases.push(("AddT", Some("addTo"), ellipse)); + cases.push(("Fade", Some("fade"), desc("null").with("Opct", pct(25.5)).with("Md ", enm("BlnM", "Scrn")))); + for edge in ["AdCH", "AdRg", "AdTp", "AdCV", "AdBt"] { + cases.push(("Dstr", Some("distort"), desc("null").with("null", target_ref("Lyr ")).with("Usng", enm("ADSt", edge)))); + } for (id, name, d) in cases { let mut events = Vec::new(); if !id.is_empty() { @@ -74,6 +81,22 @@ fn group_f_load_save_preserves_recorded_descriptors() { } } +/// Fade writes native defaults and clamps only its documented opacity range. +#[test] +fn fade_defaults_and_opacity_limits_are_explicit() { + let native = export_item("edit.fade", &json!({}), "S").unwrap().descriptor.unwrap(); + assert_eq!(native, desc("null").with("Opct", pct(100.0)).with("Md ", enm("BlnM", "Nrml"))); + for (opacity, expected) in [(-5.0, 0.0), (0.0, 0.0), (100.0, 100.0), (105.0, 100.0)] { + let bytes = file(AtnEvent::Code(*b"Fade"), desc("null").with("Opct", pct(opacity)).with("Md ", enm("BlnM", "Nrml"))); + let loaded = import(&bytes).unwrap(); + assert!(loaded.unsupported.is_empty()); + assert_eq!(loaded.actions[0].steps[0].1["opacity"].as_f64(), Some(expected)); + let saved = export("Group F", &[&loaded.actions[0]], &[], false).unwrap().0; + let d = atn::parse(&saved).unwrap().actions[0].items[0].descriptor.clone().unwrap(); + assert_eq!(d, desc("null").with("Opct", pct(expected)).with("Md ", enm("BlnM", "Nrml"))); + } +} + /// Native exports keep their current event; only imported old mask/merge steps select old IDs. #[test] fn group_f_legacy_origin_is_allowlisted_and_does_not_replace_native_events() { @@ -85,6 +108,7 @@ fn group_f_legacy_origin_is_allowlisted_and_does_not_replace_native_events() { } assert!(export_item("edit.definePattern", &json!({"_atnEvent":"MrgL"}), "S").is_none()); assert!(export_item("select.rect", &json!({"x":0,"y":0,"width":1,"height":1,"mode":"replace","_atnBounds":[0,0,1,1]}), "S").is_none()); + assert!(export_item("select.rect", &json!({"x":0,"y":0,"width":2,"height":1,"mode":"add","_atnBounds":[0,0,1,1]}), "S").is_none()); } /// Missing settings, unknown enums, repeated keys and wrong targets stay kept with their flags. From 292f48d0cfffe8fa3d6dd4eb9d8012a62b8ede8d Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 31 Oct 2026 21:22:46 -0600 Subject: [PATCH 037/216] Actions: keep a Select Sky step whose sampleAllLayers isn't a Boolean (CodeRabbit review on crates/engine/src/cmd/atn_cmds/steps_image.rs:486) A wrong-typed sampleAllLayers was read as missing and the step played with false. A shared `setting` helper now reads a present key with its expected type and returns None otherwise, so the step stays a Photoshop step, like settings the command can't represent. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/atn_cmds/descriptors.rs | 20 +++++++++++++------ crates/engine/src/cmd/atn_cmds/steps_image.rs | 18 +++++++++++++---- crates/engine/src/cmd/atn_cmds/tests.rs | 14 +++++++++++++ 3 files changed, 42 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/atn_cmds/descriptors.rs b/crates/engine/src/cmd/atn_cmds/descriptors.rs index 9e9835218b..3e1dcd7a24 100644 --- a/crates/engine/src/cmd/atn_cmds/descriptors.rs +++ b/crates/engine/src/cmd/atn_cmds/descriptors.rs @@ -20,13 +20,17 @@ pub(super) fn get_num(d: &Descriptor, k: &str) -> Option { d.get(k).and_then(num) } -pub(super) fn get_bool(d: &Descriptor, k: &str) -> Option { - match d.get(k) { - Some(D::Boolean(b)) => Some(*b), +pub(super) fn boolean(v: &D) -> Option { + match v { + D::Boolean(b) => Some(*b), _ => None, } } +pub(super) fn get_bool(d: &Descriptor, k: &str) -> Option { + d.get(k).and_then(boolean) +} + pub(super) fn get_text(d: &Descriptor, k: &str) -> Option { match d.get(k) { Some(D::Text(t)) => Some(t.to_string_lossy()), @@ -35,13 +39,17 @@ pub(super) fn get_text(d: &Descriptor, k: &str) -> Option { } /// An enumerated value's id, trailing spaces removed (`Ordn.Trgt` → `"Trgt"`). -pub(super) fn get_enum(d: &Descriptor, k: &str) -> Option { - match d.get(k) { - Some(D::Enumerated { value, .. }) => Some(trimmed(value)), +pub(super) fn enumerated(v: &D) -> Option { + match v { + D::Enumerated { value, .. } => Some(trimmed(value)), _ => None, } } +pub(super) fn get_enum(d: &Descriptor, k: &str) -> Option { + d.get(k).and_then(enumerated) +} + pub(super) fn get_obj<'a>(d: &'a Descriptor, k: &str) -> Option<&'a Descriptor> { match d.get(k) { Some(D::Descriptor(o) | D::GlobalObject(o)) => Some(o), diff --git a/crates/engine/src/cmd/atn_cmds/steps_image.rs b/crates/engine/src/cmd/atn_cmds/steps_image.rs index 2a318f2fb6..c5bfb6c220 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_image.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_image.rs @@ -1,5 +1,5 @@ use super::descriptors::{ - PX, Target, ang, class, code, desc, enm, get_bool, get_enum, get_num, get_obj, get_ref, jbool, jnum, jstr, layer_ref, layer_target, long, pct, px, + PX, Target, ang, boolean, class, code, desc, enm, get_bool, get_enum, get_num, get_obj, get_ref, jbool, jnum, jstr, layer_ref, layer_target, long, pct, px, ref_class, target, trimmed, }; use super::events::pixel_target; @@ -461,6 +461,16 @@ fn only_keys(d: &Descriptor, keys: &[&str]) -> bool { d.items.iter().all(|(k, _)| keys.contains(&trimmed(k).as_str())) } +/// The step's `key` read by `read` (its space padding ignored, as `only_keys` admits it), +/// `default` when the step leaves it out, `None` when it holds a value `read` can't take: that +/// is still the step's setting, so the step is kept rather than played with the default. +fn setting(d: &Descriptor, key: &str, read: impl Fn(&D) -> Option, default: T) -> Option { + match d.items.iter().find(|(k, _)| trimmed(k) == key.trim_end()) { + Some((_, v)) => read(v), + None => Some(default), + } +} + /// Whether `p` sets nothing but `allowed` (a `null` counts as unset). fn sets_only(p: &Value, allowed: &[&str]) -> bool { p.as_object().is_none_or(|m| m.iter().all(|(k, v)| v.is_null() || allowed.contains(&k.as_str()))) @@ -480,10 +490,10 @@ pub(super) fn bare_to(_: &str, p: &Value) -> Option { /// Select Sky (`selectSky`): `sampleAllLayers` only, false when the step leaves it out /// (the command's own default is true). pub(super) fn select_sky_from(d: &Descriptor) -> Mapped { - if !only_keys(d, &["sampleAllLayers"]) { - return Mapped::Unsupported { harmless: false }; + match setting(d, "sampleAllLayers", boolean, false) { + Some(all) if only_keys(d, &["sampleAllLayers"]) => step("select.sky", json!({"sampleAllLayers": all})), + _ => Mapped::Unsupported { harmless: false }, } - step("select.sky", json!({"sampleAllLayers": get_bool(d, "sampleAllLayers").unwrap_or(false)})) } /// The event has no mode, threshold or softness: only their replay values survive. diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index bb277c9f00..c3712da42e 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -3278,3 +3278,17 @@ fn newer_feature_steps_that_cannot_be_written_are_refused() { } assert!(export_item("select.sky", &json!({"mode": "replace", "threshold": 50, "softness": 50}), "S").is_some()); } + +/// A recorded setting of the wrong type is the step's own setting, not a missing one: the +/// mapper must not play it with a default but keep the Photoshop step (#10610). +fn kept_when_wrong_typed(name: &str, settings: &[(&str, D)]) { + for (key, v) in settings { + let m = newer(name, desc("null").with(key, v.clone())); + assert!(matches!(m, Mapped::Unsupported { harmless: false }), "{name} with {key:?} = {v:?} was mapped"); + } +} + +#[test] +fn select_sky_with_a_wrong_typed_setting_is_kept() { + kept_when_wrong_typed("selectSky", &[("sampleAllLayers", D::Integer(1)), ("sampleAllLayers", text("true"))]); +} From 7525fde387775099600b82e47a8e5575a4fd4b56 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 31 Oct 2026 21:23:44 -0600 Subject: [PATCH 038/216] Actions: keep an Oil Paint step with a wrong-typed setting (CodeRabbit review on crates/engine/src/cmd/atn_cmds/steps_image.rs:527) A present lightingOn or numeric key of another type passed the key-name gate, read as missing and played with Photoshop's default. Each key now goes through `setting`, so such a step stays a Photoshop step. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/atn_cmds/steps_image.rs | 23 ++++++++++++------- crates/engine/src/cmd/atn_cmds/tests.rs | 16 +++++++++++++ 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/atn_cmds/steps_image.rs b/crates/engine/src/cmd/atn_cmds/steps_image.rs index c5bfb6c220..fcf806f422 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_image.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_image.rs @@ -1,6 +1,6 @@ use super::descriptors::{ - PX, Target, ang, boolean, class, code, desc, enm, get_bool, get_enum, get_num, get_obj, get_ref, jbool, jnum, jstr, layer_ref, layer_target, long, pct, px, - ref_class, target, trimmed, + PX, Target, ang, boolean, class, code, desc, enm, get_bool, get_enum, get_num, get_obj, get_ref, jbool, jnum, jstr, layer_ref, layer_target, long, num, + pct, px, ref_class, target, trimmed, }; use super::events::pixel_target; use super::fields::{F, Kind, Mapped, fields_from, fields_to, step}; @@ -524,18 +524,25 @@ const OIL_PAINT: &[(&str, &str, f64, f64)] = &[ /// Oil Paint (`oilPaint`). Missing keys take Photoshop's defaults, which differ from the /// command's for cleanliness, scale, bristle detail and shine, so they are written out. pub(super) fn oil_paint_from(d: &Descriptor) -> Mapped { + match oil_paint_params(d) { + Some(p) => step("filter.stylize.oilPaint", Value::Object(p)), + None => Mapped::Unsupported { harmless: false }, + } +} + +fn oil_paint_params(d: &Descriptor) -> Option> { let keys = ["lightingOn", "stylization", "cleanliness", "brushScale", "microBrush", "LghD", "specularity"]; if !only_keys(d, &keys) { - return Mapped::Unsupported { harmless: false }; + return None; } let mut p = Map::new(); - p.insert("lighting".into(), get_bool(d, "lightingOn").unwrap_or(true).into()); + p.insert("lighting".into(), setting(d, "lightingOn", boolean, true)?.into()); for (key, param, ps, _) in OIL_PAINT { - p.insert((*param).into(), get_num(d, key).unwrap_or(*ps).into()); + p.insert((*param).into(), setting(d, key, num, *ps)?.into()); } - p.insert("angle".into(), get_num(d, "LghD").unwrap_or(-60.0).into()); - p.insert("shine".into(), get_num(d, "specularity").unwrap_or(1.299_999_952_316_28).into()); - step("filter.stylize.oilPaint", Value::Object(p)) + p.insert("angle".into(), setting(d, "LghD", num, -60.0)?.into()); + p.insert("shine".into(), setting(d, "specularity", num, 1.299_999_952_316_28)?.into()); + Some(p) } /// Every key in recorded order, the command's defaults for what the step leaves out. The light diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index c3712da42e..eeb7f0cc08 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -3292,3 +3292,19 @@ fn kept_when_wrong_typed(name: &str, settings: &[(&str, D)]) { fn select_sky_with_a_wrong_typed_setting_is_kept() { kept_when_wrong_typed("selectSky", &[("sampleAllLayers", D::Integer(1)), ("sampleAllLayers", text("true"))]); } + +#[test] +fn oil_paint_with_a_wrong_typed_setting_is_kept() { + kept_when_wrong_typed( + "oilPaint", + &[ + ("lightingOn", D::Integer(0)), + ("stylization", D::Boolean(true)), + ("cleanliness", text("5")), + ("brushScale", enm("Ordn", "Trgt")), + ("microBrush", D::Double(f64::NAN)), + ("LghD", text("45")), + ("specularity", D::Boolean(false)), + ], + ); +} From 1eb8f7d4da9f0bfdc6ea4dfd12804af012e28330 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 31 Oct 2026 21:24:40 -0600 Subject: [PATCH 039/216] Actions: keep a wrong-typed Smart Sharpen step, read an unpadded Rds (CodeRabbit review on crates/engine/src/cmd/atn_cmds/steps_image.rs:575) The enum and Boolean gates passed a present key of another type, and the amount and radius fell back to their defaults. The key gate admits `Rds` without its padding, but the radius was read as `Rds ` only, so it played as 1. Every key now goes through `setting`, which reads the key as the gate admits it and keeps a wrong-typed step as a Photoshop step. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/atn_cmds/steps_image.rs | 27 ++++++++++++------- crates/engine/src/cmd/atn_cmds/tests.rs | 24 +++++++++++++++++ 2 files changed, 41 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/atn_cmds/steps_image.rs b/crates/engine/src/cmd/atn_cmds/steps_image.rs index fcf806f422..34b152b73f 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_image.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_image.rs @@ -1,6 +1,6 @@ use super::descriptors::{ - PX, Target, ang, boolean, class, code, desc, enm, get_bool, get_enum, get_num, get_obj, get_ref, jbool, jnum, jstr, layer_ref, layer_target, long, num, - pct, px, ref_class, target, trimmed, + PX, Target, ang, boolean, class, code, desc, enm, enumerated, get_bool, get_enum, get_num, get_obj, get_ref, jbool, jnum, jstr, layer_ref, layer_target, + long, num, pct, px, ref_class, target, trimmed, }; use super::events::pixel_target; use super::fields::{F, Kind, Mapped, fields_from, fields_to, step}; @@ -575,21 +575,28 @@ pub(super) fn oil_paint_to(_: &str, p: &Value) -> Option { /// asks for what the command does not do (another blur, More Accurate, the newer filter, a /// preset) keeps it as a Photoshop step. pub(super) fn smart_sharpen_from(d: &Descriptor) -> Mapped { + match smart_sharpen_params(d) { + Some(p) => step("filter.sharpen.smartSharpen", Value::Object(p)), + None => Mapped::Unsupported { harmless: false }, + } +} + +fn smart_sharpen_params(d: &Descriptor) -> Option> { let keys = ["presetKind", "useLegacy", "Amnt", "Rds", "moreAccurate", "blur"]; let as_recorded = only_keys(d, &keys) - && get_enum(d, "presetKind").is_none_or(|k| k == "presetKindCustom") - && get_bool(d, "useLegacy") != Some(false) - && get_bool(d, "moreAccurate") != Some(true) - && get_enum(d, "blur").is_none_or(|b| b == "lensBlur"); + && setting(d, "presetKind", enumerated, "presetKindCustom".into())? == "presetKindCustom" + && setting(d, "useLegacy", boolean, true)? + && !setting(d, "moreAccurate", boolean, false)? + && setting(d, "blur", enumerated, "lensBlur".into())? == "lensBlur"; if !as_recorded { - return Mapped::Unsupported { harmless: false }; + return None; } let mut p = Map::new(); - for (key, param, default) in [("Amnt", "amount", 200.0), ("Rds ", "radius", 1.0)] { - p.insert(param.into(), get_num(d, key).unwrap_or(default).into()); + for (key, param, default) in [("Amnt", "amount", 200.0), ("Rds", "radius", 1.0)] { + p.insert(param.into(), setting(d, key, num, default)?.into()); } p.insert("reduceNoise".into(), 0.into()); - step("filter.sharpen.smartSharpen", Value::Object(p)) + Some(p) } /// Every key in recorded order. The legacy filter has no noise reduction, so only a step that diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index eeb7f0cc08..c8ba917306 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -3308,3 +3308,27 @@ fn oil_paint_with_a_wrong_typed_setting_is_kept() { ], ); } + +#[test] +fn smart_sharpen_with_a_wrong_typed_setting_is_kept() { + kept_when_wrong_typed( + "smartSharpen", + &[ + ("presetKind", text("presetKindDefault")), + ("useLegacy", D::Integer(0)), + ("moreAccurate", D::Integer(1)), + ("blur", text("motionBlur")), + ("Amnt", D::Boolean(true)), + ("Rds ", text("2")), + ("Rds", text("2")), + ], + ); +} + +/// `only_keys` admits a code without its space padding, so the mapper reads it too: an +/// unpadded `Rds` radius is the step's radius, not the default 1 (#10610). +#[test] +fn smart_sharpen_reads_an_unpadded_radius() { + let d = desc("null").with("Amnt", pct(150.0)).with("Rds", px(2.5)); + assert_eq!(mapped(newer("smartSharpen", d)), ("filter.sharpen.smartSharpen".into(), json!({"amount": 150.0, "radius": 2.5, "reduceNoise": 0}))); +} From 4a6883eede1ea51e931dee01d1f16639055952ff Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 31 Oct 2026 21:29:26 -0600 Subject: [PATCH 040/216] Actions: map Photoshop's Video, Other, Average and matting steps (#10607) De-Interlace (Dntr), NTSC Colors (NTSC), HSB/HSL (HsbP), Custom (Cstm), Average (Avrg) and Remove Black / White Matte (RmvB/RmvW) load as their commands, play, and save back with the same keys. Missing keys take Photoshop's defaults; a Custom matrix that is not 5 x 5 keeps the Photoshop step. Replace Color and the direct Gradient Map (GrMp) are still kept verbatim. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/atn_cmds.rs | 1 + crates/engine/src/cmd/atn_cmds/events.rs | 44 ++++++ crates/engine/src/cmd/atn_cmds/steps_other.rs | 121 ++++++++++++++++ crates/engine/src/cmd/atn_cmds/tests.rs | 130 ++++++++++++++++++ docs/file-format-parity.md | 3 +- 5 files changed, 298 insertions(+), 1 deletion(-) create mode 100644 crates/engine/src/cmd/atn_cmds/steps_other.rs diff --git a/crates/engine/src/cmd/atn_cmds.rs b/crates/engine/src/cmd/atn_cmds.rs index 42a4722479..5a29585733 100644 --- a/crates/engine/src/cmd/atn_cmds.rs +++ b/crates/engine/src/cmd/atn_cmds.rs @@ -36,6 +36,7 @@ mod steps_doc; mod steps_image; mod steps_layer; mod steps_make; +mod steps_other; mod tables; #[cfg(test)] diff --git a/crates/engine/src/cmd/atn_cmds/events.rs b/crates/engine/src/cmd/atn_cmds/events.rs index 5723c93c59..2cd1b0b183 100644 --- a/crates/engine/src/cmd/atn_cmds/events.rs +++ b/crates/engine/src/cmd/atn_cmds/events.rs @@ -16,6 +16,7 @@ use super::steps_layer::{ move_from, move_to, play_from, play_to, rotate_from, rotate_to, select_from, select_to, stroke_to, visibility_from, visibility_to, }; use super::steps_make::{locking_from, locking_to, make_from, make_to, set_from, set_to}; +use super::steps_other::{DE_INTERLACE, HSB_HSL, bare_from, bare_to, custom_from, custom_to, enums_from, enums_to}; use super::tables::{NOISE, OFFSET_FILL, SMART_BLUR_MODE, SMART_BLUR_QUALITY, TRIM}; use super::{AtnEvent, D, Descriptor, STOP_MESSAGE, Value, json}; @@ -251,6 +252,49 @@ pub(super) const EVENTS: &[Event] = &[ ev!("Crst", "crystallize", "Crystallize", "filter.pixelate.crystallize", &[F("ClSz", "cellSize", Kind::ExactLong)]), ev!("Dfs ", "diffuse", "Diffuse", "filter.stylize.diffuse", &[F("Md ", "mode", Kind::Enum("DfsM", DIFFUSE_MODE)), F("FlRs", "seed", Kind::Long)]), Event { code: "GEfc", name: "filterGallery", label: "Filter Gallery", ids: GALLERY_IDS, from: gallery_from, to: gallery_to }, + // Video, Other, Average and matting (#10607). + Event { + code: "Dntr", + name: "deInterlace", + label: "De-Interlace", + ids: &["filter.video.deInterlace"], + from: |d| enums_from(d, "filter.video.deInterlace", DE_INTERLACE), + to: |_, p| enums_to(p, DE_INTERLACE), + }, + Event { + code: "NTSC", + name: "NTSC", + label: "NTSC Colors", + ids: &["filter.video.ntscColors"], + from: |d| bare_from(d, "filter.video.ntscColors"), + to: bare_to, + }, + Event { + code: "HsbP", + name: "HsbP", + label: "HSB/HSL", + ids: &["filter.other.hsbHsl"], + from: |d| enums_from(d, "filter.other.hsbHsl", HSB_HSL), + to: |_, p| enums_to(p, HSB_HSL), + }, + Event { code: "Cstm", name: "custom", label: "Custom", ids: &["filter.other.custom"], from: custom_from, to: custom_to }, + Event { code: "Avrg", name: "Avrg", label: "Average", ids: &["filter.blur.average"], from: |d| bare_from(d, "filter.blur.average"), to: bare_to }, + Event { + code: "RmvB", + name: "removeBlackMatte", + label: "Remove Black Matte", + ids: &["layer.matting.removeBlackMatte"], + from: |d| bare_from(d, "layer.matting.removeBlackMatte"), + to: bare_to, + }, + Event { + code: "RmvW", + name: "removeWhiteMatte", + label: "Remove White Matte", + ids: &["layer.matting.removeWhiteMatte"], + from: |d| bare_from(d, "layer.matting.removeWhiteMatte"), + to: bare_to, + }, // Adjustments (Image › Adjustments). Event { code: "", diff --git a/crates/engine/src/cmd/atn_cmds/steps_other.rs b/crates/engine/src/cmd/atn_cmds/steps_other.rs new file mode 100644 index 0000000000..7e789fc424 --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/steps_other.rs @@ -0,0 +1,121 @@ +//! Photoshop's Video, Other, Average and matting steps (#10607): De-Interlace, NTSC Colors, +//! HSB/HSL, Custom, Average and Remove Black / White Matte, with the keys Photoshop records. + +use super::descriptors::{desc, enm, get_enum, get_list, get_num, num, trimmed}; +use super::events::pixel_target; +use super::fields::{Mapped, step}; +use super::tables::{lookup, lookup_back}; +use super::{D, Descriptor, Map, Value, json}; + +/// Whether every key of `d` is one of `keys` (codes compare without their space padding). +fn only_keys(d: &Descriptor, keys: &[&str]) -> bool { + d.items.iter().all(|(k, _)| keys.contains(&trimmed(k).as_str())) +} + +/// Whether `p` sets nothing but `allowed` (a `null` counts as unset). +fn sets_only(p: &Value, allowed: &[&str]) -> bool { + p.as_object().is_none_or(|m| m.iter().all(|(k, v)| v.is_null() || allowed.contains(&k.as_str()))) +} + +/// NTSC Colors, Average and Remove Black / White Matte: Photoshop runs them with an empty +/// descriptor. A step that carries a key keeps the Photoshop step. +pub(super) fn bare_from(d: &Descriptor, id: &str) -> Mapped { + if d.items.is_empty() { step(id, json!({})) } else { Mapped::Unsupported { harmless: false } } +} + +/// The empty descriptor, for a step that edits the layer's pixels and sets nothing else. +pub(super) fn bare_to(_: &str, p: &Value) -> Option { + (pixel_target(p) && sets_only(p, &["target"])).then(|| desc("null")) +} + +/// An enumeration key: (key, param, type, Photoshop ↔ PhotoCraft values, Photoshop's default). +type EnumKey = (&'static str, &'static str, &'static str, &'static [(&'static str, &'static str)], &'static str); + +pub(super) const DE_INTERLACE: &[EnumKey] = &[ + ("IntE", "eliminate", "IntE", &[("ElmO", "oddFields"), ("ElmE", "evenFields")], "ElmO"), + ("IntC", "createBy", "IntC", &[("CrtI", "interpolation"), ("CrtD", "duplication")], "CrtI"), +]; + +const HSB_MODELS: &[(&str, &str)] = &[("RGBC", "rgb"), ("HSBl", "hsb"), ("HSLC", "hsl")]; + +pub(super) const HSB_HSL: &[EnumKey] = &[("Inpt", "inputMode", "ClrS", HSB_MODELS, "RGBC"), ("Otpt", "rowOrder", "ClrS", HSB_MODELS, "HSBl")]; + +/// A step of enumerations only: each value looked up, Photoshop's default for a missing one. +/// An unknown value or key keeps the Photoshop step. +pub(super) fn enums_from(d: &Descriptor, id: &str, keys: &[EnumKey]) -> Mapped { + let unsupported = Mapped::Unsupported { harmless: false }; + let names: Vec<&str> = keys.iter().map(|k| k.0).collect(); + if !only_keys(d, &names) { + return unsupported; + } + let mut p = Map::new(); + for (key, param, _, table, default) in keys { + let Some(v) = lookup(table, &get_enum(d, key).unwrap_or_else(|| (*default).to_string())) else { return unsupported }; + p.insert((*param).into(), v.into()); + } + step(id, Value::Object(p)) +} + +/// Every key in recorded order; a value left out is the command's default, which is +/// Photoshop's for these two filters. +pub(super) fn enums_to(p: &Value, keys: &[EnumKey]) -> Option { + let params: Vec<&str> = keys.iter().map(|k| k.1).chain(["target"]).collect(); + if !pixel_target(p) || !sets_only(p, ¶ms) { + return None; + } + let mut d = desc("null"); + for (key, param, ty, table, default) in keys { + let code = match p.get(*param) { + None | Some(Value::Null) => *default, + Some(v) => lookup_back(table, v.as_str()?)?, + }; + d = d.with(key, enm(ty, code)); + } + Some(d) +} + +/// Custom (`Cstm`): `Mtrx` is the 5 × 5 kernel, row by row. Photoshop takes exactly 25 cells (a +/// 3 × 3 list makes it fail), so any other matrix keeps the Photoshop step. +pub(super) fn custom_from(d: &Descriptor) -> Mapped { + let unsupported = Mapped::Unsupported { harmless: false }; + if !only_keys(d, &["Scl", "Ofst", "Mtrx"]) { + return unsupported; + } + let Some(cells) = get_list(d, "Mtrx") else { return unsupported }; + let kernel: Option> = cells.iter().map(num).collect(); + let Some(kernel) = kernel.filter(|k| k.len() == 25) else { return unsupported }; + let mut p = Map::new(); + p.insert("kernel".into(), json!(kernel)); + p.insert("scale".into(), get_num(d, "Scl ").unwrap_or(1.0).into()); + p.insert("offset".into(), get_num(d, "Ofst").unwrap_or(0.0).into()); + step("filter.other.custom", Value::Object(p)) +} + +/// `Scl `, `Ofst` and the 25 cells as integers; a kernel left out is the command's identity +/// (1 in the middle). A fraction anywhere refuses the step: it would replay rounded. +pub(super) fn custom_to(_: &str, p: &Value) -> Option { + if !pixel_target(p) || !sets_only(p, &["target", "kernel", "scale", "offset"]) { + return None; + } + let int = |v: &Value| { + let n = v.as_f64()?; + (n.fract() == 0.0 && (f64::from(i32::MIN)..=f64::from(i32::MAX)).contains(&n)).then_some(n as i32) + }; + let field = |k: &str, default: i32| match p.get(k) { + None | Some(Value::Null) => Some(default), + Some(v) => int(v), + }; + let cells: Vec = match p.get("kernel") { + None | Some(Value::Null) => (0..25).map(|i| i32::from(i == 12)).collect(), + Some(v) => v.as_array()?.iter().map(int).collect::>()?, + }; + if cells.len() != 25 { + return None; + } + Some( + desc("null") + .with("Scl ", D::Integer(field("scale", 1)?)) + .with("Ofst", D::Integer(field("offset", 0)?)) + .with("Mtrx", D::List(cells.into_iter().map(D::Integer).collect())), + ) +} diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index f9e633244c..e0c3cc03bf 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -485,6 +485,16 @@ fn every_mapped_command_exports_and_imports_back() { ("filter.blur.smartBlur", json!({"radius": 2.5, "threshold": 40, "quality": "medium", "mode": "overlayEdge"})), ("filter.other.offset", json!({"horizontal": 10, "vertical": -5, "undefinedAreas": "wrap"})), ("filter.blur.blur", json!({})), + ("filter.video.deInterlace", json!({"eliminate": "evenFields", "createBy": "duplication"})), + ("filter.video.ntscColors", json!({})), + ("filter.other.hsbHsl", json!({"inputMode": "hsl", "rowOrder": "rgb"})), + ( + "filter.other.custom", + json!({"kernel": [0, 0, 0, 0, 0, 0, -1, -1, -1, 0, 0, -1, 9, -1, 0, 0, -1, -1, -1, 0, 0, 0, 0, 0, 0], "scale": 3, "offset": -10}), + ), + ("filter.blur.average", json!({})), + ("layer.matting.removeBlackMatte", json!({})), + ("layer.matting.removeWhiteMatte", json!({})), ( "image.adjustments.levels", json!({"inBlack": 5, "inWhite": 250, "gamma": 0.9, "outBlack": 0, "outWhite": 255, "red": {"inBlack": 2, "inWhite": 255, "gamma": 1, "outBlack": 0, "outWhite": 255}}), @@ -3175,3 +3185,123 @@ fn long_names_are_clipped_instead_of_copied_into_every_diagnostic() { assert_eq!(imp.unsupported.len(), 50); assert!(imp.unsupported.iter().all(|u| u.action.chars().count() == MAX_NAME_CHARS)); } + +/// The step `d` of the Photoshop event `code` (a four-character code), as PhotoCraft loads it. +fn other_step(code: &str, d: Descriptor) -> Mapped { + let event = AtnEvent::Code(code.as_bytes().try_into().unwrap()); + (events::event_for(&event).unwrap_or_else(|| panic!("{code} is unknown")).from)(&d) +} + +/// Photoshop's De-Interlace, NTSC Colors, HSB/HSL, Custom, Average and Remove Black / White +/// Matte steps load as their commands, play, and save back with the same keys (#10607). +#[test] +fn video_other_and_matting_steps_load_play_and_save_back() { + let cells: Vec = (0..25) + .map(|i| { + D::Integer(if i == 12 { + 5 + } else if [7, 11, 13, 17].contains(&i) { + -1 + } else { + 0 + }) + }) + .collect(); + let kernel: Vec = (0..25) + .map(|i| { + if i == 12 { + 5 + } else if [7, 11, 13, 17].contains(&i) { + -1 + } else { + 0 + } + }) + .collect(); + let cases = [ + ( + "Dntr", + desc("null").with("IntE", enm("IntE", "ElmE")).with("IntC", enm("IntC", "CrtD")), + "filter.video.deInterlace", + json!({"eliminate": "evenFields", "createBy": "duplication"}), + ), + ("NTSC", desc("null"), "filter.video.ntscColors", json!({})), + ( + "HsbP", + desc("null").with("Inpt", enm("ClrS", "RGBC")).with("Otpt", enm("ClrS", "HSLC")), + "filter.other.hsbHsl", + json!({"inputMode": "rgb", "rowOrder": "hsl"}), + ), + ( + "Cstm", + desc("null").with("Scl ", D::Integer(1)).with("Ofst", D::Integer(0)).with("Mtrx", D::List(cells)), + "filter.other.custom", + json!({"kernel": kernel, "scale": 1, "offset": 0}), + ), + ("Avrg", desc("null"), "filter.blur.average", json!({})), + ("RmvB", desc("null"), "layer.matting.removeBlackMatte", json!({})), + ("RmvW", desc("null"), "layer.matting.removeWhiteMatte", json!({})), + ]; + let mut s = Session::new(); + s.execute("file.new", json!({"width": 24, "height": 24, "background": "transparent"})).unwrap(); + s.execute("filter.render.clouds", json!({})).unwrap(); + for (code, d, id, params) in cases { + let Mapped::Steps(steps) = other_step(code, d.clone()) else { panic!("{code} kept") }; + let mut p = steps[0].1.clone(); + // Numbers come back as the descriptor's integers. + if let Some(k) = p.get_mut("kernel") { + *k = json!(k.as_array().unwrap().iter().map(|v| v.as_f64().unwrap() as i32).collect::>()); + } + for k in ["scale", "offset"] { + if let Some(v) = p.get_mut(k) { + *v = json!(v.as_f64().unwrap() as i32); + } + } + assert_eq!((steps[0].0.as_str(), &p), (id, ¶ms), "{code}"); + assert_eq!(export_item(id, &steps[0].1, "S").unwrap().descriptor.unwrap().items, d.items, "{code}"); + s.actions.list.push(Action::new(code, vec![(id.to_string(), steps[0].1.clone())])); + let played = s.execute("actions.play", json!({"action": code})).unwrap(); + assert_eq!(played["ran"], 1, "{code}: {played}"); + } + // Missing keys are Photoshop's defaults. + let Mapped::Steps(steps) = other_step("Dntr", desc("null")) else { panic!("kept") }; + assert_eq!(steps[0].1, json!({"eliminate": "oddFields", "createBy": "interpolation"})); + let Mapped::Steps(steps) = other_step("HsbP", desc("null")) else { panic!("kept") }; + assert_eq!(steps[0].1, json!({"inputMode": "rgb", "rowOrder": "hsb"})); +} + +/// A step the commands cannot play keeps the Photoshop step: a Custom matrix that is not 5 × 5, +/// an unknown enumeration value, or a key the event does not have (#10607). +#[test] +fn video_other_and_matting_steps_the_commands_cannot_play_are_kept() { + let kept = |m: Mapped| matches!(m, Mapped::Unsupported { harmless: false }); + let nine = D::List((0..9).map(D::Integer).collect()); + assert!(kept(other_step("Cstm", desc("null").with("Mtrx", nine)))); + assert!(kept(other_step("Cstm", desc("null")))); + assert!(kept(other_step("HsbP", desc("null").with("Inpt", enm("ClrS", "LbCl"))))); + assert!(kept(other_step("Dntr", desc("null").with("IntE", enm("IntE", "ElmA"))))); + assert!(kept(other_step("NTSC", desc("null").with("Lvl ", D::Integer(1))))); + assert!(kept(other_step("RmvW", desc("null").with("Lyr ", D::Integer(1))))); +} + +/// What the events cannot hold refuses the step: a fractional kernel cell, a kernel that is not +/// 25 cells, a mask target, an unknown mode (#10607). +#[test] +fn video_other_and_matting_steps_that_cannot_be_written_are_refused() { + let mut cells = vec![json!(0); 25]; + cells[12] = json!(1.5); + for (id, params) in [ + ("filter.other.custom", json!({"kernel": cells})), + ("filter.other.custom", json!({"kernel": [0, 0, 0, 0, 1, 0, 0, 0, 0]})), + ("filter.other.custom", json!({"scale": 2.5})), + ("filter.blur.average", json!({"target": "mask"})), + ("filter.other.hsbHsl", json!({"inputMode": "lab"})), + ("filter.video.deInterlace", json!({"eliminate": "allFields"})), + ] { + assert!(export_item(id, ¶ms, "S").is_none(), "{id} {params} exported"); + } + // A Custom step without a kernel is the command's identity kernel. + let d = export_item("filter.other.custom", &json!({}), "S").unwrap().descriptor.unwrap(); + let cells = get_list(&d, "Mtrx").unwrap(); + assert_eq!((cells.len(), cells.get(12)), (25, Some(&D::Integer(1)))); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 5ef09a2e7e..d46773f734 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-26 · **Change:** minor (TIFFs up to 300,000 px a side open, decoded a group of rows at a time into the layer, #8169; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; PSD Filter Gallery: Halftone Pattern dot/circle/line settings survive PSD/PSB saves (#8940); Save for Web writes WebP, lossy or lossless, #6391; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency, #8877; layered TIFF writes the TySh text version and descriptor in PSD big-endian order while the header, matrix, warp and bounds keep container order, #9098; layered TIFF writes Photoshop's 14-byte global layer mask, so Photoshop opens the layers instead of only the composite, #9095; legacy PSD point text placed from the layout ink box after font resolution, #6094; `.atn`: 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; `.asl` layer style libraries read and written, #1051; JPEG Options on File › Save, Save As and Save a Copy, FILE-215-19 partial (no Matte, progressive Scans or size preview); 32-bit Hue/Saturation unclipped and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; JPEG Options on Save As and Save a Copy, FILE-215-19; TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; Cineon write: 10-bit gray/RGB; animated WebP frames load into video layers, #1198; Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 53 filters (39 more: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag, Invert, Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding; Amiga IFF write: 24-plane ILBM, Save As `.iff`; PSD stores spot channels as Photoshop does, ink 0 = solid, and numbers them in 1053, #7415 split; TIFF writes and reads alpha and spot channels as extra samples with Photoshop's channel resources, PSD output unchanged, FILE-215-7, FILE-215-17's TIFF part, #7415; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-31 · **Change:** minor (`.atn`: De-Interlace, NTSC Colors, HSB/HSL, Custom, Average and Remove Black / White Matte steps load, play and save back, #10607; TIFFs up to 300,000 px a side open, decoded a group of rows at a time into the layer, #8169; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; PSD Filter Gallery: Halftone Pattern dot/circle/line settings survive PSD/PSB saves (#8940); Save for Web writes WebP, lossy or lossless, #6391; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency, #8877; layered TIFF writes the TySh text version and descriptor in PSD big-endian order while the header, matrix, warp and bounds keep container order, #9098; layered TIFF writes Photoshop's 14-byte global layer mask, so Photoshop opens the layers instead of only the composite, #9095; legacy PSD point text placed from the layout ink box after font resolution, #6094; `.atn`: 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; `.asl` layer style libraries read and written, #1051; JPEG Options on File › Save, Save As and Save a Copy, FILE-215-19 partial (no Matte, progressive Scans or size preview); 32-bit Hue/Saturation unclipped and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; JPEG Options on Save As and Save a Copy, FILE-215-19; TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; Cineon write: 10-bit gray/RGB; animated WebP frames load into video layers, #1198; Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 53 filters (39 more: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag, Invert, Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding; Amiga IFF write: 24-plane ILBM, Save As `.iff`; PSD stores spot channels as Photoshop does, ink 0 = solid, and numbers them in 1053, #7415 split; TIFF writes and reads alpha and spot channels as extra samples with Photoshop's channel resources, PSD output unchanged, FILE-215-7, FILE-215-17's TIFF part, #7415; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency) · **Target:** Adobe Photoshop 2026 **PSD Filter Gallery (#8940, 2026-10-23):** Halftone Pattern maps its size, contrast and dot/circle/line pattern as editable settings on import and export, with the reference application's descriptor keys (`HlSz`, `Cntr`, `ScrT`: `ScrD`/`ScrC`/`ScrL`). PSD/PSB round trips pass at 8/16/32 bits. This does not claim pixel equivalence for the existing renderer. Unknown or malformed settings remain verbatim on import. @@ -148,6 +148,7 @@ Embedded SVG and PDF-compatible AI/PDF/EPS payloads can use an explicit VectorCr | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | `.atn`: De-Interlace (`Dntr`), NTSC Colors (`NTSC`), HSB/HSL (`HsbP`), Custom (`Cstm`, a 5 × 5 `Mtrx`), Average (`Avrg`) and Remove Black / White Matte (`RmvB`/`RmvW`) map to their commands with Photoshop's defaults and save back with the same keys; Replace Color and the direct Gradient Map (`GrMp`) still kept verbatim (#10607) | | 2026-10-26 | minor | Embedded AI/PDF/EPS save-back retains native format and a separate source-bound display preview; original sources remain undoable. Native Adobe interoperability and standalone EPS/DCS remain unverified; no corpus floor or percentage changed. | | 2026-10-22 | minor | Embedded PSD vectors gain explicit bounded SVG editor/save-back; standalone EPS/DCS and native-format fidelity remain open. | | 2026-10-22 | minor | TIFF: flat pages up to 300,000 px a side open (was 2^30 px, about 32,768²): `codecs::TiffBands` decodes a group of rows at a time into the layer's tiles. The 117,504 × 22,464 px banner from #8456 opens in 3.3 s at 11.45 GB peak for a 10.56 GB layer; 30,000² 6.8 s / 6.43 GB → 1.1 s / 3.86 GB; 36 MP 220 → 52 ms. Layered TIFFs still decode whole (#8169) | From 815f5bdad0cd1c667dfc7111090cc0fcebfe2268 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Sat, 31 Oct 2026 20:41:15 -0700 Subject: [PATCH 041/216] Preserve measured Trace levels through playback and save --- .../engine/src/cmd/atn_cmds/steps_stylize.rs | 6 +-- .../engine/src/cmd/atn_cmds/stylize_tests.rs | 52 +++++++++---------- docs/atn.md | 22 +++++--- docs/file-format-parity.md | 6 ++- docs/gaps.md | 6 +-- 5 files changed, 50 insertions(+), 42 deletions(-) diff --git a/crates/engine/src/cmd/atn_cmds/steps_stylize.rs b/crates/engine/src/cmd/atn_cmds/steps_stylize.rs index a61046d95e..126eb5d29b 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_stylize.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_stylize.rs @@ -23,8 +23,8 @@ const EXTRUDE: &[Field] = &[ Field("ExtR", "depthMode", Kind::Enum("ExtR", &[("Rndm", "random"), ("LvlB", "levelBased")], "random")), ]; const TRACE: &[Field] = &[ - // PhotoCraft's native range; the reference application's dialog range is not measured. - Field("Lvl ", "level", Kind::Long(128, 0, 255)), + // Recorded levels stay intact; playback alone clamps to the measured boundary image. + Field("Lvl ", "level", Kind::Long(128, i32::MIN, i32::MAX)), Field("Edg ", "edge", Kind::Enum("CntE", &[("Lwr ", "lower"), ("Upr ", "upper")], "upper")), ]; const WIND: &[Field] = &[ @@ -145,8 +145,6 @@ fn params_from(id: &str, d: &Descriptor) -> Option> { return None; } let value = match d.get(key) { - // Keep unmeasured Trace levels instead of changing them to the native limit. - Some(D::Integer(n)) if id == "filter.stylize.traceContour" && !(0..=255).contains(n) => return None, Some(value) => kind.read(value)?, None => kind.default(), }; diff --git a/crates/engine/src/cmd/atn_cmds/stylize_tests.rs b/crates/engine/src/cmd/atn_cmds/stylize_tests.rs index 156b856b72..7b3cb156db 100644 --- a/crates/engine/src/cmd/atn_cmds/stylize_tests.rs +++ b/crates/engine/src/cmd/atn_cmds/stylize_tests.rs @@ -208,35 +208,33 @@ fn numeric_imports_clamp_measured_ranges() { } } -/// Map native-supported Trace levels exactly; keep other recorded values without changing them. +/// Trace levels retain their recorded integers; only playback uses the measured boundary image. #[test] -fn trace_levels_outside_native_range_keep_their_bytes_and_flags() { - for level in [0, 128, 255] { - let mut s = load_items(vec![code_item("TrcC", desc("null").with("Lvl ", D::Integer(level)))]); - let params = &s.actions.list[0].steps[0].1; - assert_eq!(s.actions.list[0].steps[0].0, "filter.stylize.traceContour"); - assert_eq!(params["level"], level); - assert_eq!( - crate::filters_ext::params_for("filter.stylize.traceContour", params), - crate::filters_ext::params_for("filter.stylize.traceContour", &json!({"level":level,"edge":"upper"})) - ); - assert_eq!(saved_items(&mut s)[0].descriptor.as_ref().unwrap().get("Lvl "), Some(&D::Integer(level))); - assert!(export_item("filter.stylize.traceContour", &json!({"level":level}), "Group D").is_some()); +fn trace_levels_round_trip_and_play_at_the_boundary() { + for level in [i32::MIN, -1, 0, 128, 255, 256, 300, i32::MAX] { + for (edge, name) in [("Lwr ", "lower"), ("Upr ", "upper")] { + let descriptor = desc("null").with("Lvl ", D::Integer(level)).with("Edg ", enm("CntE", edge)); + let mut item = code_item("TrcC", descriptor.clone()); + item.with_dialog = true; + let mut imported = load_items(vec![item]); + assert_eq!(imported.actions.list[0].steps[0].0, "filter.stylize.traceContour", "{level}/{name}"); + assert_eq!(imported.actions.list[0].steps[0].1["level"], level); + let saved = saved_items(&mut imported); + assert_eq!(saved[0].descriptor.as_ref().unwrap().to_bytes(), descriptor.to_bytes()); + assert!(saved[0].with_dialog); + assert!(export_item("filter.stylize.traceContour", &json!({"level":level,"edge":name}), "Group D").is_some()); + for depth in [8, 16, 32] { + let mut direct = Session::new(); + prepare_pattern(&mut imported, depth, "rgb"); + prepare_pattern(&mut direct, depth, "rgb"); + let played = imported.execute("actions.play", json!({"action":"A"})).unwrap(); + assert!(played.get("failed").is_none(), "{level}/{name}/{depth}: {played}"); + direct.execute("filter.stylize.traceContour", json!({"level":level.clamp(0,255),"edge":name})).unwrap(); + assert_eq!(pixels(&imported), pixels(&direct), "{level}/{name}/{depth}"); + } + } } - for level in [i32::MIN, -1, 256, 300, i32::MAX] { - let descriptor = desc("null").with("Lvl ", D::Integer(level)).with("Edg ", enm("CntE", "Lwr ")); - let mut item = code_item("TrcC", descriptor.clone()); - item.dictionary = b"Trace Contour".to_vec(); - item.enabled = false; - item.with_dialog = true; - item.dialog_options = 2; - let mut s = load_items(vec![item.clone()]); - assert_eq!(s.actions.list[0].steps[0].0, PHOTOSHOP_STEP, "Trace Contour/{level}"); - assert!(s.actions.list[0].disabled.contains(&0)); - assert!(s.actions.list[0].dialogs.contains(&0)); - let saved = saved_items(&mut s); - assert_eq!(saved, vec![item]); - assert_eq!(saved[0].descriptor.as_ref().unwrap().to_bytes(), descriptor.to_bytes()); + for level in [255.5, 2147483648.0] { assert!(export_item("filter.stylize.traceContour", &json!({"level":level}), "Group D").is_none()); } } diff --git a/docs/atn.md b/docs/atn.md index d7d1743692..fbfdcf7d8c 100644 --- a/docs/atn.md +++ b/docs/atn.md @@ -16,7 +16,7 @@ so playback skips them. This page has two parts: the events PhotoCraft maps today, and a measured reference for the events it does not map yet, with everything needed to implement them without the reference application. -## Supported events (129) +## Supported events (128) | Code | String ID | Label | PhotoCraft command(s) | |---|---|---|---| @@ -86,7 +86,6 @@ does not map yet, with everything needed to implement them without the reference | `CtTL` | cutToLayer | Layer Via Cut | `layer.new.layerViaCut` | | `Lvls` | levels | Levels | `image.adjustments.levels`, `image.autoTone`, `image.autoContrast` | | `Mk ` | make | Make | `layer.new.layer`, `layer.new.group`, `layer.groupLayers`, `layer.duplicate`, `layer.new.layerViaCopy`, `layer.new.layerViaCut`, `layer.newFillLayer.solidColor`, `layer.newFillLayer.pattern`, `layer.newAdjustmentLayer.levels`, `layer.newAdjustmentLayer.curves`, `layer.newAdjustmentLayer.hueSaturation`, `layer.newAdjustmentLayer.brightnessContrast`, `layer.newAdjustmentLayer.threshold`, `layer.newAdjustmentLayer.posterize`, `layer.newAdjustmentLayer.invert`, `layer.newAdjustmentLayer.colorBalance`, `layer.newAdjustmentLayer.vibrance`, `layer.newAdjustmentLayer.exposure`, `layer.newAdjustmentLayer.photoFilter`, `layer.newAdjustmentLayer.blackWhite`, `layer.newAdjustmentLayer.channelMixer`, `layer.newAdjustmentLayer.selectiveColor`, `layer.newAdjustmentLayer.gradientMap`, `layer.layerMask.revealAll`, `layer.layerMask.hideAll`, `layer.layerMask.revealSelection`, `layer.layerMask.hideSelection`, `file.new`, `file.newFromClipboard`, `history.newSnapshot` | -| (string ID only) | animationFramesFromLayers | Make Frames from Layers | Kept step (disabled) | | `Mxm ` | maximum | Maximum | `filter.other.maximum` | | `Mdn ` | median | Median | `filter.noise.median` | | `Mrg2` | mergeLayersNew | Merge Layers | `layer.mergeLayers` | @@ -159,10 +158,11 @@ with cell size 2, and Trace Contour imports with its upper edge. Saving a native that omits these parameters preserves its native behavior: Mosaic cell size 10 and Trace Contour lower edge. Explicit imported values stay explicit, including changed Trace Contour levels and edges that the reference application applies but does not echo. -Trace Contour maps levels within PhotoCraft's native `0..255` range unchanged. -Other recorded levels remain kept steps with their descriptors and flags intact; -native commands outside that range refuse export. This is a native support limit, -not a measurement of the reference application's dialog range, which remains pending. +Trace Contour keeps the full recorded integer level, including values below 0 +or above 255. The measured reference reports the boundary image for these values, +so playback uses the existing `0..255` clamp while saving retains the original +integer. That rendering limit is not a limit on the serialized descriptor; the +reference application's dialog range remains unmeasured. Mosaic accepts a unitless pixel value on import and writes a `#Pxl` unit double. Other fields keep their documented integer, boolean and enum types. Unknown keys, @@ -286,6 +286,16 @@ No parameters: the step carries an empty descriptor. |---|---|---|---|---|---| | `ClSz` | cellSize | integer | | 5 | 3 .. 300 (out-of-range values are clamped) | +## Recognized steps kept disabled + +These events are recognized but have no playable command. They load as kept steps +switched off, and Save Actions preserves their descriptors. They are excluded from +the supported-event count and the unmapped measured reference below. + +| String ID | Label | +|---|---| +| animationFramesFromLayers | Make Frames from Layers | + ## Event reference: events not mapped yet Each entry below is an event the reference application records that PhotoCraft does not map yet: its diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 471d975b65..e341e216d5 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-31 · **Change:** minor (`.atn`: 129 supported and 89 unmapped events after the twelve Stylize/Pixelate mappings (Group D, #10606); the supported table and measured reference are in [atn.md](atn.md); TIFFs up to 300,000 px a side open, decoded a group of rows at a time into the layer, #8169; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; PSD Filter Gallery: Halftone Pattern dot/circle/line settings survive PSD/PSB saves (#8940); Save for Web writes WebP, lossy or lossless, #6391; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency, #8877; layered TIFF writes the TySh text version and descriptor in PSD big-endian order while the header, matrix, warp and bounds keep container order, #9098; layered TIFF writes Photoshop's 14-byte global layer mask, so Photoshop opens the layers instead of only the composite, #9095; legacy PSD point text placed from the layout ink box after font resolution, #6094; `.atn`: 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; `.asl` layer style libraries read and written, #1051; JPEG Options on File › Save, Save As and Save a Copy, FILE-215-19 partial (no Matte, progressive Scans or size preview); 32-bit Hue/Saturation unclipped and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; JPEG Options on Save As and Save a Copy, FILE-215-19; TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; Cineon write: 10-bit gray/RGB; animated WebP frames load into video layers, #1198; Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 53 filters (39 more: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag, Invert, Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding; Amiga IFF write: 24-plane ILBM, Save As `.iff`; PSD stores spot channels as Photoshop does, ink 0 = solid, and numbers them in 1053, #7415 split; TIFF writes and reads alpha and spot channels as extra samples with Photoshop's channel resources, PSD output unchanged, FILE-215-7, FILE-215-17's TIFF part, #7415; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-31 · **Change:** minor (`.atn`: 128 supported and 89 unmapped events after the twelve Stylize/Pixelate mappings (Group D, #10606); the supported table and measured reference are in [atn.md](atn.md); TIFFs up to 300,000 px a side open, decoded a group of rows at a time into the layer, #8169; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; PSD Filter Gallery: Halftone Pattern dot/circle/line settings survive PSD/PSB saves (#8940); Save for Web writes WebP, lossy or lossless, #6391; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency, #8877; layered TIFF writes the TySh text version and descriptor in PSD big-endian order while the header, matrix, warp and bounds keep container order, #9098; layered TIFF writes Photoshop's 14-byte global layer mask, so Photoshop opens the layers instead of only the composite, #9095; legacy PSD point text placed from the layout ink box after font resolution, #6094; `.atn`: 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; `.asl` layer style libraries read and written, #1051; JPEG Options on File › Save, Save As and Save a Copy, FILE-215-19 partial (no Matte, progressive Scans or size preview); 32-bit Hue/Saturation unclipped and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; JPEG Options on Save As and Save a Copy, FILE-215-19; TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; Cineon write: 10-bit gray/RGB; animated WebP frames load into video layers, #1198; Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 53 filters (39 more: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag, Invert, Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding; Amiga IFF write: 24-plane ILBM, Save As `.iff`; PSD stores spot channels as Photoshop does, ink 0 = solid, and numbers them in 1053, #7415 split; TIFF writes and reads alpha and spot channels as extra samples with Photoshop's channel resources, PSD output unchanged, FILE-215-7, FILE-215-17's TIFF part, #7415; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency) · **Target:** Adobe Photoshop 2026 **PSD Filter Gallery (#8940, 2026-10-23):** Halftone Pattern maps its size, contrast and dot/circle/line pattern as editable settings on import and export, with the reference application's descriptor keys (`HlSz`, `Cntr`, `ScrT`: `ScrD`/`ScrC`/`ScrL`). PSD/PSB round trips pass at 8/16/32 bits. This does not claim pixel equivalence for the existing renderer. Unknown or malformed settings remain verbatim on import. @@ -146,10 +146,12 @@ Embedded SVG and PDF-compatible AI/PDF/EPS payloads can use an explicit VectorCr ## Revision history +The original reference entry retains its source date and historical totals; current totals are in the status above. + | Date | Change | Summary | |---|---|---| -| 2026-10-31 | minor | `.atn` Group D: twelve Stylize/Pixelate events load, play and save; 129 supported and 89 unmapped. Supported-table columns corrected against the event registry; the original 117/101 reference entry remains historical. | | 2026-11-01 | minor | `.atn`: event reference [atn.md](atn.md): the 117 supported events, and measured keys, defaults, ranges and enum values for 101 events not mapped yet | +| 2026-10-31 | minor | `.atn` Group D: twelve Stylize/Pixelate events load, play and save; 128 supported and 89 unmapped. Supported-table columns corrected against the event registry; the original 117/101 reference entry remains historical. | | 2026-10-26 | minor | Embedded AI/PDF/EPS save-back retains native format and a separate source-bound display preview; original sources remain undoable. Native Adobe interoperability and standalone EPS/DCS remain unverified; no corpus floor or percentage changed. | | 2026-10-22 | minor | Embedded PSD vectors gain explicit bounded SVG editor/save-back; standalone EPS/DCS and native-format fidelity remain open. | | 2026-10-22 | minor | TIFF: flat pages up to 300,000 px a side open (was 2^30 px, about 32,768²): `codecs::TiffBands` decodes a group of rows at a time into the layer's tiles. The 117,504 × 22,464 px banner from #8456 opens in 3.3 s at 11.45 GB peak for a 10.56 GB layer; 30,000² 6.8 s / 6.43 GB → 1.1 s / 3.86 GB; 36 MP 220 → 52 ms. Layered TIFFs still decode whole (#8169) | diff --git a/docs/gaps.md b/docs/gaps.md index cb9472f7c1..6fedc7d09c 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (G11: twelve Stylize/Pixelate ATN events mapped, 129 supported/89 unmapped; 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; G9: 12 of 181 preferences do nothing, as the scorecard prefs audit counts them; G6: Art History Brush landed as partial, TOOL-213-20, #1610, so no toolbar tool is missing; G4: five hard-round built-in presets use 5% spacing to remove stacked-tip outlines and reduce pressure-opacity bands, #8842; G9: Crop tool canvas menu, #6561; G2: Content-Aware Scale seam carving 1.65–3.6× faster with unchanged output, P28 row 3.3 → 1.6 s on a 4-core VM, #5236; G9: UI scale presets done, 105–120% steps, #3222; G14: lossy DNG, raw sidecars, Camera Raw Defaults live, Calibration and Auto, CR3 assessed as not clean-room feasible; G8: PSD type on a path opens on its path and saves back into the file's `Txt2`, #7962; G11: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; adjustment clipping stays visible in compact Properties, #5231; G9: Color panel modes, ramp and copy commands, the same in every theme, UI-217-17, #6000; G17: Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; G10: `.asl` style libraries landed, #1051; G8: area type (TYPE-218-10); G1: smart objects keep Photoshop's Cylinder warp, #4112; G8: paragraph direction buttons in the Paragraph panel and options bar, TYPE-218-2 partial; G1: 32-bit Hue/Saturation and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; G11: compound layer properties export as separate Photoshop Set events, rename last, #8304; G11: Batch and droplets take the result at the action's Save step, so actions ending in Save As and Close work; G11: `.atn` steps that switch to another open document import, play and save back, #7103; G14: the Nikon D4 white level follows the sample depth, so 12-bit D4 NEFs no longer open 2.6 stops dark, #5978; G14: a Nikon NEF's Adobe RGB preview is read as Adobe RGB (maker-note ColorSpace 0x001E), #5987; Apply Layer Comp action mapping, #7101; G10: JPEG Options dialog landed, FILE-215-19 partial (no Matte, progressive Scans or size preview); G10: Cineon write; G10: DPX read and write, #1049, Cineon and Amiga IFF read; Classical Object Finder improvement; learned segmentation remains open; G4: named, stateful section locks and visible keyboard focus in Brush Settings; G4: independent Brush Pose tilt-axis overrides, #3825 partial; G4: Transfer Mixer-only controls, #3825 partial; G9: Preferences › File Handling › Save in Background takes effect, #6207, 24 of 169 preferences do nothing; G7: Content-Aware Move previews every selection the command takes, coarse then exact, TOOL-213-4 done, #6680; G4: Pattern Stamp and Background Eraser draw live, TOOL-213-7; G9: Animated Zoom pref read: holding the Zoom tool zooms continuously, #6210, prefs that do nothing 25 of 166; G2: a cancelled 24 MP Gaussian Blur job stops in at most 44 ms, P25 under its 200 ms budget in local runs, baseline not re-run, #6205; G1: generated large-file fixtures, a 4.38 GB PSB opens on Windows through io, FILE-216-2 done, #5107; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed; G10: Amiga IFF write; G10: PSD spot channels in Photoshop's polarity, earlier PhotoCraft PSDs keep their ink, #7415 split; G10: TIFF alpha and spot channels written and read as extra samples, FILE-215-7 done, FILE-215-17's TIFF part, #7415) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (G11: twelve Stylize/Pixelate ATN events mapped, 128 supported/89 unmapped; 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; G9: 12 of 181 preferences do nothing, as the scorecard prefs audit counts them; G6: Art History Brush landed as partial, TOOL-213-20, #1610, so no toolbar tool is missing; G4: five hard-round built-in presets use 5% spacing to remove stacked-tip outlines and reduce pressure-opacity bands, #8842; G9: Crop tool canvas menu, #6561; G2: Content-Aware Scale seam carving 1.65–3.6× faster with unchanged output, P28 row 3.3 → 1.6 s on a 4-core VM, #5236; G9: UI scale presets done, 105–120% steps, #3222; G14: lossy DNG, raw sidecars, Camera Raw Defaults live, Calibration and Auto, CR3 assessed as not clean-room feasible; G8: PSD type on a path opens on its path and saves back into the file's `Txt2`, #7962; G11: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; adjustment clipping stays visible in compact Properties, #5231; G9: Color panel modes, ramp and copy commands, the same in every theme, UI-217-17, #6000; G17: Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; G10: `.asl` style libraries landed, #1051; G8: area type (TYPE-218-10); G1: smart objects keep Photoshop's Cylinder warp, #4112; G8: paragraph direction buttons in the Paragraph panel and options bar, TYPE-218-2 partial; G1: 32-bit Hue/Saturation and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; G11: compound layer properties export as separate Photoshop Set events, rename last, #8304; G11: Batch and droplets take the result at the action's Save step, so actions ending in Save As and Close work; G11: `.atn` steps that switch to another open document import, play and save back, #7103; G14: the Nikon D4 white level follows the sample depth, so 12-bit D4 NEFs no longer open 2.6 stops dark, #5978; G14: a Nikon NEF's Adobe RGB preview is read as Adobe RGB (maker-note ColorSpace 0x001E), #5987; Apply Layer Comp action mapping, #7101; G10: JPEG Options dialog landed, FILE-215-19 partial (no Matte, progressive Scans or size preview); G10: Cineon write; G10: DPX read and write, #1049, Cineon and Amiga IFF read; Classical Object Finder improvement; learned segmentation remains open; G4: named, stateful section locks and visible keyboard focus in Brush Settings; G4: independent Brush Pose tilt-axis overrides, #3825 partial; G4: Transfer Mixer-only controls, #3825 partial; G9: Preferences › File Handling › Save in Background takes effect, #6207, 24 of 169 preferences do nothing; G7: Content-Aware Move previews every selection the command takes, coarse then exact, TOOL-213-4 done, #6680; G4: Pattern Stamp and Background Eraser draw live, TOOL-213-7; G9: Animated Zoom pref read: holding the Zoom tool zooms continuously, #6210, prefs that do nothing 25 of 166; G2: a cancelled 24 MP Gaussian Blur job stops in at most 44 ms, P25 under its 200 ms budget in local runs, baseline not re-run, #6205; G1: generated large-file fixtures, a 4.38 GB PSB opens on Windows through io, FILE-216-2 done, #5107; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed; G10: Amiga IFF write; G10: PSD spot channels in Photoshop's polarity, earlier PhotoCraft PSDs keep their ink, #7415 split; G10: TIFF alpha and spot channels written and read as extra samples, FILE-215-7 done, FILE-215-17's TIFF part, #7415) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -111,7 +111,7 @@ what an agent can't finish alone. - **Estimate:** 120–200 h. ### G11. Actions and scripting -- **Done 2026-10-31 (#10606):** all twelve Stylize/Pixelate ATN events (Group D) load, play and save with measured defaults, documented clamping and strict export checks. The [event reference](atn.md) now lists 129 supported and 89 unmapped events. Other Actions/scripting gaps remain; no overall readiness percentage or estimate changed. +- **Done 2026-10-31 (#10606):** all twelve Stylize/Pixelate ATN events (Group D) load, play and save with measured defaults, documented clamping and strict export checks. The [event reference](atn.md) now lists 128 supported and 89 unmapped events. Other Actions/scripting gaps remain; no overall readiness percentage or estimate changed. - **2026-10-21 evidence (#8304):** Save Actions expands compound layer-property edits into separate Photoshop Set events, with rename last so named layer references remain valid. Exported descriptors and native playback at RGB 8/16/32-bit have [regression coverage](../crates/engine/src/atn_cmds/layer_props_tests.rs); @@ -425,7 +425,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| -| 2026-10-31 | minor | G11: twelve Stylize/Pixelate ATN events mapped; current counts 129 supported/89 unmapped. Other Actions gaps, readiness percentages and estimates unchanged. | +| 2026-10-31 | minor | G11: twelve Stylize/Pixelate ATN events mapped; current counts 128 supported/89 unmapped. Other Actions gaps, readiness percentages and estimates unchanged. | | 2026-10-27 | minor | Smooth/Feather command reads and replacement use selection bounds plus blur reach; full-canvas reference and release comparisons extended; percentages and scorecard budgets unchanged | | 2026-10-27 | minor | G9: 12 of 181 preferences do nothing, the scorecard's count with integration batch 9 (#9775) on main; no estimate changed | | 2026-10-26 | minor | Expand/Contract/Border distance work, mask reads and replacement cropped to nonzero bounds; bounded distances for radii ≤5; 36 MP exact-output comparisons improve 1.54–37.82× across eight cases; percentages, estimates and scorecard budgets unchanged | From 5a4bd379d03ec8ddefb1c78cacf20c9af9f87fdb Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 31 Oct 2026 21:44:27 -0600 Subject: [PATCH 042/216] Actions: map all 47 Filter Gallery effects (#10592) Filter Gallery steps (GEfc with the effect in GEfk, or the effect's own event) map every effect to filter.gallery., not only Spatter: each key in recorded order, out-of-range values clamped and missing keys given Photoshop's defaults, enumerations, Invert and Neon Glow's colour mapped both ways. The table is generated from the event reference's Group A; every integer range and default matches the effects'. Glass maps the Frosted and Canvas textures the reference gives codes for. A setting the event cannot hold refuses the step. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/atn_cmds.rs | 1 + crates/engine/src/cmd/atn_cmds/events.rs | 2 + .../engine/src/cmd/atn_cmds/steps_gallery.rs | 573 ++++++++++++++++++ crates/engine/src/cmd/atn_cmds/steps_image.rs | 19 +- crates/engine/src/cmd/atn_cmds/tests.rs | 141 +++++ docs/file-format-parity.md | 3 +- 6 files changed, 720 insertions(+), 19 deletions(-) create mode 100644 crates/engine/src/cmd/atn_cmds/steps_gallery.rs diff --git a/crates/engine/src/cmd/atn_cmds.rs b/crates/engine/src/cmd/atn_cmds.rs index 42a4722479..b331b4d7c4 100644 --- a/crates/engine/src/cmd/atn_cmds.rs +++ b/crates/engine/src/cmd/atn_cmds.rs @@ -33,6 +33,7 @@ mod events; mod fields; mod files; mod steps_doc; +mod steps_gallery; mod steps_image; mod steps_layer; mod steps_make; diff --git a/crates/engine/src/cmd/atn_cmds/events.rs b/crates/engine/src/cmd/atn_cmds/events.rs index 5723c93c59..263063995f 100644 --- a/crates/engine/src/cmd/atn_cmds/events.rs +++ b/crates/engine/src/cmd/atn_cmds/events.rs @@ -619,6 +619,8 @@ pub(super) fn event_for(item: &AtnEvent) -> Option<&'static Event> { let id = item.id(); let find = |id: &str| EVENTS.iter().find(|e| (!e.code.is_empty() && e.code.trim_end() == id.trim_end()) || e.name == id); find(&id).or_else(|| match item { + // An effect run from its own menu item records its id as the event (#10592). + AtnEvent::Code(_) if super::steps_gallery::is_effect(&id) => find("filterGallery"), AtnEvent::Code(_) => LEGACY_CODES.iter().find(|(code, _)| *code == id).and_then(|(_, name)| find(name)), AtnEvent::Name(_) => None, }) diff --git a/crates/engine/src/cmd/atn_cmds/steps_gallery.rs b/crates/engine/src/cmd/atn_cmds/steps_gallery.rs new file mode 100644 index 0000000000..befd72841c --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/steps_gallery.rs @@ -0,0 +1,573 @@ +//! Photoshop's Filter Gallery steps (#10592): the `GEfc` event with the effect's id in `GEfk` +//! and its settings, for all 47 effects. Each maps to its `filter.gallery.` command with +//! the keys, limits and defaults Photoshop measures (`docs/atn.md`, Group A); a stack of several +//! effects stays a Photoshop step. + +use super::descriptors::{color_desc, color_hex, desc, enm, get_bool, get_enum, get_num, get_obj, trimmed}; +use super::events::pixel_target; +use super::fields::{Mapped, step}; +use super::tables::{lookup, lookup_back}; +use super::{D, Descriptor, Map, Value}; + +/// One key of an effect, in recorded order. +enum G { + /// An integer clamped to `min..=max` on import, as Photoshop clamps it, and its default. + Int(&'static str, &'static str, i32, i32, i32), + /// An enumeration (Photoshop ↔ PhotoCraft values) and Photoshop's default value. An unknown + /// value keeps the step. + Enum(&'static str, &'static str, &'static [(&'static str, &'static str)], &'static str), + Bool(&'static str, &'static str, bool), + /// A colour object ↔ `#rrggbb`. + Color(&'static str, &'static str), +} + +impl G { + fn key(&self) -> &'static str { + match self { + G::Int(k, ..) | G::Enum(k, ..) | G::Bool(k, ..) | G::Color(k, _) => k, + } + } + + fn param(&self) -> &'static str { + match self { + G::Int(_, p, ..) | G::Enum(_, p, ..) | G::Bool(_, p, _) | G::Color(_, p) => p, + } + } +} + +/// Photoshop's effect id (`GEfk`), the command, and the effect's keys in recorded order. +/// Generated from the event reference's Group A tables; Glass maps only the textures whose +/// codes the reference gives (Frosted, Canvas). +const GALLERY: &[(&str, &str, &[G])] = &[ + // Accented Edges + ( + "AccE", + "filter.gallery.accentedEdges", + &[G::Int("EdgW", "edgeWidth", 1, 14, 2), G::Int("EdgB", "edgeBrightness", 0, 50, 38), G::Int("Smth", "smoothness", 1, 15, 5)], + ), + // Angled Strokes + ( + "AngS", + "filter.gallery.angledStrokes", + &[G::Int("DrcB", "directionBalance", 0, 100, 50), G::Int("StrL", "strokeLength", 3, 50, 15), G::Int("Shrp", "sharpness", 0, 10, 3)], + ), + // Bas Relief + ( + "BsRl", + "filter.gallery.basRelief", + &[ + G::Int("Dtl ", "detail", 1, 15, 13), + G::Int("Smth", "smoothness", 1, 15, 3), + G::Enum( + "LghD", + "light", + &[ + ("LDBL", "bottomLeft"), + ("LDBR", "bottomRight"), + ("LDBt", "bottom"), + ("LDLf", "left"), + ("LDRg", "right"), + ("LDTL", "topLeft"), + ("LDTR", "topRight"), + ("LDTp", "top"), + ], + "LDBt", + ), + ], + ), + // Chalk Charcoal + ( + "ChlC", + "filter.gallery.chalkCharcoal", + &[G::Int("ChrA", "charcoalArea", 0, 20, 6), G::Int("ChlA", "chalkArea", 0, 20, 6), G::Int("StrP", "strokePressure", 0, 5, 1)], + ), + // Charcoal + ( + "Chrc", + "filter.gallery.charcoal", + &[G::Int("ChAm", "charcoalThickness", 1, 7, 1), G::Int("Dtl ", "detail", 0, 5, 5), G::Int("LgDr", "lightDarkBalance", 0, 100, 50)], + ), + // Chrome + ("Chrm", "filter.gallery.chrome", &[G::Int("Dtl ", "detail", 0, 10, 4), G::Int("Smth", "smoothness", 0, 10, 7)]), + // Colored Pencil + ( + "ClrP", + "filter.gallery.coloredPencil", + &[G::Int("Pncl", "pencilWidth", 1, 24, 4), G::Int("StrP", "strokePressure", 0, 15, 8), G::Int("PprB", "paperBrightness", 0, 50, 25)], + ), + // Conte Crayon + ( + "CntC", + "filter.gallery.conteCrayon", + &[ + G::Int("FrgL", "foregroundLevel", 1, 15, 11), + G::Int("BckL", "backgroundLevel", 1, 15, 7), + G::Enum("TxtT", "texture", &[("TxBr", "brick"), ("TxBu", "burlap"), ("TxCa", "canvas"), ("TxSt", "sandstone")], "TxCa"), + G::Int("Scln", "scaling", 50, 200, 100), + G::Int("Rlf ", "relief", 0, 50, 4), + G::Enum( + "LghD", + "light", + &[ + ("LDBL", "bottomLeft"), + ("LDBR", "bottomRight"), + ("LDBt", "bottom"), + ("LDLf", "left"), + ("LDRg", "right"), + ("LDTL", "topLeft"), + ("LDTR", "topRight"), + ("LDTp", "top"), + ], + "LDTp", + ), + G::Bool("InvT", "invert", false), + ], + ), + // Craquelure + ( + "Crql", + "filter.gallery.craquelure", + &[G::Int("CrcS", "crackSpacing", 2, 100, 15), G::Int("CrcD", "crackDepth", 0, 10, 6), G::Int("CrcB", "crackBrightness", 0, 10, 9)], + ), + // Crosshatch + ( + "Crsh", + "filter.gallery.crosshatch", + &[G::Int("StrL", "strokeLength", 3, 50, 9), G::Int("Shrp", "sharpness", 0, 20, 6), G::Int("Strg", "strength", 1, 3, 1)], + ), + // Cutout + ( + "Ct", + "filter.gallery.cutout", + &[G::Int("NmbL", "numberOfLevels", 2, 8, 4), G::Int("EdgS", "edgeSimplicity", 0, 10, 4), G::Int("EdgF", "edgeFidelity", 1, 3, 2)], + ), + // Dark Strokes + ( + "DrkS", + "filter.gallery.darkStrokes", + &[G::Int("Blnc", "balance", 0, 10, 5), G::Int("BlcI", "blackIntensity", 0, 10, 6), G::Int("WhtI", "whiteIntensity", 0, 10, 2)], + ), + // Diffuse Glow + ( + "DfsG", + "filter.gallery.diffuseGlow", + &[G::Int("Grns", "graininess", 0, 10, 6), G::Int("GlwA", "glowAmount", 0, 20, 10), G::Int("ClrA", "clearAmount", 0, 20, 15)], + ), + // Dry Brush + ("DryB", "filter.gallery.dryBrush", &[G::Int("BrsS", "brushSize", 0, 10, 2), G::Int("BrsD", "brushDetail", 0, 10, 8), G::Int("Txtr", "texture", 1, 3, 1)]), + // Film Grain + ( + "FlmG", + "filter.gallery.filmGrain", + &[G::Int("Grn ", "grain", 0, 20, 4), G::Int("HghA", "highlightArea", 0, 20, 0), G::Int("Intn", "intensity", 0, 10, 10)], + ), + // Fresco + ("Frsc", "filter.gallery.fresco", &[G::Int("BrsS", "brushSize", 0, 10, 2), G::Int("BrsD", "brushDetail", 0, 10, 8), G::Int("Txtr", "texture", 1, 3, 1)]), + // Glass + ( + "Gls", + "filter.gallery.glass", + &[ + G::Int("Dstr", "distortion", 0, 20, 5), + G::Int("Smth", "smoothness", 1, 15, 3), + G::Enum("TxtT", "texture", &[("TxFr", "frosted"), ("TxCa", "canvas")], "TxFr"), + G::Int("Scln", "scaling", 50, 200, 100), + G::Bool("InvT", "invert", false), + ], + ), + // Glowing Edges + ( + "GlwE", + "filter.gallery.glowingEdges", + &[G::Int("EdgW", "edgeWidth", 1, 14, 2), G::Int("EdgB", "edgeBrightness", 0, 20, 6), G::Int("Smth", "smoothness", 1, 15, 5)], + ), + // Graphic Pen + ( + "GraP", + "filter.gallery.graphicPen", + &[ + G::Int("StrL", "strokeLength", 1, 15, 15), + G::Int("LgDr", "lightDarkBalance", 0, 100, 50), + G::Enum("SDir", "strokeDirection", &[("SDHz", "horizontal"), ("SDLD", "leftDiagonal"), ("SDRD", "rightDiagonal"), ("SDVt", "vertical")], "SDRD"), + ], + ), + // Halftone Screen + ( + "HlfS", + "filter.gallery.halftonePattern", + &[ + G::Int("HlSz", "size", 1, 12, 1), + G::Int("Cntr", "contrast", 0, 50, 5), + G::Enum("ScrT", "patternType", &[("ScrC", "circle"), ("ScrD", "dot"), ("ScrL", "line")], "ScrD"), + ], + ), + // Ink Outlines + ( + "InkO", + "filter.gallery.inkOutlines", + &[G::Int("StrL", "strokeLength", 1, 50, 4), G::Int("DrkI", "darkIntensity", 0, 50, 20), G::Int("LghI", "lightIntensity", 0, 50, 10)], + ), + // Mosaic Tiles + ( + "MscT", + "filter.gallery.mosaicTiles", + &[G::Int("TlSz", "tileSize", 2, 100, 12), G::Int("GrtW", "groutWidth", 1, 15, 3), G::Int("LghG", "lightenGrout", 0, 10, 9)], + ), + // Neon Glow + ("NGlw", "filter.gallery.neonGlow", &[G::Int("Sz ", "glowSize", -24, 24, 5), G::Int("Brgh", "glowBrightness", 0, 50, 15), G::Color("Clr ", "glowColor")]), + // Note Paper + ( + "NtPr", + "filter.gallery.notePaper", + &[G::Int("ImgB", "imageBalance", 0, 50, 25), G::Int("Grns", "graininess", 0, 20, 10), G::Int("Rlf ", "relief", 0, 25, 11)], + ), + // Ocean Ripple + ("OcnR", "filter.gallery.oceanRipple", &[G::Int("RplS", "rippleSize", 1, 15, 9), G::Int("RplM", "rippleMagnitude", 0, 20, 9)]), + // Paint Daubs + ( + "PntD", + "filter.gallery.paintDaubs", + &[ + G::Int("Sz ", "brushSize", 1, 50, 8), + G::Int("Shrp", "sharpness", 0, 40, 7), + G::Enum( + "BrsT", + "brushType", + &[("BrDR", "darkRough"), ("BrSm", "simple"), ("BrSp", "sparkle"), ("BrbW", "wideBlurry"), ("BrsL", "lightRough"), ("BrsW", "wideSharp")], + "BrSm", + ), + ], + ), + // Palette Knife + ( + "PltK", + "filter.gallery.paletteKnife", + &[G::Int("StrS", "strokeSize", 1, 50, 25), G::Int("StDt", "strokeDetail", 1, 3, 3), G::Int("Sftn", "softness", 0, 10, 0)], + ), + // Patchwork + ("Ptch", "filter.gallery.patchwork", &[G::Int("SqrS", "squareSize", 0, 10, 4), G::Int("Rlf ", "relief", 0, 25, 8)]), + // Photocopy + ("Phtc", "filter.gallery.photocopy", &[G::Int("Dtl ", "detail", 1, 24, 7), G::Int("Drkn", "darkness", 1, 50, 8)]), + // Plaster + ( + "Plst", + "filter.gallery.plaster", + &[ + G::Int("ImgB", "imageBalance", 0, 50, 20), + G::Int("Smth", "smoothness", 1, 15, 2), + G::Enum( + "LghP", + "light", + &[ + ("LPBL", "bottomLeft"), + ("LPBr", "bottomRight"), + ("LPBt", "bottom"), + ("LPLf", "left"), + ("LPRg", "right"), + ("LPTL", "topLeft"), + ("LPTR", "topRight"), + ("LPTp", "top"), + ], + "LPTp", + ), + ], + ), + // Plastic Wrap + ( + "PlsW", + "filter.gallery.plasticWrap", + &[G::Int("HghS", "highlightStrength", 0, 20, 15), G::Int("Dtl ", "detail", 1, 15, 9), G::Int("Smth", "smoothness", 1, 15, 7)], + ), + // Poster Edges + ( + "PstE", + "filter.gallery.posterEdges", + &[G::Int("EdgT", "edgeThickness", 0, 10, 2), G::Int("EdgI", "edgeIntensity", 0, 10, 1), G::Int("Pstr", "posterization", 0, 6, 2)], + ), + // Reticulation + ( + "Rtcl", + "filter.gallery.reticulation", + &[G::Int("Dnst", "density", 0, 50, 12), G::Int("BlcL", "foregroundLevel", 0, 50, 40), G::Int("WhtL", "backgroundLevel", 0, 50, 5)], + ), + // Rough Pastels + ( + "RghP", + "filter.gallery.roughPastels", + &[ + G::Int("StrL", "strokeLength", 0, 40, 6), + G::Int("StDt", "strokeDetail", 1, 20, 4), + G::Enum("TxtT", "texture", &[("TxBr", "brick"), ("TxBu", "burlap"), ("TxCa", "canvas"), ("TxSt", "sandstone")], "TxCa"), + G::Int("Scln", "scaling", 50, 200, 100), + G::Int("Rlf ", "relief", 0, 50, 20), + G::Enum( + "LghD", + "light", + &[ + ("LDBL", "bottomLeft"), + ("LDBR", "bottomRight"), + ("LDBt", "bottom"), + ("LDLf", "left"), + ("LDRg", "right"), + ("LDTL", "topLeft"), + ("LDTR", "topRight"), + ("LDTp", "top"), + ], + "LDBt", + ), + G::Bool("InvT", "invert", false), + ], + ), + // Smudge Stick + ( + "SmdS", + "filter.gallery.smudgeStick", + &[G::Int("StrL", "strokeLength", 0, 10, 2), G::Int("HghA", "highlightArea", 0, 20, 0), G::Int("Intn", "intensity", 0, 10, 10)], + ), + // Spatter + ("Spt", "filter.gallery.spatter", &[G::Int("SprR", "sprayRadius", 0, 25, 10), G::Int("Smth", "smoothness", 1, 15, 5)]), + // Sponge + ( + "Spng", + "filter.gallery.sponge", + &[G::Int("BrsS", "brushSize", 0, 10, 2), G::Int("Dfnt", "definition", 0, 25, 12), G::Int("Smth", "smoothness", 1, 15, 5)], + ), + // Sprayed Strokes + ( + "SprS", + "filter.gallery.sprayedStrokes", + &[ + G::Int("StrL", "strokeLength", 0, 20, 12), + G::Int("SprR", "sprayRadius", 0, 25, 7), + G::Enum("SDir", "strokeDirection", &[("SDHz", "horizontal"), ("SDLD", "leftDiagonal"), ("SDRD", "rightDiagonal"), ("SDVt", "vertical")], "SDRD"), + ], + ), + // Stained Glass + ( + "StnG", + "filter.gallery.stainedGlass", + &[G::Int("ClSz", "cellSize", 2, 50, 10), G::Int("BrdT", "borderThickness", 1, 20, 4), G::Int("LghI", "lightIntensity", 0, 10, 3)], + ), + // Stamp + ("Stmp", "filter.gallery.stamp", &[G::Int("LgDr", "lightDarkBalance", 0, 50, 25), G::Int("Smth", "smoothness", 1, 50, 5)]), + // Sumie + ( + "Smie", + "filter.gallery.sumiE", + &[G::Int("StrW", "strokeWidth", 3, 15, 10), G::Int("StrP", "strokePressure", 0, 15, 2), G::Int("Cntr", "contrast", 0, 40, 16)], + ), + // Texturizer + ( + "Txtz", + "filter.gallery.texturizer", + &[ + G::Enum("TxtT", "texture", &[("TxBr", "brick"), ("TxBu", "burlap"), ("TxCa", "canvas"), ("TxSt", "sandstone")], "TxCa"), + G::Int("Scln", "scaling", 50, 200, 100), + G::Int("Rlf ", "relief", 0, 50, 4), + G::Enum( + "LghD", + "light", + &[ + ("LDBL", "bottomLeft"), + ("LDBR", "bottomRight"), + ("LDBt", "bottom"), + ("LDLf", "left"), + ("LDRg", "right"), + ("LDTL", "topLeft"), + ("LDTR", "topRight"), + ("LDTp", "top"), + ], + "LDTp", + ), + G::Bool("InvT", "invert", false), + ], + ), + // Torn Edges + ( + "TrnE", + "filter.gallery.tornEdges", + &[G::Int("ImgB", "imageBalance", 0, 50, 25), G::Int("Smth", "smoothness", 1, 15, 11), G::Int("Cntr", "contrast", 1, 25, 17)], + ), + // Underpainting + ( + "Undr", + "filter.gallery.underpainting", + &[ + G::Int("BrsS", "brushSize", 0, 40, 6), + G::Int("TxtC", "textureCoverage", 0, 40, 16), + G::Enum("TxtT", "texture", &[("TxBr", "brick"), ("TxBu", "burlap"), ("TxCa", "canvas"), ("TxSt", "sandstone")], "TxCa"), + G::Int("Scln", "scaling", 50, 200, 100), + G::Int("Rlf ", "relief", 0, 50, 4), + G::Enum( + "LghD", + "light", + &[ + ("LDBL", "bottomLeft"), + ("LDBR", "bottomRight"), + ("LDBt", "bottom"), + ("LDLf", "left"), + ("LDRg", "right"), + ("LDTL", "topLeft"), + ("LDTR", "topRight"), + ("LDTp", "top"), + ], + "LDTp", + ), + G::Bool("InvT", "invert", false), + ], + ), + // Water Paper + ( + "WtrP", + "filter.gallery.waterPaper", + &[G::Int("FbrL", "fiberLength", 3, 50, 15), G::Int("Brgh", "brightness", 0, 100, 60), G::Int("Cntr", "contrast", 0, 100, 80)], + ), + // Watercolor + ( + "Wtrc", + "filter.gallery.watercolor", + &[G::Int("BrsD", "brushDetail", 1, 14, 9), G::Int("ShdI", "shadowIntensity", 0, 10, 1), G::Int("Txtr", "texture", 1, 3, 1)], + ), + // Grain + ( + "Grn", + "filter.gallery.grain", + &[ + G::Int("Intn", "intensity", 0, 100, 40), + G::Int("Cntr", "contrast", 0, 100, 50), + G::Enum( + "Grnt", + "grainType", + &[ + ("GrCn", "contrasty"), + ("GrSf", "soft"), + ("GrSp", "speckle"), + ("GrSr", "sprinkles"), + ("GrSt", "stippled"), + ("GrnC", "clumped"), + ("GrnE", "enlarged"), + ("GrnH", "horizontal"), + ("GrnR", "regular"), + ("GrnV", "vertical"), + ], + "GrnR", + ), + ], + ), +]; + +pub(super) const GALLERY_IDS: &[&str] = &[ + "filter.gallery.accentedEdges", + "filter.gallery.angledStrokes", + "filter.gallery.basRelief", + "filter.gallery.chalkCharcoal", + "filter.gallery.charcoal", + "filter.gallery.chrome", + "filter.gallery.coloredPencil", + "filter.gallery.conteCrayon", + "filter.gallery.craquelure", + "filter.gallery.crosshatch", + "filter.gallery.cutout", + "filter.gallery.darkStrokes", + "filter.gallery.diffuseGlow", + "filter.gallery.dryBrush", + "filter.gallery.filmGrain", + "filter.gallery.fresco", + "filter.gallery.glass", + "filter.gallery.glowingEdges", + "filter.gallery.graphicPen", + "filter.gallery.halftonePattern", + "filter.gallery.inkOutlines", + "filter.gallery.mosaicTiles", + "filter.gallery.neonGlow", + "filter.gallery.notePaper", + "filter.gallery.oceanRipple", + "filter.gallery.paintDaubs", + "filter.gallery.paletteKnife", + "filter.gallery.patchwork", + "filter.gallery.photocopy", + "filter.gallery.plaster", + "filter.gallery.plasticWrap", + "filter.gallery.posterEdges", + "filter.gallery.reticulation", + "filter.gallery.roughPastels", + "filter.gallery.smudgeStick", + "filter.gallery.spatter", + "filter.gallery.sponge", + "filter.gallery.sprayedStrokes", + "filter.gallery.stainedGlass", + "filter.gallery.stamp", + "filter.gallery.sumiE", + "filter.gallery.texturizer", + "filter.gallery.tornEdges", + "filter.gallery.underpainting", + "filter.gallery.waterPaper", + "filter.gallery.watercolor", + "filter.gallery.grain", +]; + +/// Whether `code` is a Filter Gallery effect's id. The reference has each effect recorded as +/// its own event too (Filter › Artistic › Cutout…), carrying the same `GEfk` and settings. +pub(super) fn is_effect(code: &str) -> bool { + GALLERY.iter().any(|(c, _, _)| *c == code.trim_end()) +} + +/// A Filter Gallery step: the effect `GEfk` names, every key clamped or looked up, Photoshop's +/// default for what it leaves out. An unknown effect, value or key keeps the Photoshop step. +pub(super) fn gallery_from(d: &Descriptor) -> Mapped { + effect_step(d).unwrap_or(Mapped::Unsupported { harmless: false }) +} + +fn effect_step(d: &Descriptor) -> Option { + let effect = get_enum(d, "GEfk")?; + let (_, id, keys) = GALLERY.iter().find(|(code, _, _)| *code == effect)?; + let known: Vec<&str> = keys.iter().map(|k| k.key().trim_end()).chain(["GEfk"]).collect(); + if !d.items.iter().all(|(k, _)| known.contains(&trimmed(k).as_str())) { + return None; + } + let mut p = Map::new(); + for k in keys.iter() { + let v = match k { + G::Int(key, _, min, max, default) => Value::from(get_num(d, key).map_or(*default, |n| n.round().clamp(f64::from(*min), f64::from(*max)) as i32)), + G::Enum(key, _, table, default) => Value::from(lookup(table, &get_enum(d, key).unwrap_or_else(|| (*default).to_string()))?), + G::Bool(key, _, default) => Value::from(get_bool(d, key).unwrap_or(*default)), + G::Color(key, _) => match get_obj(d, key) { + Some(c) => Value::from(color_hex(c)?), + None => continue, + }, + }; + p.insert(k.param().into(), v); + } + Some(step(id, Value::Object(p))) +} + +/// Every key in recorded order, Photoshop's default (which is the command's) for what the step +/// leaves out. A setting the event has no key for (the Sketch effects' pinned colours, a mask +/// target), a fraction in an integer key or a value without a Photoshop code refuses the step. +pub(super) fn gallery_to(id: &str, p: &Value) -> Option { + let (code, _, keys) = GALLERY.iter().find(|(_, cmd, _)| *cmd == id)?; + let allowed: Vec<&str> = keys.iter().map(G::param).chain(["target"]).collect(); + if !pixel_target(p) || !p.as_object().is_none_or(|m| m.iter().all(|(k, v)| v.is_null() || allowed.contains(&k.as_str()))) { + return None; + } + let mut d = desc("null").with("GEfk", enm("GEft", code)); + for k in keys.iter() { + let v = p.get(k.param()).filter(|v| !v.is_null()); + let out = match k { + G::Int(_, _, _, _, default) => { + let n = v.map_or(Some(f64::from(*default)), Value::as_f64)?; + if n.fract() != 0.0 || !(f64::from(i32::MIN)..=f64::from(i32::MAX)).contains(&n) { + return None; + } + D::Integer(n as i32) + } + G::Enum(key, _, table, default) => enm(key, v.map_or(Some(*default), |v| v.as_str().and_then(|s| lookup_back(table, s)))?), + G::Bool(_, _, default) => D::Boolean(v.map_or(Some(*default), Value::as_bool)?), + // The colour the effect replays with when the step names none is the session's, which + // the event cannot record. + G::Color(..) => D::Descriptor(color_desc(v?.as_str()?)?), + }; + d = d.with(k.key(), out); + } + Some(d) +} diff --git a/crates/engine/src/cmd/atn_cmds/steps_image.rs b/crates/engine/src/cmd/atn_cmds/steps_image.rs index a52b9fbf5d..691ce6b6bf 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_image.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_image.rs @@ -290,24 +290,7 @@ const REFINE_OUTPUT: &[(&str, &str)] = &[ ("selectionOutputToSelection", "selection"), ]; -/// Filter Gallery effects (`GEfc` with the effect in `GEfk`): Photoshop's effect code, the -/// command, and its fields. A stack of several effects stays unsupported. -const GALLERY: &[(&str, &str, &[F])] = &[("Spt", "filter.gallery.spatter", &[F("SprR", "sprayRadius", Kind::Long), F("Smth", "smoothness", Kind::Long)])]; - -pub(super) const GALLERY_IDS: &[&str] = &["filter.gallery.spatter"]; - -pub(super) fn gallery_from(d: &Descriptor) -> Mapped { - let Some(effect) = get_enum(d, "GEfk") else { return Mapped::Unsupported { harmless: false } }; - match GALLERY.iter().find(|(code, _, _)| *code == effect).and_then(|(_, id, fields)| Some((*id, fields_from(d, fields)?))) { - Some((id, p)) => step(id, Value::Object(p)), - None => Mapped::Unsupported { harmless: false }, - } -} - -pub(super) fn gallery_to(id: &str, p: &Value) -> Option { - let (code, _, fields) = GALLERY.iter().find(|(_, cmd, _)| *cmd == id)?; - fields_to(p, fields, desc("null").with("GEfk", enm("GEft", code))) -} +pub(super) use super::steps_gallery::{GALLERY_IDS, gallery_from, gallery_to}; /// Select and Mask as Photoshop CC 2015.5 and later record it (`smartBrushWorkspace`); the /// older Refine Edge event is [`REFINE_EDGE`], which is also what Select and Mask exports as. diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index f9e633244c..0f91e15687 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -645,6 +645,61 @@ fn every_mapped_command_exports_and_imports_back() { ("filter.distort.ripple", json!({"amount": 50, "size": "medium"})), ("filter.pixelate.crystallize", json!({"cellSize": 5})), ("filter.gallery.spatter", json!({"sprayRadius": 7, "smoothness": 15})), + ("filter.gallery.accentedEdges", json!({"edgeWidth": 14, "edgeBrightness": 50, "smoothness": 15})), + ("filter.gallery.angledStrokes", json!({"directionBalance": 100, "strokeLength": 50, "sharpness": 10})), + ("filter.gallery.basRelief", json!({"detail": 15, "smoothness": 15, "light": "bottomLeft"})), + ("filter.gallery.chalkCharcoal", json!({"charcoalArea": 20, "chalkArea": 20, "strokePressure": 5})), + ("filter.gallery.charcoal", json!({"charcoalThickness": 7, "detail": 0, "lightDarkBalance": 100})), + ("filter.gallery.chrome", json!({"detail": 10, "smoothness": 10})), + ("filter.gallery.coloredPencil", json!({"pencilWidth": 24, "strokePressure": 15, "paperBrightness": 50})), + ( + "filter.gallery.conteCrayon", + json!({"foregroundLevel": 15, "backgroundLevel": 15, "texture": "brick", "scaling": 200, "relief": 50, "light": "bottomLeft", "invert": true}), + ), + ("filter.gallery.craquelure", json!({"crackSpacing": 100, "crackDepth": 10, "crackBrightness": 10})), + ("filter.gallery.crosshatch", json!({"strokeLength": 50, "sharpness": 20, "strength": 3})), + ("filter.gallery.cutout", json!({"numberOfLevels": 8, "edgeSimplicity": 10, "edgeFidelity": 3})), + ("filter.gallery.darkStrokes", json!({"balance": 10, "blackIntensity": 10, "whiteIntensity": 10})), + ("filter.gallery.diffuseGlow", json!({"graininess": 10, "glowAmount": 20, "clearAmount": 20})), + ("filter.gallery.dryBrush", json!({"brushSize": 10, "brushDetail": 10, "texture": 3})), + ("filter.gallery.filmGrain", json!({"grain": 20, "highlightArea": 20, "intensity": 0})), + ("filter.gallery.fresco", json!({"brushSize": 10, "brushDetail": 10, "texture": 3})), + ("filter.gallery.glass", json!({"distortion": 20, "smoothness": 15, "texture": "canvas", "scaling": 200, "invert": true})), + ("filter.gallery.glowingEdges", json!({"edgeWidth": 14, "edgeBrightness": 20, "smoothness": 15})), + ("filter.gallery.graphicPen", json!({"strokeLength": 1, "lightDarkBalance": 100, "strokeDirection": "horizontal"})), + ("filter.gallery.halftonePattern", json!({"size": 12, "contrast": 50, "patternType": "circle"})), + ("filter.gallery.inkOutlines", json!({"strokeLength": 50, "darkIntensity": 50, "lightIntensity": 50})), + ("filter.gallery.mosaicTiles", json!({"tileSize": 100, "groutWidth": 15, "lightenGrout": 10})), + ("filter.gallery.neonGlow", json!({"glowSize": 24, "glowBrightness": 50, "glowColor": "#336699"})), + ("filter.gallery.notePaper", json!({"imageBalance": 50, "graininess": 20, "relief": 25})), + ("filter.gallery.oceanRipple", json!({"rippleSize": 15, "rippleMagnitude": 20})), + ("filter.gallery.paintDaubs", json!({"brushSize": 50, "sharpness": 40, "brushType": "darkRough"})), + ("filter.gallery.paletteKnife", json!({"strokeSize": 50, "strokeDetail": 1, "softness": 10})), + ("filter.gallery.patchwork", json!({"squareSize": 10, "relief": 25})), + ("filter.gallery.photocopy", json!({"detail": 24, "darkness": 50})), + ("filter.gallery.plaster", json!({"imageBalance": 50, "smoothness": 15, "light": "bottomLeft"})), + ("filter.gallery.plasticWrap", json!({"highlightStrength": 20, "detail": 15, "smoothness": 15})), + ("filter.gallery.posterEdges", json!({"edgeThickness": 10, "edgeIntensity": 10, "posterization": 6})), + ("filter.gallery.reticulation", json!({"density": 50, "foregroundLevel": 50, "backgroundLevel": 50})), + ( + "filter.gallery.roughPastels", + json!({"strokeLength": 40, "strokeDetail": 20, "texture": "brick", "scaling": 200, "relief": 50, "light": "bottomLeft", "invert": true}), + ), + ("filter.gallery.smudgeStick", json!({"strokeLength": 10, "highlightArea": 20, "intensity": 0})), + ("filter.gallery.sponge", json!({"brushSize": 10, "definition": 25, "smoothness": 15})), + ("filter.gallery.sprayedStrokes", json!({"strokeLength": 20, "sprayRadius": 25, "strokeDirection": "horizontal"})), + ("filter.gallery.stainedGlass", json!({"cellSize": 50, "borderThickness": 20, "lightIntensity": 10})), + ("filter.gallery.stamp", json!({"lightDarkBalance": 50, "smoothness": 50})), + ("filter.gallery.sumiE", json!({"strokeWidth": 15, "strokePressure": 15, "contrast": 40})), + ("filter.gallery.texturizer", json!({"texture": "brick", "scaling": 200, "relief": 50, "light": "bottomLeft", "invert": true})), + ("filter.gallery.tornEdges", json!({"imageBalance": 50, "smoothness": 15, "contrast": 25})), + ( + "filter.gallery.underpainting", + json!({"brushSize": 40, "textureCoverage": 40, "texture": "brick", "scaling": 200, "relief": 50, "light": "bottomLeft", "invert": true}), + ), + ("filter.gallery.waterPaper", json!({"fiberLength": 50, "brightness": 100, "contrast": 100})), + ("filter.gallery.watercolor", json!({"brushDetail": 14, "shadowIntensity": 10, "texture": 3})), + ("filter.gallery.grain", json!({"intensity": 100, "contrast": 100, "grainType": "contrasty"})), ( "select.refineEdge", json!({"radius": 0, "smartRadius": false, "smooth": 2, "feather": 0.5, "contrast": 15, "shiftEdge": -100, "decontaminate": false, "output": "layerMask"}), @@ -3175,3 +3230,89 @@ fn long_names_are_clipped_instead_of_copied_into_every_diagnostic() { assert_eq!(imp.unsupported.len(), 50); assert!(imp.unsupported.iter().all(|u| u.action.chars().count() == MAX_NAME_CHARS)); } + +/// A Filter Gallery step as Photoshop records it: `GEfc` with the effect in `GEfk`, or the +/// effect's own event carrying the same key. +fn gallery_step(event: &str, effect: &str, d: Descriptor) -> Mapped { + let d = d.with("GEfk", enm("GEft", effect)); + let event = AtnEvent::Code(event.as_bytes().try_into().unwrap()); + (events::event_for(&event).unwrap_or_else(|| panic!("{effect} is unknown")).from)(&d) +} + +fn gallery_params(m: Mapped) -> (String, Value) { + match m { + Mapped::Steps(mut s) if s.len() == 1 => s.remove(0), + _ => panic!("kept"), + } +} + +/// The Filter Gallery effects load with Photoshop's defaults for missing keys and its clamping, +/// from `GEfc` or from the effect's own event, play, and save back as `GEfc` with every key in +/// recorded order (#10592). +#[test] +fn gallery_effect_steps_load_clamp_play_and_save_back() { + let edges = desc("null").with("EdgW", D::Integer(3)).with("EdgB", D::Integer(20)).with("Smth", D::Integer(7)); + assert_eq!( + gallery_params(gallery_step("GEfc", "AccE", edges.clone())), + ("filter.gallery.accentedEdges".into(), json!({"edgeWidth": 3, "edgeBrightness": 20, "smoothness": 7})) + ); + // Run from its own menu item, the effect's id is the event. + assert_eq!(gallery_params(gallery_step("AccE", "AccE", edges.clone())).0, "filter.gallery.accentedEdges"); + // Missing keys take Photoshop's defaults; enumerations, booleans and colours map. + assert_eq!(gallery_params(gallery_step("GEfc", "BsRl", desc("null"))).1, json!({"detail": 13, "smoothness": 3, "light": "bottom"})); + let pastels = desc("null").with("TxtT", enm("TxtT", "TxBr")).with("LghD", enm("LghD", "LDTR")).with("InvT", D::Boolean(true)); + let p = gallery_params(gallery_step("GEfc", "RghP", pastels)).1; + assert_eq!((p["texture"].as_str(), p["light"].as_str(), p["invert"].as_bool()), (Some("brick"), Some("topRight"), Some(true))); + let glow = desc("null").with("Clr ", D::Descriptor(color_desc("#33cc66").unwrap())); + assert_eq!(gallery_params(gallery_step("GEfc", "NGlw", glow)).1["glowColor"], "#33cc66"); + // Out-of-range values are clamped as Photoshop clamps them. + let wide = desc("null").with("EdgW", D::Integer(99)).with("EdgB", D::Integer(-5)); + let p = gallery_params(gallery_step("GEfc", "AccE", wide)).1; + assert_eq!((p["edgeWidth"].as_i64(), p["edgeBrightness"].as_i64()), (Some(14), Some(0))); + + // Saved back as `GEfc`, with `GEfk` first and every key in recorded order. + let item = export_item("filter.gallery.accentedEdges", &json!({"edgeWidth": 3, "edgeBrightness": 20, "smoothness": 7}), "S").unwrap(); + assert_eq!(item.event.id(), "GEfc"); + let mut expected = vec![(Id::new("GEfk"), enm("GEft", "AccE"))]; + expected.extend(edges.items); + assert_eq!(item.descriptor.unwrap().items, expected); + + // They play. + let mut s = Session::new(); + s.execute("file.new", json!({"width": 24, "height": 24})).unwrap(); + s.execute("filter.render.clouds", json!({})).unwrap(); + for (code, effect) in [("AccE", "accentedEdges"), ("Ct ", "cutout"), ("Plst", "plaster")] { + let (id, p) = gallery_params(gallery_step("GEfc", code, desc("null"))); + assert_eq!(id, format!("filter.gallery.{effect}")); + s.actions.list.push(Action::new(code, vec![(id, p)])); + let played = s.execute("actions.play", json!({"action": code})).unwrap(); + assert_eq!(played["ran"], 1, "{code}: {played}"); + } +} + +/// What the effects cannot play keeps the Photoshop step: an unknown effect or value, a Glass +/// texture the reference gives no code for, a key the effect does not have (#10592). +#[test] +fn gallery_effect_steps_the_commands_cannot_play_are_kept() { + let kept = |m: Mapped| matches!(m, Mapped::Unsupported { harmless: false }); + assert!(kept(gallery_step("GEfc", "Zzzz", desc("null")))); + assert!(kept(gallery_step("GEfc", "BsRl", desc("null").with("LghD", enm("LghD", "LDCn"))))); + assert!(kept(gallery_step("GEfc", "Gls ", desc("null").with("TxtT", enm("TxtT", "TxBr"))))); + assert!(kept(gallery_step("GEfc", "AccE", desc("null").with("Rds ", D::Integer(3))))); +} + +/// What the event cannot hold refuses the step: a fraction in an integer key, the colours a +/// Sketch effect pinned, a value with no Photoshop code, Neon Glow without its colour (#10592). +#[test] +fn gallery_effect_steps_that_cannot_be_written_are_refused() { + for (id, params) in [ + ("filter.gallery.accentedEdges", json!({"edgeWidth": 2.5})), + ("filter.gallery.charcoal", json!({"foreground": "#000000"})), + ("filter.gallery.glass", json!({"texture": "blocks"})), + ("filter.gallery.neonGlow", json!({"glowSize": 5})), + ("filter.gallery.cutout", json!({"target": "mask"})), + ] { + assert!(export_item(id, ¶ms, "S").is_none(), "{id} {params} exported"); + } + assert!(export_item("filter.gallery.glass", &json!({"texture": "frosted"}), "S").is_some()); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 5ef09a2e7e..2a67388e35 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-26 · **Change:** minor (TIFFs up to 300,000 px a side open, decoded a group of rows at a time into the layer, #8169; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; PSD Filter Gallery: Halftone Pattern dot/circle/line settings survive PSD/PSB saves (#8940); Save for Web writes WebP, lossy or lossless, #6391; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency, #8877; layered TIFF writes the TySh text version and descriptor in PSD big-endian order while the header, matrix, warp and bounds keep container order, #9098; layered TIFF writes Photoshop's 14-byte global layer mask, so Photoshop opens the layers instead of only the composite, #9095; legacy PSD point text placed from the layout ink box after font resolution, #6094; `.atn`: 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; `.asl` layer style libraries read and written, #1051; JPEG Options on File › Save, Save As and Save a Copy, FILE-215-19 partial (no Matte, progressive Scans or size preview); 32-bit Hue/Saturation unclipped and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; JPEG Options on Save As and Save a Copy, FILE-215-19; TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; Cineon write: 10-bit gray/RGB; animated WebP frames load into video layers, #1198; Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 53 filters (39 more: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag, Invert, Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding; Amiga IFF write: 24-plane ILBM, Save As `.iff`; PSD stores spot channels as Photoshop does, ink 0 = solid, and numbers them in 1053, #7415 split; TIFF writes and reads alpha and spot channels as extra samples with Photoshop's channel resources, PSD output unchanged, FILE-215-7, FILE-215-17's TIFF part, #7415; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-31 · **Change:** minor (`.atn`: all 47 Filter Gallery effects load, play and save back with Photoshop's keys, limits and defaults, #10592; TIFFs up to 300,000 px a side open, decoded a group of rows at a time into the layer, #8169; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; PSD Filter Gallery: Halftone Pattern dot/circle/line settings survive PSD/PSB saves (#8940); Save for Web writes WebP, lossy or lossless, #6391; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency, #8877; layered TIFF writes the TySh text version and descriptor in PSD big-endian order while the header, matrix, warp and bounds keep container order, #9098; layered TIFF writes Photoshop's 14-byte global layer mask, so Photoshop opens the layers instead of only the composite, #9095; legacy PSD point text placed from the layout ink box after font resolution, #6094; `.atn`: 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; `.asl` layer style libraries read and written, #1051; JPEG Options on File › Save, Save As and Save a Copy, FILE-215-19 partial (no Matte, progressive Scans or size preview); 32-bit Hue/Saturation unclipped and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; JPEG Options on Save As and Save a Copy, FILE-215-19; TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; Cineon write: 10-bit gray/RGB; animated WebP frames load into video layers, #1198; Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 53 filters (39 more: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag, Invert, Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding; Amiga IFF write: 24-plane ILBM, Save As `.iff`; PSD stores spot channels as Photoshop does, ink 0 = solid, and numbers them in 1053, #7415 split; TIFF writes and reads alpha and spot channels as extra samples with Photoshop's channel resources, PSD output unchanged, FILE-215-7, FILE-215-17's TIFF part, #7415; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency) · **Target:** Adobe Photoshop 2026 **PSD Filter Gallery (#8940, 2026-10-23):** Halftone Pattern maps its size, contrast and dot/circle/line pattern as editable settings on import and export, with the reference application's descriptor keys (`HlSz`, `Cntr`, `ScrT`: `ScrD`/`ScrC`/`ScrL`). PSD/PSB round trips pass at 8/16/32 bits. This does not claim pixel equivalence for the existing renderer. Unknown or malformed settings remain verbatim on import. @@ -148,6 +148,7 @@ Embedded SVG and PDF-compatible AI/PDF/EPS payloads can use an explicit VectorCr | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | `.atn`: Filter Gallery (`GEfc` with the effect in `GEfk`, or the effect's own event) maps all 47 effects to `filter.gallery.` with every key in recorded order, out-of-range values clamped and missing keys given Photoshop's defaults (was Spatter only); Glass maps the Frosted and Canvas textures (#10592) | | 2026-10-26 | minor | Embedded AI/PDF/EPS save-back retains native format and a separate source-bound display preview; original sources remain undoable. Native Adobe interoperability and standalone EPS/DCS remain unverified; no corpus floor or percentage changed. | | 2026-10-22 | minor | Embedded PSD vectors gain explicit bounded SVG editor/save-back; standalone EPS/DCS and native-format fidelity remain open. | | 2026-10-22 | minor | TIFF: flat pages up to 300,000 px a side open (was 2^30 px, about 32,768²): `codecs::TiffBands` decodes a group of rows at a time into the layer's tiles. The 117,504 × 22,464 px banner from #8456 opens in 3.3 s at 11.45 GB peak for a 10.56 GB layer; 30,000² 6.8 s / 6.43 GB → 1.1 s / 3.86 GB; 36 MP 220 → 52 ms. Layered TIFFs still decode whole (#8169) | From 86f7359bfd870d467287e42b0064e1f72caeacc0 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 31 Oct 2026 21:47:25 -0600 Subject: [PATCH 043/216] Actions: keep a wrong-typed De-Interlace, HSB/HSL or Custom step (CodeRabbit review on crates/engine/src/cmd/atn_cmds/steps_other.rs:53) A present enumeration key of another type, or a Custom `Scl `/`Ofst` that isn't a number, was read as missing and played with Photoshop's default. A `setting` helper (as in steps_image.rs) now reads a present key with its expected type and returns None otherwise, so the step stays a Photoshop step. It also reads `Scl` without its padding, which the key gate already admitted but the mapper read as the default 1. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/atn_cmds/descriptors.rs | 20 +++++++++---- crates/engine/src/cmd/atn_cmds/steps_other.rs | 24 ++++++++++++---- crates/engine/src/cmd/atn_cmds/tests.rs | 28 +++++++++++++++++++ 3 files changed, 60 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/cmd/atn_cmds/descriptors.rs b/crates/engine/src/cmd/atn_cmds/descriptors.rs index 9e9835218b..3e1dcd7a24 100644 --- a/crates/engine/src/cmd/atn_cmds/descriptors.rs +++ b/crates/engine/src/cmd/atn_cmds/descriptors.rs @@ -20,13 +20,17 @@ pub(super) fn get_num(d: &Descriptor, k: &str) -> Option { d.get(k).and_then(num) } -pub(super) fn get_bool(d: &Descriptor, k: &str) -> Option { - match d.get(k) { - Some(D::Boolean(b)) => Some(*b), +pub(super) fn boolean(v: &D) -> Option { + match v { + D::Boolean(b) => Some(*b), _ => None, } } +pub(super) fn get_bool(d: &Descriptor, k: &str) -> Option { + d.get(k).and_then(boolean) +} + pub(super) fn get_text(d: &Descriptor, k: &str) -> Option { match d.get(k) { Some(D::Text(t)) => Some(t.to_string_lossy()), @@ -35,13 +39,17 @@ pub(super) fn get_text(d: &Descriptor, k: &str) -> Option { } /// An enumerated value's id, trailing spaces removed (`Ordn.Trgt` → `"Trgt"`). -pub(super) fn get_enum(d: &Descriptor, k: &str) -> Option { - match d.get(k) { - Some(D::Enumerated { value, .. }) => Some(trimmed(value)), +pub(super) fn enumerated(v: &D) -> Option { + match v { + D::Enumerated { value, .. } => Some(trimmed(value)), _ => None, } } +pub(super) fn get_enum(d: &Descriptor, k: &str) -> Option { + d.get(k).and_then(enumerated) +} + pub(super) fn get_obj<'a>(d: &'a Descriptor, k: &str) -> Option<&'a Descriptor> { match d.get(k) { Some(D::Descriptor(o) | D::GlobalObject(o)) => Some(o), diff --git a/crates/engine/src/cmd/atn_cmds/steps_other.rs b/crates/engine/src/cmd/atn_cmds/steps_other.rs index 7e789fc424..1bdf27cd90 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_other.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_other.rs @@ -1,7 +1,7 @@ //! Photoshop's Video, Other, Average and matting steps (#10607): De-Interlace, NTSC Colors, //! HSB/HSL, Custom, Average and Remove Black / White Matte, with the keys Photoshop records. -use super::descriptors::{desc, enm, get_enum, get_list, get_num, num, trimmed}; +use super::descriptors::{desc, enm, enumerated, get_list, num, trimmed}; use super::events::pixel_target; use super::fields::{Mapped, step}; use super::tables::{lookup, lookup_back}; @@ -12,6 +12,16 @@ fn only_keys(d: &Descriptor, keys: &[&str]) -> bool { d.items.iter().all(|(k, _)| keys.contains(&trimmed(k).as_str())) } +/// The step's `key` read by `read` (its space padding ignored, as `only_keys` admits it), +/// `default` when the step leaves it out, `None` when it holds a value `read` can't take: that +/// is still the step's setting, so the step is kept rather than played with the default. +fn setting(d: &Descriptor, key: &str, read: impl Fn(&D) -> Option, default: T) -> Option { + match d.items.iter().find(|(k, _)| trimmed(k) == key.trim_end()) { + Some((_, v)) => read(v), + None => Some(default), + } +} + /// Whether `p` sets nothing but `allowed` (a `null` counts as unset). fn sets_only(p: &Value, allowed: &[&str]) -> bool { p.as_object().is_none_or(|m| m.iter().all(|(k, v)| v.is_null() || allowed.contains(&k.as_str()))) @@ -41,7 +51,7 @@ const HSB_MODELS: &[(&str, &str)] = &[("RGBC", "rgb"), ("HSBl", "hsb"), ("HSLC", pub(super) const HSB_HSL: &[EnumKey] = &[("Inpt", "inputMode", "ClrS", HSB_MODELS, "RGBC"), ("Otpt", "rowOrder", "ClrS", HSB_MODELS, "HSBl")]; /// A step of enumerations only: each value looked up, Photoshop's default for a missing one. -/// An unknown value or key keeps the Photoshop step. +/// An unknown value or key, or a value that is not an enumeration, keeps the Photoshop step. pub(super) fn enums_from(d: &Descriptor, id: &str, keys: &[EnumKey]) -> Mapped { let unsupported = Mapped::Unsupported { harmless: false }; let names: Vec<&str> = keys.iter().map(|k| k.0).collect(); @@ -50,7 +60,7 @@ pub(super) fn enums_from(d: &Descriptor, id: &str, keys: &[EnumKey]) -> Mapped { } let mut p = Map::new(); for (key, param, _, table, default) in keys { - let Some(v) = lookup(table, &get_enum(d, key).unwrap_or_else(|| (*default).to_string())) else { return unsupported }; + let Some(v) = setting(d, key, enumerated, (*default).to_string()).and_then(|e| lookup(table, &e)) else { return unsupported }; p.insert((*param).into(), v.into()); } step(id, Value::Object(p)) @@ -75,7 +85,8 @@ pub(super) fn enums_to(p: &Value, keys: &[EnumKey]) -> Option { } /// Custom (`Cstm`): `Mtrx` is the 5 × 5 kernel, row by row. Photoshop takes exactly 25 cells (a -/// 3 × 3 list makes it fail), so any other matrix keeps the Photoshop step. +/// 3 × 3 list makes it fail), so any other matrix keeps the Photoshop step, as does a `Scl ` or +/// `Ofst` that is not a number. pub(super) fn custom_from(d: &Descriptor) -> Mapped { let unsupported = Mapped::Unsupported { harmless: false }; if !only_keys(d, &["Scl", "Ofst", "Mtrx"]) { @@ -84,10 +95,11 @@ pub(super) fn custom_from(d: &Descriptor) -> Mapped { let Some(cells) = get_list(d, "Mtrx") else { return unsupported }; let kernel: Option> = cells.iter().map(num).collect(); let Some(kernel) = kernel.filter(|k| k.len() == 25) else { return unsupported }; + let (Some(scale), Some(offset)) = (setting(d, "Scl ", num, 1.0), setting(d, "Ofst", num, 0.0)) else { return unsupported }; let mut p = Map::new(); p.insert("kernel".into(), json!(kernel)); - p.insert("scale".into(), get_num(d, "Scl ").unwrap_or(1.0).into()); - p.insert("offset".into(), get_num(d, "Ofst").unwrap_or(0.0).into()); + p.insert("scale".into(), scale.into()); + p.insert("offset".into(), offset.into()); step("filter.other.custom", Value::Object(p)) } diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index e0c3cc03bf..a7c49c24b8 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -3305,3 +3305,31 @@ fn video_other_and_matting_steps_that_cannot_be_written_are_refused() { let cells = get_list(&d, "Mtrx").unwrap(); assert_eq!((cells.len(), cells.get(12)), (25, Some(&D::Integer(1)))); } + +/// A recorded setting of the wrong type is the step's own setting, not a missing one: the +/// mapper keeps the Photoshop step instead of playing it with Photoshop's default (#10607). +#[test] +fn video_other_steps_with_a_wrong_typed_setting_are_kept() { + let kept = |m: Mapped| matches!(m, Mapped::Unsupported { harmless: false }); + let identity = D::List((0..25).map(|i| D::Integer(i32::from(i == 12))).collect()); + for (code, d) in [ + ("Dntr", desc("null").with("IntE", D::Integer(1))), + ("Dntr", desc("null").with("IntC", text("CrtD"))), + ("HsbP", desc("null").with("Inpt", D::Boolean(true))), + ("HsbP", desc("null").with("Otpt", text("HSLC"))), + ("Cstm", desc("null").with("Mtrx", identity.clone()).with("Scl ", text("2"))), + ("Cstm", desc("null").with("Mtrx", identity.clone()).with("Ofst", D::Boolean(true))), + ("Cstm", desc("null").with("Mtrx", identity.clone()).with("Scl", enm("Ordn", "Trgt"))), + ] { + assert!(kept(other_step(code, d.clone())), "{code} {d:?} was mapped"); + } +} + +/// The key gate admits `Scl` without its padding, so the mapper reads it too: an unpadded +/// scale is the step's scale, not the default 1. +#[test] +fn custom_reads_an_unpadded_scale() { + let identity = D::List((0..25).map(|i| D::Integer(i32::from(i == 12))).collect()); + let Mapped::Steps(steps) = other_step("Cstm", desc("null").with("Scl", D::Integer(3)).with("Mtrx", identity)) else { panic!("kept") }; + assert_eq!(steps[0].1["scale"], json!(3.0)); +} From ce6b88c12b3d5c35707ccf1efcf47c4026d3c0d4 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 31 Oct 2026 21:51:37 -0600 Subject: [PATCH 044/216] Actions: refuse a Trap step wider than Photoshop takes (CodeRabbit review on crates/engine/src/cmd/atn_cmds/steps_channels.rs:67) `image.trap` accepts any width, and `trap_to` wrote it straight to `Wdth`, so a step above the 32767 px the import clamps to saved a value Photoshop doesn't take. Like other out-of-range exports (fields.rs, #7287), it now refuses the step rather than clamping it to another width. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/atn_cmds/steps_channels.rs | 5 +++-- crates/engine/src/cmd/atn_cmds/tests.rs | 9 +++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/atn_cmds/steps_channels.rs b/crates/engine/src/cmd/atn_cmds/steps_channels.rs index 88860567e3..7e9562b235 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_channels.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_channels.rs @@ -54,14 +54,15 @@ pub(super) fn trap_from(d: &Descriptor) -> Mapped { } } -/// `Wdth` is always written: the command traps 1 px when the step leaves the width out. +/// `Wdth` is always written: the command traps 1 px when the step leaves the width out. A +/// width above `MAX_TRAP` refuses the step rather than being clamped to another value. pub(super) fn trap_to(_: &str, p: &Value) -> Option { if !sets_only(p, &["width"]) { return None; } let width = match p.get("width") { None | Some(Value::Null) => 1.0, - Some(v) => v.as_f64().filter(|n| n.is_finite())?, + Some(v) => v.as_f64().filter(|n| n.is_finite() && *n <= MAX_TRAP)?, }; Some(desc("null").with("Wdth", px(width))) } diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index d648a38216..d0d93c3dac 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -3266,3 +3266,12 @@ fn channel_and_color_steps_that_cannot_be_written_are_refused() { } assert!(export_item("image.adjustments.matchColor", &json!({"neutralize": false}), "S").is_some()); } + +/// A Trap width above the 32767 px the import clamps to refuses the step instead of writing a +/// `Wdth` Photoshop doesn't take (#10609). +#[test] +fn trap_wider_than_photoshop_takes_is_refused() { + assert!(export_item("image.trap", &json!({"width": 32768}), "S").is_none()); + let d = export_item("image.trap", &json!({"width": 32767}), "S").unwrap().descriptor.unwrap(); + assert_eq!(d.items, desc("null").with("Wdth", px(32767.0)).items); +} From fc88c451174fd1f5b1cbb1b233ff433baecd7617 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 31 Oct 2026 21:53:01 -0600 Subject: [PATCH 045/216] Actions: keep a Match Color step whose noReference isn't a Boolean (CodeRabbit review on crates/engine/src/cmd/atn_cmds/steps_channels.rs:88) Only a Boolean false kept the step: any other non-Boolean value read as missing, so the step played as a source-less Match Color and saved back as `noReference` true. A `setting` helper (as in steps_image.rs) reads the present key as a Boolean, and anything but true keeps the step. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/atn_cmds/descriptors.rs | 20 +++++++++++++------ .../engine/src/cmd/atn_cmds/steps_channels.rs | 18 +++++++++++++---- crates/engine/src/cmd/atn_cmds/tests.rs | 16 +++++++++++++++ 3 files changed, 44 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/atn_cmds/descriptors.rs b/crates/engine/src/cmd/atn_cmds/descriptors.rs index 9e9835218b..3e1dcd7a24 100644 --- a/crates/engine/src/cmd/atn_cmds/descriptors.rs +++ b/crates/engine/src/cmd/atn_cmds/descriptors.rs @@ -20,13 +20,17 @@ pub(super) fn get_num(d: &Descriptor, k: &str) -> Option { d.get(k).and_then(num) } -pub(super) fn get_bool(d: &Descriptor, k: &str) -> Option { - match d.get(k) { - Some(D::Boolean(b)) => Some(*b), +pub(super) fn boolean(v: &D) -> Option { + match v { + D::Boolean(b) => Some(*b), _ => None, } } +pub(super) fn get_bool(d: &Descriptor, k: &str) -> Option { + d.get(k).and_then(boolean) +} + pub(super) fn get_text(d: &Descriptor, k: &str) -> Option { match d.get(k) { Some(D::Text(t)) => Some(t.to_string_lossy()), @@ -35,13 +39,17 @@ pub(super) fn get_text(d: &Descriptor, k: &str) -> Option { } /// An enumerated value's id, trailing spaces removed (`Ordn.Trgt` → `"Trgt"`). -pub(super) fn get_enum(d: &Descriptor, k: &str) -> Option { - match d.get(k) { - Some(D::Enumerated { value, .. }) => Some(trimmed(value)), +pub(super) fn enumerated(v: &D) -> Option { + match v { + D::Enumerated { value, .. } => Some(trimmed(value)), _ => None, } } +pub(super) fn get_enum(d: &Descriptor, k: &str) -> Option { + d.get(k).and_then(enumerated) +} + pub(super) fn get_obj<'a>(d: &'a Descriptor, k: &str) -> Option<&'a Descriptor> { match d.get(k) { Some(D::Descriptor(o) | D::GlobalObject(o)) => Some(o), diff --git a/crates/engine/src/cmd/atn_cmds/steps_channels.rs b/crates/engine/src/cmd/atn_cmds/steps_channels.rs index 7e9562b235..43d5253148 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_channels.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_channels.rs @@ -1,7 +1,7 @@ //! Photoshop's channel and colour steps (#10609): Split Channels, Merge Spot Channel, Trap and //! Match Color, with the keys Photoshop records. -use super::descriptors::{desc, get_bool, get_num, px, trimmed}; +use super::descriptors::{boolean, desc, get_num, px, trimmed}; use super::events::pixel_target; use super::fields::{Mapped, step}; use super::{D, Descriptor, Value, json}; @@ -11,6 +11,16 @@ fn only_keys(d: &Descriptor, keys: &[&str]) -> bool { d.items.iter().all(|(k, _)| keys.contains(&trimmed(k).as_str())) } +/// The step's `key` read by `read` (its space padding ignored, as `only_keys` admits it), +/// `default` when the step leaves it out, `None` when it holds a value `read` can't take: that +/// is still the step's setting, so the step is kept rather than played with the default. +fn setting(d: &Descriptor, key: &str, read: impl Fn(&D) -> Option, default: T) -> Option { + match d.items.iter().find(|(k, _)| trimmed(k) == key.trim_end()) { + Some((_, v)) => read(v), + None => Some(default), + } +} + /// Whether `p` sets nothing but `allowed` (a `null` counts as unset). fn sets_only(p: &Value, allowed: &[&str]) -> bool { p.as_object().is_none_or(|m| m.iter().all(|(k, v)| v.is_null() || allowed.contains(&k.as_str()))) @@ -71,10 +81,10 @@ pub(super) fn trap_to(_: &str, p: &Value) -> Option { const MATCH_COLOR: &[(&str, &str, i32)] = &[("Lght", "luminance", 100), ("ClrR", "intensity", 100), ("Fade", "fade", 0)]; /// Match Color (`matchColor`) without a source, as Photoshop records it (`noReference`). A -/// step that matches another image (`noReference` false) names no source here, so it keeps -/// the Photoshop step. +/// step that matches another image (`noReference` false, or not a Boolean) names no source +/// here, so it keeps the Photoshop step. pub(super) fn match_color_from(d: &Descriptor) -> Mapped { - if !only_keys(d, &["Lght", "ClrR", "Fade", "noReference"]) || get_bool(d, "noReference") == Some(false) { + if !only_keys(d, &["Lght", "ClrR", "Fade", "noReference"]) || setting(d, "noReference", boolean, true) != Some(true) { return Mapped::Unsupported { harmless: false }; } let mut p = serde_json::Map::new(); diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index d0d93c3dac..35a23d4484 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -3275,3 +3275,19 @@ fn trap_wider_than_photoshop_takes_is_refused() { let d = export_item("image.trap", &json!({"width": 32767}), "S").unwrap().descriptor.unwrap(); assert_eq!(d.items, desc("null").with("Wdth", px(32767.0)).items); } + +/// The channel or colour step `d` of the Photoshop event `name`, as PhotoCraft loads it. +fn channel_step(name: &str, d: Descriptor) -> Mapped { + let event = if name.len() == 4 { AtnEvent::Code(name.as_bytes().try_into().unwrap()) } else { AtnEvent::name(name) }; + (events::event_for(&event).unwrap_or_else(|| panic!("{name} is unknown")).from)(&d) +} + +/// A `noReference` that isn't a Boolean doesn't say the step has no source, so the step stays +/// a Photoshop step instead of playing as a source-less Match Color (#10609). +#[test] +fn match_color_with_a_non_boolean_no_reference_is_kept() { + for v in [D::Integer(1), text("true"), enm("Ordn", "Trgt")] { + let m = channel_step("matchColor", desc("null").with("noReference", v.clone())); + assert!(matches!(m, Mapped::Unsupported { harmless: false }), "noReference {v:?} was mapped"); + } +} From 63616a670a57cb80ab8b4305fb47a77373d14a01 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 31 Oct 2026 21:54:20 -0600 Subject: [PATCH 046/216] Actions: keep a Match Color or Trap step with a non-numeric setting (CodeRabbit review on crates/engine/src/cmd/atn_cmds/steps_channels.rs:88) A present `Lght`, `ClrR`, `Fade` or Trap `Wdth` that isn't a number read as missing, so the step played with Photoshop's default and saved back with it. Each key now goes through `setting`, so such a step stays a Photoshop step. Co-Authored-By: Claude Opus 5.5 --- .../engine/src/cmd/atn_cmds/steps_channels.rs | 28 ++++++++++++------- crates/engine/src/cmd/atn_cmds/tests.rs | 17 +++++++++++ 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/atn_cmds/steps_channels.rs b/crates/engine/src/cmd/atn_cmds/steps_channels.rs index 43d5253148..54db66af1b 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_channels.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_channels.rs @@ -1,7 +1,7 @@ //! Photoshop's channel and colour steps (#10609): Split Channels, Merge Spot Channel, Trap and //! Match Color, with the keys Photoshop records. -use super::descriptors::{boolean, desc, get_num, px, trimmed}; +use super::descriptors::{boolean, desc, num, px, trimmed}; use super::events::pixel_target; use super::fields::{Mapped, step}; use super::{D, Descriptor, Value, json}; @@ -53,14 +53,15 @@ pub(super) fn merge_spot_to(_: &str, p: &Value) -> Option { /// Trap's widest width: Photoshop clamps larger ones to it. const MAX_TRAP: f64 = 32767.0; -/// Trap (`Trap`): `Wdth` in pixels. +/// Trap (`Trap`): `Wdth` in pixels. A width that isn't a number keeps the Photoshop step. pub(super) fn trap_from(d: &Descriptor) -> Mapped { if !only_keys(d, &["Wdth"]) { return Mapped::Unsupported { harmless: false }; } - match get_num(d, "Wdth") { - Some(w) => step("image.trap", json!({"width": w.min(MAX_TRAP)})), - None => step("image.trap", json!({})), + match setting(d, "Wdth", |v| num(v).map(Some), None) { + Some(Some(w)) => step("image.trap", json!({"width": w.min(MAX_TRAP)})), + Some(None) => step("image.trap", json!({})), + None => Mapped::Unsupported { harmless: false }, } } @@ -82,16 +83,23 @@ const MATCH_COLOR: &[(&str, &str, i32)] = &[("Lght", "luminance", 100), ("ClrR", /// Match Color (`matchColor`) without a source, as Photoshop records it (`noReference`). A /// step that matches another image (`noReference` false, or not a Boolean) names no source -/// here, so it keeps the Photoshop step. +/// here, so it keeps the Photoshop step, as does a setting that isn't a number. pub(super) fn match_color_from(d: &Descriptor) -> Mapped { - if !only_keys(d, &["Lght", "ClrR", "Fade", "noReference"]) || setting(d, "noReference", boolean, true) != Some(true) { - return Mapped::Unsupported { harmless: false }; + match match_color_params(d) { + Some(p) => step("image.adjustments.matchColor", Value::Object(p)), + None => Mapped::Unsupported { harmless: false }, + } +} + +fn match_color_params(d: &Descriptor) -> Option> { + if !only_keys(d, &["Lght", "ClrR", "Fade", "noReference"]) || !setting(d, "noReference", boolean, true)? { + return None; } let mut p = serde_json::Map::new(); for (key, param, default) in MATCH_COLOR { - p.insert((*param).into(), get_num(d, key).map_or(Value::from(*default), Value::from)); + p.insert((*param).into(), setting(d, key, |v| num(v).map(Value::from), Value::from(*default))?); } - step("image.adjustments.matchColor", Value::Object(p)) + Some(p) } /// Every key in recorded order. A source document or layer, Neutralize or a selection option diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index 35a23d4484..9bd0726f4a 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -3291,3 +3291,20 @@ fn match_color_with_a_non_boolean_no_reference_is_kept() { assert!(matches!(m, Mapped::Unsupported { harmless: false }), "noReference {v:?} was mapped"); } } + +/// A recorded Match Color or Trap setting that isn't a number is the step's own setting, not a +/// missing one: the step stays a Photoshop step instead of playing with the default (#10609). +#[test] +fn channel_and_color_steps_with_a_non_numeric_setting_are_kept() { + for (name, key, v) in [ + ("matchColor", "Lght", text("120")), + ("matchColor", "ClrR", D::Boolean(true)), + ("matchColor", "Fade", enm("Ordn", "Trgt")), + ("matchColor", "Lght", D::Double(f64::NAN)), + ("Trap", "Wdth", text("2")), + ("Trap", "Wdth", D::Boolean(true)), + ] { + let m = channel_step(name, desc("null").with(key, v.clone())); + assert!(matches!(m, Mapped::Unsupported { harmless: false }), "{name} with {key} = {v:?} was mapped"); + } +} From d55830de94faf801bbf2d1fb50661eb1d7cb48b0 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Sat, 31 Oct 2026 20:57:09 -0700 Subject: [PATCH 047/216] Preserve noncanonical Group F descriptor headers --- crates/engine/src/cmd/atn_cmds/events.rs | 152 ++++++++---------- .../src/cmd/atn_cmds/selection_tests.rs | 56 ++++++- .../src/cmd/atn_cmds/steps_selection.rs | 15 +- docs/atn.md | 13 +- docs/file-format-parity.md | 8 +- docs/gaps.md | 2 +- 6 files changed, 154 insertions(+), 92 deletions(-) diff --git a/crates/engine/src/cmd/atn_cmds/events.rs b/crates/engine/src/cmd/atn_cmds/events.rs index 601c6b9f0b..c471875a43 100644 --- a/crates/engine/src/cmd/atn_cmds/events.rs +++ b/crates/engine/src/cmd/atn_cmds/events.rs @@ -182,62 +182,52 @@ macro_rules! clipping_event { }; } +/// Group F validates the outer descriptor before every converter, including empty events. +macro_rules! selection_event { + ($code:literal, $name:literal, $label:literal, $ids:expr, $from:expr, $to:expr) => { + Event { code: $code, name: $name, label: $label, ids: $ids, from: |d| super::steps_selection::from(d, $from), to: $to } + }; +} + /// Every Photoshop event PhotoCraft maps. The order matters only for the panel label. pub(super) const EVENTS: &[Event] = &[ - Event { - code: "AddT", - name: "addTo", - label: "Add to Selection", - ids: &["select.rect"], - from: |d| super::steps_selection::selection_from(d, "add"), - to: |_, p| super::steps_selection::selection_to(p, "add"), - }, - Event { - code: "SbtF", - name: "subtractFrom", - label: "Subtract from Selection", - ids: &["select.rect"], - from: |d| super::steps_selection::selection_from(d, "subtract"), - to: |_, p| super::steps_selection::selection_to(p, "subtract"), - }, - Event { - code: "IntW", - name: "interfaceWhite", - label: "Intersect with Selection", - ids: &["select.rect"], - from: |d| super::steps_selection::selection_from(d, "intersect"), - to: |_, p| super::steps_selection::selection_to(p, "intersect"), - }, - Event { - code: "PstI", - name: "pasteInto", - label: "Paste Into", - ids: &["edit.pasteSpecial.pasteInto"], - from: |d| super::steps_selection::paste_from(d, "edit.pasteSpecial.pasteInto"), - to: super::steps_selection::paste_to, - }, - Event { - code: "PstO", - name: "pasteOutside", - label: "Paste Outside", - ids: &["edit.pasteSpecial.pasteOutside"], - from: |d| super::steps_selection::paste_from(d, "edit.pasteSpecial.pasteOutside"), - to: super::steps_selection::paste_to, - }, - Event { code: "Fade", name: "fade", label: "Fade", ids: &["edit.fade"], from: super::steps_selection::fade_from, to: super::steps_selection::fade_to }, - Event { - code: "", - name: "linkSelectedLayers", - label: "Link Layers", - ids: &["layer.linkLayers"], - from: super::steps_selection::link_from, - to: super::steps_selection::link_to, - }, - Event { - code: "Dstr", - name: "distort", - label: "Distribute", - ids: &[ + selection_event!("AddT", "addTo", "Add to Selection", &["select.rect"], |d| super::steps_selection::selection_from(d, "add"), |_, p| { + super::steps_selection::selection_to(p, "add") + }), + selection_event!("SbtF", "subtractFrom", "Subtract from Selection", &["select.rect"], |d| super::steps_selection::selection_from(d, "subtract"), |_, p| { + super::steps_selection::selection_to(p, "subtract") + }), + selection_event!( + "IntW", + "interfaceWhite", + "Intersect with Selection", + &["select.rect"], + |d| super::steps_selection::selection_from(d, "intersect"), + |_, p| super::steps_selection::selection_to(p, "intersect") + ), + selection_event!( + "PstI", + "pasteInto", + "Paste Into", + &["edit.pasteSpecial.pasteInto"], + |d| super::steps_selection::paste_from(d, "edit.pasteSpecial.pasteInto"), + super::steps_selection::paste_to + ), + selection_event!( + "PstO", + "pasteOutside", + "Paste Outside", + &["edit.pasteSpecial.pasteOutside"], + |d| super::steps_selection::paste_from(d, "edit.pasteSpecial.pasteOutside"), + super::steps_selection::paste_to + ), + selection_event!("Fade", "fade", "Fade", &["edit.fade"], super::steps_selection::fade_from, super::steps_selection::fade_to), + selection_event!("", "linkSelectedLayers", "Link Layers", &["layer.linkLayers"], super::steps_selection::link_from, super::steps_selection::link_to), + selection_event!( + "Dstr", + "distort", + "Distribute", + &[ "layer.distribute.leftEdges", "layer.distribute.horizontalCenters", "layer.distribute.rightEdges", @@ -245,33 +235,33 @@ pub(super) const EVENTS: &[Event] = &[ "layer.distribute.verticalCenters", "layer.distribute.bottomEdges", ], - from: super::steps_selection::distribute_from, - to: super::steps_selection::distribute_to, - }, - Event { - code: "RmvL", - name: "removeLayerMask", - label: "Remove Layer Mask", - ids: &["layer.layerMask.delete"], - from: |d| super::steps_selection::empty_from(d, "layer.layerMask.delete", Some("RmvL")), - to: super::steps_selection::empty_to, - }, - Event { - code: "DfnP", - name: "definePattern", - label: "Define Pattern", - ids: &["edit.definePattern"], - from: |d| super::steps_selection::empty_from(d, "edit.definePattern", None), - to: super::steps_selection::empty_to, - }, - Event { - code: "MrgL", - name: "", - label: "Merge Layers", - ids: &["layer.mergeLayers"], - from: |d| super::steps_selection::empty_from(d, "layer.mergeLayers", Some("MrgL")), - to: super::steps_selection::empty_to, - }, + super::steps_selection::distribute_from, + super::steps_selection::distribute_to + ), + selection_event!( + "RmvL", + "removeLayerMask", + "Remove Layer Mask", + &["layer.layerMask.delete"], + |d| super::steps_selection::empty_from(d, "layer.layerMask.delete", Some("RmvL")), + super::steps_selection::empty_to + ), + selection_event!( + "DfnP", + "definePattern", + "Define Pattern", + &["edit.definePattern"], + |d| super::steps_selection::empty_from(d, "edit.definePattern", None), + super::steps_selection::empty_to + ), + selection_event!( + "MrgL", + "", + "Merge Layers", + &["layer.mergeLayers"], + |d| super::steps_selection::empty_from(d, "layer.mergeLayers", Some("MrgL")), + super::steps_selection::empty_to + ), // Filters. ev!("GsnB", "gaussianBlur", "Gaussian Blur", "filter.blur.gaussianBlur", &[F("Rds ", "radius", Kind::Px)]), ev!( diff --git a/crates/engine/src/cmd/atn_cmds/selection_tests.rs b/crates/engine/src/cmd/atn_cmds/selection_tests.rs index 9b57b7a74a..4c17cb96a5 100644 --- a/crates/engine/src/cmd/atn_cmds/selection_tests.rs +++ b/crates/engine/src/cmd/atn_cmds/selection_tests.rs @@ -32,9 +32,8 @@ fn file(event: AtnEvent, descriptor: Descriptor) -> Vec { .unwrap() } -/// Both recorded event forms retain explicit example settings and descriptor key/type order. -#[test] -fn group_f_load_save_preserves_recorded_descriptors() { +/// Recorded and changed descriptors shared by successful and kept-step round trips. +fn fixtures() -> Vec<(&'static str, Option<&'static str>, Descriptor)> { let mut cases = vec![ ("AddT", Some("addTo"), rectangle([30.0, 30.0, 90.0, 90.0], false)), ("AddT", Some("addTo"), rectangle([30.0, 30.0, 90.0, 90.0], true)), @@ -60,7 +59,13 @@ fn group_f_load_save_preserves_recorded_descriptors() { for edge in ["AdCH", "AdRg", "AdTp", "AdCV", "AdBt"] { cases.push(("Dstr", Some("distort"), desc("null").with("null", target_ref("Lyr ")).with("Usng", enm("ADSt", edge)))); } - for (id, name, d) in cases { + cases +} + +/// Both recorded event forms retain explicit example settings and descriptor key/type order. +#[test] +fn group_f_load_save_preserves_recorded_descriptors() { + for (id, name, d) in fixtures() { let mut events = Vec::new(); if !id.is_empty() { events.push(AtnEvent::Code(id.as_bytes().try_into().unwrap())); @@ -81,6 +86,49 @@ fn group_f_load_save_preserves_recorded_descriptors() { } } +/// Outer and nested shape metadata stay kept, including headers on empty Group F events. +#[test] +fn noncanonical_group_f_headers_keep_their_bytes_and_flags() { + for (id, name, d) in fixtures() { + for variant in 0..5 { + let mut descriptor = d.clone(); + match variant { + 0 => descriptor.name = UnicodeString::new_nul("Recorded descriptor name"), + 1 => descriptor.class_id = code("Lyr "), + 2 => descriptor.class_id = Id::Str(b"null".to_vec()), + _ => { + let Some((_, D::Descriptor(shape))) = descriptor.items.iter_mut().find(|(key, _)| key.is("T ")) else { continue }; + if variant == 3 { + shape.name = UnicodeString::new_nul("Recorded shape name"); + } else { + shape.class_id = Id::Str(shape.class_id.as_bytes().to_vec()); + } + } + } + let event = if id.is_empty() { AtnEvent::name(name.unwrap()) } else { AtnEvent::Code(id.as_bytes().try_into().unwrap()) }; + let mut original = atn::parse(&file(event, descriptor.clone())).unwrap(); + original.actions[0].items[0].enabled = false; + original.actions[0].items[0].with_dialog = true; + original.actions[0].items[0].dialog_options = 2; + let mut s = Session::new(); + let loaded = s.execute("actions.load", json!({"data":photocraft_paint::tile::b64_encode(&atn::write(&original).unwrap())})).unwrap(); + assert_eq!(loaded["unsupported"].as_array().unwrap().len(), 1, "{id}/{name:?}/{variant}"); + assert_eq!(s.actions.list[0].steps[0].0, PHOTOSHOP_STEP); + assert!(s.actions.list[0].disabled.contains(&0)); + assert!(s.actions.list[0].dialogs.contains(&0)); + let saved = s.execute("actions.save", json!({"action":"Step"})).unwrap(); + assert_eq!(saved["skipped"], json!([])); + let bytes = photocraft_paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap(); + let back = atn::parse(&bytes).unwrap(); + let saved = &back.actions[0].items[0]; + let original = &original.actions[0].items[0]; + assert_eq!(saved.event, original.event); + assert_eq!((saved.enabled, saved.with_dialog, saved.dialog_options), (original.enabled, original.with_dialog, original.dialog_options)); + assert_eq!(back.actions[0].items[0].descriptor.as_ref().unwrap().to_bytes(), descriptor.to_bytes()); + } + } +} + /// Fade writes native defaults and clamps only its documented opacity range. #[test] fn fade_defaults_and_opacity_limits_are_explicit() { diff --git a/crates/engine/src/cmd/atn_cmds/steps_selection.rs b/crates/engine/src/cmd/atn_cmds/steps_selection.rs index 03e6064f78..7936af2869 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_selection.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_selection.rs @@ -5,6 +5,19 @@ use super::fields::{Mapped, step}; use super::tables::{ALIGN, blend_label, blend_ps, lookup, lookup_back}; use super::{D, Descriptor, ReferenceItem, Value, json}; +/// Exported headers use an empty name and the four-byte class identifier. +fn canonical_header(d: &Descriptor, class: &str) -> bool { + d.name == Default::default() && d.class_id == code(class) +} + +/// Group F checks the outer header before every converter, including empty events. +pub(super) fn from(d: &Descriptor, convert: fn(&Descriptor) -> Mapped) -> Mapped { + if !canonical_header(d, "null") { + return Mapped::Unsupported { harmless: false }; + } + convert(d) +} + /// Unknown or repeated descriptor keys must remain visible instead of disappearing on save. fn keys(d: &Descriptor, allowed: &[&str]) -> bool { d.items.iter().all(|(k, _)| allowed.iter().any(|name| k.is(name))) && allowed.iter().all(|name| d.items.iter().filter(|(k, _)| k.is(name)).count() <= 1) @@ -48,7 +61,7 @@ pub(super) fn selection_from(d: &Descriptor, mode: &str) -> Mapped { } let D::Descriptor(shape) = d.get("T ")? else { return None }; let ellipse = shape.class_id.is("Elps"); - if !(ellipse || shape.class_id.is("Rctn")) || (ellipse && mode != "add") || !keys(shape, &["Top ", "Left", "Btom", "Rght"]) { + if !canonical_header(shape, if ellipse { "Elps" } else { "Rctn" }) || (ellipse && mode != "add") || !keys(shape, &["Top ", "Left", "Btom", "Rght"]) { return None; } let [top, left, bottom, right] = diff --git a/docs/atn.md b/docs/atn.md index 99bdc44fae..494a7c9062 100644 --- a/docs/atn.md +++ b/docs/atn.md @@ -16,7 +16,7 @@ so playback skips them. This page has two parts: the events PhotoCraft maps today, and a measured reference for the events it does not map yet, with everything needed to implement them without the reference application. -## Supported events (128) +## Supported events (127) | Code | String ID | Label | PhotoCraft command(s) | |---|---|---|---| @@ -97,7 +97,6 @@ does not map yet, with everything needed to implement them without the reference | `CtTL` | cutToLayer | Layer Via Cut | `layer.new.layerViaCut` | | `Lvls` | levels | Levels | `image.adjustments.levels`, `image.autoTone`, `image.autoContrast` | | `Mk ` | make | Make | `layer.new.layer`, `layer.new.group`, `layer.groupLayers`, `layer.duplicate`, `layer.new.layerViaCopy`, `layer.new.layerViaCut`, `layer.newFillLayer.solidColor`, `layer.newFillLayer.pattern`, `layer.newAdjustmentLayer.levels`, `layer.newAdjustmentLayer.curves`, `layer.newAdjustmentLayer.hueSaturation`, `layer.newAdjustmentLayer.brightnessContrast`, `layer.newAdjustmentLayer.threshold`, `layer.newAdjustmentLayer.posterize`, `layer.newAdjustmentLayer.invert`, `layer.newAdjustmentLayer.colorBalance`, `layer.newAdjustmentLayer.vibrance`, `layer.newAdjustmentLayer.exposure`, `layer.newAdjustmentLayer.photoFilter`, `layer.newAdjustmentLayer.blackWhite`, `layer.newAdjustmentLayer.channelMixer`, `layer.newAdjustmentLayer.selectiveColor`, `layer.newAdjustmentLayer.gradientMap`, `layer.layerMask.revealAll`, `layer.layerMask.hideAll`, `layer.layerMask.revealSelection`, `layer.layerMask.hideSelection`, `file.new`, `file.newFromClipboard`, `history.newSnapshot` | -| (string ID only) | animationFramesFromLayers | Make Frames from Layers | Kept, disabled (no frame-animation command) | | `Mxm ` | maximum | Maximum | `filter.other.maximum` | | `Mdn ` | median | Median | `filter.noise.median` | | `Mrg2` | mergeLayersNew | Merge Layers | `layer.mergeLayers` | @@ -314,6 +313,16 @@ No parameters: the step carries an empty descriptor. The prepared-document values in this group are required inputs, rather than factory defaults. Unknown settings stay kept steps. Paste uses the current clipboard and selection; Fade uses the preceding edit. Link Layers uses the existing selected-layer toggle. Native exports retain their current Delete Mask and Merge Layers events; imported older events retain their original IDs. +## Recognized steps kept disabled + +These recognized events have no playable command. They load as kept steps switched +off, and Save Actions preserves their descriptors. They are excluded from the +supported-event count and the measured unmapped reference. + +| String ID | Label | +|---|---| +| animationFramesFromLayers | Make Frames from Layers | + ## Event reference: events not mapped yet Each entry below is an event the reference application records that PhotoCraft does not map yet: its diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index b91880b173..5e8438cc54 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-31 · **Change:** minor (`.atn`: event reference [atn.md](atn.md) with 128 supported events and the measured keys, defaults, ranges and enum values of 89 unmapped reference entries; TIFFs up to 300,000 px a side open, decoded a group of rows at a time into the layer, #8169; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; PSD Filter Gallery: Halftone Pattern dot/circle/line settings survive PSD/PSB saves (#8940); Save for Web writes WebP, lossy or lossless, #6391; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency, #8877; layered TIFF writes the TySh text version and descriptor in PSD big-endian order while the header, matrix, warp and bounds keep container order, #9098; layered TIFF writes Photoshop's 14-byte global layer mask, so Photoshop opens the layers instead of only the composite, #9095; legacy PSD point text placed from the layout ink box after font resolution, #6094; `.atn`: 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; `.asl` layer style libraries read and written, #1051; JPEG Options on File › Save, Save As and Save a Copy, FILE-215-19 partial (no Matte, progressive Scans or size preview); 32-bit Hue/Saturation unclipped and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; JPEG Options on Save As and Save a Copy, FILE-215-19; TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; Cineon write: 10-bit gray/RGB; animated WebP frames load into video layers, #1198; Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 53 filters (39 more: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag, Invert, Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding; Amiga IFF write: 24-plane ILBM, Save As `.iff`; PSD stores spot channels as Photoshop does, ink 0 = solid, and numbers them in 1053, #7415 split; TIFF writes and reads alpha and spot channels as extra samples with Photoshop's channel resources, PSD output unchanged, FILE-215-7, FILE-215-17's TIFF part, #7415; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-31 · **Change:** minor (`.atn`: event reference [atn.md](atn.md) with 127 supported events and the measured keys, defaults, ranges and enum values of 89 unmapped reference entries; TIFFs up to 300,000 px a side open, decoded a group of rows at a time into the layer, #8169; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; PSD Filter Gallery: Halftone Pattern dot/circle/line settings survive PSD/PSB saves (#8940); Save for Web writes WebP, lossy or lossless, #6391; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency, #8877; layered TIFF writes the TySh text version and descriptor in PSD big-endian order while the header, matrix, warp and bounds keep container order, #9098; layered TIFF writes Photoshop's 14-byte global layer mask, so Photoshop opens the layers instead of only the composite, #9095; legacy PSD point text placed from the layout ink box after font resolution, #6094; `.atn`: 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; `.asl` layer style libraries read and written, #1051; JPEG Options on File › Save, Save As and Save a Copy, FILE-215-19 partial (no Matte, progressive Scans or size preview); 32-bit Hue/Saturation unclipped and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; JPEG Options on Save As and Save a Copy, FILE-215-19; TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; Cineon write: 10-bit gray/RGB; animated WebP frames load into video layers, #1198; Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 53 filters (39 more: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag, Invert, Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding; Amiga IFF write: 24-plane ILBM, Save As `.iff`; PSD stores spot channels as Photoshop does, ink 0 = solid, and numbers them in 1053, #7415 split; TIFF writes and reads alpha and spot channels as extra samples with Photoshop's channel resources, PSD output unchanged, FILE-215-7, FILE-215-17's TIFF part, #7415; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency) · **Target:** Adobe Photoshop 2026 **PSD Filter Gallery (#8940, 2026-10-23):** Halftone Pattern maps its size, contrast and dot/circle/line pattern as editable settings on import and export, with the reference application's descriptor keys (`HlSz`, `Cntr`, `ScrT`: `ScrD`/`ScrC`/`ScrL`). PSD/PSB round trips pass at 8/16/32 bits. This does not claim pixel equivalence for the existing renderer. Unknown or malformed settings remain verbatim on import. @@ -133,7 +133,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | LUTs `.cube` / `.3dl` / `.look` | R/W (Export Color Lookup) | R/W / R / R | `cms/src/lutfile.rs` | | Keyboard shortcuts `.kys` | R/W | R/W | `engine/src/kys.rs` (`write`: Edit › Keyboard Shortcuts › Export…, rows carry our id; Photoshop reading our files is unverified) | | ICC profiles | R | R | Own CMS | -| Actions `.atn` ([reference](atn.md)) | R/W | R/W | `psd/src/atn.rs` (version 16 sets, exact round trip), `engine/src/atn_cmds.rs` (Photoshop events ↔ commands, including Apply Layer Comp by name, #7101; other steps kept verbatim; Save As maps to `file.saveAs`/`file.saveACopy` when its destination is a file, #7102). Panel menu, CLI `batch --actions set.atn`, `actions.load`/`actions.save`. Photoshop 5-era version 12 files unsupported (AUTO-219-2) | +| Actions `.atn` ([reference](atn.md)) | R/W | R/W | `psd/src/atn.rs` (version 16 sets, exact round trip), `engine/src/atn_cmds.rs` (Photoshop events ↔ commands, including Apply Layer Comp by name, #7101; other steps kept verbatim; Save As maps to `file.saveAs`/`file.saveACopy` when its destination is a file, #7102). Panel menu, CLI `batch --actions set.atn`, `actions.load`/`actions.save`. Photoshop 5-era version 12 files unsupported (AUTO-219-2); 127 supported event IDs, 89 measured reference entries unmapped, one recognized-but-disabled event | | Styles `.asl` | R/W | R/W | `psd/src/asl.rs`, `io/src/asl_map.rs`; Styles panel Import/Export Styles…, `style.presets.importAsl`/`exportAsl` (#1051) | | Contours `.shc` | R/W | R/W | `psd/src/shc.rs`; Contour Editor Load…/Save… (FILE-215-15). No user contour library; not yet checked against a Photoshop-written file | | Custom shapes `.csh`, tool presets `.tpl` | R/W | no | | @@ -146,10 +146,12 @@ Embedded SVG and PDF-compatible AI/PDF/EPS payloads can use an explicit VectorCr ## Revision history +The original reference row retains its source date and historical totals; current totals are in the status and Actions row above. + | Date | Change | Summary | -| 2026-10-31 | minor | `.atn` Group F: selection combinations, Paste Into/Outside, Fade, Link, Distribute, Define Pattern and older mask/merge events load, play and save; 128 supported event IDs, 89 unmapped reference entries; AUTO-219-2 remains partial | |---|---|---| | 2026-11-01 | minor | `.atn`: event reference [atn.md](atn.md): the 117 supported events, and measured keys, defaults, ranges and enum values for 101 events not mapped yet | +| 2026-10-31 | minor | `.atn` Group F: selection combinations, Paste Into/Outside, Fade, Link, Distribute, Define Pattern and older mask/merge events load, play and save; 127 supported event IDs, 89 unmapped reference entries; AUTO-219-2 remains partial | | 2026-10-26 | minor | Embedded AI/PDF/EPS save-back retains native format and a separate source-bound display preview; original sources remain undoable. Native Adobe interoperability and standalone EPS/DCS remain unverified; no corpus floor or percentage changed. | | 2026-10-22 | minor | Embedded PSD vectors gain explicit bounded SVG editor/save-back; standalone EPS/DCS and native-format fidelity remain open. | | 2026-10-22 | minor | TIFF: flat pages up to 300,000 px a side open (was 2^30 px, about 32,768²): `codecs::TiffBands` decodes a group of rows at a time into the layer's tiles. The 117,504 × 22,464 px banner from #8456 opens in 3.3 s at 11.45 GB peak for a 10.56 GB layer; 30,000² 6.8 s / 6.43 GB → 1.1 s / 3.86 GB; 36 MP 220 → 52 ms. Layered TIFFs still decode whole (#8169) | diff --git a/docs/gaps.md b/docs/gaps.md index 77cd674d79..d1a21b3f8a 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -424,8 +424,8 @@ what an agent can't finish alone. ## Revision history | Date | Change | Summary | -| 2026-10-31 | minor | G11: Group F action steps map to existing commands; 128 supported event IDs; action-file support remains partial; estimates unchanged | |---|---|---| +| 2026-10-31 | minor | G11: Group F action steps map to existing commands; 127 supported event IDs; action-file support remains partial; estimates unchanged | | 2026-10-27 | minor | Smooth/Feather command reads and replacement use selection bounds plus blur reach; full-canvas reference and release comparisons extended; percentages and scorecard budgets unchanged | | 2026-10-27 | minor | G9: 12 of 181 preferences do nothing, the scorecard's count with integration batch 9 (#9775) on main; no estimate changed | | 2026-10-26 | minor | Expand/Contract/Border distance work, mask reads and replacement cropped to nonzero bounds; bounded distances for radii ≤5; 36 MP exact-output comparisons improve 1.54–37.82× across eight cases; percentages, estimates and scorecard budgets unchanged | From 9b2be9a333157d0e0e1743f0ef0ab10ecbda4fc3 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 31 Oct 2026 21:58:46 -0600 Subject: [PATCH 048/216] Actions: keep a Distort step with a wrong-typed setting (CodeRabbit review on crates/engine/src/cmd/atn_cmds/steps_distort.rs:61) A present integer or enumeration key of another type (an `Amnt` as a Boolean, a `SphM` as text), or a Wave `SclH`/`SclV`, `UndA` or `RndS` that couldn't be read, was taken as missing, so the step played with Photoshop's default. A `setting` helper (as in steps_image.rs) now reads a present key with its expected type and returns None otherwise, so the step stays a Photoshop step. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/atn_cmds/descriptors.rs | 20 +++++++++---- .../engine/src/cmd/atn_cmds/steps_distort.rs | 30 ++++++++++++++----- crates/engine/src/cmd/atn_cmds/tests.rs | 22 ++++++++++++++ 3 files changed, 58 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/cmd/atn_cmds/descriptors.rs b/crates/engine/src/cmd/atn_cmds/descriptors.rs index 9e9835218b..3e1dcd7a24 100644 --- a/crates/engine/src/cmd/atn_cmds/descriptors.rs +++ b/crates/engine/src/cmd/atn_cmds/descriptors.rs @@ -20,13 +20,17 @@ pub(super) fn get_num(d: &Descriptor, k: &str) -> Option { d.get(k).and_then(num) } -pub(super) fn get_bool(d: &Descriptor, k: &str) -> Option { - match d.get(k) { - Some(D::Boolean(b)) => Some(*b), +pub(super) fn boolean(v: &D) -> Option { + match v { + D::Boolean(b) => Some(*b), _ => None, } } +pub(super) fn get_bool(d: &Descriptor, k: &str) -> Option { + d.get(k).and_then(boolean) +} + pub(super) fn get_text(d: &Descriptor, k: &str) -> Option { match d.get(k) { Some(D::Text(t)) => Some(t.to_string_lossy()), @@ -35,13 +39,17 @@ pub(super) fn get_text(d: &Descriptor, k: &str) -> Option { } /// An enumerated value's id, trailing spaces removed (`Ordn.Trgt` → `"Trgt"`). -pub(super) fn get_enum(d: &Descriptor, k: &str) -> Option { - match d.get(k) { - Some(D::Enumerated { value, .. }) => Some(trimmed(value)), +pub(super) fn enumerated(v: &D) -> Option { + match v { + D::Enumerated { value, .. } => Some(trimmed(value)), _ => None, } } +pub(super) fn get_enum(d: &Descriptor, k: &str) -> Option { + d.get(k).and_then(enumerated) +} + pub(super) fn get_obj<'a>(d: &'a Descriptor, k: &str) -> Option<&'a Descriptor> { match d.get(k) { Some(D::Descriptor(o) | D::GlobalObject(o)) => Some(o), diff --git a/crates/engine/src/cmd/atn_cmds/steps_distort.rs b/crates/engine/src/cmd/atn_cmds/steps_distort.rs index b1ad578cc8..cc42e29d3d 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_distort.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_distort.rs @@ -1,7 +1,7 @@ //! Photoshop's Distort filter steps (#10605): Pinch, Polar Coordinates, Spherize, Twirl, Wave and //! ZigZag, each key in recorded order with the limits and defaults Photoshop measures. -use super::descriptors::{desc, enm, get_enum, get_num, trimmed}; +use super::descriptors::{desc, enm, enumerated, num, trimmed}; use super::events::pixel_target; use super::fields::{Mapped, step}; use super::tables::{lookup, lookup_back}; @@ -12,6 +12,16 @@ fn only_keys(d: &Descriptor, keys: &[&str]) -> bool { d.items.iter().all(|(k, _)| keys.contains(&trimmed(k).as_str())) } +/// The step's `key` read by `read` (its space padding ignored, as `only_keys` admits it), +/// `default` when the step leaves it out, `None` when it holds a value `read` can't take: that +/// is still the step's setting, so the step is kept rather than played with the default. +fn setting(d: &Descriptor, key: &str, read: impl Fn(&D) -> Option, default: T) -> Option { + match d.items.iter().find(|(k, _)| trimmed(k) == key.trim_end()) { + Some((_, v)) => read(v), + None => Some(default), + } +} + /// Whether `p` sets nothing but `allowed` (a `null` counts as unset). fn sets_only(p: &Value, allowed: &[&str]) -> bool { p.as_object().is_none_or(|m| m.iter().all(|(k, v)| v.is_null() || allowed.contains(&k.as_str()))) @@ -42,7 +52,7 @@ impl Key { } /// A step with exactly `keys` (any missing): each value clamped or looked up, Photoshop's -/// default for what it leaves out. Any other key keeps the step. +/// default for what it leaves out. Any other key, or a value of the wrong type, keeps the step. pub(super) fn keys_from(d: &Descriptor, id: &str, keys: &[Key]) -> Mapped { keys_map(d, keys, &[]).map_or(Mapped::Unsupported { harmless: false }, |m| step(id, Value::Object(m))) } @@ -56,9 +66,9 @@ fn keys_map(d: &Descriptor, keys: &[Key], extra: &[&'static str]) -> Option { - Value::from(get_num(d, key).map_or(*default, |n| n.round().clamp(f64::from(*min), f64::from(*max)) as i32)) + Value::from(setting(d, key, |v| num(v).map(|n| n.round().clamp(f64::from(*min), f64::from(*max)) as i32), *default)?) } - Key::Enum { key, table, default, .. } => Value::from(lookup(table, &get_enum(d, key).unwrap_or_else(|| (*default).to_string()))?), + Key::Enum { key, table, default, .. } => Value::from(lookup(table, &setting(d, key, enumerated, (*default).to_string())?)?), }; m.insert(k.param().into(), v); } @@ -135,13 +145,17 @@ const WAVE_UNDEFINED: &[(&str, &str)] = &[("RptE", "repeat"), ("WrpA", "wrap")]; pub(super) fn wave_from(d: &Descriptor) -> Mapped { let unsupported = Mapped::Unsupported { harmless: false }; let Some(mut m) = keys_map(d, WAVE, &["SclH", "SclV", "UndA", "RndS"]) else { return unsupported }; - if ["SclH", "SclV"].iter().any(|k| get_num(d, k).is_some_and(|n| n.round().clamp(1.0, 100.0) != 100.0)) { + if ["SclH", "SclV"].iter().any(|k| setting(d, k, num, 100.0).is_none_or(|n| n.round().clamp(1.0, 100.0) != 100.0)) { return unsupported; } - let Some(undefined) = lookup(WAVE_UNDEFINED, &get_enum(d, "UndA").unwrap_or_else(|| "RptE".into())) else { return unsupported }; + let Some(undefined) = setting(d, "UndA", enumerated, "RptE".into()).and_then(|e| lookup(WAVE_UNDEFINED, &e)) else { return unsupported }; m.insert("undefinedAreas".into(), undefined.into()); - if let Some(seed) = get_num(d, "RndS") { - m.insert("seed".into(), Value::from(seed as i32 as u32)); + match setting(d, "RndS", |v| num(v).map(Some), None) { + Some(Some(seed)) => { + m.insert("seed".into(), Value::from(seed as i32 as u32)); + } + Some(None) => {} + None => return unsupported, } step("filter.distort.wave", Value::Object(m)) } diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index 8a1c3e2e1c..bb7aac7895 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -3290,3 +3290,25 @@ fn distort_filter_steps_that_cannot_be_written_are_refused() { let wave = export_item("filter.distort.wave", &json!({}), "S").unwrap().descriptor.unwrap(); assert_eq!(get_enum(&wave, "UndA").as_deref(), Some("WrpA")); } + +/// A recorded setting of the wrong type is the step's own setting, not a missing one: the +/// mapper keeps the Photoshop step instead of playing it with Photoshop's default (#10605). +#[test] +fn distort_filter_steps_with_a_wrong_typed_setting_are_kept() { + let kept = |m: Mapped| matches!(m, Mapped::Unsupported { harmless: false }); + for (code, key, v) in [ + ("Pnch", "Amnt", D::Boolean(true)), + ("Twrl", "Angl", text("90")), + ("Plr ", "Cnvr", D::Integer(1)), + ("Sphr", "SphM", text("HrzO")), + ("ZgZg", "NmbR", enm("Ordn", "Trgt")), + ("Wave", "NmbG", D::Boolean(false)), + ("Wave", "Wvtp", D::Integer(2)), + ("Wave", "SclH", text("50")), + ("Wave", "SclV", D::Boolean(true)), + ("Wave", "UndA", D::Integer(1)), + ("Wave", "RndS", text("7")), + ] { + assert!(kept(distort(code, desc("null").with(key, v.clone()))), "{code} with {key} = {v:?} was mapped"); + } +} From 6042bb68e85427a65a9b09ac0fcb5da703a4e07a Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 31 Oct 2026 21:59:54 -0600 Subject: [PATCH 049/216] Actions: refuse a Distort step outside Photoshop's range (CodeRabbit review on crates/engine/src/cmd/atn_cmds/steps_distort.rs:88) `keys_to` only checked that an integer fit in 32 bits, so a Pinch `amount` of 500 was written as is and loaded back clamped to 100. Each integer key, Wave's included, now refuses a value outside its `min..=max`, like other out-of-range exports (fields.rs, #7287). Co-Authored-By: Claude Opus 5.5 --- .../engine/src/cmd/atn_cmds/steps_distort.rs | 7 ++--- crates/engine/src/cmd/atn_cmds/tests.rs | 26 +++++++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/atn_cmds/steps_distort.rs b/crates/engine/src/cmd/atn_cmds/steps_distort.rs index cc42e29d3d..dbfaacfece 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_distort.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_distort.rs @@ -76,7 +76,8 @@ fn keys_map(d: &Descriptor, keys: &[Key], extra: &[&'static str]) -> Option Option { let params: Vec<&str> = keys.iter().map(Key::param).chain(extra.iter().copied()).chain(["target"]).collect(); if !pixel_target(p) || !sets_only(p, ¶ms) { @@ -90,9 +91,9 @@ pub(super) fn keys_to(p: &Value, keys: &[Key], ours: &[(&str, Value)], extra: &[ for k in keys { let v = value(k.param())?; d = match k { - Key::Int { key, .. } => { + Key::Int { key, min, max, .. } => { let n = v.as_f64()?; - if n.fract() != 0.0 || !(f64::from(i32::MIN)..=f64::from(i32::MAX)).contains(&n) { + if n.fract() != 0.0 || !(f64::from(*min)..=f64::from(*max)).contains(&n) { return None; } d.with(key, D::Integer(n as i32)) diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index bb7aac7895..4925d1219c 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -3312,3 +3312,29 @@ fn distort_filter_steps_with_a_wrong_typed_setting_are_kept() { assert!(kept(distort(code, desc("null").with(key, v.clone()))), "{code} with {key} = {v:?} was mapped"); } } + +/// An integer outside the range Photoshop clamps the key to refuses the step: written as is, it +/// would load back clamped, as another value (#10605). +#[test] +fn distort_filter_steps_outside_photoshops_range_are_refused() { + for (id, params) in [ + ("filter.distort.pinch", json!({"amount": 500})), + ("filter.distort.spherize", json!({"amount": -101})), + ("filter.distort.twirl", json!({"angle": 1000})), + ("filter.distort.zigZag", json!({"ridges": 21})), + ("filter.distort.zigZag", json!({"ridges": -1})), + ("filter.distort.wave", json!({"generators": 0})), + ("filter.distort.wave", json!({"amplitudeMax": 1000})), + ] { + assert!(export_item(id, ¶ms, "S").is_none(), "{id} {params} exported"); + } + // The limits themselves are written. + for (id, params) in [ + ("filter.distort.pinch", json!({"amount": -100})), + ("filter.distort.twirl", json!({"angle": 999})), + ("filter.distort.zigZag", json!({"ridges": 0})), + ("filter.distort.wave", json!({"generators": 999, "wavelengthMin": 1})), + ] { + assert!(export_item(id, ¶ms, "S").is_some(), "{id} {params} refused"); + } +} From f3f046871ed7499b6a6f54d34877196f43a663b1 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 31 Oct 2026 22:00:57 -0600 Subject: [PATCH 050/216] Actions: load a Wave seed's exact 32 bits or keep the step (CodeRabbit review on crates/engine/src/cmd/atn_cmds/steps_distort.rs:145) `RndS` went through `as i32`, so a `LargeInteger` seed above i32::MAX saturated to 2147483647 and a fractional one was truncated: both played with another seed. The seed now loads only when it is a whole number in i32::MIN..=u32::MAX (a negative one still as its signed bit pattern); anything else keeps the Photoshop step. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/atn_cmds/steps_distort.rs | 11 +++++++++-- crates/engine/src/cmd/atn_cmds/tests.rs | 17 +++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/atn_cmds/steps_distort.rs b/crates/engine/src/cmd/atn_cmds/steps_distort.rs index dbfaacfece..99e80a30e6 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_distort.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_distort.rs @@ -151,9 +151,9 @@ pub(super) fn wave_from(d: &Descriptor) -> Mapped { } let Some(undefined) = setting(d, "UndA", enumerated, "RptE".into()).and_then(|e| lookup(WAVE_UNDEFINED, &e)) else { return unsupported }; m.insert("undefinedAreas".into(), undefined.into()); - match setting(d, "RndS", |v| num(v).map(Some), None) { + match setting(d, "RndS", |v| seed_bits(v).map(Some), None) { Some(Some(seed)) => { - m.insert("seed".into(), Value::from(seed as i32 as u32)); + m.insert("seed".into(), Value::from(seed)); } Some(None) => {} None => return unsupported, @@ -161,6 +161,13 @@ pub(super) fn wave_from(d: &Descriptor) -> Mapped { step("filter.distort.wave", Value::Object(m)) } +/// `RndS` as the seed's 32 bits: a negative value is a signed seed's bit pattern. A fraction +/// or a value past 32 bits is no seed, rather than one truncated or saturated to another. +fn seed_bits(v: &D) -> Option { + let n = num(v).filter(|n| n.fract() == 0.0 && (f64::from(i32::MIN)..=f64::from(u32::MAX)).contains(n))?; + Some(if n < 0.0 { n as i32 as u32 } else { n as u32 }) +} + pub(super) fn wave_to(_: &str, p: &Value) -> Option { let ours = [ ("type", json!("sine")), diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index 4925d1219c..b2dcc96eb2 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -3338,3 +3338,20 @@ fn distort_filter_steps_outside_photoshops_range_are_refused() { assert!(export_item(id, ¶ms, "S").is_some(), "{id} {params} refused"); } } + +/// Wave's `RndS` is the seed's 32 bits, signed or not: it loads exactly or keeps the step, +/// never saturated or truncated to another seed (#10605). +#[test] +fn wave_seed_loads_its_exact_32_bits_or_keeps_the_step() { + let seed = |v: D| match distort("Wave", desc("null").with("RndS", v)) { + Mapped::Steps(s) => Some(s[0].1["seed"].clone()), + Mapped::Unsupported { .. } => None, + }; + assert_eq!(seed(D::LargeInteger(4_000_000_000)), Some(json!(4_000_000_000_u32))); + assert_eq!(seed(D::LargeInteger(-1)), Some(json!(u32::MAX))); + assert_eq!(seed(D::Integer(-7)), Some(json!(4_294_967_289_u32))); + assert_eq!(seed(D::Double(12.0)), Some(json!(12))); + for v in [D::Double(7.5), D::LargeInteger(4_294_967_296), D::LargeInteger(i64::from(i32::MIN) - 1)] { + assert_eq!(seed(v.clone()), None, "RndS {v:?} was mapped"); + } +} From 0a605880dd16025bb6f5c15345bce7bfef9ba4bc Mon Sep 17 00:00:00 2001 From: yoriswind Date: Sat, 31 Oct 2026 21:11:28 -0700 Subject: [PATCH 051/216] Separate disabled ATN steps from supported Render counts --- docs/atn.md | 13 +++++++++++-- docs/file-format-parity.md | 8 +++++--- docs/gaps.md | 6 +++--- 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/docs/atn.md b/docs/atn.md index 90f7cd549f..389cc40261 100644 --- a/docs/atn.md +++ b/docs/atn.md @@ -16,7 +16,7 @@ so playback skips them. This page has two parts: the events PhotoCraft maps today, and a measured reference for the events it does not map yet, with everything needed to implement them without the reference application. -## Supported events (121) +## Supported events (120) | Code | String ID | Label | PhotoCraft command(s) | |---|---|---|---| @@ -90,7 +90,6 @@ does not map yet, with everything needed to implement them without the reference | `CtTL` | cutToLayer | Layer Via Cut | `layer.new.layerViaCut` | | `Lvls` | levels | Levels | `image.adjustments.levels`, `image.autoTone`, `image.autoContrast` | | `Mk ` | make | Make | `layer.new.layer`, `layer.new.group`, `layer.groupLayers`, `layer.duplicate`, `layer.new.layerViaCopy`, `layer.new.layerViaCut`, `layer.newFillLayer.solidColor`, `layer.newFillLayer.pattern`, `layer.newAdjustmentLayer.levels`, `layer.newAdjustmentLayer.curves`, `layer.newAdjustmentLayer.hueSaturation`, `layer.newAdjustmentLayer.brightnessContrast`, `layer.newAdjustmentLayer.threshold`, `layer.newAdjustmentLayer.posterize`, `layer.newAdjustmentLayer.invert`, `layer.newAdjustmentLayer.colorBalance`, `layer.newAdjustmentLayer.vibrance`, `layer.newAdjustmentLayer.exposure`, `layer.newAdjustmentLayer.photoFilter`, `layer.newAdjustmentLayer.blackWhite`, `layer.newAdjustmentLayer.channelMixer`, `layer.newAdjustmentLayer.selectiveColor`, `layer.newAdjustmentLayer.gradientMap`, `layer.layerMask.revealAll`, `layer.layerMask.hideAll`, `layer.layerMask.revealSelection`, `layer.layerMask.hideSelection`, `file.new`, `file.newFromClipboard`, `history.newSnapshot` | -| (string ID only) | animationFramesFromLayers | Make Frames from Layers | Kept step (disabled) | | `Mxm ` | maximum | Maximum | `filter.other.maximum` | | `Mdn ` | median | Median | `filter.noise.median` | | `Mrg2` | mergeLayersNew | Merge Layers | `layer.mergeLayers` | @@ -195,6 +194,16 @@ Nested values as recorded (defaults): Native commands without an explicit Lens Flare center still use 0.5 / 0.5. Imported steps materialize the measured center above. A missing Fibers seed is captured once at import and preserved for later playback and export. Native color overrides and other settings without ATN fields are refused during strict export. +## Recognized steps kept disabled + +These recognized events have no playable command. They load as kept steps switched +off, and Save Actions preserves their descriptors. They are excluded from the +supported-event count and the measured unmapped reference. + +| String ID | Label | +|---|---| +| animationFramesFromLayers | Make Frames from Layers | + ## Event reference: events not mapped yet Each entry below is an event the reference application records that PhotoCraft does not map yet: its diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 55e9b309a9..7900fca24e 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-31 · **Change:** minor (`.atn`: 121 supported and 97 unmapped events after the four Render mappings (Group B, #10604); the supported table and measured reference are in [atn.md](atn.md); TIFFs up to 300,000 px a side open, decoded a group of rows at a time into the layer, #8169; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; PSD Filter Gallery: Halftone Pattern dot/circle/line settings survive PSD/PSB saves (#8940); Save for Web writes WebP, lossy or lossless, #6391; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency, #8877; layered TIFF writes the TySh text version and descriptor in PSD big-endian order while the header, matrix, warp and bounds keep container order, #9098; layered TIFF writes Photoshop's 14-byte global layer mask, so Photoshop opens the layers instead of only the composite, #9095; legacy PSD point text placed from the layout ink box after font resolution, #6094; `.atn`: 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; `.asl` layer style libraries read and written, #1051; JPEG Options on File › Save, Save As and Save a Copy, FILE-215-19 partial (no Matte, progressive Scans or size preview); 32-bit Hue/Saturation unclipped and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; JPEG Options on Save As and Save a Copy, FILE-215-19; TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; Cineon write: 10-bit gray/RGB; animated WebP frames load into video layers, #1198; Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 53 filters (39 more: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag, Invert, Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding; Amiga IFF write: 24-plane ILBM, Save As `.iff`; PSD stores spot channels as Photoshop does, ink 0 = solid, and numbers them in 1053, #7415 split; TIFF writes and reads alpha and spot channels as extra samples with Photoshop's channel resources, PSD output unchanged, FILE-215-7, FILE-215-17's TIFF part, #7415; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-31 · **Change:** minor (`.atn`: 120 supported and 97 unmapped events after the four Render mappings (Group B, #10604); the supported table and measured reference are in [atn.md](atn.md); TIFFs up to 300,000 px a side open, decoded a group of rows at a time into the layer, #8169; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; PSD Filter Gallery: Halftone Pattern dot/circle/line settings survive PSD/PSB saves (#8940); Save for Web writes WebP, lossy or lossless, #6391; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency, #8877; layered TIFF writes the TySh text version and descriptor in PSD big-endian order while the header, matrix, warp and bounds keep container order, #9098; layered TIFF writes Photoshop's 14-byte global layer mask, so Photoshop opens the layers instead of only the composite, #9095; legacy PSD point text placed from the layout ink box after font resolution, #6094; `.atn`: 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; `.asl` layer style libraries read and written, #1051; JPEG Options on File › Save, Save As and Save a Copy, FILE-215-19 partial (no Matte, progressive Scans or size preview); 32-bit Hue/Saturation unclipped and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; JPEG Options on Save As and Save a Copy, FILE-215-19; TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; Cineon write: 10-bit gray/RGB; animated WebP frames load into video layers, #1198; Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 53 filters (39 more: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag, Invert, Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding; Amiga IFF write: 24-plane ILBM, Save As `.iff`; PSD stores spot channels as Photoshop does, ink 0 = solid, and numbers them in 1053, #7415 split; TIFF writes and reads alpha and spot channels as extra samples with Photoshop's channel resources, PSD output unchanged, FILE-215-7, FILE-215-17's TIFF part, #7415; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency) · **Target:** Adobe Photoshop 2026 **PSD Filter Gallery (#8940, 2026-10-23):** Halftone Pattern maps its size, contrast and dot/circle/line pattern as editable settings on import and export, with the reference application's descriptor keys (`HlSz`, `Cntr`, `ScrT`: `ScrD`/`ScrC`/`ScrL`). PSD/PSB round trips pass at 8/16/32 bits. This does not claim pixel equivalence for the existing renderer. Unknown or malformed settings remain verbatim on import. @@ -133,7 +133,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | LUTs `.cube` / `.3dl` / `.look` | R/W (Export Color Lookup) | R/W / R / R | `cms/src/lutfile.rs` | | Keyboard shortcuts `.kys` | R/W | R/W | `engine/src/kys.rs` (`write`: Edit › Keyboard Shortcuts › Export…, rows carry our id; Photoshop reading our files is unverified) | | ICC profiles | R | R | Own CMS | -| Actions `.atn` ([reference](atn.md)) | R/W | R/W | `psd/src/atn.rs` (version 16 sets, exact round trip), `engine/src/atn_cmds.rs` (Photoshop events ↔ commands, including Apply Layer Comp by name, #7101; other steps kept verbatim; Save As maps to `file.saveAs`/`file.saveACopy` when its destination is a file, #7102). Panel menu, CLI `batch --actions set.atn`, `actions.load`/`actions.save`. Photoshop 5-era version 12 files unsupported (AUTO-219-2) | +| Actions `.atn` ([reference](atn.md)) | R/W | R/W | `psd/src/atn.rs` (version 16 sets, exact round trip), `engine/src/atn_cmds.rs` (Photoshop events ↔ commands, including Apply Layer Comp by name, #7101; other steps kept verbatim; Save As maps to `file.saveAs`/`file.saveACopy` when its destination is a file, #7102). Panel menu, CLI `batch --actions set.atn`, `actions.load`/`actions.save`. Photoshop 5-era version 12 files unsupported (AUTO-219-2); 120 supported event IDs, 97 measured reference entries unmapped, one recognized-but-disabled event | | Styles `.asl` | R/W | R/W | `psd/src/asl.rs`, `io/src/asl_map.rs`; Styles panel Import/Export Styles…, `style.presets.importAsl`/`exportAsl` (#1051) | | Contours `.shc` | R/W | R/W | `psd/src/shc.rs`; Contour Editor Load…/Save… (FILE-215-15). No user contour library; not yet checked against a Photoshop-written file | | Custom shapes `.csh`, tool presets `.tpl` | R/W | no | | @@ -146,10 +146,12 @@ Embedded SVG and PDF-compatible AI/PDF/EPS payloads can use an explicit VectorCr ## Revision history +The original reference row retains its source date and historical totals; current totals are in the status and Actions row above. + | Date | Change | Summary | |---|---|---| -| 2026-10-31 | minor | `.atn` Group B: four Render events load, play and save; 121 supported and 97 unmapped. Supported-table columns corrected against the event registry; the original 117/101 reference entry remains historical. | | 2026-11-01 | minor | `.atn`: event reference [atn.md](atn.md): the 117 supported events, and measured keys, defaults, ranges and enum values for 101 events not mapped yet | +| 2026-10-31 | minor | `.atn` Group B: four Render events load, play and save; 120 supported and 97 unmapped. Supported-table columns corrected against the event registry; the original 117/101 reference entry remains historical. | | 2026-10-26 | minor | Embedded AI/PDF/EPS save-back retains native format and a separate source-bound display preview; original sources remain undoable. Native Adobe interoperability and standalone EPS/DCS remain unverified; no corpus floor or percentage changed. | | 2026-10-22 | minor | Embedded PSD vectors gain explicit bounded SVG editor/save-back; standalone EPS/DCS and native-format fidelity remain open. | | 2026-10-22 | minor | TIFF: flat pages up to 300,000 px a side open (was 2^30 px, about 32,768²): `codecs::TiffBands` decodes a group of rows at a time into the layer's tiles. The 117,504 × 22,464 px banner from #8456 opens in 3.3 s at 11.45 GB peak for a 10.56 GB layer; 30,000² 6.8 s / 6.43 GB → 1.1 s / 3.86 GB; 36 MP 220 → 52 ms. Layered TIFFs still decode whole (#8169) | diff --git a/docs/gaps.md b/docs/gaps.md index 8ef613ea82..b3640a4d3e 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (G11: four Render ATN events mapped, 121 supported/97 unmapped; 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; G9: 12 of 181 preferences do nothing, as the scorecard prefs audit counts them; G6: Art History Brush landed as partial, TOOL-213-20, #1610, so no toolbar tool is missing; G4: five hard-round built-in presets use 5% spacing to remove stacked-tip outlines and reduce pressure-opacity bands, #8842; G9: Crop tool canvas menu, #6561; G2: Content-Aware Scale seam carving 1.65–3.6× faster with unchanged output, P28 row 3.3 → 1.6 s on a 4-core VM, #5236; G9: UI scale presets done, 105–120% steps, #3222; G14: lossy DNG, raw sidecars, Camera Raw Defaults live, Calibration and Auto, CR3 assessed as not clean-room feasible; G8: PSD type on a path opens on its path and saves back into the file's `Txt2`, #7962; G11: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; adjustment clipping stays visible in compact Properties, #5231; G9: Color panel modes, ramp and copy commands, the same in every theme, UI-217-17, #6000; G17: Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; G10: `.asl` style libraries landed, #1051; G8: area type (TYPE-218-10); G1: smart objects keep Photoshop's Cylinder warp, #4112; G8: paragraph direction buttons in the Paragraph panel and options bar, TYPE-218-2 partial; G1: 32-bit Hue/Saturation and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; G11: compound layer properties export as separate Photoshop Set events, rename last, #8304; G11: Batch and droplets take the result at the action's Save step, so actions ending in Save As and Close work; G11: `.atn` steps that switch to another open document import, play and save back, #7103; G14: the Nikon D4 white level follows the sample depth, so 12-bit D4 NEFs no longer open 2.6 stops dark, #5978; G14: a Nikon NEF's Adobe RGB preview is read as Adobe RGB (maker-note ColorSpace 0x001E), #5987; Apply Layer Comp action mapping, #7101; G10: JPEG Options dialog landed, FILE-215-19 partial (no Matte, progressive Scans or size preview); G10: Cineon write; G10: DPX read and write, #1049, Cineon and Amiga IFF read; Classical Object Finder improvement; learned segmentation remains open; G4: named, stateful section locks and visible keyboard focus in Brush Settings; G4: independent Brush Pose tilt-axis overrides, #3825 partial; G4: Transfer Mixer-only controls, #3825 partial; G9: Preferences › File Handling › Save in Background takes effect, #6207, 24 of 169 preferences do nothing; G7: Content-Aware Move previews every selection the command takes, coarse then exact, TOOL-213-4 done, #6680; G4: Pattern Stamp and Background Eraser draw live, TOOL-213-7; G9: Animated Zoom pref read: holding the Zoom tool zooms continuously, #6210, prefs that do nothing 25 of 166; G2: a cancelled 24 MP Gaussian Blur job stops in at most 44 ms, P25 under its 200 ms budget in local runs, baseline not re-run, #6205; G1: generated large-file fixtures, a 4.38 GB PSB opens on Windows through io, FILE-216-2 done, #5107; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed; G10: Amiga IFF write; G10: PSD spot channels in Photoshop's polarity, earlier PhotoCraft PSDs keep their ink, #7415 split; G10: TIFF alpha and spot channels written and read as extra samples, FILE-215-7 done, FILE-215-17's TIFF part, #7415) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (G11: four Render ATN events mapped, 120 supported/97 unmapped; 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; G9: 12 of 181 preferences do nothing, as the scorecard prefs audit counts them; G6: Art History Brush landed as partial, TOOL-213-20, #1610, so no toolbar tool is missing; G4: five hard-round built-in presets use 5% spacing to remove stacked-tip outlines and reduce pressure-opacity bands, #8842; G9: Crop tool canvas menu, #6561; G2: Content-Aware Scale seam carving 1.65–3.6× faster with unchanged output, P28 row 3.3 → 1.6 s on a 4-core VM, #5236; G9: UI scale presets done, 105–120% steps, #3222; G14: lossy DNG, raw sidecars, Camera Raw Defaults live, Calibration and Auto, CR3 assessed as not clean-room feasible; G8: PSD type on a path opens on its path and saves back into the file's `Txt2`, #7962; G11: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; adjustment clipping stays visible in compact Properties, #5231; G9: Color panel modes, ramp and copy commands, the same in every theme, UI-217-17, #6000; G17: Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; G10: `.asl` style libraries landed, #1051; G8: area type (TYPE-218-10); G1: smart objects keep Photoshop's Cylinder warp, #4112; G8: paragraph direction buttons in the Paragraph panel and options bar, TYPE-218-2 partial; G1: 32-bit Hue/Saturation and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; G11: compound layer properties export as separate Photoshop Set events, rename last, #8304; G11: Batch and droplets take the result at the action's Save step, so actions ending in Save As and Close work; G11: `.atn` steps that switch to another open document import, play and save back, #7103; G14: the Nikon D4 white level follows the sample depth, so 12-bit D4 NEFs no longer open 2.6 stops dark, #5978; G14: a Nikon NEF's Adobe RGB preview is read as Adobe RGB (maker-note ColorSpace 0x001E), #5987; Apply Layer Comp action mapping, #7101; G10: JPEG Options dialog landed, FILE-215-19 partial (no Matte, progressive Scans or size preview); G10: Cineon write; G10: DPX read and write, #1049, Cineon and Amiga IFF read; Classical Object Finder improvement; learned segmentation remains open; G4: named, stateful section locks and visible keyboard focus in Brush Settings; G4: independent Brush Pose tilt-axis overrides, #3825 partial; G4: Transfer Mixer-only controls, #3825 partial; G9: Preferences › File Handling › Save in Background takes effect, #6207, 24 of 169 preferences do nothing; G7: Content-Aware Move previews every selection the command takes, coarse then exact, TOOL-213-4 done, #6680; G4: Pattern Stamp and Background Eraser draw live, TOOL-213-7; G9: Animated Zoom pref read: holding the Zoom tool zooms continuously, #6210, prefs that do nothing 25 of 166; G2: a cancelled 24 MP Gaussian Blur job stops in at most 44 ms, P25 under its 200 ms budget in local runs, baseline not re-run, #6205; G1: generated large-file fixtures, a 4.38 GB PSB opens on Windows through io, FILE-216-2 done, #5107; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed; G10: Amiga IFF write; G10: PSD spot channels in Photoshop's polarity, earlier PhotoCraft PSDs keep their ink, #7415 split; G10: TIFF alpha and spot channels written and read as extra samples, FILE-215-7 done, FILE-215-17's TIFF part, #7415) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -111,7 +111,7 @@ what an agent can't finish alone. - **Estimate:** 120–200 h. ### G11. Actions and scripting -- **Done 2026-10-31 (#10604):** all four Render ATN events (Group B) load, play and save with measured defaults, documented clamping and strict export checks. The [event reference](atn.md) now lists 121 supported and 97 unmapped events. Other Actions/scripting gaps remain; no overall readiness percentage or estimate changed. +- **Done 2026-10-31 (#10604):** all four Render ATN events (Group B) load, play and save with measured defaults, documented clamping and strict export checks. The [event reference](atn.md) now lists 120 supported and 97 unmapped events. Other Actions/scripting gaps remain; no overall readiness percentage or estimate changed. - **2026-10-21 evidence (#8304):** Save Actions expands compound layer-property edits into separate Photoshop Set events, with rename last so named layer references remain valid. Exported descriptors and native playback at RGB 8/16/32-bit have [regression coverage](../crates/engine/src/atn_cmds/layer_props_tests.rs); @@ -425,7 +425,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| -| 2026-10-31 | minor | G11: four Render ATN events mapped; current counts 121 supported/97 unmapped. Other Actions gaps, readiness percentages and estimates unchanged. | +| 2026-10-31 | minor | G11: four Render ATN events mapped; current counts 120 supported/97 unmapped. Other Actions gaps, readiness percentages and estimates unchanged. | | 2026-10-27 | minor | Smooth/Feather command reads and replacement use selection bounds plus blur reach; full-canvas reference and release comparisons extended; percentages and scorecard budgets unchanged | | 2026-10-27 | minor | G9: 12 of 181 preferences do nothing, the scorecard's count with integration batch 9 (#9775) on main; no estimate changed | | 2026-10-26 | minor | Expand/Contract/Border distance work, mask reads and replacement cropped to nonzero bounds; bounded distances for radii ≤5; 36 MP exact-output comparisons improve 1.54–37.82× across eight cases; percentages, estimates and scorecard budgets unchanged | From 9959f8d5c26c256a8db91b361c1ff8104014221d Mon Sep 17 00:00:00 2001 From: yoriswind Date: Sat, 31 Oct 2026 21:20:52 -0700 Subject: [PATCH 052/216] Keep noncanonical Group F descriptor encodings intact --- .../src/cmd/atn_cmds/selection_tests.rs | 107 ++++++++++++++---- .../src/cmd/atn_cmds/steps_selection.rs | 29 +++-- 2 files changed, 107 insertions(+), 29 deletions(-) diff --git a/crates/engine/src/cmd/atn_cmds/selection_tests.rs b/crates/engine/src/cmd/atn_cmds/selection_tests.rs index 4c17cb96a5..84fa68f4b8 100644 --- a/crates/engine/src/cmd/atn_cmds/selection_tests.rs +++ b/crates/engine/src/cmd/atn_cmds/selection_tests.rs @@ -55,7 +55,9 @@ fn fixtures() -> Vec<(&'static str, Option<&'static str>, Descriptor)> { let mut ellipse = rectangle([1.5, 2.5, 8.5, 10.5], true); ellipse.items.last_mut().unwrap().1 = D::Boolean(false); cases.push(("AddT", Some("addTo"), ellipse)); - cases.push(("Fade", Some("fade"), desc("null").with("Opct", pct(25.5)).with("Md ", enm("BlnM", "Scrn")))); + for mode in ["Scrn", "H", "linearDodge"] { + cases.push(("Fade", Some("fade"), desc("null").with("Opct", pct(25.5)).with("Md ", enm("BlnM", mode)))); + } for edge in ["AdCH", "AdRg", "AdTp", "AdCV", "AdBt"] { cases.push(("Dstr", Some("distort"), desc("null").with("null", target_ref("Lyr ")).with("Usng", enm("ADSt", edge)))); } @@ -86,6 +88,30 @@ fn group_f_load_save_preserves_recorded_descriptors() { } } +/// A kept step retains its descriptor bytes, event identity and playback flags. +fn assert_kept(id: &str, name: Option<&str>, descriptor: Descriptor) { + let event = if id.is_empty() { AtnEvent::name(name.unwrap()) } else { AtnEvent::Code(id.as_bytes().try_into().unwrap()) }; + let mut original = atn::parse(&file(event, descriptor.clone())).unwrap(); + original.actions[0].items[0].enabled = false; + original.actions[0].items[0].with_dialog = true; + original.actions[0].items[0].dialog_options = 2; + let mut s = Session::new(); + let loaded = s.execute("actions.load", json!({"data":photocraft_paint::tile::b64_encode(&atn::write(&original).unwrap())})).unwrap(); + assert_eq!(loaded["unsupported"].as_array().unwrap().len(), 1, "{id}/{name:?}/{descriptor:?}"); + assert_eq!(s.actions.list[0].steps[0].0, PHOTOSHOP_STEP); + assert!(s.actions.list[0].disabled.contains(&0)); + assert!(s.actions.list[0].dialogs.contains(&0)); + let saved = s.execute("actions.save", json!({"action":"Step"})).unwrap(); + assert_eq!(saved["skipped"], json!([])); + let bytes = photocraft_paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap(); + let back = atn::parse(&bytes).unwrap(); + let saved = &back.actions[0].items[0]; + let original = &original.actions[0].items[0]; + assert_eq!(saved.event, original.event); + assert_eq!((saved.enabled, saved.with_dialog, saved.dialog_options), (original.enabled, original.with_dialog, original.dialog_options)); + assert_eq!(saved.descriptor.as_ref().unwrap().to_bytes(), descriptor.to_bytes()); +} + /// Outer and nested shape metadata stay kept, including headers on empty Group F events. #[test] fn noncanonical_group_f_headers_keep_their_bytes_and_flags() { @@ -105,30 +131,69 @@ fn noncanonical_group_f_headers_keep_their_bytes_and_flags() { } } } - let event = if id.is_empty() { AtnEvent::name(name.unwrap()) } else { AtnEvent::Code(id.as_bytes().try_into().unwrap()) }; - let mut original = atn::parse(&file(event, descriptor.clone())).unwrap(); - original.actions[0].items[0].enabled = false; - original.actions[0].items[0].with_dialog = true; - original.actions[0].items[0].dialog_options = 2; - let mut s = Session::new(); - let loaded = s.execute("actions.load", json!({"data":photocraft_paint::tile::b64_encode(&atn::write(&original).unwrap())})).unwrap(); - assert_eq!(loaded["unsupported"].as_array().unwrap().len(), 1, "{id}/{name:?}/{variant}"); - assert_eq!(s.actions.list[0].steps[0].0, PHOTOSHOP_STEP); - assert!(s.actions.list[0].disabled.contains(&0)); - assert!(s.actions.list[0].dialogs.contains(&0)); - let saved = s.execute("actions.save", json!({"action":"Step"})).unwrap(); - assert_eq!(saved["skipped"], json!([])); - let bytes = photocraft_paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap(); - let back = atn::parse(&bytes).unwrap(); - let saved = &back.actions[0].items[0]; - let original = &original.actions[0].items[0]; - assert_eq!(saved.event, original.event); - assert_eq!((saved.enabled, saved.with_dialog, saved.dialog_options), (original.enabled, original.with_dialog, original.dialog_options)); - assert_eq!(back.actions[0].items[0].descriptor.as_ref().unwrap().to_bytes(), descriptor.to_bytes()); + assert_kept(id, name, descriptor); } } } +/// Every keyed converter rejects alternate key encodings and adjacent order changes. +#[test] +fn noncanonical_group_f_keys_keep_their_bytes_and_flags() { + let variants = |d: &Descriptor| { + let mut variants = Vec::new(); + for index in 0..d.items.len() { + let mut changed = d.clone(); + changed.items[index].0 = Id::Str(changed.items[index].0.as_bytes().to_vec()); + variants.push(changed); + if index > 0 { + let mut changed = d.clone(); + changed.items.swap(index - 1, index); + variants.push(changed); + } + } + variants + }; + for (id, name, d) in fixtures() { + for changed in variants(&d) { + assert_kept(id, name, changed); + } + for (index, (_, value)) in d.items.iter().enumerate() { + let D::Descriptor(shape) = value else { continue }; + for changed in variants(shape) { + let mut outer = d.clone(); + outer.items[index].1 = D::Descriptor(changed); + assert_kept(id, name, outer); + } + } + } +} + +/// Enum type/value encodings and Fade aliases must not be normalized on save. +#[test] +fn noncanonical_group_f_enums_keep_their_bytes_and_flags() { + for (id, name, d) in fixtures() { + for (index, (_, value)) in d.items.iter().enumerate() { + let D::Enumerated { type_id, value } = value else { continue }; + let mut padded = value.as_bytes().to_vec(); + padded.push(b' '); + for (type_id, value) in [ + (Id::Str(type_id.as_bytes().to_vec()), value.clone()), + (type_id.clone(), Id::Str(value.as_bytes().to_vec())), + (type_id.clone(), Id::Str(padded)), + ] { + let mut changed = d.clone(); + changed.items[index].1 = D::Enumerated { type_id, value }; + if changed != d { + assert_kept(id, name, changed); + } + } + } + } + for alias in ["normal", "multiply", "hue"] { + assert_kept("Fade", Some("fade"), desc("null").with("Opct", pct(50.0)).with("Md ", enm("BlnM", alias))); + } +} + /// Fade writes native defaults and clamps only its documented opacity range. #[test] fn fade_defaults_and_opacity_limits_are_explicit() { diff --git a/crates/engine/src/cmd/atn_cmds/steps_selection.rs b/crates/engine/src/cmd/atn_cmds/steps_selection.rs index 7936af2869..0a7a3efb9e 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_selection.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_selection.rs @@ -18,9 +18,17 @@ pub(super) fn from(d: &Descriptor, convert: fn(&Descriptor) -> Mapped) -> Mapped convert(d) } -/// Unknown or repeated descriptor keys must remain visible instead of disappearing on save. +/// Keys must retain the encoding and order used by export, with no repeats or unknowns. fn keys(d: &Descriptor, allowed: &[&str]) -> bool { - d.items.iter().all(|(k, _)| allowed.iter().any(|name| k.is(name))) && allowed.iter().all(|name| d.items.iter().filter(|(k, _)| k.is(name)).count() <= 1) + let mut previous = None; + d.items.iter().all(|(key, _)| { + let Some(index) = allowed.iter().position(|name| *key == code(name)) else { return false }; + if previous.is_some_and(|previous| index <= previous) { + return false; + } + previous = Some(index); + true + }) } /// Native settings without a recorded representation cannot be exported faithfully. @@ -36,12 +44,12 @@ fn unit(d: &Descriptor, key: &str, expected: &[u8; 4]) -> Option { } } -/// Validate an enum's type as well as its value. +/// Enum identifiers must retain their encoded form, including short-code padding. fn enumeration(d: &Descriptor, key: &str, expected: &str) -> Option { - match d.get(key)? { - D::Enumerated { type_id, value } if type_id.is(expected) => Some(trimmed(value)), - _ => None, - } + let raw = d.get(key)?; + let D::Enumerated { value, .. } = raw else { return None }; + let value = trimmed(value); + (raw == &enm(expected, &value)).then_some(value) } /// The recorded reference identifies either the selection property or the targeted layers. @@ -156,7 +164,12 @@ pub(super) fn fade_from(d: &Descriptor) -> Mapped { if !keys(d, &["Opct", "Md "]) { return None; } - Some(json!({"opacity":unit(d,"Opct",b"#Prc")?.clamp(0.0,100.0),"mode":blend_label(&enumeration(d,"Md ","BlnM")?)?})) + let mode = blend_label(&enumeration(d, "Md ", "BlnM")?)?; + // An accepted alias would be rewritten to the exporter's preferred blend identifier. + if d.get("Md ") != Some(&enm("BlnM", blend_ps(mode)?)) { + return None; + } + Some(json!({"opacity":unit(d,"Opct",b"#Prc")?.clamp(0.0,100.0),"mode":mode})) }; mapped().map_or(Mapped::Unsupported { harmless: false }, |p| step("edit.fade", p)) } From 94bce3b6d5ce415ba111882292834a7da8e4f6ef Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 31 Oct 2026 22:36:20 -0600 Subject: [PATCH 053/216] Actions: save Filter Gallery stroke direction with Photoshop's StrD type (CodeRabbit review on steps_gallery.rs:573) Export wrote each enumeration with its key as the type. Photoshop's terminology gives stroke direction (`SDir`, Graphic Pen and Sprayed Strokes) its own type, `StrD` (strokeDirectionType), so those steps saved differently from Photoshop. Texture keeps `TxtT` as its type: that is how Photoshop records Glass (`TxtT` / `TxtT`), and its terminology has no separate texture type. Co-Authored-By: Claude Opus 5.5 --- .../engine/src/cmd/atn_cmds/steps_gallery.rs | 12 ++++++++++- crates/engine/src/cmd/atn_cmds/tests.rs | 21 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/atn_cmds/steps_gallery.rs b/crates/engine/src/cmd/atn_cmds/steps_gallery.rs index befd72841c..f640eafa2b 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_gallery.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_gallery.rs @@ -35,6 +35,16 @@ impl G { } } +/// The type Photoshop records an enumeration key with. Its terminology gives stroke direction +/// its own type (`strokeDirectionType`); every other gallery enumeration's type is its key (Glass +/// records `TxtT` / `TxtT`). +fn enum_type(key: &'static str) -> &'static str { + match key { + "SDir" => "StrD", + k => k, + } +} + /// Photoshop's effect id (`GEfk`), the command, and the effect's keys in recorded order. /// Generated from the event reference's Group A tables; Glass maps only the textures whose /// codes the reference gives (Frosted, Canvas). @@ -561,7 +571,7 @@ pub(super) fn gallery_to(id: &str, p: &Value) -> Option { } D::Integer(n as i32) } - G::Enum(key, _, table, default) => enm(key, v.map_or(Some(*default), |v| v.as_str().and_then(|s| lookup_back(table, s)))?), + G::Enum(key, _, table, default) => enm(enum_type(key), v.map_or(Some(*default), |v| v.as_str().and_then(|s| lookup_back(table, s)))?), G::Bool(_, _, default) => D::Boolean(v.map_or(Some(*default), Value::as_bool)?), // The colour the effect replays with when the step names none is the session's, which // the event cannot record. diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index 0f91e15687..67b14eb06a 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -3316,3 +3316,24 @@ fn gallery_effect_steps_that_cannot_be_written_are_refused() { } assert!(export_item("filter.gallery.glass", &json!({"texture": "frosted"}), "S").is_some()); } + +/// Each enumeration saves with the type Photoshop's terminology gives it: stroke direction's is +/// `StrD`, not its key, and texture's is `TxtT`, as Photoshop records Glass (#10592). +#[test] +fn gallery_effect_enumerations_save_with_photoshops_type_ids() { + let enum_type = |id: &str, params: Value, key: &str| match export_item(id, ¶ms, "S").unwrap().descriptor.unwrap().get(key) { + Some(D::Enumerated { type_id, .. }) => trimmed(type_id), + other => panic!("{id} {key}: {other:?}"), + }; + assert_eq!(enum_type("filter.gallery.graphicPen", json!({}), "SDir"), "StrD"); + assert_eq!(enum_type("filter.gallery.sprayedStrokes", json!({}), "SDir"), "StrD"); + for id in ["filter.gallery.conteCrayon", "filter.gallery.glass", "filter.gallery.roughPastels", "filter.gallery.texturizer", "filter.gallery.underpainting"] + { + assert_eq!(enum_type(id, json!({}), "TxtT"), "TxtT", "{id}"); + } + assert_eq!(enum_type("filter.gallery.basRelief", json!({}), "LghD"), "LghD"); + assert_eq!(enum_type("filter.gallery.plaster", json!({}), "LghP"), "LghP"); + assert_eq!(enum_type("filter.gallery.halftonePattern", json!({}), "ScrT"), "ScrT"); + assert_eq!(enum_type("filter.gallery.paintDaubs", json!({}), "BrsT"), "BrsT"); + assert_eq!(enum_type("filter.gallery.grain", json!({}), "Grnt"), "Grnt"); +} From 926008b90b4b26e7487c9cf3a6cfc1d48a31ef80 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 31 Oct 2026 22:38:29 -0600 Subject: [PATCH 054/216] Actions: keep a Trap step whose width isn't in pixels (CodeRabbit review on steps_channels.rs:61) Import read `Wdth` with `num`, which drops the unit, so a width in points or millimetres (or a bare number) loaded as that many pixels and saved back as `#Pxl`. Import now takes only a `#Pxl` unit double, the one Trap saves, and keeps any other width as a Photoshop step. Co-Authored-By: Claude Opus 5.5 --- .../engine/src/cmd/atn_cmds/steps_channels.rs | 16 +++++++++++++--- crates/engine/src/cmd/atn_cmds/tests.rs | 17 +++++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/atn_cmds/steps_channels.rs b/crates/engine/src/cmd/atn_cmds/steps_channels.rs index 54db66af1b..77b247d6ab 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_channels.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_channels.rs @@ -1,7 +1,7 @@ //! Photoshop's channel and colour steps (#10609): Split Channels, Merge Spot Channel, Trap and //! Match Color, with the keys Photoshop records. -use super::descriptors::{boolean, desc, num, px, trimmed}; +use super::descriptors::{PX, boolean, desc, num, px, trimmed}; use super::events::pixel_target; use super::fields::{Mapped, step}; use super::{D, Descriptor, Value, json}; @@ -53,12 +53,22 @@ pub(super) fn merge_spot_to(_: &str, p: &Value) -> Option { /// Trap's widest width: Photoshop clamps larger ones to it. const MAX_TRAP: f64 = 32767.0; -/// Trap (`Trap`): `Wdth` in pixels. A width that isn't a number keeps the Photoshop step. +/// A width in `#Pxl`, the unit Trap saves: a width in another unit (points, millimetres) or +/// without one would load as that many pixels and save back as pixels. +fn pixels(v: &D) -> Option { + match v { + D::UnitFloat { unit, value } if *unit == PX && value.is_finite() => Some(*value), + _ => None, + } +} + +/// Trap (`Trap`): `Wdth` in pixels. A width that isn't a number of pixels keeps the Photoshop +/// step. pub(super) fn trap_from(d: &Descriptor) -> Mapped { if !only_keys(d, &["Wdth"]) { return Mapped::Unsupported { harmless: false }; } - match setting(d, "Wdth", |v| num(v).map(Some), None) { + match setting(d, "Wdth", |v| pixels(v).map(Some), None) { Some(Some(w)) => step("image.trap", json!({"width": w.min(MAX_TRAP)})), Some(None) => step("image.trap", json!({})), None => Mapped::Unsupported { harmless: false }, diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index 9bd0726f4a..823913d219 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -3308,3 +3308,20 @@ fn channel_and_color_steps_with_a_non_numeric_setting_are_kept() { assert!(matches!(m, Mapped::Unsupported { harmless: false }), "{name} with {key} = {v:?} was mapped"); } } + +/// Trap saves its width as `#Pxl`, so a width in another unit, or without one, stays a +/// Photoshop step instead of loading as that many pixels and saving back as pixels (#10609). +#[test] +fn trap_width_not_in_pixels_is_kept() { + for v in [ + D::UnitFloat { unit: *b"#Pnt", value: 2.0 }, + D::UnitFloat { unit: *b"#Mlm", value: 2.0 }, + D::UnitFloat { unit: *b"#Prc", value: 2.0 }, + D::Double(2.0), + D::Integer(2), + ] { + let m = channel_step("Trap", desc("null").with("Wdth", v.clone())); + assert!(matches!(m, Mapped::Unsupported { harmless: false }), "Wdth {v:?} was mapped"); + } + assert!(matches!(channel_step("Trap", desc("null").with("Wdth", px(2.5))), Mapped::Steps(_))); +} From c5fc78bee1e054e80d58e80fdfdb7235320dd88d Mon Sep 17 00:00:00 2001 From: yoriswind Date: Sat, 31 Oct 2026 21:41:03 -0700 Subject: [PATCH 055/216] Keep ATN event codes distinct from TEXT names --- crates/engine/src/cmd/atn_cmds/events.rs | 13 +++-- .../engine/src/cmd/atn_cmds/render_tests.rs | 51 +++++++++++++++++++ crates/engine/src/cmd/atn_cmds/tests.rs | 12 ++--- 3 files changed, 65 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/cmd/atn_cmds/events.rs b/crates/engine/src/cmd/atn_cmds/events.rs index d3f62b3a2d..547b130116 100644 --- a/crates/engine/src/cmd/atn_cmds/events.rs +++ b/crates/engine/src/cmd/atn_cmds/events.rs @@ -649,11 +649,14 @@ const LEGACY_CODES: &[(&str, &str)] = pub(super) fn event_for(item: &AtnEvent) -> Option<&'static Event> { let id = item.id(); - let find = |id: &str| EVENTS.iter().find(|e| (!e.code.is_empty() && e.code.trim_end() == id.trim_end()) || (!e.name.is_empty() && e.name == id)); - find(&id).or_else(|| match item { - AtnEvent::Code(_) => LEGACY_CODES.iter().find(|(code, _)| *code == id).and_then(|(_, name)| find(name)), - AtnEvent::Name(_) => None, - }) + let by_name = |name: &str| EVENTS.iter().find(|e| !e.name.is_empty() && e.name == name); + match item { + AtnEvent::Code(_) => EVENTS + .iter() + .find(|e| !e.code.is_empty() && e.code.trim_end() == id.trim_end()) + .or_else(|| LEGACY_CODES.iter().find(|(code, _)| *code == id).and_then(|(_, name)| by_name(name))), + AtnEvent::Name(_) => by_name(&id), + } } /// The event `id` exports as: the first event listing it whose converter accepts the params diff --git a/crates/engine/src/cmd/atn_cmds/render_tests.rs b/crates/engine/src/cmd/atn_cmds/render_tests.rs index e040245c4e..06b3cf88d9 100644 --- a/crates/engine/src/cmd/atn_cmds/render_tests.rs +++ b/crates/engine/src/cmd/atn_cmds/render_tests.rs @@ -27,6 +27,57 @@ fn flare(brightness: i32, center: [f64; 2], lens: &str) -> Descriptor { .with("Lns ", enm("Lns ", lens)) } +/// Codes and TEXT names have separate identities, including the old Code-only aliases. +#[test] +fn event_lookup_keeps_codes_separate_from_names() { + for (code, id) in [ + (*b"Clds", "filter.render.clouds"), + (*b"DfrC", "filter.render.differenceClouds"), + (*b"Fbrs", "filter.render.fibers"), + (*b"LnsF", "filter.render.lensFlare"), + (*b"BoxB", "filter.blur.boxBlur"), + (*b"SrfB", "filter.blur.surfaceBlur"), + (*b"Fltn", "layer.flattenImage"), + (*b"BlnW", "image.adjustments.blackWhite"), + (*b"PhtF", "image.adjustments.photoFilter"), + ] { + assert!(event_for(&AtnEvent::Code(code)).unwrap().ids.contains(&id)); + assert!(event_for(&AtnEvent::Name(code.to_vec())).is_none(), "{code:?} is not a TEXT name"); + } + assert!(event_for(&AtnEvent::name("play")).unwrap().ids.contains(&"actions.play")); + assert!(event_for(&AtnEvent::Code(*b"Ply ")).unwrap().ids.contains(&"actions.play")); + assert!(event_for(&AtnEvent::Code(*b"play")).is_none(), "a TEXT name is not a code"); + for event in [AtnEvent::Code(*b"save"), AtnEvent::name("save")] { + assert!(event_for(&event).unwrap().ids.contains(&"file.save"), "save is registered in both forms"); + } + assert!(event_for(&AtnEvent::name("")).is_none(), "empty registry names are not event identifiers"); +} + +/// Unknown event forms stay kept rather than becoming commands, and save verbatim. +#[test] +fn code_spellings_in_text_steps_keep_their_bytes_and_flags() { + let mut events: Vec<_> = ["Clds", "DfrC", "Fbrs", "LnsF", "BoxB", "SrfB", "Fltn", "BlnW", "PhtF"].into_iter().map(AtnEvent::name).collect(); + events.push(AtnEvent::Code(*b"play")); + for event in events { + let mut original = atn::parse(&file(event, desc("null"))).unwrap(); + original.actions[0].items[0].enabled = false; + original.actions[0].items[0].with_dialog = true; + original.actions[0].items[0].dialog_options = 2; + let bytes = atn::write(&original).unwrap(); + let mut s = Session::new(); + let loaded = s.execute("actions.load", json!({"data": photocraft_paint::tile::b64_encode(&bytes)})).unwrap(); + assert_eq!(loaded["unsupported"].as_array().unwrap().len(), 1, "{:?}", original.actions[0].items[0].event); + assert_eq!(s.actions.list[0].steps[0].0, PHOTOSHOP_STEP); + let saved = s.execute("actions.save", json!({"action": "Render"})).unwrap(); + assert_eq!(saved["skipped"], json!([])); + let back = atn::parse(&photocraft_paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap()).unwrap(); + assert_eq!(back.actions[0].items, original.actions[0].items); + let mut compared = original; + compared.actions[0].items = back.actions[0].items.clone(); + assert_eq!(atn::write(&compared).unwrap(), bytes); + } +} + /// Both recorded event IDs map empty Clouds descriptors without inventing frozen colors. #[test] fn clouds_events_map_and_refuse_unrepresentable_native_settings() { diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index 2c6740dc92..f7187decaa 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -102,13 +102,13 @@ fn sample() -> AtnFile { function_key: 3, items: vec![ blur, - item("make", "Make", Some(make_levels)), + item("Mk ", "Make", Some(make_levels)), item("setd", "Set", Some(select_all)), item("select", "Select", Some(select_layer)), item("slct", "Select", Some(tool)), - item("stop", "Stop", Some(stop)), + item("Stop", "Stop", Some(stop)), item("syntheticFill", "Generative Fill", Some(fancy)), - item("play", "Play", Some(play)), + item("Ply ", "Play", Some(play)), off, ], ..Default::default() @@ -1051,7 +1051,7 @@ fn retouching_set_events_map_to_their_commands() { item("colorRange", "Color Range", Some(desc("null").with("Clrs", enm("Clrs", "skinTone")).with("Fzns", long(40.0)))), item("colorRange", "Color Range", Some(sampled)), item("colorRange", "Color Range", Some(desc("null").with("Clrs", enm("Clrs", "Rds ")).with("UsFc", D::Boolean(true)))), - item("make", "Make", Some(mask)), + item("Mk ", "Make", Some(mask)), item("set", "Set", Some(fg)), item("set", "Set", Some(lab_fg)), item("groupEvent", "Create Clipping Mask", Some(desc("null").with("null", target_ref("Lyr ")))), @@ -1434,8 +1434,8 @@ fn remaining_events_import_in_their_photoshop_shapes() { let items = vec![ item("conditional", "If", Some(cond)), item("imageSize", "Image Size", Some(size)), - item("fill", "Fill", Some(bucket_px)), - item("fill", "Fill", Some(bucket_pct)), + item("Fl ", "Fill", Some(bucket_px)), + item("Fl ", "Fill", Some(bucket_pct)), item("placeEvent", "Place", Some(place)), item("Opn ", "Open", Some(desc("null").with("null", path_value("/Users/me/in.psd")))), ]; From 17c5d4173436880578ae2d80b65419d60dafeadf Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 31 Oct 2026 22:41:11 -0600 Subject: [PATCH 056/216] Actions: keep a Match Color step whose setting isn't an integer (CodeRabbit review on steps_channels.rs:100) Import took any number for `Lght`, `ClrR` and `Fade`, but export writes them as integers and refuses a fraction, so a recorded 99.5 loaded and then could not be saved; a whole double, a large integer or a unit double would have saved back as a different type. Import now takes only an integer, as export writes it, and keeps anything else as a Photoshop step. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/atn_cmds/steps_channels.rs | 12 +++++++++--- crates/engine/src/cmd/atn_cmds/tests.rs | 16 ++++++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/atn_cmds/steps_channels.rs b/crates/engine/src/cmd/atn_cmds/steps_channels.rs index 77b247d6ab..b6a81646f4 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_channels.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_channels.rs @@ -1,7 +1,7 @@ //! Photoshop's channel and colour steps (#10609): Split Channels, Merge Spot Channel, Trap and //! Match Color, with the keys Photoshop records. -use super::descriptors::{PX, boolean, desc, num, px, trimmed}; +use super::descriptors::{PX, boolean, desc, px, trimmed}; use super::events::pixel_target; use super::fields::{Mapped, step}; use super::{D, Descriptor, Value, json}; @@ -93,7 +93,9 @@ const MATCH_COLOR: &[(&str, &str, i32)] = &[("Lght", "luminance", 100), ("ClrR", /// Match Color (`matchColor`) without a source, as Photoshop records it (`noReference`). A /// step that matches another image (`noReference` false, or not a Boolean) names no source -/// here, so it keeps the Photoshop step, as does a setting that isn't a number. +/// here, so it keeps the Photoshop step, as does a setting that isn't an integer: the step +/// saves its settings as integers, so it couldn't write a fraction, a large integer or a unit +/// back as recorded. pub(super) fn match_color_from(d: &Descriptor) -> Mapped { match match_color_params(d) { Some(p) => step("image.adjustments.matchColor", Value::Object(p)), @@ -107,7 +109,11 @@ fn match_color_params(d: &Descriptor) -> Option> } let mut p = serde_json::Map::new(); for (key, param, default) in MATCH_COLOR { - p.insert((*param).into(), setting(d, key, |v| num(v).map(Value::from), Value::from(*default))?); + let integer = |v: &D| match v { + D::Integer(n) => Some(Value::from(*n)), + _ => None, + }; + p.insert((*param).into(), setting(d, key, integer, Value::from(*default))?); } Some(p) } diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index 823913d219..c35caf26ed 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -3325,3 +3325,19 @@ fn trap_width_not_in_pixels_is_kept() { } assert!(matches!(channel_step("Trap", desc("null").with("Wdth", px(2.5))), Mapped::Steps(_))); } + +/// Match Color saves its settings as integers, so a recorded value it couldn't save back as +/// the same integer stays a Photoshop step instead of loading and then failing to save (#10609). +#[test] +fn match_color_setting_that_cannot_be_saved_back_is_kept() { + for (key, v) in [ + ("Lght", D::Double(99.5)), + ("Lght", D::Double(100.0)), + ("ClrR", D::LargeInteger(1 << 40)), + ("ClrR", D::LargeInteger(80)), + ("Fade", D::UnitFloat { unit: *b"#Prc", value: 25.0 }), + ] { + let m = channel_step("matchColor", desc("null").with(key, v.clone())); + assert!(matches!(m, Mapped::Unsupported { harmless: false }), "{key} = {v:?} was mapped"); + } +} From 13f6e076788f7a65a7c2c961ccf24f039318683f Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 31 Oct 2026 22:43:12 -0600 Subject: [PATCH 057/216] Actions: keep a Distort step that records a key twice (CodeRabbit review on steps_distort.rs:19) A descriptor may hold the same key more than once. `setting` read the first and `only_keys` admitted both, so a step with two `Amnt` played the first and saved back one. `setting` now treats a key recorded more than once as a value it can't take, so the step stays a Photoshop step. Co-Authored-By: Claude Opus 5.5 --- .../engine/src/cmd/atn_cmds/steps_distort.rs | 11 +++++++---- crates/engine/src/cmd/atn_cmds/tests.rs | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/atn_cmds/steps_distort.rs b/crates/engine/src/cmd/atn_cmds/steps_distort.rs index 99e80a30e6..a6b249ddd8 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_distort.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_distort.rs @@ -14,11 +14,14 @@ fn only_keys(d: &Descriptor, keys: &[&str]) -> bool { /// The step's `key` read by `read` (its space padding ignored, as `only_keys` admits it), /// `default` when the step leaves it out, `None` when it holds a value `read` can't take: that -/// is still the step's setting, so the step is kept rather than played with the default. +/// is still the step's setting, so the step is kept rather than played with the default. A key +/// recorded more than once is `None` too: the step saves one value, so it would drop the other. fn setting(d: &Descriptor, key: &str, read: impl Fn(&D) -> Option, default: T) -> Option { - match d.items.iter().find(|(k, _)| trimmed(k) == key.trim_end()) { - Some((_, v)) => read(v), - None => Some(default), + let mut found = d.items.iter().filter(|(k, _)| trimmed(k) == key.trim_end()); + match (found.next(), found.next()) { + (Some((_, v)), None) => read(v), + (None, _) => Some(default), + (Some(_), Some(_)) => None, } } diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index b2dcc96eb2..b898b18255 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -3355,3 +3355,22 @@ fn wave_seed_loads_its_exact_32_bits_or_keeps_the_step() { assert_eq!(seed(v.clone()), None, "RndS {v:?} was mapped"); } } + +/// A step recording one of its keys twice can't be saved back with both values, so it stays a +/// Photoshop step instead of playing the first and dropping the second (#10605). +#[test] +fn distort_filter_steps_with_a_repeated_key_are_kept() { + let kept = |m: Mapped| matches!(m, Mapped::Unsupported { harmless: false }); + for (code, key, a, b) in [ + ("Pnch", "Amnt", D::Integer(10), D::Integer(20)), + ("Sphr", "SphM", enm("SphM", "Nrml"), enm("SphM", "HrzO")), + ("Plr ", "Cnvr", enm("Cnvr", "RctP"), enm("Cnvr", "RctP")), + ("Wave", "NmbG", D::Integer(5), D::Integer(7)), + ("Wave", "SclH", D::Integer(100), D::Integer(50)), + ("Wave", "UndA", enm("UndA", "WrpA"), enm("UndA", "RptE")), + ("Wave", "RndS", D::Integer(1), D::Integer(2)), + ("ZgZg", "NmbR", D::Integer(5), D::Integer(6)), + ] { + assert!(kept(distort(code, desc("null").with(key, a.clone()).with(key, b.clone()))), "{code} with {key} twice was mapped"); + } +} From 427c59d32adcb21906ce6d95aa94de0255ba329f Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 31 Oct 2026 22:43:52 -0600 Subject: [PATCH 058/216] Actions: keep a Distort step whose enumeration has another type (CodeRabbit review on steps_distort.rs:71) Import read an enumeration's value and ignored its type, so `Cnvr` recorded as `Ordn`/`RctP` loaded as Polar Coordinates and saved back as `Cnvr`/`RctP`. Each enumeration (and Wave's `UndA`) now loads only with the type Photoshop records and the step saves back; another type keeps the Photoshop step. Co-Authored-By: Claude Opus 5.5 --- .../engine/src/cmd/atn_cmds/steps_distort.rs | 17 +++++++++++++---- crates/engine/src/cmd/atn_cmds/tests.rs | 18 ++++++++++++++++++ 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/atn_cmds/steps_distort.rs b/crates/engine/src/cmd/atn_cmds/steps_distort.rs index a6b249ddd8..f67a3dc811 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_distort.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_distort.rs @@ -1,7 +1,7 @@ //! Photoshop's Distort filter steps (#10605): Pinch, Polar Coordinates, Spherize, Twirl, Wave and //! ZigZag, each key in recorded order with the limits and defaults Photoshop measures. -use super::descriptors::{desc, enm, enumerated, num, trimmed}; +use super::descriptors::{desc, enm, num, trimmed}; use super::events::pixel_target; use super::fields::{Mapped, step}; use super::tables::{lookup, lookup_back}; @@ -30,13 +30,22 @@ fn sets_only(p: &Value, allowed: &[&str]) -> bool { p.as_object().is_none_or(|m| m.iter().all(|(k, v)| v.is_null() || allowed.contains(&k.as_str()))) } +/// An enumeration's value when its type is `ty`, the type Photoshop records it with and the +/// step saves back: one of another type isn't that setting. +fn enum_of(ty: &str) -> impl Fn(&D) -> Option + '_ { + move |v| match v { + D::Enumerated { type_id, value } if trimmed(type_id) == ty.trim_end() => Some(trimmed(value)), + _ => None, + } +} + /// One key of a Distort filter's step, in recorded order. pub(super) enum Key { /// An integer clamped to `min..=max` on import, as Photoshop clamps it; `default` when the /// step leaves it out. Int { key: &'static str, param: &'static str, min: i32, max: i32, default: i32 }, /// An enumeration (`ty`, Photoshop ↔ PhotoCraft values); `default` is Photoshop's value when - /// the step leaves it out. An unknown value keeps the step. + /// the step leaves it out. An unknown value, or one of another type, keeps the step. Enum { key: &'static str, param: &'static str, ty: &'static str, table: &'static [(&'static str, &'static str)], default: &'static str }, } @@ -71,7 +80,7 @@ fn keys_map(d: &Descriptor, keys: &[Key], extra: &[&'static str]) -> Option { Value::from(setting(d, key, |v| num(v).map(|n| n.round().clamp(f64::from(*min), f64::from(*max)) as i32), *default)?) } - Key::Enum { key, table, default, .. } => Value::from(lookup(table, &setting(d, key, enumerated, (*default).to_string())?)?), + Key::Enum { key, ty, table, default, .. } => Value::from(lookup(table, &setting(d, key, enum_of(ty), (*default).to_string())?)?), }; m.insert(k.param().into(), v); } @@ -152,7 +161,7 @@ pub(super) fn wave_from(d: &Descriptor) -> Mapped { if ["SclH", "SclV"].iter().any(|k| setting(d, k, num, 100.0).is_none_or(|n| n.round().clamp(1.0, 100.0) != 100.0)) { return unsupported; } - let Some(undefined) = setting(d, "UndA", enumerated, "RptE".into()).and_then(|e| lookup(WAVE_UNDEFINED, &e)) else { return unsupported }; + let Some(undefined) = setting(d, "UndA", enum_of("UndA"), "RptE".into()).and_then(|e| lookup(WAVE_UNDEFINED, &e)) else { return unsupported }; m.insert("undefinedAreas".into(), undefined.into()); match setting(d, "RndS", |v| seed_bits(v).map(Some), None) { Some(Some(seed)) => { diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index b898b18255..22957cfce2 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -3374,3 +3374,21 @@ fn distort_filter_steps_with_a_repeated_key_are_kept() { assert!(kept(distort(code, desc("null").with(key, a.clone()).with(key, b.clone()))), "{code} with {key} twice was mapped"); } } + +/// An enumeration of another type isn't the setting Photoshop records, so the step stays a +/// Photoshop step instead of loading its value and saving back the expected type (#10605). +#[test] +fn distort_filter_steps_with_an_enumeration_of_another_type_are_kept() { + let kept = |m: Mapped| matches!(m, Mapped::Unsupported { harmless: false }); + for (code, key, v) in [ + ("Plr ", "Cnvr", enm("Ordn", "RctP")), + ("Sphr", "SphM", enm("Cnvr", "Nrml")), + ("ZgZg", "ZZTy", enm("Ordn", "PndR")), + ("Wave", "Wvtp", enm("WvSn", "WvSn")), + ("Wave", "UndA", enm("Ordn", "WrpA")), + ] { + assert!(kept(distort(code, desc("null").with(key, v.clone()))), "{code} with {key} = {v:?} was mapped"); + } + assert!(!kept(distort("Plr ", desc("null").with("Cnvr", enm("Cnvr", "PlrR"))))); + assert!(!kept(distort("Wave", desc("null").with("UndA", enm("UndA", "WrpA"))))); +} From 97354e6269abf4ff4019f0824d9d9e74dc60f293 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 31 Oct 2026 22:45:16 -0600 Subject: [PATCH 059/216] Actions: keep a Wave step with a fractional scale (CodeRabbit review on steps_distort.rs:151) Import rounded `SclH`/`SclV` before comparing with 100, so a recorded 99.6 loaded as an unscaled Wave and saved back as 100. A fractional scale now keeps the Photoshop step: the step can only write the integer 100. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/atn_cmds/steps_distort.rs | 4 ++-- crates/engine/src/cmd/atn_cmds/tests.rs | 11 +++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/atn_cmds/steps_distort.rs b/crates/engine/src/cmd/atn_cmds/steps_distort.rs index f67a3dc811..bd3b4f2275 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_distort.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_distort.rs @@ -153,12 +153,12 @@ const WAVE: &[Key] = &[ const WAVE_UNDEFINED: &[(&str, &str)] = &[("RptE", "repeat"), ("WrpA", "wrap")]; /// Wave (`Wave`). The command has no horizontal or vertical scale, so a step that scales the -/// waves below 100 % keeps the Photoshop step. Missing undefined areas are Photoshop's Repeat +/// waves below 100 %, or by a fraction (saved back as 100), keeps the Photoshop step. Missing undefined areas are Photoshop's Repeat /// Edge Pixels (the command's own default is wrap); `RndS` keeps its 32 bits as the seed. pub(super) fn wave_from(d: &Descriptor) -> Mapped { let unsupported = Mapped::Unsupported { harmless: false }; let Some(mut m) = keys_map(d, WAVE, &["SclH", "SclV", "UndA", "RndS"]) else { return unsupported }; - if ["SclH", "SclV"].iter().any(|k| setting(d, k, num, 100.0).is_none_or(|n| n.round().clamp(1.0, 100.0) != 100.0)) { + if ["SclH", "SclV"].iter().any(|k| setting(d, k, num, 100.0).is_none_or(|n| n.fract() != 0.0 || n.clamp(1.0, 100.0) != 100.0)) { return unsupported; } let Some(undefined) = setting(d, "UndA", enum_of("UndA"), "RptE".into()).and_then(|e| lookup(WAVE_UNDEFINED, &e)) else { return unsupported }; diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index 22957cfce2..d83fadd2a3 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -3392,3 +3392,14 @@ fn distort_filter_steps_with_an_enumeration_of_another_type_are_kept() { assert!(!kept(distort("Plr ", desc("null").with("Cnvr", enm("Cnvr", "PlrR"))))); assert!(!kept(distort("Wave", desc("null").with("UndA", enm("UndA", "WrpA"))))); } + +/// Wave saves its scales as the integer 100, so a fractional scale, even one that rounds or +/// clamps to 100, stays a Photoshop step instead of saving back as 100 (#10605). +#[test] +fn wave_with_a_fractional_scale_is_kept() { + let kept = |m: Mapped| matches!(m, Mapped::Unsupported { harmless: false }); + for (key, n) in [("SclH", 99.6), ("SclV", 99.5), ("SclH", 100.4)] { + assert!(kept(distort("Wave", desc("null").with(key, D::Double(n)))), "{key} {n} was mapped"); + } + assert!(!kept(distort("Wave", desc("null").with("SclH", D::Integer(100)).with("SclV", D::Integer(100))))); +} From 527dfaa591e836ae27570afae1f81d9c73674591 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 31 Oct 2026 22:47:59 -0600 Subject: [PATCH 060/216] Actions: keep an Oil Paint step whose setting has a unit or another number type (CodeRabbit review on steps_image.rs:548) Import read Oil Paint's settings with `num`, which takes any number and drops a unit, so a recorded `#Prc` stylization or an integer bristle detail loaded and saved back as a plain double, and a fractional light direction loaded and then couldn't be saved. Import now takes the types Photoshop records and the step writes back (doubles, and an integer `LghD`), and keeps anything else as a Photoshop step. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/atn_cmds/steps_image.rs | 26 ++++++++++++++++--- crates/engine/src/cmd/atn_cmds/tests.rs | 19 ++++++++++++++ 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/atn_cmds/steps_image.rs b/crates/engine/src/cmd/atn_cmds/steps_image.rs index 34b152b73f..67330fd1f8 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_image.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_image.rs @@ -513,6 +513,23 @@ pub(super) fn select_sky_to(_: &str, p: &Value) -> Option { Some(desc("null").with("sampleAllLayers", D::Boolean(all))) } +/// A plain double, as Photoshop records Oil Paint's settings and the step saves them back: a +/// value with a unit, or an integer, would save back as another value. +fn double(v: &D) -> Option { + match v { + D::Double(n) if n.is_finite() => Some(*n), + _ => None, + } +} + +/// An integer, as Photoshop records Oil Paint's light direction and the step saves it back. +fn integer(v: &D) -> Option { + match v { + D::Integer(n) => Some(*n), + _ => None, + } +} + /// Oil Paint's keys in recorded order: (key, param, Photoshop's default, the command's default). const OIL_PAINT: &[(&str, &str, f64, f64)] = &[ ("stylization", "stylization", 4.0, 4.0), @@ -522,7 +539,8 @@ const OIL_PAINT: &[(&str, &str, f64, f64)] = &[ ]; /// Oil Paint (`oilPaint`). Missing keys take Photoshop's defaults, which differ from the -/// command's for cleanliness, scale, bristle detail and shine, so they are written out. +/// command's for cleanliness, scale, bristle detail and shine, so they are written out. A +/// setting of another number type, or with a unit, keeps the Photoshop step. pub(super) fn oil_paint_from(d: &Descriptor) -> Mapped { match oil_paint_params(d) { Some(p) => step("filter.stylize.oilPaint", Value::Object(p)), @@ -538,10 +556,10 @@ fn oil_paint_params(d: &Descriptor) -> Option> { let mut p = Map::new(); p.insert("lighting".into(), setting(d, "lightingOn", boolean, true)?.into()); for (key, param, ps, _) in OIL_PAINT { - p.insert((*param).into(), setting(d, key, num, *ps)?.into()); + p.insert((*param).into(), setting(d, key, double, *ps)?.into()); } - p.insert("angle".into(), setting(d, "LghD", num, -60.0)?.into()); - p.insert("shine".into(), setting(d, "specularity", num, 1.299_999_952_316_28)?.into()); + p.insert("angle".into(), setting(d, "LghD", |v| integer(v).map(f64::from), -60.0)?.into()); + p.insert("shine".into(), setting(d, "specularity", double, 1.299_999_952_316_28)?.into()); Some(p) } diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index c8ba917306..6ba72b03c5 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -3332,3 +3332,22 @@ fn smart_sharpen_reads_an_unpadded_radius() { let d = desc("null").with("Amnt", pct(150.0)).with("Rds", px(2.5)); assert_eq!(mapped(newer("smartSharpen", d)), ("filter.sharpen.smartSharpen".into(), json!({"amount": 150.0, "radius": 2.5, "reduceNoise": 0}))); } + +/// Oil Paint records its settings as plain doubles and the light direction as an integer, and +/// saves them back so: a value with a unit, or of the other number type, would load as a bare +/// number and save back changed, so the step stays a Photoshop step (#10610). +#[test] +fn oil_paint_setting_with_a_unit_or_another_number_type_is_kept() { + kept_when_wrong_typed( + "oilPaint", + &[ + ("stylization", pct(4.0)), + ("cleanliness", px(2.0)), + ("brushScale", D::UnitFloat { unit: *b"#Rlt", value: 0.8 }), + ("microBrush", D::Integer(10)), + ("LghD", ang(-60.0)), + ("LghD", D::Double(-60.5)), + ("specularity", D::UnitFloat { unit: *b"#Prc", value: 1.3 }), + ], + ); +} From 77229089f2fdd5c58d4cc913c8eb13fc27b1b4d6 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 31 Oct 2026 22:50:44 -0600 Subject: [PATCH 061/216] Actions: keep a Smart Sharpen step whose amount or radius is in another unit (CodeRabbit review on steps_image.rs:602) Import read `Amnt` and the radius with `num`, which drops the unit, but the step saves `Amnt` in `#Prc` and the radius in `#Pxl`, so a value in another unit (or a bare number) loaded and saved back in the step's unit. Import now takes only those units, for the padded `Rds ` and the unpadded `Rds` alike, and keeps anything else as a Photoshop step. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/atn_cmds/steps_image.rs | 17 +++++++++--- crates/engine/src/cmd/atn_cmds/tests.rs | 27 +++++++++++++++++++ 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/atn_cmds/steps_image.rs b/crates/engine/src/cmd/atn_cmds/steps_image.rs index 67330fd1f8..9b4629e33c 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_image.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_image.rs @@ -1,6 +1,6 @@ use super::descriptors::{ PX, Target, ang, boolean, class, code, desc, enm, enumerated, get_bool, get_enum, get_num, get_obj, get_ref, jbool, jnum, jstr, layer_ref, layer_target, - long, num, pct, px, ref_class, target, trimmed, + long, pct, px, ref_class, target, trimmed, }; use super::events::pixel_target; use super::fields::{F, Kind, Mapped, fields_from, fields_to, step}; @@ -588,10 +588,19 @@ pub(super) fn oil_paint_to(_: &str, p: &Value) -> Option { Some(d.with("LghD", D::Integer(angle as i32)).with("specularity", D::Double(num("shine", 1.0)?))) } +/// A unit double in `unit`: the step saves `Amnt` in `#Prc` and the radius in `#Pxl`, so a +/// value in another unit, or without one, would save back as another value. +fn in_unit(unit: [u8; 4]) -> impl Fn(&D) -> Option { + move |v| match v { + D::UnitFloat { unit: u, value } if *u == unit && value.is_finite() => Some(*value), + _ => None, + } +} + /// Smart Sharpen (`smartSharpen`), as Photoshop records it without its dialog: the legacy /// filter, removing Lens Blur, with no noise reduction (so `reduceNoise` is 0). A step that /// asks for what the command does not do (another blur, More Accurate, the newer filter, a -/// preset) keeps it as a Photoshop step. +/// preset), or an amount or radius in another unit, keeps it as a Photoshop step. pub(super) fn smart_sharpen_from(d: &Descriptor) -> Mapped { match smart_sharpen_params(d) { Some(p) => step("filter.sharpen.smartSharpen", Value::Object(p)), @@ -610,8 +619,8 @@ fn smart_sharpen_params(d: &Descriptor) -> Option> { return None; } let mut p = Map::new(); - for (key, param, default) in [("Amnt", "amount", 200.0), ("Rds", "radius", 1.0)] { - p.insert(param.into(), setting(d, key, num, default)?.into()); + for (key, param, unit, default) in [("Amnt", "amount", *b"#Prc", 200.0), ("Rds", "radius", PX, 1.0)] { + p.insert(param.into(), setting(d, key, in_unit(unit), default)?.into()); } p.insert("reduceNoise".into(), 0.into()); Some(p) diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index 6ba72b03c5..f5e942b330 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -3351,3 +3351,30 @@ fn oil_paint_setting_with_a_unit_or_another_number_type_is_kept() { ], ); } + +/// Smart Sharpen records `Amnt` in `#Prc` and its radius in `#Pxl`, and saves them back so: +/// another unit, or none, would load as that number and save back in the other unit, so the +/// step stays a Photoshop step, whether the radius key is padded or not (#10610). +#[test] +fn smart_sharpen_amount_or_radius_in_another_unit_is_kept() { + kept_when_wrong_typed( + "smartSharpen", + &[ + ("Amnt", px(150.0)), + ("Amnt", D::Double(150.0)), + ("Amnt", D::Integer(150)), + ("Rds ", pct(2.0)), + ("Rds ", D::Double(2.0)), + ("Rds", D::UnitFloat { unit: *b"#Mlm", value: 2.0 }), + ("Rds", D::Double(2.0)), + ], + ); + for rds in ["Rds ", "Rds"] { + let d = desc("null").with("Amnt", pct(150.0)).with(rds, px(2.5)); + assert_eq!( + mapped(newer("smartSharpen", d)), + ("filter.sharpen.smartSharpen".into(), json!({"amount": 150.0, "radius": 2.5, "reduceNoise": 0})), + "{rds:?}" + ); + } +} From c3b8a5aaa3d3938d4bf5ff5c9445411b73ac1251 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 31 Oct 2026 23:06:58 -0600 Subject: [PATCH 062/216] Re-run CI The gate failed in photocraft-compose's proptest the_split_route_agrees_with_the_exact_path (flaky on main, #10386), which this PR doesn't touch. Co-Authored-By: Claude Opus 5.5 From 59da5e229adc6565e56e1fb1bf719b862e3c9640 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 31 Oct 2026 23:17:09 -0600 Subject: [PATCH 063/216] Actions: refuse a Filter Gallery integer outside its setting's range (CodeRabbit review on steps_gallery.rs:567) An integer key only checked for a fraction and the i32 range, so `edgeWidth: 99` saved as `EdgW` 99, which Photoshop plays clamped and which loads back as 14. A value outside the setting's own min..max now refuses the step, as fields.rs refuses rather than clamps (#7287). Co-Authored-By: Claude Opus 5.5 --- .../engine/src/cmd/atn_cmds/steps_gallery.rs | 9 +++++--- crates/engine/src/cmd/atn_cmds/tests.rs | 23 +++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/atn_cmds/steps_gallery.rs b/crates/engine/src/cmd/atn_cmds/steps_gallery.rs index f640eafa2b..da9c26fcfa 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_gallery.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_gallery.rs @@ -553,7 +553,8 @@ fn effect_step(d: &Descriptor) -> Option { /// Every key in recorded order, Photoshop's default (which is the command's) for what the step /// leaves out. A setting the event has no key for (the Sketch effects' pinned colours, a mask -/// target), a fraction in an integer key or a value without a Photoshop code refuses the step. +/// target), a fraction in an integer key, an integer outside its setting's range or a value +/// without a Photoshop code refuses the step. pub(super) fn gallery_to(id: &str, p: &Value) -> Option { let (code, _, keys) = GALLERY.iter().find(|(_, cmd, _)| *cmd == id)?; let allowed: Vec<&str> = keys.iter().map(G::param).chain(["target"]).collect(); @@ -564,9 +565,11 @@ pub(super) fn gallery_to(id: &str, p: &Value) -> Option { for k in keys.iter() { let v = p.get(k.param()).filter(|v| !v.is_null()); let out = match k { - G::Int(_, _, _, _, default) => { + G::Int(_, _, min, max, default) => { let n = v.map_or(Some(f64::from(*default)), Value::as_f64)?; - if n.fract() != 0.0 || !(f64::from(i32::MIN)..=f64::from(i32::MAX)).contains(&n) { + // Outside the setting's range, Photoshop would play it clamped and it would load + // back as another value (`edgeWidth` 99 as 14). + if n.fract() != 0.0 || !(f64::from(*min)..=f64::from(*max)).contains(&n) { return None; } D::Integer(n as i32) diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index 67b14eb06a..c700f8eedc 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -3317,6 +3317,29 @@ fn gallery_effect_steps_that_cannot_be_written_are_refused() { assert!(export_item("filter.gallery.glass", &json!({"texture": "frosted"}), "S").is_some()); } +/// An integer outside the setting's range refuses the step rather than saving a value Photoshop +/// clamps on playback and that loads back as another (`edgeWidth` 99 as 14); each end of a range +/// still saves (#10592). +#[test] +fn gallery_effect_integers_outside_the_settings_range_are_refused() { + for (id, params) in [ + ("filter.gallery.accentedEdges", json!({"edgeWidth": 99})), + ("filter.gallery.accentedEdges", json!({"edgeWidth": 0})), + ("filter.gallery.neonGlow", json!({"glowSize": -25, "glowColor": "#336699"})), + ("filter.gallery.glass", json!({"scaling": 201})), + ] { + assert!(export_item(id, ¶ms, "S").is_none(), "{id} {params} exported"); + } + for (id, params) in [ + ("filter.gallery.accentedEdges", json!({"edgeWidth": 1})), + ("filter.gallery.accentedEdges", json!({"edgeWidth": 14})), + ("filter.gallery.neonGlow", json!({"glowSize": -24, "glowColor": "#336699"})), + ("filter.gallery.glass", json!({"scaling": 200})), + ] { + assert!(export_item(id, ¶ms, "S").is_some(), "{id} {params} refused"); + } +} + /// Each enumeration saves with the type Photoshop's terminology gives it: stroke direction's is /// `StrD`, not its key, and texture's is `TxtT`, as Photoshop records Glass (#10592). #[test] From 6699a83366b7ae3aa3d8b407101bb913244428e4 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 31 Oct 2026 23:18:47 -0600 Subject: [PATCH 064/216] Actions: save Neon Glow without a colour with the colour it plays (CodeRabbit review on steps_gallery.rs:576) A `filter.gallery.neonGlow` step without `glowColor` plays with the effect's default glow colour, but export refused it. It now writes that colour as Photoshop's RGBC components, unrounded (0, 158.1, 255), so the saved step names exactly the colour PhotoCraft plays rather than a `#009eff` rounded to whole components. Co-Authored-By: Claude Opus 5.5 --- .../engine/src/cmd/atn_cmds/steps_gallery.rs | 19 +++++++++++++++---- crates/engine/src/cmd/atn_cmds/tests.rs | 17 +++++++++++++++-- 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/atn_cmds/steps_gallery.rs b/crates/engine/src/cmd/atn_cmds/steps_gallery.rs index da9c26fcfa..359554da06 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_gallery.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_gallery.rs @@ -8,6 +8,7 @@ use super::events::pixel_target; use super::fields::{Mapped, step}; use super::tables::{lookup, lookup_back}; use super::{D, Descriptor, Map, Value}; +use photocraft_algo::{GalleryEffect, GalleryFilter}; /// One key of an effect, in recorded order. enum G { @@ -17,7 +18,7 @@ enum G { /// value keeps the step. Enum(&'static str, &'static str, &'static [(&'static str, &'static str)], &'static str), Bool(&'static str, &'static str, bool), - /// A colour object ↔ `#rrggbb`. + /// A colour object ↔ `#rrggbb`; left out, it is the effect's own default colour. Color(&'static str, &'static str), } @@ -551,6 +552,15 @@ fn effect_step(d: &Descriptor) -> Option { Some(step(id, Value::Object(p))) } +/// The colour `id`'s effect plays with when the step names none (Neon Glow's glow colour), as +/// Photoshop's 0–255 components, unrounded, so the step saves the colour it plays with. +fn default_color(id: &str) -> Option { + let filter = id.strip_prefix("filter.gallery.").and_then(GalleryFilter::from_key)?; + let [r, g, b, _] = GalleryEffect::new(filter).color; + let c = |v: f32| D::Double(f64::from(v) * 255.0); + Some(desc("RGBC").with("Rd ", c(r)).with("Grn ", c(g)).with("Bl ", c(b))) +} + /// Every key in recorded order, Photoshop's default (which is the command's) for what the step /// leaves out. A setting the event has no key for (the Sketch effects' pinned colours, a mask /// target), a fraction in an integer key, an integer outside its setting's range or a value @@ -576,9 +586,10 @@ pub(super) fn gallery_to(id: &str, p: &Value) -> Option { } G::Enum(key, _, table, default) => enm(enum_type(key), v.map_or(Some(*default), |v| v.as_str().and_then(|s| lookup_back(table, s)))?), G::Bool(_, _, default) => D::Boolean(v.map_or(Some(*default), Value::as_bool)?), - // The colour the effect replays with when the step names none is the session's, which - // the event cannot record. - G::Color(..) => D::Descriptor(color_desc(v?.as_str()?)?), + G::Color(..) => D::Descriptor(match v { + Some(v) => color_desc(v.as_str()?)?, + None => default_color(id)?, + }), }; d = d.with(k.key(), out); } diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index c700f8eedc..53bd936ca7 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -3302,14 +3302,13 @@ fn gallery_effect_steps_the_commands_cannot_play_are_kept() { } /// What the event cannot hold refuses the step: a fraction in an integer key, the colours a -/// Sketch effect pinned, a value with no Photoshop code, Neon Glow without its colour (#10592). +/// Sketch effect pinned, a value with no Photoshop code, a mask target (#10592). #[test] fn gallery_effect_steps_that_cannot_be_written_are_refused() { for (id, params) in [ ("filter.gallery.accentedEdges", json!({"edgeWidth": 2.5})), ("filter.gallery.charcoal", json!({"foreground": "#000000"})), ("filter.gallery.glass", json!({"texture": "blocks"})), - ("filter.gallery.neonGlow", json!({"glowSize": 5})), ("filter.gallery.cutout", json!({"target": "mask"})), ] { assert!(export_item(id, ¶ms, "S").is_none(), "{id} {params} exported"); @@ -3340,6 +3339,20 @@ fn gallery_effect_integers_outside_the_settings_range_are_refused() { } } +/// Neon Glow without a colour plays with the effect's default glow colour, so it saves with that +/// colour, component for component (#10592). +#[test] +fn gallery_neon_glow_without_a_colour_saves_the_colour_it_plays_with() { + let item = export_item("filter.gallery.neonGlow", &json!({"glowSize": 5}), "S").expect("refused"); + let d = item.descriptor.unwrap(); + let c = get_obj(&d, "Clr ").expect("no colour"); + assert_eq!(trimmed(&c.class_id), "RGBC"); + let neon = photocraft_algo::GalleryEffect::new(photocraft_algo::GalleryFilter::NeonGlow).color; + for (key, v) in [("Rd ", neon[0]), ("Grn ", neon[1]), ("Bl ", neon[2])] { + assert_eq!(get_num(c, key), Some(f64::from(v) * 255.0), "{key}"); + } +} + /// Each enumeration saves with the type Photoshop's terminology gives it: stroke direction's is /// `StrD`, not its key, and texture's is `TxtT`, as Photoshop records Glass (#10592). #[test] From 898176f7f7feebda1b3193926b69a4a4b55ea04b Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 31 Oct 2026 23:22:24 -0600 Subject: [PATCH 065/216] Actions: keep a Wave step the command can't play (CodeRabbit review on steps_distort.rs:147) Photoshop clamps Wave's wavelengths and amplitudes to 1..999, but filter.distort.wave takes wavelengthMin 1..998, wavelengthMax 2..999 and amplitudeMin 1..998, so `WLMn` 999, `WLMx` 1 or `AmMn` 999 mapped to a step that failed when it played. Such a step now stays a Photoshop step, and export refuses those values. A minimum above its maximum, which the command takes, still maps and plays. Co-Authored-By: Claude Opus 5.5 --- .../engine/src/cmd/atn_cmds/steps_distort.rs | 15 +++++++- crates/engine/src/cmd/atn_cmds/tests.rs | 34 +++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/atn_cmds/steps_distort.rs b/crates/engine/src/cmd/atn_cmds/steps_distort.rs index bd3b4f2275..3a954c509a 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_distort.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_distort.rs @@ -152,12 +152,22 @@ const WAVE: &[Key] = &[ const WAVE_UNDEFINED: &[(&str, &str)] = &[("RptE", "repeat"), ("WrpA", "wrap")]; +/// `filter.distort.wave`'s own limits where they are narrower than Photoshop's 1..999. A value +/// Photoshop plays but the command rejects keeps the step on import and refuses it on export, +/// rather than mapping a step that fails when it plays. +const WAVE_COMMAND_LIMITS: &[(&str, f64, f64)] = &[("wavelengthMin", 1.0, 998.0), ("wavelengthMax", 2.0, 999.0), ("amplitudeMin", 1.0, 998.0)]; + +/// Whether every value `p` sets for a key of [`WAVE_COMMAND_LIMITS`] is within the command's limits. +fn wave_command_takes(p: &Map) -> bool { + WAVE_COMMAND_LIMITS.iter().all(|(param, min, max)| p.get(*param).and_then(Value::as_f64).is_none_or(|n| (*min..=*max).contains(&n))) +} + /// Wave (`Wave`). The command has no horizontal or vertical scale, so a step that scales the /// waves below 100 %, or by a fraction (saved back as 100), keeps the Photoshop step. Missing undefined areas are Photoshop's Repeat /// Edge Pixels (the command's own default is wrap); `RndS` keeps its 32 bits as the seed. pub(super) fn wave_from(d: &Descriptor) -> Mapped { let unsupported = Mapped::Unsupported { harmless: false }; - let Some(mut m) = keys_map(d, WAVE, &["SclH", "SclV", "UndA", "RndS"]) else { return unsupported }; + let Some(mut m) = keys_map(d, WAVE, &["SclH", "SclV", "UndA", "RndS"]).filter(wave_command_takes) else { return unsupported }; if ["SclH", "SclV"].iter().any(|k| setting(d, k, num, 100.0).is_none_or(|n| n.fract() != 0.0 || n.clamp(1.0, 100.0) != 100.0)) { return unsupported; } @@ -189,6 +199,9 @@ pub(super) fn wave_to(_: &str, p: &Value) -> Option { ("amplitudeMin", json!(5)), ("amplitudeMax", json!(35)), ]; + if !p.as_object().is_none_or(wave_command_takes) { + return None; + } let d = keys_to(p, WAVE, &ours, &["undefinedAreas", "seed"])?; let undefined = match p.get("undefinedAreas") { None | Some(Value::Null) => "WrpA", diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index d83fadd2a3..4b961ccec5 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -3393,6 +3393,40 @@ fn distort_filter_steps_with_an_enumeration_of_another_type_are_kept() { assert!(!kept(distort("Wave", desc("null").with("UndA", enm("UndA", "WrpA"))))); } +/// Photoshop clamps Wave's wavelengths and amplitudes to 1..999, but `filter.distort.wave` +/// takes a minimum wavelength of 1..998, a maximum of 2..999 and a minimum amplitude of 1..998. +/// A value the command rejects keeps the Photoshop step on import and refuses it on export, +/// rather than mapping a step that fails when it plays; a minimum above its maximum, which the +/// command takes, maps and plays (#10605). +#[test] +fn wave_values_the_command_rejects_are_kept_or_refused() { + let kept = |m: Mapped| matches!(m, Mapped::Unsupported { harmless: false }); + for (key, n) in [("WLMn", 999), ("WLMn", 5000), ("WLMx", 1), ("WLMx", 0), ("AmMn", 999), ("AmMn", 1200)] { + assert!(kept(distort("Wave", desc("null").with(key, D::Integer(n)))), "{key} {n} was mapped"); + } + for (param, n) in [("wavelengthMin", 999), ("wavelengthMax", 1), ("amplitudeMin", 999)] { + assert!(export_item("filter.distort.wave", &json!({ param: n }), "S").is_none(), "{param} {n} exported"); + } + let mut s = Session::new(); + s.execute("file.new", json!({"width": 24, "height": 24})).unwrap(); + s.execute("filter.render.clouds", json!({})).unwrap(); + let plays = [ + desc("null").with("WLMn", D::Integer(998)), + desc("null").with("WLMx", D::Integer(2)), + desc("null").with("AmMn", D::Integer(998)), + desc("null").with("WLMn", D::Integer(200)).with("WLMx", D::Integer(100)), + desc("null").with("AmMn", D::Integer(40)).with("AmMx", D::Integer(20)), + ]; + for (k, d) in plays.into_iter().enumerate() { + let (id, p) = one_step(distort("Wave", d)); + assert!(export_item(&id, &p, "S").is_some(), "{p} refused"); + let name = format!("W{k}"); + s.actions.list.push(Action::new(&name, vec![(id, p.clone())])); + let played = s.execute("actions.play", json!({"action": name})).unwrap(); + assert_eq!(played["ran"], 1, "{p}: {played}"); + } +} + /// Wave saves its scales as the integer 100, so a fractional scale, even one that rounds or /// clamps to 100, stays a Photoshop step instead of saving back as 100 (#10605). #[test] From 084130fb8e2299fc7aebc22fff27c34fe2508a69 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 31 Oct 2026 23:46:16 -0600 Subject: [PATCH 066/216] Re-run CI (flaky cmyk::tests::the_split_route_agrees_with_the_exact_path, #10386) Co-Authored-By: Claude Opus 5.5 From 889f3e2e778b78048d0a095ac353931d16314917 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 31 Oct 2026 23:50:32 -0600 Subject: [PATCH 067/216] Actions: keep a Trap step narrower than 1 px (CodeRabbit review on steps_channels.rs:72) Photoshop clamps a Trap width above 32767 px, but its minimum wasn't measured, so a recorded width below the command's 1 px is kept as a Photoshop step instead of loading as a Trap that rounds to 0 px and does nothing. Export refuses such a width as it does one above 32767 px. Co-Authored-By: Claude Opus 5.5 --- .../engine/src/cmd/atn_cmds/steps_channels.rs | 17 +++++++++++------ crates/engine/src/cmd/atn_cmds/tests.rs | 15 +++++++++++++++ 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/atn_cmds/steps_channels.rs b/crates/engine/src/cmd/atn_cmds/steps_channels.rs index b6a81646f4..ffe6e2bfb3 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_channels.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_channels.rs @@ -53,6 +53,10 @@ pub(super) fn merge_spot_to(_: &str, p: &Value) -> Option { /// Trap's widest width: Photoshop clamps larger ones to it. const MAX_TRAP: f64 = 32767.0; +/// Trap's narrowest width, the command's `width:px>=1`. Photoshop's own minimum wasn't +/// measured, so a narrower recorded width isn't clamped up to it. +const MIN_TRAP: f64 = 1.0; + /// A width in `#Pxl`, the unit Trap saves: a width in another unit (points, millimetres) or /// without one would load as that many pixels and save back as pixels. fn pixels(v: &D) -> Option { @@ -62,28 +66,29 @@ fn pixels(v: &D) -> Option { } } -/// Trap (`Trap`): `Wdth` in pixels. A width that isn't a number of pixels keeps the Photoshop -/// step. +/// Trap (`Trap`): `Wdth` in pixels. A width that isn't a number of pixels, or is below +/// `MIN_TRAP`, keeps the Photoshop step. pub(super) fn trap_from(d: &Descriptor) -> Mapped { if !only_keys(d, &["Wdth"]) { return Mapped::Unsupported { harmless: false }; } match setting(d, "Wdth", |v| pixels(v).map(Some), None) { - Some(Some(w)) => step("image.trap", json!({"width": w.min(MAX_TRAP)})), + Some(Some(w)) if w >= MIN_TRAP => step("image.trap", json!({"width": w.min(MAX_TRAP)})), Some(None) => step("image.trap", json!({})), - None => Mapped::Unsupported { harmless: false }, + _ => Mapped::Unsupported { harmless: false }, } } /// `Wdth` is always written: the command traps 1 px when the step leaves the width out. A -/// width above `MAX_TRAP` refuses the step rather than being clamped to another value. +/// width outside `MIN_TRAP..=MAX_TRAP` refuses the step rather than being clamped to another +/// value. pub(super) fn trap_to(_: &str, p: &Value) -> Option { if !sets_only(p, &["width"]) { return None; } let width = match p.get("width") { None | Some(Value::Null) => 1.0, - Some(v) => v.as_f64().filter(|n| n.is_finite() && *n <= MAX_TRAP)?, + Some(v) => v.as_f64().filter(|n| (MIN_TRAP..=MAX_TRAP).contains(n))?, }; Some(desc("null").with("Wdth", px(width))) } diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index c35caf26ed..5f11170119 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -3341,3 +3341,18 @@ fn match_color_setting_that_cannot_be_saved_back_is_kept() { assert!(matches!(m, Mapped::Unsupported { harmless: false }), "{key} = {v:?} was mapped"); } } + +/// Photoshop's narrowest Trap width wasn't measured, so a recorded width below the command's +/// 1 px isn't clamped up to it: the step stays a Photoshop step, and a PhotoCraft step that +/// narrow is refused on export instead of written as a width the command doesn't trap (#10609). +#[test] +fn trap_width_below_one_pixel_is_kept() { + for w in [0.25, 0.0, -2.0] { + let m = channel_step("Trap", desc("null").with("Wdth", px(w))); + assert!(matches!(m, Mapped::Unsupported { harmless: false }), "Wdth {w} was mapped"); + assert!(export_item("image.trap", &json!({"width": w}), "S").is_none(), "width {w} exported"); + } + let d = export_item("image.trap", &json!({"width": 1}), "S").unwrap().descriptor.unwrap(); + assert_eq!(d.items, desc("null").with("Wdth", px(1.0)).items); + assert!(matches!(channel_step("Trap", desc("null").with("Wdth", px(1.0))), Mapped::Steps(_))); +} From 77d72957dd5a60f2a815ead5377b52c8b6e2573a Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 31 Oct 2026 23:51:12 -0600 Subject: [PATCH 068/216] Actions: keep a Match Color step outside the command's range (CodeRabbit review on steps_channels.rs:113) Match Color's Luminance and Color Intensity take 1..200 and Fade 0..100. The event reference records no clamp for them, so a recorded value outside those ranges is kept as a Photoshop step instead of loading as a step the command plays clamped. Export refuses such a value instead of writing it unchanged. Co-Authored-By: Claude Opus 5.5 --- .../engine/src/cmd/atn_cmds/steps_channels.rs | 20 +++++++++++-------- crates/engine/src/cmd/atn_cmds/tests.rs | 18 +++++++++++++++++ 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/atn_cmds/steps_channels.rs b/crates/engine/src/cmd/atn_cmds/steps_channels.rs index ffe6e2bfb3..37c4c0bf56 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_channels.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_channels.rs @@ -5,6 +5,7 @@ use super::descriptors::{PX, boolean, desc, px, trimmed}; use super::events::pixel_target; use super::fields::{Mapped, step}; use super::{D, Descriptor, Value, json}; +use std::ops::RangeInclusive; /// Whether every key of `d` is one of `keys` (codes compare without their space padding). fn only_keys(d: &Descriptor, keys: &[&str]) -> bool { @@ -93,14 +94,16 @@ pub(super) fn trap_to(_: &str, p: &Value) -> Option { Some(desc("null").with("Wdth", px(width))) } -/// Match Color's integer keys in recorded order: (key, param, default). -const MATCH_COLOR: &[(&str, &str, i32)] = &[("Lght", "luminance", 100), ("ClrR", "intensity", 100), ("Fade", "fade", 0)]; +/// Match Color's integer keys in recorded order: (key, param, default, the command's range). +const MATCH_COLOR: &[(&str, &str, i32, RangeInclusive)] = + &[("Lght", "luminance", 100, 1..=200), ("ClrR", "intensity", 100, 1..=200), ("Fade", "fade", 0, 0..=100)]; /// Match Color (`matchColor`) without a source, as Photoshop records it (`noReference`). A /// step that matches another image (`noReference` false, or not a Boolean) names no source /// here, so it keeps the Photoshop step, as does a setting that isn't an integer: the step /// saves its settings as integers, so it couldn't write a fraction, a large integer or a unit -/// back as recorded. +/// back as recorded. A setting outside the command's range keeps the step too: the command +/// would clamp it, and Photoshop's handling of it wasn't measured. pub(super) fn match_color_from(d: &Descriptor) -> Mapped { match match_color_params(d) { Some(p) => step("image.adjustments.matchColor", Value::Object(p)), @@ -113,9 +116,9 @@ fn match_color_params(d: &Descriptor) -> Option> return None; } let mut p = serde_json::Map::new(); - for (key, param, default) in MATCH_COLOR { + for (key, param, default, range) in MATCH_COLOR { let integer = |v: &D| match v { - D::Integer(n) => Some(Value::from(*n)), + D::Integer(n) if range.contains(n) => Some(Value::from(*n)), _ => None, }; p.insert((*param).into(), setting(d, key, integer, Value::from(*default))?); @@ -124,7 +127,8 @@ fn match_color_params(d: &Descriptor) -> Option> } /// Every key in recorded order. A source document or layer, Neutralize or a selection option -/// has no key in the event, so such a step is refused; the integers refuse a fraction. +/// has no key in the event, so such a step is refused; the integers refuse a fraction or a +/// value outside the command's range, which it plays clamped. pub(super) fn match_color_to(_: &str, p: &Value) -> Option { let off = |k: &str| p.get(k).is_none_or(|v| v.is_null() || *v == Value::Bool(false)); if !pixel_target(p) @@ -134,12 +138,12 @@ pub(super) fn match_color_to(_: &str, p: &Value) -> Option { return None; } let mut d = desc("null"); - for (key, param, default) in MATCH_COLOR { + for (key, param, default, range) in MATCH_COLOR { let n = match p.get(*param) { None | Some(Value::Null) => f64::from(*default), Some(v) => v.as_f64()?, }; - if n.fract() != 0.0 || !(f64::from(i32::MIN)..=f64::from(i32::MAX)).contains(&n) { + if n.fract() != 0.0 || !(f64::from(*range.start())..=f64::from(*range.end())).contains(&n) { return None; } d = d.with(key, D::Integer(n as i32)); diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index 5f11170119..ea8c456c15 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -3356,3 +3356,21 @@ fn trap_width_below_one_pixel_is_kept() { assert_eq!(d.items, desc("null").with("Wdth", px(1.0)).items); assert!(matches!(channel_step("Trap", desc("null").with("Wdth", px(1.0))), Mapped::Steps(_))); } + +/// Match Color's Luminance and Color Intensity take 1..200 and Fade 0..100, and Photoshop's +/// handling of a recorded value outside them wasn't measured: such a step stays a Photoshop +/// step rather than being clamped, and a PhotoCraft step outside them is refused on export +/// instead of written as a value the command plays clamped (#10609). +#[test] +fn match_color_setting_outside_its_range_is_kept() { + for (key, n) in [("Lght", 0), ("Lght", 201), ("ClrR", 0), ("ClrR", 201), ("Fade", -1), ("Fade", 101)] { + let m = channel_step("matchColor", desc("null").with(key, D::Integer(n))); + assert!(matches!(m, Mapped::Unsupported { harmless: false }), "{key} = {n} was mapped"); + } + let edges = desc("null").with("Lght", D::Integer(1)).with("ClrR", D::Integer(200)).with("Fade", D::Integer(100)); + assert!(matches!(channel_step("matchColor", edges), Mapped::Steps(_))); + for p in [json!({"luminance": 0}), json!({"luminance": 201}), json!({"intensity": 0}), json!({"fade": -1}), json!({"fade": 101})] { + assert!(export_item("image.adjustments.matchColor", &p, "S").is_none(), "{p} exported"); + } + assert!(export_item("image.adjustments.matchColor", &json!({"luminance": 1, "intensity": 200, "fade": 100}), "S").is_some()); +} From f8ec05858a263726bf65a6e6a3db2cf82a08e6e3 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sat, 31 Oct 2026 23:54:38 -0600 Subject: [PATCH 069/216] Actions: load Neon Glow without a colour in Photoshop's blue (CodeRabbit review on steps_gallery.rs:559) A Neon Glow step recorded without `Clr ` glows in Photoshop's default colour, (0, 0, 255) per the event reference, but loaded without `glowColor` and so played PhotoCraft's own default (0, 158, 255). It now loads with `glowColor` "#0000ff", which saves back as that RGBC colour. A PhotoCraft step without `glowColor` still saves the colour it plays with. Co-Authored-By: Claude Opus 5.5 --- .../engine/src/cmd/atn_cmds/steps_gallery.rs | 20 ++++++++++++------- crates/engine/src/cmd/atn_cmds/tests.rs | 17 ++++++++++++++++ 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/atn_cmds/steps_gallery.rs b/crates/engine/src/cmd/atn_cmds/steps_gallery.rs index 359554da06..c5486e0143 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_gallery.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_gallery.rs @@ -18,20 +18,22 @@ enum G { /// value keeps the step. Enum(&'static str, &'static str, &'static [(&'static str, &'static str)], &'static str), Bool(&'static str, &'static str, bool), - /// A colour object ↔ `#rrggbb`; left out, it is the effect's own default colour. - Color(&'static str, &'static str), + /// A colour object ↔ `#rrggbb` and Photoshop's default, which a step recorded without the + /// key loads with. A PhotoCraft step without it saves the effect's own default colour, the + /// one it plays with. + Color(&'static str, &'static str, &'static str), } impl G { fn key(&self) -> &'static str { match self { - G::Int(k, ..) | G::Enum(k, ..) | G::Bool(k, ..) | G::Color(k, _) => k, + G::Int(k, ..) | G::Enum(k, ..) | G::Bool(k, ..) | G::Color(k, ..) => k, } } fn param(&self) -> &'static str { match self { - G::Int(_, p, ..) | G::Enum(_, p, ..) | G::Bool(_, p, _) | G::Color(_, p) => p, + G::Int(_, p, ..) | G::Enum(_, p, ..) | G::Bool(_, p, _) | G::Color(_, p, _) => p, } } } @@ -225,7 +227,11 @@ const GALLERY: &[(&str, &str, &[G])] = &[ &[G::Int("TlSz", "tileSize", 2, 100, 12), G::Int("GrtW", "groutWidth", 1, 15, 3), G::Int("LghG", "lightenGrout", 0, 10, 9)], ), // Neon Glow - ("NGlw", "filter.gallery.neonGlow", &[G::Int("Sz ", "glowSize", -24, 24, 5), G::Int("Brgh", "glowBrightness", 0, 50, 15), G::Color("Clr ", "glowColor")]), + ( + "NGlw", + "filter.gallery.neonGlow", + &[G::Int("Sz ", "glowSize", -24, 24, 5), G::Int("Brgh", "glowBrightness", 0, 50, 15), G::Color("Clr ", "glowColor", "#0000ff")], + ), // Note Paper ( "NtPr", @@ -542,9 +548,9 @@ fn effect_step(d: &Descriptor) -> Option { G::Int(key, _, min, max, default) => Value::from(get_num(d, key).map_or(*default, |n| n.round().clamp(f64::from(*min), f64::from(*max)) as i32)), G::Enum(key, _, table, default) => Value::from(lookup(table, &get_enum(d, key).unwrap_or_else(|| (*default).to_string()))?), G::Bool(key, _, default) => Value::from(get_bool(d, key).unwrap_or(*default)), - G::Color(key, _) => match get_obj(d, key) { + G::Color(key, _, default) => match get_obj(d, key) { Some(c) => Value::from(color_hex(c)?), - None => continue, + None => Value::from(*default), }, }; p.insert(k.param().into(), v); diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index 53bd936ca7..fa8d89f0a4 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -3373,3 +3373,20 @@ fn gallery_effect_enumerations_save_with_photoshops_type_ids() { assert_eq!(enum_type("filter.gallery.paintDaubs", json!({}), "BrsT"), "BrsT"); assert_eq!(enum_type("filter.gallery.grain", json!({}), "Grnt"), "Grnt"); } + +/// Neon Glow recorded without `Clr ` glows in Photoshop's default colour, blue (0, 0, 255) per +/// the event reference, not PhotoCraft's own default: the step loads with that colour and saves +/// it back, loading again as the same step (#10592). +#[test] +fn gallery_neon_glow_without_a_colour_loads_photoshops_blue() { + let (id, p) = gallery_params(gallery_step("GEfc", "NGlw", desc("null"))); + assert_eq!(id, "filter.gallery.neonGlow"); + assert_eq!(p, json!({"glowSize": 5, "glowBrightness": 15, "glowColor": "#0000ff"})); + let d = export_item(&id, &p, "S").expect("refused").descriptor.unwrap(); + let c = get_obj(&d, "Clr ").expect("no colour"); + assert_eq!(trimmed(&c.class_id), "RGBC"); + assert_eq!([get_num(c, "Rd "), get_num(c, "Grn "), get_num(c, "Bl ")], [Some(0.0), Some(0.0), Some(255.0)]); + let event = AtnEvent::Code(*b"GEfc"); + let back = (events::event_for(&event).unwrap().from)(&d); + assert_eq!(gallery_params(back), (id, p)); +} From 036d37f90d8540336c3dc762e3df91425244b02c Mon Sep 17 00:00:00 2001 From: yoriswind Date: Sat, 31 Oct 2026 23:06:02 -0700 Subject: [PATCH 070/216] Clarify current and historical ATN documentation counts --- docs/file-format-parity.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 5e8438cc54..d0bc8f0138 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-31 · **Change:** minor (`.atn`: event reference [atn.md](atn.md) with 127 supported events and the measured keys, defaults, ranges and enum values of 89 unmapped reference entries; TIFFs up to 300,000 px a side open, decoded a group of rows at a time into the layer, #8169; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; PSD Filter Gallery: Halftone Pattern dot/circle/line settings survive PSD/PSB saves (#8940); Save for Web writes WebP, lossy or lossless, #6391; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency, #8877; layered TIFF writes the TySh text version and descriptor in PSD big-endian order while the header, matrix, warp and bounds keep container order, #9098; layered TIFF writes Photoshop's 14-byte global layer mask, so Photoshop opens the layers instead of only the composite, #9095; legacy PSD point text placed from the layout ink box after font resolution, #6094; `.atn`: 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; `.asl` layer style libraries read and written, #1051; JPEG Options on File › Save, Save As and Save a Copy, FILE-215-19 partial (no Matte, progressive Scans or size preview); 32-bit Hue/Saturation unclipped and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; JPEG Options on Save As and Save a Copy, FILE-215-19; TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; Cineon write: 10-bit gray/RGB; animated WebP frames load into video layers, #1198; Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 53 filters (39 more: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag, Invert, Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding; Amiga IFF write: 24-plane ILBM, Save As `.iff`; PSD stores spot channels as Photoshop does, ink 0 = solid, and numbers them in 1053, #7415 split; TIFF writes and reads alpha and spot channels as extra samples with Photoshop's channel resources, PSD output unchanged, FILE-215-7, FILE-215-17's TIFF part, #7415; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-11-01 · **Change:** minor (`.atn`: event reference [atn.md](atn.md) with 127 supported events and the measured keys, defaults, ranges and enum values of 89 unmapped reference entries; TIFFs up to 300,000 px a side open, decoded a group of rows at a time into the layer, #8169; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; PSD Filter Gallery: Halftone Pattern dot/circle/line settings survive PSD/PSB saves (#8940); Save for Web writes WebP, lossy or lossless, #6391; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency, #8877; layered TIFF writes the TySh text version and descriptor in PSD big-endian order while the header, matrix, warp and bounds keep container order, #9098; layered TIFF writes Photoshop's 14-byte global layer mask, so Photoshop opens the layers instead of only the composite, #9095; legacy PSD point text placed from the layout ink box after font resolution, #6094; `.atn`: 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; `.asl` layer style libraries read and written, #1051; JPEG Options on File › Save, Save As and Save a Copy, FILE-215-19 partial (no Matte, progressive Scans or size preview); 32-bit Hue/Saturation unclipped and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; JPEG Options on Save As and Save a Copy, FILE-215-19; TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; Cineon write: 10-bit gray/RGB; animated WebP frames load into video layers, #1198; Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 53 filters (39 more: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag, Invert, Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding; Amiga IFF write: 24-plane ILBM, Save As `.iff`; PSD stores spot channels as Photoshop does, ink 0 = solid, and numbers them in 1053, #7415 split; TIFF writes and reads alpha and spot channels as extra samples with Photoshop's channel resources, PSD output unchanged, FILE-215-7, FILE-215-17's TIFF part, #7415; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency) · **Target:** Adobe Photoshop 2026 **PSD Filter Gallery (#8940, 2026-10-23):** Halftone Pattern maps its size, contrast and dot/circle/line pattern as editable settings on import and export, with the reference application's descriptor keys (`HlSz`, `Cntr`, `ScrT`: `ScrD`/`ScrC`/`ScrL`). PSD/PSB round trips pass at 8/16/32 bits. This does not claim pixel equivalence for the existing renderer. Unknown or malformed settings remain verbatim on import. @@ -150,7 +150,7 @@ The original reference row retains its source date and historical totals; curren | Date | Change | Summary | |---|---|---| -| 2026-11-01 | minor | `.atn`: event reference [atn.md](atn.md): the 117 supported events, and measured keys, defaults, ranges and enum values for 101 events not mapped yet | +| 2026-11-01 | minor | `.atn`: original reference snapshot, before the Group F mappings: [atn.md](atn.md) documented 117 supported events and measured keys, defaults, ranges and enum values for 101 events not mapped yet | | 2026-10-31 | minor | `.atn` Group F: selection combinations, Paste Into/Outside, Fade, Link, Distribute, Define Pattern and older mask/merge events load, play and save; 127 supported event IDs, 89 unmapped reference entries; AUTO-219-2 remains partial | | 2026-10-26 | minor | Embedded AI/PDF/EPS save-back retains native format and a separate source-bound display preview; original sources remain undoable. Native Adobe interoperability and standalone EPS/DCS remain unverified; no corpus floor or percentage changed. | | 2026-10-22 | minor | Embedded PSD vectors gain explicit bounded SVG editor/save-back; standalone EPS/DCS and native-format fidelity remain open. | From cfc4c19eb8058d10829114d9ae0519564faebdcf Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sun, 1 Nov 2026 02:35:44 -0600 Subject: [PATCH 071/216] Actions: keep a Smart Sharpen step outside the command's amount and radius limits (FredApps review) filter.sharpen.smartSharpen takes an amount of 1..500 % and a radius of 0.1..64 px and rejects anything else. The importer accepted any finite value, so such a step mapped and then failed when it played. It now keeps the Photoshop step, and export refuses those values instead of writing them. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/atn_cmds/steps_image.rs | 15 ++++++++---- crates/engine/src/cmd/atn_cmds/tests.rs | 24 +++++++++++++++++++ 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/atn_cmds/steps_image.rs b/crates/engine/src/cmd/atn_cmds/steps_image.rs index 9b4629e33c..91c4b20bfa 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_image.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_image.rs @@ -597,10 +597,16 @@ fn in_unit(unit: [u8; 4]) -> impl Fn(&D) -> Option { } } +/// `filter.sharpen.smartSharpen`'s amount and radius limits, in `smart_sharpen_params`' key order. +/// The command rejects a value outside them, so such a step keeps the Photoshop step on import +/// and is refused on export, rather than mapping a step that fails when it plays. +const SMART_SHARPEN_LIMITS: &[(&str, f64, f64)] = &[("amount", 1.0, 500.0), ("radius", 0.1, 64.0)]; + /// Smart Sharpen (`smartSharpen`), as Photoshop records it without its dialog: the legacy /// filter, removing Lens Blur, with no noise reduction (so `reduceNoise` is 0). A step that /// asks for what the command does not do (another blur, More Accurate, the newer filter, a -/// preset), or an amount or radius in another unit, keeps it as a Photoshop step. +/// preset), or an amount or radius in another unit or outside the command's limits, keeps it +/// as a Photoshop step. pub(super) fn smart_sharpen_from(d: &Descriptor) -> Mapped { match smart_sharpen_params(d) { Some(p) => step("filter.sharpen.smartSharpen", Value::Object(p)), @@ -619,8 +625,9 @@ fn smart_sharpen_params(d: &Descriptor) -> Option> { return None; } let mut p = Map::new(); - for (key, param, unit, default) in [("Amnt", "amount", *b"#Prc", 200.0), ("Rds", "radius", PX, 1.0)] { - p.insert(param.into(), setting(d, key, in_unit(unit), default)?.into()); + for ((key, unit, default), (param, min, max)) in [("Amnt", *b"#Prc", 200.0), ("Rds", PX, 1.0)].into_iter().zip(SMART_SHARPEN_LIMITS) { + let n = setting(d, key, in_unit(unit), default).filter(|n| (*min..=*max).contains(n))?; + p.insert((*param).into(), n.into()); } p.insert("reduceNoise".into(), 0.into()); Some(p) @@ -634,7 +641,7 @@ pub(super) fn smart_sharpen_to(_: &str, p: &Value) -> Option { } let num = |k: &str, default: f64| match p.get(k) { None | Some(Value::Null) => Some(default), - Some(v) => v.as_f64().filter(|n| n.is_finite()), + Some(v) => v.as_f64().filter(|n| SMART_SHARPEN_LIMITS.iter().any(|(param, min, max)| *param == k && (*min..=*max).contains(n))), }; Some( desc("null") diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index f5e942b330..7baddb84a6 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -3378,3 +3378,27 @@ fn smart_sharpen_amount_or_radius_in_another_unit_is_kept() { ); } } + +/// `filter.sharpen.smartSharpen` takes an amount of 1..500 % and a radius of 0.1..64 px and +/// rejects anything else. A step outside them would map and then fail when it plays, so it +/// stays a Photoshop step, and a step outside them is not written either (#10610). +#[test] +fn smart_sharpen_outside_the_commands_limits_is_kept_and_refused() { + kept_when_wrong_typed("smartSharpen", &[("Amnt", pct(0.5)), ("Amnt", pct(500.5)), ("Rds ", px(0.05)), ("Rds ", px(64.5)), ("Rds", px(1000.0))]); + for (amount, radius) in [(1.0, 0.1), (500.0, 64.0)] { + let d = desc("null").with("Amnt", pct(amount)).with("Rds ", px(radius)); + assert_eq!( + mapped(newer("smartSharpen", d)), + ("filter.sharpen.smartSharpen".into(), json!({"amount": amount, "radius": radius, "reduceNoise": 0})), + "{amount} {radius}" + ); + } + for params in [ + json!({"amount": 0.5, "reduceNoise": 0}), + json!({"amount": 501, "reduceNoise": 0}), + json!({"radius": 0.05, "reduceNoise": 0}), + json!({"radius": 65, "reduceNoise": 0}), + ] { + assert!(export_item("filter.sharpen.smartSharpen", ¶ms, "S").is_none(), "{params} exported"); + } +} From 05bbed220d7b71e44ac6c7935b29afbe3ddf8ebd Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sun, 1 Nov 2026 02:37:44 -0600 Subject: [PATCH 072/216] Actions: play mapped Focus Area, Select Sky and Remove Background steps (FredApps review) Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/atn_cmds/tests.rs | 32 +++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index 7baddb84a6..9cfa53d83e 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -3402,3 +3402,35 @@ fn smart_sharpen_outside_the_commands_limits_is_kept_and_refused() { assert!(export_item("filter.sharpen.smartSharpen", ¶ms, "S").is_none(), "{params} exported"); } } + +/// Focus Area, Select Sky and Remove Background, mapped from the empty steps Photoshop records, +/// play: each step runs its command on a red disc on a blue ground (#10610). +#[test] +fn newer_feature_selection_steps_play() { + let (w, h) = (96u32, 80u32); + let mut s = Session::new(); + s.execute("file.new", json!({"width": w, "height": h})).unwrap(); + s.edit("paint", |doc, _| { + let bg = doc.layers[0].surface_mut().unwrap(); + let n = bg.channels(); + let mut data = Vec::with_capacity((w * h) as usize * n); + for y in 0..h { + for x in 0..w { + let (dx, dy) = (x as f32 - 48.0, y as f32 - 40.0); + let c = if dx * dx + dy * dy <= 22.0 * 22.0 { [0.85, 0.2, 0.15] } else { [0.25, 0.45, 0.7] }; + data.extend_from_slice(&c[..n.min(3)]); + if n == 4 { + data.push(1.0); + } + } + } + bg.write_region(photocraft_geom::Rect::new(0, 0, w as i32, h as i32), &data); + Ok(()) + }) + .unwrap(); + let steps = ["focusMask", "selectSky", "removeBackground"].map(|name| mapped(newer(name, desc("null")))); + s.actions.list.push(Action::new("Select", steps.to_vec())); + let played = s.execute("actions.play", json!({"action": "Select"})).unwrap(); + assert!(played.get("failed").is_none(), "{played}"); + assert_eq!(played["ran"], 3, "{played}"); +} From 244082fb2c2a759879b4bc80b17f304febed0fb8 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sun, 1 Nov 2026 02:42:54 -0600 Subject: [PATCH 073/216] Actions: test that Wave's generators above 999 load clamped (FredApps review) Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/atn_cmds/tests.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index 4b961ccec5..1c4278c154 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -3258,6 +3258,14 @@ fn distort_filter_steps_load_clamp_play_and_save_back() { assert_eq!(one_step(distort("Wave", desc("null").with("NmbG", D::Integer(0)))).1["generators"], 1); } +/// Photoshop clamps Wave's number of generators to 1..999 when it plays, so a recorded count far +/// above it loads as 999, as the existing 0 loads as 1 (#10605). +#[test] +fn wave_generators_above_photoshops_limit_load_clamped() { + let p = one_step(distort("Wave", desc("null").with("NmbG", D::Integer(1_000_000)))).1; + assert_eq!(p["generators"], 999, "{p}"); +} + /// What the commands cannot play keeps the Photoshop step: an unknown enumeration value, a Wave /// scaled below 100 % (the command has no scale), or a key the event does not have (#10605). #[test] From b774a20b2398b430cfe02c9db569650ecebc7999 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sun, 1 Nov 2026 02:48:39 -0600 Subject: [PATCH 074/216] Actions: test that a Match Color step naming a source is kept (FredApps review) Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/atn_cmds/tests.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index ea8c456c15..9f74da7b92 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -3374,3 +3374,17 @@ fn match_color_setting_outside_its_range_is_kept() { } assert!(export_item("image.adjustments.matchColor", &json!({"luminance": 1, "intensity": 200, "fade": 100}), "S").is_some()); } + +/// A Match Color step that names its source (`Srce`, a document by name, or the name as text) +/// matches another image the command isn't given, so it stays a Photoshop step even when it +/// also says `noReference` (#10609). +#[test] +fn match_color_with_a_source_is_kept() { + let from = |d: Descriptor| (events::event_for(&AtnEvent::name("matchColor")).unwrap().from)(&d); + let document = D::Reference(vec![ReferenceItem::Name { class: class("Dcmn"), name: UnicodeString::new_nul("Reference.psd") }]); + for source in [document, text("Reference.psd")] { + for d in [desc("null").with("Srce", source.clone()), desc("null").with("Srce", source.clone()).with("noReference", D::Boolean(true))] { + assert!(matches!(from(d.clone()), Mapped::Unsupported { harmless: false }), "{:?} was mapped", d.items); + } + } +} From 4ed3f5b5911e2dc50655fc68cd44da68dab7c219 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sun, 1 Nov 2026 02:54:18 -0600 Subject: [PATCH 075/216] Actions: keep a Custom step outside the command's scale, offset or cell range (FredApps review) filter.other.custom plays a scale of 1..9999, an offset of -9999..9999 and kernel cells of -999..999, and clamps anything else when it plays, so Scl 0 played as 1 and saved back as 0. Such a step now keeps the Photoshop step, and export refuses those values. The matrix length is checked before its cells are collected. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/atn_cmds/steps_other.rs | 49 ++++++++++++------- crates/engine/src/cmd/atn_cmds/tests.rs | 36 ++++++++++++++ 2 files changed, 68 insertions(+), 17 deletions(-) diff --git a/crates/engine/src/cmd/atn_cmds/steps_other.rs b/crates/engine/src/cmd/atn_cmds/steps_other.rs index 1bdf27cd90..2d3d57266c 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_other.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_other.rs @@ -6,6 +6,7 @@ use super::events::pixel_target; use super::fields::{Mapped, step}; use super::tables::{lookup, lookup_back}; use super::{D, Descriptor, Map, Value, json}; +use std::ops::RangeInclusive; /// Whether every key of `d` is one of `keys` (codes compare without their space padding). fn only_keys(d: &Descriptor, keys: &[&str]) -> bool { @@ -84,18 +85,31 @@ pub(super) fn enums_to(p: &Value, keys: &[EnumKey]) -> Option { Some(d) } +/// `filter.other.custom`'s ranges: (scale, offset, kernel cell). The command clamps a value +/// outside them when it plays, so `Scl 0` would play as 1 and save back as 0. +const CUSTOM_SCALE: RangeInclusive = 1.0..=9999.0; +const CUSTOM_OFFSET: RangeInclusive = -9999.0..=9999.0; +const CUSTOM_CELL: RangeInclusive = -999.0..=999.0; + +/// A number within `range`. +fn within(range: &RangeInclusive) -> impl Fn(&D) -> Option + '_ { + move |v| num(v).filter(|n| range.contains(n)) +} + /// Custom (`Cstm`): `Mtrx` is the 5 × 5 kernel, row by row. Photoshop takes exactly 25 cells (a -/// 3 × 3 list makes it fail), so any other matrix keeps the Photoshop step, as does a `Scl ` or -/// `Ofst` that is not a number. +/// 3 × 3 list makes it fail), so any other matrix keeps the Photoshop step, as does a `Scl `, +/// `Ofst` or cell that is not a number, or one outside the command's range (it would play +/// clamped). pub(super) fn custom_from(d: &Descriptor) -> Mapped { let unsupported = Mapped::Unsupported { harmless: false }; if !only_keys(d, &["Scl", "Ofst", "Mtrx"]) { return unsupported; } - let Some(cells) = get_list(d, "Mtrx") else { return unsupported }; - let kernel: Option> = cells.iter().map(num).collect(); - let Some(kernel) = kernel.filter(|k| k.len() == 25) else { return unsupported }; - let (Some(scale), Some(offset)) = (setting(d, "Scl ", num, 1.0), setting(d, "Ofst", num, 0.0)) else { return unsupported }; + let Some(cells) = get_list(d, "Mtrx").filter(|c| c.len() == 25) else { return unsupported }; + let Some(kernel) = cells.iter().map(within(&CUSTOM_CELL)).collect::>>() else { return unsupported }; + let (Some(scale), Some(offset)) = (setting(d, "Scl ", within(&CUSTOM_SCALE), 1.0), setting(d, "Ofst", within(&CUSTOM_OFFSET), 0.0)) else { + return unsupported; + }; let mut p = Map::new(); p.insert("kernel".into(), json!(kernel)); p.insert("scale".into(), scale.into()); @@ -104,30 +118,31 @@ pub(super) fn custom_from(d: &Descriptor) -> Mapped { } /// `Scl `, `Ofst` and the 25 cells as integers; a kernel left out is the command's identity -/// (1 in the middle). A fraction anywhere refuses the step: it would replay rounded. +/// (1 in the middle). A fraction anywhere refuses the step: it would replay rounded; so does a +/// value outside the command's range, which it doesn't play. pub(super) fn custom_to(_: &str, p: &Value) -> Option { if !pixel_target(p) || !sets_only(p, &["target", "kernel", "scale", "offset"]) { return None; } - let int = |v: &Value| { + let int = |range: &RangeInclusive, v: &Value| { let n = v.as_f64()?; - (n.fract() == 0.0 && (f64::from(i32::MIN)..=f64::from(i32::MAX)).contains(&n)).then_some(n as i32) + (n.fract() == 0.0 && range.contains(&n)).then_some(n as i32) }; - let field = |k: &str, default: i32| match p.get(k) { + let field = |k: &str, range: &RangeInclusive, default: i32| match p.get(k) { None | Some(Value::Null) => Some(default), - Some(v) => int(v), + Some(v) => int(range, v), }; let cells: Vec = match p.get("kernel") { None | Some(Value::Null) => (0..25).map(|i| i32::from(i == 12)).collect(), - Some(v) => v.as_array()?.iter().map(int).collect::>()?, + Some(v) => { + let a = v.as_array().filter(|a| a.len() == 25)?; + a.iter().map(|c| int(&CUSTOM_CELL, c)).collect::>()? + } }; - if cells.len() != 25 { - return None; - } Some( desc("null") - .with("Scl ", D::Integer(field("scale", 1)?)) - .with("Ofst", D::Integer(field("offset", 0)?)) + .with("Scl ", D::Integer(field("scale", &CUSTOM_SCALE, 1)?)) + .with("Ofst", D::Integer(field("offset", &CUSTOM_OFFSET, 0)?)) .with("Mtrx", D::List(cells.into_iter().map(D::Integer).collect())), ) } diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index a7c49c24b8..6ee39c6116 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -3333,3 +3333,39 @@ fn custom_reads_an_unpadded_scale() { let Mapped::Steps(steps) = other_step("Cstm", desc("null").with("Scl", D::Integer(3)).with("Mtrx", identity)) else { panic!("kept") }; assert_eq!(steps[0].1["scale"], json!(3.0)); } + +/// `filter.other.custom` plays a scale of 1..9999, an offset of -9999..9999 and cells of +/// -999..999, and clamps anything else: `Scl 0` would play as 1 and save back as 0. Such a step +/// stays a Photoshop step, as does a matrix of more than 25 cells, and export refuses those +/// values instead of writing what the command doesn't play (#10607). +#[test] +fn custom_outside_the_commands_range_is_kept_and_refused() { + let kept = |m: Mapped| matches!(m, Mapped::Unsupported { harmless: false }); + let kernel = |cell: i32| D::List((0..25).map(|i| D::Integer(if i == 12 { cell } else { 0 })).collect()); + let identity = kernel(1); + for d in [ + desc("null").with("Mtrx", identity.clone()).with("Scl ", D::Integer(0)), + desc("null").with("Mtrx", identity.clone()).with("Scl ", D::Integer(10_000)), + desc("null").with("Mtrx", identity.clone()).with("Ofst", D::Integer(-10_000)), + desc("null").with("Mtrx", identity.clone()).with("Ofst", D::Integer(10_000)), + desc("null").with("Mtrx", kernel(1000)), + desc("null").with("Mtrx", kernel(-1000)), + desc("null").with("Mtrx", D::List((0..26).map(|_| D::Integer(0)).collect())), + desc("null").with("Mtrx", D::List((0..1000).map(|_| D::Integer(0)).collect())), + ] { + assert!(kept(other_step("Cstm", d.clone())), "{:?} was mapped", d.items); + } + for (scale, offset, cell) in [(1, -9999, -999), (9999, 9999, 999)] { + let d = desc("null").with("Scl ", D::Integer(scale)).with("Ofst", D::Integer(offset)).with("Mtrx", kernel(cell)); + let Mapped::Steps(steps) = other_step("Cstm", d) else { panic!("{scale} {offset} {cell} kept") }; + assert_eq!( + (&steps[0].1["scale"], &steps[0].1["offset"], &steps[0].1["kernel"][12]), + (&json!(f64::from(scale)), &json!(f64::from(offset)), &json!(f64::from(cell))) + ); + } + let mut cells = vec![json!(0); 25]; + cells[12] = json!(1000); + for params in [json!({"scale": 0}), json!({"scale": 10_000}), json!({"offset": -10_000}), json!({"offset": 10_000}), json!({"kernel": cells})] { + assert!(export_item("filter.other.custom", ¶ms, "S").is_none(), "{params} exported"); + } +} From 72eb3a04511d5ea713381149c3f691e7234636f7 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sun, 1 Nov 2026 02:59:32 -0600 Subject: [PATCH 076/216] Actions: test every Filter Gallery effect's defaults round trip and match the command's schema (FredApps review) Co-Authored-By: Claude Opus 5.5 --- .../engine/src/cmd/atn_cmds/steps_gallery.rs | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/crates/engine/src/cmd/atn_cmds/steps_gallery.rs b/crates/engine/src/cmd/atn_cmds/steps_gallery.rs index c5486e0143..49b6168c7f 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_gallery.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_gallery.rs @@ -601,3 +601,57 @@ pub(super) fn gallery_to(id: &str, p: &Value) -> Option { } Some(d) } + +#[cfg(test)] +mod tests { + use super::*; + use photocraft_algo::GalleryParamKind; + + fn params(m: Mapped) -> (String, Value) { + match m { + Mapped::Steps(mut s) if s.len() == 1 => s.remove(0), + _ => panic!("not one step"), + } + } + + /// Every effect recorded with nothing but `GEfk` loads with Photoshop's defaults, saves + /// back, and loads again as the same step (#10592). + #[test] + fn every_gallery_effect_with_its_defaults_round_trips() { + assert_eq!(GALLERY.len(), GALLERY_IDS.len()); + for (code, id, _) in GALLERY { + assert!(GALLERY_IDS.contains(id), "{id} is not in GALLERY_IDS"); + let (got, first) = params(gallery_from(&desc("null").with("GEfk", enm("GEft", code)))); + assert_eq!(got, *id, "{code}"); + let back = gallery_to(id, &first).unwrap_or_else(|| panic!("{id} {first} does not export")); + assert_eq!(params(gallery_from(&back)), (id.to_string(), first.clone()), "{id}"); + } + } + + /// Each effect's integer limits and defaults, enumeration default and Boolean default are + /// the command's own (`GalleryFilter`'s schema), and every setting of the schema has a key. + #[test] + fn every_gallery_effect_matches_the_commands_schema() { + for (code, id, keys) in GALLERY { + let schema = GalleryFilter::from_key(id).unwrap_or_else(|| panic!("{id} has no schema")).params(); + for p in &schema { + assert!(keys.iter().any(|k| k.param() == p.key), "{id}: {} has no key", p.key); + } + for k in keys.iter() { + let kind = schema.iter().find(|p| p.key == k.param()).map(|p| &p.kind); + match (k, kind) { + (G::Int(_, param, min, max, default), Some(GalleryParamKind::Range { min: lo, max: hi, default: d })) => { + assert_eq!((*min as f32, *max as f32, *default as f32), (*lo, *hi, *d), "{id} {param}"); + } + (G::Enum(_, param, table, default), Some(GalleryParamKind::Choice(choices))) => { + assert_eq!(lookup(table, default), choices.first().copied(), "{id} {param}"); + assert!(table.iter().all(|(_, ours)| choices.contains(ours)), "{id} {param}"); + } + (G::Bool(_, param, default), Some(GalleryParamKind::Bool(d))) => assert_eq!(default, d, "{id} {param}"), + (G::Color(..), None) => {} + (k, kind) => panic!("{code} {id} {}: {kind:?}", k.param()), + } + } + } + } +} From d7bedfdd9748d60b69fbff7209fe9bc5e0a806b9 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sun, 1 Nov 2026 03:08:49 -0600 Subject: [PATCH 077/216] Actions: keep an Oil Paint step outside the command's ranges (FredApps review) filter.stylize.oilPaint takes a stylization and scale of 0.1..10 and a cleanliness, bristle detail and shine of 0..10, and clamps anything else when it plays. The importer accepted any finite value, so such a step played clamped and saved back unchanged. It now keeps the Photoshop step, and export refuses those values instead of writing them. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/atn_cmds/steps_image.rs | 23 ++++++-- crates/engine/src/cmd/atn_cmds/tests.rs | 53 +++++++++++++++++++ 2 files changed, 71 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/atn_cmds/steps_image.rs b/crates/engine/src/cmd/atn_cmds/steps_image.rs index 91c4b20bfa..d6e98a744e 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_image.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_image.rs @@ -538,9 +538,21 @@ const OIL_PAINT: &[(&str, &str, f64, f64)] = &[ ("microBrush", "bristleDetail", 10.0, 5.0), ]; +/// `filter.stylize.oilPaint`'s ranges. The command clamps a value outside them when it plays, +/// so such a step keeps the Photoshop step on import and is refused on export, rather than +/// mapping a step that plays clamped and saves back unchanged. +const OIL_PAINT_LIMITS: &[(&str, f64, f64)] = + &[("stylization", 0.1, 10.0), ("cleanliness", 0.0, 10.0), ("scale", 0.1, 10.0), ("bristleDetail", 0.0, 10.0), ("shine", 0.0, 10.0)]; + +/// Whether `n` is within `filter.stylize.oilPaint`'s range for `param` (a param without one is). +fn in_oil_paint_limits(param: &str, n: f64) -> bool { + OIL_PAINT_LIMITS.iter().all(|(p, min, max)| *p != param || (*min..=*max).contains(&n)) +} + /// Oil Paint (`oilPaint`). Missing keys take Photoshop's defaults, which differ from the /// command's for cleanliness, scale, bristle detail and shine, so they are written out. A -/// setting of another number type, or with a unit, keeps the Photoshop step. +/// setting of another number type, with a unit, or outside the command's ranges keeps the +/// Photoshop step. pub(super) fn oil_paint_from(d: &Descriptor) -> Mapped { match oil_paint_params(d) { Some(p) => step("filter.stylize.oilPaint", Value::Object(p)), @@ -556,22 +568,23 @@ fn oil_paint_params(d: &Descriptor) -> Option> { let mut p = Map::new(); p.insert("lighting".into(), setting(d, "lightingOn", boolean, true)?.into()); for (key, param, ps, _) in OIL_PAINT { - p.insert((*param).into(), setting(d, key, double, *ps)?.into()); + p.insert((*param).into(), setting(d, key, double, *ps).filter(|n| in_oil_paint_limits(param, *n))?.into()); } p.insert("angle".into(), setting(d, "LghD", |v| integer(v).map(f64::from), -60.0)?.into()); - p.insert("shine".into(), setting(d, "specularity", double, 1.299_999_952_316_28)?.into()); + p.insert("shine".into(), setting(d, "specularity", double, 1.299_999_952_316_28).filter(|n| in_oil_paint_limits("shine", *n))?.into()); Some(p) } /// Every key in recorded order, the command's defaults for what the step leaves out. The light -/// direction is an integer: a fractional angle would replay rounded, so the step is refused. +/// direction is an integer: a fractional angle would replay rounded, so the step is refused, as +/// is a setting outside the command's ranges. pub(super) fn oil_paint_to(_: &str, p: &Value) -> Option { if !pixel_target(p) || !sets_only(p, &["target", "lighting", "stylization", "cleanliness", "scale", "bristleDetail", "angle", "shine"]) { return None; } let num = |k: &str, default: f64| match p.get(k) { None | Some(Value::Null) => Some(default), - Some(v) => v.as_f64().filter(|n| n.is_finite()), + Some(v) => v.as_f64().filter(|n| n.is_finite() && in_oil_paint_limits(k, *n)), }; let lighting = match p.get("lighting") { None | Some(Value::Null) => true, diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index 9cfa53d83e..b0849d3254 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -3434,3 +3434,56 @@ fn newer_feature_selection_steps_play() { assert!(played.get("failed").is_none(), "{played}"); assert_eq!(played["ran"], 3, "{played}"); } + +/// `filter.stylize.oilPaint` takes a stylization and scale of 0.1..10 and a cleanliness, bristle +/// detail and shine of 0..10, and clamps anything else when it plays. A step outside them would +/// play clamped and save back unchanged, so it stays a Photoshop step, and a step outside them +/// is not written either (#10610). +#[test] +fn oil_paint_outside_the_commands_ranges_is_kept_and_refused() { + kept_when_wrong_typed( + "oilPaint", + &[ + ("stylization", D::Double(0.05)), + ("stylization", D::Double(10.5)), + ("cleanliness", D::Double(-0.5)), + ("cleanliness", D::Double(10.5)), + ("brushScale", D::Double(0.05)), + ("brushScale", D::Double(10.5)), + ("microBrush", D::Double(-1.0)), + ("microBrush", D::Double(10.5)), + ("specularity", D::Double(-0.5)), + ("specularity", D::Double(10.5)), + ], + ); + for (low, high) in [(0.1, 0.0), (10.0, 10.0)] { + let d = desc("null") + .with("stylization", D::Double(low)) + .with("cleanliness", D::Double(high)) + .with("brushScale", D::Double(low)) + .with("microBrush", D::Double(high)) + .with("specularity", D::Double(high)); + assert_eq!( + mapped(newer("oilPaint", d)), + ( + "filter.stylize.oilPaint".into(), + json!({"lighting": true, "stylization": low, "cleanliness": high, "scale": low, "bristleDetail": high, "angle": -60.0, "shine": high}) + ), + "{low} {high}" + ); + } + for params in [ + json!({"stylization": 0.05}), + json!({"stylization": 10.5}), + json!({"cleanliness": -0.5}), + json!({"cleanliness": 10.5}), + json!({"scale": 0.05}), + json!({"scale": 10.5}), + json!({"bristleDetail": -1}), + json!({"bristleDetail": 11}), + json!({"shine": -0.5}), + json!({"shine": 10.5}), + ] { + assert!(export_item("filter.stylize.oilPaint", ¶ms, "S").is_none(), "{params} exported"); + } +} From e1fb19f4d033a5652c6c9f817f30719c7b664720 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sun, 1 Nov 2026 03:19:57 -0600 Subject: [PATCH 078/216] Actions: keep a Smart Sharpen, Oil Paint or Select Sky step that records a key twice (CodeRabbit review on steps_image.rs:629) A descriptor may hold the same key more than once. `only_keys` admitted both and `setting` read the first, so a Smart Sharpen step with two `Amnt` (or `Rds` and `Rds `) played the first and saved back one. Oil Paint and Select Sky read through the same `setting`. It now treats a key recorded more than once, compared without its space padding, as a value it can't take, so the step stays a Photoshop step (as #10629 does for Distort). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/atn_cmds/steps_image.rs | 11 ++++--- crates/engine/src/cmd/atn_cmds/tests.rs | 30 +++++++++++++++++++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/atn_cmds/steps_image.rs b/crates/engine/src/cmd/atn_cmds/steps_image.rs index d6e98a744e..7ae8b37328 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_image.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_image.rs @@ -463,11 +463,14 @@ fn only_keys(d: &Descriptor, keys: &[&str]) -> bool { /// The step's `key` read by `read` (its space padding ignored, as `only_keys` admits it), /// `default` when the step leaves it out, `None` when it holds a value `read` can't take: that -/// is still the step's setting, so the step is kept rather than played with the default. +/// is still the step's setting, so the step is kept rather than played with the default. A key +/// recorded more than once is `None` too: the step saves one value, so it would drop the other. fn setting(d: &Descriptor, key: &str, read: impl Fn(&D) -> Option, default: T) -> Option { - match d.items.iter().find(|(k, _)| trimmed(k) == key.trim_end()) { - Some((_, v)) => read(v), - None => Some(default), + let mut found = d.items.iter().filter(|(k, _)| trimmed(k) == key.trim_end()); + match (found.next(), found.next()) { + (Some((_, v)), None) => read(v), + (None, _) => Some(default), + (Some(_), Some(_)) => None, } } diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index b0849d3254..3885c28acb 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -3333,6 +3333,36 @@ fn smart_sharpen_reads_an_unpadded_radius() { assert_eq!(mapped(newer("smartSharpen", d)), ("filter.sharpen.smartSharpen".into(), json!({"amount": 150.0, "radius": 2.5, "reduceNoise": 0}))); } +/// A step recording one of its keys twice, padded or not, can't be saved back with both values, +/// so it stays a Photoshop step instead of playing the first and dropping the second (#10610). +#[test] +fn newer_feature_steps_with_a_repeated_key_are_kept() { + let kept = |m: Mapped| matches!(m, Mapped::Unsupported { harmless: false }); + let mapped = [ + ("smartSharpen", "Amnt", "Amnt", pct(150.0), pct(250.0)), + ("smartSharpen", "Rds ", "Rds ", px(2.0), px(3.0)), + ("smartSharpen", "Rds ", "Rds", px(2.0), px(3.0)), + ("smartSharpen", "Rds", "Rds", px(2.0), px(2.0)), + ("smartSharpen", "presetKind", "presetKind", enm("presetKindType", "presetKindCustom"), enm("presetKindType", "presetKindCustom")), + ("smartSharpen", "useLegacy", "useLegacy", D::Boolean(true), D::Boolean(true)), + ("smartSharpen", "moreAccurate", "moreAccurate", D::Boolean(false), D::Boolean(false)), + ("smartSharpen", "blur", "blur", enm("blurType", "lensBlur"), enm("blurType", "lensBlur")), + ("oilPaint", "lightingOn", "lightingOn", D::Boolean(true), D::Boolean(false)), + ("oilPaint", "stylization", "stylization", D::Double(4.0), D::Double(6.0)), + ("oilPaint", "cleanliness", "cleanliness", D::Double(2.0), D::Double(3.0)), + ("oilPaint", "brushScale", "brushScale", D::Double(1.0), D::Double(2.0)), + ("oilPaint", "microBrush", "microBrush", D::Double(5.0), D::Double(7.0)), + ("oilPaint", "LghD", "LghD", D::Integer(-60), D::Integer(45)), + ("oilPaint", "specularity", "specularity", D::Double(1.0), D::Double(2.0)), + ("selectSky", "sampleAllLayers", "sampleAllLayers", D::Boolean(false), D::Boolean(true)), + ] + .into_iter() + .filter(|(name, key, again, a, b)| !kept(newer(name, desc("null").with(key, a.clone()).with(again, b.clone())))) + .map(|(name, key, again, ..)| format!("{name} {key:?} {again:?}")) + .collect::>(); + assert!(mapped.is_empty(), "mapped with a repeated key: {mapped:?}"); +} + /// Oil Paint records its settings as plain doubles and the light direction as an integer, and /// saves them back so: a value with a unit, or of the other number type, would load as a bare /// number and save back changed, so the step stays a Photoshop step (#10610). From df7e673309e98a71217f30cecf93c3348f582857 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sun, 1 Nov 2026 03:23:25 -0600 Subject: [PATCH 079/216] Actions: check that Focus Area, Select Sky and Remove Background steps do their work (CodeRabbit review on tests.rs:3433) The playback test counted three steps run, which a step that succeeds without changing anything also passes (Select Sky reports "changed": false when it finds no sky). Each step now plays on its own over a smooth blue sky above textured ground, with a textured disc: Focus Area must select the ground and not the sky, Select Sky the sky and not the ground, and Remove Background must mask the layer. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/atn_cmds/tests.rs | 44 +++++++++++++++++++------ 1 file changed, 34 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index 3885c28acb..9a1059b431 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -3434,10 +3434,12 @@ fn smart_sharpen_outside_the_commands_limits_is_kept_and_refused() { } /// Focus Area, Select Sky and Remove Background, mapped from the empty steps Photoshop records, -/// play: each step runs its command on a red disc on a blue ground (#10610). +/// play and do their work: on a textured red disc against a smooth blue sky over textured ground, +/// Focus Area selects the textured ground, Select Sky the sky, and Remove Background masks the +/// layer. Each step plays on its own so a step that runs without finding anything fails (#10610). #[test] fn newer_feature_selection_steps_play() { - let (w, h) = (96u32, 80u32); + let (w, h) = (160u32, 100u32); let mut s = Session::new(); s.execute("file.new", json!({"width": w, "height": h})).unwrap(); s.edit("paint", |doc, _| { @@ -3446,9 +3448,17 @@ fn newer_feature_selection_steps_play() { let mut data = Vec::with_capacity((w * h) as usize * n); for y in 0..h { for x in 0..w { - let (dx, dy) = (x as f32 - 48.0, y as f32 - 40.0); - let c = if dx * dx + dy * dy <= 22.0 * 22.0 { [0.85, 0.2, 0.15] } else { [0.25, 0.45, 0.7] }; - data.extend_from_slice(&c[..n.min(3)]); + let nz = (((x * 7919 + y * 104_729) % 97) as f32 / 97.0 - 0.5) * 0.25; + let (dx, dy) = (x as f32 - 80.0, y as f32 - 40.0); + let c = if dx * dx + dy * dy <= 22.0 * 22.0 { + [0.85 + nz * 0.5, 0.2 + nz, 0.15] + } else if y < 60 { + let t = y as f32 / 60.0; + [0.25 + 0.4 * t, 0.5 + 0.3 * t, 0.9 + 0.05 * t] + } else { + [0.35 + nz, 0.3 + nz, 0.15] + }; + data.extend_from_slice(&c.map(|v| v.clamp(0.0, 1.0))[..n.min(3)]); if n == 4 { data.push(1.0); } @@ -3458,11 +3468,25 @@ fn newer_feature_selection_steps_play() { Ok(()) }) .unwrap(); - let steps = ["focusMask", "selectSky", "removeBackground"].map(|name| mapped(newer(name, desc("null")))); - s.actions.list.push(Action::new("Select", steps.to_vec())); - let played = s.execute("actions.play", json!({"action": "Select"})).unwrap(); - assert!(played.get("failed").is_none(), "{played}"); - assert_eq!(played["ran"], 3, "{played}"); + let play = |s: &mut Session, name: &str| { + s.actions.list.push(Action::new(name, vec![mapped(newer(name, desc("null")))])); + let played = s.execute("actions.play", json!({"action": name})).unwrap(); + assert!(played.get("failed").is_none(), "{name}: {played}"); + assert_eq!(played["ran"], 1, "{name}: {played}"); + }; + let selected = |s: &Session, x: i32, y: i32| s.active().unwrap().doc.selection.as_ref().map(|m| m.sample_channel(x, y, 0)); + + play(&mut s, "focusMask"); + assert!(selected(&s, 20, 85).is_some_and(|v| v > 0.5), "Focus Area left the textured ground out"); + assert!(selected(&s, 20, 15).is_some_and(|v| v < 0.5), "Focus Area took the smooth sky"); + s.execute("select.deselect", json!({})).unwrap(); + + play(&mut s, "selectSky"); + assert!(selected(&s, 20, 15).is_some_and(|v| v > 0.9), "Select Sky left the sky out"); + assert!(selected(&s, 20, 85).is_some_and(|v| v < 0.1), "Select Sky took the ground"); + + play(&mut s, "removeBackground"); + assert!(s.active().unwrap().doc.layers[0].mask.is_some(), "Remove Background set no mask"); } /// `filter.stylize.oilPaint` takes a stylization and scale of 0.1..10 and a cleanliness, bristle From f2e7ced6c2d594cdbb732e099db6ba003c8300bb Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sun, 1 Nov 2026 03:47:25 -0600 Subject: [PATCH 080/216] Actions: check the Remove Background mask's values in the playback test (CodeRabbit review on tests.rs:3489) Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/atn_cmds/tests.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index 9a1059b431..af87e815d5 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -3436,7 +3436,8 @@ fn smart_sharpen_outside_the_commands_limits_is_kept_and_refused() { /// Focus Area, Select Sky and Remove Background, mapped from the empty steps Photoshop records, /// play and do their work: on a textured red disc against a smooth blue sky over textured ground, /// Focus Area selects the textured ground, Select Sky the sky, and Remove Background masks the -/// layer. Each step plays on its own so a step that runs without finding anything fails (#10610). +/// layer to the disc. Each step plays on its own so a step that runs without finding anything fails +/// (#10610). #[test] fn newer_feature_selection_steps_play() { let (w, h) = (160u32, 100u32); @@ -3486,7 +3487,9 @@ fn newer_feature_selection_steps_play() { assert!(selected(&s, 20, 85).is_some_and(|v| v < 0.1), "Select Sky took the ground"); play(&mut s, "removeBackground"); - assert!(s.active().unwrap().doc.layers[0].mask.is_some(), "Remove Background set no mask"); + let mask = s.active().unwrap().doc.layers[0].mask.as_ref().expect("Remove Background set no mask"); + assert!(mask.value(80, 40) > 0.8, "Remove Background hid the red disc"); + assert!(mask.value(20, 15) < 0.2, "Remove Background revealed the sky"); } /// `filter.stylize.oilPaint` takes a stylization and scale of 0.1..10 and a cleanliness, bristle From 8988b6bfd08887a312c199f4ed9c37f595f75736 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Sun, 1 Nov 2026 05:25:26 -0600 Subject: [PATCH 081/216] Actions: play Remove Background with no selection in the playback test (CodeRabbit review on tests.rs:3491) Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/atn_cmds/tests.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index af87e815d5..8495847dea 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -3436,7 +3436,8 @@ fn smart_sharpen_outside_the_commands_limits_is_kept_and_refused() { /// Focus Area, Select Sky and Remove Background, mapped from the empty steps Photoshop records, /// play and do their work: on a textured red disc against a smooth blue sky over textured ground, /// Focus Area selects the textured ground, Select Sky the sky, and Remove Background masks the -/// layer to the disc. Each step plays on its own so a step that runs without finding anything fails +/// layer to the disc. Each step plays on its own, with nothing selected, so a step that runs +/// without finding anything, or that works from the selection left by the step before, fails /// (#10610). #[test] fn newer_feature_selection_steps_play() { @@ -3485,6 +3486,8 @@ fn newer_feature_selection_steps_play() { play(&mut s, "selectSky"); assert!(selected(&s, 20, 15).is_some_and(|v| v > 0.9), "Select Sky left the sky out"); assert!(selected(&s, 20, 85).is_some_and(|v| v < 0.1), "Select Sky took the ground"); + s.execute("select.deselect", json!({})).unwrap(); + assert!(s.active().unwrap().doc.selection.is_none(), "Remove Background would play with the sky selected"); play(&mut s, "removeBackground"); let mask = s.active().unwrap().doc.layers[0].mask.as_ref().expect("Remove Background set no mask"); From a23b30edd299f53801d564151cedb55b5497e668 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 1 Nov 2026 13:49:32 +0100 Subject: [PATCH 082/216] Docs (atn): known gaps in mapped events; fix the adjustment rows of the supported table Every mapped event was checked against the reference application: the corpus sets' 5 296 recorded steps and factory descriptors went through Load and Save Actions and were compared with the original, and the forms PhotoCraft writes were played there. The new section lists the settings mapped events still lose (transform reference point, show only this layer, group names, clipping and colour labels on Make, shape layers, layer via copy options, load selection from another layer, Background by property, Close that asks, Lab Photo Filter colours) and the settings the commands do not have yet. The supported table showed 15 direct adjustment events with shifted columns. Co-Authored-By: Claude Opus 5.5 --- docs/atn.md | 81 ++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 65 insertions(+), 16 deletions(-) diff --git a/docs/atn.md b/docs/atn.md index 0d280e147c..3f273a7591 100644 --- a/docs/atn.md +++ b/docs/atn.md @@ -13,8 +13,9 @@ it back unchanged. Playback stops at an enabled kept step with its name, so an a work. Kept steps with no effect on the document (tool selections and the like) are imported switched off, so playback skips them. -This page has two parts: the events PhotoCraft maps today, and a measured reference for the events it -does not map yet, with everything needed to implement them without the reference application. +This page has three parts: the events PhotoCraft maps today, the settings some of them still lose, and a +measured reference for the events it does not map yet, with everything needed to implement them without +the reference application. ## Supported events (117) @@ -27,19 +28,19 @@ does not map yet, with everything needed to implement them without the reference | (string ID only) | applyLocking | Apply Locking | `layer.lockLayers` | | (string ID only) | assignProfile | Assign Profile | `edit.assignProfile` | | (string ID only) | autoColor | Auto Color | `image.autoColor` | -| (string ID only) | BanW | blackAndWhite | `Black & White` | +| (string ID only) | blackAndWhite | Black & White | `image.adjustments.blackWhite` | | `Blr ` | blur | Blur | `filter.blur.blur` | | `BlrM` | blurMore | Blur More | `filter.blur.blurMore` | | `Brdr` | border | Border | `select.modify.border` | | (string ID only) | boxblur | Box Blur | `filter.blur.boxBlur` | -| `BrgC` | BrgC | brightnessEvent | `Brightness/Contrast` | +| `BrgC` | brightnessEvent | Brightness/Contrast | `image.adjustments.brightnessContrast` | | `CnvS` | canvasSize | Canvas Size | `image.canvasSize` | -| `ChnM` | ChnM | channelMixer | `Channel Mixer` | +| `ChnM` | channelMixer | Channel Mixer | `image.adjustments.channelMixer` | | (string ID only) | clearEvent | Clear | `select.editInQuickMaskMode`, `history.clear` | | `Cls ` | close | Close | `file.close` | | (string ID only) | collapseAllGroupsEvent | Collapse All Groups | `layer.setExpanded` | +| `ClrB` | colorBalance | Color Balance | `image.adjustments.colorBalance` | | `ClrR` | colorRange | Color Range | `select.colorRange` | -| `ClrB` | ClrB | colorBalance | `Color Balance` | | `Cntc` | contract | Contract | `select.modify.contract` | | `CnvM` | convertMode | Convert Mode | `image.mode.rgb`, `image.mode.grayscale`, `image.mode.cmyk`, `image.mode.lab`, `image.mode.multichannel`, `image.mode.bits8`, `image.mode.bits16`, `image.mode.bits32`, `image.mode.bitmap` | | (string ID only) | convertToProfile | Convert to Profile | `edit.convertToProfile` | @@ -50,7 +51,7 @@ does not map yet, with everything needed to implement them without the reference | `GrpL` | groupEvent | Create Clipping Mask | `layer.createClippingMask` | | `Crop` | crop | Crop | `image.crop` | | `Crst` | crystallize | Crystallize | `filter.pixelate.crystallize` | -| `Crvs` | Crvs | curves | `Curves` | +| `Crvs` | curves | Curves | `image.adjustments.curves` | | `cut ` | cut | Cut | `edit.cut` | | `Dfrg` | defringe | Defringe | `layer.matting.defringe` | | `Dlt ` | delete | Delete | `layer.delete`, `edit.clear`, `layer.layerMask.delete`, `layer.smartFilter.deleteFilterMask` | @@ -66,22 +67,22 @@ does not map yet, with everything needed to implement them without the reference | `Exch` | exchange | Exchange | `tools.swapColors` | | `Expn` | expand | Expand | `select.modify.expand` | | `Expr` | export | Export | `file.export.saveForWebLegacy` | -| `Exps` | Exps | exposure | `Exposure` | +| `Exps` | exposure | Exposure | `image.adjustments.exposure` | | `Fthr` | feather | Feather | `select.modify.feather` | | `Fl ` | fill | Fill | `edit.fill`, `paint.bucket` | | `GEfc` | filterGallery | Filter Gallery | `filter.gallery.spatter` | | `FltI` | flattenImage | Flatten Image | `layer.flattenImage` | | `Flip` | flip | Flip | `image.imageRotation.flipCanvasHorizontal`, `image.imageRotation.flipCanvasVertical`, `edit.transform.flipHorizontal`, `edit.transform.flipVertical` | | `GsnB` | gaussianBlur | Gaussian Blur | `filter.blur.gaussianBlur` | -| `GdMp` | GdMp | gradientMapEvent | `Gradient Map` | +| `GdMp` | gradientMapEvent | Gradient Map | `image.adjustments.gradientMap` | | `Grow` | grow | Grow | `select.grow` | | `Hd ` | hide | Hide | `layer.hideLayers` | | `HghP` | highPass | High Pass | `filter.other.highPass` | -| `HStr` | HStr | hueSaturation | `Hue/Saturation` | +| `HStr` | hueSaturation | Hue/Saturation | `image.adjustments.hueSaturation` | | (string ID only) | conditional | If | `actions.conditional` | | `ImgS` | imageSize | Image Size | `image.imageSize` | | `Invs` | inverse | Inverse | `select.inverse` | -| `Invr` | Invr | invert | `Invert` | +| `Invr` | invert | Invert | `image.adjustments.invert` | | `CpTL` | copyToLayer | Layer Via Copy | `layer.new.layerViaCopy` | | `CtTL` | cutToLayer | Layer Via Cut | `layer.new.layerViaCut` | | `Lvls` | levels | Levels | `image.adjustments.levels`, `image.autoTone`, `image.autoContrast` | @@ -98,10 +99,10 @@ does not map yet, with everything needed to implement them without the reference | `Opn ` | open | Open | `file.openAs` | | `past` | paste | Paste | `edit.paste`, `edit.pasteSpecial.pasteInPlace` | | (string ID only) | pasteEffects | Paste Layer Style | `layer.layerStyle.pasteLayerStyle` | -| (string ID only) | PhtF | photoFilter | `Photo Filter` | +| (string ID only) | photoFilter | Photo Filter | `image.adjustments.photoFilter` | | `Plc ` | placeEvent | Place | `file.placeEmbedded`, `file.placeLinked` | | `Ply ` | play | Play | `actions.play` | -| `Pstr` | Pstr | posterize | `Posterize` | +| `Pstr` | posterize | Posterize | `image.adjustments.posterize` | | `Prge` | purge | Purge | `edit.purge.all`, `edit.purge.clipboard`, `edit.purge.histories`, `edit.purge.undo` | | `RdlB` | radialBlur | Radial Blur | `filter.blur.radialBlur` | | (string ID only) | rasterizeLayer | Rasterize | `layer.rasterize.layer` | @@ -117,7 +118,7 @@ does not map yet, with everything needed to implement them without the reference | (string ID only) | refineSelectionEdge | Select and Mask | `select.refineEdge` | | (string ID only) | smartBrushWorkspace | Select and Mask | `select.refineEdge` | | (string ID only) | autoCutout | Select Subject | `select.subject` | -| `SlcC` | SlcC | selectiveColor | `Selective Color` | +| `SlcC` | selectiveColor | Selective Color | `image.adjustments.selectiveColor` | | `setd` | set | Set | `select.all`, `select.deselect`, `select.reselect`, `select.loadSelection`, `select.rect`, `select.lasso`, `select.editInQuickMaskMode`, `tools.setColors`, `layer.setProps`, `layer.renameLayer`, `layer.setLabelColor`, `layer.setAdjustment`, `layer.fill.pattern.set`, `layer.new.layerFromBackground`, `layer.layerStyle.blendingOptions`, `layer.smartFilter.blendingOptions` | | (string ID only) | adaptCorrect | Shadows/Highlights | `image.adjustments.shadowsHighlights` | | `Shrp` | sharpen | Sharpen | `filter.sharpen.sharpen` | @@ -130,13 +131,61 @@ does not map yet, with everything needed to implement them without the reference | `Stop` | stop | Stop | `STOP_MESSAGE` | | `Strk` | stroke | Stroke | `edit.stroke` | | (string ID only) | surfaceBlur | Surface Blur | `filter.blur.surfaceBlur` | -| `Thrs` | Thrs | threshold | `Threshold` | +| `Thrs` | threshold | Threshold | `image.adjustments.threshold` | | `Trnf` | transform | Transform | `edit.transform`, `select.transformSelection` | | (string ID only) | trim | Trim | `image.trim` | | (string ID only) | ungroupLayersEvent | Ungroup Layers | `layer.ungroupLayers` | | `UnsM` | unsharpMask | Unsharp Mask | `filter.sharpen.unsharpMask` | | (string ID only) | updatePlacedLayer | Update Smart Objects | `layer.smartObjects.updateModifiedContent` | -| `Vbrn` | Vbrn | vibrance | `Vibrance` | +| `Vbrn` | vibrance | Vibrance | `image.adjustments.vibrance` | + +## Known gaps in mapped events + +A mapped event can still lose a setting. Every mapped event was checked on 2026-11-01: the 5 296 steps of +the `.atn` corpus (sets the reference application recorded) and factory descriptors measured in the +reference application went through `actions.load` and `actions.save`, and each saved descriptor was compared +with the original; the forms PhotoCraft writes were then played in the reference application. The steps +below change what a step does. Each line says what is lost and what the fix is. + +### Import or export loses a setting (fix in `atn_cmds` only) + +| Event | What is lost | Fix | +|---|---|---| +| Transform (`Trnf`) | The reference point `FTcs` (`QCSt`: `Qcsa` centre, `Qcs0`..`Qcs8` corners and edges) is ignored on load, and save always writes `Qcsa`. A 50 % scale about the top-left corner of a 100 px square at 60,60 ends at 60,60..110,110 in the reference application, at 85,85..135,135 when played about the centre. | Read `FTcs` into the commands' `reference`; write it back. Pure offsets are unaffected. | +| Show (`Shw `) with `TglO` true | Alt-click on the eye ("show only this layer", a toggle: a second step restores the others) loads as a plain Show. | `TglO` true maps to `layer.showOnly`, and back. | +| Make `layerSection` (Group from Layers) | Newer recordings store the group name as a top-level `Nm ` (no `Usng`); only `Usng.Nm ` is read, so the group gets the default name. | Read the top-level `Nm ` too. | +| Make `Lyr ` (New Layer) | `Usng.Grup` true (Use Previous Layer to Create Clipping Mask) is dropped. | Map to `clipToPrevious`; export it (today such a step is refused). | +| Make `AdjL` (adjustment layer) | `Usng.Clr ` (the colour label) is dropped. | Map the label; export it. | +| Make `contentLayer` with `Shp ` (Rectangle tool shape layer, with `strokeStyle`) | Loads as a solid colour fill layer covering the canvas: the shape and its stroke settings are lost. | Map to the shape command, or keep the step as a kept step until it is mapped. | +| Make with `Nw ` + `Usng ArSl.Slct` (Layer via Copy / Cut with layer options) | Only the name is kept; `Md ` (blend mode), `Opct` and `Clr ` are dropped. | Carry them into the `layer.setProps` step that follows. | +| Set of the selection to `Chnl.Trsp` of a named layer (Cmd-click on another layer's thumbnail) | The layer in the reference is dropped, so the current layer's transparency is loaded. | Keep the layer in `select.loadSelection`; export it. | +| Background layer by property (`Lyr .Bckg`) | Becomes the layer name "Background" on load and is saved as a name reference. A localised reference application has no layer of that name ("Hintergrund" in German), and the step fails there. | Keep the property form: load as the Background layer, save as `Lyr .Bckg`. | +| Close (`Cls `) without `Svng` | Without `Svng` the reference application asks whether to save (without a dialog it refuses to close a changed document). Save writes `Svng N` (do not save), so playback there silently discards changes. 244 corpus steps. | Write no `Svng` for a Close that asks. | +| Photo Filter (`photoFilter`) with a `LbCl` colour | The reference application's default filter colour is Lab (`Lmnc`, `A `, `B `); only RGB, HSB and gray colours are read, so a recorded Photo Filter usually stays a kept step. | Convert Lab colours (through `photocraft-cms`) on load. | + +### The command has no such setting (needs a new command option first) + +| Event | Setting | Note | +|---|---|---| +| Image Size (`ImgS`) | `scaleStyles` | Measured: with `scaleStyles` true a 20 px stroke becomes 10 px at 50 %; with false or no key it stays 20 px. `image.imageSize` always scales effects, so a step without the key plays differently in PhotoCraft, and a saved step plays unscaled there. 27 corpus steps carry it. | +| Convert to Profile (`convertToProfile`) | `Dthr` (dither), `Fltt` (flatten), `rasterizePlaced`, `sdwM` | Dropped on load. | +| Fill (`Fl `) with Content-Aware | `contentAwareColorAdaptationFill`, `contentAwareRotateFill`, `contentAwareScaleFill`, `contentAwareMirrorFill` | Dropped on load. | +| Paste Layer Style (`pasteEffects`) | `allowPasteFXOnLayerSet` | Dropped on load. | +| Paste (`past`) | `As ` (paste a vector clipboard as pixels, path or shape) | Dropped on load. | + +### Differences that do not change the result + +- Value types: `long` and unit values are interchangeable for the reference application (Motion Blur + `Angl`, Dust & Scratches `Rds `, Apply Image `Ofst`: same pixels either way); Apply Image's `With` + object plays the same with class `null`. +- Save writes the 4-character event code where the file had the string ID, writes `null` as the + top-level class (the application writes the event code), and writes an empty descriptor for steps that + had none. +- Ids of the recording session (`LyrI`, `DocI`, `Idnt`, `layerSectionStart`, `layerSectionEnd`, `Vrsn`, + `forceNotify`) and default flags (`MkVs` false, `Adjs` false) are not written back. +- An adjustment layer's values equal to the defaults are not written back (other values are). +- Colours are kept as 8-bit values (51.0031 becomes 51); gradient names become "Custom". +- Levels and Curves write the composite as `Chnl.Cmps` (the application skips `Ordn.Trgt`, #8543). ## Event reference: events not mapped yet From fe3d8db7e36e92e03d1dac99f6ad9d761a748947 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 1 Nov 2026 14:55:29 +0100 Subject: [PATCH 083/216] Docs (atn): reference forms, newer events, version 12 files, wrong codes in the mapping - Known gaps: every exported step of the round-trip test was played in the reference application (128 of 151 ran); six codes the mapping writes are wrong there (Vibrance and Gradient Map events, Stroke Outside, Pass Through, canvas extension colour, Absolute Colorimetric), and a document offset wraps around there. - Reference forms: layers and documents by index, id, offset and ordinal, measured. - Newer events: 63 candidates from the menu catalogue run in the reference application; which need no settings, which were measured with settings, which need their dialog. - Version 12 files: the older descriptor layout, checked on 325 real sets. Co-Authored-By: Claude Opus 5.5 --- docs/atn.md | 157 ++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 152 insertions(+), 5 deletions(-) diff --git a/docs/atn.md b/docs/atn.md index 3f273a7591..e44aefe776 100644 --- a/docs/atn.md +++ b/docs/atn.md @@ -13,8 +13,9 @@ it back unchanged. Playback stops at an enabled kept step with its name, so an a work. Kept steps with no effect on the document (tool selections and the like) are imported switched off, so playback skips them. -This page has three parts: the events PhotoCraft maps today, the settings some of them still lose, and a -measured reference for the events it does not map yet, with everything needed to implement them without +This page covers the events PhotoCraft maps today, the settings some of them still lose, how steps name +layers and documents, newer and older events and files, and a measured reference for the events it does not +map yet, with everything needed to implement them without the reference application. ## Supported events (117) @@ -144,8 +145,12 @@ the reference application. A mapped event can still lose a setting. Every mapped event was checked on 2026-11-01: the 5 296 steps of the `.atn` corpus (sets the reference application recorded) and factory descriptors measured in the reference application went through `actions.load` and `actions.save`, and each saved descriptor was compared -with the original; the forms PhotoCraft writes were then played in the reference application. The steps -below change what a step does. Each line says what is lost and what the fix is. +with the original. Then every step PhotoCraft saves for the cases in +`every_mapped_command_exports_and_imports_back` was played in the reference application: 128 of 151 ran. +Three failed on a wrong code (Vibrance, Gradient Map, Stroke Outside); the others needed a document the test +did not have (a mask, a group, a named pattern or channel). Checking every code the mapping writes found +three more (Pass Through, canvas extension colour, Absolute Colorimetric). The steps below change what a +step does; each line says what is lost and the fix. ### Import or export loses a setting (fix in `atn_cmds` only) @@ -162,6 +167,13 @@ below change what a step does. Each line says what is lost and what the fix is. | Background layer by property (`Lyr .Bckg`) | Becomes the layer name "Background" on load and is saved as a name reference. A localised reference application has no layer of that name ("Hintergrund" in German), and the step fails there. | Keep the property form: load as the Background layer, save as `Lyr .Bckg`. | | Close (`Cls `) without `Svng` | Without `Svng` the reference application asks whether to save (without a dialog it refuses to close a changed document). Save writes `Svng N` (do not save), so playback there silently discards changes. 244 corpus steps. | Write no `Svng` for a Close that asks. | | Photo Filter (`photoFilter`) with a `LbCl` colour | The reference application's default filter colour is Lab (`Lmnc`, `A `, `B `); only RGB, HSB and gray colours are read, so a recorded Photo Filter usually stays a kept step. | Convert Lab colours (through `photocraft-cms`) on load. | +| Vibrance (direct) | Saved as event `Vbrn`, which the reference application does not know: the step fails there ("command not available"). | Save as `vibrance`. | +| Gradient Map (direct) | Saved as event `GdMp`, which is the Gradient Map *class*, not an event: the step fails there. | Save as `gradientMapEvent`. | +| Stroke, Location Outside | Written as `StrL.OtsF`; the application's value is `Otsd` and it rejects `OtsF` ("parameters not valid"). A recorded `Otsd` does not load. | Use `Otsd` both ways. | +| Set layer blend mode Pass Through (groups) | Written as `BlnM.PsTh`; the application rejects it. Its value is `passThrough`. | Save `passThrough`. | +| Canvas Size, extension colour Foreground / Other | Written as `canvasExtensionColorType` `Frgd` / `Othr`; the application does not know them and opens the Canvas Size dialog instead. Its values are `FrgC` and `Clr `. | Use `FrgC` and `Clr ` both ways. | +| Convert to Profile, intent Absolute Colorimetric | Written as `Inte.AbsC`; the application reads it as Relative Colorimetric (it echoes `Clrm`). Its value is `AClr`; a recorded `AClr` does not load. | Use `AClr` both ways. | +| Select a document by offset (`Dcmn` `rele`) | The reference application wraps around at the ends of the opening order (from the second of three documents, -2 is the third, +2 the first); `document.activate` refuses an offset past the ends (#7103), so the step fails in PhotoCraft. See Reference forms. | Wrap like the reference application. | ### The command has no such setting (needs a new command option first) @@ -187,6 +199,141 @@ below change what a step does. Each line says what is lost and what the fix is. - Colours are kept as 8-bit values (51.0031 becomes 51); gradient names become "Custom". - Levels and Curves write the composite as `Chnl.Cmps` (the application skips `Ordn.Trgt`, #8543). +## Reference forms + +How the reference application resolves a layer or document a step names, measured on 2026-11-01 by +playing `select` and `move` steps with each form (documents with and without a Background, a group, +layers above and below it). A recorded step can use any of these; PhotoCraft reads names and the target +today. + +### Layers + +The stack is counted from the bottom and includes a hidden "group end" entry below each group's +children. Example stack, bottom to top: Background, L1, L2, *group end*, Gchild, G (the group), L3. + +| Form | Meaning | Measured | +|---|---|---| +| By name (`name`) | The layer with that name (the first match). | (supported) | +| Target (`Ordn.Trgt`) | The active layer. | (supported) | +| By index (`indx`) | Position in the stack above. With a Background, the Background is 0 and L1 is 1. Without one, the bottom layer is 1, and 0 fails. The group end is a position but cannot be selected: the step leaves the selection unchanged. A number past the top fails. | with Background: 0 Background, 1 L1, 2 L2, 3 (group end: no change), 4 Gchild, 5 G, 6 L3, 7 fails | +| By id (`Idnt`) | The layer id of the document the step was recorded in (`LyrI`, never reused in that document). In another document it names an unrelated layer or none. | id 3 = L2 with a Background, L1 without | +| By offset (`rele`) | Not supported for layers: every offset fails. | -3..3 all fail | +| `Ordn.Frwr` / `Ordn.Bckw` | The next layer up / down in the stack, entering groups (group ends are skipped). | from L2: Frwr Gchild, Bckw L1 | +| `Ordn.Frnt` / `Ordn.Back` | The top layer / the bottom layer (the Background when there is one). | L3 / Background | + +**Move to an index** (`move`, `T` = layer by index `m`): counted as above on the stack before the move. +Moving down, the layer lands at index `m`; moving up, at `m - 1` (directly below the layer that had index +`m`). With a Background, `m` = 0 fails; without one, `m` = 0 sends the layer to the bottom. Moving to an +index inside a group puts the layer into that group. + +| m (with Background, moving L2 at index 2) | 0 | 1 | 2, 3 | 4 | 5 | 6 | +|---|---|---|---|---|---|---| +| Stack after | fails | Bg, L2, L1, ... | unchanged | Bg, L1, *end*, L2, Gchild, G, L3 (L2 inside G, bottom) | ... *end*, Gchild, L2, G (inside G, top) | ... G, L2, L3 | + +### Documents + +| Form | Meaning | Measured (D1, D2, D3 opened in that order, D2 active) | +|---|---|---| +| By offset (`rele`) | That many documents after (positive) or before (negative) the active one, in opening order, **wrapping around** at the ends. 0 fails. | -1 D1, +1 D3, -2 D3, +2 D1 | +| By index (`indx`) | 1-based, in opening order; 0 fails. | 1 D1, 2 D2, 3 D3 | + +PhotoCraft's `document.activate {"offset"}` refuses to wrap (#7103); the reference application wraps. + +## Newer events (string ID only) + +The events the reference application records for the menu items PhotoCraft has that are neither mapped +nor in the reference below: 63 candidates from PhotoCraft's menu catalogue, each run in the reference +application without a dialog on a document prepared for it (layers, a selection, a smart object, a type +layer, a layer style or a group) on 2026-11-01. 27 more menu items record no event of their own (puppet +warp and warp splits, combine shapes, scripts such as Load Files into Stack and Statistics, artboard and +video export, missing-font commands): they cannot be recorded or play as a script step. + +**Measured: no settings beyond the target.** A step with these events carries at most `null` (the target +layer reference, `Ordn.Trgt`); mapping them needs only the event name. + +| Event | Menu item | +|---|---| +| `reverse` | Layer > Arrange > Reverse (several layers selected) | +| `linkSelectedLayers` / `unlinkSelectedLayers` | Layer > Link Layers / Unlink Layers | +| `selectLinkedLayers` | Layer > Select Linked Layers | +| `disableLayerStyle` | Layer > Layer Style > Clear Layer Style | +| `placedLayerMakeCopy` | Layer > Smart Objects > New Smart Object via Copy | +| `placedLayerConvertToLayers` | Layer > Smart Objects > Convert to Layers | +| `placedLayerConvertToEmbedded` | Layer > Smart Objects > Embed Linked | +| `placedLayerUpdateAllModified` | Layer > Smart Objects > Update All Modified Content (the returned `documentID` and `layerIDs` are session ids, not settings) | +| `rasterizeAll` | Layer > Rasterize > All Layers | +| `addBlankVideoLayer` | Layer > Video Layers > New Blank Video Layer | +| `isolateLayers` | Select > Isolate Layers | +| `textLoadDefaultTypeStyles` / `textSaveDefaultTypeStyles` | Type > Load / Save Default Type Styles | +| `recordMeasurements` | Image > Analysis > Record Measurements | +| `CropPhotosAuto0001` | File > Automate > Crop and Straighten Photos | + +**Measured: settings.** + +| Event | Menu item | Keys | +|---|---|---| +| `placedLayerExportContents` | Layer > Smart Objects > Export Contents | `null`: path of the file written | +| `placedLayerConvertToLinked` | Layer > Smart Objects > Convert to Linked | `null`: target layer; `Usng`: path of the file written | +| `placedLayerRelinkToFile` | Layer > Smart Objects > Relink to File | `null`: path; `LyrI`: session layer id | +| `placedLayerReplaceContents` | Layer > Smart Objects > Replace Contents | `null`: path; `LyrI`: session layer id | +| `artboardFromLayerGroupEvent` | Layer > New > Artboard from Group | `null` target; `artboard` (object: `guideIDs` list, `artboardPresetName` text, `Clr ` RGB background colour, `artboardBackgroundType` long, 1 = white); `changeBackground` long | +| `shapeBlur` | Filter > Blur > Shape Blur | `Rds ` radius in pixels (factory 10); the shape itself comes from the dialog | +| Make `contentLayer` with `Usng` = reference to `textLayer` `Ordn.Trgt` | Type > Convert to Shape (recorded as Make, not as `convertToShape`) | `null`: class `contentLayer`; `Usng`: the type layer | + +**Needs a step recorded through its dialog or a matching document.** Without them the event either did +nothing, returned no settings, or was not available: Export As and Quick Export (document and layers), +Content-Aware Fill workspace (`cafWorkspace`), Perspective Warp, Color Lookup, Scale Effects +(`scaleEffectsEvent`), Liquify (`LqFy`), Camera Raw Filter, Vanishing Point, Blur Gallery +(`blurbTransform`), Calculations, Photomerge, Package, data sets and variables, Close All / Close Others, +artboards (new, from layers), Frame from Layers, Create Layers from a layer style, Stack Modes +(`applyImageStackPluginRenderer`), video frames and footage, Create Work Path, Check Spelling, Find and +Replace Text, Import Notes, Measurement Scale, Convert Stroke to Fill (needs a selected path component), +Sky Replacement. + +## Older files: version 12 + +Action sets saved by the reference application's version 5 era use file version 12. PhotoCraft rejects +them today; this is what reading them needs. Checked on 2026-11-01 on 325 real version 12 sets (all parse +to the end with this layout) and against AtnKit (MIT), which reads version 12 but gets two details wrong +(noted below). + +**Only the descriptor differs.** The file header, the action record and the step record are the same as +in version 16 (set and action names are UTF-16 strings with a counted NUL; events are almost always +4-character codes, so the existing event table applies). Trailing bytes after the last action occur in +22 of 325 files (stale data from an overwritten file): keep them a warning. + +The version 12 descriptor: + +- The step's descriptor has no name and no class: it starts with the item count. +- Every identifier is a bare 4-byte code, with no length prefix: keys, classes, enum types and values, + units, reference classes. No Unicode class names anywhere. +- Strings (`TEXT`, reference names) are the same UTF-16 strings as in version 16. + +```text +step descriptor := count u32, count × (key [4], type [4], value) +object := class [4], count u32, count × (key [4], type [4], value) + +bool u8 | long i32 | comp i64 | doub f64 | UntF unit [4] + f64 +UnFl unit [4] + n u32 + n × f64 +TEXT UTF-16 string | enum type [4] + value [4] +Objc / GlbO object | type / GlbC class [4] +VlLs n u32 + n × (type [4] + value) +ObAr class [4] + object count u32 + count u32 + count × (key [4], type [4], value) +obj n u32 + n × reference item +tdta / alis / Pth u32 length + bytes + +reference item := form [4] + class [4] + (prop: key [4] | Clss: nothing | Enmr: type [4] + value [4] + | rele: i32 | indx: u32 | Idnt: u32 | name: UTF-16 string) +``` + +AtnKit reads `indx` without its class code and has no case for `UnFl`, `ObAr` and `Pth `; with those +fixed, all 325 files parse. `Idnt` was not seen in a version 12 file; its layout follows `indx`. + +**Into PhotoCraft's model:** every id becomes a 4-character code; the step descriptor gets the empty name +and class `null` that version 16 files use for code events; nested classes get the empty name. Save Actions +keeps writing version 16. Version 7 files (a different layout with 8-bit strings) and sets wrapped in +MacBinary stay rejected. + ## Event reference: events not mapped yet Each entry below is an event the reference application records that PhotoCraft does not map yet: its @@ -1449,7 +1596,7 @@ their tables will be added once a recorded step was read. | Trace Contour | The step's level and edge are applied, but the returned descriptor always shows the defaults (level 128, upper). Lower (`Lwr `) is valid; levels at or below 0 and at or above 255 give the same image. | | Gradient tool | The drag needs a full gradient object (see Gradient Map in group E for its form). | | Batch, Create Droplet, Print | File and printer settings come from their dialogs; Batch's file naming is still unknown (a step written with PhotoCraft's current naming rules is refused). | -| Sky Replacement, Smart Object commands (embed, link, relink, replace, export contents), Convert Stroke to Fill, Rasterize All Layers | Known to the application but need a matching document (a sky, a smart object, a shape with a stroke). | +| Sky Replacement, Convert Stroke to Fill | Need a sky photo and a selected path component. (The smart object commands, Rasterize All and Convert Type to Shape are measured: see Newer events.) | | Generative Fill (`syntheticFill`) | AI feature: in PhotoCraft this goes through the shared AI layer; until then such steps stay kept steps. | ### Recorded under another event From 04cf63f68c15f440abaa73cfbd66ab7b2d4af022 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Sun, 1 Nov 2026 05:58:30 -0800 Subject: [PATCH 084/216] Preserve recorded ATN settings across load and save --- crates/engine/src/cmd.rs | 27 +- crates/engine/src/cmd/atn_cmds/adjustments.rs | 15 +- crates/engine/src/cmd/atn_cmds/descriptors.rs | 19 +- crates/engine/src/cmd/atn_cmds/events.rs | 9 +- crates/engine/src/cmd/atn_cmds/steps_doc.rs | 15 +- crates/engine/src/cmd/atn_cmds/steps_image.rs | 35 +- crates/engine/src/cmd/atn_cmds/steps_layer.rs | 38 ++- crates/engine/src/cmd/atn_cmds/steps_make.rs | 78 ++++- crates/engine/src/cmd/atn_cmds/tests.rs | 304 +++++++++++++++++- crates/engine/src/cmd/channel_cmds/refs.rs | 7 +- crates/engine/src/cmd/layer_label_cmds.rs | 15 +- .../engine/src/cmd/layer_label_cmds/tests.rs | 2 +- crates/engine/src/cmd/layer_params.rs | 9 +- 13 files changed, 523 insertions(+), 50 deletions(-) diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 19e1e94afc..af2626f870 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -305,11 +305,28 @@ fn build() -> Vec { Ok(json!({ "document": i })) } ), - cmd!("file.close", "Close", ["File"], Some("Cmd+W"), r##"{"document":index?}"##, has_doc, |s, p| { - let i = p.get("document").and_then(Value::as_u64).map(|v| v as usize).or(s.active_index()).ok_or(EngineError::NoDocument)?; - s.close(i).ok_or(EngineError::NoDocument)?; - Ok(Value::Null) - }), + cmd!( + "file.close", + "Close", + ["File"], + Some("Cmd+W"), + r##"{"document":index?,"save":"discard|ask"="discard"} (ask refuses to close a changed document in headless playback)"##, + has_doc, + |s, p| { + let i = p.get("document").and_then(Value::as_u64).map(|v| v as usize).or(s.active_index()).ok_or(EngineError::NoDocument)?; + match p.get("save").filter(|v| !v.is_null()) { + Some(v) if v == "ask" => { + if s.documents().get(i).ok_or(EngineError::NoDocument)?.is_dirty() { + return Err(bad("file.close", "the document has unsaved changes; save it or explicitly discard them")); + } + } + Some(v) if v != "discard" => return Err(bad("file.close", "`save` must be ask or discard")), + _ => {} + } + s.close(i).ok_or(EngineError::NoDocument)?; + Ok(Value::Null) + } + ), // Edit cmd!("edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, |s, _| Ok(json!(s.undo()))), cmd!("edit.redo", "Redo", ["Edit"], Some("Cmd+Shift+Z"), "{}", can_redo, |s, _| Ok(json!(s.redo()))), diff --git a/crates/engine/src/cmd/atn_cmds/adjustments.rs b/crates/engine/src/cmd/atn_cmds/adjustments.rs index 5da802b800..70574838cd 100644 --- a/crates/engine/src/cmd/atn_cmds/adjustments.rs +++ b/crates/engine/src/cmd/atn_cmds/adjustments.rs @@ -1,4 +1,6 @@ -use super::descriptors::{color_desc, color_hex, desc, enm, get_bool, get_enum, get_list, get_num, get_obj, jbool, jnum, jstr, long, num, pct, text}; +use super::descriptors::{ + color_desc, color_hex, desc, enm, get_bool, get_enum, get_list, get_num, get_obj, jbool, jnum, jstr, lab_color_hex, long, num, pct, text, +}; use super::fields::{F, Kind, Mapped, fields_from, fields_to, step}; use super::tables::{CHANNELS, channel_key, channel_ref, lookup, lookup_back}; use super::{D, Descriptor, Map, Value, json}; @@ -406,7 +408,7 @@ const ADJUSTMENTS: &[Adjustment] = &[ Adjustment { class: "ClrB", suffix: "colorBalance", from: color_balance_from, to: color_balance_to }, adj_fields!("Vbrn", "vibrance", &[F("vibrance", "vibrance", Kind::Long), F("Strt", "saturation", Kind::Long)]), adj_fields!("Exps", "exposure", &[F("Exps", "exposure", Kind::Dbl), F("Ofst", "offset", Kind::Dbl), F("gammaCorrection", "gamma", Kind::Dbl)]), - Adjustment { class: "PhtF", suffix: "photoFilter", from: |d| fields_from(d, PHOTO_FILTER_FIELDS).map(Value::Object), to: photo_filter_to }, + Adjustment { class: "PhtF", suffix: "photoFilter", from: photo_filter_from, to: photo_filter_to }, adj_fields!("BanW", "blackWhite", BW), Adjustment { class: "ChnM", suffix: "channelMixer", from: channel_mixer_from, to: channel_mixer_to }, Adjustment { class: "SlcC", suffix: "selectiveColor", from: selective_from, to: selective_to }, @@ -428,6 +430,15 @@ pub(super) fn adjustment_by_class(c: &str) -> Option<&'static Adjustment> { const PHOTO_FILTER_FIELDS: &[F] = &[F("Clr ", "color", Kind::Color), F("Dnst", "density", Kind::Long), F("PrsL", "preserveLuminosity", Kind::Bool)]; +/// Photo Filter records its default colour in Lab; other colour events retain their mappings. +fn photo_filter_from(d: &Descriptor) -> Option { + let mut p = fields_from(d, &PHOTO_FILTER_FIELDS[1..])?; + if let Some(c) = get_obj(d, "Clr ") { + p.insert("color".into(), color_hex(c).or_else(|| lab_color_hex(c))?.into()); + } + Some(Value::Object(p)) +} + /// A `PhtF` step whose params carry a `filter` preset without an explicit `color`: the descriptor /// form is colour-only (Photoshop stores no preset key — the `phfl` notes in `crates/io` conclude /// the same from its CS2 oracles), so the resolved colour must be written, or re-imported playback diff --git a/crates/engine/src/cmd/atn_cmds/descriptors.rs b/crates/engine/src/cmd/atn_cmds/descriptors.rs index 9e9835218b..b88ed09c6b 100644 --- a/crates/engine/src/cmd/atn_cmds/descriptors.rs +++ b/crates/engine/src/cmd/atn_cmds/descriptors.rs @@ -109,7 +109,7 @@ pub(super) fn layer_target(r: &[ReferenceItem]) -> Option> { m.insert("layer".into(), json!(n)); } Target::Property(p) if p == "Bckg" => { - m.insert("layer".into(), json!("Background")); + m.insert("layer".into(), json!({"background": true})); } _ => return None, } @@ -177,6 +177,7 @@ pub(super) fn layer_ref(p: &Value) -> Option { match p.get("layer") { None | Some(Value::Null) => Some(target_ref("Lyr ")), Some(Value::String(n)) => Some(name_ref("Lyr ", n)), + Some(v) if *v == json!({"background": true}) => Some(D::Reference(vec![ReferenceItem::Property { class: class("Lyr "), key: code("Bckg") }])), _ => None, } } @@ -201,6 +202,22 @@ pub(super) fn jstr<'a>(p: &'a Value, k: &str) -> Option<&'a str> { // ------------------------------------------------------------------ colours and enums +/// A recorded D50 Lab colour converted through the CMS to sRGB `#rrggbb`. +pub(super) fn lab_color_hex(c: &Descriptor) -> Option { + use photocraft_cms::{Builtin, TransformOptions}; + if !c.class_id.is("LbCl") { + return None; + } + let (l, a, b) = (get_num(c, "Lmnc")?, get_num(c, "A ")?, get_num(c, "B ")?); + let transform = photocraft_cms::cached(Builtin::LabD50.profile(), Builtin::Srgb.profile(), TransformOptions { bpc: false, ..Default::default() }).ok()?; + // ICC v4 Lab device values are normalized, unlike the recorded L*, a*, b*. + let input = [(l / 100.0) as f32, ((a + 128.0) / 255.0) as f32, ((b + 128.0) / 255.0) as f32]; + let mut rgb = [0.0; 3]; + transform.convert_f32(&input, 3, &mut rgb, 3, false); + let [r, g, b] = rgb.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8); + Some(format!("#{r:02x}{g:02x}{b:02x}")) +} + /// A Photoshop colour object as `#rrggbb`: `RGBC` (0..255), `HSBC`, `Grsc`. pub(super) fn color_hex(c: &Descriptor) -> Option { let hex = |r: f64, g: f64, b: f64| { diff --git a/crates/engine/src/cmd/atn_cmds/events.rs b/crates/engine/src/cmd/atn_cmds/events.rs index 5723c93c59..d056089b29 100644 --- a/crates/engine/src/cmd/atn_cmds/events.rs +++ b/crates/engine/src/cmd/atn_cmds/events.rs @@ -473,7 +473,14 @@ pub(super) const EVENTS: &[Event] = &[ ev!("", "placedLayerEditContents", "Edit Contents", "layer.smartObjects.editContents", &[]), ev!("", "updatePlacedLayer", "Update Smart Objects", "layer.smartObjects.updateModifiedContent", &[]), Event { code: "Hd ", name: "hide", label: "Hide", ids: &["layer.hideLayers"], from: |d| visibility_from(d, "layer.hideLayers"), to: visibility_to }, - Event { code: "Shw ", name: "show", label: "Show", ids: &["layer.showLayers"], from: |d| visibility_from(d, "layer.showLayers"), to: visibility_to }, + Event { + code: "Shw ", + name: "show", + label: "Show", + ids: &["layer.showLayers", "layer.showOnly"], + from: |d| visibility_from(d, "layer.showLayers"), + to: visibility_to, + }, Event { code: "move", name: "move", diff --git a/crates/engine/src/cmd/atn_cmds/steps_doc.rs b/crates/engine/src/cmd/atn_cmds/steps_doc.rs index 12852536ca..7707976509 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_doc.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_doc.rs @@ -113,9 +113,14 @@ pub(super) fn conditional_to(_: &str, p: &Value) -> Option { Some(d) } -/// `close`: Photoshop's Save prompt answer becomes a Save step before the Close. +/// Import Close's save policy: absent asks, Yes saves first, and No explicitly discards. pub(super) fn close_from(d: &Descriptor) -> Mapped { - if get_enum(d, "Svng").as_deref() == Some("Ys") { steps(vec![("file.save", json!({})), ("file.close", json!({}))]) } else { none("file.close") } + match d.get("Svng") { + None => step("file.close", json!({"save": "ask"})), + Some(D::Enumerated { value, .. }) if value.is("Ys ") => steps(vec![("file.save", json!({})), ("file.close", json!({}))]), + Some(D::Enumerated { value, .. }) if value.is("N ") => none("file.close"), + _ => Mapped::Unsupported { harmless: false }, + } } /// A Close of an explicit document has no mapping here: written as a plain Close it would @@ -124,7 +129,11 @@ pub(super) fn close_to(_: &str, p: &Value) -> Option { if p.get("document").is_some_and(|d| !d.is_null()) { return None; } - Some(desc("null").with("Svng", enm("YsN ", "N "))) + match p.get("save").filter(|v| !v.is_null()) { + Some(v) if v == "ask" => Some(desc("null")), + Some(v) if v != "discard" => None, + _ => Some(desc("null").with("Svng", enm("YsN ", "N "))), + } } const WEB_FORMATS: &[(&str, &str)] = &[("PN24", "png24"), ("PN8", "png8"), ("JPEG", "jpeg"), ("GIFf", "gif"), ("WBMP", "wbmp")]; diff --git a/crates/engine/src/cmd/atn_cmds/steps_image.rs b/crates/engine/src/cmd/atn_cmds/steps_image.rs index a52b9fbf5d..7eb9662ac8 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_image.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_image.rs @@ -7,6 +7,20 @@ use super::fields::{F, Kind, Mapped, fields_from, fields_to, step}; use super::tables::{CHANNELS, INTERPOLATION, RADIAL_METHOD, RADIAL_QUALITY, channel_of, lookup, lookup_back}; use super::{D, Descriptor, Map, ReferenceItem, UnicodeString, Value, json}; +/// Photoshop's nine frame anchors and the average/centre anchor. +const TRANSFORM_REFERENCE: &[(&str, &str)] = &[ + ("Qcsa", "center"), + ("Qcs0", "topLeft"), + ("Qcs1", "top"), + ("Qcs2", "topRight"), + ("Qcs3", "left"), + ("Qcs4", "center"), + ("Qcs5", "right"), + ("Qcs6", "bottomLeft"), + ("Qcs7", "bottom"), + ("Qcs8", "bottomRight"), +]; + /// `transform`: Photoshop records a Free Transform relative to the frame (offset of its centre, /// width/height in percent, angle). A layer moved only becomes `layer.translate`; anything /// else is `edit.transform`'s relative form; the selection's frame is `select.transformSelection`. @@ -40,6 +54,13 @@ pub(super) fn transform_from(d: &Descriptor) -> Mapped { // to keep its sub-pixel placement and interpolation. let whole_pixels = ["dx", "dy"].iter().all(|k| p.get(*k).and_then(Value::as_f64).is_none_or(|v| v.fract() == 0.0)); let geometry_only = p.keys().all(|k| k == "dx" || k == "dy") && whole_pixels; + if let Some(value) = d.get("FTcs") { + let D::Enumerated { type_id, value } = value else { return unsupported }; + let Some(reference) = lookup(TRANSFORM_REFERENCE, &trimmed(value)).filter(|_| type_id.is("QCSt")) else { return unsupported }; + if reference != "center" && !geometry_only { + p.insert("reference".into(), reference.into()); + } + } // The resampling applies to a selection as much as to a layer (#7515). // Photoshop's bicubic variants are kept (the transforms render them as Bicubic, and they export // back unchanged); Automatic and Preserve Details become the nearest bicubic choice. Dropping @@ -74,17 +95,15 @@ pub(super) fn transform_from(d: &Descriptor) -> Mapped { } } +/// Export relative transforms with frame anchors, refusing unsupported geometry and targets. pub(super) fn transform_to(id: &str, p: &Value) -> Option { let given = |k: &str| p.get(k).is_some_and(|v| !v.is_null()); if ["quad", "corners", "matrix", "rect"].iter().any(|k| given(k)) { return None; } - // The descriptor scales and rotates about the centre (`FTcs` average) without skew or warp: - // another reference point, a skew or a warp would replay as a different transform (#7516, - // #7517, #7518, #7519). + // Skew and warp still have no descriptor mapping (#7517, #7518, #7519). let nonzero = |k: &str| p.get(k).is_some_and(|v| !v.is_null() && v.as_f64() != Some(0.0)); - let off_centre = p.get("reference").is_some_and(|v| !v.is_null() && *v != json!("center")); - if nonzero("skewX") || nonzero("skewY") || off_centre || ["warp", "style", "mesh", "grid"].iter().any(|k| given(k)) { + if nonzero("skewX") || nonzero("skewY") || ["warp", "style", "mesh", "grid"].iter().any(|k| given(k)) { return None; } let target = match id { @@ -95,7 +114,11 @@ pub(super) fn transform_to(id: &str, p: &Value) -> Option { "select.transformSelection" => D::Reference(vec![ReferenceItem::Property { class: class("Chnl"), key: code("fsel") }]), _ => return None, }; - let mut d = desc("null").with("null", target).with("FTcs", enm("QCSt", "Qcsa")); + let reference = match p.get("reference").filter(|v| !v.is_null()) { + Some(v) => lookup_back(TRANSFORM_REFERENCE, v.as_str()?)?, + None => "Qcsa", + }; + let mut d = desc("null").with("null", target).with("FTcs", enm("QCSt", reference)); d = d.with("Ofst", D::Descriptor(desc("Ofst").with("Hrzn", px(jnum(p, "dx").unwrap_or(0.0))).with("Vrtc", px(jnum(p, "dy").unwrap_or(0.0))))); for (key, param) in [("Wdth", "scaleX"), ("Hght", "scaleY")] { if let Some(v) = jnum(p, param) { diff --git a/crates/engine/src/cmd/atn_cmds/steps_layer.rs b/crates/engine/src/cmd/atn_cmds/steps_layer.rs index 9d9bf5f3f7..1e18126ae3 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_layer.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_layer.rs @@ -9,11 +9,13 @@ use super::steps_make::is_tool_class; use super::tables::{FILL_CONTENTS, INTERPOLATION, MENU_ITEMS, STROKE_LOCATION, blend_label, blend_ps, lookup, lookup_back}; use super::{D, Descriptor, Id, Map, ReferenceItem, UnicodeString, Value, json}; +/// Resolve a recorded selection while retaining layer names, Background properties and modifiers. pub(super) fn select_from(d: &Descriptor) -> Mapped { let Some(r) = get_ref(d, "null") else { return Mapped::Unsupported { harmless: false } }; match (ref_class(r).as_deref(), target(r)) { - (Some("Lyr"), Target::Name(n)) => { - let mut p = json!({"layer": n}); + (Some("Lyr"), Target::Name(_) | Target::Property(_)) => { + let Some(p) = layer_target(r) else { return Mapped::Unsupported { harmless: false } }; + let mut p = Value::Object(p); match get_enum(d, "selectionModifier").as_deref() { Some("addToSelection") => p["mode"] = json!("add"), Some("removeFromSelection") => p["mode"] = json!("remove"), @@ -60,6 +62,7 @@ pub(super) fn select_from(d: &Descriptor) -> Mapped { } } +/// Write the supported layer, channel, document and history selection references. pub(super) fn select_to(id: &str, p: &Value) -> Option { let ord = |o: &str| D::Reference(vec![ReferenceItem::Enumerated { class: class("Lyr "), type_id: code("Ordn"), value: code(o) }]); let enumerated = |cls: &str, ty: &str, v: &str| D::Reference(vec![ReferenceItem::Enumerated { class: class(cls), type_id: code(ty), value: code(v) }]); @@ -68,8 +71,12 @@ pub(super) fn select_to(id: &str, p: &Value) -> Option { if p.get("occurrence").is_some() { return None; } - let name = jstr(p, "name").or_else(|| jstr(p, "layer"))?; - let mut d = desc("null").with("null", name_ref("Lyr ", name)); + let reference = match jstr(p, "name") { + Some(name) => name_ref("Lyr ", name), + None if p.get("layer").is_some_and(|v| !v.is_null()) => layer_ref(p)?, + None => return None, + }; + let mut d = desc("null").with("null", reference); match jstr(p, "mode") { None | Some("replace") => {} Some("add") => d = d.with("selectionModifier", enm("selectionModifierType", "addToSelection")), @@ -172,13 +179,23 @@ pub(super) fn delete_to(id: &str, p: &Value) -> Option { }) } -/// `hide` / `show` take a list of references (or one). +/// Import visibility references; a single Show with TglO is the reversible Show Only command. pub(super) fn visibility_from(d: &Descriptor, id: &str) -> Mapped { let refs: Vec<&[ReferenceItem]> = match d.get("null") { - Some(D::List(l)) => l.iter().filter_map(|v| if let D::Reference(r) = v { Some(r.as_slice()) } else { None }).collect(), + Some(D::List(l)) => { + let Some(refs) = l.iter().map(|v| if let D::Reference(r) = v { Some(r.as_slice()) } else { None }).collect() else { + return Mapped::Unsupported { harmless: false }; + }; + refs + } Some(D::Reference(r)) => vec![r.as_slice()], _ => return Mapped::Unsupported { harmless: false }, }; + let id = match d.get("TglO") { + Some(D::Boolean(true)) if id == "layer.showLayers" && refs.len() == 1 => "layer.showOnly", + Some(D::Boolean(false)) | None => id, + _ => return Mapped::Unsupported { harmless: false }, + }; let mut v = Vec::new(); for r in refs { match layer_target(r) { @@ -189,8 +206,13 @@ pub(super) fn visibility_from(d: &Descriptor, id: &str) -> Mapped { if v.is_empty() { Mapped::Unsupported { harmless: false } } else { steps(v) } } -pub(super) fn visibility_to(_: &str, p: &Value) -> Option { - Some(desc("null").with("null", D::List(vec![layer_ref(p)?]))) +/// Write the layer reference and preserve Show Only's toggle flag. +pub(super) fn visibility_to(id: &str, p: &Value) -> Option { + let mut d = desc("null").with("null", D::List(vec![layer_ref(p)?])); + if id == "layer.showOnly" { + d = d.with("TglO", D::Boolean(true)); + } + Some(d) } pub(super) fn move_from(d: &Descriptor) -> Mapped { diff --git a/crates/engine/src/cmd/atn_cmds/steps_make.rs b/crates/engine/src/cmd/atn_cmds/steps_make.rs index 2fce72c36d..c08170e63a 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_make.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_make.rs @@ -92,13 +92,29 @@ fn mask_from_descriptor(d: &Descriptor) -> Option { }) } +/// Import New Document and selection-based Copy/Cut, retaining the new layer's options. fn new_document_from_descriptor(d: &Descriptor) -> Option { let nw = get_obj(d, "Nw ")?; if get_enum(d, "Usng").as_deref() == Some("Slct") { let id = if get_bool(d, "Cpy ") == Some(true) { "layer.new.layerViaCopy" } else { "layer.new.layerViaCut" }; let mut mapped_steps = vec![(id, json!({}))]; + let mut props = Map::new(); if let Some(n) = get_text(nw, "Nm ") { - mapped_steps.push(("layer.setProps", json!({"name": n}))); + props.insert("name".into(), n.into()); + } + if let Some(o) = get_num(nw, "Opct") { + props.insert("opacity".into(), (o / 100.0).into()); + } + if let Some(m) = get_enum(nw, "Md ") { + let Some(mode) = blend_label(&m) else { return Some(Mapped::Unsupported { harmless: false }) }; + props.insert("blend".into(), mode.into()); + } + if let Some(c) = get_enum(nw, "Clr ") { + let Some(color) = lookup(LABEL_COLORS, &c) else { return Some(Mapped::Unsupported { harmless: false }) }; + mapped_steps.push(("layer.setLabelColor", json!({"color": color}))); + } + if !props.is_empty() { + mapped_steps.push(("layer.setProps", Value::Object(props))); } return Some(steps(mapped_steps)); } @@ -139,6 +155,7 @@ fn new_document_from_descriptor(d: &Descriptor) -> Option { /// New Snapshot's From: the full document, the layers merged, or the current layer. const SNAPSHOT_FROM: &[(&str, &str)] = &[("FllD", "fullDocument"), ("MrgL", "mergedLayers"), ("CrnL", "currentLayer")]; +/// Import Make events only when their geometry and options can be represented faithfully. pub(super) fn make_from(d: &Descriptor) -> Mapped { if let Some(mapped) = duplicate_layer_from_descriptor(d) { return mapped; @@ -165,13 +182,33 @@ pub(super) fn make_from(d: &Descriptor) -> Mapped { if let Some(m) = get_enum(u, "Md ").and_then(|m| blend_label(&m)) { p.insert("mode".into(), m.into()); } + match u.get("Grup") { + Some(D::Boolean(true)) => { + p.insert("clipToPrevious".into(), true.into()); + } + Some(D::Boolean(false)) | None => {} + _ => return Mapped::Unsupported { harmless: false }, + } } step("layer.new.layer", Value::Object(p)) } Some("AdjL") => match type_desc.and_then(|t| adjustment_by_class(&trimmed(&t.class_id))) { - Some(a) => adjustment_steps(a, "layer.newAdjustmentLayer.", type_desc), + Some(a) => { + let mut mapped = adjustment_steps(a, "layer.newAdjustmentLayer.", type_desc); + if let Some(c) = using.and_then(|u| get_enum(u, "Clr ")) { + let Some(color) = lookup(LABEL_COLORS, &c) else { return Mapped::Unsupported { harmless: false } }; + if let Mapped::Steps(steps) = &mut mapped { + steps.push(("layer.setLabelColor".into(), json!({"color": color}))); + } + } + mapped + } None => Mapped::Unsupported { harmless: false }, }, + Some("contentLayer") if d.get("Shp ").is_some() || using.is_some_and(|u| u.get("Shp ").is_some() || u.get("strokeStyle").is_some()) => { + // A shape's geometry and stroke cannot be represented by a canvas-wide fill. + Mapped::Unsupported { harmless: false } + } Some("contentLayer") => match type_desc { Some(t) if t.class_id.is("solidColorLayer") => match get_obj(t, "Clr ").and_then(color_hex) { Some(c) => step("layer.newFillLayer.solidColor", json!({"color": c})), @@ -185,7 +222,7 @@ pub(super) fn make_from(d: &Descriptor) -> Mapped { }, Some("layerSection") => { let mut p = Map::new(); - if let Some(n) = using.and_then(|u| get_text(u, "Nm ")) { + if let Some(n) = using.and_then(|u| get_text(u, "Nm ")).or_else(|| get_text(d, "Nm ")) { p.insert("name".into(), n.into()); } match get_ref(d, "From") { @@ -223,6 +260,7 @@ pub(super) fn make_from(d: &Descriptor) -> Mapped { } } +/// Write a creation step, refusing native options the target descriptor cannot carry. pub(super) fn make_to(id: &str, p: &Value) -> Option { let with_name = |cls: &str| { let mut u = desc(cls); @@ -233,12 +271,12 @@ pub(super) fn make_to(id: &str, p: &Value) -> Option { }; Some(match id { "layer.new.layer" => { - // The descriptor carries the name, opacity and mode only. A step that also clips to - // the layer below, labels the layer, fills it with the neutral colour or places it + // The descriptor carries the name, opacity, mode and clipping flag. A step that + // labels the layer, fills it with the neutral colour or places it // below the active layer would replay as a different layer, so it isn't exported - // (strict export fails, a partial one skips it: #7502, #7512, #7513, #7514). + // (strict export fails, a partial one skips it: #7512, #7513, #7514). let set = |key: &str, default: Value| p.get(key).is_some_and(|v| !v.is_null() && *v != default); - if set("clipToPrevious", Value::Bool(false)) + if p.get("clipToPrevious").is_some_and(|v| !v.is_null() && !v.is_boolean()) || set("color", json!("none")) || set("fillNeutral", Value::Bool(false)) || set("position", json!("above")) @@ -246,6 +284,9 @@ pub(super) fn make_to(id: &str, p: &Value) -> Option { return None; } let mut u = with_name("Lyr "); + if jbool(p, "clipToPrevious") == Some(true) { + u = u.with("Grup", D::Boolean(true)); + } if let Some(o) = jnum(p, "opacity") { u = u.with("Opct", pct(o * 100.0)); } @@ -359,6 +400,7 @@ pub(super) fn make_to(id: &str, p: &Value) -> Option { }) } +/// Import selection geometry or a channel source, including its optional source layer. fn selection_from(d: &Descriptor) -> Mapped { let unsupported = Mapped::Unsupported { harmless: false }; match d.get("T ") { @@ -369,7 +411,18 @@ fn selection_from(d: &Descriptor) -> Mapped { _ => unsupported, }, Some(D::Reference(t)) => match (ref_class(t).as_deref(), target(t)) { - (Some("Chnl"), Target::Ordinal(o)) if o == "Trsp" => step("select.loadSelection", json!({"channel": "transparency"})), + (Some("Chnl"), Target::Ordinal(o)) if o == "Trsp" => { + let mut p = Map::new(); + p.insert("channel".into(), "transparency".into()); + if t.len() > 1 { + if t.len() != 2 || ref_class(&t[1..]).as_deref() != Some("Lyr") { + return unsupported; + } + let Some(layer) = layer_target(&t[1..]) else { return unsupported }; + p.extend(layer); + } + step("select.loadSelection", Value::Object(p)) + } // ⌘-click on the targeted channel (a layer mask after `select` of `Msk`). (Some("Chnl"), Target::Ordinal(o)) if o == "Trgt" => step("select.loadSelection", json!({"channel": "targeted"})), (Some("Chnl"), Target::Name(n)) => step("select.loadSelection", json!({"channel": n})), @@ -664,7 +717,14 @@ pub(super) fn set_to(id: &str, p: &Value) -> Option { return None; } "select.loadSelection" => match jstr(p, "channel")? { - "transparency" => sel(D::Reference(vec![ReferenceItem::Enumerated { class: class("Chnl"), type_id: Id::new("Chnl"), value: Id::new("Trsp") }])), + "transparency" => { + let mut r = vec![ReferenceItem::Enumerated { class: class("Chnl"), type_id: Id::new("Chnl"), value: Id::new("Trsp") }]; + if p.get("layer").is_some_and(|v| !v.is_null()) { + let D::Reference(layer) = layer_ref(p)? else { return None }; + r.extend(layer); + } + sel(D::Reference(r)) + } "targeted" => sel(D::Reference(vec![ReferenceItem::Enumerated { class: class("Chnl"), type_id: Id::new("Ordn"), value: Id::new("Trgt") }])), name => sel(name_ref("Chnl", name)), }, diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index f9e633244c..6c28a7d3e0 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -562,6 +562,7 @@ fn every_mapped_command_exports_and_imports_back() { ("layer.setProps", json!({"name": "Renamed", "opacity": 0.4, "blend": "Linear Light"})), ("layer.setProps", json!({"layer": "Shadow", "visible": false})), ("layer.setLabelColor", json!({"color": "red"})), + ("layer.showOnly", json!({"layer": "Solo"})), ("layer.select", json!({"layer": "Shadow", "mode": "add"})), ("layer.select", json!({"layer": "Shadow", "mode": "range"})), ("layer.selectTop", json!({})), @@ -2082,7 +2083,7 @@ fn minimum_and_maximum_with_roundness_are_not_exported_as_squareness() { } /// #7515: a Transform Selection's interpolation survives the round trip as a layer transform's -/// does. #7516-#7519: a reference point other than the centre, a skew or a warp can't be written, +/// does. An arbitrary coordinate reference, a skew or a warp can't be written, /// so the step is refused instead of replaying as a plain scale. #[test] fn transform_selection_keeps_its_interpolation_and_refuses_what_it_cannot_carry() { @@ -2094,7 +2095,6 @@ fn transform_selection_keeps_its_interpolation_and_refuses_what_it_cannot_carry( assert_eq!(imported.actions[0].steps, vec![("select.transformSelection".to_string(), p)]); for id in ["select.transformSelection", "edit.transform"] { for p in [ - json!({"scaleX": 140, "reference": "topLeft"}), json!({"scaleX": 140, "reference": [3, 4]}), json!({"scaleX": 140, "skewX": 12}), json!({"scaleX": 140, "skewY": -5}), @@ -2138,15 +2138,13 @@ fn canvas_bound_modify_and_placed_pattern_bucket_refuse_strict_export() { } } -/// #7502, #7512, #7513, #7514: a New Layer step that clips to the layer below, has a colour -/// label, fills with the neutral colour or goes below the active layer isn't exported, since the -/// descriptor carries only its name, opacity and mode. Those options at their defaults still +/// #7512, #7513, #7514: a New Layer step with a colour label, neutral fill or a position below +/// the active layer isn't exported. Invalid clipping values are also refused. Defaults still /// export, and the step round-trips. #[test] fn new_layer_options_the_descriptor_cannot_carry_refuse_strict_export() { let new_layer = |p: Value| Action { name: "Probe".into(), steps: vec![("layer.new.layer".into(), p)], ..Default::default() }; for p in [ - json!({"name": "New", "clipToPrevious": true}), json!({"name": "New", "color": "red"}), json!({"name": "New", "mode": "Overlay", "fillNeutral": true}), json!({"name": "New", "position": "below"}), @@ -3175,3 +3173,297 @@ fn long_names_are_clipped_instead_of_copied_into_every_diagnostic() { assert_eq!(imp.unsupported.len(), 50); assert!(imp.unsupported.iter().all(|u| u.action.chars().count() == MAX_NAME_CHARS)); } + +/// Newer Group from Layers recordings put the name outside Usng; existing nested names win. +#[test] +fn mapped_make_group_name_survives_load_and_save() { + for (top, nested, expected) in [(Some("Selected"), None, "Selected"), (None, Some("Nested"), "Nested"), (Some("Top"), Some("Nested"), "Nested")] { + let mut d = desc("null").with("null", class_ref("layerSection")).with("From", target_ref("Lyr ")); + if let Some(name) = top { + d = d.with("Nm ", text(name)); + } + if let Some(name) = nested { + d = d.with("Usng", D::Descriptor(desc("layerSection").with("Nm ", text(name)))); + } + let source = one_action(vec![item("Mk ", "Group from Layers", Some(d))]); + let loaded = import(&atn::write(&source).unwrap()).unwrap(); + assert_eq!(loaded.actions[0].steps, vec![("layer.groupLayers".into(), json!({"name": expected}))]); + let (saved, skipped) = export("Groups", &[&loaded.actions[0]], &[], false).unwrap(); + assert!(skipped.is_empty()); + assert_eq!(import(&saved).unwrap().actions[0].steps, loaded.actions[0].steps); + } +} + +/// A recorded New Layer clipping flag must reach the command and survive strict export. +#[test] +fn mapped_make_clipping_flag_survives_load_and_save() { + for clip in [None, Some(false), Some(true)] { + let mut using = desc("Lyr ").with("Nm ", text("Clipped")); + if let Some(clip) = clip { + using = using.with("Grup", D::Boolean(clip)); + } + let d = desc("null").with("null", class_ref("Lyr ")).with("Usng", D::Descriptor(using)); + let loaded = import(&atn::write(&one_action(vec![item("Mk ", "New Layer", Some(d))])).unwrap()).unwrap(); + let mut expected = json!({"name": "Clipped"}); + if clip == Some(true) { + expected["clipToPrevious"] = json!(true); + } + assert_eq!(loaded.actions[0].steps, vec![("layer.new.layer".into(), expected)]); + let (saved, skipped) = export("Layers", &[&loaded.actions[0]], &[], false).unwrap(); + assert!(skipped.is_empty()); + assert_eq!(import(&saved).unwrap().actions[0].steps, loaded.actions[0].steps); + } +} + +/// An unreadable clipping flag keeps the original step instead of creating an unclipped layer. +#[test] +fn mapped_make_invalid_clipping_flag_is_kept() { + let using = desc("Lyr ").with("Grup", long(1.0)); + let d = desc("null").with("null", class_ref("Lyr ")).with("Usng", D::Descriptor(using)); + let loaded = import(&atn::write(&one_action(vec![item("Mk ", "New Layer", Some(d))])).unwrap()).unwrap(); + assert_eq!(loaded.actions[0].steps[0].0, PHOTOSHOP_STEP); +} + +/// Load a recorded descriptor through the same parser as Load Actions. +fn mapped_recording(event: &str, descriptor: Descriptor) -> Imported { + import(&atn::write(&one_action(vec![item(event, event, Some(descriptor))])).unwrap()).unwrap() +} + +/// Every frame anchor survives import/export; unknown anchors stay available verbatim. +#[test] +fn mapped_transform_reference_points_round_trip() { + for (code, reference) in [ + ("Qcsa", "center"), + ("Qcs0", "topLeft"), + ("Qcs1", "top"), + ("Qcs2", "topRight"), + ("Qcs3", "left"), + ("Qcs4", "center"), + ("Qcs5", "right"), + ("Qcs6", "bottomLeft"), + ("Qcs7", "bottom"), + ("Qcs8", "bottomRight"), + ] { + for target in [target_ref("Lyr "), D::Reference(vec![ReferenceItem::Property { class: class("Chnl"), key: Id::new("fsel") }])] { + let d = desc("null") + .with("null", target) + .with("FTcs", enm("QCSt", code)) + .with("Wdth", pct(50.0)) + .with("Hght", pct(50.0)) + .with("Ofst", D::Descriptor(desc("Ofst").with("Hrzn", px(0.0)).with("Vrtc", px(0.0)))); + let loaded = mapped_recording("Trnf", d); + let params = &loaded.actions[0].steps[0].1; + assert_eq!(params.get("reference").and_then(Value::as_str).unwrap_or("center"), reference); + let (bytes, _) = export("Anchors", &[&loaded.actions[0]], &[], false).unwrap(); + assert_eq!(import(&bytes).unwrap().actions[0].steps, loaded.actions[0].steps); + if reference == "topLeft" && loaded.actions[0].steps[0].0 == "select.transformSelection" { + let mut s = Session::new(); + s.execute("file.new", json!({"width":256,"height":256})).unwrap(); + s.execute("select.rect", json!({"x":60,"y":60,"width":100,"height":100})).unwrap(); + let (id, p) = &loaded.actions[0].steps[0]; + s.execute(id, p.clone()).unwrap(); + assert_eq!(s.active().unwrap().doc.selection.as_ref().unwrap().content_bounds(), photocraft_geom::Rect::new(60, 60, 110, 110)); + } + } + } + let d = desc("null").with("null", target_ref("Lyr ")).with("FTcs", enm("QCSt", "unknown")).with("Wdth", pct(50.0)); + assert_eq!(mapped_recording("Trnf", d).actions[0].steps[0].0, PHOTOSHOP_STEP); +} + +/// Alt-clicking an eye twice must isolate and then restore visibility, not just show a layer. +#[test] +fn mapped_show_only_round_trip_and_toggle() { + let d = desc("null").with("null", D::List(vec![name_ref("Lyr ", "Solo")])).with("TglO", D::Boolean(true)); + let loaded = mapped_recording("Shw ", d); + assert_eq!(loaded.actions[0].steps, vec![("layer.showOnly".into(), json!({"layer": "Solo"}))]); + let (bytes, _) = export("Eyes", &[&loaded.actions[0]], &[], false).unwrap(); + let saved = atn::parse(&bytes).unwrap(); + assert_eq!(get_bool(saved.actions[0].items[0].descriptor.as_ref().unwrap(), "TglO"), Some(true)); + assert_eq!(import(&bytes).unwrap().actions[0].steps, loaded.actions[0].steps); + let mut s = Session::new(); + s.execute("file.new", json!({"width":4,"height":4})).unwrap(); + s.execute("layer.new.layer", json!({"name":"Solo"})).unwrap(); + let visible = |s: &Session| s.active().unwrap().doc.layers.iter().map(|l| l.visible).collect::>(); + let original = visible(&s); + let (id, params) = &loaded.actions[0].steps[0]; + assert_eq!(s.execute(id, params.clone()).unwrap()["shownAlone"], true); + assert_ne!(visible(&s), original); + assert_eq!(s.execute(id, params.clone()).unwrap()["shownAlone"], false); + assert_eq!(visible(&s), original); + let malformed = desc("null").with("null", D::List(vec![name_ref("Lyr ", "Solo"), long(1.0)])).with("TglO", D::Boolean(true)); + assert_eq!(mapped_recording("Shw ", malformed).actions[0].steps[0].0, PHOTOSHOP_STEP); +} + +/// A layer label is separate from an adjustment's own colour parameter. +#[test] +fn mapped_adjustment_label_survives_export() { + let d = desc("null") + .with("null", class_ref("AdjL")) + .with("Usng", D::Descriptor(desc("AdjL").with("Type", D::Descriptor(desc("Invr"))).with("Clr ", enm("Clr ", "Rd ")))); + let loaded = mapped_recording("Mk ", d); + assert_eq!(loaded.actions[0].steps, vec![("layer.newAdjustmentLayer.invert".into(), json!({})), ("layer.setLabelColor".into(), json!({"color":"red"}))]); + let (bytes, _) = export("Labels", &[&loaded.actions[0]], &[], false).unwrap(); + assert_eq!(import(&bytes).unwrap().actions[0].steps, loaded.actions[0].steps); + let mut s = Session::new(); + s.execute("file.new", json!({"width":4,"height":4})).unwrap(); + for (id, params) in &loaded.actions[0].steps { + s.execute(id, params.clone()).unwrap(); + } + let st = s.active().unwrap(); + assert_eq!(st.doc.layer(st.active_layer.unwrap()).unwrap().label.id(), "red"); +} + +/// Keeping an unmapped shape is safer than creating a solid fill over the whole canvas. +#[test] +fn mapped_shape_creation_is_kept_with_its_geometry_and_stroke() { + let d = desc("null").with("null", class_ref("contentLayer")).with("Shp ", D::Descriptor(desc("Rctn").with("Wdth", px(20.0)))).with( + "Usng", + D::Descriptor( + desc("contentLayer") + .with("Type", D::Descriptor(desc("solidColorLayer"))) + .with("strokeStyle", D::Descriptor(desc("strokeStyle").with("strokeStyleLineWidth", px(3.0)))), + ), + ); + let loaded = mapped_recording("Mk ", d.clone()); + assert_eq!(loaded.actions[0].steps[0].0, PHOTOSHOP_STEP); + let (bytes, _) = export("Shapes", &[&loaded.actions[0]], &[], false).unwrap(); + assert_eq!(atn::parse(&bytes).unwrap().actions[0].items[0].descriptor.as_ref(), Some(&d)); +} + +/// Copy and Cut retain all recorded layer options, including labels and fractional opacity. +#[test] +fn mapped_copy_cut_layer_options_survive_export() { + for (copy, id) in [(true, "layer.new.layerViaCopy"), (false, "layer.new.layerViaCut")] { + let options = desc("Lyr ").with("Nm ", text("Extracted")).with("Md ", enm("BlnM", "Mltp")).with("Opct", pct(37.5)).with("Clr ", enm("Clr ", "Bl ")); + let d = desc("null").with("Nw ", D::Descriptor(options)).with("Usng", enm("ArSl", "Slct")).with("Cpy ", D::Boolean(copy)); + let loaded = mapped_recording("Mk ", d); + assert_eq!( + loaded.actions[0].steps, + vec![ + (id.into(), json!({})), + ("layer.setLabelColor".into(), json!({"color":"blue"})), + ("layer.setProps".into(), json!({"name":"Extracted","blend":"Multiply","opacity":0.375})) + ] + ); + let (bytes, _) = export("Extract", &[&loaded.actions[0]], &[], false).unwrap(); + let saved = import(&bytes).unwrap(); + assert_eq!(saved.actions[0].steps[..2], loaded.actions[0].steps[..2]); + let mut props = serde_json::Map::new(); + for (id, p) in &saved.actions[0].steps[2..] { + assert_eq!(id, "layer.setProps"); + props.extend(p.as_object().unwrap().clone()); + } + assert_eq!(Value::Object(props), loaded.actions[0].steps[2].1); + let mut s = Session::new(); + s.execute("file.new", json!({"width":4,"height":4,"background":"white"})).unwrap(); + s.execute("select.rect", json!({"x":0,"y":0,"width":2,"height":2})).unwrap(); + for (id, params) in &saved.actions[0].steps { + s.execute(id, params.clone()).unwrap(); + } + let st = s.active().unwrap(); + let layer = st.doc.layer(st.active_layer.unwrap()).unwrap(); + assert_eq!(layer.name, "Extracted"); + assert_eq!(layer.opacity, 0.375); + assert_eq!(layer.blend, photocraft_color::BlendMode::Multiply); + assert_eq!(layer.label.id(), "blue"); + } +} + +/// Loading transparency must keep the source layer instead of using the active layer. +#[test] +fn mapped_named_layer_transparency_survives_export() { + let source = D::Reference(vec![ + ReferenceItem::Enumerated { class: class("Chnl"), type_id: code("Chnl"), value: code("Trsp") }, + ReferenceItem::Name { class: class("Lyr "), name: UnicodeString::new_nul("Source") }, + ]); + let d = desc("null").with("null", D::Reference(vec![ReferenceItem::Property { class: class("Chnl"), key: code("fsel") }])).with("T ", source); + let loaded = mapped_recording("setd", d); + assert_eq!(loaded.actions[0].steps, vec![("select.loadSelection".into(), json!({"channel":"transparency","layer":"Source"}))]); + let (bytes, _) = export("Transparency", &[&loaded.actions[0]], &[], false).unwrap(); + assert_eq!(import(&bytes).unwrap().actions[0].steps, loaded.actions[0].steps); + let mut s = Session::new(); + s.execute("file.new", json!({"width":4,"height":4,"background":"white"})).unwrap(); + s.execute("layer.setProps", json!({"name":"Source"})).unwrap(); + s.execute("layer.new.layer", json!({"name":"Empty"})).unwrap(); + let (id, p) = &loaded.actions[0].steps[0]; + s.execute(id, p.clone()).unwrap(); + assert_eq!(s.active().unwrap().doc.selection.as_ref().unwrap().content_bounds(), photocraft_geom::Rect::new(0, 0, 4, 4)); +} + +/// A localized Background and an ordinary layer named Background are distinct targets. +#[test] +fn mapped_background_property_survives_export_and_playback() { + let r = D::Reference(vec![ReferenceItem::Property { class: class("Lyr "), key: code("Bckg") }]); + let loaded = mapped_recording("Hd ", desc("null").with("null", D::List(vec![r.clone()]))); + assert_eq!(loaded.actions[0].steps, vec![("layer.hideLayers".into(), json!({"layer":{"background":true}}))]); + let (bytes, _) = export("Background", &[&loaded.actions[0]], &[], false).unwrap(); + assert_eq!(get_list(atn::parse(&bytes).unwrap().actions[0].items[0].descriptor.as_ref().unwrap(), "null"), Some(&[r][..])); + let mut s = Session::new(); + s.execute("file.new", json!({"width":4,"height":4,"background":"white"})).unwrap(); + std::sync::Arc::make_mut(&mut s.active_mut().unwrap().doc).layers[0].name = "Hintergrund".into(); + s.execute("layer.new.layer", json!({"name":"Background"})).unwrap(); + let (id, p) = &loaded.actions[0].steps[0]; + s.execute(id, p.clone()).unwrap(); + let layers = &s.active().unwrap().doc.layers; + assert!(!layers[0].visible); + assert!(layers[1].visible); + s.execute("layer.hideLayers", json!({"layer":"Background"})).unwrap(); + assert!(!s.active().unwrap().doc.layers[1].visible); + assert!(s.execute("layer.showLayers", json!({"layer":{"background":false}})).is_err()); + s.execute("layer.setLabelColor", json!({"layer":{"background":true},"color":"red"})).unwrap(); + assert_eq!(s.active().unwrap().doc.layers[0].label.id(), "red"); + s.execute("select.loadSelection", json!({"channel":"transparency","layer":{"background":true}})).unwrap(); + assert_eq!(s.active().unwrap().doc.selection.as_ref().unwrap().content_bounds(), photocraft_geom::Rect::new(0, 0, 4, 4)); + let selected = mapped_recording("slct", desc("null").with("null", D::Reference(vec![ReferenceItem::Property { class: class("Lyr "), key: code("Bckg") }]))); + let (bytes, _) = export("Select Background", &[&selected.actions[0]], &[], false).unwrap(); + assert_eq!(import(&bytes).unwrap().actions[0].steps, selected.actions[0].steps); + let (id, p) = &selected.actions[0].steps[0]; + s.execute(id, p.clone()).unwrap(); + assert_eq!(s.active().unwrap().active_layer, Some(s.active().unwrap().doc.layers[0].id)); + let revision = s.active().unwrap().revision; + for selector in [json!({"background":false}), json!({"background":"true"}), json!({"background":true,"extra":0})] { + assert!(s.execute("select.loadSelection", json!({"channel":"transparency","layer":selector})).is_err()); + assert_eq!(s.active().unwrap().revision, revision); + } +} + +/// An unanswered Close must not turn into an explicit discard or close a changed document. +#[test] +fn mapped_close_keeps_ask_and_discard_distinct() { + let loaded = mapped_recording("Cls ", desc("null")); + assert_eq!(loaded.actions[0].steps, vec![("file.close".into(), json!({"save":"ask"}))]); + let (bytes, _) = export("Close", &[&loaded.actions[0]], &[], false).unwrap(); + assert!(atn::parse(&bytes).unwrap().actions[0].items[0].descriptor.as_ref().unwrap().get("Svng").is_none()); + assert_eq!(import(&bytes).unwrap().actions[0].steps, loaded.actions[0].steps); + let mut s = Session::new(); + s.execute("file.new", json!({"width":4,"height":4})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + let (id, p) = &loaded.actions[0].steps[0]; + assert!(s.execute(id, p.clone()).is_err()); + assert_eq!(s.documents().len(), 1); + s.execute("file.close", json!({"save":"discard"})).unwrap(); + assert!(s.documents().is_empty()); + s.execute("file.new", json!({"width":4,"height":4})).unwrap(); + s.execute(id, p.clone()).unwrap(); + assert!(s.documents().is_empty()); +} + +/// Lab Photo Filter colours use the CMS conversion and remain mapped after saving as RGB. +#[test] +fn mapped_lab_photo_filter_imports_and_round_trips() { + for (lab, expected) in + [([0.0, 0.0, 0.0], "#000000"), ([100.0, 0.0, 0.0], "#ffffff"), ([50.0, 0.0, 0.0], "#777777"), ([54.2917, 80.8125, 69.8851], "#ff0000")] + { + let color = desc("LbCl").with("Lmnc", D::Double(lab[0])).with("A ", D::Double(lab[1])).with("B ", D::Double(lab[2])); + let loaded = mapped_recording("photoFilter", desc("null").with("Clr ", D::Descriptor(color)).with("Dnst", long(25.0))); + let (id, params) = &loaded.actions[0].steps[0]; + assert_eq!(id, "image.adjustments.photoFilter"); + assert_eq!(params["color"], expected); + assert_eq!(params["density"].as_f64(), Some(25.0)); + let (bytes, _) = export("Lab", &[&loaded.actions[0]], &[], false).unwrap(); + let saved = import(&bytes).unwrap(); + assert_eq!(saved.actions[0].steps[0].0, *id); + assert_eq!(saved.actions[0].steps[0].1["color"], expected); + assert_eq!(saved.actions[0].steps[0].1["density"].as_f64(), Some(25.0)); + } +} diff --git a/crates/engine/src/cmd/channel_cmds/refs.rs b/crates/engine/src/cmd/channel_cmds/refs.rs index 58708475da..09d090b3a1 100644 --- a/crates/engine/src/cmd/channel_cmds/refs.rs +++ b/crates/engine/src/cmd/channel_cmds/refs.rs @@ -102,10 +102,15 @@ pub(super) fn native_pixels(doc: &Document, layer: Option) -> Result<(V } /// `"layer"` param of a source: a layer id, a layer name (the topmost of that name, as a loaded -/// Photoshop action refers to it), or `"merged"` (default). An unknown name is an error. +/// Photoshop action refers to it), `{"background":true}`, or `"merged"` (default). +/// An unknown name or a missing requested Background is an error. fn source_layer(doc: &Document, p: &Value, cmd: &str) -> Result> { match p.get("layer") { Some(Value::Number(n)) => Ok(n.as_u64().map(LayerId)), + Some(v) if *v == serde_json::json!({"background": true}) => { + doc.background().map(|l| Some(l.id)).ok_or_else(|| bad(cmd, "the source document has no Background layer")) + } + Some(Value::Object(_)) => Err(bad(cmd, "a source layer selector must be {\"background\":true}")), Some(Value::String(name)) if !name.is_empty() && name != "merged" => doc .walk() .into_iter() diff --git a/crates/engine/src/cmd/layer_label_cmds.rs b/crates/engine/src/cmd/layer_label_cmds.rs index acb450cdbf..bcc9d17c46 100644 --- a/crates/engine/src/cmd/layer_label_cmds.rs +++ b/crates/engine/src/cmd/layer_label_cmds.rs @@ -1,7 +1,7 @@ //! Undoable layer colour labels. A group applies the label to its existing descendants, //! without changing pixels, visibility, locks or the layer selection. -use photocraft_doc::{LabelColor, LayerId}; +use photocraft_doc::LabelColor; use serde_json::{Value, json}; use crate::commands::CommandSpec; @@ -18,15 +18,19 @@ fn enabled(s: &Session) -> std::result::Result<(), String> { if st.selected_layers().is_empty() { Err("no layer selected".into()) } else { Ok(()) } } +/// Label an explicit layer or the selected layers, including a group's descendants. fn set_label(s: &mut Session, p: &Value) -> Result { - let p = p.as_object().ok_or_else(|| bad("expected an object"))?; + p.as_object().ok_or_else(|| bad("expected an object"))?; let color = p.get("color").and_then(Value::as_str).and_then(LabelColor::from_id).ok_or_else(|| { bad("`color` must be a layer colour id (none, red, orange, yellow, green, seafoam, blue, indigo, magenta, fuchsia, violet, gray)") })?; let st = s.active().ok_or(EngineError::NoDocument)?; - let roots = match p.get("layer") { - Some(v) => vec![LayerId(v.as_u64().ok_or_else(|| bad("`layer` must be an unsigned integer"))?)], + if p.get("layer") == Some(&Value::Null) { + return Err(bad("`layer` must name a layer when supplied")); + } + let roots = match crate::commands::explicit_layer(s, p)? { + Some(id) => vec![id], None => st.selected_layers(), }; if roots.is_empty() { @@ -63,13 +67,14 @@ fn set_label(s: &mut Session, p: &Value) -> Result { Ok(json!({"color": color.id(), "layers": targets.iter().map(|(_, id)| id.0).collect::>(), "changed": changed})) } +/// Register the undoable layer-label command and its target options. pub fn specs() -> Vec { vec![CommandSpec { id: ID, label: "Layer Color", menu: &[], shortcut: None, - params: r#"{"color":"none|red|orange|yellow|green|seafoam|blue|indigo|magenta|fuchsia|violet|gray","layer":id?} (no layer: selected layers; groups include descendants)"#, + params: r#"{"color":"none|red|orange|yellow|green|seafoam|blue|indigo|magenta|fuchsia|violet|gray","layer":id|name|{"background":true}?} (no layer: selected layers; groups include descendants)"#, enabled, run: set_label, journal: true, diff --git a/crates/engine/src/cmd/layer_label_cmds/tests.rs b/crates/engine/src/cmd/layer_label_cmds/tests.rs index 78fa018e7d..3288c5aebf 100644 --- a/crates/engine/src/cmd/layer_label_cmds/tests.rs +++ b/crates/engine/src/cmd/layer_label_cmds/tests.rs @@ -1,6 +1,6 @@ use super::*; use photocraft_color::SampleType; -use photocraft_doc::{Layer, LayerContent}; +use photocraft_doc::{Layer, LayerContent, LayerId}; fn fixture(depth: u8) -> (Session, [LayerId; 4]) { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/layer_params.rs b/crates/engine/src/cmd/layer_params.rs index f298893b33..bfe4c642a7 100644 --- a/crates/engine/src/cmd/layer_params.rs +++ b/crates/engine/src/cmd/layer_params.rs @@ -4,7 +4,8 @@ use super::{BlendMode, Document, EngineError, Layer, LayerId, LayerMask, Result, /// The layer a command's `"layer"` param names: an id, or a name (the topmost layer of that /// name, as Photoshop's "select layer by name" action step finds it, so a loaded action replays /// in another document; a name that matches no layer exactly matches regardless of case, so -/// `"background"` finds the Background). `None` when the param is absent. +/// `"background"` finds that name). `{"background":true}` targets the actual Background, +/// independently of its name. `None` when the param is absent. pub(crate) fn explicit_layer(s: &Session, p: &Value) -> Result> { match p.get("layer") { Some(Value::Number(n)) => { @@ -21,8 +22,12 @@ pub(crate) fn explicit_layer(s: &Session, p: &Value) -> Result> .map(|(_, _, l)| Some(l.id)) .ok_or_else(|| EngineError::Other(format!("no layer named \"{name}\"; {}", layer_listing(&d.doc)))) } + Some(Value::Object(selector)) if selector.len() == 1 && selector.get("background") == Some(&Value::Bool(true)) => { + let d = s.active().ok_or(EngineError::NoDocument)?; + d.doc.background().map(|l| Some(l.id)).ok_or_else(|| EngineError::Other("the document has no Background layer".into())) + } Some(Value::Null) | None => Ok(None), - Some(other) => Err(EngineError::Other(format!("`layer` must be an id or a name, not {other}"))), + Some(other) => Err(EngineError::Other(format!("`layer` must be an id, a name or {{\"background\":true}}, not {other}"))), } } From d7bce26deb437546c221cb5afd983cba82190d21 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Sun, 1 Nov 2026 05:33:41 -0800 Subject: [PATCH 085/216] Add optional layer-style scaling to Image Size --- crates/engine/src/cmd/image_cmds.rs | 14 +- .../engine/tests/image_size_scale_styles.rs | 124 ++++++++++++++++++ 2 files changed, 135 insertions(+), 3 deletions(-) create mode 100644 crates/engine/tests/image_size_scale_styles.rs diff --git a/crates/engine/src/cmd/image_cmds.rs b/crates/engine/src/cmd/image_cmds.rs index c8c3e0b7d2..cd18a7427a 100644 --- a/crates/engine/src/cmd/image_cmds.rs +++ b/crates/engine/src/cmd/image_cmds.rs @@ -238,6 +238,7 @@ fn image_size(s: &mut Session, p: &Value) -> Result { check_resample_budget("image.imageSize", &d.doc, nw, nh, ", or turn Resample off to change only the resolution")?; } let dpi = p.get("resolution").and_then(Value::as_f64).map(|v| v as f32); + let scale_styles = p.get("scaleStyles").and_then(Value::as_bool).unwrap_or(true); if !matches!(resample, Method::None) && (nw, nh) != (ow, oh) { // Color Sampler points scale with the pixels (same map `transform_geometry` applies). let (sx, sy) = (f64::from(nw) / f64::from(ow.max(1)), f64::from(nh) / f64::from(oh.max(1))); @@ -254,7 +255,7 @@ fn image_size(s: &mut Session, p: &Value) -> Result { Method::Automatic if nw > ow || nh > oh => Resample::PreserveDetails, Method::Automatic => Resample::BicubicSharper, }; - resample_doc(doc, nw, nh, filter); + resample_doc_with_styles(doc, nw, nh, filter, scale_styles); Ok(()) })?; // The size the document has now: without resampling `width`/`height` are ignored (#1815). @@ -292,6 +293,11 @@ pub(crate) fn check_working_budget(cmd: &str, doc: &Document, nw: u32, nh: u32, /// `nw` x `nh` px with `filter`: the body of Image Size, shared by the Crop tool's W x H x Resolution /// mode. Callers check [`check_resample_budget`] first. pub(crate) fn resample_doc(doc: &mut Document, nw: u32, nh: u32, filter: Resample) { + resample_doc_with_styles(doc, nw, nh, filter, true); +} + +/// Resize document content while optionally retaining layer-effect sizes in pixels. +fn resample_doc_with_styles(doc: &mut Document, nw: u32, nh: u32, filter: Resample, scale_styles: bool) { let (ow, oh) = (doc.size.width, doc.size.height); let (sx, sy) = (nw as f64 / ow.max(1) as f64, nh as f64 / oh.max(1) as f64); if (sx - 1.0).abs() < 1e-12 && (sy - 1.0).abs() < 1e-12 { @@ -305,7 +311,9 @@ pub(crate) fn resample_doc(doc: &mut Document, nw: u32, nh: u32, filter: Resampl }); let k = ((sx + sy) / 2.0) as f32; for_each_layer(&mut doc.layers, &mut |l| { - scale_effects(&mut l.effects, k); + if scale_styles { + scale_effects(&mut l.effects, k); + } // Canvas resampling scales the whole appearance, including shape strokes (#8341). // Dash lengths and offsets are already expressed in multiples of this width. if let LayerContent::Shape(sh) = &mut l.content @@ -983,7 +991,7 @@ pub fn specs() -> Vec { "image.imageSize", "Image Size…", ["Image"], - r##"{"width":px,"height":px,"percent":%? (both sides; an explicit side wins),"resolution":ppi,"keepPrintSize":bool=false (with only a resolution: resample the pixels to keep the print size),"resample":"automatic|preserveDetails|bicubicSmoother|bicubicSharper|bicubicAutomatic|bicubic|nearest|bilinear|lanczos|none"="bicubic"} → {width,height} (the document's size after the command; "none" changes the resolution only)"##, + r##"{"width":px,"height":px,"percent":%? (both sides; an explicit side wins),"resolution":ppi,"keepPrintSize":bool=false (with only a resolution: resample the pixels to keep the print size),"scaleStyles":bool=true (scale layer effects with the pixels),"resample":"automatic|preserveDetails|bicubicSmoother|bicubicSharper|bicubicAutomatic|bicubic|nearest|bilinear|lanczos|none"="bicubic"} → {width,height} (the document's size after the command; "none" changes the resolution only)"##, has_doc, image_size ), diff --git a/crates/engine/tests/image_size_scale_styles.rs b/crates/engine/tests/image_size_scale_styles.rs new file mode 100644 index 0000000000..4ae6cbabd2 --- /dev/null +++ b/crates/engine/tests/image_size_scale_styles.rs @@ -0,0 +1,124 @@ +//! Image Size can preserve layer-style sizes while resampling the document's geometry. + +use std::sync::Arc; + +use photocraft_color::{BlendMode, Color}; +use photocraft_doc::{Effect, Effects, FxCommon, FxPaint, Layer, LayerContent, LayerId, StrokeFx, StrokePosition}; +use photocraft_engine::Session; +use serde_json::json; + +fn styled_shape(depth: u32) -> (Session, LayerId) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 80, "height": 40, "depth": depth, "background": "transparent"})).unwrap(); + let id = s.execute("shape.create", json!({"rect": [20, 10, 40, 20], "fill": "#ff0000", "stroke": {"width": 4, "color": "#000000"}})).unwrap()["layer"] + .as_u64() + .unwrap(); + let id = LayerId(id); + s.edit("Styles", |doc, _| { + let effects = Effects { + enabled: true, + items: vec![Effect::Stroke(StrokeFx { + common: FxCommon::new(BlendMode::Normal, 1.0), + size: 20.0, + position: StrokePosition::Outside, + paint: FxPaint::Color(Color::rgb(0.0, 0.0, 1.0)), + overprint: false, + })], + psd_raw: Some(Arc::new(b"preserved layer style".to_vec())), + ..Default::default() + }; + doc.layer_mut(id).unwrap().effects = effects.clone(); + let children = std::mem::take(&mut doc.layers); + let mut outer = Layer::group("Outer", vec![Layer::group("Inner", children)]); + outer.effects = effects; + doc.layers.push(outer); + Ok(()) + }) + .unwrap(); + (s, id) +} + +fn shape_stroke(s: &Session, id: LayerId) -> f32 { + let LayerContent::Shape(shape) = &s.active().unwrap().doc.layer(id).unwrap().content else { panic!("shape lost") }; + shape.stroke.as_ref().unwrap().width +} + +/// Native omission keeps the old default; false preserves style sizes and raw PSD effects. +#[test] +fn image_size_scale_styles_is_optional_at_every_depth() { + for depth in [8, 16, 32] { + for option in [None, Some(true), Some(false)] { + let (mut s, id) = styled_shape(depth); + let before = s.active().unwrap().doc.clone(); + let group = before.layers[0].id; + let past = s.active().unwrap().history.past_len(); + let mut params = json!({"percent": 50, "resample": "nearest"}); + if let Some(value) = option { + params["scaleStyles"] = json!(value); + } + s.execute("image.imageSize", params).unwrap(); + let after = s.active().unwrap().doc.clone(); + assert_eq!((after.size.width, after.size.height), (40, 20)); + assert_eq!(shape_stroke(&s, id), 2.0, "{depth}/{option:?}: geometric strokes always scale"); + assert_eq!(s.active().unwrap().history.past_len(), past + 1); + for layer in [group, id] { + let effects = &after.layer(layer).unwrap().effects; + let Effect::Stroke(stroke) = &effects.items[0] else { panic!("style lost") }; + assert_eq!(stroke.size, if option == Some(false) { 20.0 } else { 10.0 }, "{depth}/{option:?}: layer style stroke"); + if option == Some(false) { + assert_eq!(effects, &before.layer(layer).unwrap().effects, "{depth}: preserve every style setting and raw block"); + } else { + assert!(effects.psd_raw.is_none(), "scaled effects invalidate their old PSD block"); + } + } + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.active().unwrap().doc.size, before.size); + assert_eq!(shape_stroke(&s, id), 4.0); + for layer in [group, id] { + assert_eq!(s.active().unwrap().doc.layer(layer).unwrap().effects, before.layer(layer).unwrap().effects); + } + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(s.active().unwrap().doc.size, after.size); + assert_eq!(shape_stroke(&s, id), 2.0); + for layer in [group, id] { + assert_eq!(s.active().unwrap().doc.layer(layer).unwrap().effects, after.layer(layer).unwrap().effects); + } + } + } +} + +/// Resolution-only and unchanged-size commands leave styles intact even when scaling is enabled. +#[test] +fn image_size_without_pixel_resampling_preserves_styles() { + for mut params in [json!({"percent": 50, "resolution": 144, "resample": "none"}), json!({"percent": 100, "resample": "nearest"})] { + for option in [false, true] { + let (mut s, id) = styled_shape(8); + let before = s.active().unwrap().doc.clone(); + params["scaleStyles"] = json!(option); + s.execute("image.imageSize", params.clone()).unwrap(); + let after = &s.active().unwrap().doc; + assert_eq!(after.size, before.size); + assert_eq!(shape_stroke(&s, id), 4.0); + for layer in [before.layers[0].id, id] { + assert_eq!(after.layer(layer).unwrap().effects, before.layer(layer).unwrap().effects); + } + if params["resample"] == "none" { + assert_eq!(after.resolution_dpi, 144.0); + } + } + } +} + +/// Crop's shared resampling path retains its existing style-scaling default. +#[test] +fn crop_target_keeps_scaling_styles() { + let (mut s, id) = styled_shape(8); + let group = s.active().unwrap().doc.layers[0].id; + s.execute("image.crop", json!({"x": 0, "y": 0, "width": 80, "height": 40, "targetWidth": 40, "targetHeight": 20, "resample": "nearest"})).unwrap(); + assert_eq!(shape_stroke(&s, id), 2.0); + for layer in [group, id] { + let effects = &s.active().unwrap().doc.layer(layer).unwrap().effects; + let Effect::Stroke(stroke) = &effects.items[0] else { panic!("style lost") }; + assert_eq!(stroke.size, 10.0); + } +} From de9c5e743ebf57a1625a123e824296521d7c7ae4 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Sun, 1 Nov 2026 05:51:58 -0800 Subject: [PATCH 086/216] Preserve Image Size style scaling through ATN actions --- crates/engine/src/cmd/atn_cmds/steps_doc.rs | 10 +- crates/engine/src/cmd/atn_cmds/tests.rs | 17 +- .../tests/image_size_scale_styles_atn.rs | 149 ++++++++++++++++++ 3 files changed, 168 insertions(+), 8 deletions(-) create mode 100644 crates/engine/tests/image_size_scale_styles_atn.rs diff --git a/crates/engine/src/cmd/atn_cmds/steps_doc.rs b/crates/engine/src/cmd/atn_cmds/steps_doc.rs index 12852536ca..6cfc87e79d 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_doc.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_doc.rs @@ -380,6 +380,13 @@ pub(super) fn image_size_from(d: &Descriptor) -> Mapped { if p.is_empty() { return Mapped::Unsupported { harmless: false }; } + // An omitted Photoshop flag preserves style sizes; the native command defaults to scaling. + let scale_styles = match d.get("scaleStyles") { + None => false, + Some(D::Boolean(value)) => *value, + Some(_) => return Mapped::Unsupported { harmless: false }, + }; + p.insert("scaleStyles".into(), scale_styles.into()); step("image.imageSize", Value::Object(p)) } @@ -418,7 +425,8 @@ pub(super) fn image_size_to(_: &str, p: &Value) -> Option { if let Some(i) = jstr(p, "resample").filter(|r| *r != "none") { d = d.with("Intr", enm("Intp", lookup_back(INTERPOLATION, i)?)); } - Some(d) + // Write the effective native default explicitly: omission in an ATN means false. + Some(d.with("scaleStyles", D::Boolean(jbool(p, "scaleStyles").unwrap_or(true)))) } pub(super) fn crop_from(d: &Descriptor) -> Mapped { diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index f9e633244c..accd831d56 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -527,7 +527,7 @@ fn every_mapped_command_exports_and_imports_back() { ("tools.setColors", json!({"background": "#ffffff"})), ("image.adjustments.desaturate", json!({})), ("image.autoTone", json!({})), - ("image.imageSize", json!({"width": 800, "height": 600, "resolution": 72, "resample": "bicubicSharper"})), + ("image.imageSize", json!({"width": 800, "height": 600, "resolution": 72, "resample": "bicubicSharper", "scaleStyles": true})), ("image.canvasSize", json!({"width": 100, "height": 50, "relative": true, "anchor": "topLeft", "extensionColor": "white"})), ("image.imageRotation.90cw", json!({})), ("image.rotation.arbitrary", json!({"angle": 12.5, "direction": "ccw"})), @@ -620,7 +620,7 @@ fn every_mapped_command_exports_and_imports_back() { ("layer.smartObjects.editContents", json!({})), ("layer.smartObjects.updateModifiedContent", json!({})), ("paint.bucket", json!({"x": 10, "y": 20, "tolerance": 32, "antiAlias": true, "contiguous": false, "opacity": 100})), - ("image.imageSize", json!({"percent": 200, "resample": "bicubicAutomatic"})), + ("image.imageSize", json!({"percent": 200, "resample": "bicubicAutomatic", "scaleStyles": true})), ("file.placeEmbedded", json!({"path": "/tmp/logo.png", "scale": 50})), ("file.placeLinked", json!({"path": "/tmp/logo.png"})), ("file.openAs", json!({"path": "/tmp/a.psd"})), @@ -660,7 +660,7 @@ fn every_mapped_command_exports_and_imports_back() { ("layer.newFillLayer.pattern", json!({"pattern": "Debaser-Distressed-Dark", "scale": 50, "angle": 0, "link": true})), ("layer.fill.pattern.set", json!({"pattern": "1 -R2.jpg"})), ("image.mode.bitmap", json!({"method": "threshold"})), - ("image.imageSize", json!({"resolution": 1200, "resample": "preserveDetails", "keepPrintSize": true})), + ("image.imageSize", json!({"resolution": 1200, "resample": "preserveDetails", "keepPrintSize": true, "scaleStyles": true})), ("layer.new.layerFromBackground", json!({"ifPresent": true, "select": false})), // COPYCAT, AJ_Actions2023 and the Line and Color tutorial set. ("layer.smartFilter.deleteFilterMask", json!({"ifPresent": true})), @@ -2854,8 +2854,8 @@ fn image_size_resolution_only_does_not_gain_keep_print_size() { #[test] fn image_size_intentional_controls_still_round_trip() { for params in [ - json!({"resolution": 144.0, "resample": "nearest", "keepPrintSize": true}), - json!({"width": 12.0, "height": 10.0, "resolution": 144.0, "resample": "nearest"}), + json!({"resolution": 144.0, "resample": "nearest", "keepPrintSize": true, "scaleStyles": true}), + json!({"width": 12.0, "height": 10.0, "resolution": 144.0, "resample": "nearest", "scaleStyles": true}), ] { assert_eq!(image_size_round_trip(¶ms), Some(params.clone())); } @@ -3012,12 +3012,15 @@ fn image_size_resample_none_keeps_ignoring_its_size_through_atn_export() { let mut p = sized.clone(); p["resolution"] = json!(144); p["resample"] = json!("none"); - assert_eq!(back(p.clone()), Some(json!({"resolution": 144, "resample": "none"})), "{p}"); + assert_eq!(back(p.clone()), Some(json!({"resolution": 144, "resample": "none", "scaleStyles": true})), "{p}"); } // A step with nothing left to record is refused rather than exported as an empty (unsupported) event. assert_eq!(back(json!({"width": 12, "height": 10, "resample": "none"})), None); // Real resizes are untouched. - assert_eq!(back(json!({"width": 12, "height": 10, "resample": "bicubic"})), Some(json!({"width": 12, "height": 10, "resample": "bicubic"}))); + assert_eq!( + back(json!({"width": 12, "height": 10, "resample": "bicubic"})), + Some(json!({"width": 12, "height": 10, "resample": "bicubic", "scaleStyles": true})) + ); } /// #8302, #8308: Hue/Saturation's hue is `H ` in Photoshop's `Hst2`, both ways. It was written diff --git a/crates/engine/tests/image_size_scale_styles_atn.rs b/crates/engine/tests/image_size_scale_styles_atn.rs new file mode 100644 index 0000000000..bdac446c76 --- /dev/null +++ b/crates/engine/tests/image_size_scale_styles_atn.rs @@ -0,0 +1,149 @@ +//! #10694: Image Size style scaling survives ATN import, playback and saving. + +use photocraft_color::{BlendMode, Color}; +use photocraft_doc::{Effect, Effects, FxCommon, FxPaint, LayerId, StrokeFx, StrokePosition}; +use photocraft_engine::{Session, actions_cmds::Action, atn_cmds}; +use photocraft_psd::{ + atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}, + descriptor::{Descriptor, UnicodeString, Value as D}, +}; +use serde_json::{Value, json}; + +fn recorded(event: &str, descriptor: Option) -> Vec { + let event = if event == "ImgS" { AtnEvent::Code(*b"ImgS") } else { AtnEvent::name(event) }; + atn::write(&AtnFile { + name: UnicodeString::new_nul("Styles"), + expanded: false, + actions: vec![AtnAction { + name: UnicodeString::new_nul("Half"), + items: vec![AtnItem { event, dictionary: b"Image Size".to_vec(), descriptor, ..Default::default() }], + ..Default::default() + }], + warnings: vec![], + }) + .unwrap() +} + +fn half_size() -> Descriptor { + Descriptor::new("null").with("Wdth", D::UnitFloat { unit: *b"#Prc", value: 50.0 }) +} + +fn load(s: &mut Session, bytes: &[u8]) -> Value { + s.execute("actions.load", json!({"data": photocraft_engine::paint::tile::b64_encode(bytes)})).unwrap() +} + +fn save(s: &mut Session) -> Vec { + let saved = s.execute("actions.save", json!({"set": "Styles"})).unwrap(); + assert_eq!(saved["skipped"], json!([])); + photocraft_engine::paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap() +} + +fn fixture() -> (Session, LayerId) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 80, "height": 40, "background": "transparent"})).unwrap(); + let id = LayerId(s.execute("layer.new.layer", json!({"name": "Styled"})).unwrap()["layer"].as_u64().unwrap()); + s.edit("Style", |doc, _| { + doc.layer_mut(id).unwrap().effects = Effects { + enabled: true, + items: vec![Effect::Stroke(StrokeFx { + common: FxCommon::new(BlendMode::Normal, 1.0), + size: 20.0, + position: StrokePosition::Outside, + paint: FxPaint::Color(Color::rgb(0.0, 0.0, 1.0)), + overprint: false, + })], + ..Default::default() + }; + Ok(()) + }) + .unwrap(); + (s, id) +} + +fn assert_size_and_style(s: &Session, id: LayerId, scale: bool) { + let doc = &s.active().unwrap().doc; + assert_eq!((doc.size.width, doc.size.height), (40, 20)); + let Effect::Stroke(stroke) = &doc.layer(id).unwrap().effects.items[0] else { panic!("stroke lost") }; + assert_eq!(stroke.size, if scale { 10.0 } else { 20.0 }); +} + +#[test] +fn imported_style_scaling_survives_saved_bytes_and_playback() { + for event in ["ImgS", "imageSize"] { + for option in [None, Some(false), Some(true)] { + let mut descriptor = half_size(); + if let Some(value) = option { + descriptor = descriptor.with("scaleStyles", D::Boolean(value)); + } + let (mut s, id) = fixture(); + let loaded = load(&mut s, &recorded(event, Some(descriptor))); + assert_eq!(loaded["unsupported"], json!([])); + let scale = option.unwrap_or(false); + assert_eq!(s.actions.list[0].steps, vec![("image.imageSize".into(), json!({"percent": 50, "scaleStyles": scale}))]); + let saved = save(&mut s); + let parsed = atn::parse(&saved).unwrap(); + assert_eq!(parsed.actions[0].items[0].descriptor.as_ref().unwrap().get("scaleStyles"), Some(&D::Boolean(scale))); + let imported = atn_cmds::import(&saved).unwrap(); + assert!(imported.unsupported.is_empty()); + assert_eq!(imported.actions[0].steps, s.actions.list[0].steps); + s.actions.list = imported.actions; + let result = s.execute("actions.play", json!({"action": "Half"})).unwrap(); + assert_eq!(result["ran"], 1); + assert!(result.get("failed").is_none(), "{result}"); + assert_size_and_style(&s, id, scale); + } + } +} + +#[test] +fn native_default_is_written_explicitly_and_keeps_its_behavior() { + for option in [None, Some(json!(false)), Some(json!(true)), Some(Value::Null), Some(json!(1)), Some(json!("false"))] { + let mut params = json!({"percent": 50}); + if let Some(option) = option { + params["scaleStyles"] = option; + } + let scale = params["scaleStyles"].as_bool().unwrap_or(true); + let action = Action::new("Half", vec![("image.imageSize".into(), params.clone())]); + let (bytes, skipped) = atn_cmds::export("Styles", &[&action], &[], false).unwrap(); + assert!(skipped.is_empty()); + let parsed = atn::parse(&bytes).unwrap(); + assert_eq!(parsed.actions[0].items[0].descriptor.as_ref().unwrap().get("scaleStyles"), Some(&D::Boolean(scale)), "{params}"); + let (mut direct, direct_id) = fixture(); + direct.execute("image.imageSize", params).unwrap(); + assert_size_and_style(&direct, direct_id, scale); + let (mut replay, replay_id) = fixture(); + assert_eq!(load(&mut replay, &bytes)["unsupported"], json!([])); + assert_eq!(replay.actions.list[0].steps[0].1["scaleStyles"], json!(scale)); + let result = replay.execute("actions.play", json!({"action": "Half"})).unwrap(); + assert_eq!(result["ran"], 1); + assert!(result.get("failed").is_none(), "{result}"); + assert_size_and_style(&replay, replay_id, scale); + } +} + +#[test] +fn malformed_or_empty_steps_are_kept_without_becoming_resizes() { + for event in ["ImgS", "imageSize"] { + let descriptors = [ + None, + Some(Descriptor::new("null")), + Some(Descriptor::new("null").with("scaleStyles", D::Boolean(false))), + Some(Descriptor::new("null").with("scaleStyles", D::Boolean(true))), + Some(half_size().with("scaleStyles", D::Integer(0))), + Some(half_size().with("scaleStyles", D::Integer(1))), + Some(half_size().with("scaleStyles", D::Text(UnicodeString::new_nul("false")))), + ]; + for descriptor in descriptors { + let source = recorded(event, descriptor); + let original = atn::parse(&source).unwrap(); + let mut s = Session::new(); + let loaded = load(&mut s, &source); + assert_eq!(loaded["unsupported"].as_array().unwrap().len(), 1); + assert_eq!(s.actions.list[0].steps.len(), 1); + assert_eq!(s.actions.list[0].steps[0].0, atn_cmds::PHOTOSHOP_STEP); + let saved = atn::parse(&save(&mut s)).unwrap(); + assert_eq!(saved.actions[0].items[0].descriptor, original.actions[0].items[0].descriptor); + assert_eq!(saved.actions[0].items[0].event, original.actions[0].items[0].event); + } + } +} From 4875f94ebbd13b3f9f3c244ba28a13c8785008c9 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Sun, 1 Nov 2026 06:06:28 -0800 Subject: [PATCH 087/216] Expose Content-Aware Fill scale and mirror options --- crates/engine/src/cmd.rs | 2 +- crates/engine/src/cmd/fill_cmds.rs | 36 +++-- .../tests/content_aware_fill_options.rs | 136 ++++++++++++++++++ 3 files changed, 161 insertions(+), 13 deletions(-) create mode 100644 crates/engine/tests/content_aware_fill_options.rs diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 19e1e94afc..739a6a20d7 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -318,7 +318,7 @@ fn build() -> Vec { "Fill…", ["Edit"], Some("Shift+F5"), - r##"{"contents":"foreground|background|color|contentAware|pattern|history|black|gray|white"="color","color":"#rrggbb|[r,g,b,a]"=foreground (contents=color),"pattern":id|name (contents=pattern),"scale":%=100,"angle":deg,"state":row?,"snapshot":id? (contents=history: a History panel row as history.setSource, redo rows last, or a snapshot; default the History Brush source),"colorAdaptation":bool=true (contents=contentAware),"mode":"normal|multiply|…"="normal","opacity":0..100=100,"preserveTransparency":bool=false,"target":"pixels"|"mask"|"filterMask"|"quickMask"|{"channel":i}?}"##, + r##"{"contents":"foreground|background|color|contentAware|pattern|history|black|gray|white"="color","color":"#rrggbb|[r,g,b,a]"=foreground (contents=color),"pattern":id|name (contents=pattern),"scale":%=100,"angle":deg,"state":row?,"snapshot":id? (contents=history: a History panel row as history.setSource, redo rows last, or a snapshot; default the History Brush source),"colorAdaptation":bool=true,"scaleAdaptation":bool=false,"mirror":bool=false (contents=contentAware),"mode":"normal|multiply|…"="normal","opacity":0..100=100,"preserveTransparency":bool=false,"target":"pixels"|"mask"|"filterMask"|"quickMask"|{"channel":i}?}"##, has_pixel_or_channel, crate::fill_cmds::fill ), diff --git a/crates/engine/src/cmd/fill_cmds.rs b/crates/engine/src/cmd/fill_cmds.rs index 35d25119a9..d7b2c269a1 100644 --- a/crates/engine/src/cmd/fill_cmds.rs +++ b/crates/engine/src/cmd/fill_cmds.rs @@ -5,6 +5,7 @@ //! transparency. The selection (or the whole canvas) is filled, as one history step ("Fill"). //! Every parameter is validated: a bad one is an error, never a panic. +use photocraft_algo::content_aware::FillOptions; use photocraft_color::BlendMode; use photocraft_geom::{Rect, TILE_SIZE}; use photocraft_raster::{Surface, from_rgba_into, to_rgba}; @@ -132,10 +133,13 @@ pub fn fill(s: &mut Session, p: &Value) -> Result { return Err(EngineError::Other("Content-Aware fill works on layer pixels only".into())); } let adapt = flag(p, "colorAdaptation", true)?; + let scale = flag(p, "scaleAdaptation", false)?; + let mirror = flag(p, "mirror", false)?; let lid = id.ok_or_else(|| EngineError::Other("no active layer".into()))?; let surf = st.doc.layer(lid).and_then(|l| l.surface()).ok_or_else(|| EngineError::Other("Content-Aware fill needs a pixel layer".into()))?; let seed = p.get("seed").and_then(Value::as_u64).unwrap_or(1); - content_aware(surf, sel, doc_bounds, adapt, seed)? + let options = FillOptions { scale, mirror, ..content_aware_options(adapt, seed) }; + content_aware(surf, sel, doc_bounds, &options)? } _ => return Err(bad(format!("unknown contents `{contents}`"))), }; @@ -225,13 +229,13 @@ pub(crate) fn blend_color_mask( /// The selection filled from its surroundings (`photocraft_algo::content_aware`), as straight /// RGBA over the sampling window. Only the selected pixels are used. -fn content_aware(surf: &Surface, sel: &Surface, canvas: Rect, color_adaptation: bool, seed: u64) -> Result { +fn content_aware(surf: &Surface, sel: &Surface, canvas: Rect, options: &FillOptions) -> Result { let hole_bounds = sel.content_bounds().intersect(&canvas); if hole_bounds.is_empty() { return Err(EngineError::Other("the selection is outside the canvas".into())); } let is_hole = |x: i32, y: i32| sel.sample_channel(x, y, 0) > 0.0; - let (window, filled) = content_aware_native(surf, hole_bounds, canvas, color_adaptation, seed, is_hole, |_, _| true)?; + let (window, filled) = content_aware_native_with_options(surf, hole_bounds, canvas, options, is_hole, |_, _| true)?; let fmt = surf.format(); let px = filled.chunks_exact(fmt.channels().max(1)).map(|c| to_rgba(&fmt, c)).collect(); Ok(Source::Pixels(window, px)) @@ -251,7 +255,22 @@ pub(crate) fn content_aware_native( is_hole: impl Fn(i32, i32) -> bool, usable: impl Fn(i32, i32) -> bool, ) -> Result<(Rect, Vec)> { - use photocraft_algo::content_aware::{FillOptions, color_level, fill, rotation_level}; + content_aware_native_with_options(surf, hole_bounds, canvas, &content_aware_options(color_adaptation, seed), is_hole, usable) +} + +fn content_aware_options(color_adaptation: bool, seed: u64) -> FillOptions { + FillOptions { color_adaptation: photocraft_algo::content_aware::color_level(if color_adaptation { "default" } else { "none" }), seed, ..Default::default() } +} + +fn content_aware_native_with_options( + surf: &Surface, + hole_bounds: Rect, + canvas: Rect, + options: &FillOptions, + is_hole: impl Fn(i32, i32) -> bool, + usable: impl Fn(i32, i32) -> bool, +) -> Result<(Rect, Vec)> { + use photocraft_algo::content_aware::fill; let window = sampling_window(hole_bounds, canvas); let n = surf.format().channels(); let (w, h) = window_size(window)?; @@ -267,17 +286,10 @@ pub(crate) fn content_aware_native( if !source.iter().any(|s| *s) { return Err(EngineError::Other("Content-Aware fill needs pixels outside the area to fill to sample from".into())); } - let opts = FillOptions { - color_adaptation: color_level(if color_adaptation { "default" } else { "none" }), - rotations: rotation_level("none"), - scale: false, - mirror: false, - seed, - }; if img.len() != len * n { return Err(EngineError::Other("could not read the layer pixels".into())); } - Ok((window, fill(w, h, n, &img, &hole, &source, &opts))) + Ok((window, fill(w, h, n, &img, &hole, &source, options))) } /// Photoshop's Content-Aware sampling window around the selection bounds `hole` diff --git a/crates/engine/tests/content_aware_fill_options.rs b/crates/engine/tests/content_aware_fill_options.rs new file mode 100644 index 0000000000..9dd3ca2205 --- /dev/null +++ b/crates/engine/tests/content_aware_fill_options.rs @@ -0,0 +1,136 @@ +//! Content-Aware Fill exposes native scale and mirror adaptation without changing defaults. + +use photocraft_algo::content_aware::{FillOptions, color_level, fill}; +use photocraft_doc::{LayerId, Rect}; +use photocraft_engine::Session; +use photocraft_raster::Surface; +use serde_json::{Value, json}; + +const WIDTH: usize = 64; +const HEIGHT: usize = 48; +const HOLE: Rect = Rect::new(28, 20, 36, 28); + +fn fixture(depth: u32) -> (Session, LayerId) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": WIDTH, "height": HEIGHT, "depth": depth, "background": "transparent"})).unwrap(); + let id = s.active().unwrap().doc.layers[0].id; + s.edit("Asymmetric texture", |doc, _| { + let surface = doc.layer_mut(id).unwrap().surface_mut().unwrap(); + let mut pixels = Vec::with_capacity(WIDTH * HEIGHT * 4); + for y in 0..HEIGHT { + for x in 0..WIDTH { + pixels.extend([ + ((37 * x + 11 * y + x * y % 17) % 251) as f32 / 250.0, + ((x * x + 13 * y + 3 * (x / 7)) % 241) as f32 / 240.0, + ((5 * x + 3 * y * y + 19 * (x / 9)) % 239) as f32 / 238.0, + 1.0, + ]); + } + } + surface.write_region(Rect::from_xywh(0, 0, WIDTH as u32, HEIGHT as u32), &pixels); + Ok(()) + }) + .unwrap(); + s.execute("select.rect", json!({"x": HOLE.x0, "y": HOLE.y0, "width": HOLE.width(), "height": HOLE.height()})).unwrap(); + (s, id) +} + +fn surface(s: &Session, id: LayerId) -> &Surface { + s.active().unwrap().doc.layer(id).unwrap().surface().unwrap() +} + +fn pixels(s: &Session, id: LayerId) -> Vec { + surface(s, id).read_region(s.active().unwrap().doc.bounds()) +} + +fn assert_pixels(actual: &[f32], expected: &[f32], label: &str) { + assert_eq!(actual.len(), expected.len(), "{label}"); + let difference = actual.iter().zip(expected).enumerate().find(|(_, (a, b))| a != b).map(|(i, (a, b))| (i, *a, *b)); + assert_eq!(difference, None, "{label}"); +} + +/// The small canvas is wholly inside the command's sampling window. +fn oracle(s: &Session, id: LayerId, color: bool, scale: bool, mirror: bool) -> Vec { + let input = surface(s, id); + let mut hole = vec![false; WIDTH * HEIGHT]; + for y in HOLE.y0..HOLE.y1 { + for x in HOLE.x0..HOLE.x1 { + hole[y as usize * WIDTH + x as usize] = true; + } + } + let source: Vec<_> = hole.iter().map(|v| !v).collect(); + let options = FillOptions { color_adaptation: color_level(if color { "default" } else { "none" }), scale, mirror, seed: 7, ..Default::default() }; + let filled = fill(WIDTH, HEIGHT, input.channels(), &pixels(s, id), &hole, &source, &options); + // Match the destination's sample encoding at 8/16-bit depth. + let mut expected = input.clone(); + expected.write_region(s.active().unwrap().doc.bounds(), &filled); + expected.read_region(s.active().unwrap().doc.bounds()) +} + +#[test] +fn geometric_options_reach_native_fill_and_undo_as_one_edit() { + for depth in [8, 16, 32] { + for (key, scale, mirror) in [("scaleAdaptation", true, false), ("mirror", false, true)] { + let (mut s, id) = fixture(depth); + let before = pixels(&s, id); + let expected = oracle(&s, id, false, scale, mirror); + let default = oracle(&s, id, false, false, false); + assert_ne!(expected, default, "fixture must distinguish {key} at {depth}-bit"); + let history = s.active().unwrap().history.past_len(); + let mut params = json!({"contents": "contentAware", "colorAdaptation": false, "seed": 7}); + params[key] = json!(true); + s.execute("edit.fill", params).unwrap(); + assert_pixels(&pixels(&s, id), &expected, &format!("{key} at {depth}-bit")); + assert_eq!(s.active().unwrap().history.past_len(), history + 1); + for y in 0..HEIGHT { + for x in 0..WIDTH { + if !HOLE.contains(x as i32, y as i32) { + let i = (y * WIDTH + x) * 4; + assert_eq!(&expected[i..i + 4], &before[i..i + 4]); + } + } + } + s.execute("edit.undo", json!({})).unwrap(); + assert_pixels(&pixels(&s, id), &before, "undo"); + s.execute("edit.redo", json!({})).unwrap(); + assert_pixels(&pixels(&s, id), &expected, "redo"); + } + } +} + +#[test] +fn omitted_false_and_null_options_keep_existing_fill_defaults() { + for depth in [8, 16, 32] { + for value in [None, Some(json!(false)), Some(Value::Null)] { + let (mut s, id) = fixture(depth); + let expected = oracle(&s, id, true, false, false); + let mut params = json!({"contents": "contentAware", "seed": 7}); + if let Some(value) = value { + params["scaleAdaptation"] = value.clone(); + params["mirror"] = value; + } + s.execute("edit.fill", params).unwrap(); + assert_pixels(&pixels(&s, id), &expected, &format!("default at {depth}-bit")); + } + } +} + +#[test] +fn invalid_geometric_options_fail_before_changing_pixels_or_history() { + for key in ["scaleAdaptation", "mirror"] { + for value in [json!(0), json!("false"), json!([]), json!({})] { + let (mut s, id) = fixture(8); + let before = pixels(&s, id); + let history = s.active().unwrap().history.past_len(); + let mut params = json!({"contents": "contentAware", "seed": 7}); + params[key] = value; + let error = s.execute("edit.fill", params).unwrap_err().to_string(); + assert!(error.contains(key), "{error}"); + assert_pixels(&pixels(&s, id), &before, "invalid option"); + assert_eq!(s.active().unwrap().history.past_len(), history); + } + } + // Pattern's scale remains a numeric placement parameter and is not a CAF Boolean. + let (mut s, _) = fixture(8); + s.execute("edit.fill", json!({"contents": "pattern", "pattern": "Diagonal Lines", "scale": 75})).unwrap(); +} From da496ec03f53d881efd5fb62e225fd956406b40b Mon Sep 17 00:00:00 2001 From: yoriswind Date: Sun, 1 Nov 2026 06:23:16 -0800 Subject: [PATCH 088/216] Document Content-Aware Fill adaptation helpers --- crates/engine/src/cmd/fill_cmds.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/engine/src/cmd/fill_cmds.rs b/crates/engine/src/cmd/fill_cmds.rs index d7b2c269a1..69ea798836 100644 --- a/crates/engine/src/cmd/fill_cmds.rs +++ b/crates/engine/src/cmd/fill_cmds.rs @@ -258,10 +258,12 @@ pub(crate) fn content_aware_native( content_aware_native_with_options(surf, hole_bounds, canvas, &content_aware_options(color_adaptation, seed), is_hole, usable) } +/// The adaptation defaults shared by Fill and Content-Aware Crop. fn content_aware_options(color_adaptation: bool, seed: u64) -> FillOptions { FillOptions { color_adaptation: photocraft_algo::content_aware::color_level(if color_adaptation { "default" } else { "none" }), seed, ..Default::default() } } +/// Fill the bounded sampling window with native channel values and the requested adaptations. fn content_aware_native_with_options( surf: &Surface, hole_bounds: Rect, From abb28e4d37fed00f3de4f59bb5e202732cdd9713 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Sun, 1 Nov 2026 06:24:58 -0800 Subject: [PATCH 089/216] Correct mapped ATN identifiers and wrap document offsets --- crates/engine/src/cmd.rs | 4 +- crates/engine/src/cmd/atn_cmds/events.rs | 4 +- crates/engine/src/cmd/atn_cmds/steps_doc.rs | 15 +- crates/engine/src/cmd/atn_cmds/steps_layer.rs | 6 +- crates/engine/src/cmd/atn_cmds/tables.rs | 9 +- crates/engine/src/cmd/atn_cmds/tests.rs | 9 +- crates/engine/src/session/documents.rs | 10 +- crates/engine/src/tests.rs | 21 +- crates/engine/tests/atn_mapped_identifiers.rs | 205 ++++++++++++++++++ 9 files changed, 248 insertions(+), 35 deletions(-) create mode 100644 crates/engine/tests/atn_mapped_identifiers.rs diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index af2626f870..d4e14c1e36 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -239,6 +239,7 @@ fn registry() -> &'static Registry { // ---------- the table ---------- #[allow(clippy::too_many_lines)] +/// Register the core file, edit, layer and document commands. fn build() -> Vec { let mut v = vec![ // File @@ -1070,8 +1071,7 @@ fn build() -> Vec { let idx = match (given("document"), given("offset")) { (Some(_), Some(_)) => return Err(bad("document.activate", "give `document` or `offset`, not both")), (None, None) => return Err(bad("document.activate", "needs `document` (a tab index) or `offset`")), - // Refuse offsets past the opening-order ends rather than wrap, so an action that - // copies from one document never silently pastes into the wrong one (#7103). + // Recorded offsets wrap in opening order, independently of dragged tabs. (None, Some(o)) => { let o = o.as_i64().ok_or_else(|| bad("document.activate", "`offset` must be an integer"))?; s.document_at_offset(o)? diff --git a/crates/engine/src/cmd/atn_cmds/events.rs b/crates/engine/src/cmd/atn_cmds/events.rs index d056089b29..941589d9ee 100644 --- a/crates/engine/src/cmd/atn_cmds/events.rs +++ b/crates/engine/src/cmd/atn_cmds/events.rs @@ -281,7 +281,7 @@ pub(super) const EVENTS: &[Event] = &[ adjustment_event!("", "PhtF", "photoFilter", "Photo Filter"), adjustment_event!("", "BanW", "blackAndWhite", "Black & White", "image.adjustments.blackWhite"), adjustment_event!("ChnM", "ChnM", "channelMixer", "Channel Mixer"), - adjustment_event!("GdMp", "GdMp", "gradientMapEvent", "Gradient Map", "image.adjustments.gradientMap"), + adjustment_event!("", "GdMp", "gradientMapEvent", "Gradient Map", "image.adjustments.gradientMap"), adjustment_event!("SlcC", "SlcC", "selectiveColor", "Selective Color"), ev!("Dstt", "desaturate", "Desaturate", "image.adjustments.desaturate", &[]), ev!("Eqlz", "equalize", "Equalize", "image.adjustments.equalize", &[]), @@ -620,7 +620,7 @@ pub(super) const EVENTS: &[Event] = &[ /// `flatness` property, not the flatten event. Import keeps accepting them so actions saved /// before still load; export never writes them. const LEGACY_CODES: &[(&str, &str)] = - &[("BoxB", "boxblur"), ("SrfB", "surfaceBlur"), ("Fltn", "flattenImage"), ("BlnW", "blackAndWhite"), ("PhtF", "photoFilter")]; + &[("BoxB", "boxblur"), ("SrfB", "surfaceBlur"), ("Fltn", "flattenImage"), ("BlnW", "blackAndWhite"), ("PhtF", "photoFilter"), ("GdMp", "gradientMapEvent")]; pub(super) fn event_for(item: &AtnEvent) -> Option<&'static Event> { let id = item.id(); diff --git a/crates/engine/src/cmd/atn_cmds/steps_doc.rs b/crates/engine/src/cmd/atn_cmds/steps_doc.rs index 7707976509..154acaf1b5 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_doc.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_doc.rs @@ -267,6 +267,7 @@ pub(super) fn convert_mode_to(id: &str, p: &Value) -> Option { }) } +/// Import canvas dimensions, anchors and explicit extension-colour choices. pub(super) fn canvas_size_from(d: &Descriptor) -> Mapped { let mut p = Map::new(); for (k, param) in [("Wdth", "width"), ("Hght", "height")] { @@ -296,22 +297,25 @@ pub(super) fn canvas_size_from(d: &Descriptor) -> Mapped { _ => return Mapped::Unsupported { harmless: false }, }; p.insert("anchor".into(), anchor.into()); - if let Some(c) = get_enum(d, "canvasExtensionColorType") { + if d.get("canvasExtensionColorType").is_some() { + let Some(c) = get_enum(d, "canvasExtensionColorType") else { return Mapped::Unsupported { harmless: false } }; match lookup(EXTENSION, &c) { Some(c) => { p.insert("extensionColor".into(), c.into()); } - None => match get_obj(d, "canvasExtensionColor").and_then(color_hex) { + None if c == "Clr" || c == "Othr" => match get_obj(d, "canvasExtensionColor").and_then(color_hex) { Some(hex) => { p.insert("extensionColor".into(), hex.into()); } None => return Mapped::Unsupported { harmless: false }, }, + None => return Mapped::Unsupported { harmless: false }, } } step("image.canvasSize", Value::Object(p)) } +/// Write canvas geometry and canonical extension-colour identifiers. pub(super) fn canvas_size_to(_: &str, p: &Value) -> Option { let anchor = jstr(p, "anchor").unwrap_or("center"); let v = if anchor.starts_with("top") { @@ -342,7 +346,7 @@ pub(super) fn canvas_size_to(_: &str, p: &Value) -> Option { if let Some(c) = jstr(p, "extensionColor") { d = match lookup_back(EXTENSION, c) { Some(e) => d.with("canvasExtensionColorType", enm("canvasExtensionColorType", e)), - None => d.with("canvasExtensionColorType", enm("canvasExtensionColorType", "Othr")).with("canvasExtensionColor", D::Descriptor(color_desc(c)?)), + None => d.with("canvasExtensionColorType", enm("canvasExtensionColorType", "Clr ")).with("canvasExtensionColor", D::Descriptor(color_desc(c)?)), }; } Some(d) @@ -537,12 +541,14 @@ pub(super) fn reset_to(_: &str, _: &Value) -> Option { Some(desc("null").with("null", D::Reference(vec![ReferenceItem::Property { class: class("Clr "), key: Id::new("Clrs") }]))) } +/// Resolve an assigned or converted profile, preserving a supported rendering intent. pub(super) fn profile_from(d: &Descriptor, id: &str) -> Mapped { let name = get_text(d, "T ").or_else(|| get_text(d, "profile")); let Some(profile) = name.as_deref().and_then(|n| lookup(PROFILES, n)) else { return Mapped::Unsupported { harmless: false } }; let mut p = json!({"profile": profile}); if id == "edit.convertToProfile" { - if let Some(i) = get_enum(d, "Inte").and_then(|i| lookup(INTENT, &i)) { + if d.get("Inte").is_some() { + let Some(i) = get_enum(d, "Inte").and_then(|i| lookup(INTENT, &i)) else { return Mapped::Unsupported { harmless: false } }; p["intent"] = json!(i); } if let Some(b) = get_bool(d, "MpBl") { @@ -552,6 +558,7 @@ pub(super) fn profile_from(d: &Descriptor, id: &str) -> Mapped { step(id, p) } +/// Export the supported profile and canonical rendering-intent identifier. pub(super) fn profile_to(id: &str, p: &Value) -> Option { let name = lookup_back(PROFILES, jstr(p, "profile")?)?; let mut d = desc("null").with("null", target_ref("Dcmn")); diff --git a/crates/engine/src/cmd/atn_cmds/steps_layer.rs b/crates/engine/src/cmd/atn_cmds/steps_layer.rs index 1e18126ae3..cf58be7c58 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_layer.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_layer.rs @@ -57,7 +57,7 @@ pub(super) fn select_from(d: &Descriptor) -> Mapped { }, // Switching to another open document: its offset from the active one, -1 for the previous // opened document and 1 for the next, independently of dragged tabs (#7103). - (Some("Dcmn"), Target::Offset(o)) => step("document.activate", json!({"offset": o})), + (Some("Dcmn"), Target::Offset(o)) if o != 0 => step("document.activate", json!({"offset": o})), _ => Mapped::Unsupported { harmless: false }, } } @@ -105,6 +105,10 @@ pub(super) fn select_to(id: &str, p: &Value) -> Option { // An absolute tab index has no Photoshop equivalent; an offset is what Photoshop records. "document.activate" if p.get("document").is_none_or(Value::is_null) => { let offset = i32::try_from(p.get("offset")?.as_i64()?).ok()?; + // Photoshop rejects zero, though the native command permits that no-op. + if offset == 0 { + return None; + } desc("null").with("null", D::Reference(vec![ReferenceItem::Offset { class: class("Dcmn"), offset }])) } _ => { diff --git a/crates/engine/src/cmd/atn_cmds/tables.rs b/crates/engine/src/cmd/atn_cmds/tables.rs index 0f3bc278e9..0a6a07abee 100644 --- a/crates/engine/src/cmd/atn_cmds/tables.rs +++ b/crates/engine/src/cmd/atn_cmds/tables.rs @@ -47,8 +47,8 @@ const BLEND_MODES: &[(&str, &str)] = &[ ("color", "Color"), ("Lmns", "Luminosity"), ("luminosity", "Luminosity"), - ("PsTh", "Pass Through"), ("passThrough", "Pass Through"), + ("PsTh", "Pass Through"), ]; pub(super) fn blend_label(ps: &str) -> Option<&'static str> { @@ -125,7 +125,8 @@ pub(super) const OFFSET_FILL: &[(&str, &str)] = &[("Wrp ", "wrap"), ("Rpt ", "re pub(super) const TRIM: &[(&str, &str)] = &[("Trns", "transparent"), ("topLeftPixelColor", "topLeft"), ("bottomRightPixelColor", "bottomRight")]; -pub(super) const EXTENSION: &[(&str, &str)] = &[("Bckg", "background"), ("Frgd", "foreground"), ("Wht ", "white"), ("Blck", "black"), ("Trns", "transparent")]; +pub(super) const EXTENSION: &[(&str, &str)] = + &[("Bckg", "background"), ("FrgC", "foreground"), ("Frgd", "foreground"), ("Wht ", "white"), ("Blck", "black"), ("Trns", "transparent")]; pub(super) const FILL_CONTENTS: &[(&str, &str)] = &[ ("FrgC", "foreground"), @@ -138,9 +139,9 @@ pub(super) const FILL_CONTENTS: &[(&str, &str)] = &[ ("contentAware", "contentAware"), ]; -pub(super) const STROKE_LOCATION: &[(&str, &str)] = &[("Insd", "inside"), ("Cntr", "center"), ("OtsF", "outside")]; +pub(super) const STROKE_LOCATION: &[(&str, &str)] = &[("Insd", "inside"), ("Cntr", "center"), ("Otsd", "outside"), ("OtsF", "outside")]; -pub(super) const INTENT: &[(&str, &str)] = &[("Img ", "perceptual"), ("Clrm", "relative"), ("Grp ", "saturation"), ("AbsC", "absolute")]; +pub(super) const INTENT: &[(&str, &str)] = &[("Img ", "perceptual"), ("Clrm", "relative"), ("Grp ", "saturation"), ("AClr", "absolute"), ("AbsC", "absolute")]; pub(super) const PROFILES: &[(&str, &str)] = &[("sRGB IEC61966-2.1", "srgb"), ("Display P3", "display-p3"), ("Adobe RGB (1998)", "adobe-rgb-compat"), ("ProPhoto RGB", "prophoto-compat")]; diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index 6c28a7d3e0..7f30bf21b2 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -4,7 +4,7 @@ use photocraft_psd::descriptor::{Descriptor, Id, ReferenceItem, UnicodeString, V use serde_json::json; #[test] -fn blend_modes_accept_photoshop_string_ids_and_export_canonical_char_ids() { +fn blend_modes_accept_photoshop_string_ids_and_export_canonical_ids() { for (string_id, char_id, label) in [ ("normal", "Nrml", "Normal"), ("dissolve", "Dslv", "Dissolve"), @@ -23,7 +23,7 @@ fn blend_modes_accept_photoshop_string_ids_and_export_canonical_char_ids() { ("saturation", "Strt", "Saturation"), ("color", "Clr", "Color"), ("luminosity", "Lmns", "Luminosity"), - ("passThrough", "PsTh", "Pass Through"), + ("passThrough", "passThrough", "Pass Through"), ] { assert_eq!(blend_label(string_id), Some(label), "{string_id}"); assert_eq!(blend_ps(label), Some(char_id), "{label} remains serialized canonically"); @@ -1586,9 +1586,8 @@ fn select_document_by_offset_imports_plays_and_saves_back() { one.execute("file.new", json!({"width": 8, "height": 8, "name": "template"})).unwrap(); one.execute("actions.load", json!({"data": data})).unwrap(); let r = one.execute("actions.play", json!({"action": "Copy In"})).unwrap(); - assert_eq!(r["failed"]["step"], 0, "{r}"); - assert_eq!(r["failed"]["error"], "no document 1 before the active one, which is 1 of 1", "{r}"); - assert_eq!(one.active().unwrap().doc.layers.len(), 1); + assert!(r.get("failed").is_none(), "{r}"); + assert_eq!(one.active().unwrap().doc.layers.len(), 2, "offsets wrap back to the only open document"); } fn set_of(name: &str, actions: Vec<(&str, Vec)>) -> String { diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index a82f6c18e6..d9955a0e42 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -102,15 +102,13 @@ impl Session { /// Resolve a Photoshop action's offset in opening order, returning its current tab index. /// Tab dragging changes `docs`, but must not change which document an imported action edits. + /// Offsets wrap at either end; native zero keeps the active document. pub(crate) fn document_at_offset(&self, offset: i64) -> Result { let id = self.active().ok_or(EngineError::NoDocument)?.doc.id; let active = self.open_document_order.iter().position(|&open| open == id).ok_or(EngineError::NoDocument)?; - let len = self.open_document_order.len(); - let target = i64::try_from(active).ok().and_then(|a| a.checked_add(offset)).and_then(|i| usize::try_from(i).ok()); - let Some(id) = target.and_then(|i| self.open_document_order.get(i)) else { - let side = if offset < 0 { "before" } else { "after" }; - return Err(EngineError::Other(format!("no document {} {side} the active one, which is {} of {len}", offset.unsigned_abs(), active + 1))); - }; + // Widen before addition so even extreme signed offsets wrap without overflow. + let target = (active as i128 + i128::from(offset)).rem_euclid(self.open_document_order.len() as i128) as usize; + let id = &self.open_document_order[target]; self.docs.iter().position(|st| st.doc.id == *id).ok_or(EngineError::NoDocument) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index bae9cf4c92..5e8c1095b8 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1667,8 +1667,7 @@ fn document_inspect_and_activate_invalid_index() { } /// #7103: Photoshop's Select Document by offset (previous is -1, next +1) walks opening order -/// from the active document. PhotoCraft refuses offsets past either end instead of wrapping -/// onto another document, and bad params leave the active document as it was. +/// from the active document, wrapping at both ends. Bad params preserve the active document. #[test] fn document_activate_by_offset_walks_opening_order() { let mut s = Session::new(); @@ -1688,17 +1687,17 @@ fn document_activate_by_offset_walks_opening_order() { s.execute("document.activate", json!({"document": null, "offset": -1})).unwrap(); assert_eq!(active(&s), "b"); - let err = s.execute("document.activate", json!({"offset": 2})).unwrap_err().to_string(); - assert_eq!(err, "no document 2 after the active one, which is 2 of 3"); - let err = s.execute("document.activate", json!({"offset": -2})).unwrap_err().to_string(); - assert_eq!(err, "no document 2 before the active one, which is 2 of 3"); + for (offset, name) in [(2, "a"), (-2, "c"), (i64::MAX, "c"), (i64::MIN, "c")] { + s.execute("document.activate", json!({"document":1})).unwrap(); + s.execute("document.activate", json!({"offset":offset})).unwrap(); + assert_eq!(active(&s), name, "{offset}"); + } + s.execute("document.activate", json!({"document":1})).unwrap(); for bad in [ json!({}), json!({"offset": null}), json!({"offset": 1.5}), json!({"offset": "1"}), - json!({"offset": i64::MAX}), - json!({"offset": i64::MIN}), json!({"offset": u64::MAX}), json!({"document": 0, "offset": 1}), json!({"document": -1}), @@ -1709,9 +1708,9 @@ fn document_activate_by_offset_walks_opening_order() { // Dragging "c" first changes tab indices, not the opening order a, b, c. s.execute("document.move", json!({"document": 2, "to": 0})).unwrap(); assert_eq!(active(&s), "b"); - let err = s.execute("document.activate", json!({"offset": -2})).unwrap_err().to_string(); - assert_eq!(err, "no document 2 before the active one, which is 2 of 3", "the error reports opening order, not tab position"); - assert_eq!(active(&s), "b", "a failed offset must not activate the visually first tab"); + assert_eq!(s.execute("document.activate", json!({"offset":-2})).unwrap(), json!({"document":0})); + assert_eq!(active(&s), "c", "wrapping follows opening order, not tab position"); + s.execute("document.activate", json!({"document":2})).unwrap(); assert_eq!(s.execute("document.activate", json!({"offset": -1})).unwrap(), json!({"document": 1})); assert_eq!(active(&s), "a"); assert_eq!(s.execute("document.activate", json!({"offset": 2})).unwrap(), json!({"document": 0})); diff --git a/crates/engine/tests/atn_mapped_identifiers.rs b/crates/engine/tests/atn_mapped_identifiers.rs new file mode 100644 index 0000000000..899d214108 --- /dev/null +++ b/crates/engine/tests/atn_mapped_identifiers.rs @@ -0,0 +1,205 @@ +//! Recorded identifiers must match Photoshop, even when a local round trip would accept a typo. + +use photocraft_engine::{Session, actions_cmds::Action, atn_cmds}; +use photocraft_psd::{ + atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}, + descriptor::{Class, Descriptor, Id, ReferenceItem, UnicodeString, Value as D}, +}; +use serde_json::json; + +fn class(id: &str) -> Class { + Class { name: UnicodeString::new_nul(""), class_id: Id::new(id) } +} + +fn enm(ty: &str, value: &str) -> D { + D::Enumerated { type_id: Id::new(ty), value: Id::new(value) } +} + +fn blue() -> Descriptor { + Descriptor::new("RGBC").with("Rd ", D::Double(0.0)).with("Grn ", D::Double(0.0)).with("Bl ", D::Double(255.0)) +} + +/// Load a reference-shaped descriptor through the public ATN parser. +fn recorded(event: AtnEvent, d: Descriptor) -> Action { + let bytes = atn::write(&AtnFile { + name: UnicodeString::new_nul("Identifiers"), + expanded: false, + actions: vec![AtnAction { + name: UnicodeString::new_nul("Recorded"), + items: vec![AtnItem { event, descriptor: Some(d), ..Default::default() }], + ..Default::default() + }], + warnings: vec![], + }) + .unwrap(); + atn_cmds::import(&bytes).unwrap().actions.remove(0) +} + +/// Parse the actual saved bytes, so matching import/export mistakes cannot cancel out. +fn saved(action: &Action) -> AtnItem { + let (bytes, skipped) = atn_cmds::export("Identifiers", &[action], &[], false).unwrap(); + assert!(skipped.is_empty()); + let file = atn::parse(&bytes).unwrap(); + assert_eq!(file.actions[0].items.len(), 1); + file.actions[0].items[0].clone() +} + +/// Check the enum type and value in saved bytes independently of the command parameters. +fn enum_value<'a>(d: &'a Descriptor, key: &str, ty: &str) -> &'a Id { + let Some(D::Enumerated { type_id, value }) = d.get(key) else { panic!("missing {key} enumeration") }; + assert_eq!(*type_id, Id::new(ty)); + value +} + +/// Canonical event names and old code imports share the existing gradient parameters. +#[test] +fn gradient_map_writes_the_named_event_and_keeps_legacy_code_imports() { + let stop = |location, color| { + D::Descriptor(Descriptor::new("Clrt").with("Lctn", D::Integer(location)).with("Mdpn", D::Integer(50)).with("Clr ", D::Descriptor(color))) + }; + let red = Descriptor::new("RGBC").with("Rd ", D::Double(255.0)).with("Grn ", D::Double(0.0)).with("Bl ", D::Double(0.0)); + let d = Descriptor::new("GdMp") + .with("Grad", D::Descriptor(Descriptor::new("Grdn").with("Clrs", D::List(vec![stop(0, blue()), stop(4096, red)])))) + .with("Rvrs", D::Boolean(true)) + .with("Dthr", D::Boolean(false)); + for event in [AtnEvent::name("gradientMapEvent"), AtnEvent::Code(*b"GdMp")] { + let action = recorded(event, d.clone()); + assert_eq!(action.steps, vec![("image.adjustments.gradientMap".into(), json!({"stops":[[0,"#0000ff"],[1,"#ff0000"]],"reverse":true,"dither":false}))]); + let written = saved(&action); + assert_eq!(written.event, AtnEvent::name("gradientMapEvent")); + assert!(written.descriptor.unwrap().class_id.is("GdMp")); + } + assert_eq!(recorded(AtnEvent::name("GdMp"), d).steps[0].0, atn_cmds::PHOTOSHOP_STEP); +} + +/// The measured outside identifier changes while inside, centre and unknown values stay safe. +#[test] +fn stroke_outside_writes_otsd_without_changing_inside_or_center() { + for (input, location, canonical) in [("Otsd", "outside", "Otsd"), ("OtsF", "outside", "Otsd"), ("Insd", "inside", "Insd"), ("Cntr", "center", "Cntr")] { + let d = Descriptor::new("null") + .with("Wdth", D::UnitFloat { unit: *b"#Pxl", value: 2.0 }) + .with("Lctn", enm("StrL", input)) + .with("Clr ", D::Descriptor(blue())); + let action = recorded(AtnEvent::Code(*b"Strk"), d); + assert_eq!(action.steps[0].0, "edit.stroke"); + assert_eq!(action.steps[0].1["location"], location); + assert_eq!(*enum_value(saved(&action).descriptor.as_ref().unwrap(), "Lctn", "StrL"), Id::new(canonical)); + } + let unknown = Descriptor::new("null").with("Lctn", enm("StrL", "futureLocation")); + let action = recorded(AtnEvent::Code(*b"Strk"), unknown.clone()); + assert_eq!(action.steps[0].0, atn_cmds::PHOTOSHOP_STEP); + assert_eq!(saved(&action).descriptor, Some(unknown)); +} + +/// A group blend uses Photoshop's actual identifier and reaches the native group command. +#[test] +fn pass_through_uses_the_string_identifier_and_replays_on_a_group() { + for input in ["passThrough", "PsTh"] { + let target = D::Reference(vec![ReferenceItem::Name { class: class("Lyr "), name: UnicodeString::new_nul("Group") }]); + let d = Descriptor::new("null").with("null", target).with("T ", D::Descriptor(Descriptor::new("Lyr ").with("Md ", enm("BlnM", input)))); + let action = recorded(AtnEvent::Code(*b"setd"), d); + assert_eq!(action.steps, vec![("layer.setProps".into(), json!({"layer":"Group","blend":"Pass Through"}))]); + let written = saved(&action).descriptor.unwrap(); + let Some(D::Descriptor(props)) = written.get("T ") else { panic!("layer properties lost") }; + assert_eq!(*enum_value(props, "Md ", "BlnM"), Id::new("passThrough")); + let mut s = Session::new(); + s.execute("file.new", json!({"width":4,"height":4})).unwrap(); + s.execute("layer.new.group", json!({"name":"Group"})).unwrap(); + s.execute("layer.setProps", json!({"blend":"Normal"})).unwrap(); + let (id, p) = &action.steps[0]; + s.execute(id, p.clone()).unwrap(); + let st = s.active().unwrap(); + assert_eq!(st.doc.layer(st.active_layer.unwrap()).unwrap().blend, photocraft_color::BlendMode::PassThrough); + } +} + +fn canvas() -> Descriptor { + Descriptor::new("null") + .with("Wdth", D::UnitFloat { unit: *b"#Pxl", value: 6.0 }) + .with("Hght", D::UnitFloat { unit: *b"#Pxl", value: 4.0 }) + .with("Hrzn", enm("HrzL", "Left")) + .with("Vrtc", enm("VrtL", "Top ")) +} + +/// Foreground and custom extension colours select visibly different new border pixels. +#[test] +fn canvas_extension_identifiers_preserve_foreground_and_custom_color() { + for (input, custom, canonical, expected) in [ + ("FrgC", false, "FrgC", [1.0, 0.0, 0.0, 1.0]), + ("Frgd", false, "FrgC", [1.0, 0.0, 0.0, 1.0]), + ("Clr ", true, "Clr ", [0.0, 0.0, 1.0, 1.0]), + ("Othr", true, "Clr ", [0.0, 0.0, 1.0, 1.0]), + ] { + let mut d = canvas().with("canvasExtensionColorType", enm("canvasExtensionColorType", input)); + if custom { + d = d.with("canvasExtensionColor", D::Descriptor(blue())); + } + let action = recorded(AtnEvent::Code(*b"CnvS"), d); + assert_eq!(action.steps[0].0, "image.canvasSize"); + assert_eq!(*enum_value(saved(&action).descriptor.as_ref().unwrap(), "canvasExtensionColorType", "canvasExtensionColorType"), Id::new(canonical)); + let mut s = Session::new(); + s.execute("file.new", json!({"width":4,"height":4,"background":"white"})).unwrap(); + s.execute("tools.setColors", json!({"foreground":"#ff0000","background":"#00ff00"})).unwrap(); + let (id, p) = &action.steps[0]; + s.execute(id, p.clone()).unwrap(); + let pixel: Vec = serde_json::from_value(s.execute("document.pixel", json!({"x":5,"y":1})).unwrap()).unwrap(); + assert_eq!(pixel, expected); + } + for d in [ + canvas().with("canvasExtensionColorType", enm("canvasExtensionColorType", "futureColor")).with("canvasExtensionColor", D::Descriptor(blue())), + canvas().with("canvasExtensionColorType", D::Integer(1)), + canvas().with("canvasExtensionColorType", enm("canvasExtensionColorType", "Clr ")), + ] { + let action = recorded(AtnEvent::Code(*b"CnvS"), d.clone()); + assert_eq!(action.steps[0].0, atn_cmds::PHOTOSHOP_STEP); + assert_eq!(saved(&action).descriptor, Some(d)); + } +} + +/// Absolute intent writes the measured code and unknown intent values remain preserved steps. +#[test] +fn absolute_colorimetric_writes_aclr_and_rejects_unknown_intents() { + let profile = || Descriptor::new("null").with("T ", D::Text(UnicodeString::new_nul("sRGB IEC61966-2.1"))); + for input in ["AClr", "AbsC"] { + let action = recorded(AtnEvent::name("convertToProfile"), profile().with("Inte", enm("Inte", input))); + assert_eq!(action.steps, vec![("edit.convertToProfile".into(), json!({"profile":"srgb","intent":"absolute"}))]); + assert_eq!(*enum_value(saved(&action).descriptor.as_ref().unwrap(), "Inte", "Inte"), Id::new("AClr")); + } + for value in [enm("Inte", "futureIntent"), D::Integer(1)] { + let d = profile().with("Inte", value); + let action = recorded(AtnEvent::name("convertToProfile"), d.clone()); + assert_eq!(action.steps[0].0, atn_cmds::PHOTOSHOP_STEP); + assert_eq!(saved(&action).descriptor, Some(d)); + } +} + +/// Wrap uses opening order after tab moves, with overflow-safe offsets and valid ATN references. +#[test] +fn document_offsets_wrap_in_opening_order_and_zero_atn_offsets_stay_kept() { + let mut s = Session::new(); + for name in ["a", "b", "c"] { + s.execute("file.new", json!({"width":2,"height":2,"name":name})).unwrap(); + } + s.execute("document.move", json!({"document":2,"to":0})).unwrap(); + for (offset, expected) in [(-2, "c"), (2, "a"), (5, "a"), (-5, "c"), (i64::MIN, "c"), (i64::MAX, "c"), (0, "b")] { + s.execute("document.activate", json!({"document":2})).unwrap(); + s.execute("document.activate", json!({"offset":offset})).unwrap(); + assert_eq!(s.active().unwrap().doc.name, expected, "{offset}"); + } + for offset in [-2, 2] { + let d = Descriptor::new("null").with("null", D::Reference(vec![ReferenceItem::Offset { class: class("Dcmn"), offset }])); + let action = recorded(AtnEvent::Code(*b"slct"), d); + assert_eq!(action.steps[0].0, "document.activate"); + s.execute("document.activate", json!({"document":2})).unwrap(); + let (id, p) = &action.steps[0]; + s.execute(id, p.clone()).unwrap(); + assert_eq!(s.active().unwrap().doc.name, if offset < 0 { "c" } else { "a" }); + let written = saved(&action).descriptor.unwrap(); + assert!(matches!(written.get("null"),Some(D::Reference(r)) if matches!(r.as_slice(),[ReferenceItem::Offset{offset:o,..}] if *o==offset))); + } + let zero = Descriptor::new("null").with("null", D::Reference(vec![ReferenceItem::Offset { class: class("Dcmn"), offset: 0 }])); + let action = recorded(AtnEvent::Code(*b"slct"), zero.clone()); + assert_eq!(action.steps[0].0, atn_cmds::PHOTOSHOP_STEP); + assert_eq!(saved(&action).descriptor, Some(zero)); + assert!(atn_cmds::export("Identifiers", &[&Action::new("Zero", vec![("document.activate".into(), json!({"offset":0}))])], &[], false).is_err()); +} From 376949a6f38c0eb6cf09aee5c3a21ec23417164e Mon Sep 17 00:00:00 2001 From: yoriswind Date: Sun, 1 Nov 2026 06:34:22 -0800 Subject: [PATCH 090/216] Preserve Content-Aware Fill options in ATN actions --- crates/engine/src/cmd/atn_cmds/steps_layer.rs | 29 +++ crates/engine/tests/content_aware_fill_atn.rs | 173 ++++++++++++++++++ 2 files changed, 202 insertions(+) create mode 100644 crates/engine/tests/content_aware_fill_atn.rs diff --git a/crates/engine/src/cmd/atn_cmds/steps_layer.rs b/crates/engine/src/cmd/atn_cmds/steps_layer.rs index 9d9bf5f3f7..c61fe2c88c 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_layer.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_layer.rs @@ -398,6 +398,22 @@ pub(super) fn fill_from(d: &Descriptor) -> Mapped { } let Some(contents) = get_enum(d, "Usng").and_then(|u| lookup(FILL_CONTENTS, &u)) else { return Mapped::Unsupported { harmless: false } }; let mut p = json!({"contents": contents}); + if contents == "contentAware" { + // Keep a requested rotation until the command can reproduce it, rather than run unrotated. + match d.get("contentAwareRotateFill") { + None | Some(D::Boolean(false)) => {} + Some(_) => return Mapped::Unsupported { harmless: false }, + } + for (key, param) in + [("contentAwareColorAdaptationFill", "colorAdaptation"), ("contentAwareScaleFill", "scaleAdaptation"), ("contentAwareMirrorFill", "mirror")] + { + match d.get(key) { + None => {} + Some(D::Boolean(value)) => p[param] = json!(value), + Some(_) => return Mapped::Unsupported { harmless: false }, + } + } + } if contents == "color" { match get_obj(d, "Clr ").and_then(color_hex) { Some(c) => p["color"] = json!(c), @@ -466,6 +482,19 @@ pub(super) fn fill_to(id: &str, p: &Value) -> Option { if let Some(t) = jbool(p, "preserveTransparency") { d = d.with("PrsT", D::Boolean(t)); } + if contents.eq_ignore_ascii_case("contentAware") { + // Native CAF searches unrotated sources; express that even when other flags are omitted. + d = d.with("contentAwareRotateFill", D::Boolean(false)); + for (key, param) in + [("contentAwareColorAdaptationFill", "colorAdaptation"), ("contentAwareScaleFill", "scaleAdaptation"), ("contentAwareMirrorFill", "mirror")] + { + match p.get(param) { + None | Some(Value::Null) => {} + Some(Value::Bool(value)) => d = d.with(key, D::Boolean(*value)), + Some(_) => return None, + } + } + } Some(d) } diff --git a/crates/engine/tests/content_aware_fill_atn.rs b/crates/engine/tests/content_aware_fill_atn.rs new file mode 100644 index 0000000000..f6bcdf9ea8 --- /dev/null +++ b/crates/engine/tests/content_aware_fill_atn.rs @@ -0,0 +1,173 @@ +//! #10694: Content-Aware Fill options survive ATN load, save and playback. + +use photocraft_doc::{LayerId, Rect}; +use photocraft_engine::{Session, actions_cmds::Action, atn_cmds}; +use photocraft_psd::{ + atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}, + descriptor::{Descriptor, Id, UnicodeString, Value as D}, +}; +use serde_json::{Value, json}; + +const FLAGS: [(&str, &str); 3] = + [("contentAwareColorAdaptationFill", "colorAdaptation"), ("contentAwareScaleFill", "scaleAdaptation"), ("contentAwareMirrorFill", "mirror")]; +const ROTATION: &str = "contentAwareRotateFill"; + +fn descriptor() -> Descriptor { + Descriptor::new("null").with("Usng", D::Enumerated { type_id: Id::new("FlCn"), value: Id::new("contentAware") }) +} + +fn recorded(event: AtnEvent, descriptor: Descriptor) -> Vec { + atn::write(&AtnFile { + name: UnicodeString::new_nul("CAF"), + expanded: false, + actions: vec![AtnAction { + name: UnicodeString::new_nul("Fill"), + items: vec![AtnItem { event, dictionary: b"Fill".to_vec(), descriptor: Some(descriptor), ..Default::default() }], + ..Default::default() + }], + warnings: vec![], + }) + .unwrap() +} + +fn load(s: &mut Session, bytes: &[u8]) -> Value { + s.execute("actions.load", json!({"data": photocraft_engine::paint::tile::b64_encode(bytes)})).unwrap() +} + +fn save(s: &mut Session) -> Vec { + let saved = s.execute("actions.save", json!({"set": "CAF"})).unwrap(); + assert_eq!(saved["skipped"], json!([])); + photocraft_engine::paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap() +} + +/// Match the qualified native fixture, using the action command's default seed. +fn fixture() -> (Session, LayerId) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48, "background": "transparent"})).unwrap(); + let id = s.active().unwrap().doc.layers[0].id; + s.edit("Asymmetric texture", |doc, _| { + let mut pixels = Vec::with_capacity(64 * 48 * 4); + for y in 0..48 { + for x in 0..64 { + pixels.extend([ + ((37 * x + 11 * y + x * y % 17) % 251) as f32 / 250.0, + ((x * x + 13 * y + 3 * (x / 7)) % 241) as f32 / 240.0, + ((5 * x + 3 * y * y + 19 * (x / 9)) % 239) as f32 / 238.0, + 1.0, + ]); + } + } + doc.layer_mut(id).unwrap().surface_mut().unwrap().write_region(Rect::from_xywh(0, 0, 64, 48), &pixels); + Ok(()) + }) + .unwrap(); + s.execute("select.rect", json!({"x": 28, "y": 20, "width": 8, "height": 8})).unwrap(); + (s, id) +} + +fn pixels(s: &Session, id: LayerId) -> Vec { + let doc = &s.active().unwrap().doc; + doc.layer(id).unwrap().surface().unwrap().read_region(doc.bounds()) +} + +#[test] +fn typed_flags_survive_saved_descriptors_and_public_playback() { + for event in [AtnEvent::Code(*b"Fl "), AtnEvent::name("fill")] { + for values in [[false, true, true], [true, false, false]] { + let mut d = descriptor().with(ROTATION, D::Boolean(false)); + let mut params = json!({"contents": "contentAware"}); + for ((key, param), value) in FLAGS.into_iter().zip(values) { + d = d.with(key, D::Boolean(value)); + params[param] = json!(value); + } + let (mut direct, direct_id) = fixture(); + direct.execute("edit.fill", params.clone()).unwrap(); + let expected = pixels(&direct, direct_id); + if values[1] { + let (mut default, default_id) = fixture(); + default.execute("edit.fill", json!({"contents": "contentAware"})).unwrap(); + assert_ne!(expected, pixels(&default, default_id), "combined options must change the fixture"); + } + let (mut replay, replay_id) = fixture(); + assert_eq!(load(&mut replay, &recorded(event.clone(), d))["unsupported"], json!([])); + assert_eq!(replay.actions.list[0].steps, vec![("edit.fill".into(), params)], "CAF flags must import without loss"); + let saved = save(&mut replay); + let parsed = atn::parse(&saved).unwrap(); + let saved_d = parsed.actions[0].items[0].descriptor.as_ref().unwrap(); + for ((key, _), value) in FLAGS.into_iter().zip(values) { + assert_eq!(saved_d.get(key), Some(&D::Boolean(value))); + } + assert_eq!(saved_d.get(ROTATION), Some(&D::Boolean(false))); + let imported = atn_cmds::import(&saved).unwrap(); + assert!(imported.unsupported.is_empty()); + assert_eq!(imported.actions[0].steps, replay.actions.list[0].steps); + replay.actions.list = imported.actions; + let played = replay.execute("actions.play", json!({"action": "Fill"})).unwrap(); + assert_eq!(played["ran"], 1); + assert!(played.get("failed").is_none(), "{played}"); + let actual = pixels(&replay, replay_id); + let difference = actual.iter().zip(&expected).position(|(a, b)| a != b); + assert_eq!(difference, None, "saved CAF playback must match the native options"); + } + } +} + +#[test] +fn absent_and_null_native_flags_keep_defaults_and_export_no_rotation() { + let mut s = Session::new(); + assert_eq!(load(&mut s, &recorded(AtnEvent::Code(*b"Fl "), descriptor()))["unsupported"], json!([])); + assert_eq!(s.actions.list[0].steps[0].1, json!({"contents": "contentAware"})); + let saved = save(&mut s); + let imported = atn_cmds::import(&saved).unwrap(); + assert_eq!(imported.actions[0].steps, s.actions.list[0].steps); + for null in [false, true] { + let mut params = json!({"contents": "contentAware"}); + if null { + for (_, param) in FLAGS { + params[param] = Value::Null; + } + } + let action = Action::new("Defaults", vec![("edit.fill".into(), params)]); + let (bytes, skipped) = atn_cmds::export("CAF", &[&action], &[], false).unwrap(); + assert!(skipped.is_empty()); + let file = atn::parse(&bytes).unwrap(); + let d = file.actions[0].items[0].descriptor.as_ref().unwrap(); + for (key, _) in FLAGS { + assert!(d.get(key).is_none()); + } + assert_eq!(d.get(ROTATION), Some(&D::Boolean(false)), "native CAF performs no rotated-source search"); + } + for (_, param) in FLAGS { + for value in [json!(1), json!("false")] { + let mut params = json!({"contents": "contentAware"}); + params[param] = value; + let action = Action::new("Invalid", vec![("edit.fill".into(), params)]); + assert!(atn_cmds::export("CAF", &[&action], &[], false).is_err()); + } + } +} + +#[test] +fn malformed_flags_and_requested_rotation_remain_kept_steps() { + for event in [AtnEvent::Code(*b"Fl "), AtnEvent::name("fill")] { + let mut bad = Vec::new(); + for (key, _) in FLAGS { + for value in [D::Integer(0), D::Text(UnicodeString::new_nul("false"))] { + bad.push(descriptor().with(key, value)); + } + } + bad.push(descriptor().with(ROTATION, D::Boolean(true))); + bad.push(descriptor().with(ROTATION, D::Integer(0))); + for d in bad { + let source = recorded(event.clone(), d); + let original = atn::parse(&source).unwrap(); + let mut s = Session::new(); + let loaded = load(&mut s, &source); + assert_eq!(loaded["unsupported"].as_array().unwrap().len(), 1); + assert_eq!(s.actions.list[0].steps[0].0, atn_cmds::PHOTOSHOP_STEP); + let saved = atn::parse(&save(&mut s)).unwrap(); + assert_eq!(saved.actions[0].items[0].descriptor, original.actions[0].items[0].descriptor); + assert_eq!(saved.actions[0].items[0].event, original.actions[0].items[0].event); + } + } +} From ceb5ddf6c59784b4b37fe08a86e6d760fa520446 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 1 Nov 2026 15:37:04 +0100 Subject: [PATCH 091/216] Docs (atn): events measured through dialogs and recordings, Batch file naming, panel menu Recorded in the reference application through its menus and read back from the saved Actions panel, or returned by the dialog on OK: Custom, Fibers, Displace (with ranges), Color Lookup, Shape Blur, Lighting Effects, Blur Gallery, Liquify, Camera Raw Filter, Vanishing Point, Calculations; how Image Size, Canvas Size, Convert Mode and New Group are recorded. The Batch descriptor with its file naming components, and the Actions panel menu and Playback Options. Known gaps gain four rows: Convert Mode is saved with an enum the reference application rejects, adjustment layer opacity on Make, Canvas Size in percent, Image Size in cm/inches. Co-Authored-By: Claude Opus 5.5 --- docs/atn.md | 80 +++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 75 insertions(+), 5 deletions(-) diff --git a/docs/atn.md b/docs/atn.md index e44aefe776..1788ad03b3 100644 --- a/docs/atn.md +++ b/docs/atn.md @@ -173,6 +173,10 @@ step does; each line says what is lost and the fix. | Set layer blend mode Pass Through (groups) | Written as `BlnM.PsTh`; the application rejects it. Its value is `passThrough`. | Save `passThrough`. | | Canvas Size, extension colour Foreground / Other | Written as `canvasExtensionColorType` `Frgd` / `Othr`; the application does not know them and opens the Canvas Size dialog instead. Its values are `FrgC` and `Clr `. | Use `FrgC` and `Clr ` both ways. | | Convert to Profile, intent Absolute Colorimetric | Written as `Inte.AbsC`; the application reads it as Relative Colorimetric (it echoes `Clrm`). Its value is `AClr`; a recorded `AClr` does not load. | Use `AClr` both ways. | +| Convert Mode (`CnvM`) | Saved with `T ` as an enum (`ClrS.Grys`); the application records and needs a class (`T ` type `Grys`): with the enum every mode change fails there ("program error"). A recording on a layered document also carries `Fltt` and `Rstr` (false), which avoid the flatten question on playback. | Save `T ` as a class; read and write `Fltt`/`Rstr`. | +| Make adjustment layer with `Usng.Opct` | The layer's opacity (e.g. 70 % in the bundled Gradient Map action) is dropped. | Map to the new layer's opacity. | +| Canvas Size in percent | A recorded size in `#Prc` (the bundled Frame Channel action records 200 %) stays a kept step. | Scale the current size by the percentage. | +| Image Size in cm or inches | Recorded in `#Rlt` (points) with `scaleStyles`; stays a kept step. | Convert with the document's resolution at playback. | | Select a document by offset (`Dcmn` `rele`) | The reference application wraps around at the ends of the opening order (from the second of three documents, -2 is the third, +2 the first); `document.activate` refuses an offset past the ends (#7103), so the step fails in PhotoCraft. See Reference forms. | Wrap like the reference application. | ### The command has no such setting (needs a new command option first) @@ -290,6 +294,75 @@ artboards (new, from layers), Frame from Layers, Create Layers from a layer styl Replace Text, Import Notes, Measurement Scale, Convert Stroke to Fill (needs a selected path component), Sky Replacement. +## Measured through dialogs and recordings + +Measured on 2026-11-01 in two ways: by running the event with its dialog and pressing OK (the reference +application then returns the full descriptor), and by recording actions through the menus and reading +the recorded steps back from the saved Actions panel. Ranges come from the dialog's own "a value between +… is required" message. + +| Event | Keys (recorded order) | Notes | +|---|---|---| +| Custom (`Cstm`) | `Scl ` long 1..9999, `Ofst` long -9999..9999, `Mtrx` list of 25 longs -999..999 | Without the dialog the step needs all 25 cells, or the application fails. | +| Fibers (`Fbrs`) | `Vrnc` long 1..64, `Strg` long 1..64, `RndS` long (seed) | | +| Displace (`Dspl`) | `HrzS`, `VrtS` long -999..999; `DspM` (`StrF` stretch to fit, or tile); `UndA` (`RptE` repeat edge pixels, or wrap around); `DspF` alias: path of the displacement map (.psd) | The map file is chosen after OK. | +| Color Lookup (`colorLookup`) | `lookupType` (`colorLookupType`: `3DLUT`, abstract, device link); `Nm ` text: the file's path; `Dthr` bool; `profile` raw data (ICC); `LUTFormat` (`LUTFormatType`, e.g. `LUTFormatLOOK`); `LUT3DFileData` raw data: the LUT file itself; `LUT3DFileName` text | The LUT travels inside the step. | +| Shape Blur (`shapeBlur`) | `Rds ` pixels 5..1000; `customShape` object (`Nm ` text, `Idnt` text: the shape's id); `keyOriginType` long (9) | | +| Lighting Effects (`lightFilterLightingEffects`) | `k3DLights` list of lights (`Nm `, colour `Rd `/`Grn `/`Bl ` 0..1, `hots`, `FlOf`, `shdw`, attenuation keys, `Type`, `ison`, `key3DMatrix` raw + floats, `X `/`Y `/`Z `, target `tarx`/`tary`/`tarz`, `key3DPosition`), `key3DCurrentCameraPosition`, `Glos`, `Mtrl`, `Exps`, `AmbB`, `AmbC` RGB, `BmpA`, `BmpC` (texture channel by name, localised: "Ohne" = none), `Wdth`, `Hght` | A dialog-less run returns nothing; only a recording shows the keys. | +| Blur Gallery (`blurbTransform`) | `blurbType`, `VrsM`, `VrsN`, `referenceRect` (rectangle), `blurbOpenPanel`, `blurbGeneralBlurEffectApplied`, `blurbSaveMaskChannel`, `blurbHighQuality`, `blurbWidgetList` (per widget: `blurbWidgetType` (0 = field blur), `blurbWidgetLocationX`/`Y`, `blurbWidgetSelected`, `blurbWidgetEffectEnabled`, `blurbGeneralBlurAmount`) | One event for all five Blur Gallery filters. | +| Liquify (`LqFy`) | `LqMe` raw data: the mesh | Opaque; keep the step as recorded. | +| Camera Raw Filter | `CrVe` text (version), `PrVN` long, `PrVe` long | With settings at their defaults; changed settings add keys. | +| Vanishing Point (`VnPt`) | none | The planes are not recorded. | +| Calculations | recorded as Make: `Nw ` class channel, `Usng` object (`T ` reference: source 1 channel and layer, `Clcl` blend mode, `Src2` reference) | Not a `calculations` event. | +| Image Size, size in cm or inches | `Wdth` in `#Rlt` (points), `scaleStyles`, `CnsP`, `Intr` | A physical size is recorded in points, not pixels: playback needs the document's resolution. | +| Canvas Size | only the changed side; `canvasExtensionColorType` `FrgC` for the foreground colour | | +| Convert Mode, several layers, "Don't Flatten" | `T ` as a **class** (`Grys`), `Fltt` false, `Rstr` false | With `Fltt` and `Rstr` the step plays without the flatten question. | +| New Group with a colour | `Usng` (`Clr ` label), `layerSectionStart`, `layerSectionEnd`, top-level `Nm ` | | + +**Not recordable:** Edit > Purge (no step is recorded; this version's Purge menu has Clipboard, +Histories, All and Video Cache, no Undo). + +### Batch + +Running Batch while recording records the steps the batch played, not a Batch step. The Batch +descriptor itself (`Btch`, as the dialog returns it): + +| Key | Type | Meaning | +|---|---|---| +| `null` | alias | source folder (absent for open files) | +| `Fldr` | bool | include all subfolders | +| `suppressWarnings` | bool | suppress colour profile warnings | +| `Usng` | reference | the action: `Actn` by name in `ASet` by name | +| `T ` | alias | destination folder | +| `fileNamingRules` | object | file naming (below) | +| `Log ` | alias | folder of the error log ("log errors to file") | +| `Nm ` | text | error log file name | + +`fileNamingRules`: `fileNamingComponents` (list of up to six objects; each is `component` enum +`fileNamingComponent`, or `Txt ` text for typed text), `Strt` long (starting serial number), `Mcnt`, `Win `, +`unix` bool (compatibility). + +| Component | Menu entry | +|---|---| +| `Nm ` / `lowerCase` / `upperCase` | Document Name / document name / DOCUMENT NAME | +| `oneDigit` `twoDigit` `threeDigit` `fourDigit` | 1, 2, 3, 4 digit serial number | +| `lowerCaseSerial` / `upperCaseSerial` | serial letter (a, b, c…) / (A, B, C…) | +| `mmddyy` `yyyymmdd` `yymmdd` `yyddmm` `ddmmyy` | dates | +| `lowerCaseExtension` / `upperCaseExtension` | extension / EXTENSION | + +The extension component has no dot: Document Name + extension + 3 digit serial + "_pc", starting at 5, +named `other.png` → `otherpng005_pc` (the saved file then gets its own extension). The two remaining date +entries (mmdd, ddmm) did not accept `mmdd`/`ddmm`; their values are still unknown. + +### Actions panel menu + +Button Mode; New Action, New Set, Duplicate, Delete, Play; Start Recording, Record Again, Insert Menu +Item, Insert Stop, Insert Conditional, Insert Path; Action Options, Playback Options, Allow Tool +Recording; Clear All Actions, Reset Actions, Load Actions, Replace Actions, Save Actions; the bundled sets +(Commands, Frames, Image Effects, LAB Black & White Technique, Production, Star Trails, Text Effects, +Textures, Video Actions); Close, Close Tab Group. Playback Options: Accelerated, Step by Step, Pause For +1..60 seconds. + ## Older files: version 12 Action sets saved by the reference application's version 5 era use file version 12. PhotoCraft rejects @@ -1588,14 +1661,11 @@ their tables will be added once a recorded step was read. | Event | Why | |---|---| -| Lighting Effects | The light list only appears after the dialog. | -| Displace | Reads a displacement map file chosen in the dialog. | -| Fibers (`Vrnc`, `Strg` ranges) | Values outside the dialog's range are returned unchanged, not clamped; the limits come from the dialog. | -| Custom | Without the dialog, scale and offset are ignored, and a matrix that is not 5 x 5 makes the application fail. | | Shear | Probing the point list made the application fail; the limits come from the dialog. | | Trace Contour | The step's level and edge are applied, but the returned descriptor always shows the defaults (level 128, upper). Lower (`Lwr `) is valid; levels at or below 0 and at or above 255 give the same image. | | Gradient tool | The drag needs a full gradient object (see Gradient Map in group E for its form). | -| Batch, Create Droplet, Print | File and printer settings come from their dialogs; Batch's file naming is still unknown (a step written with PhotoCraft's current naming rules is refused). | +| Create Droplet, Print | File and printer settings come from their dialogs. (Batch is measured: see Measured through dialogs and recordings.) | +| Perspective Warp, Content-Aware Fill workspace, Export As, Quick Export | Interactive workspaces; their recorded steps were not read yet. | | Sky Replacement, Convert Stroke to Fill | Need a sky photo and a selected path component. (The smart object commands, Rasterize All and Convert Type to Shape are measured: see Newer events.) | | Generative Fill (`syntheticFill`) | AI feature: in PhotoCraft this goes through the shared AI layer; until then such steps stay kept steps. | From ca60678754eaf5a76e47ec221c399addd3c808f6 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Sun, 1 Nov 2026 06:37:14 -0800 Subject: [PATCH 092/216] Describe CAF action conversion and regression witnesses --- crates/engine/src/cmd/atn_cmds/steps_layer.rs | 2 ++ crates/engine/tests/content_aware_fill_atn.rs | 3 +++ 2 files changed, 5 insertions(+) diff --git a/crates/engine/src/cmd/atn_cmds/steps_layer.rs b/crates/engine/src/cmd/atn_cmds/steps_layer.rs index c61fe2c88c..e5b3766a22 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_layer.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_layer.rs @@ -360,6 +360,7 @@ pub(super) fn play_to(_: &str, p: &Value) -> Option { Some(desc("null").with("null", D::Reference(items))) } +/// Import Fill or Paint Bucket, keeping Content-Aware choices the engine cannot replay. pub(super) fn fill_from(d: &Descriptor) -> Mapped { if let Some(from) = get_obj(d, "From") { // The Paint Bucket tool's click: its point is in pixels, or in percent of the canvas @@ -435,6 +436,7 @@ pub(super) fn fill_from(d: &Descriptor) -> Mapped { step("edit.fill", p) } +/// Export Fill settings, including the native Content-Aware no-rotation behavior. pub(super) fn fill_to(id: &str, p: &Value) -> Option { if id == "paint.bucket" { let mut d = desc("null").with("From", D::Descriptor(desc("Pnt ").with("Hrzn", px(jnum(p, "x")?)).with("Vrtc", px(jnum(p, "y")?)))); diff --git a/crates/engine/tests/content_aware_fill_atn.rs b/crates/engine/tests/content_aware_fill_atn.rs index f6bcdf9ea8..e55a9f925c 100644 --- a/crates/engine/tests/content_aware_fill_atn.rs +++ b/crates/engine/tests/content_aware_fill_atn.rs @@ -70,6 +70,7 @@ fn pixels(s: &Session, id: LayerId) -> Vec { doc.layer(id).unwrap().surface().unwrap().read_region(doc.bounds()) } +/// Imported flags retain their values and native pixel results through saved actions. #[test] fn typed_flags_survive_saved_descriptors_and_public_playback() { for event in [AtnEvent::Code(*b"Fl "), AtnEvent::name("fill")] { @@ -112,6 +113,7 @@ fn typed_flags_survive_saved_descriptors_and_public_playback() { } } +/// Omission and null retain native defaults; saved native fills explicitly disable rotation. #[test] fn absent_and_null_native_flags_keep_defaults_and_export_no_rotation() { let mut s = Session::new(); @@ -147,6 +149,7 @@ fn absent_and_null_native_flags_keep_defaults_and_export_no_rotation() { } } +/// Unreplayable flags remain intact as unsupported Photoshop steps. #[test] fn malformed_flags_and_requested_rotation_remain_kept_steps() { for event in [AtnEvent::Code(*b"Fl "), AtnEvent::name("fill")] { From 775dc0c070ce6d4bc86b6cb6616c3738dca0b8d2 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Sun, 1 Nov 2026 06:39:03 -0800 Subject: [PATCH 093/216] Keep document lifecycle tests consistent with wrapped offsets --- crates/engine/src/session/documents.rs | 2 +- crates/engine/src/tests.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index d9955a0e42..d3d5a8bc63 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -108,7 +108,7 @@ impl Session { let active = self.open_document_order.iter().position(|&open| open == id).ok_or(EngineError::NoDocument)?; // Widen before addition so even extreme signed offsets wrap without overflow. let target = (active as i128 + i128::from(offset)).rem_euclid(self.open_document_order.len() as i128) as usize; - let id = &self.open_document_order[target]; + let id = self.open_document_order.get(target).ok_or(EngineError::NoDocument)?; self.docs.iter().position(|st| st.doc.id == *id).ok_or(EngineError::NoDocument) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 5e8c1095b8..5cf131b51e 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1756,8 +1756,8 @@ fn document_activate_opening_order_survives_close_and_reopen_with_persisted_ids( } s.execute("file.new", json!({"width": 2, "height": 2, "name": "fresh"})).unwrap(); assert_eq!(s.execute("document.activate", json!({"offset": 0})).unwrap(), json!({"document": 0})); - assert!(s.execute("document.activate", json!({"offset": -1})).is_err()); - assert!(s.execute("document.activate", json!({"offset": 1})).is_err()); + assert_eq!(s.execute("document.activate", json!({"offset": -1})).unwrap(), json!({"document": 0})); + assert_eq!(s.execute("document.activate", json!({"offset": 1})).unwrap(), json!({"document": 0})); assert_eq!(s.active().unwrap().doc.name, "fresh"); } From 2e0c0f9a69ffc95a155447e63036daf86bd8ab9c Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 1 Nov 2026 15:40:20 +0100 Subject: [PATCH 094/216] Docs (atn): how Insert Menu Item and Insert Path are recorded Co-Authored-By: Claude Opus 5.5 --- docs/atn.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/atn.md b/docs/atn.md index 1788ad03b3..5c8f0afbc6 100644 --- a/docs/atn.md +++ b/docs/atn.md @@ -363,6 +363,12 @@ Recording; Clear All Actions, Reset Actions, Load Actions, Replace Actions, Save Textures, Video Actions); Close, Close Tab Group. Playback Options: Accelerated, Step by Step, Pause For 1..60 seconds. +How two menu entries record: **Insert Menu Item** records the chosen menu command's own event with no +descriptor (Image > Image Size gives an `imageSize` step without settings; playback opens its dialog). +**Insert Path** records a `set` of the work path: `null` = reference `Path` property `WrPt`, `T ` = list +of path components (`shapeOperation`, `SbpL` list of subpaths, each with `Clsp` closed and `Pts ` list of +points with `Anch` in pixels). + ## Older files: version 12 Action sets saved by the reference application's version 5 era use file version 12. PhotoCraft rejects From a02e1211e932121418878d560e3dcf482feca855 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 1 Nov 2026 15:51:23 +0100 Subject: [PATCH 095/216] Docs (atn): Custom ranges from the dialog; only Spatter of the Filter Gallery is mapped Addresses CodeRabbit on #10603: the Group A intro said every Filter Gallery effect loads as a kept step, but Spatter is mapped; Custom's scale, offset and matrix cells now have their measured ranges and the matrix has no default (a step carries all 25 cells). Co-Authored-By: Claude Opus 5.5 --- docs/atn.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/atn.md b/docs/atn.md index 5c8f0afbc6..53e22428e7 100644 --- a/docs/atn.md +++ b/docs/atn.md @@ -454,7 +454,7 @@ implemented, move it to the table above in the same change. ### Group A: The 47 Filter Gallery effects -Each Filter Gallery effect is recorded as its own event (one per effect), carrying a `GEfk` key with the effect id plus the effect's settings. Today they all load as kept steps that stop at playback. PhotoCraft already has every effect (`crates/algo/src/artistic.rs`) behind `filter.filterGallery`; this issue only maps them. Where our own range or default differs from the measured one, fix ours to match (marked in each table). +Each Filter Gallery effect is recorded as its own event (one per effect), carrying a `GEfk` key with the effect id plus the effect's settings. Today only Spatter (`GEfk` = `Spt`) is mapped (to `filter.gallery.spatter`); the other effects load as kept steps that stop at playback. PhotoCraft already has every effect (`crates/algo/src/artistic.rs`) behind `filter.filterGallery`; this issue only maps them. Where our own range or default differs from the measured one, fix ours to match (marked in each table). #### Accented Edges @@ -1360,9 +1360,9 @@ No parameters: the step carries an empty descriptor. | Key | String ID | Type | Unit | Example value | Allowed | |---|---|---|---|---|---| -| `Scl ` | scale | integer | | 1 | ignored without the dialog; see "Events that need a step recorded through their dialog" | -| `Ofst` | offset | integer | | 0 | ignored without the dialog; see "Events that need a step recorded through their dialog" | -| `Mtrx` | matrix | list | | none: the 25 cells as set in the dialog | exactly 25 integers (5 x 5, row by row); a 3 x 3 list makes the application fail | +| `Scl ` | scale | integer | | 1 | 1 .. 9999 (measured in the dialog). Without the dialog the value is ignored | +| `Ofst` | offset | integer | | 0 | -9999 .. 9999 (measured in the dialog). Without the dialog the value is ignored | +| `Mtrx` | matrix | list | | none: a step always carries all 25 cells | exactly 25 integers (5 x 5, row by row), each -999 .. 999; a 3 x 3 list makes the application fail | #### Replace Color From db1fb662a8f9e8d305d608147a998c3d58737d31 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Sun, 1 Nov 2026 05:33:41 -0800 Subject: [PATCH 096/216] Add optional layer-style scaling to Image Size --- crates/engine/src/cmd/image_cmds.rs | 14 +- .../engine/tests/image_size_scale_styles.rs | 124 ++++++++++++++++++ 2 files changed, 135 insertions(+), 3 deletions(-) create mode 100644 crates/engine/tests/image_size_scale_styles.rs diff --git a/crates/engine/src/cmd/image_cmds.rs b/crates/engine/src/cmd/image_cmds.rs index c8c3e0b7d2..cd18a7427a 100644 --- a/crates/engine/src/cmd/image_cmds.rs +++ b/crates/engine/src/cmd/image_cmds.rs @@ -238,6 +238,7 @@ fn image_size(s: &mut Session, p: &Value) -> Result { check_resample_budget("image.imageSize", &d.doc, nw, nh, ", or turn Resample off to change only the resolution")?; } let dpi = p.get("resolution").and_then(Value::as_f64).map(|v| v as f32); + let scale_styles = p.get("scaleStyles").and_then(Value::as_bool).unwrap_or(true); if !matches!(resample, Method::None) && (nw, nh) != (ow, oh) { // Color Sampler points scale with the pixels (same map `transform_geometry` applies). let (sx, sy) = (f64::from(nw) / f64::from(ow.max(1)), f64::from(nh) / f64::from(oh.max(1))); @@ -254,7 +255,7 @@ fn image_size(s: &mut Session, p: &Value) -> Result { Method::Automatic if nw > ow || nh > oh => Resample::PreserveDetails, Method::Automatic => Resample::BicubicSharper, }; - resample_doc(doc, nw, nh, filter); + resample_doc_with_styles(doc, nw, nh, filter, scale_styles); Ok(()) })?; // The size the document has now: without resampling `width`/`height` are ignored (#1815). @@ -292,6 +293,11 @@ pub(crate) fn check_working_budget(cmd: &str, doc: &Document, nw: u32, nh: u32, /// `nw` x `nh` px with `filter`: the body of Image Size, shared by the Crop tool's W x H x Resolution /// mode. Callers check [`check_resample_budget`] first. pub(crate) fn resample_doc(doc: &mut Document, nw: u32, nh: u32, filter: Resample) { + resample_doc_with_styles(doc, nw, nh, filter, true); +} + +/// Resize document content while optionally retaining layer-effect sizes in pixels. +fn resample_doc_with_styles(doc: &mut Document, nw: u32, nh: u32, filter: Resample, scale_styles: bool) { let (ow, oh) = (doc.size.width, doc.size.height); let (sx, sy) = (nw as f64 / ow.max(1) as f64, nh as f64 / oh.max(1) as f64); if (sx - 1.0).abs() < 1e-12 && (sy - 1.0).abs() < 1e-12 { @@ -305,7 +311,9 @@ pub(crate) fn resample_doc(doc: &mut Document, nw: u32, nh: u32, filter: Resampl }); let k = ((sx + sy) / 2.0) as f32; for_each_layer(&mut doc.layers, &mut |l| { - scale_effects(&mut l.effects, k); + if scale_styles { + scale_effects(&mut l.effects, k); + } // Canvas resampling scales the whole appearance, including shape strokes (#8341). // Dash lengths and offsets are already expressed in multiples of this width. if let LayerContent::Shape(sh) = &mut l.content @@ -983,7 +991,7 @@ pub fn specs() -> Vec { "image.imageSize", "Image Size…", ["Image"], - r##"{"width":px,"height":px,"percent":%? (both sides; an explicit side wins),"resolution":ppi,"keepPrintSize":bool=false (with only a resolution: resample the pixels to keep the print size),"resample":"automatic|preserveDetails|bicubicSmoother|bicubicSharper|bicubicAutomatic|bicubic|nearest|bilinear|lanczos|none"="bicubic"} → {width,height} (the document's size after the command; "none" changes the resolution only)"##, + r##"{"width":px,"height":px,"percent":%? (both sides; an explicit side wins),"resolution":ppi,"keepPrintSize":bool=false (with only a resolution: resample the pixels to keep the print size),"scaleStyles":bool=true (scale layer effects with the pixels),"resample":"automatic|preserveDetails|bicubicSmoother|bicubicSharper|bicubicAutomatic|bicubic|nearest|bilinear|lanczos|none"="bicubic"} → {width,height} (the document's size after the command; "none" changes the resolution only)"##, has_doc, image_size ), diff --git a/crates/engine/tests/image_size_scale_styles.rs b/crates/engine/tests/image_size_scale_styles.rs new file mode 100644 index 0000000000..4ae6cbabd2 --- /dev/null +++ b/crates/engine/tests/image_size_scale_styles.rs @@ -0,0 +1,124 @@ +//! Image Size can preserve layer-style sizes while resampling the document's geometry. + +use std::sync::Arc; + +use photocraft_color::{BlendMode, Color}; +use photocraft_doc::{Effect, Effects, FxCommon, FxPaint, Layer, LayerContent, LayerId, StrokeFx, StrokePosition}; +use photocraft_engine::Session; +use serde_json::json; + +fn styled_shape(depth: u32) -> (Session, LayerId) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 80, "height": 40, "depth": depth, "background": "transparent"})).unwrap(); + let id = s.execute("shape.create", json!({"rect": [20, 10, 40, 20], "fill": "#ff0000", "stroke": {"width": 4, "color": "#000000"}})).unwrap()["layer"] + .as_u64() + .unwrap(); + let id = LayerId(id); + s.edit("Styles", |doc, _| { + let effects = Effects { + enabled: true, + items: vec![Effect::Stroke(StrokeFx { + common: FxCommon::new(BlendMode::Normal, 1.0), + size: 20.0, + position: StrokePosition::Outside, + paint: FxPaint::Color(Color::rgb(0.0, 0.0, 1.0)), + overprint: false, + })], + psd_raw: Some(Arc::new(b"preserved layer style".to_vec())), + ..Default::default() + }; + doc.layer_mut(id).unwrap().effects = effects.clone(); + let children = std::mem::take(&mut doc.layers); + let mut outer = Layer::group("Outer", vec![Layer::group("Inner", children)]); + outer.effects = effects; + doc.layers.push(outer); + Ok(()) + }) + .unwrap(); + (s, id) +} + +fn shape_stroke(s: &Session, id: LayerId) -> f32 { + let LayerContent::Shape(shape) = &s.active().unwrap().doc.layer(id).unwrap().content else { panic!("shape lost") }; + shape.stroke.as_ref().unwrap().width +} + +/// Native omission keeps the old default; false preserves style sizes and raw PSD effects. +#[test] +fn image_size_scale_styles_is_optional_at_every_depth() { + for depth in [8, 16, 32] { + for option in [None, Some(true), Some(false)] { + let (mut s, id) = styled_shape(depth); + let before = s.active().unwrap().doc.clone(); + let group = before.layers[0].id; + let past = s.active().unwrap().history.past_len(); + let mut params = json!({"percent": 50, "resample": "nearest"}); + if let Some(value) = option { + params["scaleStyles"] = json!(value); + } + s.execute("image.imageSize", params).unwrap(); + let after = s.active().unwrap().doc.clone(); + assert_eq!((after.size.width, after.size.height), (40, 20)); + assert_eq!(shape_stroke(&s, id), 2.0, "{depth}/{option:?}: geometric strokes always scale"); + assert_eq!(s.active().unwrap().history.past_len(), past + 1); + for layer in [group, id] { + let effects = &after.layer(layer).unwrap().effects; + let Effect::Stroke(stroke) = &effects.items[0] else { panic!("style lost") }; + assert_eq!(stroke.size, if option == Some(false) { 20.0 } else { 10.0 }, "{depth}/{option:?}: layer style stroke"); + if option == Some(false) { + assert_eq!(effects, &before.layer(layer).unwrap().effects, "{depth}: preserve every style setting and raw block"); + } else { + assert!(effects.psd_raw.is_none(), "scaled effects invalidate their old PSD block"); + } + } + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.active().unwrap().doc.size, before.size); + assert_eq!(shape_stroke(&s, id), 4.0); + for layer in [group, id] { + assert_eq!(s.active().unwrap().doc.layer(layer).unwrap().effects, before.layer(layer).unwrap().effects); + } + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(s.active().unwrap().doc.size, after.size); + assert_eq!(shape_stroke(&s, id), 2.0); + for layer in [group, id] { + assert_eq!(s.active().unwrap().doc.layer(layer).unwrap().effects, after.layer(layer).unwrap().effects); + } + } + } +} + +/// Resolution-only and unchanged-size commands leave styles intact even when scaling is enabled. +#[test] +fn image_size_without_pixel_resampling_preserves_styles() { + for mut params in [json!({"percent": 50, "resolution": 144, "resample": "none"}), json!({"percent": 100, "resample": "nearest"})] { + for option in [false, true] { + let (mut s, id) = styled_shape(8); + let before = s.active().unwrap().doc.clone(); + params["scaleStyles"] = json!(option); + s.execute("image.imageSize", params.clone()).unwrap(); + let after = &s.active().unwrap().doc; + assert_eq!(after.size, before.size); + assert_eq!(shape_stroke(&s, id), 4.0); + for layer in [before.layers[0].id, id] { + assert_eq!(after.layer(layer).unwrap().effects, before.layer(layer).unwrap().effects); + } + if params["resample"] == "none" { + assert_eq!(after.resolution_dpi, 144.0); + } + } + } +} + +/// Crop's shared resampling path retains its existing style-scaling default. +#[test] +fn crop_target_keeps_scaling_styles() { + let (mut s, id) = styled_shape(8); + let group = s.active().unwrap().doc.layers[0].id; + s.execute("image.crop", json!({"x": 0, "y": 0, "width": 80, "height": 40, "targetWidth": 40, "targetHeight": 20, "resample": "nearest"})).unwrap(); + assert_eq!(shape_stroke(&s, id), 2.0); + for layer in [group, id] { + let effects = &s.active().unwrap().doc.layer(layer).unwrap().effects; + let Effect::Stroke(stroke) = &effects.items[0] else { panic!("style lost") }; + assert_eq!(stroke.size, 10.0); + } +} From c56822347b46f84d529a867b03e0e357da8899c3 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Sun, 1 Nov 2026 05:51:58 -0800 Subject: [PATCH 097/216] Preserve Image Size style scaling through ATN actions --- crates/engine/src/cmd/atn_cmds/steps_doc.rs | 10 +- crates/engine/src/cmd/atn_cmds/tests.rs | 17 +- .../tests/image_size_scale_styles_atn.rs | 149 ++++++++++++++++++ 3 files changed, 168 insertions(+), 8 deletions(-) create mode 100644 crates/engine/tests/image_size_scale_styles_atn.rs diff --git a/crates/engine/src/cmd/atn_cmds/steps_doc.rs b/crates/engine/src/cmd/atn_cmds/steps_doc.rs index 154acaf1b5..52005598bd 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_doc.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_doc.rs @@ -393,6 +393,13 @@ pub(super) fn image_size_from(d: &Descriptor) -> Mapped { if p.is_empty() { return Mapped::Unsupported { harmless: false }; } + // An omitted Photoshop flag preserves style sizes; the native command defaults to scaling. + let scale_styles = match d.get("scaleStyles") { + None => false, + Some(D::Boolean(value)) => *value, + Some(_) => return Mapped::Unsupported { harmless: false }, + }; + p.insert("scaleStyles".into(), scale_styles.into()); step("image.imageSize", Value::Object(p)) } @@ -431,7 +438,8 @@ pub(super) fn image_size_to(_: &str, p: &Value) -> Option { if let Some(i) = jstr(p, "resample").filter(|r| *r != "none") { d = d.with("Intr", enm("Intp", lookup_back(INTERPOLATION, i)?)); } - Some(d) + // Write the effective native default explicitly: omission in an ATN means false. + Some(d.with("scaleStyles", D::Boolean(jbool(p, "scaleStyles").unwrap_or(true)))) } pub(super) fn crop_from(d: &Descriptor) -> Mapped { diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index 7f30bf21b2..e606d24b06 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -527,7 +527,7 @@ fn every_mapped_command_exports_and_imports_back() { ("tools.setColors", json!({"background": "#ffffff"})), ("image.adjustments.desaturate", json!({})), ("image.autoTone", json!({})), - ("image.imageSize", json!({"width": 800, "height": 600, "resolution": 72, "resample": "bicubicSharper"})), + ("image.imageSize", json!({"width": 800, "height": 600, "resolution": 72, "resample": "bicubicSharper", "scaleStyles": true})), ("image.canvasSize", json!({"width": 100, "height": 50, "relative": true, "anchor": "topLeft", "extensionColor": "white"})), ("image.imageRotation.90cw", json!({})), ("image.rotation.arbitrary", json!({"angle": 12.5, "direction": "ccw"})), @@ -621,7 +621,7 @@ fn every_mapped_command_exports_and_imports_back() { ("layer.smartObjects.editContents", json!({})), ("layer.smartObjects.updateModifiedContent", json!({})), ("paint.bucket", json!({"x": 10, "y": 20, "tolerance": 32, "antiAlias": true, "contiguous": false, "opacity": 100})), - ("image.imageSize", json!({"percent": 200, "resample": "bicubicAutomatic"})), + ("image.imageSize", json!({"percent": 200, "resample": "bicubicAutomatic", "scaleStyles": true})), ("file.placeEmbedded", json!({"path": "/tmp/logo.png", "scale": 50})), ("file.placeLinked", json!({"path": "/tmp/logo.png"})), ("file.openAs", json!({"path": "/tmp/a.psd"})), @@ -661,7 +661,7 @@ fn every_mapped_command_exports_and_imports_back() { ("layer.newFillLayer.pattern", json!({"pattern": "Debaser-Distressed-Dark", "scale": 50, "angle": 0, "link": true})), ("layer.fill.pattern.set", json!({"pattern": "1 -R2.jpg"})), ("image.mode.bitmap", json!({"method": "threshold"})), - ("image.imageSize", json!({"resolution": 1200, "resample": "preserveDetails", "keepPrintSize": true})), + ("image.imageSize", json!({"resolution": 1200, "resample": "preserveDetails", "keepPrintSize": true, "scaleStyles": true})), ("layer.new.layerFromBackground", json!({"ifPresent": true, "select": false})), // COPYCAT, AJ_Actions2023 and the Line and Color tutorial set. ("layer.smartFilter.deleteFilterMask", json!({"ifPresent": true})), @@ -2851,8 +2851,8 @@ fn image_size_resolution_only_does_not_gain_keep_print_size() { #[test] fn image_size_intentional_controls_still_round_trip() { for params in [ - json!({"resolution": 144.0, "resample": "nearest", "keepPrintSize": true}), - json!({"width": 12.0, "height": 10.0, "resolution": 144.0, "resample": "nearest"}), + json!({"resolution": 144.0, "resample": "nearest", "keepPrintSize": true, "scaleStyles": true}), + json!({"width": 12.0, "height": 10.0, "resolution": 144.0, "resample": "nearest", "scaleStyles": true}), ] { assert_eq!(image_size_round_trip(¶ms), Some(params.clone())); } @@ -3009,12 +3009,15 @@ fn image_size_resample_none_keeps_ignoring_its_size_through_atn_export() { let mut p = sized.clone(); p["resolution"] = json!(144); p["resample"] = json!("none"); - assert_eq!(back(p.clone()), Some(json!({"resolution": 144, "resample": "none"})), "{p}"); + assert_eq!(back(p.clone()), Some(json!({"resolution": 144, "resample": "none", "scaleStyles": true})), "{p}"); } // A step with nothing left to record is refused rather than exported as an empty (unsupported) event. assert_eq!(back(json!({"width": 12, "height": 10, "resample": "none"})), None); // Real resizes are untouched. - assert_eq!(back(json!({"width": 12, "height": 10, "resample": "bicubic"})), Some(json!({"width": 12, "height": 10, "resample": "bicubic"}))); + assert_eq!( + back(json!({"width": 12, "height": 10, "resample": "bicubic"})), + Some(json!({"width": 12, "height": 10, "resample": "bicubic", "scaleStyles": true})) + ); } /// #8302, #8308: Hue/Saturation's hue is `H ` in Photoshop's `Hst2`, both ways. It was written diff --git a/crates/engine/tests/image_size_scale_styles_atn.rs b/crates/engine/tests/image_size_scale_styles_atn.rs new file mode 100644 index 0000000000..bdac446c76 --- /dev/null +++ b/crates/engine/tests/image_size_scale_styles_atn.rs @@ -0,0 +1,149 @@ +//! #10694: Image Size style scaling survives ATN import, playback and saving. + +use photocraft_color::{BlendMode, Color}; +use photocraft_doc::{Effect, Effects, FxCommon, FxPaint, LayerId, StrokeFx, StrokePosition}; +use photocraft_engine::{Session, actions_cmds::Action, atn_cmds}; +use photocraft_psd::{ + atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}, + descriptor::{Descriptor, UnicodeString, Value as D}, +}; +use serde_json::{Value, json}; + +fn recorded(event: &str, descriptor: Option) -> Vec { + let event = if event == "ImgS" { AtnEvent::Code(*b"ImgS") } else { AtnEvent::name(event) }; + atn::write(&AtnFile { + name: UnicodeString::new_nul("Styles"), + expanded: false, + actions: vec![AtnAction { + name: UnicodeString::new_nul("Half"), + items: vec![AtnItem { event, dictionary: b"Image Size".to_vec(), descriptor, ..Default::default() }], + ..Default::default() + }], + warnings: vec![], + }) + .unwrap() +} + +fn half_size() -> Descriptor { + Descriptor::new("null").with("Wdth", D::UnitFloat { unit: *b"#Prc", value: 50.0 }) +} + +fn load(s: &mut Session, bytes: &[u8]) -> Value { + s.execute("actions.load", json!({"data": photocraft_engine::paint::tile::b64_encode(bytes)})).unwrap() +} + +fn save(s: &mut Session) -> Vec { + let saved = s.execute("actions.save", json!({"set": "Styles"})).unwrap(); + assert_eq!(saved["skipped"], json!([])); + photocraft_engine::paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap() +} + +fn fixture() -> (Session, LayerId) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 80, "height": 40, "background": "transparent"})).unwrap(); + let id = LayerId(s.execute("layer.new.layer", json!({"name": "Styled"})).unwrap()["layer"].as_u64().unwrap()); + s.edit("Style", |doc, _| { + doc.layer_mut(id).unwrap().effects = Effects { + enabled: true, + items: vec![Effect::Stroke(StrokeFx { + common: FxCommon::new(BlendMode::Normal, 1.0), + size: 20.0, + position: StrokePosition::Outside, + paint: FxPaint::Color(Color::rgb(0.0, 0.0, 1.0)), + overprint: false, + })], + ..Default::default() + }; + Ok(()) + }) + .unwrap(); + (s, id) +} + +fn assert_size_and_style(s: &Session, id: LayerId, scale: bool) { + let doc = &s.active().unwrap().doc; + assert_eq!((doc.size.width, doc.size.height), (40, 20)); + let Effect::Stroke(stroke) = &doc.layer(id).unwrap().effects.items[0] else { panic!("stroke lost") }; + assert_eq!(stroke.size, if scale { 10.0 } else { 20.0 }); +} + +#[test] +fn imported_style_scaling_survives_saved_bytes_and_playback() { + for event in ["ImgS", "imageSize"] { + for option in [None, Some(false), Some(true)] { + let mut descriptor = half_size(); + if let Some(value) = option { + descriptor = descriptor.with("scaleStyles", D::Boolean(value)); + } + let (mut s, id) = fixture(); + let loaded = load(&mut s, &recorded(event, Some(descriptor))); + assert_eq!(loaded["unsupported"], json!([])); + let scale = option.unwrap_or(false); + assert_eq!(s.actions.list[0].steps, vec![("image.imageSize".into(), json!({"percent": 50, "scaleStyles": scale}))]); + let saved = save(&mut s); + let parsed = atn::parse(&saved).unwrap(); + assert_eq!(parsed.actions[0].items[0].descriptor.as_ref().unwrap().get("scaleStyles"), Some(&D::Boolean(scale))); + let imported = atn_cmds::import(&saved).unwrap(); + assert!(imported.unsupported.is_empty()); + assert_eq!(imported.actions[0].steps, s.actions.list[0].steps); + s.actions.list = imported.actions; + let result = s.execute("actions.play", json!({"action": "Half"})).unwrap(); + assert_eq!(result["ran"], 1); + assert!(result.get("failed").is_none(), "{result}"); + assert_size_and_style(&s, id, scale); + } + } +} + +#[test] +fn native_default_is_written_explicitly_and_keeps_its_behavior() { + for option in [None, Some(json!(false)), Some(json!(true)), Some(Value::Null), Some(json!(1)), Some(json!("false"))] { + let mut params = json!({"percent": 50}); + if let Some(option) = option { + params["scaleStyles"] = option; + } + let scale = params["scaleStyles"].as_bool().unwrap_or(true); + let action = Action::new("Half", vec![("image.imageSize".into(), params.clone())]); + let (bytes, skipped) = atn_cmds::export("Styles", &[&action], &[], false).unwrap(); + assert!(skipped.is_empty()); + let parsed = atn::parse(&bytes).unwrap(); + assert_eq!(parsed.actions[0].items[0].descriptor.as_ref().unwrap().get("scaleStyles"), Some(&D::Boolean(scale)), "{params}"); + let (mut direct, direct_id) = fixture(); + direct.execute("image.imageSize", params).unwrap(); + assert_size_and_style(&direct, direct_id, scale); + let (mut replay, replay_id) = fixture(); + assert_eq!(load(&mut replay, &bytes)["unsupported"], json!([])); + assert_eq!(replay.actions.list[0].steps[0].1["scaleStyles"], json!(scale)); + let result = replay.execute("actions.play", json!({"action": "Half"})).unwrap(); + assert_eq!(result["ran"], 1); + assert!(result.get("failed").is_none(), "{result}"); + assert_size_and_style(&replay, replay_id, scale); + } +} + +#[test] +fn malformed_or_empty_steps_are_kept_without_becoming_resizes() { + for event in ["ImgS", "imageSize"] { + let descriptors = [ + None, + Some(Descriptor::new("null")), + Some(Descriptor::new("null").with("scaleStyles", D::Boolean(false))), + Some(Descriptor::new("null").with("scaleStyles", D::Boolean(true))), + Some(half_size().with("scaleStyles", D::Integer(0))), + Some(half_size().with("scaleStyles", D::Integer(1))), + Some(half_size().with("scaleStyles", D::Text(UnicodeString::new_nul("false")))), + ]; + for descriptor in descriptors { + let source = recorded(event, descriptor); + let original = atn::parse(&source).unwrap(); + let mut s = Session::new(); + let loaded = load(&mut s, &source); + assert_eq!(loaded["unsupported"].as_array().unwrap().len(), 1); + assert_eq!(s.actions.list[0].steps.len(), 1); + assert_eq!(s.actions.list[0].steps[0].0, atn_cmds::PHOTOSHOP_STEP); + let saved = atn::parse(&save(&mut s)).unwrap(); + assert_eq!(saved.actions[0].items[0].descriptor, original.actions[0].items[0].descriptor); + assert_eq!(saved.actions[0].items[0].event, original.actions[0].items[0].event); + } + } +} From 84a84851cb25aa7eb563f2ea5512211f57d67c47 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Sun, 1 Nov 2026 07:02:15 -0800 Subject: [PATCH 098/216] Cover recorded canvas percentages, point sizes and mode flags --- crates/engine/tests/atn_mapped_identifiers.rs | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) diff --git a/crates/engine/tests/atn_mapped_identifiers.rs b/crates/engine/tests/atn_mapped_identifiers.rs index 899d214108..6970ee9392 100644 --- a/crates/engine/tests/atn_mapped_identifiers.rs +++ b/crates/engine/tests/atn_mapped_identifiers.rs @@ -7,6 +7,126 @@ use photocraft_psd::{ }; use serde_json::json; +/// A small marked document makes canvas translation and image resampling distinguishable. +fn sized_document(width: u32, height: u32, dpi: u32) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width":width,"height":height,"background":"white"})).unwrap(); + s.execute("image.imageSize", json!({"resolution":dpi,"resample":"none"})).unwrap(); + s.edit("Mark", |doc, _| { + doc.layers[0].surface_mut().unwrap().write_region(photocraft_geom::Rect::from_xywh(0, 0, 1, 1), &[1.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + s +} + +/// Replay saved bytes, so the geometry witness covers import, export and execution together. +fn replay_saved(s: &mut Session, action: &Action) { + let (bytes, _) = atn_cmds::export("Identifiers", &[action], &[], false).unwrap(); + let imported = atn_cmds::import(&bytes).unwrap(); + assert!(imported.unsupported.is_empty()); + assert_eq!(imported.actions[0].steps, action.steps); + s.actions.list = imported.actions; + let result = s.execute("actions.play", json!({"action":"Recorded"})).unwrap(); + assert_eq!(result["ran"], 1); + assert!(result.get("failed").is_none(), "{result}"); +} + +/// Percent canvas dimensions use each playback document and leave its pixels unscaled. +#[test] +fn recorded_canvas_percent_resolves_at_playback_and_keeps_an_omitted_side() { + for both_sides in [false, true] { + let mut d = Descriptor::new("null") + .with("Wdth", D::UnitFloat { unit: *b"#Prc", value: 200.0 }) + .with("Hrzn", enm("HrzL", "Left")) + .with("Vrtc", enm("VrtL", "Top ")); + if both_sides { + d = d.with("Hght", D::UnitFloat { unit: *b"#Prc", value: 200.0 }); + } + let action = recorded(AtnEvent::Code(*b"CnvS"), d); + assert_eq!(action.steps[0].0, "image.canvasSize", "recorded canvas percentages must map"); + assert_eq!(action.steps[0].1["units"], "percent"); + assert!(matches!(saved(&action).descriptor.unwrap().get("Wdth"), Some(D::UnitFloat {unit,value}) if unit==b"#Prc" && *value==200.0)); + for (width, height) in [(40, 30), (10, 15)] { + let mut s = sized_document(width, height, 72); + replay_saved(&mut s, &action); + assert_eq!(s.active().unwrap().doc.size, photocraft_geom::Size::new(width * 2, if both_sides { height * 2 } else { height })); + assert_eq!(s.execute("document.pixel", json!({"x":0,"y":0})).unwrap(), json!([1, 0, 0, 1])); + assert_eq!(s.execute("document.pixel", json!({"x":1,"y":0})).unwrap(), json!([1, 1, 1, 1]), "canvas expansion must not stretch the mark"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.active().unwrap().doc.size, photocraft_geom::Size::new(width, height)); + } + } +} + +/// Physical dimensions retain points and resolve current dpi before rounding or style scaling. +#[test] +fn recorded_image_points_use_current_resolution_and_keep_style_scaling() { + use photocraft_color::{BlendMode, Color}; + use photocraft_doc::{Effect, Effects, FxCommon, FxPaint, StrokeFx, StrokePosition}; + for dpi in [72, 144] { + for scale_styles in [None, Some(false), Some(true)] { + let mut d = Descriptor::new("null") + .with("Wdth", D::UnitFloat { unit: *b"#Rlt", value: 72.0 }) + .with("CnsP", D::Boolean(true)) + .with("Intr", enm("Intp", "Nrst")); + if let Some(value) = scale_styles { + d = d.with("scaleStyles", D::Boolean(value)); + } + let action = recorded(AtnEvent::Code(*b"ImgS"), d); + assert_eq!(action.steps[0].0, "image.imageSize", "recorded point dimensions must map"); + assert_eq!(action.steps[0].1["units"], "pt"); + assert_eq!(action.steps[0].1["width"], 72); + assert!(matches!(saved(&action).descriptor.unwrap().get("Wdth"), Some(D::UnitFloat {unit,value}) if unit==b"#Rlt" && *value==72.0)); + let mut s = sized_document(40, 20, dpi); + s.edit("Style", |doc, _| { + doc.layers[0].effects = Effects { + enabled: true, + items: vec![Effect::Stroke(StrokeFx { + common: FxCommon::new(BlendMode::Normal, 1.0), + size: 20.0, + position: StrokePosition::Outside, + paint: FxPaint::Color(Color::rgb(0.0, 0.0, 1.0)), + overprint: false, + })], + ..Default::default() + }; + Ok(()) + }) + .unwrap(); + replay_saved(&mut s, &action); + let doc = &s.active().unwrap().doc; + assert_eq!(doc.size, photocraft_geom::Size::new(dpi, dpi / 2)); + let Effect::Stroke(stroke) = &doc.layers[0].effects.items[0] else { panic!("stroke lost") }; + assert_eq!(stroke.size, if scale_styles.unwrap_or(false) { 20.0 * dpi as f32 / 40.0 } else { 20.0 }); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.active().unwrap().doc.size, photocraft_geom::Size::new(40, 20)); + } + } + let d = Descriptor::new("null").with("Wdth", D::UnitFloat { unit: *b"#Rlt", value: 0.36 }).with("Intr", enm("Intp", "Nrst")); + let action = recorded(AtnEvent::Code(*b"ImgS"), d); + let mut s = sized_document(40, 20, 300); + replay_saved(&mut s, &action); + assert_eq!(s.active().unwrap().doc.size, photocraft_geom::Size::new(2, 1), "round pixels after point conversion"); +} + +/// The layered false/false recording writes a class and explicit layer-retaining choices. +#[test] +fn recorded_mode_uses_a_class_and_retains_layers_with_false_flags() { + let d = Descriptor::new("null").with("T ", D::Class(class("Grys"))).with("Fltt", D::Boolean(false)).with("Rstr", D::Boolean(false)); + let action = recorded(AtnEvent::Code(*b"CnvM"), d); + assert_eq!(action.steps, vec![("image.mode.grayscale".into(), json!({}))]); + let written = saved(&action).descriptor.unwrap(); + assert!(matches!(written.get("T "),Some(D::Class(c)) if c.class_id.is("Grys")), "mode target must save as a class"); + assert_eq!(written.get("Fltt"), Some(&D::Boolean(false))); + assert_eq!(written.get("Rstr"), Some(&D::Boolean(false))); + let mut s = sized_document(4, 2, 72); + s.execute("layer.new.layer", json!({"name":"Second"})).unwrap(); + replay_saved(&mut s, &action); + assert_eq!(s.active().unwrap().doc.mode, photocraft_doc::ColorMode::Grayscale); + assert_eq!(s.active().unwrap().doc.layers.len(), 2); +} + fn class(id: &str) -> Class { Class { name: UnicodeString::new_nul(""), class_id: Id::new(id) } } From 69e93b8ca1200943d22f8cbdfa5ea7fdc7dfdac0 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Sun, 1 Nov 2026 07:05:52 -0800 Subject: [PATCH 099/216] Add native profile flatten and placed-rasterization options --- crates/engine/src/cmd/color_cmds.rs | 86 ++++- .../engine/tests/convert_profile_options.rs | 327 ++++++++++++++++++ 2 files changed, 411 insertions(+), 2 deletions(-) create mode 100644 crates/engine/tests/convert_profile_options.rs diff --git a/crates/engine/src/cmd/color_cmds.rs b/crates/engine/src/cmd/color_cmds.rs index b443441a18..a5469751bb 100644 --- a/crates/engine/src/cmd/color_cmds.rs +++ b/crates/engine/src/cmd/color_cmds.rs @@ -950,6 +950,78 @@ fn assign_profile(s: &mut Session, p: &Value) -> Result { Ok(json!({ "profile": desc })) } +/// Read opt-in destructive profile options without accepting malformed values as defaults. +fn profile_option(p: &Value, key: &str) -> Result { + match p.get(key) { + None | Some(Value::Null) => Ok(false), + Some(Value::Bool(value)) => Ok(*value), + Some(value) => Err(EngineError::BadParams { cmd: "edit.convertToProfile".into(), msg: format!("`{key}` must be true or false, not {value}") }), + } +} + +/// Prepare displayed Smart pixels, refreshing only absent caches before a destructive edit. +fn prepare_profile_smart_layers(doc: &mut Document, rasterize: bool) -> Result<()> { + let ids = { + let mut pending = crate::allocation::capacity(doc.layers.len(), "preparing placed layers for profile conversion")?; + pending.extend(doc.layers.iter().rev().map(|layer| (layer, true))); + let mut ids = Vec::new(); + while let Some((layer, parent_visible)) = pending.pop() { + let visible = parent_visible && layer.visible; + if (rasterize || visible) && matches!(layer.content, LayerContent::Smart(_)) { + ids.try_reserve(1).map_err(|_| EngineError::Other("not enough memory to prepare placed layers for profile conversion".into()))?; + ids.push(layer.id); + } + if let Some(children) = layer.children() { + pending + .try_reserve(children.len()) + .map_err(|_| EngineError::Other("not enough memory to prepare placed layers for profile conversion".into()))?; + pending.extend(children.iter().rev().map(|child| (child, visible))); + } + } + ids + }; + let format = doc.pixel_format(); + for id in ids { + let mut layer = doc.layer(id).cloned().ok_or(EngineError::NoLayer(id))?; + if matches!(&layer.content, LayerContent::Smart(smart) if smart.cache.is_none()) { + crate::smart_cmds::refresh_layer(doc, &mut layer)?; + } + let LayerContent::Smart(smart) = &layer.content else { return Err(EngineError::NoLayer(id)) }; + let surface = + smart.cache.clone().ok_or_else(|| EngineError::Other(format!("could not render placed layer `{}` for profile conversion", layer.name)))?; + let target = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; + if rasterize { + target.content = LayerContent::Raster(if surface.format() == format { surface } else { surface.convert(format) }); + target.psd_blocks.retain(|(key, _)| key != b"SoLd" && key != b"SoLE" && key != b"PlLd"); + } else { + target.content = layer.content; + } + } + Ok(()) +} + +/// Flatten in the source space so conversion preserves the composited appearance. +fn flatten_for_profile(doc: &mut Document, active: &mut Option) -> Result<()> { + let bounds = doc.bounds(); + if u64::from(bounds.width()) * u64::from(bounds.height()) > photocraft_compose::MAX_RENDER_PIXELS { + return Err(EngineError::BadParams { + cmd: "edit.convertToProfile".into(), + msg: format!("the document's {}×{} canvas is too large to flatten", bounds.width(), bounds.height()), + }); + } + prepare_profile_smart_layers(doc, false)?; + let format = doc.pixel_format(); + let surface = photocraft_compose::flatten_to_surface(doc, format, Some([1.0, 1.0, 1.0])); + let mut background = Layer::raster("Background", format); + background.locks.transparency = true; + background.locks.position = true; + *crate::pixels_mut(&mut background)? = surface; + *active = Some(background.id); + doc.layers = vec![background]; + Ok(()) +} + +/// Optionally freeze placed pixels or flatten the source, then convert in one atomic edit. fn convert_to_profile(s: &mut Session, p: &Value) -> Result { let spec = p .get("profile") @@ -959,7 +1031,17 @@ fn convert_to_profile(s: &mut Session, p: &Value) -> Result { let dst = s.color.resolve(spec, Some(doc), None)?; let intent = intent_or(p, s.color.settings.intent())?; let bpc = bool_param(p, "bpc", s.color.settings.bpc); - s.edit("Convert to Profile", |doc, _| convert_document(doc, &dst, intent, bpc))?; + let flatten = profile_option(p, "flatten")?; + let rasterize = profile_option(p, "rasterizePlaced")?; + s.edit("Convert to Profile", |doc, active| { + if flatten { + // Flatten consumes the displayed image; discarded hidden objects need no source read. + flatten_for_profile(doc, active)?; + } else if rasterize { + prepare_profile_smart_layers(doc, true)?; + } + convert_document(doc, &dst, intent, bpc) + })?; let d = &s.active().ok_or(EngineError::NoDocument)?.doc; Ok(json!({ "profile": dst.description, "mode": format!("{:?}", d.mode), "intent": intent.id(), "bpc": bpc })) } @@ -1168,7 +1250,7 @@ pub fn specs() -> Vec { "edit.convertToProfile", "Convert to Profile…", ["Edit"], - r##"{"profile":"srgb|display-p3|adobe-rgb-compat|prophoto-compat|linear-srgb|rec2020|gray-gamma-2.2|sgray|lab-d50|coated-cmyk|working" (or a path to an .icc file),"intent":"perceptual|relative|saturation|absolute"="relative","bpc":bool=true}"##, + r##"{"profile":"srgb|display-p3|adobe-rgb-compat|prophoto-compat|linear-srgb|rec2020|gray-gamma-2.2|sgray|lab-d50|coated-cmyk|working" (or a path to an .icc file),"intent":"perceptual|relative|saturation|absolute"="relative","bpc":bool=true,"flatten":bool=false (flatten in the source profile before conversion),"rasterizePlaced":bool=false (freeze displayed Smart Object pixels; flatten takes precedence)}"##, has_doc, convert_to_profile, true diff --git a/crates/engine/tests/convert_profile_options.rs b/crates/engine/tests/convert_profile_options.rs new file mode 100644 index 0000000000..9c7d5feb4f --- /dev/null +++ b/crates/engine/tests/convert_profile_options.rs @@ -0,0 +1,327 @@ +//! Native Convert to Profile can flatten source-space appearance or freeze placed pixels. + +use std::sync::Arc; + +use photocraft_cms::Builtin; +use photocraft_color::{BlendMode, Color, ColorMode, PixelFormat, SampleType}; +use photocraft_doc::{ + Document, Effect, Effects, FxCommon, FxPaint, Layer, LayerContent, LayerId, LayerMask, Path, SmartFilter, SmartObject, SmartSource, StrokeFx, + StrokePosition, Subpath, VectorMask, +}; +use photocraft_engine::{Session, smart_cmds}; +use photocraft_geom::{Affine, Rect, Size}; +use photocraft_raster::Surface; +use serde_json::{Value, json}; + +const SVG: &[u8] = br##""##; + +fn convert_params() -> Value { + json!({"profile": "linear-srgb", "intent": "relative", "bpc": false}) +} + +/// Half-opacity white over black separates source-space blending from linear-space blending. +fn layered(depth: u32, mode: &str) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 6, "depth": depth, "mode": mode, "background": "transparent"})).unwrap(); + s.execute("edit.fill", json!({"contents": "black"})).unwrap(); + let id = LayerId(s.execute("layer.new.layer", json!({"name": "Half white"})).unwrap()["layer"].as_u64().unwrap()); + s.execute("edit.fill", json!({"contents": "white"})).unwrap(); + s.edit("Layer opacity", |doc, _| { + doc.layer_mut(id).unwrap().opacity = 0.5; + Ok(()) + }) + .unwrap(); + let profile = match mode { + "cmyk" => "coated-cmyk", + "lab" => "lab-d50", + _ => "srgb", + }; + s.execute("edit.assignProfile", json!({"profile": profile})).unwrap(); + s +} + +fn from_document(doc: Document, active: Option) -> Session { + let mut s = Session::new(); + s.add_document(doc, None); + if let Some(id) = active { + s.select_layer(id).unwrap(); + } + s +} + +fn composed(s: &Session) -> Vec<[f32; 4]> { + photocraft_compose::flatten(&s.active().unwrap().doc).px +} + +/// The public source-first control must differ from conversion before flattening. +#[test] +fn flatten_uses_source_space_at_every_depth() { + for depth in [8, 16, 32] { + let mut s = layered(depth, "rgb"); + let before = s.active().unwrap().doc.clone(); + let active = s.active().unwrap().active_layer; + let past = s.active().unwrap().history.past_len(); + let mut source_first = from_document((*before).clone(), active); + source_first.execute("layer.flattenImage", json!({})).unwrap(); + source_first.execute("edit.convertToProfile", convert_params()).unwrap(); + let expected = composed(&source_first); + let mut destination_first = from_document((*before).clone(), active); + destination_first.execute("edit.convertToProfile", convert_params()).unwrap(); + destination_first.execute("layer.flattenImage", json!({})).unwrap(); + assert!((expected[0][0] - composed(&destination_first)[0][0]).abs() > 0.1, "order fixture at {depth}-bit"); + + let mut params = convert_params(); + params["flatten"] = json!(true); + s.execute("edit.convertToProfile", params).unwrap(); + assert_eq!(composed(&s), expected, "flatten must precede profile conversion at {depth}-bit"); + let after = s.active().unwrap().doc.clone(); + assert_eq!(after.layers.len(), 1); + assert!(matches!(after.layers[0].content, LayerContent::Raster(_))); + assert!(after.layers[0].locks.position && after.layers[0].locks.transparency); + assert_eq!(s.active().unwrap().active_layer, Some(after.layers[0].id)); + assert_eq!(s.active().unwrap().history.past_len(), past + 1); + assert_eq!(after.icc_profile, source_first.active().unwrap().doc.icc_profile); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.active().unwrap().doc, before); + assert_eq!(s.active().unwrap().active_layer, active); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(s.active().unwrap().doc, after); + } +} + +/// Native CMYK and Lab flattening retain the existing compositor and conversion path. +#[test] +fn flatten_keeps_native_source_color_models() { + for mode in ["cmyk", "lab"] { + let mut s = layered(8, mode); + let before = s.active().unwrap().doc.clone(); + let mut control = from_document((*before).clone(), s.active().unwrap().active_layer); + control.execute("layer.flattenImage", json!({})).unwrap(); + control.execute("edit.convertToProfile", convert_params()).unwrap(); + let mut params = convert_params(); + params["flatten"] = json!(true); + s.execute("edit.convertToProfile", params).unwrap(); + assert_eq!(s.active().unwrap().doc.layers.len(), 1); + assert_eq!(composed(&s), composed(&control), "source model {mode}"); + } +} + +/// Omitted, false and null options retain the old per-layer conversion behavior. +#[test] +fn default_options_preserve_editable_layers_and_reject_wrong_types() { + let s = layered(8, "rgb"); + let before = s.active().unwrap().doc.clone(); + let active = s.active().unwrap().active_layer; + let mut control = from_document((*before).clone(), active); + control.execute("edit.convertToProfile", convert_params()).unwrap(); + for value in [None, Some(json!(false)), Some(Value::Null)] { + let mut actual = from_document((*before).clone(), active); + let mut params = convert_params(); + if let Some(value) = value { + params["flatten"] = value.clone(); + params["rasterizePlaced"] = value; + } + actual.execute("edit.convertToProfile", params).unwrap(); + assert_eq!(actual.active().unwrap().doc, control.active().unwrap().doc); + assert_eq!(actual.active().unwrap().active_layer, active); + } + for key in ["flatten", "rasterizePlaced"] { + for value in [json!(1), json!("false"), json!([]), json!({})] { + let mut actual = from_document((*before).clone(), active); + let mut params = convert_params(); + params[key] = value; + assert_rejected(&mut actual, params); + } + } +} + +/// Offset pixels with distinct alpha expose a second placement, filter or mask application. +fn shown_pixels() -> Surface { + let mut surface = Surface::new(PixelFormat::RGBA32F); + surface.fill_rect(Rect::new(7, 5, 12, 9), &[0.0, 0.0, 1.0, 0.75]); + surface.fill_rect(Rect::new(12, 7, 15, 11), &[0.0, 1.0, 1.0, 0.5]); + surface +} + +fn embedded() -> SmartSource { + SmartSource::Embedded { file_name: "profile.svg".into(), bytes: Arc::new(SVG.to_vec()) } +} + +/// Keep source, placement and filter data deliberately different from the displayed cache. +fn placed(source: SmartSource, cache: Option, unsupported_filter: bool) -> Layer { + let mut smart = SmartObject::new(source, Affine { m: [2.0, 0.0, 0.0, 2.0, 7.0, 5.0] }, cache); + smart.smart_filters.push(SmartFilter { + command: if unsupported_filter { "psd.unsupportedFilter" } else { "filter.other.offset" }.into(), + params: if unsupported_filter { json!({"name": "Cache sentinel"}) } else { json!({"horizontal": 3, "vertical": 1}) }, + blend: BlendMode::Normal, + opacity: 0.7, + visible: true, + }); + let mut filter_mask = LayerMask::reveal_all(); + filter_mask.surface.fill_rect(Rect::new(7, 5, 10, 11), &[0.25]); + smart.filter_mask = Some(filter_mask); + smart.psd_raw = Some(Arc::new(vec![1, 2, 3, 4])); + let mut layer = Layer::new("Placed", LayerContent::Smart(smart)); + layer.opacity = 0.6; + layer.fill_opacity = 0.8; + let mut mask = LayerMask::reveal_all(); + mask.surface.fill_rect(Rect::new(7, 5, 10, 11), &[0.5]); + mask.density = 0.75; + layer.mask = Some(mask); + layer.vector_mask = + Some(VectorMask::new(Path { subpaths: vec![Subpath::polygon(&[(8.0, 4.0), (14.0, 4.0), (14.0, 12.0), (8.0, 12.0)])], ..Default::default() })); + layer.effects = Effects { + enabled: true, + items: vec![Effect::Stroke(StrokeFx { + common: FxCommon::new(BlendMode::Normal, 1.0), + size: 1.0, + position: StrokePosition::Outside, + paint: FxPaint::Color(Color::rgb(1.0, 1.0, 0.0)), + overprint: false, + })], + ..Default::default() + }; + for key in [*b"SoLd", *b"SoLE", *b"PlLd", *b"zzzz"] { + layer.psd_blocks.push((key, Arc::new(vec![9, 8, 7]))); + } + layer +} + +/// Nest placed objects under two groups with an ordinary active sibling. +fn smart_scene(children: Vec) -> Session { + let mut doc = Document::new("Placed profile", Size::new(24, 18), ColorMode::Rgb, SampleType::F32); + doc.icc_profile = Some(Builtin::Srgb.profile().to_bytes()); + let ordinary = Layer::raster("Ordinary", PixelFormat::RGBA32F); + let active = ordinary.id; + doc.layers = vec![Layer::group("Outer", vec![Layer::group("Inner", children)]), ordinary]; + from_document(doc, Some(active)) +} + +fn missing_source() -> SmartSource { + SmartSource::Linked { path: "missing-profile-smart-source-7ecb9a.svg".into() } +} + +/// Convert manually substituted cached pixels through the unchanged command path. +fn cached_control(before: &Document, ids: &[LayerId], active: Option) -> Session { + let mut expected = before.clone(); + let format = expected.pixel_format(); + for id in ids { + let LayerContent::Smart(smart) = &expected.layer(*id).unwrap().content else { panic!("smart object missing") }; + let cache = smart.cache.clone().unwrap(); + let layer = expected.layer_mut(*id).unwrap(); + layer.content = LayerContent::Raster(if cache.format() == format { cache } else { cache.convert(format) }); + layer.psd_blocks.retain(|(key, _)| key != b"SoLd" && key != b"SoLE" && key != b"PlLd"); + } + let mut s = from_document(expected, active); + s.execute("edit.convertToProfile", json!({"profile": "srgb", "intent": "relative", "bpc": false})).unwrap(); + s +} + +/// Rasterization freezes nested and unselected caches without rebaking masks or transforms. +#[test] +fn rasterize_placed_freezes_displayed_caches_and_restores_models_on_undo() { + let first = placed(missing_source(), Some(shown_pixels()), true); + let mut second = placed(embedded(), Some(shown_pixels()), false); + second.visible = false; + let ids = [first.id, second.id]; + let mut s = smart_scene(vec![first, second]); + s.execute("shape.create", json!({"rect": [0, 0, 3, 3], "fill": "#336699"})).unwrap(); + let active = s.active().unwrap().doc.layers.last().unwrap().id; + s.select_layer(active).unwrap(); + let before = s.active().unwrap().doc.clone(); + let past = s.active().unwrap().history.past_len(); + let selection = s.active().unwrap().selected_layers.clone(); + let control = cached_control(&before, &ids, Some(active)); + s.execute("edit.convertToProfile", json!({"profile": "srgb", "intent": "relative", "bpc": false, "rasterizePlaced": true})).unwrap(); + let after = s.active().unwrap().doc.clone(); + for id in ids { + assert!(matches!(after.layer(id).unwrap().content, LayerContent::Raster(_)), "rasterizePlaced must replace every placed model"); + assert_eq!(after.layer(id), control.active().unwrap().doc.layer(id)); + assert_eq!(after.layer(id).unwrap().surface().unwrap().rgba(9, 6)[3], 0.75); + assert_eq!(after.layer(id).unwrap().surface().unwrap().rgba(13, 8)[3], 0.5); + } + assert!(after.walk().iter().any(|(_, _, layer)| matches!(layer.content, LayerContent::Shape(_)))); + assert_eq!(composed(&s), composed(&control)); + assert_eq!(s.active().unwrap().active_layer, Some(active)); + assert_eq!(s.active().unwrap().selected_layers, selection); + assert_eq!(s.active().unwrap().history.past_len(), past + 1); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.active().unwrap().doc, before); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(s.active().unwrap().doc, after); +} + +/// A refusal must preserve the document, selection, revision and undo/redo state. +fn assert_rejected(s: &mut Session, params: Value) { + let state = s.active().unwrap(); + let before = state.doc.clone(); + let revision = state.revision; + let active = state.active_layer; + let selection = state.selected_layers.clone(); + let history = + (state.history.past_len(), state.history.can_redo(), state.history.undo_label().map(str::to_owned), state.history.redo_label().map(str::to_owned)); + assert!(s.execute("edit.convertToProfile", params).is_err()); + let state = s.active().unwrap(); + assert_eq!(state.doc, before); + assert_eq!(state.revision, revision); + assert_eq!(state.active_layer, active); + assert_eq!(state.selected_layers, selection); + assert_eq!( + (state.history.past_len(), state.history.can_redo(), state.history.undo_label().map(str::to_owned), state.history.redo_label().map(str::to_owned)), + history + ); +} + +/// Missing or unrenderable visible pixels cannot become a successful blank conversion. +#[test] +fn unavailable_placed_pixels_and_oversize_flattening_fail_atomically() { + for unavailable in [placed(missing_source(), None, false), placed(embedded(), None, true)] { + for (flatten, rasterize) in [(false, true), (true, false), (true, true)] { + let mut s = smart_scene(vec![placed(embedded(), Some(shown_pixels()), false), unavailable.clone()]); + s.execute("layer.new.layer", json!({"name": "Redo sentinel"})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + assert!(s.active().unwrap().history.can_redo()); + assert_rejected(&mut s, json!({"profile": "display-p3", "flatten": flatten, "rasterizePlaced": rasterize})); + } + } + let mut large = Session::new(); + large.execute("file.new", json!({"width": 40000, "height": 30000, "background": "transparent"})).unwrap(); + assert_rejected(&mut large, json!({"profile": "srgb", "flatten": true})); +} + +/// A missing cache can render from an available source; hidden discarded content needs no read. +#[test] +fn available_uncached_sources_render_and_flatten_skips_hidden_sources() { + let mut layer = Layer::new("Available", LayerContent::Smart(SmartObject::new(embedded(), Affine { m: [1.0, 0.0, 0.0, 1.0, 3.0, 4.0] }, None))); + let id = layer.id; + let original = smart_scene(vec![layer.clone()]); + let mut rendered = (*original.active().unwrap().doc).clone(); + assert!(smart_cmds::refresh_layer(&rendered, &mut layer).unwrap()); + let LayerContent::Smart(smart) = &layer.content else { panic!("smart object missing") }; + assert!(smart.cache.as_ref().unwrap().rgba(4, 5)[3] > 0.0); + rendered.layer_mut(id).unwrap().content = layer.content; + for flatten in [false, true] { + let active = original.active().unwrap().active_layer; + let mut s = from_document((*original.active().unwrap().doc).clone(), active); + let mut control = if flatten { from_document(rendered.clone(), active) } else { cached_control(&rendered, &[id], active) }; + if flatten { + control.execute("layer.flattenImage", json!({})).unwrap(); + control.execute("edit.convertToProfile", json!({"profile": "srgb", "intent": "relative", "bpc": false})).unwrap(); + } + s.execute("edit.convertToProfile", json!({"profile": "srgb", "intent": "relative", "bpc": false, "flatten": flatten, "rasterizePlaced": !flatten})) + .unwrap(); + assert_eq!(composed(&s), composed(&control)); + assert!(s.active().unwrap().doc.walk().iter().all(|(_, _, layer)| !matches!(layer.content, LayerContent::Smart(_)))); + } + for (flatten, rasterize) in [(true, false), (true, true), (false, true)] { + let mut hidden = placed(missing_source(), None, false); + hidden.visible = false; + let mut s = smart_scene(vec![hidden]); + if flatten { + s.execute("edit.convertToProfile", json!({"profile": "srgb", "flatten": true, "rasterizePlaced": rasterize})).unwrap(); + assert_eq!(s.active().unwrap().doc.layers.len(), 1); + } else { + assert_rejected(&mut s, json!({"profile": "srgb", "rasterizePlaced": true})); + } + } +} From 91fd1705700cefabaaa1ce6d8479c3f997c2452c Mon Sep 17 00:00:00 2001 From: yoriswind Date: Sun, 1 Nov 2026 07:09:14 -0800 Subject: [PATCH 100/216] Resolve recorded ATN dimensions at playback and write mode classes --- crates/engine/src/cmd/atn_cmds/steps_doc.rs | 132 ++++++++++++++++-- crates/engine/src/cmd/image_cmds.rs | 43 +++++- crates/engine/tests/atn_mapped_identifiers.rs | 54 ++++++- 3 files changed, 211 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/cmd/atn_cmds/steps_doc.rs b/crates/engine/src/cmd/atn_cmds/steps_doc.rs index 52005598bd..eef5458108 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_doc.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_doc.rs @@ -1,7 +1,7 @@ use super::convert::clip_name; use super::descriptors::{ - PCT, PX, Target, ang, class, color_desc, color_hex, desc, enm, get_bool, get_enum, get_num, get_obj, get_ref, get_text, jbool, jnum, jstr, long, path_text, - path_value, pct, px, target, target_ref, text, trimmed, + PCT, PT, PX, Target, ang, class, color_desc, color_hex, desc, enm, get_bool, get_enum, get_num, get_obj, get_ref, get_text, jbool, jnum, jstr, long, + path_text, path_value, pct, px, target, target_ref, text, trimmed, }; use super::fields::{Mapped, none, step, steps}; use super::tables::{ALIGN, EXTENSION, INTENT, INTERPOLATION, MODES, PROFILES, PURGE, SAVE_FORMATS, lookup, lookup_back}; @@ -201,8 +201,18 @@ pub(super) fn export_to(_: &str, p: &Value) -> Option { Some(desc("null").with("Usng", D::Descriptor(u)).with("In ", path_value(path))) } +/// Import layer-retaining conversions; keep unsupported destructive choices verbatim. pub(super) fn convert_mode_from(d: &Descriptor) -> Mapped { + let layer_options = d.get("Fltt").is_some() || d.get("Rstr").is_some(); + for key in ["Fltt", "Rstr"] { + if !matches!(d.get(key), None | Some(D::Boolean(false))) { + return Mapped::Unsupported { harmless: false }; + } + } if let Some(depth) = get_num(d, "Dpth") { + if layer_options { + return Mapped::Unsupported { harmless: false }; + } return match depth as i64 { 8 => none("image.mode.bits8"), 16 => none("image.mode.bits16"), @@ -219,8 +229,14 @@ pub(super) fn convert_mode_from(d: &Descriptor) -> Mapped { _ => return Mapped::Unsupported { harmless: false }, }; if mode == "BtmM" { + if layer_options { + return Mapped::Unsupported { harmless: false }; + } return bitmap_from(options); } + if mode == "Mltc" && layer_options { + return Mapped::Unsupported { harmless: false }; + } // Bitmap to Grayscale records a size ratio; only 1 keeps the pixels as they are. if options.and_then(|o| get_num(o, "Rt ")).is_some_and(|r| (r - 1.0).abs() > 1e-9) { return Mapped::Unsupported { harmless: false }; @@ -257,28 +273,47 @@ fn bitmap_to(p: &Value) -> Option { Some(desc("null").with("T ", D::Descriptor(desc("BtmM").with("Mthd", enm("Mthd", method))))) } +/// Write mode classes and the native CMS conversion's explicit layer-retaining policy. pub(super) fn convert_mode_to(id: &str, p: &Value) -> Option { Some(match id { "image.mode.bits8" => desc("null").with("Dpth", long(8.0)), "image.mode.bits16" => desc("null").with("Dpth", long(16.0)), "image.mode.bits32" => desc("null").with("Dpth", long(32.0)), "image.mode.bitmap" => bitmap_to(p)?, - _ => desc("null").with("T ", enm("ClrS", lookup_back(MODES, id.strip_prefix("image.mode.")?)?)), + _ => { + let mode = id.strip_prefix("image.mode.")?; + let mut d = desc("null").with("T ", D::Class(class(lookup_back(MODES, mode)?))); + if mode != "multichannel" { + d = d.with("Fltt", D::Boolean(false)).with("Rstr", D::Boolean(false)); + } + d + } }) } /// Import canvas dimensions, anchors and explicit extension-colour choices. pub(super) fn canvas_size_from(d: &Descriptor) -> Mapped { let mut p = Map::new(); + let mut units = None; for (k, param) in [("Wdth", "width"), ("Hght", "height")] { match d.get(k) { - Some(D::UnitFloat { unit: PX, value }) => { - p.insert(param.into(), value.round().into()); + Some(D::UnitFloat { unit, value }) if (*unit == PX || *unit == PCT) && value.is_finite() => { + if units.is_some_and(|previous| previous != *unit) || (*unit == PCT && *value <= 0.0) { + return Mapped::Unsupported { harmless: false }; + } + units = Some(*unit); + p.insert(param.into(), if *unit == PX { value.round() } else { *value }.into()); } Some(_) => return Mapped::Unsupported { harmless: false }, None => {} } } + if units == Some(PCT) { + if !matches!(d.get("Rltv"), None | Some(D::Boolean(false))) { + return Mapped::Unsupported { harmless: false }; + } + p.insert("units".into(), "percent".into()); + } if let Some(r) = get_bool(d, "Rltv") { p.insert("relative".into(), r.into()); } @@ -317,6 +352,22 @@ pub(super) fn canvas_size_from(d: &Descriptor) -> Mapped { /// Write canvas geometry and canonical extension-colour identifiers. pub(super) fn canvas_size_to(_: &str, p: &Value) -> Option { + let units = match p.get("units") { + None => PX, + Some(Value::String(unit)) if unit == "px" => PX, + Some(Value::String(unit)) if unit == "percent" && jbool(p, "relative") != Some(true) => PCT, + _ => return None, + }; + if units == PCT { + for key in ["width", "height"] { + if p.get(key).is_some_and(|value| !value.is_null() && value.as_f64().is_none_or(|number| !number.is_finite() || number <= 0.0)) { + return None; + } + } + if jnum(p, "width").is_none() && jnum(p, "height").is_none() { + return None; + } + } let anchor = jstr(p, "anchor").unwrap_or("center"); let v = if anchor.starts_with("top") { "Top " @@ -337,10 +388,16 @@ pub(super) fn canvas_size_to(_: &str, p: &Value) -> Option { d = d.with("Rltv", D::Boolean(r)); } if let Some(w) = jnum(p, "width") { - d = d.with("Wdth", px(w)); + if units == PCT && w <= 0.0 { + return None; + } + d = d.with("Wdth", D::UnitFloat { unit: units, value: w }); } if let Some(hh) = jnum(p, "height") { - d = d.with("Hght", px(hh)); + if units == PCT && hh <= 0.0 { + return None; + } + d = d.with("Hght", D::UnitFloat { unit: units, value: hh }); } d = d.with("Hrzn", enm("HrzL", h)).with("Vrtc", enm("VrtL", v)); if let Some(c) = jstr(p, "extensionColor") { @@ -352,17 +409,28 @@ pub(super) fn canvas_size_to(_: &str, p: &Value) -> Option { Some(d) } +/// Keep recorded physical dimensions until playback can use the current document's dpi. pub(super) fn image_size_from(d: &Descriptor) -> Mapped { let mut p = Map::new(); let mut percent: Option = None; + let mut physical = false; for (k, param) in [("Wdth", "width"), ("Hght", "height")] { match d.get(k) { Some(D::UnitFloat { unit: PX, value }) => { + if physical { + return Mapped::Unsupported { harmless: false }; + } p.insert(param.into(), value.round().into()); } - // Percent scales both sides at playback; two different percentages, or physical - // units, need the document's size and resolution and stay unsupported. - Some(D::UnitFloat { unit: PCT, value }) => match percent { + Some(D::UnitFloat { unit: PT, value }) if value.is_finite() && *value > 0.0 => { + if percent.is_some() || (!physical && !p.is_empty()) { + return Mapped::Unsupported { harmless: false }; + } + physical = true; + p.insert(param.into(), (*value).into()); + } + // Percent scales both sides; retain the existing pixel/percent precedence. + Some(D::UnitFloat { unit: PCT, value }) if !physical => match percent { Some(other) if (other - value).abs() > 1e-9 => return Mapped::Unsupported { harmless: false }, _ => percent = Some(*value), }, @@ -370,6 +438,17 @@ pub(super) fn image_size_from(d: &Descriptor) -> Mapped { None => {} } } + if physical { + // A simultaneous dpi change or an ambiguous aspect choice needs its own reference form. + if d.get("Rslt").is_some() + || !matches!(d.get("CnsP"), None | Some(D::Boolean(_))) + || (get_bool(d, "CnsP") == Some(false) && (p.contains_key("width") != p.contains_key("height"))) + || (get_bool(d, "CnsP") == Some(true) && p.contains_key("width") && p.contains_key("height")) + { + return Mapped::Unsupported { harmless: false }; + } + p.insert("units".into(), "pt".into()); + } if let Some(pc) = percent { p.insert("percent".into(), pc.into()); } @@ -403,7 +482,14 @@ pub(super) fn image_size_from(d: &Descriptor) -> Mapped { step("image.imageSize", Value::Object(p)) } +/// Export the recorded unit, avoiding sizes the native no-resample operation ignores. pub(super) fn image_size_to(_: &str, p: &Value) -> Option { + let unit = match p.get("units") { + None => PX, + Some(Value::String(unit)) if unit == "px" => PX, + Some(Value::String(unit)) if unit == "pt" => PT, + _ => return None, + }; // Resolution only with Resample on imports as keep-print-size; a command without it cannot be told apart. if jnum(p, "resolution").is_some() && jnum(p, "width").is_none() @@ -419,6 +505,19 @@ pub(super) fn image_size_to(_: &str, p: &Value) -> Option { // descriptor has no "none" (it is the absence of `Intr`), so writing the ignored size would // turn the step into a pixel resize on playback (#7189). let resolution_only = jstr(p, "resample") == Some("none"); + if unit == PT && !resolution_only { + for key in ["width", "height"] { + if p.get(key).is_some_and(|value| !value.is_null() && value.as_f64().is_none_or(|number| !number.is_finite() || number <= 0.0)) { + return None; + } + } + if jnum(p, "width").is_none() && jnum(p, "height").is_none() { + return None; + } + } + if unit == PT && !resolution_only && (p.get("resolution").is_some() || p.get("percent").is_some()) { + return None; + } if resolution_only && jnum(p, "resolution").is_none() { // Nothing left to record: the step changes nothing. return None; @@ -427,10 +526,19 @@ pub(super) fn image_size_to(_: &str, p: &Value) -> Option { d = d.with("Wdth", pct(pc)).with("Hght", pct(pc)).with("CnsP", D::Boolean(true)); } if let Some(w) = jnum(p, "width").filter(|_| !resolution_only) { - d = d.with("Wdth", px(w)); + if unit == PT && w <= 0.0 { + return None; + } + d = d.with("Wdth", D::UnitFloat { unit, value: w }); } if let Some(h) = jnum(p, "height").filter(|_| !resolution_only) { - d = d.with("Hght", px(h)); + if unit == PT && h <= 0.0 { + return None; + } + d = d.with("Hght", D::UnitFloat { unit, value: h }); + } + if unit == PT && !resolution_only { + d = d.with("CnsP", D::Boolean(jnum(p, "width").is_none() || jnum(p, "height").is_none())); } if let Some(r) = jnum(p, "resolution") { d = d.with("Rslt", D::UnitFloat { unit: *b"#Rsl", value: r }); diff --git a/crates/engine/src/cmd/image_cmds.rs b/crates/engine/src/cmd/image_cmds.rs index cd18a7427a..9480f75faf 100644 --- a/crates/engine/src/cmd/image_cmds.rs +++ b/crates/engine/src/cmd/image_cmds.rs @@ -193,6 +193,23 @@ fn parse_resample(s: &str) -> Method { /// The largest raster Image Size resamples to: the decoders' default allocation budget. const MAX_RESAMPLE_BYTES: u64 = if cfg!(target_pointer_width = "64") { 8 << 30 } else { 2 << 30 }; +/// Keep pixel defaults while rejecting an explicit unsupported dimension unit. +fn size_units<'a>(p: &'a Value, cmd: &str, allowed: &[&str]) -> Result<&'a str> { + let Some(value) = p.get("units") else { return Ok("px") }; + let units = value.as_str().filter(|unit| allowed.contains(unit)).ok_or_else(|| bad(cmd, "unsupported `units` for these dimensions"))?; + Ok(units) +} + +/// Resolve a positive dimension before rounding, rejecting invalid or overflowing conversions. +fn scaled_dimension(p: &Value, key: &str, scale: f64, cmd: &str) -> Result> { + let Some(value) = p.get(key).filter(|value| !value.is_null()) else { return Ok(None) }; + let value = value.as_f64().filter(|value| value.is_finite() && *value > 0.0).ok_or_else(|| bad(cmd, format!("`{key}` must be a positive number")))? * scale; + if !value.is_finite() || value <= 0.0 { + return Err(bad(cmd, format!("`{key}` cannot be converted to pixels"))); + } + Ok(Some(value)) +} + /// Image → Image Size. fn image_size(s: &mut Session, p: &Value) -> Result { let d = s.active().ok_or(EngineError::NoDocument)?; @@ -206,6 +223,12 @@ fn image_size(s: &mut Session, p: &Value) -> Result { }; let mut pw = p.get("width").and_then(Value::as_f64); let mut ph = p.get("height").and_then(Value::as_f64); + if size_units(p, "image.imageSize", &["px", "pt"])? == "pt" && !matches!(resample, Method::None) { + // Recorded points are 1/72 inch, resolved against this document at playback. + let scale = f64::from(d.doc.resolution_dpi) / 72.0; + pw = scaled_dimension(p, "width", scale, "image.imageSize")?; + ph = scaled_dimension(p, "height", scale, "image.imageSize")?; + } // `percent` scales both sides (what a Photoshop action records as "200%"); an explicit side wins. if let Some(pc) = p.get("percent") { let pc = pc.as_f64().filter(|v| v.is_finite() && *v > 0.0).ok_or_else(|| bad("image.imageSize", "`percent` must be a positive number"))?; @@ -534,8 +557,20 @@ fn canvas_size(s: &mut Session, p: &Value) -> Result { let d = s.active().ok_or(EngineError::NoDocument)?; let (ow, oh) = (d.doc.size.width as f64, d.doc.size.height as f64); let relative = p.get("relative").and_then(Value::as_bool).unwrap_or(false); - let w = p.get("width").and_then(Value::as_f64).unwrap_or(if relative { 0.0 } else { ow }); - let h = p.get("height").and_then(Value::as_f64).unwrap_or(if relative { 0.0 } else { oh }); + let (w, h) = if size_units(p, "image.canvasSize", &["px", "percent"])? == "percent" { + if relative { + return Err(bad("image.canvasSize", "percentage dimensions require an absolute canvas size")); + } + ( + scaled_dimension(p, "width", ow / 100.0, "image.canvasSize")?.unwrap_or(ow), + scaled_dimension(p, "height", oh / 100.0, "image.canvasSize")?.unwrap_or(oh), + ) + } else { + ( + p.get("width").and_then(Value::as_f64).unwrap_or(if relative { 0.0 } else { ow }), + p.get("height").and_then(Value::as_f64).unwrap_or(if relative { 0.0 } else { oh }), + ) + }; let (nw, nh) = if relative { (ow + w, oh + h) } else { (w, h) }; let (nw, nh) = (nw.round().clamp(1.0, 300_000.0) as u32, nh.round().clamp(1.0, 300_000.0) as u32); let (ax, ay) = anchor_factors("image.canvasSize", p.get("anchor").and_then(Value::as_str).unwrap_or("center"))?; @@ -991,7 +1026,7 @@ pub fn specs() -> Vec { "image.imageSize", "Image Size…", ["Image"], - r##"{"width":px,"height":px,"percent":%? (both sides; an explicit side wins),"resolution":ppi,"keepPrintSize":bool=false (with only a resolution: resample the pixels to keep the print size),"scaleStyles":bool=true (scale layer effects with the pixels),"resample":"automatic|preserveDetails|bicubicSmoother|bicubicSharper|bicubicAutomatic|bicubic|nearest|bilinear|lanczos|none"="bicubic"} → {width,height} (the document's size after the command; "none" changes the resolution only)"##, + r##"{"width":number,"height":number,"units":"px|pt"="px" (points use the document's current resolution),"percent":%? (both sides; an explicit side wins),"resolution":ppi,"keepPrintSize":bool=false (with only a resolution: resample the pixels to keep the print size),"scaleStyles":bool=true (scale layer effects with the pixels),"resample":"automatic|preserveDetails|bicubicSmoother|bicubicSharper|bicubicAutomatic|bicubic|nearest|bilinear|lanczos|none"="bicubic"} → {width,height} (the document's size after the command; "none" changes the resolution only)"##, has_doc, image_size ), @@ -999,7 +1034,7 @@ pub fn specs() -> Vec { "image.canvasSize", "Canvas Size…", ["Image"], - r##"{"width":px,"height":px,"relative":bool=false,"anchor":"topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight"="center","extensionColor":"background|foreground|white|black|transparent|#rrggbb"="background"}"##, + r##"{"width":number,"height":number,"units":"px|percent"="px" (percent uses each current side; absolute sizes only),"relative":bool=false,"anchor":"topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight"="center","extensionColor":"background|foreground|white|black|transparent|#rrggbb"="background"}"##, has_doc, canvas_size ), diff --git a/crates/engine/tests/atn_mapped_identifiers.rs b/crates/engine/tests/atn_mapped_identifiers.rs index 6970ee9392..bd4db1711a 100644 --- a/crates/engine/tests/atn_mapped_identifiers.rs +++ b/crates/engine/tests/atn_mapped_identifiers.rs @@ -51,8 +51,8 @@ fn recorded_canvas_percent_resolves_at_playback_and_keeps_an_omitted_side() { let mut s = sized_document(width, height, 72); replay_saved(&mut s, &action); assert_eq!(s.active().unwrap().doc.size, photocraft_geom::Size::new(width * 2, if both_sides { height * 2 } else { height })); - assert_eq!(s.execute("document.pixel", json!({"x":0,"y":0})).unwrap(), json!([1, 0, 0, 1])); - assert_eq!(s.execute("document.pixel", json!({"x":1,"y":0})).unwrap(), json!([1, 1, 1, 1]), "canvas expansion must not stretch the mark"); + assert_eq!(s.execute("document.pixel", json!({"x":0,"y":0})).unwrap(), json!([1.0, 0.0, 0.0, 1.0])); + assert_eq!(s.execute("document.pixel", json!({"x":1,"y":0})).unwrap(), json!([1.0, 1.0, 1.0, 1.0]), "canvas expansion must not stretch the mark"); s.execute("edit.undo", json!({})).unwrap(); assert_eq!(s.active().unwrap().doc.size, photocraft_geom::Size::new(width, height)); } @@ -127,6 +127,56 @@ fn recorded_mode_uses_a_class_and_retains_layers_with_false_flags() { assert_eq!(s.active().unwrap().doc.layers.len(), 2); } +/// Unsupported dimension combinations and destructive mode choices must not become different edits. +#[test] +fn unrepresented_units_and_mode_choices_stay_verbatim() { + let point = || Descriptor::new("null").with("Wdth", D::UnitFloat { unit: *b"#Rlt", value: 72.0 }); + let mode = || Descriptor::new("null").with("T ", D::Class(class("Grys"))); + let canvas = || Descriptor::new("null").with("Wdth", D::UnitFloat { unit: *b"#Prc", value: 200.0 }); + for (event, d) in [ + (*b"CnvM", mode().with("Fltt", D::Boolean(true))), + (*b"CnvM", mode().with("Rstr", D::Boolean(true))), + (*b"CnvM", mode().with("Rstr", D::Integer(0))), + (*b"CnvS", canvas().with("Hght", D::UnitFloat { unit: *b"#Pxl", value: 40.0 })), + (*b"CnvS", canvas().with("Rltv", D::Boolean(true))), + (*b"ImgS", point().with("Hght", D::UnitFloat { unit: *b"#Pxl", value: 40.0 })), + (*b"ImgS", point().with("Rslt", D::UnitFloat { unit: *b"#Rsl", value: 144.0 })), + (*b"ImgS", point().with("CnsP", D::Boolean(false))), + ] { + let action = recorded(AtnEvent::Code(event), d.clone()); + assert_eq!(action.steps[0].0, atn_cmds::PHOTOSHOP_STEP); + assert_eq!(saved(&action).descriptor, Some(d)); + } +} + +/// New unit validation must fail before editing, while no-resample still ignores size fields. +#[test] +fn native_units_validate_before_edit_and_no_resample_remains_resolution_only() { + let mut s = sized_document(40, 20, 300); + for (id, params, refuse_export) in [ + ("image.canvasSize", json!({"width":200,"units":"unknown"}), true), + ("image.canvasSize", json!({"width":"200","units":"percent"}), true), + ("image.canvasSize", json!({"width":200,"units":"percent","relative":true}), true), + ("image.imageSize", json!({"width":72,"units":1}), true), + ("image.imageSize", json!({"width":"72","units":"pt"}), true), + // A finite recorded point value is representable; overflow depends on playback dpi. + ("image.imageSize", json!({"width":1e308,"units":"pt"}), false), + ] { + let before = s.execute("document.inspect", json!({})).unwrap(); + assert!(s.execute(id, params.clone()).is_err(), "{id} {params}"); + assert_eq!(s.execute("document.inspect", json!({})).unwrap(), before); + let action = Action::new("Invalid", vec![(id.into(), params)]); + assert_eq!(atn_cmds::export("Identifiers", &[&action], &[], false).is_err(), refuse_export); + } + let params = json!({"width":1e308,"units":"pt","resolution":144,"resample":"none"}); + s.execute("image.imageSize", params.clone()).unwrap(); + assert_eq!(s.active().unwrap().doc.size, photocraft_geom::Size::new(40, 20)); + assert_eq!(s.active().unwrap().doc.resolution_dpi, 144.0); + let action = Action::new("Resolution", vec![("image.imageSize".into(), params)]); + let d = saved(&action).descriptor.unwrap(); + assert!(d.get("Wdth").is_none() && d.get("Hght").is_none()); +} + fn class(id: &str) -> Class { Class { name: UnicodeString::new_nul(""), class_id: Id::new(id) } } From 947d4fd1b0cee0a10780b1de455c7cdee11a7820 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Sun, 1 Nov 2026 07:15:45 -0800 Subject: [PATCH 101/216] Revert "Preserve Image Size style scaling through ATN actions" This reverts commit de9c5e743ebf57a1625a123e824296521d7c7ae4. --- crates/engine/src/cmd/atn_cmds/steps_doc.rs | 10 +- crates/engine/src/cmd/atn_cmds/tests.rs | 17 +- .../tests/image_size_scale_styles_atn.rs | 149 ------------------ 3 files changed, 8 insertions(+), 168 deletions(-) delete mode 100644 crates/engine/tests/image_size_scale_styles_atn.rs diff --git a/crates/engine/src/cmd/atn_cmds/steps_doc.rs b/crates/engine/src/cmd/atn_cmds/steps_doc.rs index 6cfc87e79d..12852536ca 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_doc.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_doc.rs @@ -380,13 +380,6 @@ pub(super) fn image_size_from(d: &Descriptor) -> Mapped { if p.is_empty() { return Mapped::Unsupported { harmless: false }; } - // An omitted Photoshop flag preserves style sizes; the native command defaults to scaling. - let scale_styles = match d.get("scaleStyles") { - None => false, - Some(D::Boolean(value)) => *value, - Some(_) => return Mapped::Unsupported { harmless: false }, - }; - p.insert("scaleStyles".into(), scale_styles.into()); step("image.imageSize", Value::Object(p)) } @@ -425,8 +418,7 @@ pub(super) fn image_size_to(_: &str, p: &Value) -> Option { if let Some(i) = jstr(p, "resample").filter(|r| *r != "none") { d = d.with("Intr", enm("Intp", lookup_back(INTERPOLATION, i)?)); } - // Write the effective native default explicitly: omission in an ATN means false. - Some(d.with("scaleStyles", D::Boolean(jbool(p, "scaleStyles").unwrap_or(true)))) + Some(d) } pub(super) fn crop_from(d: &Descriptor) -> Mapped { diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index accd831d56..f9e633244c 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -527,7 +527,7 @@ fn every_mapped_command_exports_and_imports_back() { ("tools.setColors", json!({"background": "#ffffff"})), ("image.adjustments.desaturate", json!({})), ("image.autoTone", json!({})), - ("image.imageSize", json!({"width": 800, "height": 600, "resolution": 72, "resample": "bicubicSharper", "scaleStyles": true})), + ("image.imageSize", json!({"width": 800, "height": 600, "resolution": 72, "resample": "bicubicSharper"})), ("image.canvasSize", json!({"width": 100, "height": 50, "relative": true, "anchor": "topLeft", "extensionColor": "white"})), ("image.imageRotation.90cw", json!({})), ("image.rotation.arbitrary", json!({"angle": 12.5, "direction": "ccw"})), @@ -620,7 +620,7 @@ fn every_mapped_command_exports_and_imports_back() { ("layer.smartObjects.editContents", json!({})), ("layer.smartObjects.updateModifiedContent", json!({})), ("paint.bucket", json!({"x": 10, "y": 20, "tolerance": 32, "antiAlias": true, "contiguous": false, "opacity": 100})), - ("image.imageSize", json!({"percent": 200, "resample": "bicubicAutomatic", "scaleStyles": true})), + ("image.imageSize", json!({"percent": 200, "resample": "bicubicAutomatic"})), ("file.placeEmbedded", json!({"path": "/tmp/logo.png", "scale": 50})), ("file.placeLinked", json!({"path": "/tmp/logo.png"})), ("file.openAs", json!({"path": "/tmp/a.psd"})), @@ -660,7 +660,7 @@ fn every_mapped_command_exports_and_imports_back() { ("layer.newFillLayer.pattern", json!({"pattern": "Debaser-Distressed-Dark", "scale": 50, "angle": 0, "link": true})), ("layer.fill.pattern.set", json!({"pattern": "1 -R2.jpg"})), ("image.mode.bitmap", json!({"method": "threshold"})), - ("image.imageSize", json!({"resolution": 1200, "resample": "preserveDetails", "keepPrintSize": true, "scaleStyles": true})), + ("image.imageSize", json!({"resolution": 1200, "resample": "preserveDetails", "keepPrintSize": true})), ("layer.new.layerFromBackground", json!({"ifPresent": true, "select": false})), // COPYCAT, AJ_Actions2023 and the Line and Color tutorial set. ("layer.smartFilter.deleteFilterMask", json!({"ifPresent": true})), @@ -2854,8 +2854,8 @@ fn image_size_resolution_only_does_not_gain_keep_print_size() { #[test] fn image_size_intentional_controls_still_round_trip() { for params in [ - json!({"resolution": 144.0, "resample": "nearest", "keepPrintSize": true, "scaleStyles": true}), - json!({"width": 12.0, "height": 10.0, "resolution": 144.0, "resample": "nearest", "scaleStyles": true}), + json!({"resolution": 144.0, "resample": "nearest", "keepPrintSize": true}), + json!({"width": 12.0, "height": 10.0, "resolution": 144.0, "resample": "nearest"}), ] { assert_eq!(image_size_round_trip(¶ms), Some(params.clone())); } @@ -3012,15 +3012,12 @@ fn image_size_resample_none_keeps_ignoring_its_size_through_atn_export() { let mut p = sized.clone(); p["resolution"] = json!(144); p["resample"] = json!("none"); - assert_eq!(back(p.clone()), Some(json!({"resolution": 144, "resample": "none", "scaleStyles": true})), "{p}"); + assert_eq!(back(p.clone()), Some(json!({"resolution": 144, "resample": "none"})), "{p}"); } // A step with nothing left to record is refused rather than exported as an empty (unsupported) event. assert_eq!(back(json!({"width": 12, "height": 10, "resample": "none"})), None); // Real resizes are untouched. - assert_eq!( - back(json!({"width": 12, "height": 10, "resample": "bicubic"})), - Some(json!({"width": 12, "height": 10, "resample": "bicubic", "scaleStyles": true})) - ); + assert_eq!(back(json!({"width": 12, "height": 10, "resample": "bicubic"})), Some(json!({"width": 12, "height": 10, "resample": "bicubic"}))); } /// #8302, #8308: Hue/Saturation's hue is `H ` in Photoshop's `Hst2`, both ways. It was written diff --git a/crates/engine/tests/image_size_scale_styles_atn.rs b/crates/engine/tests/image_size_scale_styles_atn.rs deleted file mode 100644 index bdac446c76..0000000000 --- a/crates/engine/tests/image_size_scale_styles_atn.rs +++ /dev/null @@ -1,149 +0,0 @@ -//! #10694: Image Size style scaling survives ATN import, playback and saving. - -use photocraft_color::{BlendMode, Color}; -use photocraft_doc::{Effect, Effects, FxCommon, FxPaint, LayerId, StrokeFx, StrokePosition}; -use photocraft_engine::{Session, actions_cmds::Action, atn_cmds}; -use photocraft_psd::{ - atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}, - descriptor::{Descriptor, UnicodeString, Value as D}, -}; -use serde_json::{Value, json}; - -fn recorded(event: &str, descriptor: Option) -> Vec { - let event = if event == "ImgS" { AtnEvent::Code(*b"ImgS") } else { AtnEvent::name(event) }; - atn::write(&AtnFile { - name: UnicodeString::new_nul("Styles"), - expanded: false, - actions: vec![AtnAction { - name: UnicodeString::new_nul("Half"), - items: vec![AtnItem { event, dictionary: b"Image Size".to_vec(), descriptor, ..Default::default() }], - ..Default::default() - }], - warnings: vec![], - }) - .unwrap() -} - -fn half_size() -> Descriptor { - Descriptor::new("null").with("Wdth", D::UnitFloat { unit: *b"#Prc", value: 50.0 }) -} - -fn load(s: &mut Session, bytes: &[u8]) -> Value { - s.execute("actions.load", json!({"data": photocraft_engine::paint::tile::b64_encode(bytes)})).unwrap() -} - -fn save(s: &mut Session) -> Vec { - let saved = s.execute("actions.save", json!({"set": "Styles"})).unwrap(); - assert_eq!(saved["skipped"], json!([])); - photocraft_engine::paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap() -} - -fn fixture() -> (Session, LayerId) { - let mut s = Session::new(); - s.execute("file.new", json!({"width": 80, "height": 40, "background": "transparent"})).unwrap(); - let id = LayerId(s.execute("layer.new.layer", json!({"name": "Styled"})).unwrap()["layer"].as_u64().unwrap()); - s.edit("Style", |doc, _| { - doc.layer_mut(id).unwrap().effects = Effects { - enabled: true, - items: vec![Effect::Stroke(StrokeFx { - common: FxCommon::new(BlendMode::Normal, 1.0), - size: 20.0, - position: StrokePosition::Outside, - paint: FxPaint::Color(Color::rgb(0.0, 0.0, 1.0)), - overprint: false, - })], - ..Default::default() - }; - Ok(()) - }) - .unwrap(); - (s, id) -} - -fn assert_size_and_style(s: &Session, id: LayerId, scale: bool) { - let doc = &s.active().unwrap().doc; - assert_eq!((doc.size.width, doc.size.height), (40, 20)); - let Effect::Stroke(stroke) = &doc.layer(id).unwrap().effects.items[0] else { panic!("stroke lost") }; - assert_eq!(stroke.size, if scale { 10.0 } else { 20.0 }); -} - -#[test] -fn imported_style_scaling_survives_saved_bytes_and_playback() { - for event in ["ImgS", "imageSize"] { - for option in [None, Some(false), Some(true)] { - let mut descriptor = half_size(); - if let Some(value) = option { - descriptor = descriptor.with("scaleStyles", D::Boolean(value)); - } - let (mut s, id) = fixture(); - let loaded = load(&mut s, &recorded(event, Some(descriptor))); - assert_eq!(loaded["unsupported"], json!([])); - let scale = option.unwrap_or(false); - assert_eq!(s.actions.list[0].steps, vec![("image.imageSize".into(), json!({"percent": 50, "scaleStyles": scale}))]); - let saved = save(&mut s); - let parsed = atn::parse(&saved).unwrap(); - assert_eq!(parsed.actions[0].items[0].descriptor.as_ref().unwrap().get("scaleStyles"), Some(&D::Boolean(scale))); - let imported = atn_cmds::import(&saved).unwrap(); - assert!(imported.unsupported.is_empty()); - assert_eq!(imported.actions[0].steps, s.actions.list[0].steps); - s.actions.list = imported.actions; - let result = s.execute("actions.play", json!({"action": "Half"})).unwrap(); - assert_eq!(result["ran"], 1); - assert!(result.get("failed").is_none(), "{result}"); - assert_size_and_style(&s, id, scale); - } - } -} - -#[test] -fn native_default_is_written_explicitly_and_keeps_its_behavior() { - for option in [None, Some(json!(false)), Some(json!(true)), Some(Value::Null), Some(json!(1)), Some(json!("false"))] { - let mut params = json!({"percent": 50}); - if let Some(option) = option { - params["scaleStyles"] = option; - } - let scale = params["scaleStyles"].as_bool().unwrap_or(true); - let action = Action::new("Half", vec![("image.imageSize".into(), params.clone())]); - let (bytes, skipped) = atn_cmds::export("Styles", &[&action], &[], false).unwrap(); - assert!(skipped.is_empty()); - let parsed = atn::parse(&bytes).unwrap(); - assert_eq!(parsed.actions[0].items[0].descriptor.as_ref().unwrap().get("scaleStyles"), Some(&D::Boolean(scale)), "{params}"); - let (mut direct, direct_id) = fixture(); - direct.execute("image.imageSize", params).unwrap(); - assert_size_and_style(&direct, direct_id, scale); - let (mut replay, replay_id) = fixture(); - assert_eq!(load(&mut replay, &bytes)["unsupported"], json!([])); - assert_eq!(replay.actions.list[0].steps[0].1["scaleStyles"], json!(scale)); - let result = replay.execute("actions.play", json!({"action": "Half"})).unwrap(); - assert_eq!(result["ran"], 1); - assert!(result.get("failed").is_none(), "{result}"); - assert_size_and_style(&replay, replay_id, scale); - } -} - -#[test] -fn malformed_or_empty_steps_are_kept_without_becoming_resizes() { - for event in ["ImgS", "imageSize"] { - let descriptors = [ - None, - Some(Descriptor::new("null")), - Some(Descriptor::new("null").with("scaleStyles", D::Boolean(false))), - Some(Descriptor::new("null").with("scaleStyles", D::Boolean(true))), - Some(half_size().with("scaleStyles", D::Integer(0))), - Some(half_size().with("scaleStyles", D::Integer(1))), - Some(half_size().with("scaleStyles", D::Text(UnicodeString::new_nul("false")))), - ]; - for descriptor in descriptors { - let source = recorded(event, descriptor); - let original = atn::parse(&source).unwrap(); - let mut s = Session::new(); - let loaded = load(&mut s, &source); - assert_eq!(loaded["unsupported"].as_array().unwrap().len(), 1); - assert_eq!(s.actions.list[0].steps.len(), 1); - assert_eq!(s.actions.list[0].steps[0].0, atn_cmds::PHOTOSHOP_STEP); - let saved = atn::parse(&save(&mut s)).unwrap(); - assert_eq!(saved.actions[0].items[0].descriptor, original.actions[0].items[0].descriptor); - assert_eq!(saved.actions[0].items[0].event, original.actions[0].items[0].event); - } - } -} From 23f1b8bce76784f8d32f0117ecf5bf27e48341f2 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Sun, 1 Nov 2026 07:15:45 -0800 Subject: [PATCH 102/216] Revert "Add optional layer-style scaling to Image Size" This reverts commit d7bce26deb437546c221cb5afd983cba82190d21. --- crates/engine/src/cmd/image_cmds.rs | 14 +- .../engine/tests/image_size_scale_styles.rs | 124 ------------------ 2 files changed, 3 insertions(+), 135 deletions(-) delete mode 100644 crates/engine/tests/image_size_scale_styles.rs diff --git a/crates/engine/src/cmd/image_cmds.rs b/crates/engine/src/cmd/image_cmds.rs index cd18a7427a..c8c3e0b7d2 100644 --- a/crates/engine/src/cmd/image_cmds.rs +++ b/crates/engine/src/cmd/image_cmds.rs @@ -238,7 +238,6 @@ fn image_size(s: &mut Session, p: &Value) -> Result { check_resample_budget("image.imageSize", &d.doc, nw, nh, ", or turn Resample off to change only the resolution")?; } let dpi = p.get("resolution").and_then(Value::as_f64).map(|v| v as f32); - let scale_styles = p.get("scaleStyles").and_then(Value::as_bool).unwrap_or(true); if !matches!(resample, Method::None) && (nw, nh) != (ow, oh) { // Color Sampler points scale with the pixels (same map `transform_geometry` applies). let (sx, sy) = (f64::from(nw) / f64::from(ow.max(1)), f64::from(nh) / f64::from(oh.max(1))); @@ -255,7 +254,7 @@ fn image_size(s: &mut Session, p: &Value) -> Result { Method::Automatic if nw > ow || nh > oh => Resample::PreserveDetails, Method::Automatic => Resample::BicubicSharper, }; - resample_doc_with_styles(doc, nw, nh, filter, scale_styles); + resample_doc(doc, nw, nh, filter); Ok(()) })?; // The size the document has now: without resampling `width`/`height` are ignored (#1815). @@ -293,11 +292,6 @@ pub(crate) fn check_working_budget(cmd: &str, doc: &Document, nw: u32, nh: u32, /// `nw` x `nh` px with `filter`: the body of Image Size, shared by the Crop tool's W x H x Resolution /// mode. Callers check [`check_resample_budget`] first. pub(crate) fn resample_doc(doc: &mut Document, nw: u32, nh: u32, filter: Resample) { - resample_doc_with_styles(doc, nw, nh, filter, true); -} - -/// Resize document content while optionally retaining layer-effect sizes in pixels. -fn resample_doc_with_styles(doc: &mut Document, nw: u32, nh: u32, filter: Resample, scale_styles: bool) { let (ow, oh) = (doc.size.width, doc.size.height); let (sx, sy) = (nw as f64 / ow.max(1) as f64, nh as f64 / oh.max(1) as f64); if (sx - 1.0).abs() < 1e-12 && (sy - 1.0).abs() < 1e-12 { @@ -311,9 +305,7 @@ fn resample_doc_with_styles(doc: &mut Document, nw: u32, nh: u32, filter: Resamp }); let k = ((sx + sy) / 2.0) as f32; for_each_layer(&mut doc.layers, &mut |l| { - if scale_styles { - scale_effects(&mut l.effects, k); - } + scale_effects(&mut l.effects, k); // Canvas resampling scales the whole appearance, including shape strokes (#8341). // Dash lengths and offsets are already expressed in multiples of this width. if let LayerContent::Shape(sh) = &mut l.content @@ -991,7 +983,7 @@ pub fn specs() -> Vec { "image.imageSize", "Image Size…", ["Image"], - r##"{"width":px,"height":px,"percent":%? (both sides; an explicit side wins),"resolution":ppi,"keepPrintSize":bool=false (with only a resolution: resample the pixels to keep the print size),"scaleStyles":bool=true (scale layer effects with the pixels),"resample":"automatic|preserveDetails|bicubicSmoother|bicubicSharper|bicubicAutomatic|bicubic|nearest|bilinear|lanczos|none"="bicubic"} → {width,height} (the document's size after the command; "none" changes the resolution only)"##, + r##"{"width":px,"height":px,"percent":%? (both sides; an explicit side wins),"resolution":ppi,"keepPrintSize":bool=false (with only a resolution: resample the pixels to keep the print size),"resample":"automatic|preserveDetails|bicubicSmoother|bicubicSharper|bicubicAutomatic|bicubic|nearest|bilinear|lanczos|none"="bicubic"} → {width,height} (the document's size after the command; "none" changes the resolution only)"##, has_doc, image_size ), diff --git a/crates/engine/tests/image_size_scale_styles.rs b/crates/engine/tests/image_size_scale_styles.rs deleted file mode 100644 index 4ae6cbabd2..0000000000 --- a/crates/engine/tests/image_size_scale_styles.rs +++ /dev/null @@ -1,124 +0,0 @@ -//! Image Size can preserve layer-style sizes while resampling the document's geometry. - -use std::sync::Arc; - -use photocraft_color::{BlendMode, Color}; -use photocraft_doc::{Effect, Effects, FxCommon, FxPaint, Layer, LayerContent, LayerId, StrokeFx, StrokePosition}; -use photocraft_engine::Session; -use serde_json::json; - -fn styled_shape(depth: u32) -> (Session, LayerId) { - let mut s = Session::new(); - s.execute("file.new", json!({"width": 80, "height": 40, "depth": depth, "background": "transparent"})).unwrap(); - let id = s.execute("shape.create", json!({"rect": [20, 10, 40, 20], "fill": "#ff0000", "stroke": {"width": 4, "color": "#000000"}})).unwrap()["layer"] - .as_u64() - .unwrap(); - let id = LayerId(id); - s.edit("Styles", |doc, _| { - let effects = Effects { - enabled: true, - items: vec![Effect::Stroke(StrokeFx { - common: FxCommon::new(BlendMode::Normal, 1.0), - size: 20.0, - position: StrokePosition::Outside, - paint: FxPaint::Color(Color::rgb(0.0, 0.0, 1.0)), - overprint: false, - })], - psd_raw: Some(Arc::new(b"preserved layer style".to_vec())), - ..Default::default() - }; - doc.layer_mut(id).unwrap().effects = effects.clone(); - let children = std::mem::take(&mut doc.layers); - let mut outer = Layer::group("Outer", vec![Layer::group("Inner", children)]); - outer.effects = effects; - doc.layers.push(outer); - Ok(()) - }) - .unwrap(); - (s, id) -} - -fn shape_stroke(s: &Session, id: LayerId) -> f32 { - let LayerContent::Shape(shape) = &s.active().unwrap().doc.layer(id).unwrap().content else { panic!("shape lost") }; - shape.stroke.as_ref().unwrap().width -} - -/// Native omission keeps the old default; false preserves style sizes and raw PSD effects. -#[test] -fn image_size_scale_styles_is_optional_at_every_depth() { - for depth in [8, 16, 32] { - for option in [None, Some(true), Some(false)] { - let (mut s, id) = styled_shape(depth); - let before = s.active().unwrap().doc.clone(); - let group = before.layers[0].id; - let past = s.active().unwrap().history.past_len(); - let mut params = json!({"percent": 50, "resample": "nearest"}); - if let Some(value) = option { - params["scaleStyles"] = json!(value); - } - s.execute("image.imageSize", params).unwrap(); - let after = s.active().unwrap().doc.clone(); - assert_eq!((after.size.width, after.size.height), (40, 20)); - assert_eq!(shape_stroke(&s, id), 2.0, "{depth}/{option:?}: geometric strokes always scale"); - assert_eq!(s.active().unwrap().history.past_len(), past + 1); - for layer in [group, id] { - let effects = &after.layer(layer).unwrap().effects; - let Effect::Stroke(stroke) = &effects.items[0] else { panic!("style lost") }; - assert_eq!(stroke.size, if option == Some(false) { 20.0 } else { 10.0 }, "{depth}/{option:?}: layer style stroke"); - if option == Some(false) { - assert_eq!(effects, &before.layer(layer).unwrap().effects, "{depth}: preserve every style setting and raw block"); - } else { - assert!(effects.psd_raw.is_none(), "scaled effects invalidate their old PSD block"); - } - } - s.execute("edit.undo", json!({})).unwrap(); - assert_eq!(s.active().unwrap().doc.size, before.size); - assert_eq!(shape_stroke(&s, id), 4.0); - for layer in [group, id] { - assert_eq!(s.active().unwrap().doc.layer(layer).unwrap().effects, before.layer(layer).unwrap().effects); - } - s.execute("edit.redo", json!({})).unwrap(); - assert_eq!(s.active().unwrap().doc.size, after.size); - assert_eq!(shape_stroke(&s, id), 2.0); - for layer in [group, id] { - assert_eq!(s.active().unwrap().doc.layer(layer).unwrap().effects, after.layer(layer).unwrap().effects); - } - } - } -} - -/// Resolution-only and unchanged-size commands leave styles intact even when scaling is enabled. -#[test] -fn image_size_without_pixel_resampling_preserves_styles() { - for mut params in [json!({"percent": 50, "resolution": 144, "resample": "none"}), json!({"percent": 100, "resample": "nearest"})] { - for option in [false, true] { - let (mut s, id) = styled_shape(8); - let before = s.active().unwrap().doc.clone(); - params["scaleStyles"] = json!(option); - s.execute("image.imageSize", params.clone()).unwrap(); - let after = &s.active().unwrap().doc; - assert_eq!(after.size, before.size); - assert_eq!(shape_stroke(&s, id), 4.0); - for layer in [before.layers[0].id, id] { - assert_eq!(after.layer(layer).unwrap().effects, before.layer(layer).unwrap().effects); - } - if params["resample"] == "none" { - assert_eq!(after.resolution_dpi, 144.0); - } - } - } -} - -/// Crop's shared resampling path retains its existing style-scaling default. -#[test] -fn crop_target_keeps_scaling_styles() { - let (mut s, id) = styled_shape(8); - let group = s.active().unwrap().doc.layers[0].id; - s.execute("image.crop", json!({"x": 0, "y": 0, "width": 80, "height": 40, "targetWidth": 40, "targetHeight": 20, "resample": "nearest"})).unwrap(); - assert_eq!(shape_stroke(&s, id), 2.0); - for layer in [group, id] { - let effects = &s.active().unwrap().doc.layer(layer).unwrap().effects; - let Effect::Stroke(stroke) = &effects.items[0] else { panic!("style lost") }; - assert_eq!(stroke.size, 10.0); - } -} From 5020854dde3f283fbe10c12cf57e6a6542dd4d08 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 1 Nov 2026 16:31:26 +0100 Subject: [PATCH 103/216] docs(atn): measure Shear, gradients, workspaces, Print, Droplet, Sky Replacement, layer comps Measured in the reference application through its dialogs and recorded actions: Shear point ranges and what ShrS/ShrE mean, the classic and the new Gradient tool steps, Content-Aware Fill, Perspective Warp, Print, Create Droplet and Sky Replacement descriptors, the mmdd/ddmm Batch date components, and every layer comp reference form. Export As records no step; Convert Stroke to Fill was not available in the measured version. Co-Authored-By: Claude Opus 5.5 --- docs/atn.md | 48 ++++++++++++++++++++++++++++++++++++------------ 1 file changed, 36 insertions(+), 12 deletions(-) diff --git a/docs/atn.md b/docs/atn.md index 53e22428e7..8acc4e1f2a 100644 --- a/docs/atn.md +++ b/docs/atn.md @@ -243,6 +243,24 @@ index inside a group puts the layer into that group. PhotoCraft's `document.activate {"offset"}` refuses to wrap (#7103); the reference application wraps. +### Layer comps + +Measured with three comps (CompA, CompB, CompC) that each show a different layer, applied with +`applyComp` and every reference form. The document's `compsList` gives each comp's `title`, `itemIndex` +(1-based), `ID` (long) and `selection`. + +| Form | Result | +|---|---| +| By name (`name`) | Applies that comp. An unknown name fails. | +| By index (`indx`) | 1-based, in panel order. 0, negative and past-the-end indexes are **accepted and change nothing**. | +| By ID (`Idnt`) | Applies the comp with that `ID`. An unknown ID fails. | +| By offset (`rele`) | Always fails, for every offset. | +| Ordinal (`Ordn`) `Trgt`, `Nxt `, `Prvs` | Applies the current (last applied) comp; `Nxt ` and `Prvs` do not move. | +| Ordinal `Frst`, `Lst `, `Al ` | Applies the **first** comp (also for `Lst `). | + +Delete (`Dlt `) by index uses the same 1-based numbering. PhotoCraft's `applyComp` import reads only the +name form today; the index, ID and ordinal forms load as kept steps. + ## Newer events (string ID only) The events the reference application records for the menu items PhotoCraft has that are neither mapped @@ -318,9 +336,19 @@ the recorded steps back from the saved Actions panel. Ranges come from the dialo | Canvas Size | only the changed side; `canvasExtensionColorType` `FrgC` for the foreground colour | | | Convert Mode, several layers, "Don't Flatten" | `T ` as a **class** (`Grys`), `Fltt` false, `Rstr` false | With `Fltt` and `Rstr` the step plays without the flatten question. | | New Group with a colour | `Usng` (`Clr ` label), `layerSectionStart`, `layerSectionEnd`, top-level `Nm ` | | +| Shear (`Shr `) | `ShrP` list of `Pnt ` (`Hrzn`, `Vrtc` doubles), `UndA` (`WrpA` wrap around, `RptE` repeat edge pixels), `ShrS` long, `ShrE` long | The dialog writes whole numbers: `Hrzn` -64..63 (dragging past the grid clamps), `Vrtc` 1..128 with the first point at 1 and the last at 128; dragging a point out of the grid deletes it. `ShrS` is 0 and `ShrE` the last point's index (count - 1). Script runs take any value unclamped and crashed on far-off `ShrE`/`ShrS` or a first `Vrtc` below 1. | +| Gradient tool, classic (`Grdn` / `gradientClassEvent`) | `From`, `T ` points in pixels; `Opct` percent, `Md ` blend mode, `Type` (`GrdT`: `Lnr `, `Rdl `, `Angl`, `Rflc`, `Dmnd`), `Rvrs` bool, `Dthr` bool, `UsMs` bool (transparency), `gradientsInterpolationMethod` (`Perc` perceptual, …), `Grad` gradient object (as in Gradient Map) | Recorded in that order. `Opct`, `Md ` and `Rvrs` appear only when they differ from 100 %, Normal and off; `Dthr` and `UsMs` only when on. The five types are the only accepted values. A step without `Grad` fails. | +| Gradient tool, default mode | two steps: Make (`null` = class `contentLayer`, `Usng` = `Type` gradient layer object) then Set (`null` = `contentLayer` target, `T ` = the same object plus `Algn` false) | The object: `Dthr`, `gradientsInterpolationMethod`, `Angl` angle, `Type`, `Scl ` percent, `Ofst` (`Hrzn`, `Vrtc` percent), `Grad`. The new tool makes a gradient fill layer; Make holds the first click, Set the finished drag. | +| Content-Aware Fill (`cafWorkspace`) | `cafSamplingRegion` (`cafSamplingRegionRectangular`, …), `cafSampleAllLayers` bool, `cafColorAdaptationLevel` (`cafColorAdaptationDefault`, …), `cafRotationAmount` (`cafRotationAmountNone`, …), `cafScale` bool, `cafMirror` bool, `cafOutput` (`cafOutputToNewLayer`, …) | Runs on the selection, which is recorded as its own step before it. | +| Perspective Warp (`perspectiveWarpTransform`) | `null` = layer target, `Md ` (`perspectiveWarpMode`: `warp`), `referenceRect` rectangle in pixels, `vertices` list of points in pixels (the layout quad), `warpedVertices` (the same points after warping), `quads` list of `indices` (4 vertex indexes each) | Layout and warp are one step. | +| Print (`print`) then Print One Copy (`printOneCopy`) | `print`: `null` = reference `printOutput` property of the target document; `printOutput` (`PstS`, `Inte`, `printSixteenBit`, `printerName` text, `printProofSetup`), `printViewProofSetup`, `printOutputOptions` (caption, marks, `Bckg` colour, `BrdT`, `Bld `, `Rslt`, `vectorData`, `PgPs`, `Left`, `Top `, `Scl `, crop rectangle), `osSpecificPrintInfo` raw data, `Cptn` text. `printOneCopy` has no keys. | Pressing Print in the dialog records both steps. | +| Create Droplet (`CrtD`) | `In ` alias: the droplet file; `Fldr` bool (include subfolders); `Usng` reference to the action; then the Batch keys below (destination, file naming, error log) when set | The same settings as Batch, with the droplet file instead of a source folder. | +| Sky Replacement (`skyReplacement`) | `shiftEdge` double, `borderSmoothness` long (fade edge, default 50), `Brgh` long, `temperature` long, `harmonizationOpacity` long (35), `foregroundLightingOpacity` long, `lightingMode` blend mode (`Mltp`), `edgeLightingOpacity` long (70), `skyReplacementOutput` (`skyReplacementOutputToNewSheets` new layers, …), `Idnt` text (the sky preset's id), `Nm ` text (its name), `File` alias (the sky image) | Scale and Flip are **not** in the step: with 150 % and Flip on, the returned keys were the same. `foregroundLightingOpacity` is computed from the sky (44 and 29 for two skies). Without a chosen sky the dialog returns no keys. | **Not recordable:** Edit > Purge (no step is recorded; this version's Purge menu has Clipboard, -Histories, All and Video Cache, no Undo). +Histories, All and Video Cache, no Undo); File > Export > Export As (the file is written, no step is +recorded). Convert Stroke to Fill (`vectorStrokeToFill`) was not available in the measured version, even +on a shape layer with a stroke and its path component selected, and it is not in the path context menu. ### Batch @@ -347,12 +375,12 @@ descriptor itself (`Btch`, as the dialog returns it): | `Nm ` / `lowerCase` / `upperCase` | Document Name / document name / DOCUMENT NAME | | `oneDigit` `twoDigit` `threeDigit` `fourDigit` | 1, 2, 3, 4 digit serial number | | `lowerCaseSerial` / `upperCaseSerial` | serial letter (a, b, c…) / (A, B, C…) | -| `mmddyy` `yyyymmdd` `yymmdd` `yyddmm` `ddmmyy` | dates | +| `mmddyy` `mmdd` `yyyymmdd` `yymmdd` `yyddmm` `ddmmyy` `ddmm` | dates | | `lowerCaseExtension` / `upperCaseExtension` | extension / EXTENSION | The extension component has no dot: Document Name + extension + 3 digit serial + "_pc", starting at 5, -named `other.png` → `otherpng005_pc` (the saved file then gets its own extension). The two remaining date -entries (mmdd, ddmm) did not accept `mmdd`/`ddmm`; their values are still unknown. +named `other.png` → `otherpng005_pc` (the saved file then gets its own extension). The two short date +entries are `mmdd` and `ddmm` (on 1 November they give `1101` and `0111`). ### Actions panel menu @@ -1070,10 +1098,10 @@ All commands exist in PhotoCraft (`filter.distort.*`); only the `.atn` mapping i | Key | String ID | Type | Unit | Default | Allowed | |---|---|---|---|---|---| -| `ShrP` | shearPoints | list | | list[2] | list of `Pnt ` points (`Hrzn`, `Vrtc`); default two points (0, 1) and (0, 128); limits pending | +| `ShrP` | shearPoints | list | | list[2] | list of `Pnt ` points (`Hrzn`, `Vrtc`) from top to bottom; default two points (0, 1) and (0, 128). The dialog writes whole numbers: `Hrzn` -64..63 (dragging past the grid edge clamps), `Vrtc` 1..128 with the first point at 1 and the last at 128; dragging a point out of the grid deletes it. A script run accepts any value without clamping (e.g. `Hrzn` ±10000, fractions) and crashed on a first point with `Vrtc` < 1: clamp to the dialog ranges on import | | `UndA` | undefinedArea | enumerated | | `WrpA` wrapAround | `RptE` repeatEdgePixels, `WrpA` wrapAround | -| `ShrS` | shearStart | integer | | 0 | limits pending, see "Events that need a step recorded through their dialog" | -| `ShrE` | shearEnd | integer | | 1 | limits pending, see "Events that need a step recorded through their dialog" | +| `ShrS` | shearStart | integer | | 0 | index of the first curve point; the dialog always writes 0 | +| `ShrE` | shearEnd | integer | | 1 | index of the last curve point (point count - 1); the dialog writes it. Script runs with other values change the result, and far-off values (`ShrE` 100 or 1000, `ShrS` -1000) crashed the application: on import accept only `ShrS` 0 and `ShrE` = point count - 1, otherwise keep the step as a kept step | Nested values as recorded (defaults): ``` @@ -1667,12 +1695,8 @@ their tables will be added once a recorded step was read. | Event | Why | |---|---| -| Shear | Probing the point list made the application fail; the limits come from the dialog. | | Trace Contour | The step's level and edge are applied, but the returned descriptor always shows the defaults (level 128, upper). Lower (`Lwr `) is valid; levels at or below 0 and at or above 255 give the same image. | -| Gradient tool | The drag needs a full gradient object (see Gradient Map in group E for its form). | -| Create Droplet, Print | File and printer settings come from their dialogs. (Batch is measured: see Measured through dialogs and recordings.) | -| Perspective Warp, Content-Aware Fill workspace, Export As, Quick Export | Interactive workspaces; their recorded steps were not read yet. | -| Sky Replacement, Convert Stroke to Fill | Need a sky photo and a selected path component. (The smart object commands, Rasterize All and Convert Type to Shape are measured: see Newer events.) | +| Quick Export | Not read yet; Export As records no step (see Measured through dialogs and recordings). | | Generative Fill (`syntheticFill`) | AI feature: in PhotoCraft this goes through the shared AI layer; until then such steps stay kept steps. | ### Recorded under another event From f64e3b39f2a2d1d5062888160f0ee6e52abe1fdb Mon Sep 17 00:00:00 2001 From: yoriswind Date: Sun, 1 Nov 2026 07:38:01 -0800 Subject: [PATCH 104/216] Update Image Size import expectation for explicit style scaling --- crates/engine/src/cmd/atn_cmds/tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index e606d24b06..89283ac3c7 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -1491,7 +1491,7 @@ fn remaining_events_import_in_their_photoshop_shapes() { assert_eq!(more_steps[4].1, json!({"foreground": "#000000"})); assert!(more.unsupported.is_empty()); assert_eq!(rest_steps[0].1, json!({"if": "hasLayers", "then": "FS8-Layers", "else": "FS8-No-Layers"})); - assert_eq!(rest_steps[1].1, json!({"percent": 200, "resample": "bicubicAutomatic"})); + assert_eq!(rest_steps[1].1, json!({"percent": 200, "resample": "bicubicAutomatic", "scaleStyles": true})); assert_eq!(rest_steps[2].1, json!({"x": 0, "y": 0, "tolerance": 32, "contiguous": false})); assert_eq!(rest_steps[4].1, json!({"path": "/Users/me/logo.png", "scale": 50})); assert_eq!(rest_steps[5].1, json!({"path": "/Users/me/in.psd"})); From ab0b26a9c6938fe338bb6829711f65444895dae6 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Sun, 1 Nov 2026 07:46:48 -0800 Subject: [PATCH 105/216] Wire profile action options and dither 8-bit conversions --- crates/engine/src/cmd/atn_cmds/steps_doc.rs | 28 ++- crates/engine/src/cmd/atn_cmds/tests.rs | 5 +- crates/engine/src/cmd/color_cmds.rs | 7 +- .../src/cmd/color_cmds/profile_dither.rs | 177 +++++++++++++++ crates/engine/src/cmd/image_cmds.rs | 2 +- crates/engine/tests/convert_profile_atn.rs | 209 +++++++++++++++++ crates/engine/tests/convert_profile_dither.rs | 211 ++++++++++++++++++ 7 files changed, 634 insertions(+), 5 deletions(-) create mode 100644 crates/engine/src/cmd/color_cmds/profile_dither.rs create mode 100644 crates/engine/tests/convert_profile_atn.rs create mode 100644 crates/engine/tests/convert_profile_dither.rs diff --git a/crates/engine/src/cmd/atn_cmds/steps_doc.rs b/crates/engine/src/cmd/atn_cmds/steps_doc.rs index 12852536ca..f8300cb32e 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_doc.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_doc.rs @@ -528,11 +528,24 @@ pub(super) fn reset_to(_: &str, _: &Value) -> Option { Some(desc("null").with("null", D::Reference(vec![ReferenceItem::Property { class: class("Clr "), key: Id::new("Clrs") }]))) } +/// Import represented RGB targets and strict profile flags; keep other destination modes intact. pub(super) fn profile_from(d: &Descriptor, id: &str) -> Mapped { let name = get_text(d, "T ").or_else(|| get_text(d, "profile")); let Some(profile) = name.as_deref().and_then(|n| lookup(PROFILES, n)) else { return Mapped::Unsupported { harmless: false } }; let mut p = json!({"profile": profile}); if id == "edit.convertToProfile" { + // All mapped PROFILES are RGB. Other radio values need their own target mapping. + if !matches!(d.get("sdwM"), None | Some(D::Integer(2))) { + return Mapped::Unsupported { harmless: false }; + } + for (key, param) in [("Dthr", "dither"), ("Fltt", "flatten"), ("rasterizePlaced", "rasterizePlaced")] { + let value = match d.get(key) { + None => false, + Some(D::Boolean(value)) => *value, + Some(_) => return Mapped::Unsupported { harmless: false }, + }; + p[param] = json!(value); + } if let Some(i) = get_enum(d, "Inte").and_then(|i| lookup(INTENT, &i)) { p["intent"] = json!(i); } @@ -543,14 +556,27 @@ pub(super) fn profile_from(d: &Descriptor, id: &str) -> Mapped { step(id, p) } +/// Export effective native flags explicitly, deriving the RGB radio value from the mapped target. pub(super) fn profile_to(id: &str, p: &Value) -> Option { let name = lookup_back(PROFILES, jstr(p, "profile")?)?; let mut d = desc("null").with("null", target_ref("Dcmn")); if id == "edit.convertToProfile" { + if p.get("sdwM").is_some() { + return None; + } d = d .with("T ", text(name)) .with("Inte", enm("Inte", lookup_back(INTENT, jstr(p, "intent").unwrap_or("relative"))?)) - .with("MpBl", D::Boolean(jbool(p, "bpc").unwrap_or(true))); + .with("MpBl", D::Boolean(jbool(p, "bpc").unwrap_or(true))) + .with("sdwM", D::Integer(2)); + for (key, param) in [("Dthr", "dither"), ("Fltt", "flatten"), ("rasterizePlaced", "rasterizePlaced")] { + let value = match p.get(param) { + None | Some(Value::Null) => false, + Some(Value::Bool(value)) => *value, + Some(_) => return None, + }; + d = d.with(key, D::Boolean(value)); + } } else { d = d.with("profile", text(name)); } diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index f9e633244c..b9a50c9b99 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -590,7 +590,10 @@ fn every_mapped_command_exports_and_imports_back() { ("edit.paste", json!({})), ("edit.purge.histories", json!({})), ("tools.defaultColors", json!({})), - ("edit.convertToProfile", json!({"profile": "display-p3", "intent": "perceptual", "bpc": false})), + ( + "edit.convertToProfile", + json!({"profile": "display-p3", "intent": "perceptual", "bpc": false, "dither": false, "flatten": false, "rasterizePlaced": false}), + ), ("edit.assignProfile", json!({"profile": "srgb"})), ("file.close", json!({})), ("file.save", json!({})), diff --git a/crates/engine/src/cmd/color_cmds.rs b/crates/engine/src/cmd/color_cmds.rs index a5469751bb..7b766ed084 100644 --- a/crates/engine/src/cmd/color_cmds.rs +++ b/crates/engine/src/cmd/color_cmds.rs @@ -23,6 +23,8 @@ use serde_json::{Value, json}; use crate::commands::CommandSpec; use crate::{EngineError, Result, Session}; +mod profile_dither; + // ------------------------------------------------------------------ state /// View › Proof Setup settings. @@ -1033,6 +1035,7 @@ fn convert_to_profile(s: &mut Session, p: &Value) -> Result { let bpc = bool_param(p, "bpc", s.color.settings.bpc); let flatten = profile_option(p, "flatten")?; let rasterize = profile_option(p, "rasterizePlaced")?; + let dither = profile_option(p, "dither")?; s.edit("Convert to Profile", |doc, active| { if flatten { // Flatten consumes the displayed image; discarded hidden objects need no source read. @@ -1040,7 +1043,7 @@ fn convert_to_profile(s: &mut Session, p: &Value) -> Result { } else if rasterize { prepare_profile_smart_layers(doc, true)?; } - convert_document(doc, &dst, intent, bpc) + if dither { profile_dither::convert(doc, &dst, intent, bpc) } else { convert_document(doc, &dst, intent, bpc) } })?; let d = &s.active().ok_or(EngineError::NoDocument)?.doc; Ok(json!({ "profile": dst.description, "mode": format!("{:?}", d.mode), "intent": intent.id(), "bpc": bpc })) @@ -1250,7 +1253,7 @@ pub fn specs() -> Vec { "edit.convertToProfile", "Convert to Profile…", ["Edit"], - r##"{"profile":"srgb|display-p3|adobe-rgb-compat|prophoto-compat|linear-srgb|rec2020|gray-gamma-2.2|sgray|lab-d50|coated-cmyk|working" (or a path to an .icc file),"intent":"perceptual|relative|saturation|absolute"="relative","bpc":bool=true,"flatten":bool=false (flatten in the source profile before conversion),"rasterizePlaced":bool=false (freeze displayed Smart Object pixels; flatten takes precedence)}"##, + r##"{"profile":"srgb|display-p3|adobe-rgb-compat|prophoto-compat|linear-srgb|rec2020|gray-gamma-2.2|sgray|lab-d50|coated-cmyk|working" (or a path to an .icc file),"intent":"perceptual|relative|saturation|absolute"="relative","bpc":bool=true,"dither":bool=false (deterministic color quantization for 8-bit profile changes),"flatten":bool=false (flatten in the source profile before conversion),"rasterizePlaced":bool=false (freeze displayed Smart Object pixels; flatten takes precedence)}"##, has_doc, convert_to_profile, true diff --git a/crates/engine/src/cmd/color_cmds/profile_dither.rs b/crates/engine/src/cmd/color_cmds/profile_dither.rs new file mode 100644 index 0000000000..eff335a10b --- /dev/null +++ b/crates/engine/src/cmd/color_cmds/profile_dither.rs @@ -0,0 +1,177 @@ +//! Opt-in 8-bit profile quantization. The ordinary CMS buffer paths stay unchanged. + +use photocraft_geom::{Rect, TILE_SIZE, TileCoord}; + +use super::{ + ColorMode, Document, EngineError, Intent, PixelFormat, Profile, Result, SampleType, Surface, Transform, cms_err, convert_color, convert_document, + convert_layer_colors, convert_surface, document_profile, space_mode, +}; + +const TILE_PIXELS: u128 = (TILE_SIZE * TILE_SIZE) as u128; +// Two per-pixel arrays and the decoded source default; no tile or plane staging. +const SCRATCH_BYTES: u128 = (3 * 16 * std::mem::size_of::()) as u128; + +fn bad(message: &str) -> EngineError { + EngineError::BadParams { cmd: "edit.convertToProfile".into(), msg: message.into() } +} + +/// Dither only layouts the CMS can transform directly, copying alpha without conversion. +fn eligible(s: &Surface, from: ColorMode, to: PixelFormat, t: &Transform) -> bool { + let sf = s.format(); + sf.sample == SampleType::U8 + && sf.mode == from + && t.inputs() == sf.mode.color_channels() + && t.outputs() == to.mode.color_channels() + && sf.alpha == to.alpha + && sf.channels() <= 16 + && to.channels() <= 16 +} + +fn in_canvas_tiles(c: TileCoord, bounds: Rect) -> bool { + if bounds.is_empty() { + return false; + } + let a = TileCoord::containing(bounds.x0, bounds.y0); + let b = TileCoord::containing(bounds.x1 - 1, bounds.y1 - 1); + c.tx >= a.tx && c.tx <= b.tx && c.ty >= a.ty && c.ty <= b.ty +} + +/// Noise needs real pixel coordinates, not the saturating rectangle of a malformed tile. +fn tile_origin(c: TileCoord) -> Result<(i32, i32)> { + let x = i64::from(c.tx) * i64::from(TILE_SIZE); + let y = i64::from(c.ty) * i64::from(TILE_SIZE); + if x < i64::from(i32::MIN) + || y < i64::from(i32::MIN) + || x + i64::from(TILE_SIZE - 1) > i64::from(i32::MAX) + || y + i64::from(TILE_SIZE - 1) > i64::from(i32::MAX) + { + return Err(bad("a stored tile has unaddressable coordinates; disable dithering to use the ordinary conversion")); + } + Ok((x as i32, y as i32)) +} + +/// Price every additional output tile before allocating any; distant tiles add no bounding box. +fn output_bytes(s: &Surface, to: PixelFormat, bounds: Rect) -> Result { + let mut count = u128::from(bounds.tile_count()); + for (c, _) in s.tiles() { + tile_origin(*c)?; + if !in_canvas_tiles(*c, bounds) { + count = count.checked_add(1).ok_or_else(|| bad("dither output size overflowed"))?; + } + } + count + .checked_mul(TILE_PIXELS) + .and_then(|n| n.checked_mul(to.bytes_per_pixel() as u128)) + .and_then(|n| n.checked_add(to.bytes_per_pixel() as u128)) + .ok_or_else(|| bad("dither output size overflowed")) +} + +/// Stochastic rounding before quantization preserves integer samples and neutral channels. +fn quantize(value: f32, threshold: f64) -> u8 { + if !value.is_finite() { + // Match the ordinary converter's saturating float cast, including NaN -> zero. + return (value.clamp(0.0, 1.0) * 255.0 + 0.5) as u8; + } + let scaled = f64::from(value.clamp(0.0, 1.0) * 255.0); + let lower = scaled.floor(); + (lower + f64::from(u8::from(scaled - lower > threshold))) as u8 +} + +/// Stream the finite canvas and stored deviations, retaining the undithered infinite default. +fn surface(s: &Surface, to: PixelFormat, t: &Transform, bounds: Rect) -> Result { + let (ss, ds) = (s.channels(), to.channels()); + let (ni, no) = (t.inputs(), t.outputs()); + let mut converted_default = [0.0; 16]; + t.convert_f32(&s.default_pixel(), ss, &mut converted_default[..ds], ds, true); + let mut out = Surface::with_default(to, &converted_default[..ds]); + let default = s.default_bytes(); + let outside = s.tiles().map(|(c, _)| *c).filter(|c| !in_canvas_tiles(*c, bounds)); + for c in bounds.tiles().chain(outside) { + let (x0, y0) = tile_origin(c)?; + let source = s.tile(c).map(|tile| tile.bytes()); + let dest = out.try_tile_mut(c).map_err(|e| EngineError::Other(format!("could not allocate dither output: {e}")))?; + for (index, dp) in dest.bytes_mut().chunks_exact_mut(ds).enumerate() { + let x = x0 + (index % TILE_SIZE as usize) as i32; + let y = y0 + (index / TILE_SIZE as usize) as i32; + let sp = match source { + Some(bytes) => bytes.get(index * ss..index * ss + ss).ok_or_else(|| bad("a stored tile has invalid sample storage"))?, + None => default, + }; + // A redundant default tile outside the canvas must match the implicit plane. + if !bounds.contains(x, y) && sp == default { + continue; + } + let mut input = [0.0; 16]; + let mut output = [0.0; 16]; + for (d, s) in input.iter_mut().zip(sp).take(ni) { + *d = f32::from(*s) / 255.0; + } + t.eval_fast(&input[..ni], &mut output[..no]); + // The hash can return 1.0; recenter its 65536 bins strictly inside (0, 1). + let threshold = (f64::from(photocraft_color::dither_noise(x, y)) * 65535.0 + 0.5) / 65536.0; + for (d, value) in dp.iter_mut().zip(output).take(no) { + *d = quantize(value, threshold); + } + for (d, s) in dp.iter_mut().skip(no).zip(sp.iter().skip(ni)) { + *d = *s; + } + } + } + Ok(out) +} + +/// Preserve ordinary conversion outside opt-in U8, admitting the whole dither edit first. +pub(super) fn convert(doc: &mut Document, dst: &Profile, intent: Intent, bpc: bool) -> Result<()> { + let src = document_profile(doc); + if doc.depth != SampleType::U8 || src.to_bytes() == dst.to_bytes() || doc.mode == ColorMode::Duotone { + return convert_document(doc, dst, intent, bpc); + } + let to_mode = space_mode(dst.color_space).ok_or_else(|| EngineError::Other(format!("cannot convert to a {:?} profile", dst.color_space)))?; + let from = doc.pixel_format().mode; + let t = Transform::new(&src, dst, intent, bpc).map_err(cms_err)?; + let format = PixelFormat::new(to_mode, doc.depth, true); + let bounds = doc.bounds(); + if doc.size.width > i32::MAX as u32 || doc.size.height > i32::MAX as u32 { + return Err(bad("the canvas has unaddressable coordinates; disable dithering to use the ordinary conversion")); + } + let mut bytes = SCRATCH_BYTES; + let mut admission = Ok(()); + crate::image_cmds::for_each_surface(&mut doc.layers, false, &mut |s, _| { + if admission.is_err() { + return; + } + let to = PixelFormat { alpha: s.format().alpha, ..format }; + if eligible(s, from, to, &t) { + admission = output_bytes(s, to, bounds).and_then(|additional| { + bytes = bytes.checked_add(additional).ok_or_else(|| bad("dither output size overflowed"))?; + Ok(()) + }); + } + }); + admission?; + // This is an output-raster cap, not process peak: the source and history remain retained. + if bytes > u128::from(crate::image_cmds::MAX_RESAMPLE_BYTES) { + return Err(bad("dither output exceeds the raster allocation budget; use a smaller canvas or disable dithering")); + } + let mut result = Ok(()); + crate::image_cmds::for_each_surface(&mut doc.layers, false, &mut |s, _| { + if result.is_err() { + return; + } + let to = PixelFormat { alpha: s.format().alpha, ..format }; + if eligible(s, from, to, &t) { + result = surface(s, to, &t, bounds).map(|converted| *s = converted); + } else { + *s = convert_surface(s, from, to, &t); + } + }); + result?; + for layer in &mut doc.layers { + convert_layer_colors(layer, &mut |c| convert_color(c, from, to_mode, &t)); + } + doc.mode = to_mode; + doc.icc_profile = Some(dst.to_bytes()); + doc.color_table = None; + doc.duotone = None; + Ok(()) +} diff --git a/crates/engine/src/cmd/image_cmds.rs b/crates/engine/src/cmd/image_cmds.rs index c8c3e0b7d2..3edf91b6b3 100644 --- a/crates/engine/src/cmd/image_cmds.rs +++ b/crates/engine/src/cmd/image_cmds.rs @@ -191,7 +191,7 @@ fn parse_resample(s: &str) -> Method { } /// The largest raster Image Size resamples to: the decoders' default allocation budget. -const MAX_RESAMPLE_BYTES: u64 = if cfg!(target_pointer_width = "64") { 8 << 30 } else { 2 << 30 }; +pub(crate) const MAX_RESAMPLE_BYTES: u64 = if cfg!(target_pointer_width = "64") { 8 << 30 } else { 2 << 30 }; /// Image → Image Size. fn image_size(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/tests/convert_profile_atn.rs b/crates/engine/tests/convert_profile_atn.rs new file mode 100644 index 0000000000..77100789df --- /dev/null +++ b/crates/engine/tests/convert_profile_atn.rs @@ -0,0 +1,209 @@ +//! Recorded-shaped Convert to Profile flags survive ATN load, save and native playback. + +use std::sync::Arc; + +use photocraft_cms::Builtin; +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_doc::{Document, Layer, LayerContent, SmartObject, SmartSource}; +use photocraft_engine::{Session, actions_cmds::Action, atn_cmds}; +use photocraft_geom::{Affine, Size}; +use photocraft_psd::{ + atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}, + descriptor::{Descriptor, Id, UnicodeString, Value as D}, +}; +use photocraft_raster::Surface; +use serde_json::{Value, json}; + +const FLAGS: [(&str, &str); 3] = [("Dthr", "dither"), ("Fltt", "flatten"), ("rasterizePlaced", "rasterizePlaced")]; + +fn descriptor() -> Descriptor { + Descriptor::new("null") + .with("T ", D::Text(UnicodeString::new_nul("sRGB IEC61966-2.1"))) + .with("Inte", D::Enumerated { type_id: Id::new("Inte"), value: Id::new("Clrm") }) + .with("MpBl", D::Boolean(false)) +} + +fn recorded(d: Descriptor) -> Vec { + atn::write(&AtnFile { + name: UnicodeString::new_nul("Profile"), + actions: vec![AtnAction { + name: UnicodeString::new_nul("Convert"), + items: vec![AtnItem { + event: AtnEvent::name("convertToProfile"), + dictionary: b"Convert to Profile".to_vec(), + descriptor: Some(d), + ..Default::default() + }], + ..Default::default() + }], + expanded: false, + warnings: vec![], + }) + .unwrap() +} + +fn load(s: &mut Session, bytes: &[u8]) -> Value { + s.execute("actions.load", json!({"data": photocraft_engine::paint::tile::b64_encode(bytes)})).unwrap() +} + +fn save(s: &mut Session) -> Vec { + let saved = s.execute("actions.save", json!({"set": "Profile"})).unwrap(); + assert_eq!(saved["skipped"], json!([])); + photocraft_engine::paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap() +} + +/// A cached placed layer over black separates flatten order, rasterization and U8 dither. +fn fixture() -> Session { + let mut doc = Document::new("Linear source", Size::new(256, 256), ColorMode::Rgb, SampleType::U8); + doc.icc_profile = Some(Builtin::LinearSrgb.profile().to_bytes()); + doc.layers.push(Layer::new("Black", LayerContent::Raster(Surface::with_default(PixelFormat::RGBA8, &[0.0, 0.0, 0.0, 1.0])))); + let cache = Surface::with_default(PixelFormat::RGBA8, &[128.0 / 255.0, 128.0 / 255.0, 128.0 / 255.0, 1.0]); + let source = SmartSource::Embedded { file_name: "cached.svg".into(), bytes: Arc::new(b"cached appearance must be used".to_vec()) }; + let mut layer = Layer::new("Half placed", LayerContent::Smart(SmartObject::new(source, Affine::IDENTITY, Some(cache)))); + layer.opacity = 0.5; + doc.layers.push(layer); + let mut s = Session::new(); + s.add_document(doc, None); + s +} + +fn rendered(s: &Session) -> Vec<[f32; 4]> { + photocraft_compose::flatten(&s.active().unwrap().doc).px +} + +/// Assert saved typed fields and independent pixel/model effects, not just paired adapters. +#[test] +fn recorded_rgb_flags_survive_save_reimport_and_public_playback() { + for values in [[true, true, true], [true, false, true], [true, false, false], [false, false, false]] { + let mut d = descriptor().with("sdwM", D::Integer(2)); + let mut params = json!({"profile": "srgb", "intent": "relative", "bpc": false}); + for ((key, param), value) in FLAGS.into_iter().zip(values) { + d = d.with(key, D::Boolean(value)); + params[param] = json!(value); + } + let mut direct = fixture(); + direct.execute("edit.convertToProfile", params.clone()).unwrap(); + let expected = direct.active().unwrap().doc.clone(); + if values[0] { + let mut control = fixture(); + let mut p = params.clone(); + p["dither"] = json!(false); + control.execute("edit.convertToProfile", p).unwrap(); + assert_ne!(rendered(&direct), rendered(&control), "ignored dither cannot pass"); + } + if values[1] { + let mut wrong_order = fixture(); + let mut p = params.clone(); + p["flatten"] = json!(false); + wrong_order.execute("edit.convertToProfile", p).unwrap(); + wrong_order.execute("layer.flattenImage", json!({})).unwrap(); + assert!((rendered(&direct)[0][0] - rendered(&wrong_order)[0][0]).abs() > 0.1, "source-first order fixture"); + } + let mut replay = fixture(); + assert_eq!(load(&mut replay, &recorded(d))["unsupported"], json!([])); + assert_eq!(replay.actions.list[0].steps, vec![("edit.convertToProfile".into(), params)]); + let saved = save(&mut replay); + let parsed = atn::parse(&saved).unwrap(); + let saved_d = parsed.actions[0].items[0].descriptor.as_ref().unwrap(); + assert_eq!(saved_d.get("sdwM"), Some(&D::Integer(2))); + for ((key, _), value) in FLAGS.into_iter().zip(values) { + assert_eq!(saved_d.get(key), Some(&D::Boolean(value))); + } + let imported = atn_cmds::import(&saved).unwrap(); + assert!(imported.unsupported.is_empty()); + assert_eq!(imported.actions[0].steps, replay.actions.list[0].steps); + replay.actions.list = imported.actions; + let played = replay.execute("actions.play", json!({"action": "Convert"})).unwrap(); + assert_eq!(played["ran"], 1); + assert!(played.get("failed").is_none(), "{played}"); + assert_eq!(rendered(&replay), rendered(&direct)); + let actual = &replay.active().unwrap().doc; + assert_eq!(actual.icc_profile, expected.icc_profile); + assert_eq!(actual.layers.len(), if values[1] { 1 } else { 2 }); + assert_eq!(actual.layers.iter().filter(|l| matches!(l.content, LayerContent::Smart(_))).count(), usize::from(!values[1] && !values[2])); + for (actual, expected) in actual.layers.iter().zip(&expected.layers) { + assert_eq!(actual.surface(), expected.surface()); + assert_eq!(actual.opacity, expected.opacity); + } + } +} + +/// Legacy omission and native nulls have the existing false defaults, serialized explicitly. +#[test] +fn defaults_are_explicit_false_and_assign_profile_is_unchanged() { + let mut s = fixture(); + assert_eq!(load(&mut s, &recorded(descriptor()))["unsupported"], json!([])); + let params = &s.actions.list[0].steps[0].1; + for (_, param) in FLAGS { + assert_eq!(params[param], false); + } + let saved = save(&mut s); + s.actions.list = atn_cmds::import(&saved).unwrap().actions; + let mut control = fixture(); + control.execute("edit.convertToProfile", json!({"profile": "srgb", "intent": "relative", "bpc": false})).unwrap(); + let played = s.execute("actions.play", json!({"action": "Convert"})).unwrap(); + assert_eq!(played["ran"], 1); + assert!(played.get("failed").is_none(), "{played}"); + assert_eq!(rendered(&s), rendered(&control)); + assert!(matches!(s.active().unwrap().doc.layers[1].content, LayerContent::Smart(_))); + for nulls in [false, true] { + let mut params = json!({"profile": "srgb"}); + if nulls { + for (_, param) in FLAGS { + params[param] = Value::Null; + } + } + let action = Action::new("Defaults", vec![("edit.convertToProfile".into(), params)]); + let (bytes, skipped) = atn_cmds::export("Profile", &[&action], &[], false).unwrap(); + assert!(skipped.is_empty()); + let parsed = atn::parse(&bytes).unwrap(); + let d = parsed.actions[0].items[0].descriptor.as_ref().unwrap(); + for (key, _) in FLAGS { + assert_eq!(d.get(key), Some(&D::Boolean(false))); + } + assert_eq!(d.get("sdwM"), Some(&D::Integer(2))); + } + let assign = Action::new("Assign", vec![("edit.assignProfile".into(), json!({"profile": "srgb"}))]); + let (bytes, skipped) = atn_cmds::export("Profile", &[&assign], &[], false).unwrap(); + assert!(skipped.is_empty()); + assert_eq!(atn_cmds::import(&bytes).unwrap().actions[0].steps, assign.steps); + let parsed = atn::parse(&bytes).unwrap(); + let d = parsed.actions[0].items[0].descriptor.as_ref().unwrap(); + for key in ["sdwM", "Dthr", "Fltt", "rasterizePlaced"] { + assert!(d.get(key).is_none()); + } +} + +/// Unrepresented radio values and malformed booleans remain complete kept Photoshop steps. +#[test] +fn unsupported_values_are_preserved_and_invalid_native_values_refuse_export() { + let mut descriptors = Vec::new(); + for value in [D::Integer(3), D::Integer(4), D::Integer(-1), D::Integer(999), D::Boolean(true), D::Double(2.0)] { + descriptors.push(descriptor().with("sdwM", value)); + } + for (key, _) in FLAGS { + for value in [D::Integer(1), D::Text(UnicodeString::new_nul("true"))] { + descriptors.push(descriptor().with("sdwM", D::Integer(2)).with(key, value)); + } + } + for d in descriptors { + let bytes = recorded(d); + let original = atn::parse(&bytes).unwrap(); + let mut s = Session::new(); + assert_eq!(load(&mut s, &bytes)["unsupported"].as_array().unwrap().len(), 1); + assert_eq!(s.actions.list[0].steps[0].0, atn_cmds::PHOTOSHOP_STEP); + let saved = atn::parse(&save(&mut s)).unwrap(); + assert_eq!(saved.actions[0].items[0].descriptor, original.actions[0].items[0].descriptor); + assert_eq!(saved.actions[0].items[0].event, original.actions[0].items[0].event); + } + for key in ["dither", "flatten", "rasterizePlaced", "sdwM"] { + for value in [json!(2), json!("false"), json!([]), json!({})] { + let mut params = json!({"profile": "srgb"}); + params[key] = value; + let action = Action::new("Invalid", vec![("edit.convertToProfile".into(), params)]); + assert!(atn_cmds::export("Profile", &[&action], &[], false).is_err()); + } + } + let action = Action::new("Invalid radio null", vec![("edit.convertToProfile".into(), json!({"profile": "srgb", "sdwM": null}))]); + assert!(atn_cmds::export("Profile", &[&action], &[], false).is_err()); +} diff --git a/crates/engine/tests/convert_profile_dither.rs b/crates/engine/tests/convert_profile_dither.rs new file mode 100644 index 0000000000..54cc62a31f --- /dev/null +++ b/crates/engine/tests/convert_profile_dither.rs @@ -0,0 +1,211 @@ +//! Opt-in Profile dithering preserves representation, alpha, wide paths and atomic refusal. + +use photocraft_cms::{Builtin, Intent, Transform}; +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_doc::{Document, Layer, LayerContent}; +use photocraft_engine::Session; +use photocraft_geom::{Rect, Size, TileCoord}; +use photocraft_raster::Surface; +use serde_json::{Value, json}; + +const GRAY: [f32; 4] = [128.0 / 255.0, 128.0 / 255.0, 128.0 / 255.0, 83.0 / 255.0]; + +fn document(size: Size, surface: Surface, depth: SampleType) -> Document { + let mut doc = Document::new("Dither", size, ColorMode::Rgb, depth); + doc.icc_profile = Some(Builtin::Srgb.profile().to_bytes()); + doc.layers.push(Layer::new("Pixels", LayerContent::Raster(surface))); + doc +} + +fn session(doc: Document) -> Session { + let mut s = Session::new(); + s.add_document(doc, None); + s +} + +fn params(dither: Value) -> Value { + json!({"profile": "linear-srgb", "intent": "relative", "bpc": false, "dither": dither}) +} + +fn converted(doc: Document, p: Value) -> Document { + let mut s = session(doc); + s.execute("edit.convertToProfile", p).unwrap(); + (*s.active().unwrap().doc).clone() +} + +fn pixels(doc: &Document) -> &Surface { + doc.layers[0].surface().unwrap() +} + +/// A fractional constant distinguishes prequantization noise from ignored or post-byte noise. +#[test] +fn dither_rounds_fractional_colors_without_bias_or_alpha_noise() { + let transform = Transform::new(Builtin::Srgb.profile(), Builtin::LinearSrgb.profile(), Intent::RelativeColorimetric, false).unwrap(); + let mut fractional = [0.0; 3]; + transform.eval_fast(&GRAY[..3], &mut fractional); + assert!((fractional[0] * 255.0 - 55.04443).abs() < 0.02, "the CMS fixture must have a fractional destination value"); + let before = document(Size::new(256, 256), Surface::with_default(PixelFormat::RGBA8, &GRAY), SampleType::U8); + let ordinary = converted(before.clone(), params(json!(false))); + assert_eq!((pixels(&ordinary).pixel(0, 0)[0] * 255.0).round() as u8, 55); + let mut s = session(before.clone()); + s.execute("edit.convertToProfile", params(json!(true))).unwrap(); + let after = s.active().unwrap().doc.clone(); + assert!(pixels(&after).read_region(after.bounds()) != pixels(&ordinary).read_region(ordinary.bounds()), "dither must change fractional U8 colors"); + let tile = pixels(&after).tile(TileCoord::new(0, 0)).unwrap(); + let mut total = 0u64; + let mut raised = 0; + for px in tile.bytes().chunks_exact(4) { + assert!(px[0] == 55 || px[0] == 56, "dither quantizes neighboring destination values"); + assert_eq!(px[0], px[1]); + assert_eq!(px[0], px[2]); + assert_eq!(px[3], 83, "alpha is copied byte-for-byte"); + total += u64::from(px[0]); + raised += usize::from(px[0] == 56); + } + assert!(raised > 0 && raised < 65536, "ignored dithering cannot pass this fixture"); + assert!((total as f64 / 65536.0 - 55.04443).abs() < 0.02); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(*s.active().unwrap().doc, before); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(s.active().unwrap().doc, after); +} + +/// Missing and redundant tiles must encode the same finite image, including outside padding. +#[test] +fn implicit_and_materialized_defaults_dither_identically() { + let implicit = Surface::with_default(PixelFormat::RGBA8, &GRAY); + let mut stored = implicit.clone(); + let tile = stored.solid_tile(&GRAY); + stored.put_tiles([(TileCoord::new(0, 0), tile.clone()), (TileCoord::new(-2, 1), tile.clone()), (TileCoord::new(1_000_000, -1_000_000), tile)]); + let size = Size::new(257, 259); + let a = converted(document(size, implicit, SampleType::U8), params(json!(true))); + let b = converted(document(size, stored, SampleType::U8), params(json!(true))); + assert_eq!(pixels(&a).read_region(a.bounds()), pixels(&b).read_region(b.bounds())); + assert_eq!(pixels(&a).default_bytes(), pixels(&b).default_bytes()); + for (x, y) in [(-512, 256), (256_000_000, -256_000_000), (300, 300), (256, 259)] { + assert_eq!(pixels(&a).pixel(x, y), pixels(&b).pixel(x, y)); + assert_eq!(pixels(&b).pixel(x, y), pixels(&b).default_pixel()); + } + assert!(pixels(&b).tile_count() <= 6, "no bounding box between distant tiles"); +} + +/// Shared source tiles need independent absolute-coordinate patterns; sparse content survives. +#[test] +fn shared_tiles_and_distant_nondefault_pixels_use_actual_coordinates() { + let mut source = Surface::new(PixelFormat::RGBA8); + let tile = source.solid_tile(&GRAY); + source.put_tiles([(TileCoord::new(0, 0), tile.clone()), (TileCoord::new(1, 0), tile)]); + for (x, y) in [(-512, 0), (256_000_003, 256_000_007)] { + source.write_region(Rect::from_xywh(x, y, 1, 1), &GRAY); + } + let mut independent = source.clone(); + independent.put_tiles([(TileCoord::new(1, 0), independent.solid_tile(&GRAY))]); + let size = Size::new(512, 256); + let a = converted(document(size, source, SampleType::U8), params(json!(true))); + let b = converted(document(size, independent, SampleType::U8), params(json!(true))); + assert_eq!(pixels(&a).read_region(a.bounds()), pixels(&b).read_region(b.bounds())); + assert_ne!(pixels(&a).tile(TileCoord::new(0, 0)).unwrap().bytes(), pixels(&a).tile(TileCoord::new(1, 0)).unwrap().bytes()); + for (x, y) in [(-512, 0), (256_000_003, 256_000_007)] { + let px = pixels(&a).pixel(x, y); + assert!([55.0, 56.0].contains(&(px[0] * 255.0).round())); + assert_eq!((px[3] * 255.0).round() as u8, 83); + assert_eq!(pixels(&a).pixel(x + 1, y), pixels(&a).default_pixel()); + } + assert_eq!(pixels(&a).tile_count(), 4); +} + +/// Defaults, wide documents (including U8 caches), same-profile and mismatched surfaces stay old. +#[test] +fn old_paths_and_integer_endpoints_remain_unchanged() { + let base = document(Size::new(8, 6), Surface::with_default(PixelFormat::RGBA8, &GRAY), SampleType::U8); + let ordinary = converted(base.clone(), params(json!(false))); + for p in [json!({"profile": "linear-srgb", "intent": "relative", "bpc": false}), params(Value::Null)] { + assert_eq!(converted(base.clone(), p), ordinary); + } + for depth in [SampleType::U16, SampleType::F32] { + for sample in [depth, SampleType::U8] { + let format = PixelFormat::new(ColorMode::Rgb, sample, true); + let doc = document(Size::new(8, 6), Surface::with_default(format, &GRAY), depth); + assert_eq!(converted(doc.clone(), params(json!(true))), converted(doc, params(json!(false)))); + } + } + for target in ["srgb", "linear-srgb"] { + let mut doc = base.clone(); + if target != "srgb" { + doc.layers[0].content = LayerContent::Raster(Surface::with_default(PixelFormat::new(ColorMode::Grayscale, SampleType::U8, true), &[0.5, 0.3])); + } + let mut p = params(json!(false)); + p["profile"] = json!(target); + let mut q = p.clone(); + q["dither"] = json!(true); + assert_eq!(converted(doc.clone(), p), converted(doc, q)); + } + let mut endpoints = Surface::new(PixelFormat::RGBA8); + endpoints.write_region(Rect::from_xywh(0, 0, 2, 1), &[0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0]); + let doc = document(Size::new(2, 1), endpoints, SampleType::U8); + let a = converted(doc.clone(), params(json!(true))); + let b = converted(doc, params(json!(false))); + assert_eq!(pixels(&a).read_region(a.bounds()), pixels(&b).read_region(b.bounds())); +} + +/// The alpha index follows channel counts when converting to and from four-color CMYK. +#[test] +fn cmyk_channels_do_not_receive_alpha_or_drop_it() { + for from_cmyk in [false, true] { + let mut doc = document(Size::new(16, 16), Surface::with_default(PixelFormat::RGBA8, &GRAY), SampleType::U8); + let mut p = params(json!(true)); + if from_cmyk { + doc.mode = ColorMode::Cmyk; + doc.icc_profile = Some(Builtin::CoatedCmyk.profile().to_bytes()); + doc.layers[0].content = + LayerContent::Raster(Surface::with_default(PixelFormat::new(ColorMode::Cmyk, SampleType::U8, true), &[0.2, 0.4, 0.6, 0.1, GRAY[3]])); + } else { + p["profile"] = json!("coated-cmyk"); + } + let after = converted(doc, p); + let surface = pixels(&after); + for px in surface.tile(TileCoord::new(0, 0)).unwrap().bytes().chunks_exact(surface.channels()).take(16) { + assert_eq!(*px.last().unwrap(), 83); + } + assert_eq!(surface.channels(), if from_cmyk { 4 } else { 5 }); + } +} + +/// Sparse oversize, aggregate allocation, hostile coordinates and wrong flags preserve history. +#[test] +fn refusals_are_early_and_atomic() { + let surface = Surface::with_default(PixelFormat::RGBA8, &GRAY); + let mut aggregate = document(Size::new(32768, 16384), surface.clone(), SampleType::U8); + for _ in 0..4 { + aggregate.layers.push(Layer::new("Shared input", LayerContent::Raster(surface.clone()))); + } + let mut hostile = surface.clone(); + hostile.put_tiles([(TileCoord::new(i32::MAX, 0), hostile.solid_tile(&GRAY))]); + let mut cases = vec![ + (document(Size::new(1 << 20, 1 << 20), surface.clone(), SampleType::U8), json!(true)), + (aggregate, json!(true)), + (document(Size::new(1, 1), hostile, SampleType::U8), json!(true)), + ]; + for value in [json!(1), json!("false"), json!([]), json!({})] { + cases.push((document(Size::new(8, 6), surface.clone(), SampleType::U8), value)); + } + for (doc, flag) in cases { + let mut s = session(doc); + s.execute("layer.new.layer", json!({"name": "Redo sentinel"})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + let before = s.active().unwrap().doc.clone(); + let state = s.active().unwrap(); + let revision = state.revision; + let selection = state.selected_layers.clone(); + let active = state.active_layer; + let history = (state.history.past_len(), state.history.undo_label().map(str::to_owned), state.history.redo_label().map(str::to_owned)); + assert!(s.execute("edit.convertToProfile", params(flag)).is_err()); + let state = s.active().unwrap(); + assert_eq!(state.doc, before); + assert_eq!(state.revision, revision); + assert_eq!(state.selected_layers, selection); + assert_eq!(state.active_layer, active); + assert_eq!((state.history.past_len(), state.history.undo_label().map(str::to_owned), state.history.redo_label().map(str::to_owned)), history); + assert!(state.history.can_redo()); + } +} From c2c5b5c30f7446f944029741a7710842dc06fa33 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Sun, 1 Nov 2026 08:09:11 -0800 Subject: [PATCH 106/216] Cover Vibrance and adjustment opacity action round trips Vibrance playback coverage adapted from bommerts' PR #8659, commit c4d5c632592ebbd763837f8167d28ae454604c51. Add the reported Gradient Map opacity case, metadata-only defaults, and atomic validation controls. --- crates/engine/tests/atn_mapped_identifiers.rs | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) diff --git a/crates/engine/tests/atn_mapped_identifiers.rs b/crates/engine/tests/atn_mapped_identifiers.rs index bd4db1711a..8db727b826 100644 --- a/crates/engine/tests/atn_mapped_identifiers.rs +++ b/crates/engine/tests/atn_mapped_identifiers.rs @@ -221,6 +221,119 @@ fn enum_value<'a>(d: &'a Descriptor, key: &str, ty: &str) -> &'a Id { value } +/// Make's opacity belongs to the layer around the adjustment descriptor. +fn adjustment_make(t: Descriptor, opacity: Option) -> Descriptor { + let mut using = Descriptor::new("AdjL").with("Type", D::Descriptor(t)); + if let Some(opacity) = opacity { + using = using.with("Opct", opacity); + } + Descriptor::new("null").with("null", D::Reference(vec![ReferenceItem::Class(class("AdjL"))])).with("Usng", D::Descriptor(using)) +} + +/// Ported from #8659: the saved event changes, while legacy imports and pixel results agree. +#[test] +fn recorded_vibrance_uses_the_string_event_and_keeps_legacy_playback() { + let d = Descriptor::new("Vbrn").with("vibrance", D::Integer(47)).with("Strt", D::Integer(-19)); + for depth in [8, 16] { + for event in [AtnEvent::name("vibrance"), AtnEvent::Code(*b"Vbrn")] { + let action = recorded(event, d.clone()); + assert_eq!(action.steps, vec![("image.adjustments.vibrance".into(), json!({"vibrance":47,"saturation":-19}))]); + let written = saved(&action); + assert_eq!(written.event, AtnEvent::name("vibrance"), "Vibrance must save by its string event"); + assert_eq!(written.descriptor, Some(d.clone())); + let create = || { + let mut s = Session::new(); + s.execute("file.new", json!({"width":4,"height":3,"depth":depth})).unwrap(); + s.execute("edit.fill", json!({"contents":"color","color":"#6594c2"})).unwrap(); + s + }; + let mut actual = create(); + replay_saved(&mut actual, &action); + let mut expected = create(); + expected.execute("image.adjustments.vibrance", action.steps[0].1.clone()).unwrap(); + for channel in 0..4 { + assert_eq!( + actual.active().unwrap().doc.layers[0].surface().unwrap().sample_channel(1, 1, channel), + expected.active().unwrap().doc.layers[0].surface().unwrap().sample_channel(1, 1, channel) + ); + } + } + } +} + +/// The reported Gradient Map Make and the metadata-only default both round-trip atomically. +#[test] +fn recorded_adjustment_opacity_keeps_gradient_settings_and_defaults() { + let full = + saved(&Action::new("Recorded", vec![("image.adjustments.gradientMap".into(), json!({"stops":[[0,"#0000ff"],[1,"#ff0000"]]}))])).descriptor.unwrap(); + let default = Descriptor::new("GdMp").with("presetKind", enm("presetKindType", "presetKindDefault")); + for t in [full, default] { + let action = recorded(AtnEvent::Code(*b"Mk "), adjustment_make(t.clone(), Some(D::UnitFloat { unit: *b"#Prc", value: 70.0 }))); + assert_eq!(action.steps.len(), 1); + assert_eq!(action.steps[0].0, "layer.newAdjustmentLayer.gradientMap"); + assert_eq!(action.steps[0].1["opacity"], json!(179.0 / 255.0), "Make must retain surrounding opacity"); + if t.get("Grad").is_some() { + assert_eq!(action.steps[0].1["stops"], json!([[0, "#0000ff"], [1, "#ff0000"]])); + } + let written = saved(&action).descriptor.unwrap(); + let Some(D::Descriptor(using)) = written.get("Usng") else { panic!("missing adjustment metadata") }; + assert!(matches!(using.get("Opct"),Some(D::UnitFloat{unit,value}) if unit==b"#Prc" && (*value-179.0/255.0*100.0).abs()<1e-10)); + for depth in [8, 16] { + let mut s = Session::new(); + s.execute("file.new", json!({"width":4,"height":3,"depth":depth})).unwrap(); + let base = s.active().unwrap().active_layer.unwrap(); + replay_saved(&mut s, &action); + let state = s.active().unwrap(); + assert_eq!(state.doc.layers.len(), 2); + assert_eq!(state.doc.layer(state.active_layer.unwrap()).unwrap().opacity, 179.0_f32 / 255.0); + assert_eq!(state.doc.layer(base).unwrap().opacity, 1.0); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.active().unwrap().doc.layers.len(), 1, "one undo removes the complete Make"); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(s.active().unwrap().doc.layers.len(), 2); + } + } +} + +/// Invalid supplied opacity cannot become a silent default or create a layer. +#[test] +fn adjustment_opacity_validation_and_endpoints_are_atomic() { + let t = Descriptor::new("GdMp").with("presetKind", enm("presetKindType", "presetKindDefault")); + for bad in [ + D::UnitFloat { unit: *b"#Pxl", value: 70.0 }, + D::UnitFloat { unit: *b"#Prc", value: -1.0 }, + D::UnitFloat { unit: *b"#Prc", value: 101.0 }, + D::Text(UnicodeString::new_nul("70")), + D::Boolean(false), + D::Double(f64::NAN), + ] { + let action = recorded(AtnEvent::Code(*b"Mk "), adjustment_make(t.clone(), Some(bad))); + assert_eq!(action.steps[0].0, atn_cmds::PHOTOSHOP_STEP); + saved(&action); + } + let mut s = Session::new(); + s.execute("file.new", json!({"width":4,"height":3})).unwrap(); + for bad in [json!(null), json!("70"), json!(-0.1), json!(1.1)] { + let before = s.execute("document.inspect", json!({})).unwrap(); + assert!(s.execute("layer.newAdjustmentLayer.gradientMap", json!({"opacity":bad})).is_err()); + assert_eq!(s.execute("document.inspect", json!({})).unwrap(), before); + assert!( + atn_cmds::export("Identifiers", &[&Action::new("Bad", vec![("layer.newAdjustmentLayer.gradientMap".into(), json!({"opacity":bad}))])], &[], false) + .is_err() + ); + } + for opacity in [None, Some(0.0), Some(1.0)] { + let p = opacity.map_or_else(|| json!({}), |opacity| json!({"opacity":opacity})); + s.execute("layer.newAdjustmentLayer.gradientMap", p.clone()).unwrap(); + let state = s.active().unwrap(); + assert_eq!(state.doc.layer(state.active_layer.unwrap()).unwrap().opacity, opacity.unwrap_or(1.0)); + let action = Action::new("Recorded", vec![("layer.newAdjustmentLayer.gradientMap".into(), p)]); + let reloaded = recorded(AtnEvent::Code(*b"Mk "), saved(&action).descriptor.unwrap()); + assert_eq!(reloaded.steps, action.steps); + s.execute("edit.undo", json!({})).unwrap(); + } +} + /// Canonical event names and old code imports share the existing gradient parameters. #[test] fn gradient_map_writes_the_named_event_and_keeps_legacy_code_imports() { From bfe096a98829ef31880674961948dbf4f90d2092 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Sun, 1 Nov 2026 08:11:10 -0800 Subject: [PATCH 107/216] Preserve named Vibrance exports and adjustment creation opacity Port the relevant changes from @bommerts in PR #8659: c4d5c632592ebbd763837f8167d28ae454604c51, 9f0ae2e43f77da6b452af0aabb22eb8507cccec5, 35d1102540983be53f161fcc1f8388a0a2a87df8 and 668f006ef8976edc6647ff2280e3b4ed8dd26982. Keep current mask handling and layer placement. Separate layer opacity from adjustment settings so a default Gradient Map still saves as a default preset. Co-authored-by: bommerts <44821497+bommerts@users.noreply.github.com> --- crates/engine/src/cmd.rs | 2 +- crates/engine/src/cmd/adjust_params.rs | 9 ++++ crates/engine/src/cmd/atn_cmds/events.rs | 13 ++++-- crates/engine/src/cmd/atn_cmds/steps_make.rs | 46 +++++++++++++++---- crates/engine/tests/atn_mapped_identifiers.rs | 11 +++-- 5 files changed, 64 insertions(+), 17 deletions(-) diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index d4e14c1e36..93ccc06d54 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -1201,7 +1201,7 @@ fn build() -> Vec { label, menu: &["Layer", "New Adjustment Layer"], shortcut: None, - params: Box::leak(format!("{params} + {{{NEW_LAYER_PATH}}}").into_boxed_str()), + params: Box::leak(format!(r#"{params} + {{{NEW_LAYER_PATH},"opacity":0..1=1}}"#).into_boxed_str()), enabled: has_doc, run: |s, p| { let kind = p.get("__kind").and_then(Value::as_str).unwrap_or("invert").to_string(); diff --git a/crates/engine/src/cmd/adjust_params.rs b/crates/engine/src/cmd/adjust_params.rs index 2c8adc9a09..6e89defef2 100644 --- a/crates/engine/src/cmd/adjust_params.rs +++ b/crates/engine/src/cmd/adjust_params.rs @@ -3,11 +3,20 @@ use super::layer_params::{layer_param, mask_new_layer}; use super::{Adjustment, ColorMode, EngineError, Layer, LayerContent, Map, Result, Session, Value, json, pixels}; /// Add an adjustment layer above the active one; it takes the mask `mask_new_layer` builds from `p` (a path, the selection, or the default white mask). +/// Optional opacity is a 0..1 fraction, validated before the creation edit. pub(super) fn new_adjustment(s: &mut Session, adj: Adjustment, p: &Value, cmd: &str) -> Result { + let opacity = match p.get("opacity") { + None => 1.0, + Some(value) => value + .as_f64() + .filter(|o| o.is_finite() && (0.0..=1.0).contains(o)) + .ok_or_else(|| EngineError::BadParams { cmd: cmd.into(), msg: "opacity must be a number in 0..1".into() })? as f32, + }; let label = format!("New {} Layer", adj.label()); let name = adj.label().to_string(); let id = s.edit(&label, |doc, active| { let mut l = Layer::new(doc.next_layer_name(&name), LayerContent::Adjustment(adj)); + l.opacity = opacity; mask_new_layer(doc, &mut l, p, cmd, true)?; let id = crate::layer_place::insert_new(doc, *active, l, crate::layer_place::Place::Auto); *active = Some(id); diff --git a/crates/engine/src/cmd/atn_cmds/events.rs b/crates/engine/src/cmd/atn_cmds/events.rs index 941589d9ee..9eebffbc10 100644 --- a/crates/engine/src/cmd/atn_cmds/events.rs +++ b/crates/engine/src/cmd/atn_cmds/events.rs @@ -275,7 +275,7 @@ pub(super) const EVENTS: &[Event] = &[ adjustment_event!("Pstr", "Pstr", "posterize", "Posterize"), adjustment_event!("Invr", "Invr", "invert", "Invert"), adjustment_event!("ClrB", "ClrB", "colorBalance", "Color Balance"), - adjustment_event!("Vbrn", "Vbrn", "vibrance", "Vibrance"), + adjustment_event!("", "Vbrn", "vibrance", "Vibrance"), adjustment_event!("Exps", "Exps", "exposure", "Exposure"), // Photoshop runs Photo Filter by name only: `PhtF` is the class, not an event (#8301). adjustment_event!("", "PhtF", "photoFilter", "Photo Filter"), @@ -619,8 +619,15 @@ pub(super) const EVENTS: &[Event] = &[ /// these codes fails ("the command is not currently available"), and `Fltn` is the /// `flatness` property, not the flatten event. Import keeps accepting them so actions saved /// before still load; export never writes them. -const LEGACY_CODES: &[(&str, &str)] = - &[("BoxB", "boxblur"), ("SrfB", "surfaceBlur"), ("Fltn", "flattenImage"), ("BlnW", "blackAndWhite"), ("PhtF", "photoFilter"), ("GdMp", "gradientMapEvent")]; +const LEGACY_CODES: &[(&str, &str)] = &[ + ("BoxB", "boxblur"), + ("SrfB", "surfaceBlur"), + ("Fltn", "flattenImage"), + ("BlnW", "blackAndWhite"), + ("PhtF", "photoFilter"), + ("Vbrn", "vibrance"), + ("GdMp", "gradientMapEvent"), +]; pub(super) fn event_for(item: &AtnEvent) -> Option<&'static Event> { let id = item.id(); diff --git a/crates/engine/src/cmd/atn_cmds/steps_make.rs b/crates/engine/src/cmd/atn_cmds/steps_make.rs index c08170e63a..4238affa8f 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_make.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_make.rs @@ -1,7 +1,7 @@ use super::adjustments::{HUE_RANGES, adjustment_by_class, adjustment_by_suffix, adjustment_steps}; use super::descriptors::{ - PX, Target, ang, class, class_ref, code, color_desc, color_hex, desc, enm, get_bool, get_enum, get_list, get_num, get_obj, get_ref, get_text, jbool, jnum, - jstr, layer_ref, layer_target, long, name_ref, pct, px, ref_class, target, target_ref, text, trimmed, + PCT, PX, Target, ang, class, class_ref, code, color_desc, color_hex, desc, enm, get_bool, get_enum, get_list, get_num, get_obj, get_ref, get_text, jbool, + jnum, jstr, layer_ref, layer_target, long, name_ref, pct, px, ref_class, target, target_ref, text, trimmed, }; use super::fields::{Mapped, none, step, steps}; use super::steps_layer::quick_mask_ref; @@ -194,14 +194,27 @@ pub(super) fn make_from(d: &Descriptor) -> Mapped { } Some("AdjL") => match type_desc.and_then(|t| adjustment_by_class(&trimmed(&t.class_id))) { Some(a) => { - let mut mapped = adjustment_steps(a, "layer.newAdjustmentLayer.", type_desc); + let Mapped::Steps(mut mapped) = adjustment_steps(a, "layer.newAdjustmentLayer.", type_desc) else { + return Mapped::Unsupported { harmless: false }; + }; + if let Some(u) = using + && u.get("Opct").is_some() + { + if matches!(u.get("Opct"), Some(D::UnitFloat { unit, .. }) if *unit != PCT) { + return Mapped::Unsupported { harmless: false }; + } + let Some(opacity) = get_num(u, "Opct").filter(|o| (0.0..=100.0).contains(o)) else { + return Mapped::Unsupported { harmless: false }; + }; + let Some((_, Value::Object(params))) = mapped.first_mut() else { return Mapped::Unsupported { harmless: false } }; + // Recorded layer opacity uses 0..255 even in a 16-bit document. + params.insert("opacity".into(), ((opacity * 255.0 / 100.0).round() / 255.0).into()); + } if let Some(c) = using.and_then(|u| get_enum(u, "Clr ")) { let Some(color) = lookup(LABEL_COLORS, &c) else { return Mapped::Unsupported { harmless: false } }; - if let Mapped::Steps(steps) = &mut mapped { - steps.push(("layer.setLabelColor".into(), json!({"color": color}))); - } + mapped.push(("layer.setLabelColor".into(), json!({"color": color}))); } - mapped + Mapped::Steps(mapped) } None => Mapped::Unsupported { harmless: false }, }, @@ -390,12 +403,25 @@ pub(super) fn make_to(id: &str, p: &Value) -> Option { _ => { let suffix = id.strip_prefix("layer.newAdjustmentLayer.")?; let a = adjustment_by_suffix(suffix)?; - let t = if p.as_object().is_some_and(|o| o.is_empty()) { + let opacity = match p.get("opacity") { + None => None, + Some(value) => Some(value.as_f64().filter(|o| o.is_finite() && (0.0..=1.0).contains(o))?), + }; + let mut params = p.clone(); + if let Some(params) = params.as_object_mut() { + params.remove("opacity"); + } + // Layer opacity must not turn default adjustment settings into a custom preset. + let t = if params.as_object().is_some_and(|o| o.is_empty()) { desc(a.class).with("presetKind", enm("presetKindType", "presetKindDefault")) } else { - (a.to)(p)? + (a.to)(¶ms)? }; - desc("null").with("null", class_ref("AdjL")).with("Usng", D::Descriptor(desc("AdjL").with("Type", D::Descriptor(t)))) + let mut using = desc("AdjL").with("Type", D::Descriptor(t)); + if let Some(opacity) = opacity { + using = using.with("Opct", pct(opacity * 100.0)); + } + desc("null").with("null", class_ref("AdjL")).with("Usng", D::Descriptor(using)) } }) } diff --git a/crates/engine/tests/atn_mapped_identifiers.rs b/crates/engine/tests/atn_mapped_identifiers.rs index 8db727b826..bbb1565ea5 100644 --- a/crates/engine/tests/atn_mapped_identifiers.rs +++ b/crates/engine/tests/atn_mapped_identifiers.rs @@ -234,7 +234,7 @@ fn adjustment_make(t: Descriptor, opacity: Option) -> Descriptor { #[test] fn recorded_vibrance_uses_the_string_event_and_keeps_legacy_playback() { let d = Descriptor::new("Vbrn").with("vibrance", D::Integer(47)).with("Strt", D::Integer(-19)); - for depth in [8, 16] { + for depth in [8, 16, 32] { for event in [AtnEvent::name("vibrance"), AtnEvent::Code(*b"Vbrn")] { let action = recorded(event, d.clone()); assert_eq!(action.steps, vec![("image.adjustments.vibrance".into(), json!({"vibrance":47,"saturation":-19}))]); @@ -278,7 +278,7 @@ fn recorded_adjustment_opacity_keeps_gradient_settings_and_defaults() { let written = saved(&action).descriptor.unwrap(); let Some(D::Descriptor(using)) = written.get("Usng") else { panic!("missing adjustment metadata") }; assert!(matches!(using.get("Opct"),Some(D::UnitFloat{unit,value}) if unit==b"#Prc" && (*value-179.0/255.0*100.0).abs()<1e-10)); - for depth in [8, 16] { + for depth in [8, 16, 32] { let mut s = Session::new(); s.execute("file.new", json!({"width":4,"height":3,"depth":depth})).unwrap(); let base = s.active().unwrap().active_layer.unwrap(); @@ -329,7 +329,12 @@ fn adjustment_opacity_validation_and_endpoints_are_atomic() { assert_eq!(state.doc.layer(state.active_layer.unwrap()).unwrap().opacity, opacity.unwrap_or(1.0)); let action = Action::new("Recorded", vec![("layer.newAdjustmentLayer.gradientMap".into(), p)]); let reloaded = recorded(AtnEvent::Code(*b"Mk "), saved(&action).descriptor.unwrap()); - assert_eq!(reloaded.steps, action.steps); + assert_eq!(reloaded.steps.len(), 1); + assert_eq!(reloaded.steps[0].0, action.steps[0].0); + match opacity { + None => assert_eq!(reloaded.steps[0].1, json!({})), + Some(opacity) => assert_eq!(reloaded.steps[0].1["opacity"].as_f64(), Some(f64::from(opacity))), + } s.execute("edit.undo", json!({})).unwrap(); } } From 0588012b29125edf05f3e7fa71beff70710990c1 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 1 Nov 2026 17:11:39 +0100 Subject: [PATCH 108/216] docs(atn): measure Batch behaviour, document-independent recording, Trace Contour Measured in the reference application: Batch source and destination keys (open documents, Save and Close, override Open and Save As, suppress open options), file naming output (the extension component only gets a dot as the last component), processing order, flat output with subfolders, name clashes, stop and log error modes with the log file format, and Esc. What a normal recording names (layers by name, merged nudges and typing, document offsets) and how it plays on another document. Trace Contour's recorded keys and its 0..255 level; Quick Export records no step. Co-Authored-By: Claude Opus 5.5 --- docs/atn.md | 117 +++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 106 insertions(+), 11 deletions(-) diff --git a/docs/atn.md b/docs/atn.md index 8acc4e1f2a..c62b2b60e5 100644 --- a/docs/atn.md +++ b/docs/atn.md @@ -346,8 +346,8 @@ the recorded steps back from the saved Actions panel. Ranges come from the dialo | Sky Replacement (`skyReplacement`) | `shiftEdge` double, `borderSmoothness` long (fade edge, default 50), `Brgh` long, `temperature` long, `harmonizationOpacity` long (35), `foregroundLightingOpacity` long, `lightingMode` blend mode (`Mltp`), `edgeLightingOpacity` long (70), `skyReplacementOutput` (`skyReplacementOutputToNewSheets` new layers, …), `Idnt` text (the sky preset's id), `Nm ` text (its name), `File` alias (the sky image) | Scale and Flip are **not** in the step: with 150 % and Flip on, the returned keys were the same. `foregroundLightingOpacity` is computed from the sky (44 and 29 for two skies). Without a chosen sky the dialog returns no keys. | **Not recordable:** Edit > Purge (no step is recorded; this version's Purge menu has Clipboard, -Histories, All and Video Cache, no Undo); File > Export > Export As (the file is written, no step is -recorded). Convert Stroke to Fill (`vectorStrokeToFill`) was not available in the measured version, even +Histories, All and Video Cache, no Undo); File > Export > Export As and Quick Export as PNG (the file +is written, no step is recorded). Convert Stroke to Fill (`vectorStrokeToFill`) was not available in the measured version, even on a shape layer with a stroke and its path component selected, and it is not in the path context menu. ### Batch @@ -357,11 +357,15 @@ descriptor itself (`Btch`, as the dialog returns it): | Key | Type | Meaning | |---|---|---| -| `null` | alias | source folder (absent for open files) | +| `null` | alias, or enum | source folder; for open documents the enum `batchSourceType` `sourceOpenFiles` | +| `OvrO` | bool | override action "Open" commands (written only when on) | | `Fldr` | bool | include all subfolders | | `suppressWarnings` | bool | suppress colour profile warnings | +| `suppressOpenOptions` | bool | suppress file open options dialogs | | `Usng` | reference | the action: `Actn` by name in `ASet` by name | -| `T ` | alias | destination folder | +| `T ` | alias | destination folder (destination Folder) | +| `SvAn` | bool | destination Save and Close (written instead of `T `) | +| `Ovrd` | bool | override action "Save As" commands (written only when on) | | `fileNamingRules` | object | file naming (below) | | `Log ` | alias | folder of the error log ("log errors to file") | | `Nm ` | text | error log file name | @@ -378,9 +382,63 @@ descriptor itself (`Btch`, as the dialog returns it): | `mmddyy` `mmdd` `yyyymmdd` `yymmdd` `yyddmm` `ddmmyy` `ddmm` | dates | | `lowerCaseExtension` / `upperCaseExtension` | extension / EXTENSION | -The extension component has no dot: Document Name + extension + 3 digit serial + "_pc", starting at 5, -named `other.png` → `otherpng005_pc` (the saved file then gets its own extension). The two short date -entries are `mmdd` and `ddmm` (on 1 November they give `1101` and `0111`). +The extension component is written **without a dot, unless it is the last component**, and nothing +else adds an extension: the name is exactly what the components spell. Measured on `a.png`: + +| Components | Saved as | +|---|---| +| Name + extension | `a.png` | +| Name + EXTENSION | `a.PNG` | +| Name + extension + 3 digit serial | `apng001` (no extension) | +| Name + 3 digit serial | `a001` (no extension) | +| Name + extension + text `x` | `apngx` | +| Name + extension + extension | `apng.png` | +| Name + text `.jpg` | `a.jpg` (still PNG data) | +| DOCUMENT NAME + `_` + serial letter + `_` + SERIAL LETTER | `A_a_A` | + +The two short date entries are `mmdd` and `ddmm` (on 1 November they give `1101` and `0111`). + +#### Batch behaviour + +Measured with a folder `in` holding `a.png`, `b.png`, `notes.txt` and `sub/a.png`, `sub/c.png`, +`sub/deeper/d.png`: + +- **Order:** the folder's files by name, then each subfolder (by name, depth first). The serial number + counts across all of them: `a_001` (`in/a`), `b_002`, `a_003` (`in/sub/a`), `c_004`, `d_005`. +- **Include All Subfolders:** results all go **flat into the destination folder**; the subfolder structure + is not rebuilt. A later file with the same output name **silently overwrites** an earlier one + (`in/a.png` and `in/sub/a.png` give one `a.png`). +- **Destination None:** every processed file stays open, changed and unsaved. **Save and Close** saves + each file over itself in its own format and closes it. **Folder** saves a copy named by the file + naming rules. An open document that was never saved asks with a Save As dialog (Photoshop format, + the document's name) even with Folder as the destination. +- **Override Open:** files are opened only by the action's own Open steps; without one the action runs + with no document and every file fails. **Override Save As:** files are saved only by the action's own + Save As steps, into the destination folder; without one nothing is saved. The dialog says both when + the box is ticked. +- **Files that are not images** (`notes.txt`) are an error for that file (code -25600, "extension not + recognised"), not skipped silently. +- **Errors, Stop For Errors** (no `Log ` key): the batch stops at the failing file; files before it are + saved, nothing after it is processed. +- **Errors, Log Errors To File:** the batch goes on, and at the end a message says an error occurred and + the details are in the log. The log file is **UTF-16 LE with BOM and CRLF**, and is written even when + nothing failed. In German the lines are (the application's own language): + + ``` + Stapelanfang + + Datei: "" + Fehler: () + + …one pair per failed file… + Stapelende + ``` +- **Cancel:** Esc during a batch stops the running command and asks "A command was cancelled or stopped + during batch processing. Continue with the next file or stop?" (Continue / Stop). Stop ends the batch + at once; the file being processed is not saved (12 of 60 written in the test). There is no progress + bar: the documents open and close in the window as they are processed. +- **Return value:** when any file failed, the Batch step itself reports "cancelled" (the dialog returns no + descriptor); a script run with Stop For Errors reports success even though it stopped. ### Actions panel menu @@ -397,6 +455,45 @@ descriptor (Image > Image Size gives an `imageSize` step without settings; playb of path components (`shapeOperation`, `SbpL` list of subpaths, each with `Clsp` closed and `Pts ` list of points with `Anch` in pixels). +## Recording that plays on any document + +What the reference application records when a user works normally, measured on 2026-11-01 by recording +an action through the panel, mouse and keyboard on a document with layers A, B and C and a second open +document, then reading the steps back. This is the form PhotoCraft's recorder should write (AUTO-219-1); +today it copies journal entries with explicit layer ids. + +| User action | Recorded step | +|---|---| +| Click a layer in the Layers panel | `select`, `null` = layer **by name** (`"B"`), `MkVs` false, `LyrI` list of layer ids (a hint, not the reference) | +| Ctrl+J (Layer via Copy) | `copyToLayer`, no keys | +| Click an empty spot of the canvas with Auto-Select on | `selectNoLayers`, `null` = layer target | +| Arrow keys with the Move tool | **one** `move` step per run of key presses, `null` = layer target, `T ` offset in pixels: Right, Right, Right, Shift+Down gave `Hrzn` 3, `Vrtc` 10 | +| Eye icon | `hide` (or `show`), `null` = **list** of layers by name | +| Opacity typed in the Layers panel | `set` of the target layer, `T ` = `Opct` percent (pressing Enter after typing recorded the same step twice) | +| Select All, Deselect | `set` of the selection (`fsel`) to `Al ` / `None` | +| Alt+Backspace (fill with foreground) | `fill`, `Usng` `FrgC`, `Opct` 100 %, `Md ` Normal | +| Type tool: click, type "Hallo", commit | **one** `make` of a text layer (`TxLr`) with `Txt ` = the whole text; position `TxtC` in **percent of the canvas**; bounds in points | +| Ctrl+E (Merge Down) | `mergeLayersNew`, no keys | +| Ctrl+Tab / Ctrl+Shift+Tab | `select` of a document **by offset** (`rele` +1 / -1) plus `DocI` (the document id, a hint) and `forceNotify` | + +So the recorder names layers by name and acts on the target layer, never by id; it merges a run of +nudges into one relative move and a typing session into one text step; text and document positions are +relative. + +### Playing on another document + +The same recording played on two other documents (Play from step 2, skipping the setup step): + +- **Same layer names, new ids:** plays through; layers are found by name, `LyrI` and `DocI` are ignored. +- **No layer with that name** (layers X, Y, Z): each step that names a missing layer stops with a + message and Continue / Stop. Select by name: "The command 'Select' is not currently available". Hide by + name: "The object 'Layer "A"' is not currently available". Steps on the target layer act on whatever + layer is selected (Ctrl+J copied Z into "Z Kopie"). +- **After `selectNoLayers`**, steps that need a target layer (move, set opacity, fill) each stop with + "The command '…' is not currently available" and Continue / Stop. Continue skips the step; nothing is + undone. +- **Play from a step:** selecting a step in the panel and pressing Play plays from that step to the end. + ## Older files: version 12 Action sets saved by the reference application's version 5 era use file version 12. PhotoCraft rejects @@ -1205,8 +1302,8 @@ No parameters: the step carries an empty descriptor. | Key | String ID | Type | Unit | Default | Allowed | |---|---|---|---|---|---| -| `Lvl ` | level | integer | | 128 | applied, but not echoed: the returned step always shows 128. Levels <= 0 and >= 255 give the same image. Dialog range pending. | -| `Edg ` | edge | enumerated | | `Upr ` upper | `Lwr ` lower, `Upr ` upper (applied; the returned step always shows upper) | +| `Lvl ` | level | integer | | 128 | whole number 0..255 (the dialog clamps to the nearest value). A recorded step carries the real level (e.g. 77); only a script run echoes 128 back. Levels <= 0 and >= 255 give the same image. | +| `Edg ` | edge | enumerated | | `Upr ` upper | `CntE` enum: `Lwr ` lower, `Upr ` upper; recorded as set in the dialog | #### Wind @@ -1695,8 +1792,6 @@ their tables will be added once a recorded step was read. | Event | Why | |---|---| -| Trace Contour | The step's level and edge are applied, but the returned descriptor always shows the defaults (level 128, upper). Lower (`Lwr `) is valid; levels at or below 0 and at or above 255 give the same image. | -| Quick Export | Not read yet; Export As records no step (see Measured through dialogs and recordings). | | Generative Fill (`syntheticFill`) | AI feature: in PhotoCraft this goes through the shared AI layer; until then such steps stay kept steps. | ### Recorded under another event From d3392e6124780864a4a28cfd9505327c9141103b Mon Sep 17 00:00:00 2001 From: yoriswind Date: Sun, 1 Nov 2026 09:09:00 -0800 Subject: [PATCH 109/216] Honor fill rotation and style paste group eligibility --- crates/engine/src/cmd.rs | 2 +- crates/engine/src/cmd/atn_cmds/events.rs | 12 +- crates/engine/src/cmd/atn_cmds/steps_layer.rs | 45 ++- crates/engine/src/cmd/atn_cmds/tests.rs | 2 +- crates/engine/src/cmd/extra_cmds.rs | 22 +- crates/engine/src/cmd/fill_cmds.rs | 5 +- crates/engine/tests/content_aware_fill_atn.rs | 34 ++- crates/engine/tests/content_aware_rotation.rs | 126 +++++++++ crates/engine/tests/paste_style_options.rs | 265 ++++++++++++++++++ 9 files changed, 488 insertions(+), 25 deletions(-) create mode 100644 crates/engine/tests/content_aware_rotation.rs create mode 100644 crates/engine/tests/paste_style_options.rs diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 739a6a20d7..5d5801acc6 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -318,7 +318,7 @@ fn build() -> Vec { "Fill…", ["Edit"], Some("Shift+F5"), - r##"{"contents":"foreground|background|color|contentAware|pattern|history|black|gray|white"="color","color":"#rrggbb|[r,g,b,a]"=foreground (contents=color),"pattern":id|name (contents=pattern),"scale":%=100,"angle":deg,"state":row?,"snapshot":id? (contents=history: a History panel row as history.setSource, redo rows last, or a snapshot; default the History Brush source),"colorAdaptation":bool=true,"scaleAdaptation":bool=false,"mirror":bool=false (contents=contentAware),"mode":"normal|multiply|…"="normal","opacity":0..100=100,"preserveTransparency":bool=false,"target":"pixels"|"mask"|"filterMask"|"quickMask"|{"channel":i}?}"##, + r##"{"contents":"foreground|background|color|contentAware|pattern|history|black|gray|white"="color","color":"#rrggbb|[r,g,b,a]"=foreground (contents=color),"pattern":id|name (contents=pattern),"scale":%=100,"angle":deg,"state":row?,"snapshot":id? (contents=history: a History panel row as history.setSource, redo rows last, or a snapshot; default the History Brush source),"colorAdaptation":bool=true,"scaleAdaptation":bool=false,"mirror":bool=false,"rotationAdaptation":bool=false (contents=contentAware: enable quarter-turn source candidates within the sampling budget),"mode":"normal|multiply|…"="normal","opacity":0..100=100,"preserveTransparency":bool=false,"target":"pixels"|"mask"|"filterMask"|"quickMask"|{"channel":i}?}"##, has_pixel_or_channel, crate::fill_cmds::fill ), diff --git a/crates/engine/src/cmd/atn_cmds/events.rs b/crates/engine/src/cmd/atn_cmds/events.rs index 5723c93c59..5a0ab12ac2 100644 --- a/crates/engine/src/cmd/atn_cmds/events.rs +++ b/crates/engine/src/cmd/atn_cmds/events.rs @@ -13,7 +13,8 @@ use super::steps_image::{ }; use super::steps_layer::{ STROKE, apply_comp_from, apply_comp_to, clear_from, clear_to, delete_from, delete_to, duplicate_from, duplicate_to, fill_from, fill_to, flip_from, flip_to, - move_from, move_to, play_from, play_to, rotate_from, rotate_to, select_from, select_to, stroke_to, visibility_from, visibility_to, + move_from, move_to, paste_effects_from, paste_effects_to, play_from, play_to, rotate_from, rotate_to, select_from, select_to, stroke_to, visibility_from, + visibility_to, }; use super::steps_make::{locking_from, locking_to, make_from, make_to, set_from, set_to}; use super::tables::{NOISE, OFFSET_FILL, SMART_BLUR_MODE, SMART_BLUR_QUALITY, TRIM}; @@ -501,7 +502,14 @@ pub(super) const EVENTS: &[Event] = &[ clipping_event!("GrpL", "groupEvent", "Create Clipping Mask", "layer.createClippingMask"), ev!("", "ungroupLayersEvent", "Ungroup Layers", "layer.ungroupLayers", &[]), ev!("", "copyEffects", "Copy Layer Style", "layer.layerStyle.copyLayerStyle", &[]), - ev!("", "pasteEffects", "Paste Layer Style", "layer.layerStyle.pasteLayerStyle", &[]), + Event { + code: "", + name: "pasteEffects", + label: "Paste Layer Style", + ids: &["layer.layerStyle.pasteLayerStyle"], + from: paste_effects_from, + to: paste_effects_to, + }, Event { code: "", name: "collapseAllGroupsEvent", diff --git a/crates/engine/src/cmd/atn_cmds/steps_layer.rs b/crates/engine/src/cmd/atn_cmds/steps_layer.rs index e5b3766a22..a021f1c3fe 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_layer.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_layer.rs @@ -400,11 +400,12 @@ pub(super) fn fill_from(d: &Descriptor) -> Mapped { let Some(contents) = get_enum(d, "Usng").and_then(|u| lookup(FILL_CONTENTS, &u)) else { return Mapped::Unsupported { harmless: false } }; let mut p = json!({"contents": contents}); if contents == "contentAware" { - // Keep a requested rotation until the command can reproduce it, rather than run unrotated. - match d.get("contentAwareRotateFill") { - None | Some(D::Boolean(false)) => {} + let rotate = match d.get("contentAwareRotateFill") { + None => false, + Some(D::Boolean(value)) => *value, Some(_) => return Mapped::Unsupported { harmless: false }, - } + }; + p["rotationAdaptation"] = json!(rotate); for (key, param) in [("contentAwareColorAdaptationFill", "colorAdaptation"), ("contentAwareScaleFill", "scaleAdaptation"), ("contentAwareMirrorFill", "mirror")] { @@ -436,7 +437,7 @@ pub(super) fn fill_from(d: &Descriptor) -> Mapped { step("edit.fill", p) } -/// Export Fill settings, including the native Content-Aware no-rotation behavior. +/// Export Fill settings, including the effective native Content-Aware rotation option. pub(super) fn fill_to(id: &str, p: &Value) -> Option { if id == "paint.bucket" { let mut d = desc("null").with("From", D::Descriptor(desc("Pnt ").with("Hrzn", px(jnum(p, "x")?)).with("Vrtc", px(jnum(p, "y")?)))); @@ -485,8 +486,12 @@ pub(super) fn fill_to(id: &str, p: &Value) -> Option { d = d.with("PrsT", D::Boolean(t)); } if contents.eq_ignore_ascii_case("contentAware") { - // Native CAF searches unrotated sources; express that even when other flags are omitted. - d = d.with("contentAwareRotateFill", D::Boolean(false)); + let rotate = match p.get("rotationAdaptation") { + None | Some(Value::Null) => false, + Some(Value::Bool(value)) => *value, + Some(_) => return None, + }; + d = d.with("contentAwareRotateFill", D::Boolean(rotate)); for (key, param) in [("contentAwareColorAdaptationFill", "colorAdaptation"), ("contentAwareScaleFill", "scaleAdaptation"), ("contentAwareMirrorFill", "mirror")] { @@ -500,6 +505,32 @@ pub(super) fn fill_to(id: &str, p: &Value) -> Option { Some(d) } +/// Preserve the group eligibility flag without approximating an explicit recorded target. +pub(super) fn paste_effects_from(d: &Descriptor) -> Mapped { + if d.get("null").is_some() { + return Mapped::Unsupported { harmless: false }; + } + let allow = match d.get("allowPasteFXOnLayerSet") { + None => true, + Some(D::Boolean(value)) => *value, + Some(_) => return Mapped::Unsupported { harmless: false }, + }; + step("layer.layerStyle.pasteLayerStyle", json!({"allowGroups": allow})) +} + +/// Emit the effective default; unrepresented target parameters must not silently disappear. +pub(super) fn paste_effects_to(_: &str, p: &Value) -> Option { + if !pixel_target(p) || p.get("layer").is_some() || p.get("layers").is_some() { + return None; + } + let allow = match p.get("allowGroups") { + None | Some(Value::Null) => true, + Some(Value::Bool(value)) => *value, + Some(_) => return None, + }; + Some(desc("null").with("allowPasteFXOnLayerSet", D::Boolean(allow))) +} + /// The Paint Bucket's `color` as `paint.bucket` reads it (`[r, g, b(, a)]` in 0..=1 or /// `#rrggbb(aa)`): `Some(None)` when it fills with the foreground (no colour, or a string the /// command doesn't read), `None` when the colour can't be written (not opaque, or outside 0..=1). diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index b9a50c9b99..b9a7f952f0 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -638,7 +638,7 @@ fn every_mapped_command_exports_and_imports_back() { ("layer.createClippingMask", json!({})), ("layer.ungroupLayers", json!({})), ("layer.layerStyle.copyLayerStyle", json!({})), - ("layer.layerStyle.pasteLayerStyle", json!({})), + ("layer.layerStyle.pasteLayerStyle", json!({"allowGroups": true})), ("layer.setExpanded", json!({"all": true, "expanded": false})), ("layerComp.apply", json!({"comp": "Original"})), ("actions.play", json!({"action": "Other", "set": "S"})), diff --git a/crates/engine/src/cmd/extra_cmds.rs b/crates/engine/src/cmd/extra_cmds.rs index 45fec91db1..7da8abccad 100644 --- a/crates/engine/src/cmd/extra_cmds.rs +++ b/crates/engine/src/cmd/extra_cmds.rs @@ -912,13 +912,31 @@ pub fn specs() -> Vec { "Paste Layer Style", &["Layer", "Layer Style"], None, - r##"{"layer":id?} (no layer: every selected layer but the Background)"##, + r##"{"layer":id?,"allowGroups":bool=true} (no layer: every selected layer but the Background; false skips groups, refusing if none remain)"##, |s| { has_layer(s)?; s.style_clipboard.as_ref().map(|_| ()).ok_or_else(|| "no layer style has been copied".into()) }, |s, p| { - let ids = style_targets(s, p, |_| true)?; + let allow_groups = match p.get("allowGroups") { + None | Some(Value::Null) => true, + Some(Value::Bool(value)) => *value, + Some(_) => { + return Err(EngineError::BadParams { + cmd: "layer.layerStyle.pasteLayerStyle".into(), + msg: "`allowGroups` must be true or false".into(), + }); + } + }; + let mut ids = style_targets(s, p, |_| true)?; + if !allow_groups { + // Apply this after resolution: the shared helper keeps single targets verbatim. + let doc = &s.active().ok_or(EngineError::NoDocument)?.doc; + ids.retain(|id| doc.layer(*id).is_some_and(|layer| !layer.is_group())); + if ids.is_empty() { + return Err(EngineError::Other("none of the selected layers can take this layer style change".into())); + } + } let style = s.style_clipboard.clone().ok_or(EngineError::Other("no layer style has been copied".into()))?; let mut fx = style.effects; s.edit("Paste Layer Style", |doc, _| { diff --git a/crates/engine/src/cmd/fill_cmds.rs b/crates/engine/src/cmd/fill_cmds.rs index 69ea798836..75529715ad 100644 --- a/crates/engine/src/cmd/fill_cmds.rs +++ b/crates/engine/src/cmd/fill_cmds.rs @@ -135,10 +135,13 @@ pub fn fill(s: &mut Session, p: &Value) -> Result { let adapt = flag(p, "colorAdaptation", true)?; let scale = flag(p, "scaleAdaptation", false)?; let mirror = flag(p, "mirror", false)?; + let rotate = flag(p, "rotationAdaptation", false)?; let lid = id.ok_or_else(|| EngineError::Other("no active layer".into()))?; let surf = st.doc.layer(lid).and_then(|l| l.surface()).ok_or_else(|| EngineError::Other("Content-Aware fill needs a pixel layer".into()))?; let seed = p.get("seed").and_then(Value::as_u64).unwrap_or(1); - let options = FillOptions { scale, mirror, ..content_aware_options(adapt, seed) }; + // The native Boolean enables quarter-turn candidates, within the existing mosaic cap. + let rotations = if rotate { photocraft_algo::content_aware::rotation_level("full") } else { Vec::new() }; + let options = FillOptions { rotations, scale, mirror, ..content_aware_options(adapt, seed) }; content_aware(surf, sel, doc_bounds, &options)? } _ => return Err(bad(format!("unknown contents `{contents}`"))), diff --git a/crates/engine/tests/content_aware_fill_atn.rs b/crates/engine/tests/content_aware_fill_atn.rs index e55a9f925c..453f9eeec8 100644 --- a/crates/engine/tests/content_aware_fill_atn.rs +++ b/crates/engine/tests/content_aware_fill_atn.rs @@ -8,8 +8,12 @@ use photocraft_psd::{ }; use serde_json::{Value, json}; -const FLAGS: [(&str, &str); 3] = - [("contentAwareColorAdaptationFill", "colorAdaptation"), ("contentAwareScaleFill", "scaleAdaptation"), ("contentAwareMirrorFill", "mirror")]; +const FLAGS: [(&str, &str); 4] = [ + ("contentAwareColorAdaptationFill", "colorAdaptation"), + ("contentAwareScaleFill", "scaleAdaptation"), + ("contentAwareMirrorFill", "mirror"), + ("contentAwareRotateFill", "rotationAdaptation"), +]; const ROTATION: &str = "contentAwareRotateFill"; fn descriptor() -> Descriptor { @@ -74,8 +78,8 @@ fn pixels(s: &Session, id: LayerId) -> Vec { #[test] fn typed_flags_survive_saved_descriptors_and_public_playback() { for event in [AtnEvent::Code(*b"Fl "), AtnEvent::name("fill")] { - for values in [[false, true, true], [true, false, false]] { - let mut d = descriptor().with(ROTATION, D::Boolean(false)); + for values in [[false, true, true, true], [true, false, false, false], [true, false, false, true]] { + let mut d = descriptor(); let mut params = json!({"contents": "contentAware"}); for ((key, param), value) in FLAGS.into_iter().zip(values) { d = d.with(key, D::Boolean(value)); @@ -89,6 +93,13 @@ fn typed_flags_survive_saved_descriptors_and_public_playback() { default.execute("edit.fill", json!({"contents": "contentAware"})).unwrap(); assert_ne!(expected, pixels(&default, default_id), "combined options must change the fixture"); } + if values[3] { + let (mut unrotated, unrotated_id) = fixture(); + let mut off = params.clone(); + off["rotationAdaptation"] = json!(false); + unrotated.execute("edit.fill", off).unwrap(); + assert!(expected != pixels(&unrotated, unrotated_id), "the action fixture must distinguish rotated candidates"); + } let (mut replay, replay_id) = fixture(); assert_eq!(load(&mut replay, &recorded(event.clone(), d))["unsupported"], json!([])); assert_eq!(replay.actions.list[0].steps, vec![("edit.fill".into(), params)], "CAF flags must import without loss"); @@ -98,7 +109,7 @@ fn typed_flags_survive_saved_descriptors_and_public_playback() { for ((key, _), value) in FLAGS.into_iter().zip(values) { assert_eq!(saved_d.get(key), Some(&D::Boolean(value))); } - assert_eq!(saved_d.get(ROTATION), Some(&D::Boolean(false))); + assert_eq!(saved_d.get(ROTATION), Some(&D::Boolean(values[3]))); let imported = atn_cmds::import(&saved).unwrap(); assert!(imported.unsupported.is_empty()); assert_eq!(imported.actions[0].steps, replay.actions.list[0].steps); @@ -118,7 +129,7 @@ fn typed_flags_survive_saved_descriptors_and_public_playback() { fn absent_and_null_native_flags_keep_defaults_and_export_no_rotation() { let mut s = Session::new(); assert_eq!(load(&mut s, &recorded(AtnEvent::Code(*b"Fl "), descriptor()))["unsupported"], json!([])); - assert_eq!(s.actions.list[0].steps[0].1, json!({"contents": "contentAware"})); + assert_eq!(s.actions.list[0].steps[0].1, json!({"contents": "contentAware", "rotationAdaptation": false})); let saved = save(&mut s); let imported = atn_cmds::import(&saved).unwrap(); assert_eq!(imported.actions[0].steps, s.actions.list[0].steps); @@ -135,9 +146,11 @@ fn absent_and_null_native_flags_keep_defaults_and_export_no_rotation() { let file = atn::parse(&bytes).unwrap(); let d = file.actions[0].items[0].descriptor.as_ref().unwrap(); for (key, _) in FLAGS { - assert!(d.get(key).is_none()); + if key != ROTATION { + assert!(d.get(key).is_none()); + } } - assert_eq!(d.get(ROTATION), Some(&D::Boolean(false)), "native CAF performs no rotated-source search"); + assert_eq!(d.get(ROTATION), Some(&D::Boolean(false)), "the default performs no rotated-source search"); } for (_, param) in FLAGS { for value in [json!(1), json!("false")] { @@ -149,9 +162,9 @@ fn absent_and_null_native_flags_keep_defaults_and_export_no_rotation() { } } -/// Unreplayable flags remain intact as unsupported Photoshop steps. +/// Malformed flags remain intact as unsupported Photoshop steps. #[test] -fn malformed_flags_and_requested_rotation_remain_kept_steps() { +fn malformed_flags_remain_kept_steps() { for event in [AtnEvent::Code(*b"Fl "), AtnEvent::name("fill")] { let mut bad = Vec::new(); for (key, _) in FLAGS { @@ -159,7 +172,6 @@ fn malformed_flags_and_requested_rotation_remain_kept_steps() { bad.push(descriptor().with(key, value)); } } - bad.push(descriptor().with(ROTATION, D::Boolean(true))); bad.push(descriptor().with(ROTATION, D::Integer(0))); for d in bad { let source = recorded(event.clone(), d); diff --git a/crates/engine/tests/content_aware_rotation.rs b/crates/engine/tests/content_aware_rotation.rs new file mode 100644 index 0000000000..9073425d9c --- /dev/null +++ b/crates/engine/tests/content_aware_rotation.rs @@ -0,0 +1,126 @@ +//! Native Fill rotation enables quarter-turn candidates without changing ordinary Fill or Crop. + +use photocraft_algo::content_aware::{FillOptions, color_level, fill, rotation_level}; +use photocraft_doc::{LayerId, Rect}; +use photocraft_engine::Session; +use photocraft_raster::Surface; +use serde_json::{Value, json}; + +const W: usize = 64; +const H: usize = 48; +const HOLE: Rect = Rect::new(28, 20, 36, 28); + +fn fixture(depth: u32) -> (Session, LayerId) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": W, "height": H, "depth": depth, "background": "transparent"})).unwrap(); + let id = s.active().unwrap().doc.layers[0].id; + s.edit("Asymmetric texture", |doc, _| { + let mut pixels = Vec::with_capacity(W * H * 4); + for y in 0..H { + for x in 0..W { + pixels.extend([ + ((37 * x + 11 * y + x * y % 17) % 251) as f32 / 250.0, + ((x * x + 13 * y + 3 * (x / 7)) % 241) as f32 / 240.0, + ((5 * x + 3 * y * y + 19 * (x / 9)) % 239) as f32 / 238.0, + 1.0, + ]); + } + } + doc.layer_mut(id).unwrap().surface_mut().unwrap().write_region(Rect::from_xywh(0, 0, W as u32, H as u32), &pixels); + Ok(()) + }) + .unwrap(); + s.execute("select.rect", json!({"x": HOLE.x0, "y": HOLE.y0, "width": HOLE.width(), "height": HOLE.height()})).unwrap(); + (s, id) +} + +fn surface(s: &Session, id: LayerId) -> &Surface { + s.active().unwrap().doc.layer(id).unwrap().surface().unwrap() +} + +fn pixels(s: &Session, id: LayerId) -> Vec { + surface(s, id).read_region(s.active().unwrap().doc.bounds()) +} + +fn assert_pixels(actual: &[f32], expected: &[f32], message: &str) { + assert_eq!(actual.len(), expected.len()); + assert_eq!(actual.iter().zip(expected).position(|(a, b)| a != b), None, "{message}"); +} + +/// Qualify rotation against the same bounded algorithm before checking the command wiring. +fn oracle(s: &Session, id: LayerId, rotate: bool) -> Vec { + let input = surface(s, id); + let mut hole = vec![false; W * H]; + for y in HOLE.y0..HOLE.y1 { + for x in HOLE.x0..HOLE.x1 { + hole[y as usize * W + x as usize] = true; + } + } + let source: Vec<_> = hole.iter().map(|v| !v).collect(); + let options = + FillOptions { color_adaptation: color_level("none"), rotations: if rotate { rotation_level("full") } else { vec![] }, seed: 7, ..Default::default() }; + let output = fill(W, H, input.channels(), &pixels(s, id), &hole, &source, &options); + let mut encoded = input.clone(); + encoded.write_region(s.active().unwrap().doc.bounds(), &output); + encoded.read_region(s.active().unwrap().doc.bounds()) +} + +/// A nonzero rotated/unrotated difference makes an ignored option fail at every native depth. +#[test] +fn rotated_candidates_reach_fill_at_all_depths_and_undo_once() { + assert_eq!(rotation_level("full"), vec![90.0, 180.0, 270.0]); + for depth in [8, 16, 32] { + let (mut s, id) = fixture(depth); + let before = s.active().unwrap().doc.clone(); + let on = oracle(&s, id, true); + let off = oracle(&s, id, false); + assert!(on != off, "the rotation fixture must discriminate at {depth}-bit"); + let history = s.active().unwrap().history.past_len(); + s.execute("edit.fill", json!({"contents": "contentAware", "colorAdaptation": false, "rotationAdaptation": true, "seed": 7})).unwrap(); + assert_pixels(&pixels(&s, id), &on, "rotationAdaptation must reach the native sampler"); + assert_eq!(s.active().unwrap().history.past_len(), history + 1); + let after = s.active().unwrap().doc.clone(); + let old = before.layer(id).unwrap().surface().unwrap().read_region(before.bounds()); + for y in 0..H { + for x in 0..W { + if !HOLE.contains(x as i32, y as i32) { + let i = (y * W + x) * 4; + assert_eq!(&on[i..i + 4], &old[i..i + 4]); + } + } + } + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.active().unwrap().doc, before); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(s.active().unwrap().doc, after); + } +} + +/// Omission, false and null preserve old sampling; malformed flags preserve document and redo. +#[test] +fn defaults_and_invalid_rotation_flags_preserve_existing_behavior() { + for value in [None, Some(Value::Null), Some(json!(false))] { + let (mut s, id) = fixture(8); + let expected = oracle(&s, id, false); + let mut p = json!({"contents": "contentAware", "colorAdaptation": false, "seed": 7}); + if let Some(value) = value { + p["rotationAdaptation"] = value; + } + s.execute("edit.fill", p).unwrap(); + assert_pixels(&pixels(&s, id), &expected, "ordinary Fill default"); + } + for value in [json!(1), json!("full"), json!([]), json!({})] { + let (mut s, _) = fixture(8); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + let before = s.active().unwrap().doc.clone(); + let revision = s.active().unwrap().revision; + let history = s.active().unwrap().history.past_len(); + let redo = s.active().unwrap().history.redo_label().map(str::to_owned); + assert!(s.execute("edit.fill", json!({"contents": "contentAware", "rotationAdaptation": value})).is_err()); + assert_eq!(s.active().unwrap().doc, before); + assert_eq!(s.active().unwrap().revision, revision); + assert_eq!(s.active().unwrap().history.past_len(), history); + assert_eq!(s.active().unwrap().history.redo_label(), redo.as_deref()); + } +} diff --git a/crates/engine/tests/paste_style_options.rs b/crates/engine/tests/paste_style_options.rs new file mode 100644 index 0000000000..0202344cdb --- /dev/null +++ b/crates/engine/tests/paste_style_options.rs @@ -0,0 +1,265 @@ +//! Paste Layer Style applies group eligibility consistently and preserves the ATN flag. + +use photocraft_color::{BlendMode, Color, ColorMode, SampleType}; +use photocraft_doc::{BlendRange, Document, Effect, Layer, LayerId}; +use photocraft_engine::{Session, actions_cmds::Action, atn_cmds, layer_multi_cmds}; +use photocraft_geom::Size; +use photocraft_psd::{ + atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}, + descriptor::{Class, Descriptor, Id, UnicodeString, Value as D}, +}; +use serde_json::{Value, json}; + +const CMD: &str = "layer.layerStyle.pasteLayerStyle"; +const KEY: &str = "allowPasteFXOnLayerSet"; + +struct Scene { + s: Session, + source: LayerId, + raster: LayerId, + group: LayerId, + other_group: LayerId, + child: LayerId, + background: LayerId, +} + +/// The copied style contains effects and blending data, so a partial copy cannot pass. +fn fixture() -> Scene { + let mut doc = Document::with_background("Style", Size::new(8, 6), ColorMode::Rgb, SampleType::U8, Color::rgb(1.0, 1.0, 1.0)); + let background = doc.layers[0].id; + let mut source = Layer::raster("Source", doc.pixel_format()); + source.effects.items.push(Effect::default_drop_shadow()); + source.blend = BlendMode::Multiply; + source.fill_opacity = 0.6; + source.advanced.blend_interior = true; + source.blend_if.set(0, [BlendRange::from_bytes([12, 24, 230, 240]), BlendRange::FULL]); + source.excluded_channels = 2; + let raster = Layer::raster("Raster", doc.pixel_format()); + let child = Layer::raster("Child", doc.pixel_format()); + let child_id = child.id; + let group = Layer::group("Group", vec![child]); + let other = Layer::group("Other group", vec![]); + let ids = (source.id, raster.id, group.id, other.id); + doc.layers.extend([source, raster, group, other]); + let mut s = Session::new(); + s.add_document(doc, None); + s.select_layer(ids.0).unwrap(); + s.execute("layer.layerStyle.copyLayerStyle", json!({})).unwrap(); + Scene { s, source: ids.0, raster: ids.1, group: ids.2, other_group: ids.3, child: child_id, background } +} + +fn select(scene: &mut Scene, ids: Vec) { + let active = ids.last().copied(); + layer_multi_cmds::set_selection(&mut scene.s, ids, active, active).unwrap(); +} + +fn layer(s: &Session, id: LayerId) -> &Layer { + s.active().unwrap().doc.layer(id).unwrap() +} + +fn assert_style(actual: &Layer, source: &Layer) { + assert_eq!(actual.effects, source.effects); + assert_eq!(actual.blend, source.blend); + assert_eq!(actual.fill_opacity, source.fill_opacity); + assert_eq!(actual.advanced, source.advanced); + assert_eq!(actual.blend_if, source.blend_if); + assert_eq!(actual.excluded_channels, source.excluded_channels); +} + +/// Refusals must not consume style data, modify selection, or remove a redo branch. +fn assert_rejected(s: &mut Session, params: Value) { + let state = s.active().unwrap(); + let before = state.doc.clone(); + let revision = state.revision; + let selected = state.selected_layers.clone(); + let active = state.active_layer; + let history = (state.history.past_len(), state.history.undo_label().map(str::to_owned), state.history.redo_label().map(str::to_owned)); + let clipboard = s.style_clipboard.as_ref().unwrap().effects.clone(); + assert!(s.execute(CMD, params).is_err(), "group restriction must refuse an ineligible target"); + let state = s.active().unwrap(); + assert_eq!(state.doc, before); + assert_eq!(state.revision, revision); + assert_eq!(state.selected_layers, selected); + assert_eq!(state.active_layer, active); + assert_eq!((state.history.past_len(), state.history.undo_label().map(str::to_owned), state.history.redo_label().map(str::to_owned)), history); + assert_eq!(s.style_clipboard.as_ref().unwrap().effects, clipboard); +} + +/// Every target cardinality uses the same restriction; group selection never expands children. +#[test] +fn false_excludes_explicit_single_and_all_group_targets_atomically() { + for explicit in [false, true] { + let mut scene = fixture(); + let group = scene.group; + select(&mut scene, vec![group]); + let mut params = json!({"allowGroups": false}); + if explicit { + params["layer"] = json!(group.0); + } + assert_rejected(&mut scene.s, params); + } + for background in [false, true] { + let mut scene = fixture(); + let ids = vec![scene.group, if background { scene.background } else { scene.other_group }]; + select(&mut scene, ids); + assert_rejected(&mut scene.s, json!({"allowGroups": false})); + } +} + +/// Mixed selections change only eligible explicit members and create one undoable edit. +#[test] +fn mixed_targets_skip_groups_without_expanding_their_children() { + for selected_child in [false, true] { + let mut scene = fixture(); + let eligible = if selected_child { scene.child } else { scene.raster }; + let group = scene.group; + select(&mut scene, vec![group, eligible]); + let before = scene.s.active().unwrap().doc.clone(); + let history = scene.s.active().unwrap().history.past_len(); + scene.s.execute(CMD, json!({"allowGroups": false})).unwrap(); + assert_style(layer(&scene.s, eligible), layer(&scene.s, scene.source)); + assert_style(layer(&scene.s, group), before.layer(group).unwrap()); + if !selected_child { + assert_eq!(layer(&scene.s, scene.child), before.layer(scene.child).unwrap()); + } + assert_eq!(scene.s.active().unwrap().history.past_len(), history + 1); + let after = scene.s.active().unwrap().doc.clone(); + scene.s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(scene.s.active().unwrap().doc, before); + scene.s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(scene.s.active().unwrap().doc, after); + } +} + +/// Native legacy/default group support and explicit Background conversion remain available. +#[test] +fn defaults_and_background_keep_legacy_behavior_and_wrong_types_preserve_redo() { + for value in [None, Some(Value::Null), Some(json!(true))] { + let mut scene = fixture(); + let child = layer(&scene.s, scene.child).clone(); + let mut p = json!({"layer": scene.group.0}); + if let Some(value) = value { + p["allowGroups"] = value; + } + scene.s.execute(CMD, p).unwrap(); + assert_style(layer(&scene.s, scene.group), layer(&scene.s, scene.source)); + assert_eq!(layer(&scene.s, scene.child), &child); + } + let mut scene = fixture(); + scene.s.execute(CMD, json!({"layer": scene.background.0, "allowGroups": false})).unwrap(); + assert_style(layer(&scene.s, scene.background), layer(&scene.s, scene.source)); + assert!(!scene.s.active().unwrap().doc.is_background(scene.background)); + for value in [json!(0), json!("false"), json!([]), json!({})] { + let mut scene = fixture(); + scene.s.execute("layer.new.layer", json!({})).unwrap(); + scene.s.execute("edit.undo", json!({})).unwrap(); + assert!(scene.s.active().unwrap().history.can_redo()); + assert_rejected(&mut scene.s, json!({"layer": scene.raster.0, "allowGroups": value})); + } +} + +fn recorded(d: Descriptor) -> Vec { + atn::write(&AtnFile { + name: UnicodeString::new_nul("Style"), + expanded: false, + warnings: vec![], + actions: vec![AtnAction { + name: UnicodeString::new_nul("Paste"), + items: vec![AtnItem { + event: AtnEvent::name("pasteEffects"), + dictionary: b"Paste Layer Style".to_vec(), + descriptor: Some(d), + ..Default::default() + }], + ..Default::default() + }], + }) + .unwrap() +} + +fn load(s: &mut Session, bytes: &[u8]) -> Value { + s.execute("actions.load", json!({"data": photocraft_engine::paint::tile::b64_encode(bytes)})).unwrap() +} + +fn save(s: &mut Session) -> Vec { + let result = s.execute("actions.save", json!({"set": "Style"})).unwrap(); + assert_eq!(result["skipped"], json!([])); + photocraft_engine::paint::tile::b64_decode(result["data"].as_str().unwrap()).unwrap() +} + +/// Inspect typed saved fields, then play against selected groups and pixels to expose dropped flags. +#[test] +fn atn_group_flag_survives_saved_descriptor_and_public_playback() { + for allow in [false, true] { + let mut scene = fixture(); + let ids = vec![scene.group, scene.raster]; + select(&mut scene, ids); + let group_before = layer(&scene.s, scene.group).clone(); + let child_before = layer(&scene.s, scene.child).clone(); + let bytes = recorded(Descriptor::new("null").with(KEY, D::Boolean(allow))); + assert_eq!(load(&mut scene.s, &bytes)["unsupported"], json!([])); + assert_eq!(scene.s.actions.list[0].steps, vec![(CMD.into(), json!({"allowGroups": allow}))]); + let saved = save(&mut scene.s); + let parsed = atn::parse(&saved).unwrap(); + assert_eq!(parsed.actions[0].items[0].descriptor.as_ref().unwrap().get(KEY), Some(&D::Boolean(allow))); + let imported = atn_cmds::import(&saved).unwrap(); + assert_eq!(imported.actions[0].steps, scene.s.actions.list[0].steps); + scene.s.actions.list = imported.actions; + let result = scene.s.execute("actions.play", json!({"action": "Paste"})).unwrap(); + assert_eq!(result["ran"], 1); + assert!(result.get("failed").is_none(), "{result}"); + assert_style(layer(&scene.s, scene.raster), layer(&scene.s, scene.source)); + if allow { + assert_style(layer(&scene.s, scene.group), layer(&scene.s, scene.source)); + } else { + assert_eq!(layer(&scene.s, scene.group), &group_before); + } + assert_eq!(layer(&scene.s, scene.child), &child_before); + } +} + +/// Omission retains legacy group eligibility and exports its effective Boolean true. +#[test] +fn atn_defaults_are_explicit_and_unsupported_fields_remain_whole_steps() { + let mut scene = fixture(); + assert_eq!(load(&mut scene.s, &recorded(Descriptor::new("null")))["unsupported"], json!([])); + assert_eq!(scene.s.actions.list[0].steps[0].1, json!({"allowGroups": true})); + for value in [None, Some(Value::Null)] { + let mut params = json!({}); + if let Some(value) = value { + params["allowGroups"] = value; + } + let action = Action::new("Defaults", vec![(CMD.into(), params)]); + let (bytes, skipped) = atn_cmds::export("Style", &[&action], &[], false).unwrap(); + assert!(skipped.is_empty()); + assert_eq!(atn::parse(&bytes).unwrap().actions[0].items[0].descriptor.as_ref().unwrap().get(KEY), Some(&D::Boolean(true))); + } + let bad = [ + Descriptor::new("null").with(KEY, D::Integer(1)), + Descriptor::new("null").with(KEY, D::Text(UnicodeString::new_nul("true"))), + Descriptor::new("null").with("null", D::Class(Class { name: UnicodeString::new_nul(""), class_id: Id::new("Lyr ") })), + ]; + for d in bad { + let bytes = recorded(d); + let original = atn::parse(&bytes).unwrap(); + let mut s = Session::new(); + assert_eq!(load(&mut s, &bytes)["unsupported"].as_array().unwrap().len(), 1); + assert_eq!(s.actions.list[0].steps[0].0, atn_cmds::PHOTOSHOP_STEP); + let saved = atn::parse(&save(&mut s)).unwrap(); + assert_eq!(saved.actions[0].items[0].descriptor, original.actions[0].items[0].descriptor); + } + for (key, value) in [ + ("allowGroups", json!(1)), + ("allowGroups", json!("true")), + ("allowGroups", json!([])), + ("layer", json!(17)), + ("layer", Value::Null), + ("layers", json!([17])), + ("layers", Value::Null), + ] { + let mut params = json!({}); + params[key] = value; + let action = Action::new("Invalid", vec![(CMD.into(), params)]); + assert!(atn_cmds::export("Style", &[&action], &[], false).is_err()); + } +} From 2ec27abb958c861ccab2123f4ce238df5595601b Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 1 Nov 2026 18:10:32 +0100 Subject: [PATCH 110/216] docs(atn): measure the remaining menu events through recordings Every remaining event recorded in the reference application through its menus: Scale Effects, Create Layers, Frame and Artboards, all eleven Stack Modes with their plug-in codes, video layer and frame commands, Create Work Path, Find and Replace, Check Spelling, measurement scale and Scale Marker, Close All, data sets (apply, import, export), Package, Video Frames to Layers, Import Notes, Photomerge, Crop and Straighten Photos, Conditional Mode Change and Fit Image. Close Others and Define Variables record nothing. Co-Authored-By: Claude Opus 5.5 --- docs/atn.md | 52 +++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 43 insertions(+), 9 deletions(-) diff --git a/docs/atn.md b/docs/atn.md index c62b2b60e5..56a0f14b09 100644 --- a/docs/atn.md +++ b/docs/atn.md @@ -302,15 +302,49 @@ layer reference, `Ordn.Trgt`); mapping them needs only the event name. | `shapeBlur` | Filter > Blur > Shape Blur | `Rds ` radius in pixels (factory 10); the shape itself comes from the dialog | | Make `contentLayer` with `Usng` = reference to `textLayer` `Ordn.Trgt` | Type > Convert to Shape (recorded as Make, not as `convertToShape`) | `null`: class `contentLayer`; `Usng`: the type layer | -**Needs a step recorded through its dialog or a matching document.** Without them the event either did -nothing, returned no settings, or was not available: Export As and Quick Export (document and layers), -Content-Aware Fill workspace (`cafWorkspace`), Perspective Warp, Color Lookup, Scale Effects -(`scaleEffectsEvent`), Liquify (`LqFy`), Camera Raw Filter, Vanishing Point, Blur Gallery -(`blurbTransform`), Calculations, Photomerge, Package, data sets and variables, Close All / Close Others, -artboards (new, from layers), Frame from Layers, Create Layers from a layer style, Stack Modes -(`applyImageStackPluginRenderer`), video frames and footage, Create Work Path, Check Spelling, Find and -Replace Text, Import Notes, Measurement Scale, Convert Stroke to Fill (needs a selected path component), -Sky Replacement. +**Recorded through the menus** (2026-11-01, steps read back from the saved Actions panel): + +| Menu | Recorded step | Keys | +|---|---|---| +| Layer > Layer Style > Scale Effects | `scaleEffectsEvent` | `Scl ` percent, whole number 1..1000 (the dialog clamps) | +| Layer > Layer Style > Create Layer(s) | Make, `null` = class `Lyr `, `Usng` = reference to the `Lefx` property of the target layer | none; the dialog first warns that some effects cannot be reproduced with layers (OK / Cancel) | +| Layer > New > Frame from Layers | Make, `null` = class `framedGroupSection`, `From` = layer **by name**, `layerSectionStart`, `layerSectionEnd` long, `Nm ` text | the dialog also shows width and height, which are not recorded | +| Layer > New > Artboard from Layers | Make, `null` = class `artboardSection`, `From` = layer target, `layerSectionStart`, `layerSectionEnd`, `Nm ` | | +| Layer > New > Artboard | Make, `null` = class `artboardSection`, `layerSectionStart`, `layerSectionEnd`, `Nm `, `artboardRect` (`Top `, `Left`, `Btom`, `Rght` doubles, pixels) | | +| Layer > Smart Objects > Stack Mode > mode | `applyImageStackPluginRenderer`: `imageStackPlugin` **class** with the mode's code, `Nm ` text = the menu name (localised, except "Entropy") | codes: Range `rang`, Entropy `entr`, Kurtosis `kurt`, Maximum `maxx`, Median `medn`, Minimum `minn`, Mean `avrg`, Skewness `skew`, Standard Deviation `stdv`, Summation `summ`, Variance `vari` | +| Layer > Smart Objects > Stack Mode > None | `imageStackConvertSmartObject` | none | +| Layer > Video Layers > New Blank Video Layer, Insert Blank Frame, Delete Frame, Show / Hide Altered Video | `addBlankVideoLayer`, `insertBlankFrame`, `deleteFrame`, `showAlteredVideo` / `hideAlteredVideo` | none | +| Layer > Video Layers > Interpret Footage | `interpretFootage` | `Vrsn` long (1), `interpretAlpha` (`alphaInterpretation`: `Ignr` ignore, …), `colorConvertAlterations` bool | +| Type > Create Work Path | Make, `null` = class `Path`, `From` = text layer (`TxLr`) target | | +| Edit > Find and Replace Text (Replace All) | `replace`, `null` = reference: property `replace` of all text layers (`TxLr` `Ordn.Al`), `Usng` = `find` text, `replace` text, `checkAll`, `Fwd `, `caseSensitive`, `wholeWord`, `ignoreAccents` bool | | +| Edit > Check Spelling | `spellCheck`, `null` = property `spellCheck` of all text layers, `Usng` = `checkAll` bool | recorded with its dialog switched on; the corrections themselves are not recorded | +| Image > Analysis > Set Measurement Scale > Custom | Set, `null` = property `measurementScale` of the document target, `T ` = `pixelLength` long, `logicalLength` double, `logicalUnits` text | | +| Image > Analysis > Place Scale Marker | a script step (`AdobeScriptAutomation Scripts`) with `jsop` = `measurementScaleMarkerLength`, `measurementScaleMarkerDisplayText`, `measurementScaleMarkerTextPositionBottom` bool, `measurementScaleMarkerColor` text (`FFFFFF` / `000000`), `measurementScaleMarkerFont`, `measurementScaleMarkerFontSize` text ("4 pt") | the dialog is a bundled script; the step stores its options | +| Image > Analysis > Record Measurements | `recordMeasurements` | none | +| File > Close All | one `close` per document: `Svng` (`YsN ` `N ` when "don't save" was chosen), `DocI`, `forceNotify` | not a `closeAll` event | + +**Not recorded:** File > Close Others, Image > Variables > Define (the variables are kept, no step). + +**Recorded with source files** (2026-11-01): + +| Menu | Recorded step | Keys | +|---|---|---| +| Image > Apply Data Set | `apply`, `null` = `dataSetClass` **by name** | | +| File > Export > Data Sets as Files | `exportDataSet`, `null` = class `dataSetClass` | `All ` bool (all data sets), `T ` alias (folder), `fileNamingRules` (`fileNamingComponents` list of 7 incl. `None`; components as in Batch plus `underscore` and `dataSetName`; `Mcnt`, `Win `, `unix`) | +| File > Import > Variable Data Sets | `importDataSets`, `null` = class `dataSetClass` | `Usng` alias (the text file), `Encd` (`dataSetEncoding`: `dataSetEncodingAuto`, …), `eraseAll` bool (replace existing), `useFirstColumn` bool (first column = data set names) | +| File > Package | `packageFile` | `T ` alias (folder); writes " Folder" (localised) with the document and a `Links` folder | +| File > Import > Video Frames to Layers | `importVideoToLayers` | `Usng` alias (video), `makeAnimation` bool, `frameSkip` long (limit to every n-th frame; absent when off) | +| File > Import > Notes (Annotations) | `importAnnots` | `Usng` alias (PDF). A file without notes shows "No annotations were imported" and records nothing | +| Layer > Video Layers > New Video Layer from File | `addVideoLayer` | `null` alias (video) | +| Layer > Video Layers > Replace Footage | `replaceFootage` | `null` alias (video) | +| Layer > Video Layers > Duplicate Frame, Restore Frame, Restore All Frames, Reload Frame | `duplicateFrame`, `restoreFrame`, `restoreAllFrames`, `reloadFrame` | none. Duplicate needs a painted frame; Restore needs an altered frame; Restore All asks for confirmation | +| File > Automate > Photomerge | the steps the script runs (open dialog, open, duplicate, `addLayerFromFile`, `align` with `layerTransformation`, `mergeAlignedLayers` …), then the script event `0f9db13f-a772-4035-9020-840f0e5e2f02` with an empty descriptor (class `cjsp`) | the layout and options are not in the step | +| File > Automate > Crop and Straighten Photos | `CropPhotosAuto0001` (no keys), then the steps it runs: `CropPhotos0001` with `Vl ` (list of 8 doubles: the four corners), duplicate, `crop` | needs a selected, visible, unlocked layer, otherwise an error message and a step without effect | +| File > Automate > Conditional Mode Change | the Convert Mode it runs, then `8cba8cd6-cb66-11d1-bc43-0060b0a13dc4` | `Msge` text, `SrcM` list of `Cndn` enums (`UGry`, `URGB`, …: the source modes), `DstM` object whose class is the target mode (`CMYM` for CMYK) | +| File > Automate > Fit Image | the Image Size it runs, then `3caa3434-cb67-11d1-bc43-0060b0a13dc4` | `Msge` text, `Hght`, `Wdth` pixels, `limit` bool (don't enlarge) | + +**Not recorded:** importing or applying inside Image > Variables > Data Sets (only the separate menu +commands record). ## Measured through dialogs and recordings From ea326374b288972e3c20cf6d19d64ebacfbe00f0 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 1 Nov 2026 18:23:54 +0100 Subject: [PATCH 111/216] docs(atn): Convert Stroke to Fill is not a user command, nothing to map Co-Authored-By: Claude Opus 5.5 --- docs/atn.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/atn.md b/docs/atn.md index 56a0f14b09..01f938909b 100644 --- a/docs/atn.md +++ b/docs/atn.md @@ -381,8 +381,9 @@ the recorded steps back from the saved Actions panel. Ranges come from the dialo **Not recordable:** Edit > Purge (no step is recorded; this version's Purge menu has Clipboard, Histories, All and Video Cache, no Undo); File > Export > Export As and Quick Export as PNG (the file -is written, no step is recorded). Convert Stroke to Fill (`vectorStrokeToFill`) was not available in the measured version, even -on a shape layer with a stroke and its path component selected, and it is not in the path context menu. +is written, no step is recorded). `vectorStrokeToFill` (Convert Stroke to Fill) is not a user command: the reference application has +no menu entry or context-menu item for it, refuses the event even with a stroked path component selected, +and so never records it. No action can contain it; nothing to map. ### Batch From c345eef785696a4327e37662f0c94e696d8e89c9 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Sun, 1 Nov 2026 09:34:25 -0800 Subject: [PATCH 112/216] Validate ATN sizing and opacity and honor transform anchors --- crates/engine/src/cmd/atn_cmds/steps_doc.rs | 3 +- crates/engine/src/cmd/atn_cmds/steps_make.rs | 8 +- crates/engine/src/cmd/select_extra_cmds.rs | 2 +- crates/engine/src/cmd/transform_cmds.rs | 6 +- crates/engine/tests/atn_mapped_identifiers.rs | 115 ++++++++++++++++++ 5 files changed, 128 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/atn_cmds/steps_doc.rs b/crates/engine/src/cmd/atn_cmds/steps_doc.rs index eef5458108..68e019d5a0 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_doc.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_doc.rs @@ -441,6 +441,7 @@ pub(super) fn image_size_from(d: &Descriptor) -> Mapped { if physical { // A simultaneous dpi change or an ambiguous aspect choice needs its own reference form. if d.get("Rslt").is_some() + || !matches!(d.get("Intr"), Some(D::Enumerated { type_id, .. }) if type_id.is("Intp")) || !matches!(d.get("CnsP"), None | Some(D::Boolean(_))) || (get_bool(d, "CnsP") == Some(false) && (p.contains_key("width") != p.contains_key("height"))) || (get_bool(d, "CnsP") == Some(true) && p.contains_key("width") && p.contains_key("height")) @@ -515,7 +516,7 @@ pub(super) fn image_size_to(_: &str, p: &Value) -> Option { return None; } } - if unit == PT && !resolution_only && (p.get("resolution").is_some() || p.get("percent").is_some()) { + if unit == PT && !resolution_only && ["resolution", "percent"].iter().any(|key| p.get(*key).is_some_and(|value| !value.is_null())) { return None; } if resolution_only && jnum(p, "resolution").is_none() { diff --git a/crates/engine/src/cmd/atn_cmds/steps_make.rs b/crates/engine/src/cmd/atn_cmds/steps_make.rs index 4238affa8f..60de6fd260 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_make.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_make.rs @@ -102,7 +102,13 @@ fn new_document_from_descriptor(d: &Descriptor) -> Option { if let Some(n) = get_text(nw, "Nm ") { props.insert("name".into(), n.into()); } - if let Some(o) = get_num(nw, "Opct") { + if nw.get("Opct").is_some() { + if matches!(nw.get("Opct"), Some(D::UnitFloat { unit, .. }) if *unit != PCT) { + return Some(Mapped::Unsupported { harmless: false }); + } + let Some(o) = get_num(nw, "Opct").filter(|o| (0.0..=100.0).contains(o)) else { + return Some(Mapped::Unsupported { harmless: false }); + }; props.insert("opacity".into(), (o / 100.0).into()); } if let Some(m) = get_enum(nw, "Md ") { diff --git a/crates/engine/src/cmd/select_extra_cmds.rs b/crates/engine/src/cmd/select_extra_cmds.rs index 8ca012266d..5733847ed9 100644 --- a/crates/engine/src/cmd/select_extra_cmds.rs +++ b/crates/engine/src/cmd/select_extra_cmds.rs @@ -102,7 +102,7 @@ pub fn isolation_shows(doc: &Document, isolated: &[LayerId], id: LayerId) -> boo /// The point the scale / rotate / skew params pivot about: a named point of the frame `r` or /// `[x, y]`; missing (or null) is the centre. Anything else is an error, not a silent centre. -fn reference_point(cmd: &str, p: &Value, r: [f64; 4]) -> Result<[f64; 2]> { +pub(crate) fn reference_point(cmd: &str, p: &Value, r: [f64; 4]) -> Result<[f64; 2]> { let (x0, y0, x1, y1) = (r[0], r[1], r[2], r[3]); let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0); let unknown = || bad(cmd, "`reference` must be one of center|topLeft|top|topRight|left|right|bottomLeft|bottom|bottomRight or [x, y]"); diff --git a/crates/engine/src/cmd/transform_cmds.rs b/crates/engine/src/cmd/transform_cmds.rs index 520f61e506..9c29f9e3e0 100644 --- a/crates/engine/src/cmd/transform_cmds.rs +++ b/crates/engine/src/cmd/transform_cmds.rs @@ -740,7 +740,7 @@ pub(crate) fn composite_over(dst: &mut Surface, top: &Surface) { /// The relative form (`scaleX`, `scaleY` in percent, `rotate` in degrees clockwise, `dx`, `dy` /// in pixels), as Photoshop records a Free Transform: scale and rotate about the frame's -/// centre, then move. `None` when none of them is given. +/// named reference point (the centre by default), then move. `None` when none of them is given. fn relative_matrix(p: &Value, rect: [f64; 4]) -> Result> { let num = |k: &str| -> Result> { match p.get(k) { @@ -756,7 +756,7 @@ fn relative_matrix(p: &Value, rect: [f64; 4]) -> Result> { if sx.abs() < 1e-6 || sy.abs() < 1e-6 { return Err(bad("`scaleX` and `scaleY` must not be 0")); } - let (cx, cy) = ((rect[0] + rect[2]) / 2.0, (rect[1] + rect[3]) / 2.0); + let [cx, cy] = crate::select_extra_cmds::reference_point("edit.transform", p, rect)?; let (sin, cos) = rot.unwrap_or(0.0).to_radians().sin_cos(); // (x, y) → translate(-c) → scale → rotate → translate(c + d). let (a, b, c, d) = (sx * cos, sx * sin, -sy * sin, sy * cos); @@ -950,7 +950,7 @@ pub fn specs() -> Vec { label: "Free Transform", menu: &[], shortcut: None, - params: r##"{"layer":id?,"layers":[id,…]? (several layers moved by one box, one step; default with neither = the selected layers but the Background, as Photoshop's Free Transform with several selected),"rect":[x0,y0,x1,y1]? (source frame; default = the layers' content ∩ selection),"quad":[[x,y]×4]? (where the frame's corners go, clockwise from top-left),"matrix":[a,b,c,d,e,f]? (affine alternative),"scaleX":%?,"scaleY":%?,"rotate":deg?,"dx":px?,"dy":px? (relative alternative: scale and rotate about the frame's centre, then move, as a Photoshop action records a Free Transform),"interpolation":"bicubic|bilinear|nearest|bicubicSharper|bicubicSmoother|bicubicAutomatic"="bicubic" (Photoshop's bicubic variants render as Bicubic),"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target (an unlinked mask, an alpha channel or the Quick Mask transforms alone; a linked mask moves with its layer)}"##, + params: r##"{"layer":id?,"layers":[id,…]? (several layers moved by one box, one step; default with neither = the selected layers but the Background, as Photoshop's Free Transform with several selected),"rect":[x0,y0,x1,y1]? (source frame; default = the layers' content ∩ selection),"quad":[[x,y]×4]? (where the frame's corners go, clockwise from top-left),"matrix":[a,b,c,d,e,f]? (affine alternative),"scaleX":%?,"scaleY":%?,"rotate":deg?,"dx":px?,"dy":px?,"reference":"center|topLeft|top|topRight|left|right|bottomLeft|bottom|bottomRight"|[x,y]="center" (relative alternative: scale and rotate about this point, then move),"interpolation":"bicubic|bilinear|nearest|bicubicSharper|bicubicSmoother|bicubicAutomatic"="bicubic" (Photoshop's bicubic variants render as Bicubic),"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target (an unlinked mask, an alpha channel or the Quick Mask transforms alone; a linked mask moves with its layer)}"##, enabled: has_layer, journal: true, run: transform, diff --git a/crates/engine/tests/atn_mapped_identifiers.rs b/crates/engine/tests/atn_mapped_identifiers.rs index bbb1565ea5..1154693161 100644 --- a/crates/engine/tests/atn_mapped_identifiers.rs +++ b/crates/engine/tests/atn_mapped_identifiers.rs @@ -59,6 +59,121 @@ fn recorded_canvas_percent_resolves_at_playback_and_keeps_an_omitted_side() { } } +/// A missing resampler means print-size editing, which the native point-size resize cannot express. +#[test] +fn review_point_resize_without_interpolation_stays_kept() { + for key in ["Wdth", "Hght"] { + for interpolation in [None, Some(D::Boolean(false)), Some(enm("wrong", "Nrst"))] { + let mut d = Descriptor::new("null").with(key, D::UnitFloat { unit: *b"#Rlt", value: 72.0 }); + if let Some(value) = interpolation { + d = d.with("Intr", value); + } + let action = recorded(AtnEvent::Code(*b"ImgS"), d.clone()); + assert_eq!(action.steps[0].0, atn_cmds::PHOTOSHOP_STEP, "point sizes without a valid Intr must stay unsupported"); + assert_eq!(saved(&action).descriptor, Some(d)); + } + } +} + +/// Null optional values have the same export semantics as omitted values. +#[test] +fn review_point_resize_exports_with_null_optional_values() { + for extras in [json!({"resolution":null}), json!({"percent":null}), json!({"resolution":null,"percent":null})] { + let mut params = json!({"width":72,"units":"pt","resample":"nearest","scaleStyles":false}); + params.as_object_mut().unwrap().extend(extras.as_object().unwrap().clone()); + let action = Action::new("Recorded", vec![("image.imageSize".into(), params)]); + let (bytes, skipped) = atn_cmds::export("Points", &[&action], &[], false).expect("null values must not prevent point-size export"); + assert!(skipped.is_empty()); + let imported = atn_cmds::import(&bytes).unwrap(); + assert!(imported.unsupported.is_empty()); + assert_eq!(imported.actions[0].steps[0].1, json!({"width":72,"units":"pt","resample":"nearest","scaleStyles":false})); + } +} + +/// Invalid metadata must keep the whole Copy/Cut event, before a layer or source pixels can change. +#[test] +fn review_copy_cut_opacity_is_validated_before_any_step_runs() { + for copy in [true, false] { + for opacity in [D::Double(-1.0), D::Double(101.0), D::Double(f64::NAN), D::Boolean(true), D::UnitFloat { unit: *b"#Pxl", value: 50.0 }] { + let d = Descriptor::new("null") + .with("Nw ", D::Descriptor(Descriptor::new("Lyr ").with("Opct", opacity))) + .with("Usng", enm("UsrM", "Slct")) + .with("Cpy ", D::Boolean(copy)); + let action = recorded(AtnEvent::Code(*b"Mk "), d); + assert_eq!(action.steps[0].0, atn_cmds::PHOTOSHOP_STEP, "invalid opacity must keep Copy/Cut before mutation"); + let mut s = sized_document(8, 6, 72); + s.execute("select.rect", json!({"x":0,"y":0,"width":2,"height":2})).unwrap(); + let before = s.active().unwrap().doc.clone(); + s.actions.list = vec![action]; + let result = s.execute("actions.play", json!({"action":"Recorded"})).unwrap(); + assert!(result.get("failed").is_some()); + assert_eq!(s.active().unwrap().doc, before); + } + for opacity in [0.0, 50.0, 100.0] { + let d = Descriptor::new("null") + .with("Nw ", D::Descriptor(Descriptor::new("Lyr ").with("Opct", D::Double(opacity)))) + .with("Usng", enm("UsrM", "Slct")) + .with("Cpy ", D::Boolean(copy)); + let action = recorded(AtnEvent::Code(*b"Mk "), d); + assert!(action.steps.iter().all(|(id, _)| id != atn_cmds::PHOTOSHOP_STEP)); + assert_eq!(action.steps.iter().find(|(id, _)| id == "layer.setProps").unwrap().1["opacity"], opacity / 100.0); + } + } +} + +/// Every named layer anchor must affect actual playback geometry, not just the saved parameters. +#[test] +fn review_layer_transform_uses_the_recorded_anchor() { + for depth in [8, 16, 32] { + for (code, fx, fy) in + [("Qcs0", 0, 0), ("Qcs1", 1, 0), ("Qcs2", 2, 0), ("Qcs3", 0, 1), ("Qcs4", 1, 1), ("Qcs5", 2, 1), ("Qcs6", 0, 2), ("Qcs7", 1, 2), ("Qcs8", 2, 2)] + { + let d = Descriptor::new("null") + .with("FTcs", enm("QCSt", code)) + .with( + "Ofst", + D::Descriptor( + Descriptor::new("Ofst") + .with("Hrzn", D::UnitFloat { unit: *b"#Pxl", value: 0.0 }) + .with("Vrtc", D::UnitFloat { unit: *b"#Pxl", value: 0.0 }), + ), + ) + .with("Wdth", D::UnitFloat { unit: *b"#Prc", value: 50.0 }) + .with("Hght", D::UnitFloat { unit: *b"#Prc", value: 50.0 }) + .with("Intr", enm("Intp", "Nrst")); + let action = recorded(AtnEvent::Code(*b"Trnf"), d); + let mut s = Session::new(); + s.execute("file.new", json!({"width":64,"height":64,"depth":depth})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.edit("Mark", |doc, active| { + doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(20, 20, 40, 40), &[1.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + replay_saved(&mut s, &action); + let state = s.active().unwrap(); + assert_eq!( + state.doc.layer(state.active_layer.unwrap()).unwrap().surface().unwrap().content_bounds(), + photocraft_geom::Rect::new(20 + 5 * fx, 20 + 5 * fy, 30 + 5 * fx, 30 + 5 * fy), + "{code}, depth {depth}" + ); + s.execute("edit.undo", json!({})).unwrap(); + if code == "Qcs0" { + s.execute("edit.transform", json!({"rotate":90,"reference":"topLeft","interpolation":"nearest"})).unwrap(); + let state = s.active().unwrap(); + assert_eq!( + state.doc.layer(state.active_layer.unwrap()).unwrap().surface().unwrap().content_bounds(), + photocraft_geom::Rect::new(0, 20, 20, 40) + ); + s.execute("edit.undo", json!({})).unwrap(); + } + let before = s.active().unwrap().doc.clone(); + assert!(s.execute("edit.transform", json!({"scaleX":50,"reference":"unknown"})).is_err()); + assert_eq!(s.active().unwrap().doc, before); + } + } +} + /// Physical dimensions retain points and resolve current dpi before rounding or style scaling. #[test] fn recorded_image_points_use_current_resolution_and_keep_style_scaling() { From 46fb7822c9152186007ef10e02c490f5d6d83af1 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Sun, 1 Nov 2026 09:35:13 -0800 Subject: [PATCH 113/216] Support source-backed SVG Paste modes and ATN classes Retain bounded original SVG alongside its raster mirror. Explicit Pixels, Path and Shape modes use real source data, validate destination and workload limits before editing, and preserve legacy Paste when the mode is omitted. Round-trip typed Photoshop Paste classes and keep unsupported descriptors. Adapted-from: Skyfuller1303's PR #3409 Original-commit: 897f20072762fdf0eb688adcdb8d8cda7b38ce66 Original-source: https://github.com/storytold/photocraft/pull/3409 Validation: engine check --tests; strict lib and vector target Clippy; 7 focused native/ATN tests; owned-file formatting and diff checks. --- crates/engine/src/channel_clip.rs | 3 +- crates/engine/src/cmd.rs | 1 + crates/engine/src/cmd/atn_cmds/steps_doc.rs | 33 +- crates/engine/src/cmd/edit_cmds.rs | 39 ++- crates/engine/src/cmd/edit_cmds/tests.rs | 10 +- crates/engine/src/cmd/vector_clipboard.rs | 236 ++++++++++++++ crates/engine/tests/vector_paste_options.rs | 335 ++++++++++++++++++++ 7 files changed, 643 insertions(+), 14 deletions(-) create mode 100644 crates/engine/src/cmd/vector_clipboard.rs create mode 100644 crates/engine/tests/vector_paste_options.rs diff --git a/crates/engine/src/channel_clip.rs b/crates/engine/src/channel_clip.rs index 06ab713420..3b64b10ca2 100644 --- a/crates/engine/src/channel_clip.rs +++ b/crates/engine/src/channel_clip.rs @@ -85,7 +85,7 @@ pub(crate) fn copy(s: &mut Session, p: &Value) -> Result> { if bounds.is_empty() { return Err(EngineError::Other("Could not copy: the selected area is empty".into())); } - s.clipboard = Some(Clip { components: None, surface, bounds, layers: None, resolution: Some(doc.resolution_dpi), source: Some(doc.id) }); + s.clipboard = Some(Clip { svg: None, components: None, surface, bounds, layers: None, resolution: Some(doc.resolution_dpi), source: Some(doc.id) }); Ok(Some(json!({"bounds": [bounds.x0, bounds.y0, bounds.width(), bounds.height()], "channel": k}))) } @@ -190,6 +190,7 @@ pub(crate) fn copy_colors(s: &mut Session, p: &Value, colors: crate::color_chann surface.try_write_region(r, &out)?; } s.clipboard = Some(Clip { + svg: None, surface, bounds: area, layers: None, diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 5d5801acc6..2f2a397f46 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -152,6 +152,7 @@ pub mod type_spell_cmds; pub mod type_styles_cmds; pub mod updates_cmds; pub(super) mod variables_cmds; +mod vector_clipboard; pub mod vector_cmds; pub(super) mod video_cmds; pub mod vp_cmds; diff --git a/crates/engine/src/cmd/atn_cmds/steps_doc.rs b/crates/engine/src/cmd/atn_cmds/steps_doc.rs index f8300cb32e..90eb5625c8 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_doc.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_doc.rs @@ -475,11 +475,42 @@ pub(super) fn stop_to(_: &str, p: &Value) -> Option { } pub(super) fn paste_from(d: &Descriptor) -> Mapped { + // As is a class, not text or an enum; unknown forms must not fall back to raster paste. + if let Some(value) = d.get("As ") { + let mode = match value { + D::Class(value) if value.class_id.is("Pxel") => "pixels", + D::Class(value) if value.class_id.is("Path") => "path", + D::Class(value) if value.class_id.is("shapeClass") => "shape", + _ => return Mapped::Unsupported { harmless: false }, + }; + if !matches!(d.get("inPlace"), None | Some(D::Boolean(false))) || d.get("null").is_some() { + return Mapped::Unsupported { harmless: false }; + } + if d.get("AntA").is_some_and(|v| !matches!(v, D::Enumerated { type_id, value } if type_id.is("Annt") && value.is("Anno"))) { + return Mapped::Unsupported { harmless: false }; + } + return step("edit.paste", json!({"as": mode})); + } none(if get_bool(d, "inPlace") == Some(true) { "edit.pasteSpecial.pasteInPlace" } else { "edit.paste" }) } -pub(super) fn paste_to(id: &str, _: &Value) -> Option { +pub(super) fn paste_to(id: &str, p: &Value) -> Option { let mut d = desc("null"); + if let Some(mode) = p.get("as") { + if id != "edit.paste" + || ["center", "inPlace", "clampToCanvas", "document", "layer", "layers", "path"].iter().any(|key| p.get(key).is_some()) + || p.get("target").is_some_and(|v| v.as_str() != Some("pixels")) + { + return None; + } + let id = match mode.as_str()? { + "pixels" => "Pxel", + "path" => "Path", + "shape" => "shapeClass", + _ => return None, + }; + d = d.with("As ", D::Class(class(id))); + } if id == "edit.pasteSpecial.pasteInPlace" { d = d.with("inPlace", D::Boolean(true)); } diff --git a/crates/engine/src/cmd/edit_cmds.rs b/crates/engine/src/cmd/edit_cmds.rs index db4f16309c..fb3cc944f6 100644 --- a/crates/engine/src/cmd/edit_cmds.rs +++ b/crates/engine/src/cmd/edit_cmds.rs @@ -22,6 +22,8 @@ pub(crate) use paste_target; /// Pixels on the session clipboard, with where they came from (for Paste in Place). #[derive(Clone, Debug)] pub struct Clip { + /// Original SVG snapshot, when available. The raster mirror belongs to the same source. + pub svg: Option>>, /// Native colour indices for a multi-component clip, in channel order. Singletons are grayscale. pub components: Option>, pub surface: Surface, @@ -38,6 +40,13 @@ pub struct Clip { pub source: Option, } +impl Clip { + /// Prepare bounded original SVG and its raster mirror before installing the clipboard. + pub fn from_svg(bytes: &[u8]) -> Result { + super::vector_clipboard::from_svg(bytes) + } +} + /// Photoshop's layer-based copy (CC 2018 on): with no selection, Copy takes the active layer /// whole when it is a smart object (#2428), a type or shape layer, or a group (its whole subtree) /// (#2670), so Paste recreates it instead of its pixels. Fill and adjustment layers have no @@ -200,7 +209,7 @@ fn lift(src: &Surface, sel: Option<&Surface>, canvas: Rect) -> Result { }; let mut out = Surface::new(with_alpha); if area.is_empty() { - return Ok(Clip { components: None, surface: out, bounds: Rect::EMPTY, layers: None, resolution: None, source: None }); + return Ok(Clip { svg: None, components: None, surface: out, bounds: Rect::EMPTY, layers: None, resolution: None, source: None }); } // Only the selected area is read: converting the whole layer (a 24 MP Background gaining // alpha) cost about a second per Layer via Copy (#668). @@ -222,7 +231,7 @@ fn lift(src: &Surface, sel: Option<&Surface>, canvas: Rect) -> Result { out.put_tiles(shared); out.prune(); let bounds = out.content_bounds(); - Ok(Clip { components: None, surface: out, bounds, layers: None, resolution: None, source: None }) + Ok(Clip { svg: None, components: None, surface: out, bounds, layers: None, resolution: None, source: None }) } /// [`lift`] over one rectangle: `src`'s pixels with alpha (`n` channels), the selection @@ -351,7 +360,7 @@ fn copy_mask(s: &mut Session, filter: bool) -> Result { } let b = bounds; let (resolution, source) = (Some(d.doc.resolution_dpi), Some(d.doc.id)); - s.clipboard = Some(Clip { components: None, surface, bounds: b, layers: None, resolution, source }); + s.clipboard = Some(Clip { svg: None, components: None, surface, bounds: b, layers: None, resolution, source }); Ok(json!({"bounds": [b.x0, b.y0, b.width(), b.height()], "target": if filter { "filterMask" } else { "mask" }})) } @@ -389,26 +398,40 @@ pub(crate) fn clear_area(doc: &mut Document, id: LayerId, area: Rect, sel: Optio /// Paste as a new layer (or into the targeted mask or channel: [`paste_to_target`]). `in_place` keeps the original position; otherwise the pixels are centred /// on `center` (the view centre from the UI) or the canvas, unless they already overlap the canvas. fn paste(s: &mut Session, p: &Value, in_place: bool) -> Result { + let mode = super::vector_clipboard::mode(p)?; + if let Some(mode) = mode { + super::vector_clipboard::validate(s, p, in_place)?; + if mode != super::vector_clipboard::Mode::Pixels || s.clipboard.as_ref().is_some_and(|clip| clip.svg.is_some()) { + return super::vector_clipboard::paste(s, p, mode); + } + super::vector_clipboard::layer_target(s)?; + } crate::allocation::checkpoint("pasting pixels")?; let clip = s.clipboard.clone().ok_or(EngineError::Other("the clipboard is empty".into()))?; let Some(d) = s.active() else { // Nothing open to paste into: the clipboard becomes a document of its own (#368). return new_from_clipboard(s); }; - if crate::channel_cmds::target_of(p) != crate::channel_cmds::Target::Pixels { + if mode.is_none() && crate::channel_cmds::target_of(p) != crate::channel_cmds::Target::Pixels { return paste_to_target(s, p, in_place, None, "Paste"); } - if let Some(colors) = crate::color_channels::target(s, p)?.filter(|c| c.len() > 1 || p.get("target").is_some() || clip.components.is_some()) { + if mode.is_none() + && let Some(colors) = crate::color_channels::target(s, p)?.filter(|c| c.len() > 1 || p.get("target").is_some() || clip.components.is_some()) + { return crate::channel_clip::paste_colors(s, p, colors, in_place, None, "Paste"); } // A targeted colour channel takes the paste instead of a new layer (#2307). - if let Some(k) = crate::channel_clip::paste_color_target(s, p) { + if mode.is_none() + && let Some(k) = crate::channel_clip::paste_color_target(s, p) + { return crate::channel_clip::paste(s, p, k, in_place, None, "Paste"); } let canvas = d.doc.bounds(); let fmt = d.doc.pixel_format(); let (dx, dy) = paste_offset(&clip, d.doc.id, canvas, p, in_place); - if let Some(layers) = clip.layers.as_deref().filter(|_| crate::layer_copy_cmds::layered(d.doc.mode)) { + if mode.is_none() + && let Some(layers) = clip.layers.as_deref().filter(|_| crate::layer_copy_cmds::layered(d.doc.mode)) + { return paste_layers(s, layers, dx, dy); } let moved = shifted(&clip.surface, dx, dy); @@ -1097,7 +1120,7 @@ pub fn specs() -> Vec { &["Edit"], Some("Cmd+V"), concat!( - r#"{"center":[x,y]? (view centre; default keeps the position when it overlaps the canvas),"clampToCanvas":bool=true (keep the paste on the canvas, or at its top-left when larger),"#, + r#"{"as":"pixels|path|shape"? (explicit mode: active layered document/composite only; SVG canvas is centred, Path/Shape require retained vector source),"center":[x,y]? (finite explicit coordinates within ±10 million; default keeps raster position when it overlaps the canvas),"clampToCanvas":bool=true (legacy mode only; keep the paste on the canvas, or at its top-left when larger),"#, paste_target!(), "} (with no document open: a new document from the clipboard)" ), diff --git a/crates/engine/src/cmd/edit_cmds/tests.rs b/crates/engine/src/cmd/edit_cmds/tests.rs index 33f2282e3d..58b92ae779 100644 --- a/crates/engine/src/cmd/edit_cmds/tests.rs +++ b/crates/engine/src/cmd/edit_cmds/tests.rs @@ -17,7 +17,7 @@ fn new_clipboard_document_rejects_an_unrepresentable_origin() { let mut s = Session::new(); let mut surface = Surface::new(PixelFormat::RGBA8); surface.write_pixel(i32::MIN, 0, &[1.0, 0.0, 0.0, 1.0]); - s.clipboard = Some(Clip { components: None, surface, bounds: Rect::from_xywh(i32::MIN, 0, 1, 1), layers: None, resolution: None, source: None }); + s.clipboard = Some(Clip { svg: None, components: None, surface, bounds: Rect::from_xywh(i32::MIN, 0, 1, 1), layers: None, resolution: None, source: None }); let result = new_from_clipboard(&mut s); assert!(result.unwrap_err().to_string().contains("clipboard origin")); assert!(s.execute("file.newFromClipboard", json!({})).is_err()); @@ -51,6 +51,7 @@ fn paste_centres_pixels_from_another_document() { fn paste_centers_clipboard_bounds_near_coordinate_limits() { for origin in [i32::MAX - 512, i32::MIN + 512] { let clip = Clip { + svg: None, components: None, surface: Surface::new(PixelFormat::RGBA8), bounds: Rect::from_xywh(origin, origin, 2, 2), @@ -239,7 +240,7 @@ fn external_clip_session(w: i32, h: i32, cw: i32, ch: i32) -> Session { s.execute("file.new", json!({"width": w, "height": h})).unwrap(); let mut surface = Surface::new(PixelFormat::RGBA8); surface.fill_rect(Rect::new(0, 0, cw, ch), &[1.0, 0.0, 0.0, 1.0]); - s.clipboard = Some(Clip { components: None, surface, bounds: Rect::new(0, 0, cw, ch), layers: None, resolution: None, source: None }); + s.clipboard = Some(Clip { svg: None, components: None, surface, bounds: Rect::new(0, 0, cw, ch), layers: None, resolution: None, source: None }); s } @@ -296,6 +297,7 @@ fn paste_offset_clamp_survives_extreme_coordinates() { assert_eq!(clamp_shift(f64::NEG_INFINITY, i32::MIN, i32::MAX, 0, 1), i32::MAX); assert_eq!(clamp_shift(1e30, i32::MIN, i32::MIN + 1, i32::MAX - 1, i32::MAX), i32::MAX); let clip = Clip { + svg: None, components: None, surface: Surface::new(PixelFormat::RGBA8), bounds: Rect::new(i32::MAX - 2, i32::MAX - 2, i32::MAX, i32::MAX), @@ -1451,13 +1453,13 @@ fn lift_reference(src: &Surface, sel: Option<&Surface>, canvas: Rect) -> Clip { }; let mut out = Surface::new(with_alpha); if area.is_empty() { - return Clip { components: None, surface: out, bounds: Rect::EMPTY, layers: None, resolution: None, source: None }; + return Clip { svg: None, components: None, surface: out, bounds: Rect::EMPTY, layers: None, resolution: None, source: None }; } let px = lift_region(src, sel, area, with_alpha.channels()).unwrap(); out.try_write_region(area, &px).unwrap(); out.prune(); let bounds = out.content_bounds(); - Clip { components: None, surface: out, bounds, layers: None, resolution: None, source: None } + Clip { svg: None, components: None, surface: out, bounds, layers: None, resolution: None, source: None } } fn noise(fmt: PixelFormat, r: Rect, seed: u32) -> Surface { diff --git a/crates/engine/src/cmd/vector_clipboard.rs b/crates/engine/src/cmd/vector_clipboard.rs new file mode 100644 index 0000000000..2a61f8b5e3 --- /dev/null +++ b/crates/engine/src/cmd/vector_clipboard.rs @@ -0,0 +1,236 @@ +//! Source-backed Paste modes. Preparation and geometry conversion adapt Skyfuller1303's +//! PR #3409, commit 897f20072762fdf0eb688adcdb8d8cda7b38ce66, to the current clipboard API. + +use std::sync::Arc; + +use photocraft_color::{BlendMode, ColorMode, PixelFormat, SampleType}; +use photocraft_doc::{Document, Layer, LayerContent, NamedPath, Path}; +use photocraft_geom::Affine; +use serde_json::{Value, json}; + +use super::edit_cmds::Clip; +use crate::{EngineError, Result, Session}; + +const MAX_SOURCE_BYTES: usize = 16 * 1024 * 1024; +const MAX_CACHE_PIXELS: u64 = 24_000_000; + +#[derive(Clone, Copy, PartialEq, Eq)] +pub(super) enum Mode { + Pixels, + Path, + Shape, +} + +fn bad(message: &str) -> EngineError { + EngineError::BadParams { cmd: "edit.paste".into(), msg: message.into() } +} + +/// Omission alone selects the old command routes; malformed explicit modes must not disappear. +pub(super) fn mode(p: &Value) -> Result> { + match p.get("as") { + None => Ok(None), + Some(Value::String(value)) => match value.as_str() { + "pixels" => Ok(Some(Mode::Pixels)), + "path" => Ok(Some(Mode::Path)), + "shape" => Ok(Some(Mode::Shape)), + _ => Err(bad("`as` must be pixels, path or shape")), + }, + Some(_) => Err(bad("`as` must be pixels, path or shape")), + } +} + +/// Imported data is temporary; only original bytes and their raster mirror survive in Clip. +fn prepare(bytes: &[u8]) -> Result<(Document, Vec)> { + if bytes.is_empty() || bytes.len() > MAX_SOURCE_BYTES || !photocraft_io::svg::is_svg(bytes) { + return Err(bad("SVG clipboard source must be valid markup between 1 byte and 16 MiB")); + } + let mut imported = photocraft_io::svg::import_svg_bounded("Clipboard.svg", bytes, MAX_CACHE_PIXELS) + .map_err(|e| EngineError::Other(format!("can't import clipboard SVG: {e}")))?; + if imported.document.layers.is_empty() { + return Err(bad("the SVG contains no drawable objects")); + } + imported.document.icc_profile = Some(photocraft_cms::Builtin::Srgb.profile().to_bytes()); + Ok((imported.document, imported.warnings)) +} + +pub(super) fn from_svg(bytes: &[u8]) -> Result { + let _ = prepare(bytes)?; + let image = crate::smart_cmds::svg_source_image(bytes, PixelFormat::RGBA32F, 1.0)?; + let mut owned = crate::allocation::capacity(bytes.len(), "copying SVG clipboard source")?; + owned.extend_from_slice(bytes); + Ok(Clip { + svg: Some(Arc::new(owned)), + components: None, + surface: (*image.surface).clone(), + bounds: image.bounds, + layers: None, + resolution: None, + source: None, + }) +} + +/// Explicit modes do not fall through to new-document, mask, channel or in-place operations. +pub(super) fn validate(s: &Session, p: &Value, in_place: bool) -> Result<[f64; 2]> { + if in_place || ["inPlace", "clampToCanvas", "document", "layer", "layers", "path"].iter().any(|key| p.get(key).is_some()) { + return Err(bad("explicit Paste modes do not support these placement or target options")); + } + if p.get("target").is_some_and(|v| v.as_str() != Some("pixels")) { + return Err(bad("explicit Paste requires the layer composite target")); + } + let state = s.active().ok_or(EngineError::NoDocument)?; + if s.tools.mask_targeted + || state.doc.quick_mask.is_some() + || state.channel_view.layer_mask.is_some() + || state.channel_view.target != crate::channel_cmds::ChannelTarget::Composite + || !crate::layer_copy_cmds::layered(state.doc.mode) + { + return Err(bad("explicit Paste requires a layered document and the layer composite target")); + } + if s.job_on(state.doc.id).is_some() { + return Err(bad("the destination has a running background job")); + } + match p.get("center") { + None => Ok([f64::from(state.doc.size.width) / 2.0, f64::from(state.doc.size.height) / 2.0]), + Some(Value::Array(a)) if a.len() == 2 => { + let x = a.first().and_then(Value::as_f64); + let y = a.get(1).and_then(Value::as_f64); + match (x, y) { + (Some(x), Some(y)) if x.is_finite() && y.is_finite() && x.abs() <= 1e7 && y.abs() <= 1e7 => Ok([x, y]), + _ => Err(bad("`center` must contain two finite coordinates within ±10 million pixels")), + } + } + Some(_) => Err(bad("`center` must contain two finite coordinates within ±10 million pixels")), + } +} + +/// Extracting a child must not discard a clipping mask or a group's nonneutral composition. +fn neutral_group(layer: &Layer) -> bool { + layer.opacity == 1.0 + && layer.fill_opacity == 1.0 + && layer.mask.is_none() + && layer.vector_mask.is_none() + && layer.effects.items.is_empty() + && !layer.clipped + && layer.advanced.is_default() + && layer.blend_if.is_default() + && layer.excluded_channels == 0 + && matches!(layer.blend, BlendMode::Normal | BlendMode::PassThrough) +} + +fn paths(doc: &Document) -> Result { + let mut result: Option = None; + for (_, _, layer) in doc.walk() { + match &layer.content { + LayerContent::Group(_) if neutral_group(layer) => {} + LayerContent::Shape(shape) if layer.mask.is_none() && layer.vector_mask.is_none() && !layer.clipped => { + if let Some(path) = &mut result { + if path.fill_rule != shape.path.fill_rule || path.inverted != shape.path.inverted { + return Err(bad("the SVG paths have incompatible fill rules")); + } + path.subpaths.extend(shape.path.subpaths.clone()); + } else { + result = Some(shape.path.clone()); + } + } + _ => return Err(bad("the SVG contains rasterized or clipped objects without a supported editable path")), + } + } + result.filter(|p| !p.is_empty()).ok_or_else(|| bad("the SVG contains no editable path")) +} + +fn shape(doc: &Document) -> Result<&Layer> { + let mut found = None; + for (_, _, layer) in doc.walk() { + match &layer.content { + LayerContent::Group(_) if neutral_group(layer) => {} + LayerContent::Shape(_) if found.is_none() && layer.mask.is_none() && layer.vector_mask.is_none() && !layer.clipped => found = Some(layer), + _ => return Err(bad("Shape requires one editable shape without clipping or group blending")), + } + } + found.ok_or_else(|| bad("the SVG contains no editable shape")) +} + +/// Preflight only the destination caches that adaptation/translation will actually render. +fn shape_budget(layer: &Layer, destination: &Document, dx: i32, dy: i32) -> Result<()> { + let LayerContent::Shape(shape) = &layer.content else { + return Err(bad("the source is not an editable shape")); + }; + // Adaptation can refresh at the source position before translation refreshes the final cache. + for transform in [Affine::IDENTITY, Affine::translate(f64::from(dx), f64::from(dy))] { + let mut shape = shape.clone(); + crate::vector_cmds::transform_shape(&mut shape, &transform); + photocraft_vector::flatten::validate_shape(&shape.path, shape.stroke.as_ref()).map_err(bad)?; + let compiled = photocraft_vector::CompiledShape::new(&shape, photocraft_vector::DEFAULT_TOLERANCE, destination.bounds()); + if compiled.bounds().is_some_and(|r| r.intersect(&destination.bounds()).size().area() > MAX_CACHE_PIXELS) { + return Err(bad("the pasted shape cache exceeds 24 megapixels; use Pixels")); + } + } + Ok(()) +} + +/// Editable insertions may bypass a locked active group, but cannot modify a locked ancestor. +pub(super) fn layer_target(s: &Session) -> Result<()> { + let state = s.active().ok_or(EngineError::NoDocument)?; + let parent = state + .active_layer + .and_then(|id| state.doc.path_of(id)) + .map(|mut path| { + path.pop(); + path + }) + .unwrap_or_default(); + let locks = state.doc.locks_at(&parent); + if locks.all || locks.pixels || locks.position || locks.transparency { + return Err(bad("the destination group is locked")); + } + Ok(()) +} + +/// Prepare all fallible work before a single insertion, retaining the original clipboard source. +pub(super) fn paste(s: &mut Session, p: &Value, mode: Mode) -> Result { + let center = validate(s, p, false)?; + let bytes = s.clipboard.as_ref().and_then(|clip| clip.svg.clone()).ok_or_else(|| bad("this Paste mode requires original vector clipboard source"))?; + let (mut copies, warnings) = prepare(&bytes)?; + let state = s.active().ok_or(EngineError::NoDocument)?; + let destination = state.doc.clone(); + let (dx, dy) = ((center[0] - f64::from(copies.size.width) / 2.0).round(), (center[1] - f64::from(copies.size.height) / 2.0).round()); + if !dx.is_finite() || !dy.is_finite() || dx < f64::from(i32::MIN) || dx > f64::from(i32::MAX) || dy < f64::from(i32::MIN) || dy > f64::from(i32::MAX) { + return Err(bad("the paste offset is not addressable")); + } + let (dx, dy) = (dx as i32, dy as i32); + if mode == Mode::Path { + let path = paths(&copies)?.transform(&Affine::translate(f64::from(dx), f64::from(dy))); + photocraft_vector::flatten::validate_path(&path).map_err(bad)?; + let name = (1..=destination.paths.len().saturating_add(1)) + .map(|i| format!("Pasted Path {i}")) + .find(|name| !destination.paths.iter().any(|p| &p.name == name)) + .ok_or_else(|| bad("no free path name"))?; + s.edit("Paste Path", |doc, _| { + doc.paths.push(NamedPath { name: name.clone(), path, psd_raw: None }); + Ok(()) + })?; + return Ok(json!({"as":"path","path":name,"offset":[dx,dy],"warnings":warnings})); + } + layer_target(s)?; + copies.layers = if mode == Mode::Shape { + let layer = shape(&copies)?; + shape_budget(layer, &destination, dx, dy)?; + vec![layer.duplicate()] + } else { + let image = crate::smart_cmds::svg_source_image(&bytes, PixelFormat::new(ColorMode::Rgb, SampleType::F32, true), 1.0)?; + copies.depth = SampleType::F32; + vec![Layer::new("Pasted SVG", LayerContent::Raster((*image.surface).clone()))] + }; + crate::layer_copy_cmds::adapt_copies(s, &mut copies, &destination)?; + crate::layer_copy_cmds::translate_copies(&mut copies, &destination, dx, dy); + let layer = copies.layers.pop().ok_or_else(|| bad("the SVG contains no paste output"))?; + let id = s.edit("Paste", |doc, active| { + let id = crate::layer_place::insert_new(doc, *active, layer, crate::layer_place::Place::AutoEditable); + *active = Some(id); + if mode == Mode::Pixels { + doc.selection = None; + } + Ok(id) + })?; + Ok(json!({"as":if mode==Mode::Shape{"shape"}else{"pixels"},"layer":id.0,"offset":[dx,dy],"warnings":warnings})) +} diff --git a/crates/engine/tests/vector_paste_options.rs b/crates/engine/tests/vector_paste_options.rs new file mode 100644 index 0000000000..8f22954bbf --- /dev/null +++ b/crates/engine/tests/vector_paste_options.rs @@ -0,0 +1,335 @@ +//! Explicit Paste modes preserve source kind instead of silently using the ordinary raster route. + +use photocraft_engine::Session; +use serde_json::json; + +/// This uses only the original public command API, so the pre-fix failure is executable. +#[test] +fn raster_clip_cannot_silently_paste_as_path() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 6, "background": "white"})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + let before = s.active().unwrap().doc.clone(); + let history = s.active().unwrap().history.past_len(); + assert!(s.execute("edit.paste", json!({"as": "path"})).is_err(), "a raster-only clipboard must refuse Paste As Path"); + assert_eq!(s.active().unwrap().doc, before); + assert_eq!(s.active().unwrap().history.past_len(), history); +} + +use std::sync::Arc; + +use photocraft_color::{ColorMode, SampleType}; +use photocraft_doc::{Fill, Layer, LayerContent, Path, Subpath}; +use photocraft_engine::{actions_cmds::Action, atn_cmds, edit_cmds::Clip}; +use photocraft_geom::{Point, Size}; +use photocraft_psd::{ + atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}, + descriptor::{Class, Descriptor, Id, UnicodeString, Value as D}, +}; +use serde_json::Value; + +const SVG: &[u8] = br##""##; + +fn scene(depth: u32) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 20, "height": 16, "depth": depth, "background": "transparent"})).unwrap(); + s.clipboard = Some(Clip::from_svg(SVG).unwrap()); + s +} + +fn output(s: &Session) -> &Layer { + let state = s.active().unwrap(); + state.doc.layer(state.active_layer.unwrap()).unwrap() +} + +fn source(s: &Session) -> &Arc> { + s.clipboard.as_ref().unwrap().svg.as_ref().unwrap() +} + +fn assert_curve(path: &Path) { + assert!( + path.subpaths.iter().flat_map(|p| &p.knots).any(|k| k.anchor == Point::new(6.0, 5.0) && k.out_ctrl == Point::new(7.0, 4.0)), + "original cubic controls must survive canvas-centred placement" + ); +} + +/// Refusal keeps document, selection, revision, redo, and the immutable clipboard source. +fn rejected(s: &mut Session, p: Value) -> String { + let state = s.active().unwrap(); + let before = state.doc.clone(); + let revision = state.revision; + let selection = state.selected_layers.clone(); + let active = state.active_layer; + let history = (state.history.past_len(), state.history.redo_label().map(str::to_owned)); + let clip = s.clipboard.clone().unwrap(); + let error = s.execute("edit.paste", p).unwrap_err().to_string(); + let state = s.active().unwrap(); + assert_eq!(state.doc, before); + assert_eq!(state.revision, revision); + assert_eq!(state.selected_layers, selection); + assert_eq!(state.active_layer, active); + assert_eq!((state.history.past_len(), state.history.redo_label().map(str::to_owned)), history); + let after = s.clipboard.as_ref().unwrap(); + assert_eq!(after.surface, clip.surface); + assert_eq!(after.bounds, clip.bounds); + match (&after.svg, &clip.svg) { + (Some(a), Some(b)) => assert!(Arc::ptr_eq(a, b)), + (None, None) => {} + _ => panic!("refusal changed source availability"), + } + error +} + +/// All modes use original data, even when the supplied raster mirror is deliberately different. +#[test] +fn original_svg_drives_pixels_curves_and_shape_paint_with_one_undo() { + let mut s = scene(8); + s.edit("Existing work path", |doc, _| { + doc.work_path = Some(Path { subpaths: vec![Subpath::polygon(&[(0.0, 0.0), (2.0, 0.0), (1.0, 2.0)])], ..Default::default() }); + Ok(()) + }) + .unwrap(); + let clip = s.clipboard.as_mut().unwrap(); + clip.surface.fill_rect(clip.bounds, &[0.0, 1.0, 0.0, 1.0]); + let raw = source(&s).clone(); + for mode in ["pixels", "path", "shape"] { + let before = s.active().unwrap().doc.clone(); + let history = s.active().unwrap().history.past_len(); + let result = s.execute("edit.paste", json!({"as":mode})).unwrap(); + assert_eq!(result["offset"], json!([5, 4])); + match mode { + "pixels" => { + assert!(matches!(output(&s).content, LayerContent::Raster(_))); + let px = output(&s).surface().unwrap().pixel(10, 8); + assert!(px[0] > 0.9 && px[1] < 0.1 && px[3] > 0.9, "Pixels must render red source, not green mirror: {px:?}"); + } + "path" => { + assert_eq!(s.active().unwrap().doc.layers.len(), before.layers.len()); + assert_curve(&s.active().unwrap().doc.paths.last().unwrap().path); + assert_eq!(s.active().unwrap().doc.work_path, before.work_path); + } + _ => { + let LayerContent::Shape(shape) = &output(&s).content else { + panic!("Shape must remain editable"); + }; + assert_curve(&shape.path); + assert!(matches!(&shape.fill,Some(Fill::Solid(c)) if c.c[0]>0.9 && c.c[1]<0.1)); + let stroke = shape.stroke.as_ref().unwrap(); + assert_eq!(stroke.width, 1.0); + assert!(matches!(&stroke.paint,Fill::Solid(c) if c.c[2]>0.9)); + } + } + assert_eq!(s.active().unwrap().history.past_len(), history + 1); + assert!(Arc::ptr_eq(source(&s), &raw)); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.active().unwrap().doc, before); + assert!(Arc::ptr_eq(source(&s), &raw)); + } + s.execute("edit.paste", json!({})).unwrap(); + let legacy = output(&s).surface().unwrap().pixel(10, 8); + assert!(legacy[1] > 0.9 && legacy[0] < 0.1, "omission keeps the ordinary raster mirror route"); + s.execute("edit.undo", json!({})).unwrap(); + let replaced = String::from_utf8(SVG.to_vec()).unwrap().replace("C2 0", "C3 0"); + s.clipboard = Some(Clip::from_svg(replaced.as_bytes()).unwrap()); + s.execute("edit.paste", json!({"as":"path"})).unwrap(); + assert!(s.active().unwrap().doc.paths.last().unwrap().path.subpaths.iter().flat_map(|p| &p.knots).any(|k| k.out_ctrl == Point::new(8.0, 4.0))); + s.execute("edit.undo", json!({})).unwrap(); + s.execute("edit.paste", json!({"as":"path","center":[7,6]})).unwrap(); + assert!(s.active().unwrap().doc.paths.last().unwrap().path.subpaths.iter().flat_map(|p| &p.knots).any(|k| k.anchor == Point::new(3.0, 3.0))); +} + +/// Native depth/profile adaptation applies to the real source and editable shape caches. +#[test] +fn source_outputs_follow_destination_depth_and_pixels_forces_raster_from_layer_copy() { + for depth in [8, 16, 32] { + for mode in ["pixels", "shape"] { + let mut s = scene(depth); + s.execute("edit.paste", json!({"as":mode})).unwrap(); + let expected = match depth { + 8 => SampleType::U8, + 16 => SampleType::U16, + _ => SampleType::F32, + }; + assert_eq!(output(&s).surface().unwrap().format().sample, expected); + assert_eq!(output(&s).surface().unwrap().format().mode, ColorMode::Rgb); + } + } + let mut s = scene(8); + s.execute("edit.paste", json!({"as":"shape"})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + assert!(s.clipboard.as_ref().unwrap().svg.is_none(), "ordinary Copy replaces vector source"); + assert!(s.clipboard.as_ref().unwrap().layers.is_some()); + rejected(&mut s, json!({"as":"path"})); + s.execute("edit.paste", json!({})).unwrap(); + assert!(matches!(output(&s).content, LayerContent::Shape(_)), "omission keeps whole-layer recreation"); + s.execute("edit.undo", json!({})).unwrap(); + s.execute("edit.paste", json!({"as":"pixels"})).unwrap(); + assert!(matches!(output(&s).content, LayerContent::Raster(_))); + s.execute("edit.purge.clipboard", json!({})).unwrap(); + assert!(s.clipboard.is_none()); +} + +/// Malformed modes, unsupported placement/targets, and unsupported source kinds fail atomically. +#[test] +fn invalid_modes_targets_and_capabilities_preserve_source_and_history() { + let mut s = scene(8); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + for p in [ + json!({"as":"unknown"}), + json!({"as":true}), + json!({"as":null}), + json!({"as":"shape","center":[0]}), + json!({"as":"shape","center":[0,null]}), + json!({"as":"shape","center":[1e20,0]}), + json!({"as":"shape","target":"mask"}), + json!({"as":"shape","clampToCanvas":false}), + json!({"as":"shape","inPlace":false}), + ] { + rejected(&mut s, p); + } + let multi = br##""##; + s.clipboard = Some(Clip::from_svg(multi).unwrap()); + rejected(&mut s, json!({"as":"shape"})); + s.execute("edit.paste", json!({"as":"path"})).unwrap(); + assert_eq!(s.active().unwrap().doc.paths.last().unwrap().path.subpaths.len(), 2); + let opacity = br##""##; + s.clipboard = Some(Clip::from_svg(opacity).unwrap()); + for mode in ["path", "shape"] { + rejected(&mut s, json!({"as":mode})); + } + let mut empty = Session::new(); + empty.clipboard = Some(Clip::from_svg(SVG).unwrap()); + assert!(empty.execute("edit.paste", json!({"as":"pixels"})).is_err()); + assert!(empty.active().is_none()); + let mut locked = scene(8); + locked + .edit("Locked ancestor", |doc, active| { + let child = Layer::raster("Child", doc.pixel_format()); + *active = Some(child.id); + let mut group = Layer::group("Locked", vec![child]); + group.locks.all = true; + doc.layers.push(group); + Ok(()) + }) + .unwrap(); + for mode in ["pixels", "shape"] { + rejected(&mut locked, json!({"as":mode})); + } + locked.clipboard.as_mut().unwrap().svg = None; + rejected(&mut locked, json!({"as":"pixels"})); +} + +/// Existing geometry and cache limits apply before publication, including intermediate caches. +#[test] +fn source_and_destination_work_limits_refuse_before_editing() { + assert!(Clip::from_svg(b"not svg").is_err()); + assert!(Clip::from_svg(&vec![b' '; 16 * 1024 * 1024 + 1]).is_err()); + assert!(Clip::from_svg(br##""##).is_err()); + let mut s = scene(8); + let mut many = String::from(""); + for _ in 0..1025 { + many.push_str(""); + } + many.push_str(""); + s.clipboard = Some(Clip::from_svg(many.as_bytes()).unwrap()); + assert!(rejected(&mut s, json!({"as":"path"})).contains("4096")); + // The original position covers 36MP in this destination; the final position covers zero. + let oversized = br##""##; + let prepared = photocraft_io::svg::import_svg_bounded("control.svg", oversized, 24_000_000).unwrap(); + assert!(prepared.document.walk().iter().any(|(_, _, l)| matches!(l.content, LayerContent::Shape(_))), "the cache fixture must retain actual geometry"); + let mut doc = photocraft_doc::Document::new("Sparse destination", Size::new(7000, 7000), ColorMode::Rgb, SampleType::U8); + doc.layers.push(Layer::raster("Pixels", doc.pixel_format())); + let mut large = Session::new(); + large.add_document(doc, None); + large.clipboard = Some(Clip::from_svg(oversized).unwrap()); + assert!(rejected(&mut large, json!({"as":"shape","center":[-10000,-10000]})).contains("cache exceeds 24 megapixels")); +} + +fn descriptor(class_id: &str) -> Descriptor { + Descriptor::new("null").with("As ", D::Class(Class { name: UnicodeString::new_nul(""), class_id: Id::new(class_id) })) +} + +fn recorded(d: Descriptor) -> Vec { + atn::write(&AtnFile { + name: UnicodeString::new_nul("Vector"), + expanded: false, + warnings: vec![], + actions: vec![AtnAction { + name: UnicodeString::new_nul("Paste"), + items: vec![AtnItem { event: AtnEvent::Code(*b"past"), dictionary: b"Paste".to_vec(), descriptor: Some(d), ..Default::default() }], + ..Default::default() + }], + }) + .unwrap() +} + +fn load(s: &mut Session, bytes: &[u8]) -> Value { + s.execute("actions.load", json!({"data":photocraft_engine::paint::tile::b64_encode(bytes)})).unwrap() +} +fn save(s: &mut Session) -> Vec { + let v = s.execute("actions.save", json!({"set":"Vector"})).unwrap(); + assert_eq!(v["skipped"], json!([])); + photocraft_engine::paint::tile::b64_decode(v["data"].as_str().unwrap()).unwrap() +} + +/// Raw classes survive save/reimport and drive actual source-backed document output. +#[test] +fn atn_classes_roundtrip_and_play_real_modes() { + for (class_id, mode) in [("Pxel", "pixels"), ("Path", "path"), ("shapeClass", "shape")] { + let mut s = scene(8); + assert_eq!(load(&mut s, &recorded(descriptor(class_id)))["unsupported"], json!([])); + assert_eq!(s.actions.list[0].steps, vec![("edit.paste".into(), json!({"as":mode}))]); + let saved = save(&mut s); + let file = atn::parse(&saved).unwrap(); + assert_eq!(file.actions[0].items[0].descriptor.as_ref().unwrap().get("As "), descriptor(class_id).get("As ")); + let imported = atn_cmds::import(&saved).unwrap(); + assert_eq!(imported.actions[0].steps, s.actions.list[0].steps); + s.actions.list = imported.actions; + let result = s.execute("actions.play", json!({"action":"Paste"})).unwrap(); + assert_eq!(result["ran"], 1); + assert!(result.get("failed").is_none(), "{result}"); + match mode { + "path" => assert_curve(&s.active().unwrap().doc.paths.last().unwrap().path), + "shape" => assert!(matches!(output(&s).content, LayerContent::Shape(_))), + _ => assert!(matches!(output(&s).content, LayerContent::Raster(_))), + } + } + let mut raster = scene(8); + raster.clipboard.as_mut().unwrap().svg = None; + assert_eq!(load(&mut raster, &recorded(descriptor("Pxel")))["unsupported"], json!([])); + assert_eq!(raster.execute("actions.play", json!({"action":"Paste"})).unwrap()["ran"], 1); + assert!(matches!(output(&raster).content, LayerContent::Raster(_))); +} + +/// Unknown classes/types/placement remain complete kept steps; invalid native options refuse export. +#[test] +fn atn_unknown_forms_are_kept_and_legacy_omission_stays_legacy() { + let mut s = scene(8); + assert_eq!(load(&mut s, &recorded(Descriptor::new("null")))["unsupported"], json!([])); + assert_eq!(s.actions.list[0].steps, vec![("edit.paste".into(), json!({}))]); + for d in [ + descriptor("unknownClass"), + Descriptor::new("null").with("As ", D::Text(UnicodeString::new_nul("Path"))), + descriptor("Path").with("inPlace", D::Boolean(true)), + descriptor("Path").with("inPlace", D::Integer(0)), + descriptor("Path").with("AntA", D::Enumerated { type_id: Id::new("Wrong"), value: Id::new("Anno") }), + ] { + let original = recorded(d); + let file = atn::parse(&original).unwrap(); + let mut s = Session::new(); + assert_eq!(load(&mut s, &original)["unsupported"].as_array().unwrap().len(), 1); + assert_eq!(s.actions.list[0].steps[0].0, atn_cmds::PHOTOSHOP_STEP); + assert_eq!(atn::parse(&save(&mut s)).unwrap().actions[0].items[0].descriptor, file.actions[0].items[0].descriptor); + } + for p in [ + json!({"as":null}), + json!({"as":"smartObject"}), + json!({"as":"shape","center":[1,2]}), + json!({"as":"path","target":"mask"}), + json!({"as":"pixels","inPlace":false}), + ] { + let action = Action::new("Bad", vec![("edit.paste".into(), p)]); + assert!(atn_cmds::export("Vector", &[&action], &[], false).is_err()); + } +} From 5a9ca48f8a3b12d020eeef78c15bfda68a726acd Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 1 Nov 2026 18:37:35 +0100 Subject: [PATCH 114/216] docs(atn): Convert Stroke to Fill exists only internally; where it was looked for Co-Authored-By: Claude Opus 5.5 --- docs/atn.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/atn.md b/docs/atn.md index 01f938909b..61c2403a19 100644 --- a/docs/atn.md +++ b/docs/atn.md @@ -381,9 +381,11 @@ the recorded steps back from the saved Actions panel. Ranges come from the dialo **Not recordable:** Edit > Purge (no step is recorded; this version's Purge menu has Clipboard, Histories, All and Video Cache, no Undo); File > Export > Export As and Quick Export as PNG (the file -is written, no step is recorded). `vectorStrokeToFill` (Convert Stroke to Fill) is not a user command: the reference application has -no menu entry or context-menu item for it, refuses the event even with a stroked path component selected, -and so never records it. No action can contain it; nothing to map. +is written, no step is recorded). `vectorStrokeToFill` (Convert Stroke to Fill) exists only internally: the reference application knows +it by a localised command name ("command … is not currently available"), but it is in no menu, panel +menu, context menu, the stroke options (incl. their gear menu), the Properties panel or the Keyboard +Shortcuts summary of all commands and tools, and the event is refused even with a stroked path component +selected. It can't be triggered, so it is never recorded: no action contains it, nothing to map. ### Batch From 53f3ef7c412d1ec33a8459210067b74b443ee7c8 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Sun, 1 Nov 2026 09:57:05 -0800 Subject: [PATCH 115/216] Record the default resampler for point-sized actions --- crates/engine/src/cmd/atn_cmds/steps_doc.rs | 4 +++- crates/engine/tests/atn_mapped_identifiers.rs | 24 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/atn_cmds/steps_doc.rs b/crates/engine/src/cmd/atn_cmds/steps_doc.rs index 68e019d5a0..e012792833 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_doc.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_doc.rs @@ -544,7 +544,9 @@ pub(super) fn image_size_to(_: &str, p: &Value) -> Option { if let Some(r) = jnum(p, "resolution") { d = d.with("Rslt", D::UnitFloat { unit: *b"#Rsl", value: r }); } - if let Some(i) = jstr(p, "resample").filter(|r| *r != "none") { + // Point sizes need an interpolation entry; omission uses the native bicubic default. + let resample = jstr(p, "resample").or_else(|| (unit == PT).then_some("bicubic")); + if let Some(i) = resample.filter(|r| *r != "none") { d = d.with("Intr", enm("Intp", lookup_back(INTERPOLATION, i)?)); } // Write the effective native default explicitly: omission in an ATN means false. diff --git a/crates/engine/tests/atn_mapped_identifiers.rs b/crates/engine/tests/atn_mapped_identifiers.rs index 1154693161..ef92301a8d 100644 --- a/crates/engine/tests/atn_mapped_identifiers.rs +++ b/crates/engine/tests/atn_mapped_identifiers.rs @@ -90,6 +90,30 @@ fn review_point_resize_exports_with_null_optional_values() { } } +/// The native default resampler must be explicit in a physical-size recording. +#[test] +fn review_point_resize_exports_the_default_resampler() { + for extras in [json!({}), json!({"resample":null})] { + let mut params = json!({"width":72,"units":"pt","scaleStyles":false}); + params.as_object_mut().unwrap().extend(extras.as_object().unwrap().clone()); + let action = Action::new("Recorded", vec![("image.imageSize".into(), params.clone())]); + let (bytes, skipped) = atn_cmds::export("Points", &[&action], &[], false).unwrap(); + assert!(skipped.is_empty()); + let imported = atn_cmds::import(&bytes).unwrap(); + assert!(imported.unsupported.is_empty(), "a point-size export must include its native default resampler"); + assert_eq!(imported.actions[0].steps[0].1["resample"], "bicubic"); + let mut expected = sized_document(40, 20, 144); + expected.execute("image.imageSize", params).unwrap(); + let mut actual = sized_document(40, 20, 144); + actual.actions.list = imported.actions; + let result = actual.execute("actions.play", json!({"action":"Recorded"})).unwrap(); + assert!(result.get("failed").is_none(), "{result}"); + let (a, b) = (&actual.active().unwrap().doc, &expected.active().unwrap().doc); + assert_eq!(a.size, b.size); + assert_eq!(a.layers[0].surface().unwrap().read_region(a.bounds()), b.layers[0].surface().unwrap().read_region(b.bounds())); + } +} + /// Invalid metadata must keep the whole Copy/Cut event, before a layer or source pixels can change. #[test] fn review_copy_cut_opacity_is_validated_before_any_step_runs() { From 363caa53c3fbb40ad7c55e544989c1ff6482236a Mon Sep 17 00:00:00 2001 From: yoriswind Date: Sun, 1 Nov 2026 10:07:36 -0800 Subject: [PATCH 116/216] Check pixel orientation after relative rotation --- crates/engine/tests/atn_mapped_identifiers.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/engine/tests/atn_mapped_identifiers.rs b/crates/engine/tests/atn_mapped_identifiers.rs index ef92301a8d..e9475d63ed 100644 --- a/crates/engine/tests/atn_mapped_identifiers.rs +++ b/crates/engine/tests/atn_mapped_identifiers.rs @@ -183,12 +183,18 @@ fn review_layer_transform_uses_the_recorded_anchor() { ); s.execute("edit.undo", json!({})).unwrap(); if code == "Qcs0" { + s.edit("Mark rotation", |doc, active| { + doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().write_pixel(22, 30, &[0.0, 1.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); s.execute("edit.transform", json!({"rotate":90,"reference":"topLeft","interpolation":"nearest"})).unwrap(); let state = s.active().unwrap(); assert_eq!( state.doc.layer(state.active_layer.unwrap()).unwrap().surface().unwrap().content_bounds(), photocraft_geom::Rect::new(0, 20, 20, 40) ); + assert_eq!(state.doc.layer(state.active_layer.unwrap()).unwrap().surface().unwrap().rgba(9, 22), [0.0, 1.0, 0.0, 1.0]); s.execute("edit.undo", json!({})).unwrap(); } let before = s.active().unwrap().doc.clone(); From a547bdcb309b3f06f45a07a60f2b6247abbeaf52 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Sun, 1 Nov 2026 11:06:36 -0800 Subject: [PATCH 117/216] Connect SVG clipboard source to explicit Paste modes Read bounded original SVG through the platform clipboard service for native Paste options and action playback. Preserve a newer internal copy when an older OS source remains unchanged, and report failed or panicking reads without losing the existing copy. Retain ordinary raster clipboard routes. Add source replacement, mode/output, Undo, action playback, stale-source, size-bound and panic/retry regressions. Completes the app intake for the source-backed backend adapted from Skyfuller1303's PR #3409. Validation: 104 unique UI/Actions/native-service passes, one existing manual clipboard ignore; actual app-model Pixels/Shape renders and saved Path; 2921 engine package passes (22 ignores), adversarial pass, all-target strict engine/UI/app Clippy, full format, 31-crate layers and standard wasm/HEIF. --- apps/photocraft/src/services.rs | 28 +++++ apps/photocraft/src/services/tests.rs | 27 +++++ crates/ui-egui/src/app.rs | 1 + crates/ui-egui/src/app/clipboard.rs | 67 +++++++++++- crates/ui-egui/src/app/clipboard_tests.rs | 122 ++++++++++++++++++++++ crates/ui-egui/src/app/commands.rs | 20 +++- crates/ui-egui/src/lib.rs | 10 +- crates/ui-egui/src/panels/actions.rs | 3 + crates/ui-egui/src/services.rs | 5 + 9 files changed, 275 insertions(+), 8 deletions(-) diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index 675996c5d0..cabd2196d6 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -497,6 +497,11 @@ fn guarded_clipboard_get(mut read: impl FnMut() -> Result Result>, String> + 'static) -> photocraft_ui_egui::ClipboardGetSvgFn { + Box::new(move || crate::crash_guard::guard("Paste", &mut read)) +} + fn guarded_clipboard_set(mut write: impl FnMut(u32, u32, &[u8]) -> Result<(), String> + 'static) -> photocraft_ui_egui::ClipboardSetFn { Box::new(move |w: u32, h: u32, px: &[u8]| crate::crash_guard::guard("Copy", || write(w, h, px))) } @@ -616,6 +621,18 @@ fn clipboard_text(read: Result) -> Result } } +/// Keep SVG source for Paste As without accepting an unbounded clipboard string as a new copy. +fn svg_clipboard_source(text: Option) -> Result>, String> { + let Some(text) = text else { return Ok(None) }; + if !photocraft_io::svg::is_svg(text.as_bytes()) { + return Ok(None); + } + if text.len() > 16 << 20 { + return Err("SVG clipboard data exceeds the 16 MiB limit".into()); + } + Ok(Some(text.into_bytes())) +} + /// The shell command that starts this install under XWayland, where winit delivers file drops /// (winit 0.30 has none on Wayland, #386). A Flatpak gets the X11 socket only without the Wayland /// one, an AppImage relaunches by its own path, and anything else is `photocraft` on the PATH. @@ -748,6 +765,17 @@ pub fn native(automation: Option) -> ) }) }), + clipboard_get_svg: Some({ + let clip = clip.clone(); + guarded_clipboard_svg(move || { + let mut slot = clip.try_borrow_mut().map_err(|_| "clipboard is busy".to_string())?; + let cb = match slot.as_mut() { + Some(c) => c, + None => slot.insert(arboard::Clipboard::new().map_err(|e| e.to_string())?), + }; + svg_clipboard_source(clipboard_text(cb.get_text())?) + }) + }), clipboard_unreadable: Some(Box::new(move || clip_unreadable.get())), load_prefs: Some(Box::new(|| std::fs::read_to_string(prefs_file()?).ok())), photoshop_shortcuts: Some(Box::new(|| crate::photoshop_settings::live_keyboard_shortcuts(&|k| std::env::var_os(k)))), diff --git a/apps/photocraft/src/services/tests.rs b/apps/photocraft/src/services/tests.rs index 8f12fee908..a0a91aa118 100644 --- a/apps/photocraft/src/services/tests.rs +++ b/apps/photocraft/src/services/tests.rs @@ -709,6 +709,33 @@ fn from_text(text: &str) -> Option<(u32, u32, Vec)> { clipboard_image(&mut (), |_| Ok(None), |_| Ok(None), |_| Ok(Some(text.to_string()))).expect("no read fails") } +#[test] +fn svg_clipboard_source_preserves_markup_and_bounds_its_size() { + let bytes = svg_clipboard_source(Some(HALF_RED.into())).unwrap().unwrap(); + assert_eq!(bytes, HALF_RED.as_bytes()); + for text in [None, Some("plain text".into()), Some("

text

".into())] { + assert_eq!(svg_clipboard_source(text), Ok(None)); + } + let mut oversized = HALF_RED.to_string(); + oversized.extend(std::iter::repeat_n(' ', (16 << 20) + 1)); + assert!(svg_clipboard_source(Some(oversized)).is_err()); +} + +#[test] +fn a_panicking_svg_clipboard_read_is_reported_and_can_be_retried() { + let panics = Rc::new(Cell::new(true)); + let fail = panics.clone(); + let mut get = guarded_clipboard_svg(move || { + if fail.get() { + panic!("SVG clipboard reader bug"); + } + Ok(Some(HALF_RED.as_bytes().to_vec())) + }); + assert!(get().unwrap_err().contains("Paste failed")); + panics.set(false); + assert_eq!(get().unwrap().unwrap(), HALF_RED.as_bytes()); +} + fn pixel(img: &(u32, u32, Vec), x: u32, y: u32) -> [u8; 4] { let at = ((y * img.0 + x) * 4) as usize; img.2[at..at + 4].try_into().unwrap() diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 14038b10de..3015f0214d 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -143,6 +143,7 @@ impl PhotocraftApp { clone_preview: None, info_sample: None, os_clip_sig: None, + os_clip_svg: None, clip_unmirrored: true, clip_external: false, clip_read_for_paste: false, diff --git a/crates/ui-egui/src/app/clipboard.rs b/crates/ui-egui/src/app/clipboard.rs index a553cc932c..ade57b1c25 100644 --- a/crates/ui-egui/src/app/clipboard.rs +++ b/crates/ui-egui/src/app/clipboard.rs @@ -8,7 +8,67 @@ fn clip_signature(w: u32, h: u32, px: &[u8]) -> u64 { sig } +/// Match the bitmap signature by sampling the bounded SVG mirror, without another full pixel buffer. +fn svg_clip_signature(clip: &photocraft_engine::edit_cmds::Clip) -> u64 { + let b = clip.bounds; + let (w, h) = (b.width(), b.height()); + let mut sig = (u64::from(w) << 32) | u64::from(h); + if w == 0 || h == 0 { + return sig; + } + for i in (0..b.size().area().saturating_mul(4)).step_by(997) { + let x = b.x0.saturating_add((i / 4 % u64::from(w)) as i32); + let y = b.y0.saturating_add((i / 4 / u64::from(w)) as i32); + let mut pixel = [[0; 4]]; + clip.surface.read_rgba8_into(photocraft_geom::Rect::from_xywh(x, y, 1, 1), &mut pixel); + let value = pixel[0].get((i % 4) as usize).copied().unwrap_or_default(); + sig = sig.rotate_left(5) ^ u64::from(value); + } + sig +} + impl PhotocraftApp { + /// Capture SVG and its raster mirror together; failures leave the previous copy intact. + pub(super) fn import_os_svg_clipboard(&mut self) -> Result { + let Some(get) = self.services.clipboard_get_svg.as_mut() else { return Ok(false) }; + let source = match get() { + Ok(source) => source, + Err(_) if self.clip_unmirrored && !self.clip_external && self.session.clipboard.is_some() => return Ok(false), + Err(error) => { + self.ui.status = error.clone(); + self.ui.status_error = true; + return Err(error); + } + }; + let Some(source) = source else { + self.os_clip_svg = None; + // Bitmap equality cannot prove that an earlier external vector source is current. + if self.clip_external && self.session.clipboard.as_ref().is_some_and(|clip| clip.svg.is_some()) { + self.session.clipboard = None; + self.os_clip_sig = None; + self.clip_external = false; + self.clip_unmirrored = true; + } + return Ok(false); + }; + if self.os_clip_svg.as_deref().is_some_and(|old| old == &source) && self.session.clipboard.is_some() { + // A newer internal copy remains authoritative until the external bytes change. + return Ok(true); + } + let clip = photocraft_engine::edit_cmds::Clip::from_svg(&source).map_err(|error| { + let error = error.to_string(); + self.ui.status = error.clone(); + self.ui.status_error = true; + error + })?; + self.os_clip_sig = Some(svg_clip_signature(&clip)); + self.os_clip_svg = clip.svg.clone(); + self.session.clipboard = Some(clip); + self.clip_external = true; + self.clip_unmirrored = false; + Ok(true) + } + /// Mirror the session clipboard onto the OS clipboard (RGBA8). pub(super) fn export_os_clipboard(&mut self) { self.clip_unmirrored = true; @@ -24,6 +84,7 @@ impl PhotocraftApp { clip.surface.read_rgba8_into(b, &mut px); let bytes: Vec = px.into_iter().flatten().collect(); if set(b.width(), b.height(), &bytes).is_ok() { + self.os_clip_svg = None; self.os_clip_sig = Some(clip_signature(b.width(), b.height(), &bytes)); self.clip_unmirrored = false; } @@ -59,7 +120,8 @@ impl PhotocraftApp { }; let image = image.filter(|(w, h, bytes)| *w > 0 && *h > 0 && bytes.len() == *w as usize * *h as usize * 4); let Some((w, h, bytes)) = image else { - if self.os_clip_sig.take().is_some() && !self.clip_unmirrored { + self.os_clip_svg = None; + if (self.os_clip_sig.take().is_some() || self.clip_external) && !self.clip_unmirrored { self.session.clipboard = None; self.clip_external = false; self.clip_unmirrored = true; @@ -74,7 +136,8 @@ impl PhotocraftApp { let mut surface = photocraft_raster::Surface::from_interleaved(photocraft_color::PixelFormat::RGBA8, r, &bytes); surface.prune(); self.session.clipboard = - Some(photocraft_engine::edit_cmds::Clip { components: None, surface, bounds: r, layers: None, resolution: None, source: None }); + Some(photocraft_engine::edit_cmds::Clip { components: None, surface, bounds: r, layers: None, resolution: None, source: None, svg: None }); + self.os_clip_svg = None; self.os_clip_sig = Some(sig); self.clip_external = true; self.clip_unmirrored = false; diff --git a/crates/ui-egui/src/app/clipboard_tests.rs b/crates/ui-egui/src/app/clipboard_tests.rs index 65318b174f..e0cb4eb138 100644 --- a/crates/ui-egui/src/app/clipboard_tests.rs +++ b/crates/ui-egui/src/app/clipboard_tests.rs @@ -58,6 +58,128 @@ fn os_clipboard_honours_export_clipboard_preference() { type OsClip = Arc)>>>; +const CLIP_SVG: &str = r##""##; + +#[test] +fn svg_clipboard_paste_as_keeps_source_and_creates_the_requested_content() { + for mode in ["pixels", "path", "shape"] { + let services = Services { clipboard_get_svg: Some(Box::new(|| Ok(Some(CLIP_SVG.as_bytes().to_vec())))), ..Default::default() }; + let mut app = PhotocraftApp::new(Session::new(), services); + app.run("file.new", json!({"width": 24, "height": 20})).unwrap(); + let (layers, paths) = { + let doc = &app.session.active().unwrap().doc; + (doc.layers.len(), doc.paths.len()) + }; + app.run("edit.paste", json!({"as": mode})).unwrap(); + let state = app.session.active().unwrap(); + if mode == "path" { + assert_eq!(state.doc.paths.len(), paths + 1); + assert_eq!(state.doc.layers.len(), layers); + } else { + let content = &state.doc.layer(state.active_layer.unwrap()).unwrap().content; + assert!(match mode { + "pixels" => matches!(content, photocraft_doc::LayerContent::Raster(_)), + _ => matches!(content, photocraft_doc::LayerContent::Shape(_)), + }); + } + let source = app.session.clipboard.as_ref().unwrap().svg.clone().unwrap(); + assert_eq!(source.as_slice(), CLIP_SVG.as_bytes()); + app.run("edit.undo", json!({})).unwrap(); + let doc = &app.session.active().unwrap().doc; + assert_eq!((doc.layers.len(), doc.paths.len()), (layers, paths)); + app.run("edit.redo", json!({})).unwrap(); + assert!(Arc::ptr_eq(&source, app.session.clipboard.as_ref().unwrap().svg.as_ref().unwrap())); + } +} + +#[test] +fn svg_clipboard_errors_preserve_the_copy_and_empty_reads_invalidate_external_source() { + let response = Arc::new(Mutex::new(Ok(Some(CLIP_SVG.as_bytes().to_vec())))); + let read = response.clone(); + let services = Services { clipboard_get_svg: Some(Box::new(move || read.lock().unwrap().clone())), ..Default::default() }; + let mut app = PhotocraftApp::new(Session::new(), services); + app.run("file.new", json!({"width": 24, "height": 20})).unwrap(); + app.run("edit.paste", json!({"as": "path"})).unwrap(); + let before = app.session.active().unwrap().doc.clone(); + let source = app.session.clipboard.as_ref().unwrap().svg.clone().unwrap(); + for failure in [Err("clipboard is busy".into()), Ok(Some(b""##; + for params in [json!({}), json!({"as":"pixels"})] { + let image = OsClip::default(); + let read = image.clone(); + let services = Services { + clipboard_get_svg: Some(Box::new(|| Ok(Some(SOURCE.as_bytes().to_vec())))), + clipboard_get_image: Some(Box::new(move || Ok(read.lock().unwrap().clone()))), + ..Default::default() + }; + let mut app = PhotocraftApp::new(Session::new(), services); + app.run("file.new", json!({"width":64,"height":64})).unwrap(); + app.import_os_svg_clipboard().unwrap(); + let clip = app.session.clipboard.as_ref().unwrap(); + let b = clip.bounds; + let mut pixels = vec![[0; 4]; b.size().area() as usize]; + clip.surface.read_rgba8_into(b, &mut pixels); + let bytes: Vec<_> = pixels.into_iter().flatten().collect(); + let signature = bytes.iter().step_by(997).fold((u64::from(b.width()) << 32) | u64::from(b.height()), |sig, byte| sig.rotate_left(5) ^ u64::from(*byte)); + assert_eq!(app.os_clip_sig, Some(signature), "match the image signature across all four RGBA components"); + *image.lock().unwrap() = Some((b.width(), b.height(), bytes)); + app.session.edit_prefs(|prefs| prefs.general.export_clipboard = false); + app.run("file.new", json!({"width":8,"height":6})).unwrap(); + app.run("edit.fill", json!({"color":"#0000ff"})).unwrap(); + app.run("edit.copy", json!({})).unwrap(); + app.run("edit.paste", params).unwrap(); + assert_eq!(app.session.clipboard.as_ref().unwrap().surface.rgba(0, 0), [0.0, 0.0, 1.0, 1.0]); + assert!(!app.clip_external, "the unchanged older SVG must not replace the newer internal copy"); + } +} + +#[test] +fn action_vector_paste_reads_the_current_svg_source() { + let services = Services { clipboard_get_svg: Some(Box::new(|| Ok(Some(CLIP_SVG.as_bytes().to_vec())))), ..Default::default() }; + let mut app = PhotocraftApp::new(Session::new(), services); + app.run("file.new", json!({"width": 24, "height": 20})).unwrap(); + app.session.actions.list.push(photocraft_engine::actions_cmds::Action::new("SVG", vec![("edit.paste".into(), json!({"as": "shape"}))])); + let result = app.run("actions.play", json!({"action": "SVG"})).unwrap(); + assert!(result.get("failed").is_none(), "{result}"); + let state = app.session.active().unwrap(); + assert!(matches!(state.doc.layer(state.active_layer.unwrap()).unwrap().content, photocraft_doc::LayerContent::Shape(_))); +} + #[test] fn failed_clipboard_reads_preserve_editable_contents_until_retry() { use std::sync::atomic::{AtomicBool, Ordering}; diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index 39d17bf493..d17fb2ad26 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -85,10 +85,26 @@ impl PhotocraftApp { let t0 = gpu_canvas::now_ms(); // The OS clipboard is read only on an explicit paste, never in the background (privacy, CPU). if matches!(id, "edit.paste" | "edit.pasteSpecial.pasteInPlace" | "file.newFromClipboard") { - if !clip_read { + let paste_as = if id == "edit.paste" { + match params.get("as") { + None => None, + Some(value) => match value.as_str() { + Some(mode @ ("pixels" | "path" | "shape")) => Some(mode), + _ => return Err("`as` must be pixels, path or shape".into()), + }, + } + } else { + None + }; + if let Some(mode) = paste_as { + let imported = self.import_os_svg_clipboard()?; + if mode == "pixels" && !imported && !clip_read { + self.import_os_clipboard()?; + } + } else if !clip_read { self.import_os_clipboard()?; } - if self.session.clipboard.is_none() && self.services.clipboard_get_image.is_some() { + if paste_as.is_none() && self.session.clipboard.is_none() && self.services.clipboard_get_image.is_some() { if self.services.clipboard_unreadable.as_ref().is_some_and(|unreadable| unreadable()) { // The clipboard may well hold an image that can't be reached: say why. notices::clipboard_unreadable(self); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e17b2bc80d..8165b8a41d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -253,10 +253,10 @@ pub(crate) use services::SAVE_OPTIONS_OPEN; pub(crate) use services::saved_revision_after_write; pub use services::{ AdoptAutosaveFn, AppendTextFn, AutomationCommandFn, AutomationOpenPcraftFn, AutomationReadFn, AutomationWriteFn, AutosaveFn, AutosaveResultsFn, CapsLockFn, - CaptionStyleFn, ClipboardGetFn, ClipboardSetFn, ClipboardUnreadableFn, CursorPosFn, DiscardAutosaveFn, EncodePngFn, ExportFn, ExportSettings, - ExternalSvgEditor, ExternalSvgSession, ExternalVectorSave, ExternalVectorSource, FileStamp, FileStampFn, FormatChoices, ImportFn, Inbox, LoadTextFn, - OpenExternalSvgFn, OpenUrlFn, OsEventsFn, PhotoshopShortcutsFn, QuitFn, RecoverFn, Recoverable, RightButtonDownFn, SameFileFn, SaveFileFn, SaveTextFn, - Services, SystemThemeFn, WindowFrameFn, WriteFn, + CaptionStyleFn, ClipboardGetFn, ClipboardGetSvgFn, ClipboardSetFn, ClipboardUnreadableFn, CursorPosFn, DiscardAutosaveFn, EncodePngFn, ExportFn, + ExportSettings, ExternalSvgEditor, ExternalSvgSession, ExternalVectorSave, ExternalVectorSource, FileStamp, FileStampFn, FormatChoices, ImportFn, Inbox, + LoadTextFn, OpenExternalSvgFn, OpenUrlFn, OsEventsFn, PhotoshopShortcutsFn, QuitFn, RecoverFn, Recoverable, RightButtonDownFn, SameFileFn, SaveFileFn, + SaveTextFn, Services, SystemThemeFn, WindowFrameFn, WriteFn, }; pub use text::bidi_text; pub use text::cjk_fonts; @@ -593,6 +593,8 @@ pub struct PhotocraftApp { pub(crate) pending_after_prompt: Option<(String, serde_json::Value)>, /// Last mirrored or imported OS image; this can predate the current internal copy. os_clip_sig: Option, + /// Last observed SVG bytes, to distinguish an unchanged OS source from a newer internal copy. + os_clip_svg: Option>>, /// The session copy, if any, need not track the OS clipboard. An older OS signature is kept /// to recognize unchanged system contents when exporting a newer copy failed or was disabled. clip_unmirrored: bool, diff --git a/crates/ui-egui/src/panels/actions.rs b/crates/ui-egui/src/panels/actions.rs index c1ba436d22..42ad89fa30 100644 --- a/crates/ui-egui/src/panels/actions.rs +++ b/crates/ui-egui/src/panels/actions.rs @@ -177,6 +177,9 @@ fn play_owned(app: &mut PhotocraftApp, params: &Value, suspended: Option) } else if actions_cmds::shell_view_command(id) { app.sync_views(); app.run(id, p.clone()) + } else if id == "edit.paste" && p.get("as").is_some() { + // An explicit vector paste needs the original OS source, not just an old pixel copy. + app.run(id, p.clone()) } else { app.session.execute(id, p.clone()).map_err(|e| e.to_string()) }; diff --git a/crates/ui-egui/src/services.rs b/crates/ui-egui/src/services.rs index 1bcaf89e32..11fc27119b 100644 --- a/crates/ui-egui/src/services.rs +++ b/crates/ui-egui/src/services.rs @@ -247,6 +247,9 @@ pub type ClipboardSetFn = Box Result<(), String>>; /// `Err` means the contents could not be read and must not invalidate the internal copy. pub type ClipboardGetFn = Box Result)>, String>>; +/// Read original SVG markup for an explicit Paste As. A read error preserves the internal copy. +pub type ClipboardGetSvgFn = Box Result>, String>>; + /// Whether the last clipboard read found no way to reach the OS clipboard at all. pub type ClipboardUnreadableFn = Box bool>; @@ -433,6 +436,8 @@ pub struct Services { /// OS clipboard images: copies go out, screenshots and images from other apps come in. pub clipboard_set_image: Option, pub clipboard_get_image: Option, + /// SVG text copied by another app, read only for an explicit pixels/path/shape paste. + pub clipboard_get_svg: Option, /// True when the last `clipboard_get_image` read had no clipboard to read, as in a Flatpak on /// Wayland (#5031), rather than a clipboard that holds no image. `None` where that can't happen. pub clipboard_unreadable: Option, From 027b2cdd3cd72562c6fa25d0e8d0fa0a296eaec4 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Sun, 1 Nov 2026 11:43:45 -0800 Subject: [PATCH 118/216] Cover normal Paste followed by Paste As after an internal copy --- .../tests/image_size_scale_styles_atn.rs | 3 +++ crates/ui-egui/tests/it/clipboard_svg.rs | 21 +++++++++++++++++++ crates/ui-egui/tests/it/main.rs | 1 + 3 files changed, 25 insertions(+) create mode 100644 crates/ui-egui/tests/it/clipboard_svg.rs diff --git a/crates/engine/tests/image_size_scale_styles_atn.rs b/crates/engine/tests/image_size_scale_styles_atn.rs index bdac446c76..a177202a98 100644 --- a/crates/engine/tests/image_size_scale_styles_atn.rs +++ b/crates/engine/tests/image_size_scale_styles_atn.rs @@ -67,6 +67,7 @@ fn assert_size_and_style(s: &Session, id: LayerId, scale: bool) { assert_eq!(stroke.size, if scale { 10.0 } else { 20.0 }); } +/// Recorded true, false and omitted flags retain their meaning through save and playback. #[test] fn imported_style_scaling_survives_saved_bytes_and_playback() { for event in ["ImgS", "imageSize"] { @@ -95,6 +96,7 @@ fn imported_style_scaling_survives_saved_bytes_and_playback() { } } +/// Saving native calls writes their effective default instead of changing style scaling. #[test] fn native_default_is_written_explicitly_and_keeps_its_behavior() { for option in [None, Some(json!(false)), Some(json!(true)), Some(Value::Null), Some(json!(1)), Some(json!("false"))] { @@ -121,6 +123,7 @@ fn native_default_is_written_explicitly_and_keeps_its_behavior() { } } +/// Unsupported flags and empty steps remain complete descriptors instead of becoming resizes. #[test] fn malformed_or_empty_steps_are_kept_without_becoming_resizes() { for event in ["ImgS", "imageSize"] { diff --git a/crates/ui-egui/tests/it/clipboard_svg.rs b/crates/ui-egui/tests/it/clipboard_svg.rs new file mode 100644 index 0000000000..d1325cbe87 --- /dev/null +++ b/crates/ui-egui/tests/it/clipboard_svg.rs @@ -0,0 +1,21 @@ +use photocraft_engine::Session; +use photocraft_ui_egui::{PhotocraftApp, Services}; +use serde_json::json; + +/// A normal paste must not make an unchanged external SVG replace a newer internal copy. +#[test] +fn normal_paste_preserves_newer_internal_copy_for_later_paste_as() { + const SVG: &[u8] = br##""##; + let services = + Services { clipboard_get_svg: Some(Box::new(|| Ok(Some(SVG.to_vec())))), clipboard_get_image: Some(Box::new(|| Ok(None))), ..Default::default() }; + let mut app = PhotocraftApp::new(Session::new(), services); + app.run("file.new", json!({"width": 8, "height": 6})).unwrap(); + app.run("edit.paste", json!({"as": "pixels"})).unwrap(); + app.session.edit_prefs(|prefs| prefs.general.export_clipboard = false); + app.run("file.new", json!({"width": 8, "height": 6})).unwrap(); + app.run("edit.fill", json!({"color": "#0000ff"})).unwrap(); + app.run("edit.copy", json!({})).unwrap(); + app.run("edit.paste", json!({})).unwrap(); + app.run("edit.paste", json!({"as": "pixels"})).unwrap(); + assert_eq!(app.run("document.pixel", json!({"x": 4, "y": 3})).unwrap(), json!([0.0, 0.0, 1.0, 1.0])); +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index e8d8b334ba..22a28c8477 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -9,6 +9,7 @@ mod camera_raw_scope; mod camera_raw_zoom; mod canvas_16f; mod checker_canvas; +mod clipboard_svg; mod color_managed_canvas; mod crop_beyond_canvas; mod crop_default_mode; From 7ca16e9eba5529cdd93fe5e28d5dfa9a7388f7fa Mon Sep 17 00:00:00 2001 From: yoriswind Date: Sun, 1 Nov 2026 11:45:32 -0800 Subject: [PATCH 119/216] Read Photoshop Actions version 12 descriptors --- crates/engine/src/cmd/atn_cmds/tests.rs | 19 +++ crates/psd/fuzz/atn-v12-seeds.md | 13 ++ crates/psd/fuzz/corpus/atn/v12-commands.atn | Bin 0 -> 404 bytes crates/psd/fuzz/corpus/atn/v12-stop.atn | Bin 0 -> 113 bytes crates/psd/fuzz/corpus/atn/v12-values.atn | Bin 0 -> 562 bytes crates/psd/src/atn.rs | 30 ++-- crates/psd/src/atn/legacy.rs | 111 +++++++++++++++ crates/psd/tests/it/atn_v12.rs | 143 ++++++++++++++++++++ crates/psd/tests/it/main.rs | 1 + docs/file-format-parity.md | 5 +- 10 files changed, 306 insertions(+), 16 deletions(-) create mode 100644 crates/psd/fuzz/atn-v12-seeds.md create mode 100644 crates/psd/fuzz/corpus/atn/v12-commands.atn create mode 100644 crates/psd/fuzz/corpus/atn/v12-stop.atn create mode 100644 crates/psd/fuzz/corpus/atn/v12-values.atn create mode 100644 crates/psd/src/atn/legacy.rs create mode 100644 crates/psd/tests/it/atn_v12.rs diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index f9e633244c..a815e2cafa 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -68,6 +68,25 @@ fn b64(bytes: &[u8]) -> String { photocraft_paint::tile::b64_encode(bytes) } +/// Legacy descriptor syntax must reach the same commands and parameters as version 16. +#[test] +fn legacy_v12_commands_import_like_v16() { + let bytes = include_bytes!("../../../../psd/fuzz/corpus/atn/v12-commands.atn"); + let modern = atn::write(&atn::parse(bytes).unwrap()).unwrap(); + let expected = vec![ + ("filter.blur.gaussianBlur".into(), json!({"radius": 2.5})), + ("image.adjustments.levels".into(), json!({"inBlack": 10.0, "inWhite": 240.0, "gamma": 1.2, "outBlack": 0.0, "outWhite": 250.0})), + ("select.all".into(), json!({})), + ("layer.new.layer".into(), json!({"name": "Legacy layer"})), + ]; + for input in [bytes.as_slice(), modern.as_slice()] { + let mut s = Session::new(); + let result = s.execute("actions.load", json!({"data": b64(input)})).unwrap(); + assert_eq!(result["unsupported"], json!([])); + assert_eq!(s.actions.list[0].steps, expected); + } +} + /// A set with a mapped filter, an adjustment layer, selection steps, a layer by name, a Stop, /// a Play, a tool selection (no document effect) and an event PhotoCraft has no command for. fn sample() -> AtnFile { diff --git a/crates/psd/fuzz/atn-v12-seeds.md b/crates/psd/fuzz/atn-v12-seeds.md new file mode 100644 index 0000000000..235cd69b59 --- /dev/null +++ b/crates/psd/fuzz/atn-v12-seeds.md @@ -0,0 +1,13 @@ +The small synthetic sets in `corpus/atn/v12-*.atn` were assembled from the measured grammar +in #10603, without using PhotoCraft's parser or writer. No proprietary files or +assets are included. They also serve as inputs for `tests/it/atn_v12.rs`. + +- `v12-stop.atn`: Stop with Unicode text and a Boolean. +- `v12-values.atn`: named plug-in event, every legacy value/reference form, + bare `warp`/`list`/`time` codes, an object array with three objects and two + columns, unpaired UTF-16, opaque path/alias/data bytes, and a descriptorless step. +- `v12-commands.atn`: Gaussian Blur, Levels, Select All and Make Layer. + +`Idnt` follows the measured reference's inferred index-like layout; no real +version 12 identifier reference was available. Saving these sets produces +version 16. Keep them outside the version 16 byte-identical corpus oracle. diff --git a/crates/psd/fuzz/corpus/atn/v12-commands.atn b/crates/psd/fuzz/corpus/atn/v12-commands.atn new file mode 100644 index 0000000000000000000000000000000000000000..6b02e7ae712d29a10610d3c361f0a5df01e038ff GIT binary patch literal 404 zcmZ`$(MrTH5S-M4Ao>preu5vM^*9fqS~+^+>-Ey>QIl;+TCsmE_~wglYAcAi5O#KU zhTS9pP5~SvMTrVq>@WaID1{?Qk)RlB^x$tgRtk}6xLSF>mL@(Wf_$w#ZMeHRe|R-l zap(v+Y10?uLp^H~UHeCq22N!~y_0?-I9?psG(}B;U$S+f9*=GPEw|m|8Rv`UBTo~!YGf<>$DEm6LF$_1bOdU1l0w;$vW9PkBZ-)0^F literal 0 HcmV?d00001 diff --git a/crates/psd/fuzz/corpus/atn/v12-stop.atn b/crates/psd/fuzz/corpus/atn/v12-stop.atn new file mode 100644 index 0000000000000000000000000000000000000000..826bdabe55d2e6ebb9af56527b60a149b445cbfa GIT binary patch literal 113 zcmXYoJqm+B7)0N<5hP&e1=4vCu}hl7!Zwj*5tiLR{K3=f6q#b?F~a~AP;$o;cr&tt xsWL7KCn|Wdkp8t9F6I7$UVPQTvogsyZcPnsn$sueXeVf+6Y2l} literal 0 HcmV?d00001 diff --git a/crates/psd/fuzz/corpus/atn/v12-values.atn b/crates/psd/fuzz/corpus/atn/v12-values.atn new file mode 100644 index 0000000000000000000000000000000000000000..0fc46dde8ec6b368b474e8ce45a61bd934cf41c1 GIT binary patch literal 562 zcmYjO!A^rf6da(&n)IO2M2{pM{0uR*RTCnOX&bK#Y>{SJ$g;%Z$v^WqJo*Rv1=ja~ zgCxVeH*a<`*#}?(;1D4sG6*DS0f!_99+6Ac2v2CBd)ajyxXan|k_MAdW+G`9s>yO1 z1AF?2PF{tn;<8kZ%4>V4;wiJu3n_I|Ftb6GI%Ck8@1!g$#yj5voTp_IGd{k$9*xfJ zO56G6VyA*puW^VWi2F%W6mJs--%YKvQ6l3Ipa2nrL-0}CRtW9U6L-J3VP(sRX0sQ0da*a-yPpLpCCYh#oDzTmjmgR_AzdNy)Xm+{u8?yxdt z6}HBIEPQ{e>iRY*^h_6qwaBD;X), s: &[u8]) -> Result<()> { Ok(()) } -fn read_item(r: &mut Reader<'_>) -> Result { +fn read_item(r: &mut Reader<'_>, legacy: bool) -> Result { let expanded = r.u8()? != 0; let enabled = r.u8()? != 0; let with_dialog = r.u8()? != 0; @@ -182,13 +184,13 @@ fn read_item(r: &mut Reader<'_>) -> Result { let dictionary = read_ascii(r)?; let descriptor = match r.i32()? { 0 => None, - -1 => Some(Descriptor::read(r)?), + -1 => Some(if legacy { legacy::read(r)? } else { Descriptor::read(r)? }), n => return Err(PsdError::invalid(format!("bad descriptor flag {n}"))), }; Ok(AtnItem { expanded, enabled, with_dialog, dialog_options, event, dictionary, descriptor }) } -fn read_action(r: &mut Reader<'_>) -> Result { +fn read_action(r: &mut Reader<'_>, legacy: bool) -> Result { let function_key = r.u16()?; let shift = r.u8()? != 0; let command = r.u8()? != 0; @@ -203,17 +205,17 @@ fn read_action(r: &mut Reader<'_>) -> Result { } let mut items = Vec::with_capacity((n as usize).min(1024)); for _ in 0..n { - items.push(read_item(r)?); + items.push(read_item(r, legacy)?); } Ok(AtnAction { function_key, shift, command, color, name, expanded, items }) } -/// Parses an `.atn` file. +/// Parses a version 12 or 16 `.atn` file, warning about trailing bytes. pub fn parse(data: &[u8]) -> Result { let mut r = Reader::new(data); let version = r.u32()?; - if version != VERSION { - return Err(PsdError::Unsupported(format!("actions file version {version} (only version 16, Photoshop 6 and later, is supported)"))); + if !matches!(version, 12 | VERSION) { + return Err(PsdError::Unsupported(format!("actions file version {version} (supported versions: 12 and 16)"))); } let name = UnicodeString::read(&mut r)?; let expanded = r.u8()? != 0; @@ -225,7 +227,7 @@ pub fn parse(data: &[u8]) -> Result { } let mut actions = Vec::with_capacity((n as usize).min(256)); for _ in 0..n { - actions.push(read_action(&mut r)?); + actions.push(read_action(&mut r, version == 12)?); } let mut warnings = Vec::new(); if !r.is_empty() { @@ -430,7 +432,7 @@ mod tests { #[test] fn rejects_other_versions_and_bad_structure() { let mut bytes = write(&set()).unwrap(); - bytes[3] = 12; + bytes[3] = 7; assert!(matches!(parse(&bytes), Err(PsdError::Unsupported(_)))); // A count far beyond the data must fail before allocating. let mut huge = vec![0, 0, 0, 16, 0, 0, 0, 0, 0]; diff --git a/crates/psd/src/atn/legacy.rs b/crates/psd/src/atn/legacy.rs new file mode 100644 index 0000000000..c0f5053cbd --- /dev/null +++ b/crates/psd/src/atn/legacy.rs @@ -0,0 +1,111 @@ +//! Version 12 descriptors use bare codes and omit Unicode class names. + +use crate::descriptor::{Class, Descriptor, Id, MAX_DEPTH, ObjectArray, ReferenceItem, UnicodeString, Value, vec_for_count}; +use crate::error::{PsdError, Result}; +use crate::io::Reader; + +/// Read the unnamed, classless descriptor at the root of a legacy action step. +pub(super) fn read(r: &mut Reader<'_>) -> Result { + descriptor(r, Id::Code(*b"null"), 0) +} + +fn class(r: &mut Reader<'_>) -> Result { + Ok(Class { name: UnicodeString::default(), class_id: Id::Code(r.array()?) }) +} + +/// Read items after their enclosing class, with the same depth bound as modern descriptors. +fn descriptor(r: &mut Reader<'_>, class_id: Id, depth: usize) -> Result { + if depth > MAX_DEPTH { + return Err(PsdError::LimitExceeded("descriptor nesting too deep")); + } + let n = r.u32()?; + // A bare key, type and Boolean need nine bytes, without a modern ID length. + r.check_count(u64::from(n), 9)?; + let mut items = vec_for_count(n); + for _ in 0..n { + items.push((Id::Code(r.array()?), value(r, depth + 1)?)); + } + Ok(Descriptor { name: UnicodeString::default(), class_id, items }) +} + +/// Decode a legacy value without trying the modern dialect on malformed input. +fn value(r: &mut Reader<'_>, depth: usize) -> Result { + if depth > MAX_DEPTH { + return Err(PsdError::LimitExceeded("descriptor nesting too deep")); + } + Ok(match &r.array::<4>()? { + b"obj " => { + let n = r.u32()?; + r.check_count(u64::from(n), 8)?; + let mut items = vec_for_count(n); + for _ in 0..n { + items.push(reference(r)?); + } + Value::Reference(items) + } + ty @ (b"Objc" | b"GlbO") => { + let class_id = Id::Code(r.array()?); + let d = descriptor(r, class_id, depth + 1)?; + if ty == b"Objc" { Value::Descriptor(d) } else { Value::GlobalObject(d) } + } + b"VlLs" => { + let n = r.u32()?; + r.check_count(u64::from(n), 4)?; + let mut items = vec_for_count(n); + for _ in 0..n { + items.push(value(r, depth + 1)?); + } + Value::List(items) + } + b"doub" => Value::Double(r.f64()?), + b"UntF" => Value::UnitFloat { unit: r.array()?, value: r.f64()? }, + b"UnFl" => { + let unit = r.array()?; + let n = r.u32()?; + r.check_count(u64::from(n), 8)?; + let mut values = vec_for_count(n); + for _ in 0..n { + values.push(r.f64()?); + } + Value::UnitFloats { unit, values } + } + b"TEXT" => Value::Text(UnicodeString::read(r)?), + b"enum" => Value::Enumerated { type_id: Id::Code(r.array()?), value: Id::Code(r.array()?) }, + b"long" => Value::Integer(r.i32()?), + b"comp" => Value::LargeInteger(r.i64()?), + b"bool" => Value::Boolean(r.u8()? != 0), + b"type" => Value::Class(class(r)?), + b"GlbC" => Value::GlobalClass(class(r)?), + ty @ (b"alis" | b"Pth " | b"tdta") => { + let n = r.u32()?; + let bytes = r.bytes_u64(u64::from(n))?.to_vec(); + match ty { + b"alis" => Value::Alias(bytes), + b"Pth " => Value::Path(bytes), + _ => Value::RawData(bytes), + } + } + b"ObAr" => { + let class_id = Id::Code(r.array()?); + let prefix = r.u32()?; + Value::ObjectArray(ObjectArray { prefix, body: descriptor(r, class_id, depth + 1)? }) + } + other => return Err(PsdError::Unsupported(format!("legacy descriptor OSType {:?}", String::from_utf8_lossy(other)))), + }) +} + +/// Every legacy reference form carries a bare class, including index and identifier forms. +fn reference(r: &mut Reader<'_>) -> Result { + let ty = r.array::<4>()?; + let class = class(r)?; + Ok(match &ty { + b"prop" => ReferenceItem::Property { class, key: Id::Code(r.array()?) }, + b"Clss" => ReferenceItem::Class(class), + b"Enmr" => ReferenceItem::Enumerated { class, type_id: Id::Code(r.array()?), value: Id::Code(r.array()?) }, + b"rele" => ReferenceItem::Offset { class, offset: r.i32()? }, + b"Idnt" => ReferenceItem::Identifier { class, id: r.u32()? }, + b"indx" => ReferenceItem::Index { class, index: r.u32()? }, + b"name" => ReferenceItem::Name { class, name: UnicodeString::read(r)? }, + other => return Err(PsdError::Unsupported(format!("legacy reference item type {:?}", String::from_utf8_lossy(other)))), + }) +} diff --git a/crates/psd/tests/it/atn_v12.rs b/crates/psd/tests/it/atn_v12.rs new file mode 100644 index 0000000000..73b782b48d --- /dev/null +++ b/crates/psd/tests/it/atn_v12.rs @@ -0,0 +1,143 @@ +use photocraft_psd::PsdError; +use photocraft_psd::atn; +use photocraft_psd::descriptor::{Class, Descriptor, Id, MAX_DEPTH, ObjectArray, ReferenceItem as R, UnicodeString, Value as V}; + +const STOP: &[u8] = include_bytes!("../../fuzz/corpus/atn/v12-stop.atn"); +const VALUES: &[u8] = include_bytes!("../../fuzz/corpus/atn/v12-values.atn"); +const COMMANDS: &[u8] = include_bytes!("../../fuzz/corpus/atn/v12-commands.atn"); + +fn code(id: &[u8; 4]) -> Id { + Id::Code(*id) +} + +fn class(id: &[u8; 4]) -> Class { + Class { name: UnicodeString::default(), class_id: code(id) } +} + +fn descriptor(id: &[u8; 4], items: Vec<(Id, V)>) -> Descriptor { + Descriptor { name: UnicodeString::default(), class_id: code(id), items } +} + +/// The fixture bytes are independent of the version 16 writer and legacy reader. +#[test] +fn stop_and_named_event_preserve_headers_and_canonicalize_to_v16() { + let parsed = atn::parse(STOP).unwrap(); + assert_eq!(parsed.name, UnicodeString::new_nul("Legacy")); + let action = &parsed.actions[0]; + assert_eq!((action.function_key, action.shift, action.command, action.color), (3, true, false, 2)); + assert_eq!(action.name, UnicodeString::new_nul("Run")); + let step = &action.items[0]; + assert_eq!((step.expanded, step.enabled, step.with_dialog, step.dialog_options), (true, true, false, 2)); + assert_eq!(step.event, atn::AtnEvent::Code(*b"Stop")); + assert_eq!( + step.descriptor, + Some(descriptor(b"null", vec![(code(b"Msge"), V::Text(UnicodeString::new_nul("Review ✓"))), (code(b"Cntn"), V::Boolean(true)),])) + ); + let plugin = atn::parse(VALUES).unwrap(); + assert_eq!(plugin.actions[0].items[0].event, atn::AtnEvent::name("LegacyPlugin")); + assert!(plugin.actions[0].items[1].descriptor.is_none()); + for bytes in [STOP, VALUES, COMMANDS] { + let original = atn::parse(bytes).unwrap(); + let modern = atn::write(&original).unwrap(); + assert_eq!(&modern[..4], &16_u32.to_be_bytes()); + assert_eq!(atn::parse(&modern).unwrap(), original); + } +} + +#[test] +fn all_legacy_values_and_reference_classes_are_retained() { + let parsed = atn::parse(VALUES).unwrap(); + let actual = parsed.actions[0].items[0].descriptor.as_ref().unwrap(); + let floats = |values| V::UnitFloats { unit: *b"#Pxl", values }; + let expected = descriptor( + b"null", + vec![ + (code(b"warp"), V::Boolean(true)), + (code(b"list"), V::GlobalClass(class(b"list"))), + (code(b"time"), V::Enumerated { type_id: code(b"time"), value: code(b"warp") }), + (code(b"long"), V::Integer(-17)), + (code(b"comp"), V::LargeInteger(i64::MIN + 23)), + (code(b"doub"), V::Double(-0.5)), + (code(b"unit"), V::UnitFloat { unit: *b"#Pxl", value: 2.5 }), + (code(b"TEXT"), V::Text(UnicodeString(vec![0xd800, 0]))), + (code(b"Objc"), V::Descriptor(descriptor(b"warp", vec![(code(b"flag"), V::Boolean(false))]))), + (code(b"GlbO"), V::GlobalObject(descriptor(b"list", vec![]))), + (code(b"type"), V::Class(class(b"time"))), + (code(b"VlLs"), V::List(vec![V::Boolean(false), V::Descriptor(descriptor(b"time", vec![]))])), + ( + code(b"ObAr"), + V::ObjectArray(ObjectArray { + prefix: 3, + body: descriptor(b"time", vec![(code(b"Hrzn"), floats(vec![1.0, 2.0, 3.0])), (code(b"Vrtc"), floats(vec![4.0, 5.0, 6.0]))]), + }), + ), + ( + code(b"refs"), + V::Reference(vec![ + R::Property { class: class(b"Lyr "), key: code(b"Nm ") }, + R::Class(class(b"Dcmn")), + R::Enumerated { class: class(b"Lyr "), type_id: code(b"Ordn"), value: code(b"Trgt") }, + R::Offset { class: class(b"Lyr "), offset: -2 }, + R::Index { class: class(b"Lyr "), index: 3 }, + R::Identifier { class: class(b"Lyr "), id: 9 }, + R::Name { class: class(b"Lyr "), name: UnicodeString::new_nul("Layer ✓") }, + ]), + ), + (code(b"Pth "), V::Path(vec![0, 1, 255, 3])), + (code(b"alis"), V::Alias(b"alias".to_vec())), + (code(b"tdta"), V::RawData(vec![])), + ], + ); + assert_eq!(actual, &expected); +} + +fn with_descriptor(body: &[u8]) -> Vec { + let start = STOP.windows(4).position(|w| w == [255; 4]).unwrap() + 4; + let mut bytes = STOP[..start].to_vec(); + bytes.extend_from_slice(body); + bytes +} + +#[test] +fn truncations_fail_and_trailing_bytes_only_warn() { + for bytes in [STOP, VALUES, COMMANDS] { + for cut in 0..bytes.len() { + assert!(atn::parse(&bytes[..cut]).is_err(), "length {}, cut {cut}", bytes.len()); + } + let mut padded = bytes.to_vec(); + padded.extend_from_slice(b"junk"); + let mut parsed = atn::parse(&padded).unwrap(); + assert_eq!(parsed.warnings, ["4 trailing bytes ignored"]); + parsed.warnings.clear(); + assert_eq!(parsed, atn::parse(bytes).unwrap()); + } +} + +#[test] +fn legacy_counts_depth_and_unknown_forms_fail_without_fallback() { + assert!(atn::parse(&with_descriptor(&u32::MAX.to_be_bytes())).is_err()); + for prefix in [b"VlLs".as_slice(), b"obj ", b"UnFl#Pxl", b"TEXT", b"Pth ", b"ObArtime\0\0\0\x03"] { + let mut body = b"\0\0\0\x01test".to_vec(); + body.extend_from_slice(prefix); + body.extend_from_slice(&u32::MAX.to_be_bytes()); + assert!(atn::parse(&with_descriptor(&body)).is_err()); + } + // The smallest descriptor item is a bare key/type and one Boolean byte. + assert!(atn::parse(&with_descriptor(b"\0\0\0\x01testbool\x01")).is_ok()); + for ty in [b"VlLs\0\0\0\x01".as_slice(), b"Objcnull\0\0\0\x01test"] { + let mut body = b"\0\0\0\x01test".to_vec(); + for _ in 0..=MAX_DEPTH { + body.extend_from_slice(ty); + } + body.extend_from_slice(b"bool\x01"); + assert!(matches!(atn::parse(&with_descriptor(&body)), Err(PsdError::LimitExceeded(_)))); + } + for body in [b"\0\0\0\x01testwhat\0".as_slice(), b"\0\0\0\x01testobj \0\0\0\x01whatLyr "] { + assert!(matches!(atn::parse(&with_descriptor(body)), Err(PsdError::Unsupported(_)))); + } + for version in [0_u32, 7, 11, 13, 17, 0x0005_1600] { + let mut bytes = STOP.to_vec(); + bytes[..4].copy_from_slice(&version.to_be_bytes()); + assert!(matches!(atn::parse(&bytes), Err(PsdError::Unsupported(message)) if message.contains(&version.to_string()))); + } +} diff --git a/crates/psd/tests/it/main.rs b/crates/psd/tests/it/main.rs index ee6879aa66..e2015735ac 100644 --- a/crates/psd/tests/it/main.rs +++ b/crates/psd/tests/it/main.rs @@ -1,5 +1,6 @@ //! The crate's integration tests, linked into one test binary. +mod atn_v12; mod builder; mod corpus; mod corpus_atn; diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 5ef09a2e7e..222ef34c4c 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-26 · **Change:** minor (TIFFs up to 300,000 px a side open, decoded a group of rows at a time into the layer, #8169; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; PSD Filter Gallery: Halftone Pattern dot/circle/line settings survive PSD/PSB saves (#8940); Save for Web writes WebP, lossy or lossless, #6391; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency, #8877; layered TIFF writes the TySh text version and descriptor in PSD big-endian order while the header, matrix, warp and bounds keep container order, #9098; layered TIFF writes Photoshop's 14-byte global layer mask, so Photoshop opens the layers instead of only the composite, #9095; legacy PSD point text placed from the layout ink box after font resolution, #6094; `.atn`: 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; `.asl` layer style libraries read and written, #1051; JPEG Options on File › Save, Save As and Save a Copy, FILE-215-19 partial (no Matte, progressive Scans or size preview); 32-bit Hue/Saturation unclipped and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; JPEG Options on Save As and Save a Copy, FILE-215-19; TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; Cineon write: 10-bit gray/RGB; animated WebP frames load into video layers, #1198; Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 53 filters (39 more: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag, Invert, Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding; Amiga IFF write: 24-plane ILBM, Save As `.iff`; PSD stores spot channels as Photoshop does, ink 0 = solid, and numbers them in 1053, #7415 split; TIFF writes and reads alpha and spot channels as extra samples with Photoshop's channel resources, PSD output unchanged, FILE-215-7, FILE-215-17's TIFF part, #7415; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-11-01 · **Change:** minor (`.atn` version 12 import; version 16 output retained, #10699; TIFFs up to 300,000 px a side open, decoded a group of rows at a time into the layer, #8169; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; PSD Filter Gallery: Halftone Pattern dot/circle/line settings survive PSD/PSB saves (#8940); Save for Web writes WebP, lossy or lossless, #6391; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency, #8877; layered TIFF writes the TySh text version and descriptor in PSD big-endian order while the header, matrix, warp and bounds keep container order, #9098; layered TIFF writes Photoshop's 14-byte global layer mask, so Photoshop opens the layers instead of only the composite, #9095; legacy PSD point text placed from the layout ink box after font resolution, #6094; `.atn`: 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; `.asl` layer style libraries read and written, #1051; JPEG Options on File › Save, Save As and Save a Copy, FILE-215-19 partial (no Matte, progressive Scans or size preview); 32-bit Hue/Saturation unclipped and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; JPEG Options on Save As and Save a Copy, FILE-215-19; TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; Cineon write: 10-bit gray/RGB; animated WebP frames load into video layers, #1198; Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 53 filters (39 more: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag, Invert, Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding; Amiga IFF write: 24-plane ILBM, Save As `.iff`; PSD stores spot channels as Photoshop does, ink 0 = solid, and numbers them in 1053, #7415 split; TIFF writes and reads alpha and spot channels as extra samples with Photoshop's channel resources, PSD output unchanged, FILE-215-7, FILE-215-17's TIFF part, #7415; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency) · **Target:** Adobe Photoshop 2026 **PSD Filter Gallery (#8940, 2026-10-23):** Halftone Pattern maps its size, contrast and dot/circle/line pattern as editable settings on import and export, with the reference application's descriptor keys (`HlSz`, `Cntr`, `ScrT`: `ScrD`/`ScrC`/`ScrL`). PSD/PSB round trips pass at 8/16/32 bits. This does not claim pixel equivalence for the existing renderer. Unknown or malformed settings remain verbatim on import. @@ -133,7 +133,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | LUTs `.cube` / `.3dl` / `.look` | R/W (Export Color Lookup) | R/W / R / R | `cms/src/lutfile.rs` | | Keyboard shortcuts `.kys` | R/W | R/W | `engine/src/kys.rs` (`write`: Edit › Keyboard Shortcuts › Export…, rows carry our id; Photoshop reading our files is unverified) | | ICC profiles | R | R | Own CMS | -| Actions `.atn` | R/W | R/W | `psd/src/atn.rs` (version 16 sets, exact round trip), `engine/src/atn_cmds.rs` (Photoshop events ↔ commands, including Apply Layer Comp by name, #7101; other steps kept verbatim; Save As maps to `file.saveAs`/`file.saveACopy` when its destination is a file, #7102). Panel menu, CLI `batch --actions set.atn`, `actions.load`/`actions.save`. Photoshop 5-era version 12 files unsupported (AUTO-219-2) | +| Actions `.atn` | R/W | R/W | `psd/src/atn.rs` reads version 12 and 16 sets; writes version 16. Legacy bare identifiers, reference classes and object-array values retain their meaning; trailing bytes remain a warning (#10699). `engine/src/atn_cmds.rs` maps Photoshop events to commands, including Apply Layer Comp by name (#7101), and preserves unsupported steps. Save As maps to `file.saveAs`/`file.saveACopy` only for a file destination (#7102). Panel menu, CLI `batch --actions set.atn`, `actions.load`/`actions.save`. Version 7 and MacBinary wrappers remain unsupported. Legacy coverage uses synthetic fixtures, not licensed real version 12 sets. | | Styles `.asl` | R/W | R/W | `psd/src/asl.rs`, `io/src/asl_map.rs`; Styles panel Import/Export Styles…, `style.presets.importAsl`/`exportAsl` (#1051) | | Contours `.shc` | R/W | R/W | `psd/src/shc.rs`; Contour Editor Load…/Save… (FILE-215-15). No user contour library; not yet checked against a Photoshop-written file | | Custom shapes `.csh`, tool presets `.tpl` | R/W | no | | @@ -148,6 +148,7 @@ Embedded SVG and PDF-compatible AI/PDF/EPS payloads can use an explicit VectorCr | Date | Change | Summary | |---|---|---| +| 2026-11-01 | minor | `.atn` version 12 import uses the existing descriptor model and limits, preserves trailing-byte warnings and writes version 16. Synthetic fixtures cover legacy values, references, truncations and command-import equivalence (#10699). | | 2026-10-26 | minor | Embedded AI/PDF/EPS save-back retains native format and a separate source-bound display preview; original sources remain undoable. Native Adobe interoperability and standalone EPS/DCS remain unverified; no corpus floor or percentage changed. | | 2026-10-22 | minor | Embedded PSD vectors gain explicit bounded SVG editor/save-back; standalone EPS/DCS and native-format fidelity remain open. | | 2026-10-22 | minor | TIFF: flat pages up to 300,000 px a side open (was 2^30 px, about 32,768²): `codecs::TiffBands` decodes a group of rows at a time into the layer's tiles. The 117,504 × 22,464 px banner from #8456 opens in 3.3 s at 11.45 GB peak for a 10.56 GB layer; 30,000² 6.8 s / 6.43 GB → 1.1 s / 3.86 GB; 36 MP 220 → 52 ms. Layered TIFFs still decode whole (#8169) | From 4c6ddb9cb79f36c662a0751931ea22db199079e5 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Sun, 1 Nov 2026 11:49:17 -0800 Subject: [PATCH 120/216] Correct legacy action import expectations and document version support --- crates/engine/src/cmd/atn_cmds/tests.rs | 2 +- crates/psd/src/atn.rs | 2 ++ crates/psd/tests/it/atn_v12.rs | 2 ++ docs/development.md | 4 ++-- 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index a815e2cafa..df2cfb1f65 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -75,7 +75,7 @@ fn legacy_v12_commands_import_like_v16() { let modern = atn::write(&atn::parse(bytes).unwrap()).unwrap(); let expected = vec![ ("filter.blur.gaussianBlur".into(), json!({"radius": 2.5})), - ("image.adjustments.levels".into(), json!({"inBlack": 10.0, "inWhite": 240.0, "gamma": 1.2, "outBlack": 0.0, "outWhite": 250.0})), + ("image.adjustments.levels".into(), json!({"inBlack": 10, "inWhite": 240, "gamma": 1.2, "outBlack": 0, "outWhite": 250})), ("select.all".into(), json!({})), ("layer.new.layer".into(), json!({"name": "Legacy layer"})), ]; diff --git a/crates/psd/src/atn.rs b/crates/psd/src/atn.rs index aeeba0aab4..d96317f190 100644 --- a/crates/psd/src/atn.rs +++ b/crates/psd/src/atn.rs @@ -171,6 +171,7 @@ fn write_ascii(out: &mut Vec, s: &[u8]) -> Result<()> { Ok(()) } +/// Read one step with the descriptor dialect selected by the file version. fn read_item(r: &mut Reader<'_>, legacy: bool) -> Result { let expanded = r.u8()? != 0; let enabled = r.u8()? != 0; @@ -190,6 +191,7 @@ fn read_item(r: &mut Reader<'_>, legacy: bool) -> Result { Ok(AtnItem { expanded, enabled, with_dialog, dialog_options, event, dictionary, descriptor }) } +/// Read an action and its steps using the file's descriptor dialect. fn read_action(r: &mut Reader<'_>, legacy: bool) -> Result { let function_key = r.u16()?; let shift = r.u8()? != 0; diff --git a/crates/psd/tests/it/atn_v12.rs b/crates/psd/tests/it/atn_v12.rs index 73b782b48d..d9a773d7e2 100644 --- a/crates/psd/tests/it/atn_v12.rs +++ b/crates/psd/tests/it/atn_v12.rs @@ -98,6 +98,7 @@ fn with_descriptor(body: &[u8]) -> Vec { bytes } +/// Reject every truncated fixture while preserving trailing data as a warning. #[test] fn truncations_fail_and_trailing_bytes_only_warn() { for bytes in [STOP, VALUES, COMMANDS] { @@ -113,6 +114,7 @@ fn truncations_fail_and_trailing_bytes_only_warn() { } } +/// Reject excessive counts/depth and unsupported forms without dialect fallback. #[test] fn legacy_counts_depth_and_unknown_forms_fail_without_fallback() { assert!(atn::parse(&with_descriptor(&u32::MAX.to_be_bytes())).is_err()); diff --git a/docs/development.md b/docs/development.md index 3ecefb9f59..d340941469 100644 --- a/docs/development.md +++ b/docs/development.md @@ -612,8 +612,8 @@ so a following `ui.inspect` observes their effect. ## Photoshop Actions (.atn) -`photocraft-psd` reads and writes Photoshop Actions files (`atn`, version 16: Photoshop 6 and -later); `photocraft-engine`'s `atn_cmds` maps their events onto commands (`actions.load`, +`photocraft-psd` reads Photoshop Actions files (`atn`, versions 12 and 16) and writes version 16 +(Photoshop 6 and later); `photocraft-engine`'s `atn_cmds` maps their events onto commands (`actions.load`, `actions.save`; the Actions panel menu's Load/Save Actions…; `photocraft-cli batch --actions set.atn`). To see what a set holds (event ids, panel names, the parameter keys Photoshop recorded) when adding an event to the table: From 713aa92ba540ad8b7002a1423ca230372cf4f57f Mon Sep 17 00:00:00 2001 From: yoriswind Date: Sun, 1 Nov 2026 11:50:10 -0800 Subject: [PATCH 121/216] Exercise legacy action imports through the integration suite --- crates/engine/src/cmd/atn_cmds/tests.rs | 19 ------------------- crates/engine/tests/it/atn_v12.rs | 22 ++++++++++++++++++++++ crates/engine/tests/it/main.rs | 1 + 3 files changed, 23 insertions(+), 19 deletions(-) create mode 100644 crates/engine/tests/it/atn_v12.rs diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index df2cfb1f65..f9e633244c 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -68,25 +68,6 @@ fn b64(bytes: &[u8]) -> String { photocraft_paint::tile::b64_encode(bytes) } -/// Legacy descriptor syntax must reach the same commands and parameters as version 16. -#[test] -fn legacy_v12_commands_import_like_v16() { - let bytes = include_bytes!("../../../../psd/fuzz/corpus/atn/v12-commands.atn"); - let modern = atn::write(&atn::parse(bytes).unwrap()).unwrap(); - let expected = vec![ - ("filter.blur.gaussianBlur".into(), json!({"radius": 2.5})), - ("image.adjustments.levels".into(), json!({"inBlack": 10, "inWhite": 240, "gamma": 1.2, "outBlack": 0, "outWhite": 250})), - ("select.all".into(), json!({})), - ("layer.new.layer".into(), json!({"name": "Legacy layer"})), - ]; - for input in [bytes.as_slice(), modern.as_slice()] { - let mut s = Session::new(); - let result = s.execute("actions.load", json!({"data": b64(input)})).unwrap(); - assert_eq!(result["unsupported"], json!([])); - assert_eq!(s.actions.list[0].steps, expected); - } -} - /// A set with a mapped filter, an adjustment layer, selection steps, a layer by name, a Stop, /// a Play, a tool selection (no document effect) and an event PhotoCraft has no command for. fn sample() -> AtnFile { diff --git a/crates/engine/tests/it/atn_v12.rs b/crates/engine/tests/it/atn_v12.rs new file mode 100644 index 0000000000..0f3426e465 --- /dev/null +++ b/crates/engine/tests/it/atn_v12.rs @@ -0,0 +1,22 @@ +use photocraft_engine::{Session, paint::tile::b64_encode}; +use photocraft_psd::atn; +use serde_json::json; + +/// Legacy descriptor syntax must reach the same commands and parameters as version 16. +#[test] +fn legacy_v12_commands_import_like_v16() { + let bytes = include_bytes!("../../../psd/fuzz/corpus/atn/v12-commands.atn"); + let modern = atn::write(&atn::parse(bytes).unwrap()).unwrap(); + let expected = vec![ + ("filter.blur.gaussianBlur".into(), json!({"radius": 2.5})), + ("image.adjustments.levels".into(), json!({"inBlack": 10, "inWhite": 240, "gamma": 1.2, "outBlack": 0, "outWhite": 250})), + ("select.all".into(), json!({})), + ("layer.new.layer".into(), json!({"name": "Legacy layer"})), + ]; + for input in [bytes.as_slice(), modern.as_slice()] { + let mut s = Session::new(); + let result = s.execute("actions.load", json!({"data": b64_encode(input)})).unwrap(); + assert_eq!(result["unsupported"], json!([])); + assert_eq!(s.actions.list[0].steps, expected); + } +} diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index f6a8360a4f..1bf5305978 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -6,6 +6,7 @@ mod atn_crystallize_cell_size; mod atn_ripple_amount; mod atn_selective_color; mod atn_stroke_width; +mod atn_v12; mod background_and_mask_depth; mod background_layer_consistency; mod brush_smoothing; From 0ad7ddd3170a5d7e867152c7580ac664db275047 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Sun, 1 Nov 2026 11:53:21 -0800 Subject: [PATCH 122/216] Keep the SVG marker when normal Paste retains an internal copy --- crates/ui-egui/src/app/clipboard.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/ui-egui/src/app/clipboard.rs b/crates/ui-egui/src/app/clipboard.rs index ade57b1c25..7f10130a05 100644 --- a/crates/ui-egui/src/app/clipboard.rs +++ b/crates/ui-egui/src/app/clipboard.rs @@ -120,6 +120,9 @@ impl PhotocraftApp { }; let image = image.filter(|(w, h, bytes)| *w > 0 && *h > 0 && bytes.len() == *w as usize * *h as usize * 4); let Some((w, h, bytes)) = image else { + if self.clip_unmirrored && !self.clip_external && self.session.clipboard.is_some() { + return Ok(false); + } self.os_clip_svg = None; if (self.os_clip_sig.take().is_some() || self.clip_external) && !self.clip_unmirrored { self.session.clipboard = None; From 408f48d0bccdd65857e2e58e3b38968c8f59e0e7 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Sun, 1 Nov 2026 12:16:02 -0800 Subject: [PATCH 123/216] Reproduce named transparency and false depth-flag import failures --- crates/engine/tests/atn_mapped_identifiers.rs | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/crates/engine/tests/atn_mapped_identifiers.rs b/crates/engine/tests/atn_mapped_identifiers.rs index e9475d63ed..09bb025577 100644 --- a/crates/engine/tests/atn_mapped_identifiers.rs +++ b/crates/engine/tests/atn_mapped_identifiers.rs @@ -294,6 +294,77 @@ fn unrepresented_units_and_mode_choices_stay_verbatim() { } } +/// Explicit false layer flags mean the same thing as omitted flags for a bit-depth conversion. +#[test] +fn review_10702_depth_conversion_accepts_false_layer_flags() { + for (depth, command) in [(8, "image.mode.bits8"), (16, "image.mode.bits16"), (32, "image.mode.bits32")] { + for flags in [vec![], vec!["Fltt"], vec!["Rstr"], vec!["Fltt", "Rstr"]] { + let mut d = Descriptor::new("null").with("Dpth", D::Integer(depth)); + for key in flags { + d = d.with(key, D::Boolean(false)); + } + let action = recorded(AtnEvent::Code(*b"CnvM"), d); + assert_eq!(action.steps, vec![(command.into(), json!({}))]); + let mut s = sized_document(2, 1, 72); + s.execute("layer.new.layer", json!({"name":"Retained"})).unwrap(); + replay_saved(&mut s, &action); + assert_eq!(s.active().unwrap().doc.layers.len(), 2); + assert_eq!(s.active().unwrap().doc.depth.bits(), depth as u32); + } + } + for key in ["Fltt", "Rstr"] { + for value in [D::Boolean(true), D::Integer(0), D::Text(UnicodeString::new_nul("false"))] { + let d = Descriptor::new("null").with("Dpth", D::Integer(16)).with(key, value); + let action = recorded(AtnEvent::Code(*b"CnvM"), d.clone()); + assert_eq!(action.steps[0].0, atn_cmds::PHOTOSHOP_STEP); + assert_eq!(saved(&action).descriptor, Some(d)); + } + } +} + +/// A recorded name is resolved at playback, including names that collide with native sentinels. +#[test] +fn review_10702_transparency_names_resolve_at_playback() { + use photocraft_doc::LayerId; + use photocraft_geom::Rect; + + for name in ["Subject", "merged", ""] { + let d = Descriptor::new("null").with("null", D::Reference(vec![ReferenceItem::Property { class: class("Chnl"), key: Id::new("fsel") }])).with( + "T ", + D::Reference(vec![ + ReferenceItem::Enumerated { class: class("Chnl"), type_id: Id::new("Chnl"), value: Id::new("Trsp") }, + ReferenceItem::Name { class: class("Lyr "), name: UnicodeString::new_nul(name) }, + ]), + ); + let action = recorded(AtnEvent::Code(*b"setd"), d.clone()); + assert_eq!(action.steps[0].0, "select.loadSelection"); + let mut s = sized_document(2, 1, 72); + for alpha in [[1.0, 0.0], [0.0, 1.0]] { + let id = LayerId(s.execute("layer.new.layer", json!({"name":"Placeholder"})).unwrap()["layer"].as_u64().unwrap()); + s.edit("Fixture", |doc, _| { + let layer = doc.layer_mut(id).unwrap(); + layer.name = name.into(); + layer.surface_mut().unwrap().write_region(Rect::from_xywh(0, 0, 2, 1), &[1.0, 0.0, 0.0, alpha[0], 1.0, 0.0, 0.0, alpha[1]]); + Ok(()) + }) + .unwrap(); + } + s.execute("layer.new.layer", json!({"name":"Active"})).unwrap(); + s.execute("edit.fill", json!({"contents":"color","color":"#ffffff"})).unwrap(); + s.actions.list = vec![action.clone()]; + let played = s.execute("actions.play", json!({"action":"Recorded"})).unwrap(); + assert_eq!(played["ran"], 1, "{played}"); + let doc = &s.active().unwrap().doc; + let selected = doc.selection.as_ref().map(|mask| mask.read_region(doc.bounds())).unwrap_or(vec![1.0; 2]); + assert_eq!(selected, vec![0.0, 1.0], "{name:?}: use the topmost named layer, not the active layer or merged image"); + assert!(s.undo()); + assert!(s.active().unwrap().doc.selection.is_none()); + assert_eq!(saved(&action).descriptor, Some(d)); + replay_saved(&mut s, &action); + assert_eq!(s.active().unwrap().doc.selection.as_ref().unwrap().read_region(Rect::from_xywh(0, 0, 2, 1)), vec![0.0, 1.0]); + } +} + /// New unit validation must fail before editing, while no-resample still ignores size fields. #[test] fn native_units_validate_before_edit_and_no_resample_remains_resolution_only() { From 2854b4f9fbc0d5cb14e73f5aa5575317e6199313 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Sun, 1 Nov 2026 12:17:43 -0800 Subject: [PATCH 124/216] Preserve named transparency sources and allow false depth flags --- crates/engine/src/cmd/atn_cmds/steps_doc.rs | 3 -- crates/engine/src/cmd/atn_cmds/steps_make.rs | 18 ++++++++-- crates/engine/src/cmd/channel_cmds.rs | 4 +-- crates/engine/src/cmd/channel_cmds/refs.rs | 33 +++++++++++-------- crates/engine/tests/atn_mapped_identifiers.rs | 14 ++++++++ 5 files changed, 51 insertions(+), 21 deletions(-) diff --git a/crates/engine/src/cmd/atn_cmds/steps_doc.rs b/crates/engine/src/cmd/atn_cmds/steps_doc.rs index e012792833..900fb06485 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_doc.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_doc.rs @@ -210,9 +210,6 @@ pub(super) fn convert_mode_from(d: &Descriptor) -> Mapped { } } if let Some(depth) = get_num(d, "Dpth") { - if layer_options { - return Mapped::Unsupported { harmless: false }; - } return match depth as i64 { 8 => none("image.mode.bits8"), 16 => none("image.mode.bits16"), diff --git a/crates/engine/src/cmd/atn_cmds/steps_make.rs b/crates/engine/src/cmd/atn_cmds/steps_make.rs index 60de6fd260..9a225dca38 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_make.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_make.rs @@ -450,7 +450,13 @@ fn selection_from(d: &Descriptor) -> Mapped { if t.len() != 2 || ref_class(&t[1..]).as_deref() != Some("Lyr") { return unsupported; } - let Some(layer) = layer_target(&t[1..]) else { return unsupported }; + let Some(mut layer) = layer_target(&t[1..]) else { return unsupported }; + if let Some(Value::String(name)) = layer.get("layer") + && (name.is_empty() || name == "merged") + { + let selector = json!({"name": name}); + layer.insert("layer".into(), selector); + } p.extend(layer); } step("select.loadSelection", Value::Object(p)) @@ -752,7 +758,15 @@ pub(super) fn set_to(id: &str, p: &Value) -> Option { "transparency" => { let mut r = vec![ReferenceItem::Enumerated { class: class("Chnl"), type_id: Id::new("Chnl"), value: Id::new("Trsp") }]; if p.get("layer").is_some_and(|v| !v.is_null()) { - let D::Reference(layer) = layer_ref(p)? else { return None }; + let reference = match p.get("layer") { + // These native source defaults mean the active layer for transparency. + Some(Value::String(name)) if name.is_empty() || name == "merged" => target_ref("Lyr "), + Some(Value::Object(selector)) if selector.len() == 1 && selector.contains_key("name") => { + name_ref("Lyr ", selector.get("name")?.as_str()?) + } + _ => layer_ref(p)?, + }; + let D::Reference(layer) = reference else { return None }; r.extend(layer); } sel(D::Reference(r)) diff --git a/crates/engine/src/cmd/channel_cmds.rs b/crates/engine/src/cmd/channel_cmds.rs index 49a45cba6a..16b11ffdec 100644 --- a/crates/engine/src/cmd/channel_cmds.rs +++ b/crates/engine/src/cmd/channel_cmds.rs @@ -148,7 +148,7 @@ pub fn specs() -> Vec { "Load Selection…", ["Select"], None, - r##"{"channel":index|name|"composite"|"red|green|blue|…"|"transparency"|"mask"|"vectorMask"|"quickMask"|"selection"|"targeted" (the Channels panel's target),"layer":id?,"document":index?,"invert":bool=false,"operation":"new|add|subtract|intersect"="new"}"##, + r##"{"channel":index|name|"composite"|"red|green|blue|…"|"transparency"|"mask"|"vectorMask"|"quickMask"|"selection"|"targeted" (the Channels panel's target),"layer":id|name|{"name":str}|{"background":true}?,"document":index?,"invert":bool=false,"operation":"new|add|subtract|intersect"="new"}"##, has_doc, load_selection ), @@ -158,7 +158,7 @@ pub fn specs() -> Vec { "Apply Image…", ["Image"], None, - r##"{"source":{"document":index?,"layer":id|name|"merged"="merged","channel":"composite"|"red|…"|index|"selection"|"transparency"|"mask"="composite","invert":bool=false},"blending":"normal|multiply|screen|overlay|softLight|hardLight|colorDodge|colorBurn|darken|lighten|difference|exclusion|linearBurn|linearDodge|add|subtract|…"="multiply","opacity":0..100=100,"scale":1..2=1,"offset":-255..255=0,"preserveTransparency":bool=false,"mask":{"document","layer","channel","invert"}?,"sourceChannel|sourceDocument|sourceLayer|sourceInvert|maskChannel|…":flat form of source/mask?}"##, + r##"{"source":{"document":index?,"layer":id|name|{"name":str}|{"background":true}|"merged"="merged","channel":"composite"|"red|…"|index|"selection"|"transparency"|"mask"="composite","invert":bool=false},"blending":"normal|multiply|screen|overlay|softLight|hardLight|colorDodge|colorBurn|darken|lighten|difference|exclusion|linearBurn|linearDodge|add|subtract|…"="multiply","opacity":0..100=100,"scale":1..2=1,"offset":-255..255=0,"preserveTransparency":bool=false,"mask":{"document","layer","channel","invert"}?,"sourceChannel|sourceDocument|sourceLayer|sourceInvert|maskChannel|…":flat form of source/mask?}"##, has_apply_target, apply_image ), diff --git a/crates/engine/src/cmd/channel_cmds/refs.rs b/crates/engine/src/cmd/channel_cmds/refs.rs index 09d090b3a1..bf12284997 100644 --- a/crates/engine/src/cmd/channel_cmds/refs.rs +++ b/crates/engine/src/cmd/channel_cmds/refs.rs @@ -102,24 +102,29 @@ pub(super) fn native_pixels(doc: &Document, layer: Option) -> Result<(V } /// `"layer"` param of a source: a layer id, a layer name (the topmost of that name, as a loaded -/// Photoshop action refers to it), `{"background":true}`, or `"merged"` (default). +/// Photoshop action refers to it), `{"name":"merged"}` for a literal name, `{"background":true}`, +/// or `"merged"` (default). /// An unknown name or a missing requested Background is an error. fn source_layer(doc: &Document, p: &Value, cmd: &str) -> Result> { - match p.get("layer") { - Some(Value::Number(n)) => Ok(n.as_u64().map(LayerId)), + let name = match p.get("layer") { + Some(Value::Number(n)) => return Ok(n.as_u64().map(LayerId)), Some(v) if *v == serde_json::json!({"background": true}) => { - doc.background().map(|l| Some(l.id)).ok_or_else(|| bad(cmd, "the source document has no Background layer")) + return doc.background().map(|l| Some(l.id)).ok_or_else(|| bad(cmd, "the source document has no Background layer")); } - Some(Value::Object(_)) => Err(bad(cmd, "a source layer selector must be {\"background\":true}")), - Some(Value::String(name)) if !name.is_empty() && name != "merged" => doc - .walk() - .into_iter() - .rev() - .find(|(_, _, l)| l.name == *name) - .map(|(_, _, l)| Some(l.id)) - .ok_or_else(|| bad(cmd, format!("no source layer named \"{name}\""))), - _ => Ok(None), - } + Some(Value::Object(selector)) => selector + .get("name") + .filter(|_| selector.len() == 1) + .and_then(Value::as_str) + .ok_or_else(|| bad(cmd, "a source layer selector must be {\"background\":true} or {\"name\":\"layer name\"}"))?, + Some(Value::String(name)) if !name.is_empty() && name != "merged" => name, + _ => return Ok(None), + }; + doc.walk() + .into_iter() + .rev() + .find(|(_, _, l)| l.name == name) + .map(|(_, _, l)| Some(l.id)) + .ok_or_else(|| bad(cmd, format!("no source layer named \"{name}\""))) } /// Planes of channel `r` from document `doc` (`layer` = None: merged). A composite gives one diff --git a/crates/engine/tests/atn_mapped_identifiers.rs b/crates/engine/tests/atn_mapped_identifiers.rs index 09bb025577..6909ed37cd 100644 --- a/crates/engine/tests/atn_mapped_identifiers.rs +++ b/crates/engine/tests/atn_mapped_identifiers.rs @@ -362,6 +362,20 @@ fn review_10702_transparency_names_resolve_at_playback() { assert_eq!(saved(&action).descriptor, Some(d)); replay_saved(&mut s, &action); assert_eq!(s.active().unwrap().doc.selection.as_ref().unwrap().read_region(Rect::from_xywh(0, 0, 2, 1)), vec![0.0, 1.0]); + for layer in [json!({"name":"Missing"}), json!({"name":1}), json!({"name":name,"extra":true})] { + assert!(s.execute("select.loadSelection", json!({"channel":"transparency","layer":layer})).is_err()); + assert_eq!(s.active().unwrap().doc.selection.as_ref().unwrap().read_region(Rect::from_xywh(0, 0, 2, 1)), vec![0.0, 1.0]); + } + if name == "merged" { + for sentinel in ["merged", ""] { + let native = Action::new("Recorded", vec![("select.loadSelection".into(), json!({"channel":"transparency","layer":sentinel}))]); + let reloaded = recorded(AtnEvent::Code(*b"setd"), saved(&native).descriptor.unwrap()); + let (id, params) = &reloaded.steps[0]; + s.execute(id, params.clone()).unwrap(); + let doc = &s.active().unwrap().doc; + assert_eq!(doc.selection.as_ref().map(|mask| mask.read_region(doc.bounds())).unwrap_or(vec![1.0; 2]), vec![1.0, 1.0]); + } + } } } From 1e3a04c34bc5c7bdd8b00e6e036f2d830d5c6340 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Sun, 1 Nov 2026 12:23:00 -0800 Subject: [PATCH 125/216] Document selection descriptor encoding --- crates/engine/src/cmd/atn_cmds/steps_make.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/engine/src/cmd/atn_cmds/steps_make.rs b/crates/engine/src/cmd/atn_cmds/steps_make.rs index 9a225dca38..6acc3d3249 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_make.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_make.rs @@ -739,6 +739,7 @@ fn layer_set_to(p: &Value) -> Option { Some(desc("null").with("null", layer_ref(p)?).with("T ", D::Descriptor(t))) } +/// Encode selection and colour-setting steps as Photoshop Set descriptors. pub(super) fn set_to(id: &str, p: &Value) -> Option { let fsel = D::Reference(vec![ReferenceItem::Property { class: class("Chnl"), key: Id::new("fsel") }]); let sel = |t: D| desc("null").with("null", fsel.clone()).with("T ", t); From e8c5608966919187a8e9298aa2da30dae5ed843f Mon Sep 17 00:00:00 2001 From: yoriswind Date: Sun, 1 Nov 2026 12:38:07 -0800 Subject: [PATCH 126/216] Strengthen paste and profile playback checks Include the default frosted Glass code in the reference allowed values. --- crates/engine/tests/convert_profile_atn.rs | 20 +++++++++++++++++++- crates/ui-egui/tests/it/clipboard_svg.rs | 7 +++++-- docs/atn.md | 2 +- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/crates/engine/tests/convert_profile_atn.rs b/crates/engine/tests/convert_profile_atn.rs index 7b7cfb8d6e..f982143b97 100644 --- a/crates/engine/tests/convert_profile_atn.rs +++ b/crates/engine/tests/convert_profile_atn.rs @@ -59,7 +59,7 @@ fn absolute_and_unknown_intents_remain_distinct_with_conversion_flags() { let mut d = descriptor(); d.items.retain(|(key, _)| !key.is("Inte")); d.items.push((Id::new("Inte"), D::Enumerated { type_id: Id::new("Inte"), value: Id::new(code) })); - let mut s = Session::new(); + let mut s = intent_fixture(); let result = load(&mut s, &recorded(d.clone())); let saved = atn::parse(&save(&mut s)).unwrap(); let saved = saved.actions[0].items[0].descriptor.as_ref().unwrap(); @@ -70,6 +70,15 @@ fn absolute_and_unknown_intents_remain_distinct_with_conversion_flags() { assert_eq!(result["unsupported"], json!([])); assert_eq!(s.actions.list[0].steps[0].1["intent"], json!("absolute")); assert_eq!(saved.get("Inte"), Some(&D::Enumerated { type_id: Id::new("Inte"), value: Id::new("AClr") })); + let mut absolute = intent_fixture(); + absolute.execute("edit.convertToProfile", json!({"profile": "srgb", "intent": "absolute", "bpc": false})).unwrap(); + let mut relative = intent_fixture(); + relative.execute("edit.convertToProfile", json!({"profile": "srgb", "intent": "relative", "bpc": false})).unwrap(); + assert_ne!(rendered(&absolute), rendered(&relative), "the profile pair must distinguish rendering intents"); + let played = s.execute("actions.play", json!({"action": "Convert"})).unwrap(); + assert_eq!(played["ran"], 1); + assert!(played.get("failed").is_none(), "{played}"); + assert_eq!(rendered(&s), rendered(&absolute)); } } } @@ -89,6 +98,15 @@ fn fixture() -> Session { s } +/// A darker media white makes absolute and relative conversion produce different pixels. +fn intent_fixture() -> Session { + let mut s = fixture(); + let mut profile = Builtin::LinearSrgb.profile().clone(); + profile.white_point = profile.white_point.map(|value| value * 0.5); + Arc::make_mut(&mut s.active_mut().unwrap().doc).icc_profile = Some(profile.with_encoded_bytes().to_bytes()); + s +} + fn rendered(s: &Session) -> Vec<[f32; 4]> { photocraft_compose::flatten(&s.active().unwrap().doc).px } diff --git a/crates/ui-egui/tests/it/clipboard_svg.rs b/crates/ui-egui/tests/it/clipboard_svg.rs index d1325cbe87..5036eb42d5 100644 --- a/crates/ui-egui/tests/it/clipboard_svg.rs +++ b/crates/ui-egui/tests/it/clipboard_svg.rs @@ -15,7 +15,10 @@ fn normal_paste_preserves_newer_internal_copy_for_later_paste_as() { app.run("file.new", json!({"width": 8, "height": 6})).unwrap(); app.run("edit.fill", json!({"color": "#0000ff"})).unwrap(); app.run("edit.copy", json!({})).unwrap(); - app.run("edit.paste", json!({})).unwrap(); - app.run("edit.paste", json!({"as": "pixels"})).unwrap(); + let pasted = app.run("edit.paste", json!({})).unwrap(); + assert!(pasted["layer"].as_u64().is_some(), "normal paste did not insert a layer: {pasted}"); + let pasted_as = app.run("edit.paste", json!({"as": "pixels"})).unwrap(); + assert!(pasted_as["layer"].as_u64().is_some(), "Paste As did not insert a layer: {pasted_as}"); + assert_ne!(pasted["layer"], pasted_as["layer"]); assert_eq!(app.run("document.pixel", json!({"x": 4, "y": 3})).unwrap(), json!([0.0, 0.0, 1.0, 1.0])); } diff --git a/docs/atn.md b/docs/atn.md index 01fdeddfe7..d48214cc2f 100644 --- a/docs/atn.md +++ b/docs/atn.md @@ -775,7 +775,7 @@ Each Filter Gallery effect is recorded as its own event (one per effect), carryi |---|---|---|---|---|---|---| | `Dstr` | distortion | integer | | 5 | 0 .. 20 (out-of-range values are clamped) | maps to our `distortion` (same range and default) | | `Smth` | smoothness | integer | | 3 | 1 .. 15 (out-of-range values are clamped) | same | -| `TxtT` | textureType | enumerated | | `TxFr` texTypeFrosted | `TxBr` texTypeBrick, `TxBu` texTypeBurlap, `TxCa` texTypeCanvas, `TxSt` texTypeSandstone | | +| `TxtT` | textureType | enumerated | | `TxFr` texTypeFrosted | `TxFr` texTypeFrosted, `TxBr` texTypeBrick, `TxBu` texTypeBurlap, `TxCa` texTypeCanvas, `TxSt` texTypeSandstone | | | `Scln` | scaling | integer | | 100 | 50 .. 200 (out-of-range values are clamped) | same | | `InvT` | invertTexture | boolean | | false | | | From 21b6546907232c58a3c228ab4e15b3f3dcd4bd09 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Sun, 1 Nov 2026 12:55:02 -0800 Subject: [PATCH 127/216] Resolve recorded Action reference forms at playback --- crates/engine/src/cmd.rs | 18 +- crates/engine/src/cmd/atn_cmds/descriptors.rs | 37 ++ crates/engine/src/cmd/atn_cmds/events.rs | 9 +- crates/engine/src/cmd/atn_cmds/steps_layer.rs | 115 +++-- crates/engine/src/cmd/atn_cmds/tests.rs | 37 +- crates/engine/src/cmd/comps_cmds.rs | 42 +- .../engine/src/cmd/layer_multi_cmds/moves.rs | 54 ++- .../src/cmd/layer_multi_cmds/selection.rs | 14 + crates/engine/src/cmd/layer_params.rs | 2 + crates/engine/src/cmd/layer_references.rs | 79 ++++ crates/engine/src/session/documents.rs | 10 + crates/engine/src/session/execute.rs | 16 +- crates/engine/tests/it/atn_reference_forms.rs | 416 ++++++++++++++++++ crates/engine/tests/it/main.rs | 1 + docs/development.md | 9 + docs/file-format-parity.md | 5 +- 16 files changed, 818 insertions(+), 46 deletions(-) create mode 100644 crates/engine/src/cmd/layer_references.rs create mode 100644 crates/engine/tests/it/atn_reference_forms.rs diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 19e1e94afc..85a51f93bc 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -103,6 +103,7 @@ pub mod layer_menu_cmds; pub mod layer_multi_cmds; pub mod layer_nav_cmds; mod layer_params; +mod layer_references; pub mod layer_xmp_cmds; pub mod lens_cmds; pub mod magnetic_cmds; @@ -166,6 +167,7 @@ pub(crate) use enabled::{always, check_all_unlocked, check_pixels_unlocked, has_ use enabled::{can_redo, can_undo, has_doc, has_layer, has_pixel_layer, has_pixel_or_channel, has_selection}; pub use layer_params::blend_from_str; pub(crate) use layer_params::{NEW_LAYER_PATH, explicit_layer, layer_listing, layer_panel_option, layer_param, mask_at_depth, mask_new_layer, selection_mask}; +pub(crate) use layer_references::{recorded_drop, recorded_layer}; use ops::{arrange, merge_group, set_mask, set_mask_and_deselect}; pub(crate) use ops::{layer_copy, translate_layer}; use params::{bad, set_clipped}; @@ -586,7 +588,7 @@ fn build() -> Vec { "Select Layer", [], None, - r##"{"layer":id|str | "name":str,"occurrence":index?,"mode":"replace|toggle|range|add|remove"="replace"} (name occurrence: zero-based document walk order; required for duplicate names; toggle = ⌘-click, range = ⇧-click, remove = deselect it, keeping at least one)"##, + r##"{"layer":id|str|{"index":uint}|{"ordinal":"Trgt|Frwr|Bckw|Frnt|Back"} | "name":str,"occurrence":index?,"mode":"replace|toggle|range|add|remove"="replace"} (typed references use the recorded full layer stack; name occurrence: zero-based document walk order; required for duplicate names; toggle = ⌘-click, range = ⇧-click, remove = deselect it, keeping at least one)"##, has_doc, crate::layer_multi_cmds::select ), @@ -910,7 +912,7 @@ fn build() -> Vec { "Reorder Layer", [], None, - r##"{"layer":id?| "layers":[id,…]?, "target":id|name,"targetOccurrence":n? (which of several layers named `target`, in document order),"position":"above|below|into"="above","copy":bool=false} (layers: one undoable move, document order preserved; copy: place duplicates there and leave the layers, as ⌥-dragging a Layers panel row; a recorded action keeps the target by name)"##, + r##"{"layer":id|{"index":uint}|{"ordinal":"Trgt|Frwr|Bckw|Frnt|Back"}?| "layers":[id,…]?, "target":id|name|{"index":uint},"targetOccurrence":n? (which of several layers named `target`, in document order),"position":"above|below|into"="above","copy":bool=false} (typed target: pre-move recorded stack boundary, omit position; layers: one undoable move, document order preserved; copy: place duplicates there and leave the layers, as ⌥-dragging a Layers panel row; recorded typed references retain their form)"##, has_layer, crate::layer_multi_cmds::move_to ), @@ -1045,7 +1047,7 @@ fn build() -> Vec { label: "Activate Document", menu: &[], shortcut: None, - params: r##"{"document":index} or {"offset":int} (the document that many places after the active one in opening order, before it when negative: a Photoshop action's switch to another document) → {document}"##, + params: r##"{"document":index|{"index":uint}} or {"offset":int} (numeric document: zero-based tab index; typed index: one-based opening order; offset: places after the active document in opening order, before it when negative) → {document}"##, enabled: has_doc, run: |s, p| { // A null key counts as omitted. @@ -1060,6 +1062,16 @@ fn build() -> Vec { s.document_at_offset(o)? } (Some(d), None) => { + if let Some(selector) = d.as_object() { + let index = selector + .get("index") + .filter(|_| selector.len() == 1) + .and_then(Value::as_u64) + .ok_or_else(|| bad("document.activate", "document reference needs one non-negative index"))?; + return s + .document_at_index(index) + .and_then(|idx| if s.set_active(idx) { Ok(json!({"document": idx})) } else { Err(EngineError::NoDocument) }); + } let i = d.as_u64().ok_or_else(|| bad("document.activate", "`document` must be a tab index"))?; usize::try_from(i).map_err(|_| bad("document.activate", "`document` out of range"))? } diff --git a/crates/engine/src/cmd/atn_cmds/descriptors.rs b/crates/engine/src/cmd/atn_cmds/descriptors.rs index 9e9835218b..2392d70ee5 100644 --- a/crates/engine/src/cmd/atn_cmds/descriptors.rs +++ b/crates/engine/src/cmd/atn_cmds/descriptors.rs @@ -116,6 +116,22 @@ pub(super) fn layer_target(r: &[ReferenceItem]) -> Option> { Some(m) } +/// Select and Move retain stack references until playback rather than capturing local ids. +pub(super) fn recorded_layer_target(r: &[ReferenceItem]) -> Option> { + let selector = match r { + [ReferenceItem::Index { class, index }] if trimmed(&class.class_id) == "Lyr" => json!({"index": index}), + [ReferenceItem::Enumerated { class, type_id, value }] + if trimmed(&class.class_id) == "Lyr" && type_id.is("Ordn") && matches!(trimmed(value).as_str(), "Trgt" | "Frwr" | "Bckw" | "Frnt" | "Back") => + { + json!({"ordinal": trimmed(value)}) + } + [ReferenceItem::Index { .. } | ReferenceItem::Enumerated { .. }] => return None, + _ if matches!(target(r), Target::Index(_) | Target::Ordinal(_)) => return None, + _ => return layer_target(r), + }; + Some(Map::from_iter([("layer".into(), selector)])) +} + // ------------------------------------------------------------------ descriptor building /// A Photoshop id: codes shorter than four characters are space-padded (`H `, `Rd `), @@ -181,6 +197,27 @@ pub(super) fn layer_ref(p: &Value) -> Option { } } +/// Write the recorded form, including the padded four-byte ordinal codes. +pub(super) fn recorded_layer_ref(p: &Value) -> Option { + let Some(Value::Object(selector)) = p.get("layer") else { return layer_ref(p) }; + if !selector.contains_key("index") && !selector.contains_key("ordinal") { + return layer_ref(p); + } + if selector.len() != 1 { + return None; + } + let item = if let Some(index) = selector.get("index") { + ReferenceItem::Index { class: class("Lyr "), index: u32::try_from(index.as_u64()?).ok()? } + } else { + let ordinal = selector.get("ordinal")?.as_str()?; + if !matches!(ordinal, "Trgt" | "Frwr" | "Bckw" | "Frnt" | "Back") { + return None; + } + ReferenceItem::Enumerated { class: class("Lyr "), type_id: code("Ordn"), value: code(ordinal) } + }; + Some(D::Reference(vec![item])) +} + pub(super) fn desc(cls: &str) -> Descriptor { Descriptor::new(cls) } diff --git a/crates/engine/src/cmd/atn_cmds/events.rs b/crates/engine/src/cmd/atn_cmds/events.rs index 5723c93c59..60850ac908 100644 --- a/crates/engine/src/cmd/atn_cmds/events.rs +++ b/crates/engine/src/cmd/atn_cmds/events.rs @@ -478,7 +478,14 @@ pub(super) const EVENTS: &[Event] = &[ code: "move", name: "move", label: "Move", - ids: &["layer.translate", "layer.arrange.bringToFront", "layer.arrange.sendToBack", "layer.arrange.bringForward", "layer.arrange.sendBackward"], + ids: &[ + "layer.translate", + "layer.moveTo", + "layer.arrange.bringToFront", + "layer.arrange.sendToBack", + "layer.arrange.bringForward", + "layer.arrange.sendBackward", + ], from: move_from, to: move_to, }, diff --git a/crates/engine/src/cmd/atn_cmds/steps_layer.rs b/crates/engine/src/cmd/atn_cmds/steps_layer.rs index 9d9bf5f3f7..11a6397012 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_layer.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_layer.rs @@ -1,7 +1,7 @@ use super::convert::clip_name; use super::descriptors::{ PT, PX, Target, ang, class, code, color_desc, color_hex, desc, enm, get_bool, get_enum, get_num, get_obj, get_ref, get_text, jbool, jnum, jstr, layer_ref, - layer_target, long, name_ref, pct, px, ref_class, target, target_ref, text, trimmed, + layer_target, long, name_ref, pct, px, recorded_layer_ref, recorded_layer_target, ref_class, target, target_ref, text, trimmed, }; use super::events::pixel_target; use super::fields::{F, Kind, Mapped, fields_to, none, step, steps}; @@ -9,11 +9,13 @@ use super::steps_make::is_tool_class; use super::tables::{FILL_CONTENTS, INTERPOLATION, MENU_ITEMS, STROKE_LOCATION, blend_label, blend_ps, lookup, lookup_back}; use super::{D, Descriptor, Id, Map, ReferenceItem, UnicodeString, Value, json}; +/// Import recorded targets while retaining stack and opening-order references for playback. pub(super) fn select_from(d: &Descriptor) -> Mapped { let Some(r) = get_ref(d, "null") else { return Mapped::Unsupported { harmless: false } }; match (ref_class(r).as_deref(), target(r)) { - (Some("Lyr"), Target::Name(n)) => { - let mut p = json!({"layer": n}); + (Some("Lyr"), Target::Name(_) | Target::Index(_) | Target::Ordinal(_)) => { + let Some(params) = recorded_layer_target(r) else { return Mapped::Unsupported { harmless: false } }; + let mut p = Value::Object(params); match get_enum(d, "selectionModifier").as_deref() { Some("addToSelection") => p["mode"] = json!("add"), Some("removeFromSelection") => p["mode"] = json!("remove"), @@ -34,19 +36,6 @@ pub(super) fn select_from(d: &Descriptor) -> Mapped { // History › Step Backward / Step Forward. (Some("HstS"), Target::Ordinal(o)) if o == "Prvs" => none("edit.undo"), (Some("HstS"), Target::Ordinal(o)) if o == "Nxt" => none("edit.redo"), - (Some("Lyr"), Target::Ordinal(o)) => { - // Shift+Alt+[ / ] add the next layer to the selection instead of replacing it. - let add = get_enum(d, "selectionModifier").as_deref() == Some("addToSelection"); - match (o.as_str(), add) { - ("Frwr", false) => none("layer.selectAbove"), - ("Frwr", true) => none("layer.addAboveToSelection"), - ("Bckw", false) => none("layer.selectBelow"), - ("Bckw", true) => none("layer.addBelowToSelection"), - ("Frnt", _) => none("layer.selectTop"), - ("Back", _) => none("layer.selectBottom"), - _ => Mapped::Unsupported { harmless: false }, - } - } (Some(c), Target::Class) if is_tool_class(c) => Mapped::Unsupported { harmless: true }, // A menu item chosen while recording (`Mn`): the View menu's zoom items replay. (Some("Mn"), Target::Ordinal(item)) => match lookup(MENU_ITEMS, &item) { @@ -56,10 +45,12 @@ pub(super) fn select_from(d: &Descriptor) -> Mapped { // Switching to another open document: its offset from the active one, -1 for the previous // opened document and 1 for the next, independently of dragged tabs (#7103). (Some("Dcmn"), Target::Offset(o)) => step("document.activate", json!({"offset": o})), + (Some("Dcmn"), Target::Index(index)) if r.len() == 1 => step("document.activate", json!({"document": {"index": index}})), _ => Mapped::Unsupported { harmless: false }, } } +/// Write supported selection/navigation references without replacing their kind with a name. pub(super) fn select_to(id: &str, p: &Value) -> Option { let ord = |o: &str| D::Reference(vec![ReferenceItem::Enumerated { class: class("Lyr "), type_id: code("Ordn"), value: code(o) }]); let enumerated = |cls: &str, ty: &str, v: &str| D::Reference(vec![ReferenceItem::Enumerated { class: class(cls), type_id: code(ty), value: code(v) }]); @@ -68,8 +59,13 @@ pub(super) fn select_to(id: &str, p: &Value) -> Option { if p.get("occurrence").is_some() { return None; } - let name = jstr(p, "name").or_else(|| jstr(p, "layer"))?; - let mut d = desc("null").with("null", name_ref("Lyr ", name)); + let reference = if let Some(name) = p.get("name") { + name_ref("Lyr ", name.as_str()?) + } else { + p.get("layer").filter(|v| !v.is_null())?; + recorded_layer_ref(p)? + }; + let mut d = desc("null").with("null", reference); match jstr(p, "mode") { None | Some("replace") => {} Some("add") => d = d.with("selectionModifier", enm("selectionModifierType", "addToSelection")), @@ -95,7 +91,15 @@ pub(super) fn select_to(id: &str, p: &Value) -> Option { "layer.addBelowToSelection" => desc("null").with("null", ord("Bckw")).with("selectionModifier", enm("selectionModifierType", "addToSelection")), "layer.selectTop" => desc("null").with("null", ord("Frnt")), "layer.selectBottom" => desc("null").with("null", ord("Back")), - // An absolute tab index has no Photoshop equivalent; an offset is what Photoshop records. + "document.activate" if p.get("document").is_some_and(Value::is_object) => { + if p.get("offset").is_some_and(|v| !v.is_null()) { + return None; + } + let selector = p.get("document")?.as_object().filter(|selector| selector.len() == 1)?; + let index = u32::try_from(selector.get("index")?.as_u64()?).ok()?; + desc("null").with("null", D::Reference(vec![ReferenceItem::Index { class: class("Dcmn"), index }])) + } + // A native numeric document selects a tab; the typed index above selects opening order. "document.activate" if p.get("document").is_none_or(Value::is_null) => { let offset = i32::try_from(p.get("offset")?.as_i64()?).ok()?; desc("null").with("null", D::Reference(vec![ReferenceItem::Offset { class: class("Dcmn"), offset }])) @@ -193,6 +197,7 @@ pub(super) fn visibility_to(_: &str, p: &Value) -> Option { Some(desc("null").with("null", D::List(vec![layer_ref(p)?]))) } +/// Import supported layer moves while retaining pre-move stack boundaries. pub(super) fn move_from(d: &Descriptor) -> Mapped { let Some(r) = get_ref(d, "null") else { return Mapped::Unsupported { harmless: false } }; // The selection outline nudged. @@ -208,7 +213,7 @@ pub(super) fn move_from(d: &Descriptor) -> Mapped { if ref_class(r).as_deref() != Some("Lyr") { return Mapped::Unsupported { harmless: false }; } - let Some(mut p) = layer_target(r) else { return Mapped::Unsupported { harmless: false } }; + let Some(mut p) = layer_target(r).or_else(|| recorded_layer_target(r)) else { return Mapped::Unsupported { harmless: false } }; match d.get("T ") { Some(D::Descriptor(t)) => { let unit = |k: &str| match t.get(k) { @@ -233,15 +238,18 @@ pub(super) fn move_from(d: &Descriptor) -> Mapped { step("layer.translate", Value::Object(p)) } Some(D::Reference(t)) => { + if let Target::Index(index) = target(t) { + if t.len() != 1 || ref_class(t).as_deref() != Some("Lyr") { + return Mapped::Unsupported { harmless: false }; + } + p.insert("target".into(), json!({"index": index})); + return step("layer.moveTo", Value::Object(p)); + } let id = match target(t) { Target::Ordinal(o) if o == "Frnt" => "layer.arrange.bringToFront", Target::Ordinal(o) if o == "Back" => "layer.arrange.sendToBack", Target::Ordinal(o) if o == "Nxt" => "layer.arrange.bringForward", Target::Ordinal(o) if o == "Prvs" => "layer.arrange.sendBackward", - Target::Offset(1) => "layer.arrange.bringForward", - Target::Offset(-1) => "layer.arrange.sendBackward", - // Index 0 is the bottom of the stack. - Target::Index(0) => "layer.arrange.sendToBack", _ => return Mapped::Unsupported { harmless: false }, }; step(id, Value::Object(p)) @@ -250,9 +258,20 @@ pub(super) fn move_from(d: &Descriptor) -> Mapped { } } +/// Encode an indexed boundary or a supported relative move without losing its source selector. pub(super) fn move_to(id: &str, p: &Value) -> Option { let ord = |o: &str| D::Reference(vec![ReferenceItem::Enumerated { class: class("Lyr "), type_id: code("Ordn"), value: code(o) }]); let t = match id { + "layer.moveTo" => { + if ["layers", "position", "targetOccurrence"].iter().any(|key| p.get(key).is_some_and(|value| !value.is_null())) + || p.get("copy").is_some_and(|value| value.as_bool() != Some(false)) + { + return None; + } + let selector = p.get("target")?.as_object().filter(|selector| selector.len() == 1)?; + let index = u32::try_from(selector.get("index")?.as_u64()?).ok()?; + D::Reference(vec![ReferenceItem::Index { class: class("Lyr "), index }]) + } "layer.translate" => { let unit = match jstr(p, "units") { None | Some("px") => PX, @@ -268,7 +287,7 @@ pub(super) fn move_to(id: &str, p: &Value) -> Option { "layer.arrange.sendBackward" => ord("Prvs"), _ => return None, }; - Some(desc("null").with("null", layer_ref(p)?).with("T ", t)) + Some(desc("null").with("null", recorded_layer_ref(p)?).with("T ", t)) } pub(super) fn duplicate_from(d: &Descriptor) -> Mapped { @@ -326,18 +345,52 @@ pub(super) fn duplicate_to(id: &str, p: &Value) -> Option { Some(d) } -/// Only a comp name in the active document is portable across action playback. +/// Retain measured comp reference forms and resolve them against the playback document. pub(super) fn apply_comp_from(d: &Descriptor) -> Mapped { - match get_ref(d, "null") { + let comp = match get_ref(d, "null") { Some([ReferenceItem::Name { class, name }]) if trimmed(&class.class_id) == "compsClass" => { - step("layerComp.apply", json!({"comp": name.to_string_lossy()})) + json!(name.to_string_lossy()) } - _ => Mapped::Unsupported { harmless: false }, - } + Some([ReferenceItem::Index { class, index }]) if trimmed(&class.class_id) == "compsClass" => json!({"index": index}), + Some([ReferenceItem::Identifier { class, id }]) if trimmed(&class.class_id) == "compsClass" => json!({"id": id}), + Some([ReferenceItem::Enumerated { class, type_id, value }]) + if trimmed(&class.class_id) == "compsClass" + && type_id.is("Ordn") + && matches!(trimmed(value).as_str(), "Trgt" | "Nxt" | "Prvs" | "Frst" | "Lst" | "Al") => + { + json!({"ordinal": trimmed(value)}) + } + _ => return Mapped::Unsupported { harmless: false }, + }; + step("layerComp.apply", json!({"comp": comp})) } +/// Write the original reference kind, including padded ordinal codes and signed index bits. pub(super) fn apply_comp_to(_: &str, p: &Value) -> Option { - Some(desc("null").with("null", name_ref("compsClass", jstr(p, "comp")?))) + let reference = match p.get("comp")? { + Value::String(name) => name_ref("compsClass", name), + Value::Object(selector) if selector.len() == 1 => { + let item = if let Some(index) = selector.get("index") { + let index = if let Some(index) = index.as_u64() { + u32::try_from(index).ok()? + } else { + u32::from_be_bytes(i32::try_from(index.as_i64()?).ok()?.to_be_bytes()) + }; + ReferenceItem::Index { class: class("compsClass"), index } + } else if let Some(id) = selector.get("id") { + ReferenceItem::Identifier { class: class("compsClass"), id: u32::try_from(id.as_u64()?).ok()? } + } else { + let ordinal = selector.get("ordinal")?.as_str()?; + if !matches!(ordinal, "Trgt" | "Nxt" | "Prvs" | "Frst" | "Lst" | "Al") { + return None; + } + ReferenceItem::Enumerated { class: class("compsClass"), type_id: code("Ordn"), value: code(ordinal) } + }; + D::Reference(vec![item]) + } + _ => return None, + }; + Some(desc("null").with("null", reference)) } pub(super) fn play_from(d: &Descriptor) -> Mapped { diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index f9e633244c..6ff1c92d46 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -184,10 +184,9 @@ fn layer_comp_apply_unknown_references_are_kept_and_stop_playback() { Some(D::Integer(1)), Some(D::Reference(vec![])), Some(name_ref("Lyr ", "Comp 1")), - Some(target_ref("compsClass")), Some(class_ref("compsClass")), - Some(D::Reference(vec![ReferenceItem::Index { class: class("compsClass"), index: 1 }])), - Some(D::Reference(vec![ReferenceItem::Identifier { class: class("compsClass"), id: 1 }])), + Some(D::Reference(vec![ReferenceItem::Offset { class: class("compsClass"), offset: 1 }])), + Some(D::Reference(vec![ReferenceItem::Enumerated { class: class("compsClass"), type_id: code("Ordn"), value: code("unsupported") }])), Some(D::Reference(vec![ ReferenceItem::Name { class: class("compsClass"), name: UnicodeString::new_nul("Comp 1") }, ReferenceItem::Name { class: class("Dcmn"), name: UnicodeString::new_nul("Other") }, @@ -473,6 +472,7 @@ fn settings_at_the_value_the_event_plays_still_export() { } } +/// Every exported command has a canonical import, including recorded full-stack navigation. #[test] fn every_mapped_command_exports_and_imports_back() { let cases: Vec<(&str, Value)> = vec![ @@ -564,6 +564,11 @@ fn every_mapped_command_exports_and_imports_back() { ("layer.setLabelColor", json!({"color": "red"})), ("layer.select", json!({"layer": "Shadow", "mode": "add"})), ("layer.select", json!({"layer": "Shadow", "mode": "range"})), + ("layer.select", json!({"layer": {"index": 2}})), + ("layer.moveTo", json!({"target": {"index": 0}})), + ("layer.moveTo", json!({"layer": {"index": 2}, "target": {"index": 4}})), + ("layer.selectAbove", json!({})), + ("layer.selectBottom", json!({})), ("layer.selectTop", json!({})), ("layer.selectBelow", json!({})), ("layer.addBelowToSelection", json!({})), @@ -641,6 +646,7 @@ fn every_mapped_command_exports_and_imports_back() { ("actions.play", json!({"action": "Other", "set": "S"})), (STOP_MESSAGE, json!({"message": "Hi", "allowContinue": false})), ("document.activate", json!({"offset": -1})), + ("document.activate", json!({"document": {"index": 2}})), // The TrueGrit Texture Supply sets (Debaser, Distress Press, Halftone Zine Machine). ("filter.distort.ripple", json!({"amount": 50, "size": "medium"})), ("filter.pixelate.crystallize", json!({"cellSize": 5})), @@ -672,6 +678,9 @@ fn every_mapped_command_exports_and_imports_back() { ("history.clear", json!({})), ("history.newSnapshot", json!({"name": "CopyCat - action result", "from": "fullDocument", "replaceExisting": true})), ("layerComp.apply", json!({"comp": "Comp 1"})), + ("layerComp.apply", json!({"comp": {"index": 2}})), + ("layerComp.apply", json!({"comp": {"id": 7}})), + ("layerComp.apply", json!({"comp": {"ordinal": "Nxt"}})), ]; for (id, params) in &cases { let item = export_item(id, params, "S").unwrap_or_else(|| panic!("{id} does not export")); @@ -684,6 +693,23 @@ fn every_mapped_command_exports_and_imports_back() { match (e.from)(item.descriptor.as_ref().unwrap()) { Mapped::Steps(steps) => { assert_eq!(steps.len(), 1, "{id}: {steps:?}"); + let navigation = match *id { + "layer.selectAbove" => Some(("Frwr", false)), + "layer.selectBelow" => Some(("Bckw", false)), + "layer.selectTop" => Some(("Frnt", false)), + "layer.selectBottom" => Some(("Back", false)), + "layer.addAboveToSelection" => Some(("Frwr", true)), + "layer.addBelowToSelection" => Some(("Bckw", true)), + _ => None, + }; + if let Some((ordinal, add)) = navigation { + let mut expected = json!({"layer": {"ordinal": ordinal}}); + if add { + expected["mode"] = json!("add"); + } + assert_eq!(steps, vec![("layer.select".into(), expected)]); + continue; + } assert_eq!(steps[0].0, *id, "{id} came back as another command"); let mut back = steps[0].1.clone(); normalise(&mut back); @@ -739,8 +765,6 @@ fn every_mapped_command_exports_and_imports_back() { "layer.newAdjustmentLayer.vibrance", "layer.rasterize.layer", "layer.renameLayer", - "layer.selectAbove", - "layer.selectBottom", "layer.setAdjustment", "select.allLayers", "select.deselectLayers", @@ -1483,10 +1507,11 @@ fn remaining_events_import_in_their_photoshop_shapes() { let more_steps = &more.actions[0].steps; assert_eq!( more_steps.iter().map(|(id, _)| id.as_str()).collect::>(), - ["edit.transform", "select.transformSelection", "layer.arrange.sendToBack", "view.fitOnScreen", "tools.setColors"] + ["edit.transform", "select.transformSelection", "layer.moveTo", "view.fitOnScreen", "tools.setColors"] ); assert_eq!(more_steps[0].1, json!({"dx": 4, "dy": 6, "scaleX": 50, "scaleY": 50})); assert_eq!(more_steps[1].1, json!({"dx": 3, "dy": -2})); + assert_eq!(more_steps[2].1, json!({"target": {"index": 0}})); assert_eq!(more_steps[4].1, json!({"foreground": "#000000"})); assert!(more.unsupported.is_empty()); assert_eq!(rest_steps[0].1, json!({"if": "hasLayers", "then": "FS8-Layers", "else": "FS8-No-Layers"})); diff --git a/crates/engine/src/cmd/comps_cmds.rs b/crates/engine/src/cmd/comps_cmds.rs index db239c328f..29b9596a45 100644 --- a/crates/engine/src/cmd/comps_cmds.rs +++ b/crates/engine/src/cmd/comps_cmds.rs @@ -202,8 +202,38 @@ fn apply(s: &mut Session, id: u32, label: &str) -> Result { Ok(json!({"comp": id, "missingLayers": missing})) } +/// Resolve recorded comp forms without confusing panel indexes with persisted ids. +fn recorded_comp(doc: &Document, selector: &Value) -> Result> { + const CMD: &str = "layerComp.apply"; + let fields = selector.as_object().filter(|fields| fields.len() == 1).ok_or_else(|| bad(CMD, "comp reference needs one index, id or ordinal"))?; + if let Some(index) = fields.get("index") { + if let Some(index) = index.as_u64() { + return Ok(index.checked_sub(1).and_then(|index| usize::try_from(index).ok()).and_then(|index| doc.layer_comps.get(index)).map(|comp| comp.id)); + } + if index.as_i64().is_some_and(|index| index < 0) { + return Ok(None); + } + return Err(bad(CMD, "comp index must be an integer")); + } + if let Some(id) = fields.get("id") { + let id = crate::commands::u32_id(CMD, "comp id", id)?; + return doc.comp(id).map(|comp| Some(comp.id)).ok_or_else(|| bad(CMD, format!("no layer comp with id {id}"))); + } + let ordinal = fields.get("ordinal").and_then(Value::as_str).ok_or_else(|| bad(CMD, "comp reference needs an index, id or ordinal"))?; + match ordinal { + "Trgt" | "Nxt" | "Prvs" => Ok(doc.last_applied_comp.filter(|id| doc.comp(*id).is_some()).or_else(|| doc.layer_comps.first().map(|comp| comp.id))), + "Frst" | "Lst" | "Al" => Ok(doc.layer_comps.first().map(|comp| comp.id)), + _ => Err(bad(CMD, format!("unsupported comp ordinal {ordinal}"))), + } +} + +/// Apply the selected comp; an out-of-range recorded index succeeds without an edit. fn apply_cmd(s: &mut Session, p: &Value) -> Result { let d = s.active().ok_or(EngineError::NoDocument)?; + if let Some(selector @ Value::Object(_)) = p.get("comp") { + let Some(id) = recorded_comp(&d.doc, selector)? else { return Ok(json!({"comp": null, "unchanged": true})) }; + return apply(s, id, "Apply Layer Comp"); + } let id = comp_param(&d.doc, p, "layerComp.apply")?; apply(s, id, "Apply Layer Comp") } @@ -213,6 +243,9 @@ fn apply_cmd(s: &mut Session, p: &Value) -> Result { /// (#7181); by name the step finds that document's comp of the name, or stops with an error. /// The comp is the one just applied, so `{}` and `null` (the last applied) record its name too. pub(crate) fn recorded_apply(s: &Session, mut p: Value) -> Value { + if p.get("comp").is_some_and(Value::is_object) { + return p; + } let Some(d) = s.active() else { return p }; let Some(comp) = d.doc.last_applied_comp.and_then(|id| d.doc.comp(id)) else { return p }; // `null` params run as `{}` (the last applied comp), so they are recorded by name too. @@ -461,7 +494,14 @@ pub fn specs() -> Vec { has_comps, update_comp ), - spec!("layerComp.apply", "Apply Layer Comp", &[], r##"{"comp":id|name? (default: last applied)} → {comp, missingLayers}"##, has_comps, apply_cmd), + spec!( + "layerComp.apply", + "Apply Layer Comp", + &[], + r##"{"comp":id|name|{"index":int}|{"id":uint}|{"ordinal":"Trgt|Nxt|Prvs|Frst|Lst|Al"}? (default: last applied; typed index is one-based panel order, invalid indexes do nothing)} → {comp, missingLayers}"##, + has_comps, + apply_cmd + ), spec!("layerComp.previous", "Apply Previous Layer Comp", &[], "{}", has_comps, |s, _| step(s, -1)), spec!("layerComp.next", "Apply Next Layer Comp", &[], "{}", has_comps, |s, _| step(s, 1)), spec!("layerComp.delete", "Delete Layer Comp", &[], r##"{"comp":id|name?}"##, has_comps, delete), diff --git a/crates/engine/src/cmd/layer_multi_cmds/moves.rs b/crates/engine/src/cmd/layer_multi_cmds/moves.rs index 2ac5d605ef..ed26ba1030 100644 --- a/crates/engine/src/cmd/layer_multi_cmds/moves.rs +++ b/crates/engine/src/cmd/layer_multi_cmds/moves.rs @@ -396,6 +396,38 @@ fn apply_move(doc: &mut Document, moved_ids: &[LayerId], target: LayerId, pos: & /// selected nodes are detached, and every reference is validated before editing. pub fn move_to(s: &mut Session, p: &Value) -> Result { const CMD: &str = "layer.moveTo"; + let indexed = p.get("target").is_some_and(Value::is_object); + let resolved; + let p = if let Some(Value::Object(selector)) = p.get("target") { + let index = selector + .get("index") + .filter(|_| selector.len() == 1) + .and_then(Value::as_u64) + .ok_or_else(|| bad(CMD, "target reference needs one non-negative index"))?; + if p.get("position").is_some_and(|value| !value.is_null()) { + return Err(bad(CMD, "an indexed target already defines its drop position")); + } + let doc = &s.active().ok_or(EngineError::NoDocument)?.doc; + let (target, position) = crate::commands::recorded_drop(doc, index)?; + let mut fields = p.as_object().cloned().ok_or_else(|| bad(CMD, "params must be an object"))?; + fields.insert("target".into(), json!(target.0)); + fields.insert("position".into(), json!(position)); + if !fields.contains_key("layer") && !fields.contains_key("layers") { + match selected(s).as_slice() { + [layer] => { + fields.insert("layer".into(), json!(layer.0)); + } + layers if !layers.is_empty() => { + fields.insert("layers".into(), json!(layers.iter().map(|layer| layer.0).collect::>())); + } + _ => {} + } + } + resolved = Value::Object(fields); + &resolved + } else { + p + }; let pos = p.get("position").and_then(Value::as_str).unwrap_or("above"); if !matches!(pos, "above" | "below" | "into") { return Err(bad(CMD, "position must be above, below, or into")); @@ -432,7 +464,7 @@ pub fn move_to(s: &mut Session, p: &Value) -> Result { } } let moved_ids = top_level(doc, &requested); - if moved_ids.len() == 1 && moved_ids[0] == target && batch.is_none() && !copy { + if moved_ids.len() == 1 && moved_ids[0] == target && (batch.is_none() || indexed) && !copy { // Preserve single-layer drag-to-self semantics. return Ok(Value::Null); } @@ -449,6 +481,26 @@ pub fn move_to(s: &mut Session, p: &Value) -> Result { return Err(bad(CMD, "target is not a group")); } + if indexed + && !copy + && let [source] = moved_ids.as_slice() + { + let source_path = doc.path_of(*source).ok_or(EngineError::NoLayer(*source))?; + if let Some((&at, parent)) = source_path.split_last() { + let unchanged = match pos { + "below" => target_path.split_last().is_some_and(|(&to, target_parent)| parent == target_parent && at.checked_add(1) == Some(to)), + "into" => { + parent == target_path.as_slice() + && doc.layer(target).and_then(Layer::children).is_some_and(|children| at.checked_add(1) == Some(children.len())) + } + _ => false, + }; + if unchanged { + return Ok(Value::Null); + } + } + } + let original_active = st.active_layer; let add_copy = crate::commands::layer_panel_option(s, "addCopyToCopiedLayersAndGroups", true); let (count, copies) = s.edit(if copy { "Duplicate Layer" } else { "Reorder Layers" }, |doc, active| { diff --git a/crates/engine/src/cmd/layer_multi_cmds/selection.rs b/crates/engine/src/cmd/layer_multi_cmds/selection.rs index a0b5f76a77..218ca068f0 100644 --- a/crates/engine/src/cmd/layer_multi_cmds/selection.rs +++ b/crates/engine/src/cmd/layer_multi_cmds/selection.rs @@ -222,6 +222,20 @@ fn range_order(layers: &[Layer], include_collapsed: bool, out: &mut Vec /// `layer.select`: `{"layer": id, "mode": "replace|toggle|range|add|remove"}`. pub fn select(s: &mut Session, p: &Value) -> Result { + if let Some(selector @ Value::Object(fields)) = p.get("layer") + && (fields.contains_key("index") || fields.contains_key("ordinal")) + && crate::commands::recorded_layer(s, selector)?.is_none() + { + if p.get("name").is_some() || p.get("occurrence").is_some() { + return Err(bad("layer.select", "a layer reference cannot be combined with name or occurrence")); + } + let mode = p.get("mode").and_then(Value::as_str).unwrap_or("replace"); + if !matches!(mode, "replace" | "toggle" | "range" | "add" | "remove") { + return Err(bad("layer.select", format!("unknown mode `{mode}`"))); + } + // A group-end index leaves the active layer, anchor and full selection unchanged. + return Ok(json!({"selected": selected(s).iter().map(|id| id.0).collect::>()})); + } let id = selection_target(s, p)?; let mode = p.get("mode").and_then(Value::as_str).unwrap_or("replace"); let st = s.active().ok_or(EngineError::NoDocument)?; diff --git a/crates/engine/src/cmd/layer_params.rs b/crates/engine/src/cmd/layer_params.rs index f298893b33..af67bf36e7 100644 --- a/crates/engine/src/cmd/layer_params.rs +++ b/crates/engine/src/cmd/layer_params.rs @@ -21,6 +21,7 @@ pub(crate) fn explicit_layer(s: &Session, p: &Value) -> Result> .map(|(_, _, l)| Some(l.id)) .ok_or_else(|| EngineError::Other(format!("no layer named \"{name}\"; {}", layer_listing(&d.doc)))) } + Some(v @ Value::Object(fields)) if fields.contains_key("index") || fields.contains_key("ordinal") => super::recorded_layer(s, v), Some(Value::Null) | None => Ok(None), Some(other) => Err(EngineError::Other(format!("`layer` must be an id or a name, not {other}"))), } @@ -51,6 +52,7 @@ pub(crate) fn layer_listing(doc: &Document) -> String { pub(crate) fn layer_param(s: &Session, p: &Value) -> Result { match explicit_layer(s, p)? { Some(id) => Ok(id), + None if p.get("layer").is_some_and(Value::is_object) => Err(EngineError::Other("a group end is not a layer".into())), None => s.active().and_then(|d| d.active_layer).ok_or(EngineError::Other("no active layer".into())), } } diff --git a/crates/engine/src/cmd/layer_references.rs b/crates/engine/src/cmd/layer_references.rs new file mode 100644 index 0000000000..3a07ad63a1 --- /dev/null +++ b/crates/engine/src/cmd/layer_references.rs @@ -0,0 +1,79 @@ +//! Recorded layer references use the full stack, including hidden group-end entries. + +use super::{Document, EngineError, LayerId, Result, Session, Value}; + +#[derive(Clone, Copy)] +enum Entry { + Layer(LayerId), + GroupEnd(LayerId), +} + +/// Bottom-to-top recording order, independent of collapsed Layers panel rows. +fn stack(doc: &Document) -> Vec { + let mut pending: Vec<_> = doc.layers.iter().rev().map(|layer| (layer, false)).collect(); + let mut out = Vec::new(); + while let Some((layer, visited)) = pending.pop() { + if !visited && let Some(children) = layer.children() { + out.push(Entry::GroupEnd(layer.id)); + pending.push((layer, true)); + pending.extend(children.iter().rev().map(|child| (child, false))); + } else { + out.push(Entry::Layer(layer.id)); + } + } + out +} + +fn bad(message: impl Into) -> EngineError { + EngineError::Other(message.into()) +} + +/// A Background occupies index zero; without it the first stack entry is one. +fn at_index(doc: &Document, index: u64) -> Result { + let first = u64::from(doc.background().is_none()); + let entry = index.checked_sub(first).and_then(|n| usize::try_from(n).ok()).and_then(|n| stack(doc).get(n).copied()); + entry.ok_or_else(|| bad(format!("no layer at stack index {index}"))) +} + +/// Resolve one typed selector; a group-end index succeeds without selecting a layer. +pub(crate) fn recorded_layer(s: &Session, selector: &Value) -> Result> { + let fields = selector.as_object().filter(|fields| fields.len() == 1).ok_or_else(|| bad("layer reference needs one index or ordinal"))?; + let st = s.active().ok_or(EngineError::NoDocument)?; + if let Some(index) = fields.get("index") { + let index = index.as_u64().ok_or_else(|| bad("layer index must be a non-negative integer"))?; + return Ok(match at_index(&st.doc, index)? { + Entry::Layer(id) => Some(id), + Entry::GroupEnd(_) => None, + }); + } + let ordinal = fields.get("ordinal").and_then(Value::as_str).ok_or_else(|| bad("layer reference needs an index or ordinal"))?; + let rows: Vec<_> = stack(&st.doc).into_iter().filter_map(|entry| if let Entry::Layer(id) = entry { Some(id) } else { None }).collect(); + let last = rows.len().checked_sub(1).ok_or_else(|| bad("the document has no layers"))?; + let current = st.active_layer.and_then(|id| rows.iter().position(|layer| *layer == id)); + let target = match ordinal { + "Frnt" => last, + "Back" => 0, + "Trgt" => current.ok_or_else(|| bad("no active layer"))?, + "Frwr" => current.ok_or_else(|| bad("no active layer"))?.saturating_add(1).min(last), + "Bckw" => current.ok_or_else(|| bad("no active layer"))?.saturating_sub(1), + _ => return Err(bad(format!("unsupported layer ordinal {ordinal}"))), + }; + rows.get(target).copied().map(Some).ok_or_else(|| bad("no layer there")) +} + +/// Convert a pre-move stack boundary to an existing layer.moveTo drop position. +pub(crate) fn recorded_drop(doc: &Document, index: u64) -> Result<(LayerId, &'static str)> { + if index == 0 { + if doc.background().is_some() { + return Err(bad("cannot move a layer below the Background")); + } + return doc.layers.first().map(|layer| (layer.id, "below")).ok_or_else(|| bad("the document has no layers")); + } + match at_index(doc, index)? { + Entry::GroupEnd(id) => Ok((id, "below")), + Entry::Layer(id) => { + let position = if doc.layer(id).is_some_and(|layer| layer.is_group()) { "into" } else { "below" }; + Ok((id, position)) + } + } +} diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index a82f6c18e6..f4516ddeb3 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -114,6 +114,16 @@ impl Session { self.docs.iter().position(|st| st.doc.id == *id).ok_or(EngineError::NoDocument) } + /// A recorded index is one-based opening order, even after tabs are dragged. + pub(crate) fn document_at_index(&self, index: u64) -> Result { + let id = index + .checked_sub(1) + .and_then(|index| usize::try_from(index).ok()) + .and_then(|index| self.open_document_order.get(index)) + .ok_or_else(|| EngineError::Other(format!("no document at opening index {index}")))?; + self.docs.iter().position(|st| st.doc.id == *id).ok_or(EngineError::NoDocument) + } + /// Add a document (from File → New, an import, etc.) and make it active. /// Preserve its identity unless another open document already owns it. /// Identity-dependent callers must read the admitted document at the returned index. diff --git a/crates/engine/src/session/execute.rs b/crates/engine/src/session/execute.rs index 2c7ef1ba1c..b939e73271 100644 --- a/crates/engine/src/session/execute.rs +++ b/crates/engine/src/session/execute.rs @@ -143,6 +143,16 @@ impl Session { if self.names_existing_layer(spec.id, params) { return Ok(()); } + // A recorded invalid comp index succeeds without an edit, even with no comps. + // Ordinary Apply and references by id/name retain the command's usual precondition. + if spec.id == "layerComp.apply" + && params + .get("comp") + .and_then(Value::as_object) + .is_some_and(|fields| fields.len() == 1 && fields.get("index").is_some_and(|index| index.as_u64().is_some() || index.as_i64().is_some())) + { + return self.active().map(|_| ()).ok_or_else(|| "no document open".into()); + } // `ifPresent`: with no Background there is nothing to do, which isn't an error. if spec.id == "layer.new.layerFromBackground" && params.get("ifPresent").and_then(Value::as_bool) == Some(true) { return self.active().map(|_| ()).ok_or_else(|| "no document open".into()); @@ -171,7 +181,11 @@ impl Session { if !spec.params.contains("\"layer\":id") { return None; } - let target = LayerId(params.get("layer")?.as_u64()?); + let layer = params.get("layer")?; + if !(layer.is_number() || layer.is_object()) { + return None; + } + let target = commands::explicit_layer(self, params).ok()??; let d = self.active_mut()?; d.doc.layer(target)?; let active = d.active_layer.replace(target); diff --git a/crates/engine/tests/it/atn_reference_forms.rs b/crates/engine/tests/it/atn_reference_forms.rs new file mode 100644 index 0000000000..bee097ef59 --- /dev/null +++ b/crates/engine/tests/it/atn_reference_forms.rs @@ -0,0 +1,416 @@ +use photocraft_engine::{Session, actions_cmds::Action, atn_cmds}; +use photocraft_psd::atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}; +use photocraft_psd::descriptor::{Class, Descriptor, Id, ReferenceItem, UnicodeString, Value as D}; +use serde_json::{Value, json}; + +fn class(name: &str) -> Class { + Class { name: UnicodeString::new_nul(""), class_id: Id::new(name) } +} + +fn ordinal(name: &str) -> ReferenceItem { + ReferenceItem::Enumerated { class: class("Lyr "), type_id: Id::new("Ordn"), value: Id::new(name) } +} + +fn load(s: &mut Session, reference: ReferenceItem) { + let descriptor = Descriptor::new("null").with("null", D::Reference(vec![reference])); + s.actions.list = vec![imported(AtnEvent::Code(*b"slct"), descriptor)]; +} + +fn bytes(event: AtnEvent, descriptor: Descriptor) -> Vec { + let file = AtnFile { + name: UnicodeString::new_nul("References"), + actions: vec![AtnAction { + name: UnicodeString::new_nul("Select"), + items: vec![AtnItem { event, descriptor: Some(descriptor), ..Default::default() }], + ..Default::default() + }], + ..Default::default() + }; + atn::write(&file).unwrap() +} + +fn imported(event: AtnEvent, descriptor: Descriptor) -> Action { + let mut result = atn_cmds::import(&bytes(event, descriptor)).unwrap(); + assert!(result.unsupported.is_empty(), "{:?}", result.unsupported); + result.actions.remove(0) +} + +/// Check actual saved reference kinds/codes, not only a matching import/export implementation. +fn roundtrip(action: &Action, reference: &ReferenceItem, target: Option<&ReferenceItem>) { + let (data, skipped) = atn_cmds::export("References", &[action], &[], false).unwrap(); + assert!(skipped.is_empty()); + let file = atn::parse(&data).unwrap(); + let descriptor = file.actions[0].items[0].descriptor.as_ref().unwrap(); + assert_eq!(descriptor.get("null"), Some(&D::Reference(vec![reference.clone()]))); + if let Some(target) = target { + assert_eq!(descriptor.get("T "), Some(&D::Reference(vec![target.clone()]))); + } + assert_eq!(atn_cmds::import(&data).unwrap().actions[0].steps, action.steps); +} + +fn replay(s: &mut Session, action: &Action) { + s.actions.list = vec![action.clone()]; + let result = s.execute("actions.play", json!({"action":"Select"})).unwrap(); + assert_eq!(result["ran"], 1, "{result}"); + assert!(result.get("failed").is_none(), "{result}"); +} + +fn layer(s: &Session, name: &str) -> u64 { + s.active().unwrap().doc.walk().into_iter().find(|(_, _, layer)| layer.name == name).unwrap().2.id.0 +} + +fn selection(s: &Session) -> Value { + let st = s.active().unwrap(); + json!({"ids":st.selected_layers().iter().map(|id|id.0).collect::>(),"active":st.active_layer.map(|id|id.0),"anchor":st.layer_anchor.map(|id|id.0),"revision":st.revision}) +} + +/// The measured stack has a collapsed group and an optional structural Background. +fn scene(background: bool) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width":4,"height":4,"background":if background {"white"} else {"transparent"}})).unwrap(); + s.execute("layer.renameLayer", json!({"name":"Base"})).unwrap(); + for name in ["L1", "L2", "Gchild"] { + s.execute("layer.new.layer", json!({"name":name})).unwrap(); + } + s.execute("layer.groupLayers", json!({"name":"G"})).unwrap(); + s.execute("layer.new.layer", json!({"name":"L3"})).unwrap(); + assert_eq!(s.active().unwrap().doc.background().is_some(), background); + s +} + +fn index(class_name: &str, index: u32) -> ReferenceItem { + ReferenceItem::Index { class: class(class_name), index } +} + +fn select(reference: ReferenceItem) -> Action { + imported(AtnEvent::Code(*b"slct"), Descriptor::new("null").with("null", D::Reference(vec![reference]))) +} + +fn moved(reference: ReferenceItem, target: ReferenceItem) -> Action { + imported(AtnEvent::Code(*b"move"), Descriptor::new("null").with("null", D::Reference(vec![reference])).with("T ", D::Reference(vec![target]))) +} + +fn fixture() -> (Session, u64, u64, u64) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 4, "height": 4})).unwrap(); + s.execute("layer.new.layer", json!({"name": "L1"})).unwrap(); + let l2 = s.execute("layer.new.layer", json!({"name": "L2"})).unwrap()["layer"].as_u64().unwrap(); + let child = s.execute("layer.new.layer", json!({"name": "Gchild"})).unwrap()["layer"].as_u64().unwrap(); + let group = s.execute("layer.groupLayers", json!({"name": "G"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.new.layer", json!({"name": "L3"})).unwrap(); + (s, l2, child, group) +} + +/// Recorded navigation enters collapsed groups; native keyboard navigation follows visible rows. +#[test] +fn recorded_forward_enters_a_collapsed_group() { + let (mut s, l2, child, group) = fixture(); + s.execute("layer.select", json!({"layer": l2})).unwrap(); + s.execute("layer.selectAbove", json!({})).unwrap(); + assert_eq!(s.active().unwrap().active_layer.unwrap().0, group); + s.execute("layer.select", json!({"layer": l2})).unwrap(); + load(&mut s, ordinal("Frwr")); + s.execute("actions.play", json!({"action": "Select"})).unwrap(); + assert_eq!(s.active().unwrap().active_layer.unwrap().0, child); +} + +/// Indexes count group ends; recorded navigation visits collapsed children and retains its form. +#[test] +fn indexes_and_ordinals_use_the_recorded_stack_with_or_without_background() { + for background in [false, true] { + let mut s = scene(background); + let first = u32::from(!background); + let expected = [Some("Base"), Some("L1"), Some("L2"), None, Some("Gchild"), Some("G"), Some("L3")]; + for (offset, name) in expected.into_iter().enumerate() { + s.execute("layer.select", json!({"layer":layer(&s,"L2")})).unwrap(); + let reference = index("Lyr ", first + offset as u32); + let action = select(reference.clone()); + roundtrip(&action, &reference, None); + replay(&mut s, &action); + assert_eq!(s.active().unwrap().active_layer.unwrap().0, layer(&s, name.unwrap_or("L2"))); + } + for (code, expected) in [("Trgt", "L2"), ("Frwr", "Gchild"), ("Bckw", "L1"), ("Frnt", "L3"), ("Back", "Base")] { + s.execute("layer.select", json!({"layer":layer(&s,"L2")})).unwrap(); + let reference = ordinal(code); + let action = select(reference.clone()); + roundtrip(&action, &reference, None); + replay(&mut s, &action); + assert_eq!(s.active().unwrap().active_layer.unwrap().0, layer(&s, expected)); + } + for invalid in [first + 7, u32::MAX].into_iter().chain((!background).then_some(0)) { + let action = select(index("Lyr ", invalid)); + let before = selection(&s); + assert!(s.execute(&action.steps[0].0, action.steps[0].1.clone()).is_err()); + assert_eq!(selection(&s), before); + } + } +} + +/// An end marker is a true no-op, including multi-selection, anchor and add/remove modifiers. +#[test] +fn group_end_does_not_replace_the_current_selection() { + let mut s = scene(true); + s.execute("layer.select", json!({"layer":layer(&s,"L1")})).unwrap(); + s.execute("layer.select", json!({"layer":layer(&s,"L2"),"mode":"add"})).unwrap(); + for modifier in [None, Some("addToSelection"), Some("removeFromSelection"), Some("addToSelectionContinuous")] { + let mut d = Descriptor::new("null").with("null", D::Reference(vec![index("Lyr ", 3)])); + if let Some(modifier) = modifier { + d = d.with("selectionModifier", D::Enumerated { type_id: Id::new("selectionModifierType"), value: Id::new(modifier) }); + } + let action = imported(AtnEvent::Code(*b"slct"), d); + let before = selection(&s); + replay(&mut s, &action); + assert_eq!(selection(&s), before); + } + for extras in [json!({"name":"L1"}), json!({"occurrence":0}), json!({"mode":"invalid"})] { + let mut params = json!({"layer":{"index":3}}); + params.as_object_mut().unwrap().extend(extras.as_object().unwrap().clone()); + let before = selection(&s); + assert!(s.execute("layer.select", params).is_err()); + assert_eq!(selection(&s), before); + } +} + +/// Move indexes name pre-move boundaries; the two self boundaries preserve selection/history. +#[test] +fn indexed_moves_follow_the_measured_boundary_table() { + for target in 0..=6 { + let mut s = scene(true); + let reference = index("Lyr ", 2); + let destination = index("Lyr ", target); + let action = moved(reference.clone(), destination.clone()); + roundtrip(&action, &reference, Some(&destination)); + let before = selection(&s); + if target == 0 { + let doc = s.active().unwrap().doc.clone(); + assert!(s.execute(&action.steps[0].0, action.steps[0].1.clone()).is_err()); + assert_eq!(s.active().unwrap().doc, doc); + assert_eq!(selection(&s), before); + continue; + } + replay(&mut s, &action); + let expected = match target { + 1 => vec!["Base", "L2", "L1", "G", "L3"], + 2 | 3 => vec!["Base", "L1", "L2", "G", "L3"], + 4 | 5 => vec!["Base", "L1", "G", "L3"], + _ => vec!["Base", "L1", "G", "L2", "L3"], + }; + let doc = &s.active().unwrap().doc; + assert_eq!(doc.layers.iter().map(|l| l.name.as_str()).collect::>(), expected, "target {target}"); + let children = doc.layer(photocraft_doc::LayerId(layer(&s, "G"))).unwrap().children().unwrap(); + assert_eq!( + children.iter().map(|l| l.name.as_str()).collect::>(), + match target { + 4 => vec!["L2", "Gchild"], + 5 => vec!["Gchild", "L2"], + _ => vec!["Gchild"], + } + ); + if matches!(target, 2 | 3) { + assert_eq!(selection(&s), before); + } + } + let mut s = scene(false); + let action = moved(index("Lyr ", 3), index("Lyr ", 0)); + replay(&mut s, &action); + assert_eq!(s.active().unwrap().doc.layers[0].name, "L2"); +} + +/// A target-layer move keeps the complete selected source set and remains one undoable edit. +#[test] +fn indexed_move_preserves_multiple_selected_sources() { + let mut s = scene(true); + s.execute("layer.select", json!({"layer":layer(&s,"L1")})).unwrap(); + s.execute("layer.select", json!({"layer":layer(&s,"L2"),"mode":"add"})).unwrap(); + let before = selection(&s); + let action = moved(ordinal("Trgt"), index("Lyr ", 6)); + roundtrip(&action, &ordinal("Trgt"), Some(&index("Lyr ", 6))); + replay(&mut s, &action); + let after = selection(&s); + for key in ["ids", "active", "anchor"] { + assert_eq!(after[key], before[key], "{key}"); + } + assert_eq!(s.active().unwrap().doc.layers.iter().map(|layer| layer.name.as_str()).collect::>(), ["Base", "G", "L1", "L2", "L3"]); + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc.layers.iter().map(|layer| layer.name.as_str()).collect::>(), ["Base", "L1", "L2", "G", "L3"]); +} + +/// Nested empty groups have distinct end/header boundaries, and cycle refusals are atomic. +#[test] +fn nested_empty_group_boundaries_reparent_at_the_correct_level() { + for target in [1, 2, 3] { + let mut s = Session::new(); + s.execute("file.new", json!({"width":4,"height":4,"background":"white"})).unwrap(); + s.execute("layer.new.group", json!({"name":"Inner"})).unwrap(); + s.execute("layer.groupLayers", json!({"name":"Outer"})).unwrap(); + s.execute("layer.new.layer", json!({"name":"Top"})).unwrap(); + let outer = photocraft_doc::LayerId(layer(&s, "Outer")); + let inner = photocraft_doc::LayerId(layer(&s, "Inner")); + let action = moved(index("Lyr ", 5), index("Lyr ", target)); + replay(&mut s, &action); + let doc = &s.active().unwrap().doc; + let parent = doc.path_of(photocraft_doc::LayerId(layer(&s, "Top"))).unwrap(); + assert_eq!( + parent.len(), + match target { + 1 => 1, + 2 => 2, + _ => 3, + } + ); + assert_eq!(doc.layer(outer).unwrap().children().unwrap().len(), if target == 2 { 2 } else { 1 }); + assert_eq!(doc.layer(inner).unwrap().children().unwrap().len(), usize::from(target == 3)); + let action = moved(index("Lyr ", 5), index("Lyr ", 4)); + let before = doc.clone(); + assert!(s.execute(&action.steps[0].0, action.steps[0].1.clone()).is_err()); + assert_eq!(s.active().unwrap().doc, before); + } +} + +/// Typed document indexes use opening order while numeric document indexes still address tabs. +#[test] +fn document_indexes_survive_tab_moves_close_and_reopen_with_old_ids() { + let mut s = Session::new(); + for name in ["A", "B", "C"] { + s.execute("file.new", json!({"width":2,"height":2,"name":name})).unwrap(); + } + let reopened = (*s.documents()[0].doc).clone(); + s.execute("document.move", json!({"document":2,"to":0})).unwrap(); + s.execute("document.activate", json!({"document":0})).unwrap(); + assert_eq!(s.active().unwrap().doc.name, "C"); + let reference = index("Dcmn", 2); + let action = select(reference.clone()); + roundtrip(&action, &reference, None); + replay(&mut s, &action); + assert_eq!(s.active().unwrap().doc.name, "B"); + s.execute("file.close", json!({"document":1})).unwrap(); + s.add_document(reopened, None); + for (n, expected) in [(1, "B"), (2, "C"), (3, "A")] { + replay(&mut s, &select(index("Dcmn", n))); + assert_eq!(s.active().unwrap().doc.name, expected); + } + for invalid in [0, u32::MAX, 4] { + let before = s.active_index(); + let action = select(index("Dcmn", invalid)); + assert!(s.execute(&action.steps[0].0, action.steps[0].1.clone()).is_err()); + assert_eq!(s.active_index(), before); + } +} + +fn comp_scene() -> (Session, u32, u32) { + let mut s = Session::new(); + s.execute("file.new", json!({"width":2,"height":2})).unwrap(); + let dead = s.execute("layerComp.new", json!({"name":"Deleted"})).unwrap()["comp"].as_u64().unwrap(); + s.execute("layer.hideLayers", json!({})).unwrap(); + let first = s.execute("layerComp.new", json!({"name":"First"})).unwrap()["comp"].as_u64().unwrap() as u32; + s.execute("layer.showLayers", json!({})).unwrap(); + let second = s.execute("layerComp.new", json!({"name":"Second"})).unwrap()["comp"].as_u64().unwrap() as u32; + s.execute("layerComp.delete", json!({"comp":dead})).unwrap(); + (s, first, second) +} + +/// Comp ids, panel indexes and unusual measured ordinals stay distinct after reordering. +#[test] +fn comp_forms_keep_their_wire_kind_and_resolve_at_playback() { + let (mut s, first, second) = comp_scene(); + for reordered in [false, true] { + if reordered { + s.execute("layerComp.reorder", json!({"comp":second,"index":0})).unwrap(); + } + let panel_first = if reordered { second } else { first }; + let panel_second = if reordered { first } else { second }; + let current = panel_second; + let mut cases = vec![ + (index("compsClass", 1), panel_first), + (index("compsClass", 2), panel_second), + (ReferenceItem::Identifier { class: class("compsClass"), id: first }, first), + ]; + for (code, expected) in [("Trgt", current), ("Nxt ", current), ("Prvs", current), ("Frst", panel_first), ("Lst ", panel_first), ("Al ", panel_first)] { + cases.push(( + ReferenceItem::Enumerated { class: class("compsClass"), type_id: Id::new("Ordn"), value: Id::Code(code.as_bytes().try_into().unwrap()) }, + expected, + )); + } + for (reference, expected) in cases { + s.execute("layerComp.apply", json!({"comp":current})).unwrap(); + let action = imported(AtnEvent::name("applyComp"), Descriptor::new("null").with("null", D::Reference(vec![reference.clone()]))); + roundtrip(&action, &reference, None); + replay(&mut s, &action); + assert_eq!(s.active().unwrap().doc.last_applied_comp, Some(expected)); + assert_eq!(s.active().unwrap().doc.layers[0].visible, expected == second); + } + } +} + +/// Invalid comp indexes succeed without edits; unmeasured layer/comp reference forms stay kept. +#[test] +fn invalid_comp_indexes_and_unsupported_references_preserve_state() { + let (mut s, _, _) = comp_scene(); + for invalid in [0, 3, u32::MAX] { + let reference = index("compsClass", invalid); + let action = imported(AtnEvent::name("applyComp"), Descriptor::new("null").with("null", D::Reference(vec![reference.clone()]))); + roundtrip(&action, &reference, None); + let before = s.active().unwrap().doc.clone(); + let state = selection(&s); + replay(&mut s, &action); + assert_eq!(s.active().unwrap().doc, before); + assert_eq!(selection(&s), state); + } + let before = s.active().unwrap().doc.clone(); + s.execute("layerComp.apply", json!({"comp":{"index":-1}})).unwrap(); + assert_eq!(s.active().unwrap().doc, before); + assert!(s.execute("layerComp.apply", json!({"comp":{"id":u32::MAX}})).is_err()); + assert_eq!(s.active().unwrap().doc, before); + let negative = Action::new("Select", vec![("layerComp.apply".into(), json!({"comp":{"index":-1}}))]); + let (saved, _) = atn_cmds::export("References", &[&negative], &[], false).unwrap(); + assert_eq!( + atn::parse(&saved).unwrap().actions[0].items[0].descriptor.as_ref().unwrap().get("null"), + Some(&D::Reference(vec![index("compsClass", u32::MAX)])) + ); + for (event, reference) in [ + (AtnEvent::Code(*b"slct"), ReferenceItem::Identifier { class: class("Lyr "), id: 2 }), + (AtnEvent::Code(*b"slct"), ReferenceItem::Offset { class: class("Lyr "), offset: 1 }), + (AtnEvent::name("applyComp"), ReferenceItem::Offset { class: class("compsClass"), offset: 0 }), + (AtnEvent::name("applyComp"), ReferenceItem::Enumerated { class: class("compsClass"), type_id: Id::new("wrong"), value: Id::new("Trgt") }), + (AtnEvent::Code(*b"slct"), ReferenceItem::Enumerated { class: class("Lyr "), type_id: Id::new("wrong"), value: Id::new("Frwr") }), + ] { + let d = Descriptor::new("null").with("null", D::Reference(vec![reference])); + let data = bytes(event, d.clone()); + let result = atn_cmds::import(&data).unwrap(); + assert_eq!(result.unsupported.len(), 1); + assert_eq!(result.actions[0].steps[0].0, atn_cmds::PHOTOSHOP_STEP); + let (saved, _) = atn_cmds::export("References", &[&result.actions[0]], &[], false).unwrap(); + assert_eq!(atn::parse(&saved).unwrap().actions[0].items[0].descriptor, Some(d)); + } + for offset in [-1, 0, 1] { + let d = Descriptor::new("null") + .with("null", D::Reference(vec![ordinal("Trgt")])) + .with("T ", D::Reference(vec![ReferenceItem::Offset { class: class("Lyr "), offset }])); + assert_eq!(atn_cmds::import(&bytes(AtnEvent::Code(*b"move"), d)).unwrap().unsupported.len(), 1); + } +} + +/// A recorded invalid index is a no-op even with zero comps; ordinary Apply stays disabled. +#[test] +fn empty_comp_list_index_is_noop() { + let mut s = Session::new(); + assert!(!s.is_enabled_with("layerComp.apply", &json!({"comp":{"index":0}}))); + s.execute("file.new", json!({"width":2,"height":2})).unwrap(); + assert!(s.active().unwrap().doc.layer_comps.is_empty()); + assert!(!s.is_enabled("layerComp.apply")); + for invalid in [0, 1, u32::MAX] { + let reference = index("compsClass", invalid); + let action = imported(AtnEvent::name("applyComp"), Descriptor::new("null").with("null", D::Reference(vec![reference.clone()]))); + roundtrip(&action, &reference, None); + let before = s.active().unwrap().doc.clone(); + let state = selection(&s); + replay(&mut s, &action); + assert_eq!(s.active().unwrap().doc, before); + assert_eq!(selection(&s), state); + } + s.execute("layerComp.apply", json!({"comp":{"index":-1}})).unwrap(); + assert!(!s.is_enabled("layerComp.apply")); + for params in [json!({}), json!({"comp":{"id":1}}), json!({"comp":{"index":false}})] { + assert!(s.execute("layerComp.apply", params).is_err()); + } +} diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index f6a8360a4f..5bb8cd89ff 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -3,6 +3,7 @@ mod affinity; mod artboard_export_names; mod atn_crystallize_cell_size; +mod atn_reference_forms; mod atn_ripple_amount; mod atn_selective_color; mod atn_stroke_width; diff --git a/docs/development.md b/docs/development.md index 3ecefb9f59..7aabc270b4 100644 --- a/docs/development.md +++ b/docs/development.md @@ -631,6 +631,15 @@ PhotoCraft equivalent. Events outside the table load as `actions.photoshopStep` steps that keep the descriptor verbatim (so Save Actions writes them back) and fail at playback with the step's Photoshop name. +Recorded layer indexes and ordinals resolve against the full stack, including collapsed children +and hidden group-end positions. Indexed moves use the stack before the move. Recorded document +indexes are one-based opening order; a native numeric `document` parameter remains a zero-based +tab index. Layer comp references retain their name, index, ID or ordinal form when saved, including +the measured comp ordinals that reapply the current or first comp. Invalid comp indexes do nothing, +even with an empty comp list. Recording-document layer IDs and layer/comp offsets stay kept steps. +These rules follow [davidmann4's measured reference](https://github.com/storytold/photocraft/pull/10603) +(#10701). + ## Remove Tool quality `cargo run --release -p photocraft-engine --example remove_quality -- [out dir]` fills diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 5ef09a2e7e..c9be113a77 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-26 · **Change:** minor (TIFFs up to 300,000 px a side open, decoded a group of rows at a time into the layer, #8169; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; PSD Filter Gallery: Halftone Pattern dot/circle/line settings survive PSD/PSB saves (#8940); Save for Web writes WebP, lossy or lossless, #6391; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency, #8877; layered TIFF writes the TySh text version and descriptor in PSD big-endian order while the header, matrix, warp and bounds keep container order, #9098; layered TIFF writes Photoshop's 14-byte global layer mask, so Photoshop opens the layers instead of only the composite, #9095; legacy PSD point text placed from the layout ink box after font resolution, #6094; `.atn`: 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; `.asl` layer style libraries read and written, #1051; JPEG Options on File › Save, Save As and Save a Copy, FILE-215-19 partial (no Matte, progressive Scans or size preview); 32-bit Hue/Saturation unclipped and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; JPEG Options on Save As and Save a Copy, FILE-215-19; TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; Cineon write: 10-bit gray/RGB; animated WebP frames load into video layers, #1198; Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 53 filters (39 more: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag, Invert, Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding; Amiga IFF write: 24-plane ILBM, Save As `.iff`; PSD stores spot channels as Photoshop does, ink 0 = solid, and numbers them in 1053, #7415 split; TIFF writes and reads alpha and spot channels as extra samples with Photoshop's channel resources, PSD output unchanged, FILE-215-7, FILE-215-17's TIFF part, #7415; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-11-01 · **Change:** minor (`.atn` recorded layer/document/comp reference forms, #10701; TIFFs up to 300,000 px a side open, decoded a group of rows at a time into the layer, #8169; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; PSD Filter Gallery: Halftone Pattern dot/circle/line settings survive PSD/PSB saves (#8940); Save for Web writes WebP, lossy or lossless, #6391; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency, #8877; layered TIFF writes the TySh text version and descriptor in PSD big-endian order while the header, matrix, warp and bounds keep container order, #9098; layered TIFF writes Photoshop's 14-byte global layer mask, so Photoshop opens the layers instead of only the composite, #9095; legacy PSD point text placed from the layout ink box after font resolution, #6094; `.atn`: 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; `.asl` layer style libraries read and written, #1051; JPEG Options on File › Save, Save As and Save a Copy, FILE-215-19 partial (no Matte, progressive Scans or size preview); 32-bit Hue/Saturation unclipped and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; JPEG Options on Save As and Save a Copy, FILE-215-19; TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; Cineon write: 10-bit gray/RGB; animated WebP frames load into video layers, #1198; Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 53 filters (39 more: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag, Invert, Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding; Amiga IFF write: 24-plane ILBM, Save As `.iff`; PSD stores spot channels as Photoshop does, ink 0 = solid, and numbers them in 1053, #7415 split; TIFF writes and reads alpha and spot channels as extra samples with Photoshop's channel resources, PSD output unchanged, FILE-215-7, FILE-215-17's TIFF part, #7415; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency) · **Target:** Adobe Photoshop 2026 **PSD Filter Gallery (#8940, 2026-10-23):** Halftone Pattern maps its size, contrast and dot/circle/line pattern as editable settings on import and export, with the reference application's descriptor keys (`HlSz`, `Cntr`, `ScrT`: `ScrD`/`ScrC`/`ScrL`). PSD/PSB round trips pass at 8/16/32 bits. This does not claim pixel equivalence for the existing renderer. Unknown or malformed settings remain verbatim on import. @@ -133,7 +133,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | LUTs `.cube` / `.3dl` / `.look` | R/W (Export Color Lookup) | R/W / R / R | `cms/src/lutfile.rs` | | Keyboard shortcuts `.kys` | R/W | R/W | `engine/src/kys.rs` (`write`: Edit › Keyboard Shortcuts › Export…, rows carry our id; Photoshop reading our files is unverified) | | ICC profiles | R | R | Own CMS | -| Actions `.atn` | R/W | R/W | `psd/src/atn.rs` (version 16 sets, exact round trip), `engine/src/atn_cmds.rs` (Photoshop events ↔ commands, including Apply Layer Comp by name, #7101; other steps kept verbatim; Save As maps to `file.saveAs`/`file.saveACopy` when its destination is a file, #7102). Panel menu, CLI `batch --actions set.atn`, `actions.load`/`actions.save`. Photoshop 5-era version 12 files unsupported (AUTO-219-2) | +| Actions `.atn` | R/W | R/W | `psd/src/atn.rs` (version 16 sets, exact round trip), `engine/src/atn_cmds.rs` (Photoshop events ↔ commands, including Apply Layer Comp by name, #7101; recorded layer indexes/ordinals and indexed moves, opening-order document indexes, and comp index/ID/ordinal references, #10701; other steps kept verbatim; Save As maps to `file.saveAs`/`file.saveACopy` when its destination is a file, #7102). Panel menu, CLI `batch --actions set.atn`, `actions.load`/`actions.save`. Photoshop 5-era version 12 files unsupported (AUTO-219-2) | | Styles `.asl` | R/W | R/W | `psd/src/asl.rs`, `io/src/asl_map.rs`; Styles panel Import/Export Styles…, `style.presets.importAsl`/`exportAsl` (#1051) | | Contours `.shc` | R/W | R/W | `psd/src/shc.rs`; Contour Editor Load…/Save… (FILE-215-15). No user contour library; not yet checked against a Photoshop-written file | | Custom shapes `.csh`, tool presets `.tpl` | R/W | no | | @@ -148,6 +148,7 @@ Embedded SVG and PDF-compatible AI/PDF/EPS payloads can use an explicit VectorCr | Date | Change | Summary | |---|---|---| +| 2026-11-01 | minor | `.atn`: layer indexes/ordinals include collapsed children and group ends; indexed moves use pre-move boundaries; document indexes use opening order; comp index/ID/ordinal forms retain their reference kind (#10701). | | 2026-10-26 | minor | Embedded AI/PDF/EPS save-back retains native format and a separate source-bound display preview; original sources remain undoable. Native Adobe interoperability and standalone EPS/DCS remain unverified; no corpus floor or percentage changed. | | 2026-10-22 | minor | Embedded PSD vectors gain explicit bounded SVG editor/save-back; standalone EPS/DCS and native-format fidelity remain open. | | 2026-10-22 | minor | TIFF: flat pages up to 300,000 px a side open (was 2^30 px, about 32,768²): `codecs::TiffBands` decodes a group of rows at a time into the layer's tiles. The 117,504 × 22,464 px banner from #8456 opens in 3.3 s at 11.45 GB peak for a 10.56 GB layer; 30,000² 6.8 s / 6.43 GB → 1.1 s / 3.86 GB; 36 MP 220 → 52 ms. Layered TIFFs still decode whole (#8169) | From 8aed34d5406c1c9e03f284db184a59b9ecf93a6e Mon Sep 17 00:00:00 2001 From: yoriswind Date: Sun, 1 Nov 2026 13:04:53 -0800 Subject: [PATCH 128/216] Cover group transparency and empty-name source defaults --- crates/engine/tests/atn_mapped_identifiers.rs | 62 ++++++++++++++++--- 1 file changed, 53 insertions(+), 9 deletions(-) diff --git a/crates/engine/tests/atn_mapped_identifiers.rs b/crates/engine/tests/atn_mapped_identifiers.rs index 6909ed37cd..40e9304002 100644 --- a/crates/engine/tests/atn_mapped_identifiers.rs +++ b/crates/engine/tests/atn_mapped_identifiers.rs @@ -366,19 +366,63 @@ fn review_10702_transparency_names_resolve_at_playback() { assert!(s.execute("select.loadSelection", json!({"channel":"transparency","layer":layer})).is_err()); assert_eq!(s.active().unwrap().doc.selection.as_ref().unwrap().read_region(Rect::from_xywh(0, 0, 2, 1)), vec![0.0, 1.0]); } - if name == "merged" { - for sentinel in ["merged", ""] { - let native = Action::new("Recorded", vec![("select.loadSelection".into(), json!({"channel":"transparency","layer":sentinel}))]); - let reloaded = recorded(AtnEvent::Code(*b"setd"), saved(&native).descriptor.unwrap()); - let (id, params) = &reloaded.steps[0]; - s.execute(id, params.clone()).unwrap(); - let doc = &s.active().unwrap().doc; - assert_eq!(doc.selection.as_ref().map(|mask| mask.read_region(doc.bounds())).unwrap_or(vec![1.0; 2]), vec![1.0, 1.0]); - } + for sentinel in ["merged", ""] { + let native = Action::new("Recorded", vec![("select.loadSelection".into(), json!({"channel":"transparency","layer":sentinel}))]); + let reloaded = recorded(AtnEvent::Code(*b"setd"), saved(&native).descriptor.unwrap()); + let (id, params) = &reloaded.steps[0]; + s.execute(id, params.clone()).unwrap(); + let doc = &s.active().unwrap().doc; + assert_eq!(doc.selection.as_ref().map(|mask| mask.read_region(doc.bounds())).unwrap_or(vec![1.0; 2]), vec![1.0, 1.0]); } } } +/// A group has no supported transparency plane and must not overwrite a real selection. +#[test] +fn group_transparency_sources_fail_without_changing_selection() { + use photocraft_doc::LayerId; + use photocraft_geom::Rect; + + for name in ["Group", "merged", ""] { + let mut s = sized_document(2, 1, 72); + let child = LayerId(s.execute("layer.new.layer", json!({"name":"Child"})).unwrap()["layer"].as_u64().unwrap()); + s.edit("Child pixels", |doc, _| { + doc.layer_mut(child).unwrap().surface_mut().unwrap().write_region(Rect::from_xywh(0, 0, 2, 1), &[1.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 0.0]); + Ok(()) + }) + .unwrap(); + let group = LayerId(s.execute("layer.groupLayers", json!({"name":"Group"})).unwrap()["layer"].as_u64().unwrap()); + s.edit("Group name", |doc, _| { + doc.layer_mut(group).unwrap().name = name.into(); + Ok(()) + }) + .unwrap(); + s.execute("layer.new.layer", json!({"name":"Active"})).unwrap(); + s.execute("edit.fill", json!({"color":"#ffffff"})).unwrap(); + s.execute("select.loadSelection", json!({"channel":"transparency","layer":"Child"})).unwrap(); + assert_eq!(s.active().unwrap().doc.selection.as_ref().unwrap().read_region(Rect::from_xywh(0, 0, 2, 1)), vec![1.0, 0.0]); + let descriptor = Descriptor::new("null").with("null", D::Reference(vec![ReferenceItem::Property { class: class("Chnl"), key: Id::new("fsel") }])).with( + "T ", + D::Reference(vec![ + ReferenceItem::Enumerated { class: class("Chnl"), type_id: Id::new("Chnl"), value: Id::new("Trsp") }, + ReferenceItem::Name { class: class("Lyr "), name: UnicodeString::new_nul(name) }, + ]), + ); + let action = recorded(AtnEvent::Code(*b"setd"), descriptor.clone()); + let before = s.active().unwrap().doc.clone(); + let revision = s.active().unwrap().revision; + let history = s.active().unwrap().history.past_len(); + let (id, params) = &action.steps[0]; + let result = s.execute(id, params.clone()); + let alpha = s.active().unwrap().doc.selection.as_ref().map(|mask| mask.read_region(Rect::from_xywh(0, 0, 2, 1))); + assert!(result.is_err(), "group {name:?} returned {result:?} with selection {alpha:?}"); + assert_eq!(s.active().unwrap().doc, before); + assert_eq!(s.active().unwrap().revision, revision); + assert_eq!(s.active().unwrap().history.past_len(), history); + assert_eq!(saved(&action).descriptor, Some(descriptor)); + } +} + /// New unit validation must fail before editing, while no-resample still ignores size fields. #[test] fn native_units_validate_before_edit_and_no_resample_remains_resolution_only() { From cebea834c1161d000269c8e81b03ba107a469f75 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Sun, 1 Nov 2026 13:06:05 -0800 Subject: [PATCH 129/216] Prove recorded comp ordinals restore the current layout --- crates/engine/tests/it/atn_reference_forms.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/engine/tests/it/atn_reference_forms.rs b/crates/engine/tests/it/atn_reference_forms.rs index bee097ef59..d1da9ccc78 100644 --- a/crates/engine/tests/it/atn_reference_forms.rs +++ b/crates/engine/tests/it/atn_reference_forms.rs @@ -333,6 +333,8 @@ fn comp_forms_keep_their_wire_kind_and_resolve_at_playback() { } for (reference, expected) in cases { s.execute("layerComp.apply", json!({"comp":current})).unwrap(); + s.execute(if current == second { "layer.hideLayers" } else { "layer.showLayers" }, json!({})).unwrap(); + assert_eq!(s.active().unwrap().doc.last_applied_comp, Some(current)); let action = imported(AtnEvent::name("applyComp"), Descriptor::new("null").with("null", D::Reference(vec![reference.clone()]))); roundtrip(&action, &reference, None); replay(&mut s, &action); From 0aac40105e4efbf8107f64df370467bbabe727a9 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Sun, 1 Nov 2026 13:12:39 -0800 Subject: [PATCH 130/216] Reject group transparency before modifying the selection --- crates/engine/src/cmd/channel_cmds/refs.rs | 1 + crates/engine/tests/atn_mapped_identifiers.rs | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/crates/engine/src/cmd/channel_cmds/refs.rs b/crates/engine/src/cmd/channel_cmds/refs.rs index bf12284997..1bdf587f67 100644 --- a/crates/engine/src/cmd/channel_cmds/refs.rs +++ b/crates/engine/src/cmd/channel_cmds/refs.rs @@ -171,6 +171,7 @@ fn ref_planes(doc: &Document, layer: Option, active: Option, r .map(|p| p[3]) .collect(), ], + LayerContent::Group(_) => return Err(EngineError::Other("group transparency sources are not supported".into())), // Adjustment and fill layers cover the whole canvas. _ => vec![vec![1.0; area.width() as usize * area.height() as usize]], }, diff --git a/crates/engine/tests/atn_mapped_identifiers.rs b/crates/engine/tests/atn_mapped_identifiers.rs index 40e9304002..a0f54a14cf 100644 --- a/crates/engine/tests/atn_mapped_identifiers.rs +++ b/crates/engine/tests/atn_mapped_identifiers.rs @@ -420,6 +420,12 @@ fn group_transparency_sources_fail_without_changing_selection() { assert_eq!(s.active().unwrap().revision, revision); assert_eq!(s.active().unwrap().history.past_len(), history); assert_eq!(saved(&action).descriptor, Some(descriptor)); + + // The group's explicit mask remains a valid source. + s.execute("layer.layerMask.revealSelection", json!({"layer":group.0})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + s.execute("select.loadSelection", json!({"channel":"mask","layer":params["layer"]})).unwrap(); + assert_eq!(s.active().unwrap().doc.selection.as_ref().unwrap().read_region(Rect::from_xywh(0, 0, 2, 1)), vec![1.0, 0.0]); } } From 0ae73560641875fb6dbcc37e2faa94b7eabefc6f Mon Sep 17 00:00:00 2001 From: yoriswind Date: Sun, 1 Nov 2026 13:15:34 -0800 Subject: [PATCH 131/216] Respect mask creation clearing the selection in the control test --- crates/engine/tests/atn_mapped_identifiers.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/tests/atn_mapped_identifiers.rs b/crates/engine/tests/atn_mapped_identifiers.rs index a0f54a14cf..35cce20f21 100644 --- a/crates/engine/tests/atn_mapped_identifiers.rs +++ b/crates/engine/tests/atn_mapped_identifiers.rs @@ -423,7 +423,7 @@ fn group_transparency_sources_fail_without_changing_selection() { // The group's explicit mask remains a valid source. s.execute("layer.layerMask.revealSelection", json!({"layer":group.0})).unwrap(); - s.execute("select.deselect", json!({})).unwrap(); + assert!(s.active().unwrap().doc.selection.is_none()); s.execute("select.loadSelection", json!({"channel":"mask","layer":params["layer"]})).unwrap(); assert_eq!(s.active().unwrap().doc.selection.as_ref().unwrap().read_region(Rect::from_xywh(0, 0, 2, 1)), vec![1.0, 0.0]); } From 6e5f02c014c5d13cc30eb043c057028643630bc6 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Sun, 1 Nov 2026 13:17:55 -0800 Subject: [PATCH 132/216] Check absolute profile intent against an independent pixel value --- crates/engine/tests/convert_profile_atn.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/engine/tests/convert_profile_atn.rs b/crates/engine/tests/convert_profile_atn.rs index f982143b97..bf539408c0 100644 --- a/crates/engine/tests/convert_profile_atn.rs +++ b/crates/engine/tests/convert_profile_atn.rs @@ -79,6 +79,10 @@ fn absolute_and_unknown_intents_remain_distinct_with_conversion_flags() { assert_eq!(played["ran"], 1); assert!(played.get("failed").is_none(), "{played}"); assert_eq!(rendered(&s), rendered(&absolute)); + // Half media white scales linear 128/255 to 64/255, about 137/255 in sRGB. + // Check the cached pixel before opacity so a shared intent swap cannot pass. + let pixel = s.active().unwrap().doc.layers[1].surface().unwrap().default_pixel(); + assert!(pixel[..3].iter().all(|value| (value - 137.0 / 255.0).abs() <= 1.0 / 255.0), "{pixel:?}"); } } } From 01b3d74c50948c55ef9d636533b228ae8667d2fc Mon Sep 17 00:00:00 2001 From: yoriswind Date: Sun, 1 Nov 2026 13:23:53 -0800 Subject: [PATCH 133/216] docs: mark recorded ATN reference forms supported --- docs/atn.md | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/docs/atn.md b/docs/atn.md index 61c2403a19..49c0ea9e4d 100644 --- a/docs/atn.md +++ b/docs/atn.md @@ -207,8 +207,8 @@ step does; each line says what is lost and the fix. How the reference application resolves a layer or document a step names, measured on 2026-11-01 by playing `select` and `move` steps with each form (documents with and without a Background, a group, -layers above and below it). A recorded step can use any of these; PhotoCraft reads names and the target -today. +layers above and below it). PhotoCraft resolves the supported recorded forms below against the +playback document and retains their reference kinds on save (#10701). ### Layers @@ -219,13 +219,13 @@ children. Example stack, bottom to top: Background, L1, L2, *group end*, Gchild, |---|---|---| | By name (`name`) | The layer with that name (the first match). | (supported) | | Target (`Ordn.Trgt`) | The active layer. | (supported) | -| By index (`indx`) | Position in the stack above. With a Background, the Background is 0 and L1 is 1. Without one, the bottom layer is 1, and 0 fails. The group end is a position but cannot be selected: the step leaves the selection unchanged. A number past the top fails. | with Background: 0 Background, 1 L1, 2 L2, 3 (group end: no change), 4 Gchild, 5 G, 6 L3, 7 fails | +| By index (`indx`) | Position in the stack above. With a Background, the Background is 0 and L1 is 1. Without one, the bottom layer is 1, and 0 fails. The group end is a position but cannot be selected: the step leaves the selection unchanged. A number past the top fails. | (supported) with Background: 0 Background, 1 L1, 2 L2, 3 (group end: no change), 4 Gchild, 5 G, 6 L3, 7 fails | | By id (`Idnt`) | The layer id of the document the step was recorded in (`LyrI`, never reused in that document). In another document it names an unrelated layer or none. | id 3 = L2 with a Background, L1 without | | By offset (`rele`) | Not supported for layers: every offset fails. | -3..3 all fail | -| `Ordn.Frwr` / `Ordn.Bckw` | The next layer up / down in the stack, entering groups (group ends are skipped). | from L2: Frwr Gchild, Bckw L1 | -| `Ordn.Frnt` / `Ordn.Back` | The top layer / the bottom layer (the Background when there is one). | L3 / Background | +| `Ordn.Frwr` / `Ordn.Bckw` | The next layer up / down in the stack, entering groups (group ends are skipped). | (supported) from L2: Frwr Gchild, Bckw L1 | +| `Ordn.Frnt` / `Ordn.Back` | The top layer / the bottom layer (the Background when there is one). | (supported) L3 / Background | -**Move to an index** (`move`, `T` = layer by index `m`): counted as above on the stack before the move. +**Move to an index** (supported) (`move`, `T` = layer by index `m`): counted as above on the stack before the move. Moving down, the layer lands at index `m`; moving up, at `m - 1` (directly below the layer that had index `m`). With a Background, `m` = 0 fails; without one, `m` = 0 sends the layer to the bottom. Moving to an index inside a group puts the layer into that group. @@ -239,7 +239,7 @@ index inside a group puts the layer into that group. | Form | Meaning | Measured (D1, D2, D3 opened in that order, D2 active) | |---|---|---| | By offset (`rele`) | That many documents after (positive) or before (negative) the active one, in opening order, **wrapping around** at the ends. 0 fails. | -1 D1, +1 D3, -2 D3, +2 D1 | -| By index (`indx`) | 1-based, in opening order; 0 fails. | 1 D1, 2 D2, 3 D3 | +| By index (`indx`) | 1-based, in opening order; 0 fails. | (supported) 1 D1, 2 D2, 3 D3 | PhotoCraft's `document.activate {"offset"}` refuses to wrap (#7103); the reference application wraps. @@ -252,14 +252,14 @@ Measured with three comps (CompA, CompB, CompC) that each show a different layer | Form | Result | |---|---| | By name (`name`) | Applies that comp. An unknown name fails. | -| By index (`indx`) | 1-based, in panel order. 0, negative and past-the-end indexes are **accepted and change nothing**. | -| By ID (`Idnt`) | Applies the comp with that `ID`. An unknown ID fails. | +| By index (`indx`) | (supported) 1-based, in panel order. 0, negative and past-the-end indexes are **accepted and change nothing**. | +| By ID (`Idnt`) | (supported) Applies the comp with that `ID`. An unknown ID fails. | | By offset (`rele`) | Always fails, for every offset. | -| Ordinal (`Ordn`) `Trgt`, `Nxt `, `Prvs` | Applies the current (last applied) comp; `Nxt ` and `Prvs` do not move. | -| Ordinal `Frst`, `Lst `, `Al ` | Applies the **first** comp (also for `Lst `). | +| Ordinal (`Ordn`) `Trgt`, `Nxt `, `Prvs` | (supported) Applies the current (last applied) comp; `Nxt ` and `Prvs` do not move. | +| Ordinal `Frst`, `Lst `, `Al ` | (supported) Applies the **first** comp (also for `Lst `). | -Delete (`Dlt `) by index uses the same 1-based numbering. PhotoCraft's `applyComp` import reads only the -name form today; the index, ID and ordinal forms load as kept steps. +Delete (`Dlt `) by index uses the same 1-based numbering. PhotoCraft's `applyComp` import preserves +name, index, ID and ordinal references; offset references remain kept steps (#10701). ## Newer events (string ID only) From 072a87e7b09bcca263a2f4524dda28ca4a80a2e9 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Sun, 1 Nov 2026 13:37:03 -0800 Subject: [PATCH 134/216] Clear the stale bitmap signature when preserving an internal copy --- crates/ui-egui/src/app/clipboard.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/ui-egui/src/app/clipboard.rs b/crates/ui-egui/src/app/clipboard.rs index 7f10130a05..9c57d9a7c6 100644 --- a/crates/ui-egui/src/app/clipboard.rs +++ b/crates/ui-egui/src/app/clipboard.rs @@ -121,6 +121,7 @@ impl PhotocraftApp { let image = image.filter(|(w, h, bytes)| *w > 0 && *h > 0 && bytes.len() == *w as usize * *h as usize * 4); let Some((w, h, bytes)) = image else { if self.clip_unmirrored && !self.clip_external && self.session.clipboard.is_some() { + self.os_clip_sig = None; return Ok(false); } self.os_clip_svg = None; From c5ed8f9fcff8781d2513f71ba3916f197f27ec8d Mon Sep 17 00:00:00 2001 From: yoriswind Date: Sun, 1 Nov 2026 13:44:29 -0800 Subject: [PATCH 135/216] Cover comp recording wire bounds and invalid move destinations --- crates/engine/tests/it/atn_reference_forms.rs | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/crates/engine/tests/it/atn_reference_forms.rs b/crates/engine/tests/it/atn_reference_forms.rs index d1da9ccc78..9774aa7e73 100644 --- a/crates/engine/tests/it/atn_reference_forms.rs +++ b/crates/engine/tests/it/atn_reference_forms.rs @@ -214,6 +214,15 @@ fn indexed_moves_follow_the_measured_boundary_table() { let action = moved(index("Lyr ", 3), index("Lyr ", 0)); replay(&mut s, &action); assert_eq!(s.active().unwrap().doc.layers[0].name, "L2"); + let mut s = scene(true); + for invalid in [7, u32::MAX] { + let action = moved(index("Lyr ", 2), index("Lyr ", invalid)); + let doc = s.active().unwrap().doc.clone(); + let state = selection(&s); + assert!(s.execute(&action.steps[0].0, action.steps[0].1.clone()).is_err()); + assert_eq!(s.active().unwrap().doc, doc); + assert_eq!(selection(&s), state); + } } /// A target-layer move keeps the complete selected source set and remains one undoable edit. @@ -392,6 +401,31 @@ fn invalid_comp_indexes_and_unsupported_references_preserve_state() { } } +/// Reject integers outside the ATN reference format before they can enter a recording. +#[test] +fn unrepresentable_comp_indexes_are_rejected_before_recording() { + let (mut s, _, _) = comp_scene(); + for valid in [json!(i32::MIN), json!(u32::MAX)] { + s.execute("layerComp.apply", json!({"comp":{"index":valid}})).unwrap(); + } + let before = s.active().unwrap().doc.clone(); + let state = selection(&s); + let history = s.active().unwrap().history.past_len(); + s.execute("actions.record", json!({"name":"Index bounds"})).unwrap(); + let mut accepted = Vec::new(); + for invalid in [json!(u64::from(u32::MAX) + 1), json!(u64::MAX), json!(i64::from(i32::MIN) - 1), json!(i64::MIN)] { + if s.execute("layerComp.apply", json!({"comp":{"index":invalid}})).is_ok() { + accepted.push(invalid); + } + assert_eq!(s.active().unwrap().doc, before); + assert_eq!(selection(&s), state); + assert_eq!(s.active().unwrap().history.past_len(), history); + } + s.execute("actions.stop", json!({})).unwrap(); + assert!(accepted.is_empty(), "unrepresentable indexes were accepted: {accepted:?}"); + assert!(s.actions.list.last().unwrap().steps.is_empty()); +} + /// A recorded invalid index is a no-op even with zero comps; ordinary Apply stays disabled. #[test] fn empty_comp_list_index_is_noop() { From 5321bd773d30bb1fc654c830f74a093789b79c12 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Sun, 1 Nov 2026 13:52:11 -0800 Subject: [PATCH 136/216] Reject comp references outside the ATN index format before recording --- crates/engine/src/cmd/comps_cmds.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/comps_cmds.rs b/crates/engine/src/cmd/comps_cmds.rs index 29b9596a45..ee6bc6d196 100644 --- a/crates/engine/src/cmd/comps_cmds.rs +++ b/crates/engine/src/cmd/comps_cmds.rs @@ -208,12 +208,13 @@ fn recorded_comp(doc: &Document, selector: &Value) -> Result> { let fields = selector.as_object().filter(|fields| fields.len() == 1).ok_or_else(|| bad(CMD, "comp reference needs one index, id or ordinal"))?; if let Some(index) = fields.get("index") { if let Some(index) = index.as_u64() { + let index = u32::try_from(index).map_err(|_| bad(CMD, "comp index must be a 32-bit integer"))?; return Ok(index.checked_sub(1).and_then(|index| usize::try_from(index).ok()).and_then(|index| doc.layer_comps.get(index)).map(|comp| comp.id)); } - if index.as_i64().is_some_and(|index| index < 0) { + if index.as_i64().is_some_and(|index| index < 0 && i32::try_from(index).is_ok()) { return Ok(None); } - return Err(bad(CMD, "comp index must be an integer")); + return Err(bad(CMD, "comp index must be a 32-bit integer")); } if let Some(id) = fields.get("id") { let id = crate::commands::u32_id(CMD, "comp id", id)?; @@ -227,7 +228,7 @@ fn recorded_comp(doc: &Document, selector: &Value) -> Result> { } } -/// Apply the selected comp; an out-of-range recorded index succeeds without an edit. +/// Apply the selected comp; a 32-bit index outside the comp list is a no-op. fn apply_cmd(s: &mut Session, p: &Value) -> Result { let d = s.active().ok_or(EngineError::NoDocument)?; if let Some(selector @ Value::Object(_)) = p.get("comp") { From 686f08c884f8bf4aaa376f3bb12e7e4f2bf76500 Mon Sep 17 00:00:00 2001 From: yoriswind Date: Sun, 1 Nov 2026 14:08:40 -0800 Subject: [PATCH 137/216] test(engine): record and export accepted comp index bounds --- crates/engine/tests/it/atn_reference_forms.rs | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/crates/engine/tests/it/atn_reference_forms.rs b/crates/engine/tests/it/atn_reference_forms.rs index 9774aa7e73..bc92415156 100644 --- a/crates/engine/tests/it/atn_reference_forms.rs +++ b/crates/engine/tests/it/atn_reference_forms.rs @@ -426,6 +426,26 @@ fn unrepresentable_comp_indexes_are_rejected_before_recording() { assert!(s.actions.list.last().unwrap().steps.is_empty()); } +/// Valid extreme indexes survive recording and export as ATN Index references. +#[test] +fn accepted_comp_boundary_indexes_record_and_export() { + let (mut s, _, _) = comp_scene(); + s.execute("actions.record", json!({"name":"Boundary indexes"})).unwrap(); + for valid in [json!(i32::MIN), json!(u32::MAX)] { + s.execute("layerComp.apply", json!({"comp":{"index":valid}})).unwrap(); + } + s.execute("actions.stop", json!({})).unwrap(); + let action = s.actions.list.last().unwrap(); + assert_eq!(action.steps.len(), 2); + let (saved, skipped) = atn_cmds::export("References", &[action], &[], false).unwrap(); + assert!(skipped.is_empty()); + let parsed = atn::parse(&saved).unwrap(); + assert_eq!(parsed.actions[0].items.len(), 2); + for (item, expected) in parsed.actions[0].items.iter().zip([i32::MIN as u32, u32::MAX]) { + assert_eq!(item.descriptor.as_ref().unwrap().get("null"), Some(&D::Reference(vec![index("compsClass", expected)]))); + } +} + /// A recorded invalid index is a no-op even with zero comps; ordinary Apply stays disabled. #[test] fn empty_comp_list_index_is_noop() { From 363e0f5e89045ac0e1fc11292e128d6363ce1696 Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 2 Nov 2026 01:58:01 +0100 Subject: [PATCH 138/216] Integration: keep Wave action steps with more generators than the renderer draws (#10629) A Wave step recorded with NmbG above 64 (photocraft_algo::MAX_WAVE_GENERATORS) mapped to filter.distort.wave, but the renderer clamps to 64 generators, so the step played differently from Photoshop. Add generators 1..MAX_WAVE_GENERATORS to WAVE_COMMAND_LIMITS: such a step (e.g. NmbG 65, 999, 1000000) now stays a kept Photoshop step on import, and {"generators": 65} is refused on export, as the PSD smart-filter mapper does. Co-authored-by: hectorviov <1501713+hectorviov@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- .../engine/src/cmd/atn_cmds/steps_distort.rs | 14 +++++++++---- crates/engine/src/cmd/atn_cmds/tests.rs | 20 +++++++++++++------ 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/atn_cmds/steps_distort.rs b/crates/engine/src/cmd/atn_cmds/steps_distort.rs index 3a954c509a..439cf9b544 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_distort.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_distort.rs @@ -152,10 +152,16 @@ const WAVE: &[Key] = &[ const WAVE_UNDEFINED: &[(&str, &str)] = &[("RptE", "repeat"), ("WrpA", "wrap")]; -/// `filter.distort.wave`'s own limits where they are narrower than Photoshop's 1..999. A value -/// Photoshop plays but the command rejects keeps the step on import and refuses it on export, -/// rather than mapping a step that fails when it plays. -const WAVE_COMMAND_LIMITS: &[(&str, f64, f64)] = &[("wavelengthMin", 1.0, 998.0), ("wavelengthMax", 2.0, 999.0), ("amplitudeMin", 1.0, 998.0)]; +/// `filter.distort.wave`'s own limits where the command plays narrower than Photoshop's 1..999 +/// (the renderer draws at most [`photocraft_algo::MAX_WAVE_GENERATORS`] generators). A value +/// Photoshop plays but the command rejects or plays differently keeps the step on import and +/// refuses it on export, rather than mapping a step that fails or differs when it plays. +const WAVE_COMMAND_LIMITS: &[(&str, f64, f64)] = &[ + ("generators", 1.0, photocraft_algo::MAX_WAVE_GENERATORS as f64), + ("wavelengthMin", 1.0, 998.0), + ("wavelengthMax", 2.0, 999.0), + ("amplitudeMin", 1.0, 998.0), +]; /// Whether every value `p` sets for a key of [`WAVE_COMMAND_LIMITS`] is within the command's limits. fn wave_command_takes(p: &Map) -> bool { diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index 1c4278c154..c036a155e3 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -3258,12 +3258,20 @@ fn distort_filter_steps_load_clamp_play_and_save_back() { assert_eq!(one_step(distort("Wave", desc("null").with("NmbG", D::Integer(0)))).1["generators"], 1); } -/// Photoshop clamps Wave's number of generators to 1..999 when it plays, so a recorded count far -/// above it loads as 999, as the existing 0 loads as 1 (#10605). +/// PhotoCraft's renderer draws at most `MAX_WAVE_GENERATORS` (64) generators, so a Wave step +/// recorded with more keeps the Photoshop step on import and is refused on export, as the PSD +/// smart-filter mapper does, rather than mapping a step that plays differently (#10605). #[test] -fn wave_generators_above_photoshops_limit_load_clamped() { - let p = one_step(distort("Wave", desc("null").with("NmbG", D::Integer(1_000_000)))).1; - assert_eq!(p["generators"], 999, "{p}"); +fn wave_generators_past_what_photocraft_draws_keep_the_step() { + assert_eq!(photocraft_algo::MAX_WAVE_GENERATORS, 64); + let p = one_step(distort("Wave", desc("null").with("NmbG", D::Integer(64)))).1; + assert_eq!(p["generators"], 64, "{p}"); + for n in [65, 999, 1_000_000] { + let m = distort("Wave", desc("null").with("NmbG", D::Integer(n))); + assert!(matches!(m, Mapped::Unsupported { harmless: false }), "NmbG {n}"); + } + assert!(export_item("filter.distort.wave", &json!({"generators": 65}), "S").is_none()); + assert!(export_item("filter.distort.wave", &json!({"generators": 64}), "S").is_some()); } /// What the commands cannot play keeps the Photoshop step: an unknown enumeration value, a Wave @@ -3341,7 +3349,7 @@ fn distort_filter_steps_outside_photoshops_range_are_refused() { ("filter.distort.pinch", json!({"amount": -100})), ("filter.distort.twirl", json!({"angle": 999})), ("filter.distort.zigZag", json!({"ridges": 0})), - ("filter.distort.wave", json!({"generators": 999, "wavelengthMin": 1})), + ("filter.distort.wave", json!({"generators": 64, "wavelengthMin": 1})), ] { assert!(export_item(id, ¶ms, "S").is_some(), "{id} {params} refused"); } From 632a62b55416cd32beb776fc1aed2924fc7c23df Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 2 Nov 2026 01:59:05 +0100 Subject: [PATCH 139/216] Integration: Custom import takes whole numbers only, export refuses non-object params (#10631) A Custom (Cstm) step with a fractional Scl/Ofst/cell, e.g. Scl Double 1.5, was mapped to filter.other.custom with scale 1.5, but export writes integers and refuses the fraction, so Save Actions failed on a step that used to stay a kept Photoshop step and round-trip. `within` now requires a whole number; an integral Double such as 2.0 still maps. `sets_only` accepted any non-object params, so custom_to(json!([1])) wrote an identity Custom and bare_to(json!(5)) an empty descriptor for params no command can play. Only an object or null passes now. Co-authored-by: hectorviov <1501713+hectorviov@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/atn_cmds/steps_other.rs | 13 +++++++------ crates/engine/src/cmd/atn_cmds/tests.rs | 12 ++++++++++++ 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/atn_cmds/steps_other.rs b/crates/engine/src/cmd/atn_cmds/steps_other.rs index 2d3d57266c..10ee4c0646 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_other.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_other.rs @@ -23,9 +23,10 @@ fn setting(d: &Descriptor, key: &str, read: impl Fn(&D) -> Option, default } } -/// Whether `p` sets nothing but `allowed` (a `null` counts as unset). +/// Whether `p` sets nothing but `allowed` (a `null` counts as unset). Params that are neither an +/// object nor `null` are no command's params, so they set something no event can hold. fn sets_only(p: &Value, allowed: &[&str]) -> bool { - p.as_object().is_none_or(|m| m.iter().all(|(k, v)| v.is_null() || allowed.contains(&k.as_str()))) + p.is_null() || p.as_object().is_some_and(|m| m.iter().all(|(k, v)| v.is_null() || allowed.contains(&k.as_str()))) } /// NTSC Colors, Average and Remove Black / White Matte: Photoshop runs them with an empty @@ -91,15 +92,15 @@ const CUSTOM_SCALE: RangeInclusive = 1.0..=9999.0; const CUSTOM_OFFSET: RangeInclusive = -9999.0..=9999.0; const CUSTOM_CELL: RangeInclusive = -999.0..=999.0; -/// A number within `range`. +/// A whole number within `range`. fn within(range: &RangeInclusive) -> impl Fn(&D) -> Option + '_ { - move |v| num(v).filter(|n| range.contains(n)) + move |v| num(v).filter(|n| n.fract() == 0.0 && range.contains(n)) } /// Custom (`Cstm`): `Mtrx` is the 5 × 5 kernel, row by row. Photoshop takes exactly 25 cells (a /// 3 × 3 list makes it fail), so any other matrix keeps the Photoshop step, as does a `Scl `, -/// `Ofst` or cell that is not a number, or one outside the command's range (it would play -/// clamped). +/// `Ofst` or cell that is not a number, one that is not a whole number (export writes integers, +/// so Save Actions would refuse it), or one outside the command's range (it would play clamped). pub(super) fn custom_from(d: &Descriptor) -> Mapped { let unsupported = Mapped::Unsupported { harmless: false }; if !only_keys(d, &["Scl", "Ofst", "Mtrx"]) { diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index 6ee39c6116..35c9be589a 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -3297,9 +3297,14 @@ fn video_other_and_matting_steps_that_cannot_be_written_are_refused() { ("filter.blur.average", json!({"target": "mask"})), ("filter.other.hsbHsl", json!({"inputMode": "lab"})), ("filter.video.deInterlace", json!({"eliminate": "allFields"})), + ("filter.other.custom", json!([1])), + ("filter.blur.average", json!(5)), + ("filter.other.hsbHsl", json!("hsl")), ] { assert!(export_item(id, ¶ms, "S").is_none(), "{id} {params} exported"); } + // `null` params set nothing. + assert!(export_item("filter.blur.average", &Value::Null, "S").is_some()); // A Custom step without a kernel is the command's identity kernel. let d = export_item("filter.other.custom", &json!({}), "S").unwrap().descriptor.unwrap(); let cells = get_list(&d, "Mtrx").unwrap(); @@ -3352,6 +3357,10 @@ fn custom_outside_the_commands_range_is_kept_and_refused() { desc("null").with("Mtrx", kernel(-1000)), desc("null").with("Mtrx", D::List((0..26).map(|_| D::Integer(0)).collect())), desc("null").with("Mtrx", D::List((0..1000).map(|_| D::Integer(0)).collect())), + // Fractions: export writes integers, so Save Actions would refuse the mapped step. + desc("null").with("Mtrx", identity.clone()).with("Scl ", D::Double(1.5)), + desc("null").with("Mtrx", identity.clone()).with("Ofst", D::Double(2.5)), + desc("null").with("Mtrx", D::List((0..25).map(|i| if i == 12 { D::Double(0.5) } else { D::Integer(0) }).collect())), ] { assert!(kept(other_step("Cstm", d.clone())), "{:?} was mapped", d.items); } @@ -3363,6 +3372,9 @@ fn custom_outside_the_commands_range_is_kept_and_refused() { (&json!(f64::from(scale)), &json!(f64::from(offset)), &json!(f64::from(cell))) ); } + // An integral Double is a whole number and still maps. + let Mapped::Steps(steps) = other_step("Cstm", desc("null").with("Mtrx", identity.clone()).with("Scl ", D::Double(2.0))) else { panic!("Scl 2.0 kept") }; + assert_eq!(steps[0].1["scale"], json!(2.0)); let mut cells = vec![json!(0); 25]; cells[12] = json!(1000); for params in [json!({"scale": 0}), json!({"scale": 10_000}), json!({"offset": -10_000}), json!({"offset": 10_000}), json!({"kernel": cells})] { From 1f524c378b8ca3c99b390b313d9e38d3952fb5ef Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 2 Nov 2026 02:09:41 +0100 Subject: [PATCH 140/216] Integration: track the v12 .atn fixtures and sniff v12 in the CLI (#10743) The three version 12 fixtures were tracked inside crates/psd/fuzz/corpus, which crates/psd/fuzz/.gitignore ignores and cargo-fuzz rewrites (cmin) or deletes; the psd and engine test binaries include_bytes! them, so cleaning the corpus broke the build. They move to crates/psd/tests/fixtures/atn with a README (sizes, purpose, sha256; the seeds note folded in) and an ATTRIBUTION.md row; the fuzzer takes the folder as an extra corpus dir. photocraft-cli recognised an extensionless Actions file only by version word 16, so a version 12 set without .atn (classic Mac OS Photoshop 5) went down the JSON path and failed with a UTF-8 error. It now accepts 12 and 16, the versions photocraft-psd reads. Input: v12-commands.atn copied to "Legacy Actions" and passed to load_actions_file. Co-authored-by: yoriswind <164435053+yoriswind@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- ATTRIBUTION.md | 6 ++++ apps/photocraft-cli/src/actions.rs | 4 +-- apps/photocraft-cli/tests/it/cli.rs | 14 ++++++++ crates/engine/tests/it/atn_v12.rs | 2 +- crates/psd/fuzz/atn-v12-seeds.md | 13 -------- crates/psd/tests/fixtures/atn/README.md | 30 ++++++++++++++++++ .../fixtures}/atn/v12-commands.atn | Bin .../fixtures}/atn/v12-stop.atn | Bin .../fixtures}/atn/v12-values.atn | Bin crates/psd/tests/it/atn_v12.rs | 6 ++-- 10 files changed, 56 insertions(+), 19 deletions(-) delete mode 100644 crates/psd/fuzz/atn-v12-seeds.md create mode 100644 crates/psd/tests/fixtures/atn/README.md rename crates/psd/{fuzz/corpus => tests/fixtures}/atn/v12-commands.atn (100%) rename crates/psd/{fuzz/corpus => tests/fixtures}/atn/v12-stop.atn (100%) rename crates/psd/{fuzz/corpus => tests/fixtures}/atn/v12-values.atn (100%) diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 2c7e49c2a6..ecf1c1c74a 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -140,6 +140,12 @@ next to the files: Files copied into `corpus/` by hand (tiff, exr, raw) must be MIT, BSD or CC0. +## Synthetic parser test fixtures + +| Path | Title | Author | Source | License | +|---|---|---|---|---| +| `crates/psd/tests/fixtures/atn/v12-*.atn` | Independently encoded ATN version-12 parser fixtures | @yoriswind | Original synthetic test data generated for PhotoCraft; wire layout documented in `crates/psd/src/atn.rs`; provenance in the adjacent README | MIT OR Apache-2.0 | + ## Embedded-vector editor source The external SVG watcher, engine/UI handoff and bounded SVG rendering in this change diff --git a/apps/photocraft-cli/src/actions.rs b/apps/photocraft-cli/src/actions.rs index 4d494c8a82..f8b900e49a 100644 --- a/apps/photocraft-cli/src/actions.rs +++ b/apps/photocraft-cli/src/actions.rs @@ -83,11 +83,11 @@ pub fn load_actions(text: &str, name: Option<&str>) -> Result<(Steps, Vec) -> Result<(Steps, Vec, Vec), ActionsError> { let bytes = std::fs::read(path).map_err(|source| ActionsError::Read { path: path.to_path_buf(), source })?; - let is_atn = path.extension().is_some_and(|e| e.eq_ignore_ascii_case("atn")) || bytes.starts_with(&[0, 0, 0, 16]); + let is_atn = path.extension().is_some_and(|e| e.eq_ignore_ascii_case("atn")) || matches!(bytes.get(..4), Some([0, 0, 0, 12 | 16])); if !is_atn { let text = String::from_utf8(bytes).map_err(|source| ActionsError::Utf8 { path: path.to_path_buf(), source })?; let (steps, set) = load_actions(&text, name)?; diff --git a/apps/photocraft-cli/tests/it/cli.rs b/apps/photocraft-cli/tests/it/cli.rs index e592d552e2..fcc93e06a0 100644 --- a/apps/photocraft-cli/tests/it/cli.rs +++ b/apps/photocraft-cli/tests/it/cli.rs @@ -1090,6 +1090,20 @@ fn batch_plays_a_photoshop_actions_file() { assert!(!o.status.success()); } +/// A version 12 Actions file (Photoshop 5, often saved on classic Mac OS without an extension) is +/// recognised by its version word like version 16, not read as a JSON actions list. +#[test] +fn extensionless_version_12_actions_file_is_read_as_atn() { + let d = tmp("atn-v12"); + let path = d.join("Legacy Actions"); + std::fs::write(&path, include_bytes!("../../../../crates/psd/tests/fixtures/atn/v12-commands.atn")).unwrap(); + let (steps, set, warnings) = photocraft_cli::load_actions_file(&path, None).unwrap(); + assert_eq!(set.len(), 1); + assert_eq!(steps, vec![("actions.play".to_string(), json!({"action": set[0].name}))]); + assert!(warnings.is_empty(), "{warnings:?}"); + std::fs::remove_dir_all(d).unwrap(); +} + /// What `photocraft_cli::run` returned and wrote, in-process. struct Invoke { code: i32, diff --git a/crates/engine/tests/it/atn_v12.rs b/crates/engine/tests/it/atn_v12.rs index 0f3426e465..d5bc6d8263 100644 --- a/crates/engine/tests/it/atn_v12.rs +++ b/crates/engine/tests/it/atn_v12.rs @@ -5,7 +5,7 @@ use serde_json::json; /// Legacy descriptor syntax must reach the same commands and parameters as version 16. #[test] fn legacy_v12_commands_import_like_v16() { - let bytes = include_bytes!("../../../psd/fuzz/corpus/atn/v12-commands.atn"); + let bytes = include_bytes!("../../../psd/tests/fixtures/atn/v12-commands.atn"); let modern = atn::write(&atn::parse(bytes).unwrap()).unwrap(); let expected = vec![ ("filter.blur.gaussianBlur".into(), json!({"radius": 2.5})), diff --git a/crates/psd/fuzz/atn-v12-seeds.md b/crates/psd/fuzz/atn-v12-seeds.md deleted file mode 100644 index 235cd69b59..0000000000 --- a/crates/psd/fuzz/atn-v12-seeds.md +++ /dev/null @@ -1,13 +0,0 @@ -The small synthetic sets in `corpus/atn/v12-*.atn` were assembled from the measured grammar -in #10603, without using PhotoCraft's parser or writer. No proprietary files or -assets are included. They also serve as inputs for `tests/it/atn_v12.rs`. - -- `v12-stop.atn`: Stop with Unicode text and a Boolean. -- `v12-values.atn`: named plug-in event, every legacy value/reference form, - bare `warp`/`list`/`time` codes, an object array with three objects and two - columns, unpaired UTF-16, opaque path/alias/data bytes, and a descriptorless step. -- `v12-commands.atn`: Gaussian Blur, Levels, Select All and Make Layer. - -`Idnt` follows the measured reference's inferred index-like layout; no real -version 12 identifier reference was available. Saving these sets produces -version 16. Keep them outside the version 16 byte-identical corpus oracle. diff --git a/crates/psd/tests/fixtures/atn/README.md b/crates/psd/tests/fixtures/atn/README.md new file mode 100644 index 0000000000..33273b9ff5 --- /dev/null +++ b/crates/psd/tests/fixtures/atn/README.md @@ -0,0 +1,30 @@ +# Synthetic ATN parser fixtures + +These seven tiny files were independently encoded field by field from the version-16 and +version-12 layouts documented in `src/atn.rs`, without calling PhotoCraft's parser or writer. They are +original test data, licensed MIT OR Apache-2.0 under the repository's licenses. They contain no user +artwork, Photoshop program code or proprietary assets. They are not Photoshop-authored +interoperability oracles. + +| File | Bytes | Purpose | SHA-256 | +|---|---:|---|---| +| `empty.atn` | 13 | Empty set and explicit big-endian version | `6fd936f91296a13b0e7456a38b139a100fd17407d1c582d4502ce76413d01539` | +| `metadata.atn` | 214 | UTF-16 surrogate pairs/unpaired units, action order, function key, modifiers, panel flags, disabled step, unknown string event, dialog options | `cec2ff5a25e003aa29e790ea6b110f06ba848f0afd7901f3f8ad4740f782ede2` | +| `descriptors.atn` | 859 | Every descriptor value variant and every reference form currently represented by the parser | `6c76502822c1f36c8060a71adb227c4540663518785bbab1b70951dac0ef58ad` | +| `float-bits.atn` | 428 | NaN payload, negative zero, infinities, and floats inside lists, objects and arrays | `0aa6278a569c777f249b305e889896132c2a87e44a24cb1a55d1d3fc0928fd0c` | +| `v12-stop.atn` | 113 | Version 12: Stop with Unicode text and a Boolean | `293462a7d5cf0043fdc5135630524699f545679088c54bad83f4a86e15160271` | +| `v12-values.atn` | 562 | Version 12: named plug-in event, every legacy value and reference form, bare `warp`/`list`/`time` codes, an object array with three objects and two columns, unpaired UTF-16, opaque path/alias/data bytes, and a descriptorless step | `23d3f377996435988367365ee5c2a15462c0ed42ce506dc4a15039387ac6a853` | +| `v12-commands.atn` | 404 | Version 12: Gaussian Blur, Levels, Select All and Make Layer | `451958b686da9aab45e65ae902c86333b7149d72021c21904e3aae8e39eb77cb` | + +`tests/it/atn_contract.rs` checks the independently encoded version-16 bytes and expected public +structures; `tests/it/atn_v12.rs` (and `photocraft-engine`'s `tests/it/atn_v12.rs`) check the +version-12 files. The version-12 files follow the version 12 layout in `src/atn.rs`, assembled from +the measured grammar in #10603. Their `Idnt` reference follows the inferred index-like layout; no +real version 12 identifier reference was available. Saving them produces version 16, so keep them +out of the version-16 byte-identical corpus oracle. + +The files also seed the `atn` fuzz target: `cargo fuzz run atn crates/psd/tests/fixtures/atn` +(from `crates/psd`: `cargo fuzz run atn tests/fixtures/atn`). + +A fixture change should explain the changed wire fields and update this table; do not regenerate +expectations merely to make a failing parser pass. diff --git a/crates/psd/fuzz/corpus/atn/v12-commands.atn b/crates/psd/tests/fixtures/atn/v12-commands.atn similarity index 100% rename from crates/psd/fuzz/corpus/atn/v12-commands.atn rename to crates/psd/tests/fixtures/atn/v12-commands.atn diff --git a/crates/psd/fuzz/corpus/atn/v12-stop.atn b/crates/psd/tests/fixtures/atn/v12-stop.atn similarity index 100% rename from crates/psd/fuzz/corpus/atn/v12-stop.atn rename to crates/psd/tests/fixtures/atn/v12-stop.atn diff --git a/crates/psd/fuzz/corpus/atn/v12-values.atn b/crates/psd/tests/fixtures/atn/v12-values.atn similarity index 100% rename from crates/psd/fuzz/corpus/atn/v12-values.atn rename to crates/psd/tests/fixtures/atn/v12-values.atn diff --git a/crates/psd/tests/it/atn_v12.rs b/crates/psd/tests/it/atn_v12.rs index d9a773d7e2..28f3921d1f 100644 --- a/crates/psd/tests/it/atn_v12.rs +++ b/crates/psd/tests/it/atn_v12.rs @@ -2,9 +2,9 @@ use photocraft_psd::PsdError; use photocraft_psd::atn; use photocraft_psd::descriptor::{Class, Descriptor, Id, MAX_DEPTH, ObjectArray, ReferenceItem as R, UnicodeString, Value as V}; -const STOP: &[u8] = include_bytes!("../../fuzz/corpus/atn/v12-stop.atn"); -const VALUES: &[u8] = include_bytes!("../../fuzz/corpus/atn/v12-values.atn"); -const COMMANDS: &[u8] = include_bytes!("../../fuzz/corpus/atn/v12-commands.atn"); +const STOP: &[u8] = include_bytes!("../fixtures/atn/v12-stop.atn"); +const VALUES: &[u8] = include_bytes!("../fixtures/atn/v12-values.atn"); +const COMMANDS: &[u8] = include_bytes!("../fixtures/atn/v12-commands.atn"); fn code(id: &[u8; 4]) -> Id { Id::Code(*id) From 6ab24508d8a2cb85e97f4164c02fe592459881f8 Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 2 Nov 2026 02:13:01 +0100 Subject: [PATCH 141/216] Integration: .atn Oil Paint/Smart Sharpen clamp on import, Smart Sharpen maps the Gaussian form (#10627) Out-of-range Oil Paint and Smart Sharpen settings kept the step as a Photoshop step; the reference application clamps them when the step plays, so the import now clamps (stylization 10.5 -> 10, Amnt 500.5 % -> 500, Rds 0.05 px -> 0.1). Export still refuses out-of-range params. Oil Paint's light direction (LghD) had no range: LghD 500 mapped and saved back as 500. It now clamps to the command's -180..180 on import (500 -> 180) and export refuses {"angle": 181}. Smart Sharpen mapped the legacy Lens Blur form, which the command's Gaussian sharpen does not play, and wrote every step as legacy Lens Blur. It now follows the PSD smart-filter mapping (photocraft-io smart_map): only useLegacy false + blur GsnB maps (noiseReduction -> reduceNoise); the reference default (useLegacy true, lensBlur), Motion Blur and legacy steps stay Photoshop steps; export writes useLegacy false, Amnt, Rds, noiseReduction, blur GsnB. Co-authored-by: hectorviov <1501713+hectorviov@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/atn_cmds/steps_image.rs | 89 ++++--- crates/engine/src/cmd/atn_cmds/tests.rs | 225 ++++++++++++------ docs/file-format-parity.md | 2 +- 3 files changed, 201 insertions(+), 115 deletions(-) diff --git a/crates/engine/src/cmd/atn_cmds/steps_image.rs b/crates/engine/src/cmd/atn_cmds/steps_image.rs index 7ae8b37328..df33969d92 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_image.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_image.rs @@ -541,21 +541,32 @@ const OIL_PAINT: &[(&str, &str, f64, f64)] = &[ ("microBrush", "bristleDetail", 10.0, 5.0), ]; -/// `filter.stylize.oilPaint`'s ranges. The command clamps a value outside them when it plays, -/// so such a step keeps the Photoshop step on import and is refused on export, rather than -/// mapping a step that plays clamped and saves back unchanged. -const OIL_PAINT_LIMITS: &[(&str, f64, f64)] = - &[("stylization", 0.1, 10.0), ("cleanliness", 0.0, 10.0), ("scale", 0.1, 10.0), ("bristleDetail", 0.0, 10.0), ("shine", 0.0, 10.0)]; +/// `filter.stylize.oilPaint`'s ranges (the light direction's is the command's -180..180). Photoshop +/// clamps a recorded value outside them when the step plays, so the import clamps it too; export +/// refuses one, as the dialog cannot hold it. +const OIL_PAINT_LIMITS: &[(&str, f64, f64)] = &[ + ("stylization", 0.1, 10.0), + ("cleanliness", 0.0, 10.0), + ("scale", 0.1, 10.0), + ("bristleDetail", 0.0, 10.0), + ("angle", -180.0, 180.0), + ("shine", 0.0, 10.0), +]; /// Whether `n` is within `filter.stylize.oilPaint`'s range for `param` (a param without one is). fn in_oil_paint_limits(param: &str, n: f64) -> bool { OIL_PAINT_LIMITS.iter().all(|(p, min, max)| *p != param || (*min..=*max).contains(&n)) } +/// `n` clamped to `filter.stylize.oilPaint`'s range for `param` (unchanged for a param without one). +fn oil_paint_clamped(param: &str, n: f64) -> f64 { + OIL_PAINT_LIMITS.iter().find(|(p, ..)| *p == param).map_or(n, |(_, min, max)| n.clamp(*min, *max)) +} + /// Oil Paint (`oilPaint`). Missing keys take Photoshop's defaults, which differ from the /// command's for cleanliness, scale, bristle detail and shine, so they are written out. A -/// setting of another number type, with a unit, or outside the command's ranges keeps the -/// Photoshop step. +/// setting of another number type or with a unit keeps the Photoshop step; one outside the +/// command's ranges is clamped, as Photoshop clamps it. pub(super) fn oil_paint_from(d: &Descriptor) -> Mapped { match oil_paint_params(d) { Some(p) => step("filter.stylize.oilPaint", Value::Object(p)), @@ -571,10 +582,10 @@ fn oil_paint_params(d: &Descriptor) -> Option> { let mut p = Map::new(); p.insert("lighting".into(), setting(d, "lightingOn", boolean, true)?.into()); for (key, param, ps, _) in OIL_PAINT { - p.insert((*param).into(), setting(d, key, double, *ps).filter(|n| in_oil_paint_limits(param, *n))?.into()); + p.insert((*param).into(), oil_paint_clamped(param, setting(d, key, double, *ps)?).into()); } - p.insert("angle".into(), setting(d, "LghD", |v| integer(v).map(f64::from), -60.0)?.into()); - p.insert("shine".into(), setting(d, "specularity", double, 1.299_999_952_316_28).filter(|n| in_oil_paint_limits("shine", *n))?.into()); + p.insert("angle".into(), oil_paint_clamped("angle", setting(d, "LghD", |v| integer(v).map(f64::from), -60.0)?).into()); + p.insert("shine".into(), oil_paint_clamped("shine", setting(d, "specularity", double, 1.299_999_952_316_28)?).into()); Some(p) } @@ -613,16 +624,17 @@ fn in_unit(unit: [u8; 4]) -> impl Fn(&D) -> Option { } } -/// `filter.sharpen.smartSharpen`'s amount and radius limits, in `smart_sharpen_params`' key order. -/// The command rejects a value outside them, so such a step keeps the Photoshop step on import -/// and is refused on export, rather than mapping a step that fails when it plays. -const SMART_SHARPEN_LIMITS: &[(&str, f64, f64)] = &[("amount", 1.0, 500.0), ("radius", 0.1, 64.0)]; +/// `filter.sharpen.smartSharpen`'s amount, radius and noise reduction limits, in +/// `smart_sharpen_params`' key order. Photoshop clamps a recorded value outside them when the step +/// plays, so the import clamps it too; export refuses one, as the dialog cannot hold it. +const SMART_SHARPEN_LIMITS: &[(&str, f64, f64)] = &[("amount", 1.0, 500.0), ("radius", 0.1, 64.0), ("reduceNoise", 0.0, 100.0)]; -/// Smart Sharpen (`smartSharpen`), as Photoshop records it without its dialog: the legacy -/// filter, removing Lens Blur, with no noise reduction (so `reduceNoise` is 0). A step that -/// asks for what the command does not do (another blur, More Accurate, the newer filter, a -/// preset), or an amount or radius in another unit or outside the command's limits, keeps it -/// as a Photoshop step. +/// Smart Sharpen (`smartSharpen`) in the one form the command plays, the same form PSD smart +/// filters map (`photocraft-io`'s `smart_map`): the newer filter (`useLegacy` false) removing a +/// Gaussian blur (`blur` `GsnB`), both recorded. The legacy filter (Photoshop's default, which +/// removes Lens Blur), Lens Blur, Motion Blur, a preset other than Custom or any other key, or an +/// amount, radius or noise reduction in another unit, keeps it as a Photoshop step. A missing +/// amount, radius or noise reduction takes Photoshop's default (200 %, 1 px, 10 %). pub(super) fn smart_sharpen_from(d: &Descriptor) -> Mapped { match smart_sharpen_params(d) { Some(p) => step("filter.sharpen.smartSharpen", Value::Object(p)), @@ -630,29 +642,37 @@ pub(super) fn smart_sharpen_from(d: &Descriptor) -> Mapped { } } +/// A `blurType` enumeration's value (`enumerated` alone does not check the type). +fn blur_type(v: &D) -> Option { + match v { + D::Enumerated { type_id, .. } if trimmed(type_id) == "blurType" => enumerated(v), + _ => None, + } +} + fn smart_sharpen_params(d: &Descriptor) -> Option> { - let keys = ["presetKind", "useLegacy", "Amnt", "Rds", "moreAccurate", "blur"]; - let as_recorded = only_keys(d, &keys) + let keys = ["presetKind", "useLegacy", "Amnt", "Rds", "noiseReduction", "blur"]; + let gaussian = only_keys(d, &keys) && setting(d, "presetKind", enumerated, "presetKindCustom".into())? == "presetKindCustom" - && setting(d, "useLegacy", boolean, true)? - && !setting(d, "moreAccurate", boolean, false)? - && setting(d, "blur", enumerated, "lensBlur".into())? == "lensBlur"; - if !as_recorded { + && !setting(d, "useLegacy", boolean, true)? + && setting(d, "blur", blur_type, "lensBlur".into())? == "GsnB"; + if !gaussian { return None; } let mut p = Map::new(); - for ((key, unit, default), (param, min, max)) in [("Amnt", *b"#Prc", 200.0), ("Rds", PX, 1.0)].into_iter().zip(SMART_SHARPEN_LIMITS) { - let n = setting(d, key, in_unit(unit), default).filter(|n| (*min..=*max).contains(n))?; + let read = [("Amnt", *b"#Prc", 200.0), ("Rds", PX, 1.0), ("noiseReduction", *b"#Prc", 10.0)]; + for ((key, unit, default), (param, min, max)) in read.into_iter().zip(SMART_SHARPEN_LIMITS) { + let n = setting(d, key, in_unit(unit), default)?.clamp(*min, *max); p.insert((*param).into(), n.into()); } - p.insert("reduceNoise".into(), 0.into()); Some(p) } -/// Every key in recorded order. The legacy filter has no noise reduction, so only a step that -/// sets `reduceNoise` to 0 is written (the command reduces noise by 10 when it is left out). +/// The form `smart_sharpen_params` reads, with the keys in the order Photoshop records them (as +/// PSD smart filters write it; `presetKind` is left out, as Photoshop leaves it out of a step with +/// explicit values). The command's defaults fill what the step leaves out. pub(super) fn smart_sharpen_to(_: &str, p: &Value) -> Option { - if !pixel_target(p) || !sets_only(p, &["target", "amount", "radius", "reduceNoise"]) || p.get("reduceNoise").and_then(Value::as_f64) != Some(0.0) { + if !pixel_target(p) || !sets_only(p, &["target", "amount", "radius", "reduceNoise"]) { return None; } let num = |k: &str, default: f64| match p.get(k) { @@ -661,11 +681,10 @@ pub(super) fn smart_sharpen_to(_: &str, p: &Value) -> Option { }; Some( desc("null") - .with("presetKind", enm("presetKindType", "presetKindCustom")) - .with("useLegacy", D::Boolean(true)) + .with("useLegacy", D::Boolean(false)) .with("Amnt", pct(num("amount", 100.0)?)) .with("Rds ", px(num("radius", 1.0)?)) - .with("moreAccurate", D::Boolean(false)) - .with("blur", enm("blurType", "lensBlur")), + .with("noiseReduction", pct(num("reduceNoise", 10.0)?)) + .with("blur", enm("blurType", "GsnB")), ) } diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index 8495847dea..ec2f9aa22e 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -3210,16 +3210,21 @@ fn newer_feature_steps_load_with_photoshops_defaults() { "bristleDetail": 10.0, "angle": -60.0, "shine": 1.299_999_952_316_28}); assert_eq!(mapped(newer("oilPaint", oil.clone())), ("filter.stylize.oilPaint".into(), expected_oil.clone())); assert_eq!(mapped(newer("oilPaint", desc("null"))).1, expected_oil, "a missing key takes Photoshop's default"); + // Smart Sharpen in the form the command plays (the newer filter, removing a Gaussian blur). let sharpen = desc("null") - .with("presetKind", enm("presetKindType", "presetKindCustom")) - .with("useLegacy", D::Boolean(true)) - .with("Amnt", pct(200.0)) - .with("Rds ", px(1.0)) - .with("moreAccurate", D::Boolean(false)) - .with("blur", enm("blurType", "lensBlur")); + .with("useLegacy", D::Boolean(false)) + .with("Amnt", pct(150.0)) + .with("Rds ", px(2.5)) + .with("noiseReduction", pct(20.0)) + .with("blur", enm("blurType", "GsnB")); assert_eq!( mapped(newer("smartSharpen", sharpen.clone())), - ("filter.sharpen.smartSharpen".into(), json!({"amount": 200.0, "radius": 1.0, "reduceNoise": 0})) + ("filter.sharpen.smartSharpen".into(), json!({"amount": 150.0, "radius": 2.5, "reduceNoise": 20.0})) + ); + assert_eq!( + mapped(newer("smartSharpen", gaussian_sharpen(&[]))).1, + json!({"amount": 200.0, "radius": 1.0, "reduceNoise": 10.0}), + "a missing amount, radius or noise reduction takes Photoshop's default" ); assert_eq!(mapped(newer("selectSky", desc("null"))), ("select.sky".into(), json!({"sampleAllLayers": false}))); assert_eq!(mapped(newer("focusMask", desc("null"))), ("select.focusArea".into(), json!({}))); @@ -3228,7 +3233,9 @@ fn newer_feature_steps_load_with_photoshops_defaults() { // Written back, they carry the same keys in the same order. let back = |id: &str, p: Value| export_item(id, &p, "S").unwrap_or_else(|| panic!("{id} does not export")).descriptor.unwrap(); assert_eq!(back("filter.stylize.oilPaint", expected_oil).items, oil.items); - assert_eq!(back("filter.sharpen.smartSharpen", json!({"amount": 200, "radius": 1, "reduceNoise": 0})).items, sharpen.items); + assert_eq!(back("filter.sharpen.smartSharpen", json!({"amount": 150, "radius": 2.5, "reduceNoise": 20})).items, sharpen.items); + let (id, params) = mapped(newer("smartSharpen", sharpen.clone())); + assert_eq!(back(&id, params).items, sharpen.items, "load -> save changed the step"); // Oil Paint and Smart Sharpen play. let mut s = Session::new(); @@ -3238,7 +3245,7 @@ fn newer_feature_steps_load_with_photoshops_defaults() { "Paint", vec![ ("filter.stylize.oilPaint".into(), mapped(newer("oilPaint", desc("null"))).1), - ("filter.sharpen.smartSharpen".into(), mapped(newer("smartSharpen", desc("null"))).1), + ("filter.sharpen.smartSharpen".into(), mapped(newer("smartSharpen", gaussian_sharpen(&[]))).1), ], ); s.actions.list.push(action); @@ -3251,10 +3258,26 @@ fn newer_feature_steps_load_with_photoshops_defaults() { #[test] fn newer_feature_steps_the_commands_cannot_play_are_kept() { let kept = |m: Mapped| matches!(m, Mapped::Unsupported { harmless: false }); - assert!(kept(newer("smartSharpen", desc("null").with("blur", enm("blurType", "motionBlur"))))); - assert!(kept(newer("smartSharpen", desc("null").with("moreAccurate", D::Boolean(true))))); - assert!(kept(newer("smartSharpen", desc("null").with("useLegacy", D::Boolean(false))))); - assert!(kept(newer("smartSharpen", desc("null").with("presetKind", enm("presetKindType", "presetKindDefault"))))); + // Photoshop's own default step: the legacy filter removing Lens Blur, which the command's + // Gaussian sharpen does not play. + let legacy = desc("null") + .with("presetKind", enm("presetKindType", "presetKindCustom")) + .with("useLegacy", D::Boolean(true)) + .with("Amnt", pct(200.0)) + .with("Rds ", px(1.0)) + .with("moreAccurate", D::Boolean(false)) + .with("blur", enm("blurType", "lensBlur")); + assert!(kept(newer("smartSharpen", legacy))); + assert!(kept(newer("smartSharpen", desc("null"))), "an empty step is the legacy filter"); + assert!(kept(newer("smartSharpen", desc("null").with("useLegacy", D::Boolean(false)))), "no blur recorded is Lens Blur"); + assert!(kept(newer("smartSharpen", desc("null").with("blur", enm("blurType", "GsnB")))), "no useLegacy recorded is the legacy filter"); + for blur in ["lensBlur", "MtnB", "motionBlur"] { + assert!(kept(newer("smartSharpen", gaussian_sharpen(&[("blur", enm("blurType", blur))]))), "{blur}"); + } + assert!(kept(newer("smartSharpen", gaussian_sharpen(&[("useLegacy", D::Boolean(true))])))); + assert!(kept(newer("smartSharpen", gaussian_sharpen(&[("blur", enm("Ordn", "GsnB"))]))), "a blur of another type"); + assert!(kept(newer("smartSharpen", gaussian_sharpen(&[("moreAccurate", D::Boolean(false))])))); + assert!(kept(newer("smartSharpen", gaussian_sharpen(&[("presetKind", enm("presetKindType", "presetKindDefault"))])))); assert!(kept(newer("focusMask", desc("null").with("Rng ", D::Double(3.0))))); assert!(kept(newer("removeBackground", desc("null").with("Lyr ", D::Integer(1))))); assert!(kept(newer("selectSky", desc("null").with("smartRadius", D::Boolean(true))))); @@ -3265,8 +3288,8 @@ fn newer_feature_steps_the_commands_cannot_play_are_kept() { #[test] fn newer_feature_steps_that_cannot_be_written_are_refused() { for (id, params) in [ - ("filter.sharpen.smartSharpen", json!({"amount": 150})), - ("filter.sharpen.smartSharpen", json!({"amount": 150, "reduceNoise": 10})), + ("filter.sharpen.smartSharpen", json!({"amount": 150, "blur": "lens"})), + ("filter.sharpen.smartSharpen", json!({"target": "mask"})), ("filter.stylize.oilPaint", json!({"angle": 10.5})), ("filter.stylize.oilPaint", json!({"target": "mask"})), ("select.focusArea", json!({"range": 0.3})), @@ -3279,6 +3302,28 @@ fn newer_feature_steps_that_cannot_be_written_are_refused() { assert!(export_item("select.sky", &json!({"mode": "replace", "threshold": 50, "softness": 50}), "S").is_some()); } +/// A Smart Sharpen step in the form the command plays (`useLegacy` false, `blur` `GsnB`) plus +/// `settings`, which replace those two when they name them. +fn gaussian_sharpen(settings: &[(&str, D)]) -> Descriptor { + let named = |k: &str| settings.iter().any(|(key, _)| key.trim_end() == k); + let mut d = desc("null"); + if !named("useLegacy") { + d = d.with("useLegacy", D::Boolean(false)); + } + if !named("blur") { + d = d.with("blur", enm("blurType", "GsnB")); + } + settings.iter().fold(d, |d, (k, v)| d.with(k, v.clone())) +} + +/// `kept_when_wrong_typed` for Smart Sharpen, on the form it would otherwise map. +fn sharpen_kept_when(settings: &[(&str, D)]) { + for (key, v) in settings { + let m = newer("smartSharpen", gaussian_sharpen(&[(key, v.clone())])); + assert!(matches!(m, Mapped::Unsupported { harmless: false }), "smartSharpen with {key:?} = {v:?} was mapped"); + } +} + /// A recorded setting of the wrong type is the step's own setting, not a missing one: the /// mapper must not play it with a default but keep the Photoshop step (#10610). fn kept_when_wrong_typed(name: &str, settings: &[(&str, D)]) { @@ -3311,26 +3356,23 @@ fn oil_paint_with_a_wrong_typed_setting_is_kept() { #[test] fn smart_sharpen_with_a_wrong_typed_setting_is_kept() { - kept_when_wrong_typed( - "smartSharpen", - &[ - ("presetKind", text("presetKindDefault")), - ("useLegacy", D::Integer(0)), - ("moreAccurate", D::Integer(1)), - ("blur", text("motionBlur")), - ("Amnt", D::Boolean(true)), - ("Rds ", text("2")), - ("Rds", text("2")), - ], - ); + sharpen_kept_when(&[ + ("presetKind", text("presetKindDefault")), + ("useLegacy", D::Integer(0)), + ("blur", text("GsnB")), + ("Amnt", D::Boolean(true)), + ("Rds ", text("2")), + ("Rds", text("2")), + ("noiseReduction", text("10")), + ]); } /// `only_keys` admits a code without its space padding, so the mapper reads it too: an /// unpadded `Rds` radius is the step's radius, not the default 1 (#10610). #[test] fn smart_sharpen_reads_an_unpadded_radius() { - let d = desc("null").with("Amnt", pct(150.0)).with("Rds", px(2.5)); - assert_eq!(mapped(newer("smartSharpen", d)), ("filter.sharpen.smartSharpen".into(), json!({"amount": 150.0, "radius": 2.5, "reduceNoise": 0}))); + let d = gaussian_sharpen(&[("Amnt", pct(150.0)), ("Rds", px(2.5))]); + assert_eq!(mapped(newer("smartSharpen", d)), ("filter.sharpen.smartSharpen".into(), json!({"amount": 150.0, "radius": 2.5, "reduceNoise": 10.0}))); } /// A step recording one of its keys twice, padded or not, can't be saved back with both values, @@ -3344,9 +3386,9 @@ fn newer_feature_steps_with_a_repeated_key_are_kept() { ("smartSharpen", "Rds ", "Rds", px(2.0), px(3.0)), ("smartSharpen", "Rds", "Rds", px(2.0), px(2.0)), ("smartSharpen", "presetKind", "presetKind", enm("presetKindType", "presetKindCustom"), enm("presetKindType", "presetKindCustom")), - ("smartSharpen", "useLegacy", "useLegacy", D::Boolean(true), D::Boolean(true)), - ("smartSharpen", "moreAccurate", "moreAccurate", D::Boolean(false), D::Boolean(false)), - ("smartSharpen", "blur", "blur", enm("blurType", "lensBlur"), enm("blurType", "lensBlur")), + ("smartSharpen", "useLegacy", "useLegacy", D::Boolean(false), D::Boolean(false)), + ("smartSharpen", "noiseReduction", "noiseReduction", pct(10.0), pct(20.0)), + ("smartSharpen", "blur", "blur", enm("blurType", "GsnB"), enm("blurType", "GsnB")), ("oilPaint", "lightingOn", "lightingOn", D::Boolean(true), D::Boolean(false)), ("oilPaint", "stylization", "stylization", D::Double(4.0), D::Double(6.0)), ("oilPaint", "cleanliness", "cleanliness", D::Double(2.0), D::Double(3.0)), @@ -3357,7 +3399,14 @@ fn newer_feature_steps_with_a_repeated_key_are_kept() { ("selectSky", "sampleAllLayers", "sampleAllLayers", D::Boolean(false), D::Boolean(true)), ] .into_iter() - .filter(|(name, key, again, a, b)| !kept(newer(name, desc("null").with(key, a.clone()).with(again, b.clone())))) + .filter(|(name, key, again, a, b)| { + let d = if *name == "smartSharpen" { + gaussian_sharpen(&[(key, a.clone()), (again, b.clone())]) + } else { + desc("null").with(key, a.clone()).with(again, b.clone()) + }; + !kept(newer(name, d)) + }) .map(|(name, key, again, ..)| format!("{name} {key:?} {again:?}")) .collect::>(); assert!(mapped.is_empty(), "mapped with a repeated key: {mapped:?}"); @@ -3387,47 +3436,59 @@ fn oil_paint_setting_with_a_unit_or_another_number_type_is_kept() { /// step stays a Photoshop step, whether the radius key is padded or not (#10610). #[test] fn smart_sharpen_amount_or_radius_in_another_unit_is_kept() { - kept_when_wrong_typed( - "smartSharpen", - &[ - ("Amnt", px(150.0)), - ("Amnt", D::Double(150.0)), - ("Amnt", D::Integer(150)), - ("Rds ", pct(2.0)), - ("Rds ", D::Double(2.0)), - ("Rds", D::UnitFloat { unit: *b"#Mlm", value: 2.0 }), - ("Rds", D::Double(2.0)), - ], - ); + sharpen_kept_when(&[ + ("Amnt", px(150.0)), + ("Amnt", D::Double(150.0)), + ("Amnt", D::Integer(150)), + ("Rds ", pct(2.0)), + ("Rds ", D::Double(2.0)), + ("Rds", D::UnitFloat { unit: *b"#Mlm", value: 2.0 }), + ("Rds", D::Double(2.0)), + ("noiseReduction", px(10.0)), + ("noiseReduction", D::Double(10.0)), + ]); for rds in ["Rds ", "Rds"] { - let d = desc("null").with("Amnt", pct(150.0)).with(rds, px(2.5)); + let d = gaussian_sharpen(&[("Amnt", pct(150.0)), (rds, px(2.5))]); assert_eq!( mapped(newer("smartSharpen", d)), - ("filter.sharpen.smartSharpen".into(), json!({"amount": 150.0, "radius": 2.5, "reduceNoise": 0})), + ("filter.sharpen.smartSharpen".into(), json!({"amount": 150.0, "radius": 2.5, "reduceNoise": 10.0})), "{rds:?}" ); } } -/// `filter.sharpen.smartSharpen` takes an amount of 1..500 % and a radius of 0.1..64 px and -/// rejects anything else. A step outside them would map and then fail when it plays, so it -/// stays a Photoshop step, and a step outside them is not written either (#10610). +/// `filter.sharpen.smartSharpen` takes an amount of 1..500 %, a radius of 0.1..64 px and a noise +/// reduction of 0..100 %. Photoshop clamps a recorded value outside them when the step plays, +/// so the import clamps it too; a step outside them is not written (#10610). #[test] -fn smart_sharpen_outside_the_commands_limits_is_kept_and_refused() { - kept_when_wrong_typed("smartSharpen", &[("Amnt", pct(0.5)), ("Amnt", pct(500.5)), ("Rds ", px(0.05)), ("Rds ", px(64.5)), ("Rds", px(1000.0))]); - for (amount, radius) in [(1.0, 0.1), (500.0, 64.0)] { - let d = desc("null").with("Amnt", pct(amount)).with("Rds ", px(radius)); +fn smart_sharpen_outside_the_commands_limits_is_clamped_on_import_and_refused_on_export() { + for (key, v, param, want) in [ + ("Amnt", pct(0.5), "amount", 1.0), + ("Amnt", pct(500.5), "amount", 500.0), + ("Rds ", px(0.05), "radius", 0.1), + ("Rds ", px(64.5), "radius", 64.0), + ("Rds", px(1000.0), "radius", 64.0), + ("noiseReduction", pct(-5.0), "reduceNoise", 0.0), + ("noiseReduction", pct(150.0), "reduceNoise", 100.0), + ] { + let (id, p) = mapped(newer("smartSharpen", gaussian_sharpen(&[(key, v.clone())]))); + assert_eq!((id.as_str(), &p[param]), ("filter.sharpen.smartSharpen", &json!(want)), "{key:?} = {v:?}"); + } + for (amount, radius, noise) in [(1.0, 0.1, 0.0), (500.0, 64.0, 100.0)] { + let d = gaussian_sharpen(&[("Amnt", pct(amount)), ("Rds ", px(radius)), ("noiseReduction", pct(noise))]); assert_eq!( mapped(newer("smartSharpen", d)), - ("filter.sharpen.smartSharpen".into(), json!({"amount": amount, "radius": radius, "reduceNoise": 0})), + ("filter.sharpen.smartSharpen".into(), json!({"amount": amount, "radius": radius, "reduceNoise": noise})), "{amount} {radius}" ); } for params in [ - json!({"amount": 0.5, "reduceNoise": 0}), - json!({"amount": 501, "reduceNoise": 0}), - json!({"radius": 0.05, "reduceNoise": 0}), - json!({"radius": 65, "reduceNoise": 0}), + json!({"amount": 0.5}), + json!({"amount": 501}), + json!({"radius": 0.05}), + json!({"radius": 65}), + json!({"reduceNoise": -1}), + json!({"reduceNoise": 101}), ] { assert!(export_item("filter.sharpen.smartSharpen", ¶ms, "S").is_none(), "{params} exported"); } @@ -3495,27 +3556,30 @@ fn newer_feature_selection_steps_play() { assert!(mask.value(20, 15) < 0.2, "Remove Background revealed the sky"); } -/// `filter.stylize.oilPaint` takes a stylization and scale of 0.1..10 and a cleanliness, bristle -/// detail and shine of 0..10, and clamps anything else when it plays. A step outside them would -/// play clamped and save back unchanged, so it stays a Photoshop step, and a step outside them -/// is not written either (#10610). +/// `filter.stylize.oilPaint` takes a stylization and scale of 0.1..10, a cleanliness, bristle +/// detail and shine of 0..10 and a light direction of -180..180. Photoshop clamps a recorded +/// value outside them when the step plays, so the import clamps it too; a step outside them is +/// not written (#10610). #[test] -fn oil_paint_outside_the_commands_ranges_is_kept_and_refused() { - kept_when_wrong_typed( - "oilPaint", - &[ - ("stylization", D::Double(0.05)), - ("stylization", D::Double(10.5)), - ("cleanliness", D::Double(-0.5)), - ("cleanliness", D::Double(10.5)), - ("brushScale", D::Double(0.05)), - ("brushScale", D::Double(10.5)), - ("microBrush", D::Double(-1.0)), - ("microBrush", D::Double(10.5)), - ("specularity", D::Double(-0.5)), - ("specularity", D::Double(10.5)), - ], - ); +fn oil_paint_outside_the_commands_ranges_is_clamped_on_import_and_refused_on_export() { + for (key, v, param, want) in [ + ("stylization", D::Double(0.05), "stylization", 0.1), + ("stylization", D::Double(10.5), "stylization", 10.0), + ("cleanliness", D::Double(-0.5), "cleanliness", 0.0), + ("cleanliness", D::Double(10.5), "cleanliness", 10.0), + ("brushScale", D::Double(0.05), "scale", 0.1), + ("brushScale", D::Double(10.5), "scale", 10.0), + ("microBrush", D::Double(-1.0), "bristleDetail", 0.0), + ("microBrush", D::Double(10.5), "bristleDetail", 10.0), + ("specularity", D::Double(-0.5), "shine", 0.0), + ("specularity", D::Double(10.5), "shine", 10.0), + ("LghD", D::Integer(500), "angle", 180.0), + ("LghD", D::Integer(-500), "angle", -180.0), + ("LghD", D::Integer(181), "angle", 180.0), + ] { + let (id, p) = mapped(newer("oilPaint", desc("null").with(key, v.clone()))); + assert_eq!((id.as_str(), &p[param]), ("filter.stylize.oilPaint", &json!(want)), "{key:?} = {v:?}"); + } for (low, high) in [(0.1, 0.0), (10.0, 10.0)] { let d = desc("null") .with("stylization", D::Double(low)) @@ -3543,6 +3607,9 @@ fn oil_paint_outside_the_commands_ranges_is_kept_and_refused() { json!({"bristleDetail": 11}), json!({"shine": -0.5}), json!({"shine": 10.5}), + json!({"angle": 181}), + json!({"angle": -181}), + json!({"angle": 1000}), ] { assert!(export_item("filter.stylize.oilPaint", ¶ms, "S").is_none(), "{params} exported"); } diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 093f8ee93f..50d62d667f 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -148,7 +148,7 @@ Embedded SVG and PDF-compatible AI/PDF/EPS payloads can use an explicit VectorCr | Date | Change | Summary | |---|---|---| -| 2026-10-31 | minor | `.atn`: Oil Paint (`oilPaint`), Smart Sharpen (`smartSharpen`), Focus Area (`focusMask`), Select Sky (`selectSky`) and Remove Background (`removeBackground`) map to their commands with the keys and defaults Photoshop records (Oil Paint's and Select Sky's defaults written out where they differ from the commands'); Smart Sharpen maps the legacy Lens Blur form and keeps the others as Photoshop steps (#10610) | +| 2026-10-31 | minor | `.atn`: Oil Paint (`oilPaint`), Smart Sharpen (`smartSharpen`), Focus Area (`focusMask`), Select Sky (`selectSky`) and Remove Background (`removeBackground`) map to their commands with the keys and defaults Photoshop records (Oil Paint's and Select Sky's defaults written out where they differ from the commands'); Smart Sharpen maps the newer filter's Gaussian form, as PSD smart filters do, and keeps the legacy filter, Lens Blur and Motion Blur as Photoshop steps; out-of-range Oil Paint and Smart Sharpen settings are clamped on import (#10610) | | 2026-10-26 | minor | Embedded AI/PDF/EPS save-back retains native format and a separate source-bound display preview; original sources remain undoable. Native Adobe interoperability and standalone EPS/DCS remain unverified; no corpus floor or percentage changed. | | 2026-10-22 | minor | Embedded PSD vectors gain explicit bounded SVG editor/save-back; standalone EPS/DCS and native-format fidelity remain open. | | 2026-10-22 | minor | TIFF: flat pages up to 300,000 px a side open (was 2^30 px, about 32,768²): `codecs::TiffBands` decodes a group of rows at a time into the layer's tiles. The 117,504 × 22,464 px banner from #8456 opens in 3.3 s at 11.45 GB peak for a 10.56 GB layer; 30,000² 6.8 s / 6.43 GB → 1.1 s / 3.86 GB; 36 MP 220 → 52 ms. Layered TIFFs still decode whole (#8169) | From ea4112a4ce76e5196aeea8da88a82373052a7145 Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 2 Nov 2026 02:17:31 +0100 Subject: [PATCH 142/216] Integration: group-end layer index fails, ordinal precondition resolves once (#10753) A `{"layer":{"index":n}}` that lands on a group end made explicit_layer return None, so layer.translate and the other callers acted on the selected layers instead (e.g. layer.translate {"layer":{"index":3}} with L1+L2 selected moved both). It now fails with "a group end is not a layer"; layer.select still treats a group end as a no-op. explicit_layer_enabled resolved a relative ordinal (Frwr/Bckw), made that layer active, then ran the precondition with the unresolved ordinal, which resolved again one layer further. With only Red targeted and A (active), B, C (type) bottom to top, edit.clear {"layer":{"ordinal":"Frwr"}} was refused with "Rasterize the layer" although it acts on raster B. The precondition now sees the resolved layer id. Co-authored-by: yoriswind <164435053+yoriswind@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/layer_params.rs | 6 ++- crates/engine/src/session/execute.rs | 8 +++- crates/engine/tests/it/atn_reference_forms.rs | 39 +++++++++++++++++++ 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/layer_params.rs b/crates/engine/src/cmd/layer_params.rs index af67bf36e7..c5abca3d7a 100644 --- a/crates/engine/src/cmd/layer_params.rs +++ b/crates/engine/src/cmd/layer_params.rs @@ -21,7 +21,10 @@ pub(crate) fn explicit_layer(s: &Session, p: &Value) -> Result> .map(|(_, _, l)| Some(l.id)) .ok_or_else(|| EngineError::Other(format!("no layer named \"{name}\"; {}", layer_listing(&d.doc)))) } - Some(v @ Value::Object(fields)) if fields.contains_key("index") || fields.contains_key("ordinal") => super::recorded_layer(s, v), + // A group end is no layer: fail rather than fall back to the selection. + Some(v @ Value::Object(fields)) if fields.contains_key("index") || fields.contains_key("ordinal") => { + super::recorded_layer(s, v)?.map(Some).ok_or_else(|| EngineError::Other("a group end is not a layer".into())) + } Some(Value::Null) | None => Ok(None), Some(other) => Err(EngineError::Other(format!("`layer` must be an id or a name, not {other}"))), } @@ -52,7 +55,6 @@ pub(crate) fn layer_listing(doc: &Document) -> String { pub(crate) fn layer_param(s: &Session, p: &Value) -> Result { match explicit_layer(s, p)? { Some(id) => Ok(id), - None if p.get("layer").is_some_and(Value::is_object) => Err(EngineError::Other("a group end is not a layer".into())), None => s.active().and_then(|d| d.active_layer).ok_or(EngineError::Other("no active layer".into())), } } diff --git a/crates/engine/src/session/execute.rs b/crates/engine/src/session/execute.rs index b939e73271..1a57be0981 100644 --- a/crates/engine/src/session/execute.rs +++ b/crates/engine/src/session/execute.rs @@ -189,7 +189,13 @@ impl Session { let d = self.active_mut()?; d.doc.layer(target)?; let active = d.active_layer.replace(target); - let r = self.precondition(spec, params); + // The checks see the resolved id: a relative ordinal (`Frwr`, `Bckw`) would otherwise + // resolve again from the swapped-in active layer and land one layer further. + let mut resolved = params.clone(); + if let Some(fields) = resolved.as_object_mut() { + fields.insert("layer".into(), Value::from(target.0)); + } + let r = self.precondition(spec, &resolved); if let Some(d) = self.active_mut() { d.active_layer = active; } diff --git a/crates/engine/tests/it/atn_reference_forms.rs b/crates/engine/tests/it/atn_reference_forms.rs index bc92415156..0e35280eb7 100644 --- a/crates/engine/tests/it/atn_reference_forms.rs +++ b/crates/engine/tests/it/atn_reference_forms.rs @@ -470,3 +470,42 @@ fn empty_comp_list_index_is_noop() { assert!(s.execute("layerComp.apply", params).is_err()); } } + +/// A group-end index names no layer, so a command acting on a layer fails and changes nothing +/// rather than falling back to the selected layers. +#[test] +fn group_end_index_never_targets_the_selection() { + let mut s = scene(true); + s.execute("layer.select", json!({"layer":layer(&s,"L1")})).unwrap(); + s.execute("layer.select", json!({"layer":layer(&s,"L2"),"mode":"add"})).unwrap(); + let past = s.active().unwrap().history.past_len(); + let doc = s.active().unwrap().doc.clone(); + assert!(s.execute("layer.translate", json!({"layer":{"index":3},"dx":1,"dy":0})).is_err()); + let offset = Descriptor::new("Ofst").with("Hrzn", D::UnitFloat { unit: *b"#Pxl", value: 1.0 }).with("Vrtc", D::UnitFloat { unit: *b"#Pxl", value: 0.0 }); + let action = + imported(AtnEvent::Code(*b"move"), Descriptor::new("null").with("null", D::Reference(vec![index("Lyr ", 3)])).with("T ", D::Descriptor(offset))); + s.actions.list = vec![action]; + let result = s.execute("actions.play", json!({"action":"Select"})).unwrap(); + assert!(result.get("failed").is_some(), "{result}"); + assert_eq!(s.active().unwrap().history.past_len(), past); + assert_eq!(s.active().unwrap().doc, doc); +} + +/// A relative ordinal is resolved once: the precondition checked on the named layer sees that +/// layer, not the one past it (here a type layer that would refuse a colour-channel edit). +#[test] +fn forward_ordinal_precondition_checks_the_resolved_layer() { + let mut s = Session::new(); + s.execute("file.new", json!({"width":4,"height":4,"background":"transparent"})).unwrap(); + s.execute("layer.renameLayer", json!({"name":"A"})).unwrap(); + s.execute("layer.new.layer", json!({"name":"B"})).unwrap(); + s.execute("edit.fill", json!({"contents":"color","color":"#000000"})).unwrap(); + s.execute("type.create", json!({"text":"C","size":2})).unwrap(); + s.execute("layer.renameLayer", json!({"name":"C"})).unwrap(); + s.execute("layer.select", json!({"layer":layer(&s,"A")})).unwrap(); + s.execute("channel.target", json!({"colors":[0]})).unwrap(); + let b = photocraft_doc::LayerId(layer(&s, "B")); + let before = s.active().unwrap().doc.layer(b).cloned(); + s.execute("edit.clear", json!({"layer":{"ordinal":"Frwr"}})).unwrap(); + assert_ne!(s.active().unwrap().doc.layer(b).cloned(), before); +} From 4abb9a4f949fb9c87d07e33f3c7c1726c216e678 Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 2 Nov 2026 02:17:36 +0100 Subject: [PATCH 143/216] Integration: count only the surviving sets in the multi-file Load Actions summary (#10601) actions.load replaces a set of the same name, so when two chosen .atn files both hold "Set 1" the panel keeps only the later one, but the summary still added up both files' actions and unsupported steps ("actions: 2" with one action in the panel). The summary now counts per set name, says which set was replaced ("Set 1 from b.atn replaced the one from a.atn"), and is plural-aware and translated in every catalog. Co-authored-by: yoriswind <164435053+yoriswind@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/ar.tsv | 3 ++ crates/ui-egui/src/i18n/cs.tsv | 3 ++ crates/ui-egui/src/i18n/da.tsv | 3 ++ crates/ui-egui/src/i18n/de.tsv | 3 ++ crates/ui-egui/src/i18n/el.tsv | 3 ++ crates/ui-egui/src/i18n/es.tsv | 3 ++ crates/ui-egui/src/i18n/fi.tsv | 3 ++ crates/ui-egui/src/i18n/fr.tsv | 3 ++ crates/ui-egui/src/i18n/hi.tsv | 8 ++++ crates/ui-egui/src/i18n/hu.tsv | 3 ++ crates/ui-egui/src/i18n/id.tsv | 3 ++ crates/ui-egui/src/i18n/it.tsv | 3 ++ crates/ui-egui/src/i18n/ja.tsv | 3 ++ crates/ui-egui/src/i18n/ko.tsv | 3 ++ crates/ui-egui/src/i18n/nb.tsv | 3 ++ crates/ui-egui/src/i18n/nl.tsv | 3 ++ crates/ui-egui/src/i18n/pl.tsv | 3 ++ crates/ui-egui/src/i18n/pt-br.tsv | 3 ++ crates/ui-egui/src/i18n/ru.tsv | 3 ++ crates/ui-egui/src/i18n/sr.tsv | 3 ++ crates/ui-egui/src/i18n/sv.tsv | 3 ++ crates/ui-egui/src/i18n/tr.tsv | 3 ++ crates/ui-egui/src/i18n/uk.tsv | 3 ++ crates/ui-egui/src/i18n/vi.tsv | 3 ++ crates/ui-egui/src/i18n/zh-hans.tsv | 3 ++ crates/ui-egui/src/i18n/zh-hant.tsv | 3 ++ crates/ui-egui/src/panels/actions.rs | 42 ++++++++++++++---- crates/ui-egui/src/panels/actions/tests.rs | 51 +++++++++++++++++++++- 28 files changed, 167 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 921c6c6e39..0b09aae6e1 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -3264,6 +3264,9 @@ curves Smooth تنعيم @plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} خطوة ليس لها مكافئ في PhotoCraft بعد|{n} خطوة ليس لها مكافئ في PhotoCraft بعد|{n} خطوتان ليس لهما مكافئ في PhotoCraft بعد|{n} خطوات ليس لها مكافئ في PhotoCraft بعد|{n} خطوةً ليس لها مكافئ في PhotoCraft بعد|{n} خطوة ليس لها مكافئ في PhotoCraft بعد @plural Saved {n} action to {file}|Saved {n} actions to {file} تم حفظ {n} إجراء في {file}|تم حفظ {n} إجراء في {file}|تم حفظ {n} إجراءين في {file}|تم حفظ {n} إجراءات في {file}|تم حفظ {n} إجراءً في {file}|تم حفظ {n} إجراء في {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out تم استبعاد {n} خطوة ليس لها مكافئ في Photoshop|تم استبعاد {n} خطوة ليس لها مكافئ في Photoshop|تم استبعاد {n} خطوتين ليس لهما مكافئ في Photoshop|تم استبعاد {n} خطوات ليس لها مكافئ في Photoshop|تم استبعاد {n} خطوةً ليس لها مكافئ في Photoshop|تم استبعاد {n} خطوة ليس لها مكافئ في Photoshop +@plural {n} action file loaded|{n} action files loaded تم تحميل {n} ملف إجراءات|تم تحميل {n} ملف إجراءات|تم تحميل {n} ملفي إجراءات|تم تحميل {n} ملفات إجراءات|تم تحميل {n} ملفًا للإجراءات|تم تحميل {n} ملف إجراءات + {set} from {file} replaced the one from {earlier} حلّت المجموعة {set} من {file} محل المجموعة من {earlier} +@plural {n} file could not be loaded: {errors}|{n} files could not be loaded: {errors} تعذّر تحميل {n} ملف: {errors}|تعذّر تحميل {n} ملف: {errors}|تعذّر تحميل {n} ملفين: {errors}|تعذّر تحميل {n} ملفات: {errors}|تعذّر تحميل {n} ملفًا: {errors}|تعذّر تحميل {n} ملف: {errors} File Naming تسمية الملفات Starting serial الرقم التسلسلي الأولي diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index fbbe8d1de0..704517c867 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3884,6 +3884,9 @@ navigator Zoom Zvětšení @plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} krok zatím nemá v PhotoCraftu obdobu|{n} kroky zatím nemají v PhotoCraftu obdobu|{n} kroků zatím nemá v PhotoCraftu obdobu @plural Saved {n} action to {file}|Saved {n} actions to {file} Uložena {n} akce do souboru {file}|Uloženy {n} akce do souboru {file}|Uloženo {n} akcí do souboru {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Vynechán {n} krok bez obdoby v Photoshopu|Vynechány {n} kroky bez obdoby v Photoshopu|Vynecháno {n} kroků bez obdoby v Photoshopu +@plural {n} action file loaded|{n} action files loaded Načten {n} soubor akcí|Načteny {n} soubory akcí|Načteno {n} souborů akcí + {set} from {file} replaced the one from {earlier} Sada {set} ze souboru {file} nahradila sadu ze souboru {earlier} +@plural {n} file could not be loaded: {errors}|{n} files could not be loaded: {errors} {n} soubor nelze načíst: {errors}|{n} soubory nelze načíst: {errors}|{n} souborů nelze načíst: {errors} File Naming Pojmenování souborů Starting serial Počáteční pořadové číslo Insert Stop… Vložit zastavení… diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 2bb8fcbb59..00cfb09d84 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -3182,6 +3182,9 @@ curves Smooth Udjævn @plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} trin har ingen PhotoCraft-ækvivalent endnu|{n} trin har ingen PhotoCraft-ækvivalent endnu @plural Saved {n} action to {file}|Saved {n} actions to {file} Gemte {n} handling i {file}|Gemte {n} handlinger i {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out {n} trin uden Photoshop-ækvivalent udeladt|{n} trin uden Photoshop-ækvivalent udeladt +@plural {n} action file loaded|{n} action files loaded {n} handlingsfil indlæst|{n} handlingsfiler indlæst + {set} from {file} replaced the one from {earlier} Sættet {set} fra {file} erstattede det fra {earlier} +@plural {n} file could not be loaded: {errors}|{n} files could not be loaded: {errors} {n} fil kunne ikke indlæses: {errors}|{n} filer kunne ikke indlæses: {errors} File Naming Filnavngivning Starting serial Startserienummer diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 8cd5a6b02d..49ef4a86ff 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3282,6 +3282,9 @@ curves Smooth Glätten @plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} Schritt hat noch keine Entsprechung in PhotoCraft|{n} Schritte haben noch keine Entsprechung in PhotoCraft @plural Saved {n} action to {file}|Saved {n} actions to {file} {n} Aktion in {file} gespeichert|{n} Aktionen in {file} gespeichert @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out {n} Schritt ohne Photoshop-Entsprechung ausgelassen|{n} Schritte ohne Photoshop-Entsprechung ausgelassen +@plural {n} action file loaded|{n} action files loaded {n} Aktionsdatei geladen|{n} Aktionsdateien geladen + {set} from {file} replaced the one from {earlier} Satz {set} aus {file} hat den aus {earlier} ersetzt +@plural {n} file could not be loaded: {errors}|{n} files could not be loaded: {errors} {n} Datei konnte nicht geladen werden: {errors}|{n} Dateien konnten nicht geladen werden: {errors} File Naming Dateibenennung Starting serial Startnummer Insert Stop… Stopp einfügen… diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index fc778e15ae..2b154f6c0b 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3967,6 +3967,9 @@ curves Smooth Εξομάλυνση @plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} βήμα δεν έχει ακόμη αντίστοιχο στο PhotoCraft|{n} βήματα δεν έχουν ακόμη αντίστοιχο στο PhotoCraft @plural Saved {n} action to {file}|Saved {n} actions to {file} Αποθηκεύτηκε {n} ενέργεια στο {file}|Αποθηκεύτηκαν {n} ενέργειες στο {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Παραλείφθηκε {n} βήμα χωρίς αντίστοιχο στο Photoshop|Παραλείφθηκαν {n} βήματα χωρίς αντίστοιχο στο Photoshop +@plural {n} action file loaded|{n} action files loaded Φορτώθηκε {n} αρχείο ενεργειών|Φορτώθηκαν {n} αρχεία ενεργειών + {set} from {file} replaced the one from {earlier} Το σύνολο {set} από το {file} αντικατέστησε εκείνο από το {earlier} +@plural {n} file could not be loaded: {errors}|{n} files could not be loaded: {errors} Δεν ήταν δυνατή η φόρτωση {n} αρχείου: {errors}|Δεν ήταν δυνατή η φόρτωση {n} αρχείων: {errors} File Naming Ονομασία αρχείων Starting serial Αρχικός αριθμός σειράς Insert Stop… Εισαγωγή διακοπής… diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 70c79fd3b4..45bb916716 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4181,6 +4181,9 @@ navigator Zoom Zoom @plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} paso aún no tiene equivalente en PhotoCraft|{n} pasos aún no tienen equivalente en PhotoCraft @plural Saved {n} action to {file}|Saved {n} actions to {file} Se guardó {n} acción en {file}|Se guardaron {n} acciones en {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out {n} paso sin equivalente en Photoshop omitido|{n} pasos sin equivalente en Photoshop omitidos +@plural {n} action file loaded|{n} action files loaded Se cargó {n} archivo de acciones|Se cargaron {n} archivos de acciones + {set} from {file} replaced the one from {earlier} El conjunto {set} de {file} reemplazó al de {earlier} +@plural {n} file could not be loaded: {errors}|{n} files could not be loaded: {errors} No se pudo cargar {n} archivo: {errors}|No se pudieron cargar {n} archivos: {errors} Starting serial Número de serie inicial Insert Stop… Insertar detención… Save Set… Guardar conjunto… diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 4eed74673c..1cacf9ffea 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -3183,6 +3183,9 @@ curves Smooth Tasoita @plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} vaiheella ei ole vielä vastaavuutta PhotoCraftissa|{n} vaiheella ei ole vielä vastaavuutta PhotoCraftissa @plural Saved {n} action to {file}|Saved {n} actions to {file} Tallennettu {n} toiminto tiedostoon {file}|Tallennettu {n} toimintoa tiedostoon {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out {n} vaihe jätettiin pois, koska sillä ei ole Photoshop-vastinetta|{n} vaihetta jätettiin pois, koska niillä ei ole Photoshop-vastinetta +@plural {n} action file loaded|{n} action files loaded Ladattu {n} toimintotiedosto|Ladattu {n} toimintotiedostoa + {set} from {file} replaced the one from {earlier} Sarja {set} tiedostosta {file} korvasi sarjan tiedostosta {earlier} +@plural {n} file could not be loaded: {errors}|{n} files could not be loaded: {errors} {n} tiedoston lataaminen epäonnistui: {errors}|{n} tiedoston lataaminen epäonnistui: {errors} File Naming Tiedostojen nimeäminen Starting serial Aloitusnumero diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 923f6f6fbb..3c45f01228 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3273,6 +3273,9 @@ curves Smooth Lisser @plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} étape n’a pas encore d’équivalent dans PhotoCraft|{n} étapes n’ont pas encore d’équivalent dans PhotoCraft @plural Saved {n} action to {file}|Saved {n} actions to {file} {n} action enregistrée dans {file}|{n} actions enregistrées dans {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out {n} étape sans équivalent Photoshop omise|{n} étapes sans équivalent Photoshop omises +@plural {n} action file loaded|{n} action files loaded {n} fichier d'actions chargé|{n} fichiers d'actions chargés + {set} from {file} replaced the one from {earlier} Le jeu {set} de {file} a remplacé celui de {earlier} +@plural {n} file could not be loaded: {errors}|{n} files could not be loaded: {errors} {n} fichier n'a pas pu être chargé : {errors}|{n} fichiers n'ont pas pu être chargés : {errors} File Naming Dénomination des fichiers Starting serial Numéro de série initial Insert Stop… Insérer un arrêt… diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 659cab4fee..0acf7e4bf1 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -589,3 +589,11 @@ cameraRaw Yellow पीला # Egui 0.37 migration controls (Aspect Ratio, Category, Color Table, Gamma, Interpolation, Profile, Target, Units, Demosaic) Invalid numeric entry. An integer between {min}% and {max}% is required. Previous value was inserted. अमान्य संख्यात्मक प्रविष्टि। {min}% और {max}% के बीच एक पूर्णांक आवश्यक है। पिछला मान डाला गया। Edit Brush… ब्रश संपादित करें… + +# Actions panel: loading several action files at once + {message} ({note}) {message} ({note}) +@plural {n} action|{n} actions {n} एक्शन|{n} एक्शन +@plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} चरण का अभी PhotoCraft में कोई समकक्ष नहीं है|{n} चरणों का अभी PhotoCraft में कोई समकक्ष नहीं है +@plural {n} action file loaded|{n} action files loaded {n} एक्शन फ़ाइल लोड हुई|{n} एक्शन फ़ाइलें लोड हुईं + {set} from {file} replaced the one from {earlier} {file} के सेट {set} ने {earlier} वाले सेट की जगह ले ली +@plural {n} file could not be loaded: {errors}|{n} files could not be loaded: {errors} {n} फ़ाइल लोड नहीं हो सकी: {errors}|{n} फ़ाइलें लोड नहीं हो सकीं: {errors} diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index af18a8aa6d..f77ec930ef 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4144,6 +4144,9 @@ cameraRaw Yellow Sárga @plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} lépésnek még nincs megfelelője a PhotoCraftban @plural Saved {n} action to {file}|Saved {n} actions to {file} {n} művelet mentve. Cél: {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out {n} Photoshop-megfelelő nélküli lépés kihagyva +@plural {n} action file loaded|{n} action files loaded {n} műveletfájl betöltve + {set} from {file} replaced the one from {earlier} A(z) {file} {set} készlete lecserélte a(z) {earlier} készletét +@plural {n} file could not be loaded: {errors}|{n} files could not be loaded: {errors} {n} fájlt nem sikerült betölteni: {errors} Starting serial Kezdő sorszám Insert Stop… Leállítás beszúrása… Save Set… Készlet mentése… diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 601dca3b8d..09d37cf787 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3278,6 +3278,9 @@ curves Smooth Haluskan @plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} langkah belum memiliki padanan di PhotoCraft @plural Saved {n} action to {file}|Saved {n} actions to {file} {n} tindakan disimpan ke {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out {n} langkah tanpa padanan Photoshop dilewati +@plural {n} action file loaded|{n} action files loaded {n} file tindakan dimuat + {set} from {file} replaced the one from {earlier} Set {set} dari {file} menggantikan set dari {earlier} +@plural {n} file could not be loaded: {errors}|{n} files could not be loaded: {errors} {n} file tidak dapat dimuat: {errors} File Naming Penamaan Berkas Starting serial Nomor urut awal Insert Stop… Sisipkan Berhenti… diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index a6ca7389ce..d66b0499bb 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4230,6 +4230,9 @@ navigator Zoom Zoom @plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} passaggio non ha ancora un equivalente in PhotoCraft|{n} passaggi non hanno ancora un equivalente in PhotoCraft @plural Saved {n} action to {file}|Saved {n} actions to {file} Salvata {n} azione in {file}|Salvate {n} azioni in {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Omesso {n} passaggio senza equivalente in Photoshop|Omessi {n} passaggi senza equivalente in Photoshop +@plural {n} action file loaded|{n} action files loaded Caricato {n} file di azioni|Caricati {n} file di azioni + {set} from {file} replaced the one from {earlier} Il set {set} di {file} ha sostituito quello di {earlier} +@plural {n} file could not be loaded: {errors}|{n} files could not be loaded: {errors} Impossibile caricare {n} file: {errors}|Impossibile caricare {n} file: {errors} File Naming Denominazione file Starting serial Numero di serie iniziale Insert Stop… Inserisci interruzione… diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 062137ac41..4bd69f5e9c 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3885,6 +3885,9 @@ curves Smooth スムーズ @plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} 個のステップにはまだ PhotoCraft の対応機能がありません @plural Saved {n} action to {file}|Saved {n} actions to {file} {file} にアクション {n} 個を保存しました @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Photoshop に対応するもののない {n} 個のステップを省きました +@plural {n} action file loaded|{n} action files loaded アクションファイル {n} 個を読み込みました + {set} from {file} replaced the one from {earlier} {file} のセット {set} が {earlier} のセットを置き換えました +@plural {n} file could not be loaded: {errors}|{n} files could not be loaded: {errors} {n} 個のファイルを読み込めませんでした: {errors} File Naming ファイル名の付け方 Starting serial 開始番号 Insert Stop… ストップを挿入… diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 7117a2c747..5f63b60748 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3473,6 +3473,9 @@ curves Smooth 매끄럽게 @plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n}개 단계에는 아직 PhotoCraft에 해당하는 기능이 없습니다 @plural Saved {n} action to {file}|Saved {n} actions to {file} {file}에 액션 {n}개를 저장했습니다 @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Photoshop에 해당하는 기능이 없는 {n}개 단계를 제외했습니다 +@plural {n} action file loaded|{n} action files loaded 액션 파일 {n}개를 불러왔습니다 + {set} from {file} replaced the one from {earlier} {file}의 세트 {set}이(가) {earlier}의 세트를 대체했습니다 +@plural {n} file could not be loaded: {errors}|{n} files could not be loaded: {errors} 파일 {n}개를 불러오지 못했습니다: {errors} File Naming 파일 이름 지정 Starting serial 시작 일련번호 Insert Stop… 정지 삽입… diff --git a/crates/ui-egui/src/i18n/nb.tsv b/crates/ui-egui/src/i18n/nb.tsv index 14e7d9824f..85f9a0441a 100644 --- a/crates/ui-egui/src/i18n/nb.tsv +++ b/crates/ui-egui/src/i18n/nb.tsv @@ -3182,6 +3182,9 @@ curves Smooth Jevn ut @plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} trinn har ingen PhotoCraft-ekvivalent ennå|{n} trinn har ingen PhotoCraft-ekvivalent ennå @plural Saved {n} action to {file}|Saved {n} actions to {file} Lagret {n} handling til {file}|Lagret {n} handlinger til {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out {n} trinn uten Photoshop-ekvivalent ble utelatt|{n} trinn uten Photoshop-ekvivalent ble utelatt +@plural {n} action file loaded|{n} action files loaded {n} handlingsfil lastet inn|{n} handlingsfiler lastet inn + {set} from {file} replaced the one from {earlier} Settet {set} fra {file} erstattet det fra {earlier} +@plural {n} file could not be loaded: {errors}|{n} files could not be loaded: {errors} {n} fil kunne ikke lastes inn: {errors}|{n} filer kunne ikke lastes inn: {errors} File Naming Filnavngiving Starting serial Startserienummer diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 4ae734f302..2914efc01a 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -4026,6 +4026,9 @@ curves Smooth Vloeiend maken @plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} stap heeft nog geen equivalent in PhotoCraft|{n} stappen hebben nog geen equivalent in PhotoCraft @plural Saved {n} action to {file}|Saved {n} actions to {file} {n} handeling opgeslagen in {file}|{n} handelingen opgeslagen in {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out {n} stap zonder Photoshop-equivalent weggelaten|{n} stappen zonder Photoshop-equivalent weggelaten +@plural {n} action file loaded|{n} action files loaded {n} handelingenbestand geladen|{n} handelingenbestanden geladen + {set} from {file} replaced the one from {earlier} Set {set} uit {file} heeft die uit {earlier} vervangen +@plural {n} file could not be loaded: {errors}|{n} files could not be loaded: {errors} {n} bestand kon niet worden geladen: {errors}|{n} bestanden konden niet worden geladen: {errors} File Naming Bestandsnamen Starting serial Beginnummer Insert Stop… Stop invoegen… diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 561d12b1fa..5092ea491d 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3883,6 +3883,9 @@ curves Smooth Wygładź @plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} krok nie ma jeszcze odpowiednika w PhotoCraft|{n} kroki nie mają jeszcze odpowiednika w PhotoCraft|{n} kroków nie ma jeszcze odpowiednika w PhotoCraft @plural Saved {n} action to {file}|Saved {n} actions to {file} Zapisano {n} operację do {file}|Zapisano {n} operacje do {file}|Zapisano {n} operacji do {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Pominięto {n} krok bez odpowiednika w Photoshopie|Pominięto {n} kroki bez odpowiednika w Photoshopie|Pominięto {n} kroków bez odpowiednika w Photoshopie +@plural {n} action file loaded|{n} action files loaded Wczytano {n} plik operacji|Wczytano {n} pliki operacji|Wczytano {n} plików operacji + {set} from {file} replaced the one from {earlier} Zestaw {set} z {file} zastąpił zestaw z {earlier} +@plural {n} file could not be loaded: {errors}|{n} files could not be loaded: {errors} Nie udało się wczytać {n} pliku: {errors}|Nie udało się wczytać {n} plików: {errors}|Nie udało się wczytać {n} plików: {errors} File Naming Nazewnictwo plików Starting serial Początkowy numer seryjny Insert Stop… Wstaw zatrzymanie… diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 0ccb6ffe8e..e1077f10ec 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -4066,6 +4066,9 @@ layerLabel No Color Sem cor @plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} etapa ainda não tem equivalente no PhotoCraft|{n} etapas ainda não têm equivalente no PhotoCraft @plural Saved {n} action to {file}|Saved {n} actions to {file} {n} ação salva em {file}|{n} ações salvas em {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out {n} etapa sem equivalente no Photoshop foi omitida|{n} etapas sem equivalente no Photoshop foram omitidas +@plural {n} action file loaded|{n} action files loaded {n} arquivo de ações carregado|{n} arquivos de ações carregados + {set} from {file} replaced the one from {earlier} O conjunto {set} de {file} substituiu o de {earlier} +@plural {n} file could not be loaded: {errors}|{n} files could not be loaded: {errors} Não foi possível carregar {n} arquivo: {errors}|Não foi possível carregar {n} arquivos: {errors} File Naming Nomenclatura de arquivos Starting serial Número de série inicial Insert Stop… Inserir parada… diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index bb117dee33..a7f45fbc7a 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3230,6 +3230,9 @@ curves Smooth Сгладить @plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} шаг пока не имеет эквивалента в PhotoCraft|{n} шага пока не имеют эквивалента в PhotoCraft|{n} шагов пока не имеют эквивалента в PhotoCraft @plural Saved {n} action to {file}|Saved {n} actions to {file} Сохранена {n} операция в {file}|Сохранены {n} операции в {file}|Сохранено {n} операций в {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Пропущен {n} шаг без эквивалента в Photoshop|Пропущены {n} шага без эквивалента в Photoshop|Пропущено {n} шагов без эквивалента в Photoshop +@plural {n} action file loaded|{n} action files loaded Загружен {n} файл операций|Загружены {n} файла операций|Загружено {n} файлов операций + {set} from {file} replaced the one from {earlier} Набор {set} из {file} заменил набор из {earlier} +@plural {n} file could not be loaded: {errors}|{n} files could not be loaded: {errors} Не удалось загрузить {n} файл: {errors}|Не удалось загрузить {n} файла: {errors}|Не удалось загрузить {n} файлов: {errors} File Naming Именование файлов Starting serial Начальный порядковый номер Insert Stop… Вставить остановку… diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 3fc1d8b861..a3baa8e723 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -4292,6 +4292,9 @@ shape Appearance Izgled @plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} korak još nema ekvivalent u PhotoCraftu|{n} koraka još nemaju ekvivalent u PhotoCraftu|{n} koraka još nema ekvivalent u PhotoCraftu @plural Saved {n} action to {file}|Saved {n} actions to {file} Sačuvana je {n} radnja u {file}|Sačuvane su {n} radnje u {file}|Sačuvano je {n} radnji u {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Izostavljen je {n} korak bez ekvivalenta u Photoshopu|Izostavljena su {n} koraka bez ekvivalenta u Photoshopu|Izostavljeno je {n} koraka bez ekvivalenta u Photoshopu +@plural {n} action file loaded|{n} action files loaded Učitana je {n} datoteka radnji|Učitane su {n} datoteke radnji|Učitano je {n} datoteka radnji + {set} from {file} replaced the one from {earlier} Skup {set} iz {file} zamenio je skup iz {earlier} +@plural {n} file could not be loaded: {errors}|{n} files could not be loaded: {errors} Nije moguće učitati {n} datoteku: {errors}|Nije moguće učitati {n} datoteke: {errors}|Nije moguće učitati {n} datoteka: {errors} Insert Stop… Umetni zaustavljanje… Save Set… Sačuvaj skup… Message: Poruka: diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 10b5805352..64191012c4 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -3180,6 +3180,9 @@ curves Smooth Jämna ut @plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} steg saknar ännu motsvarighet i PhotoCraft|{n} steg saknar ännu motsvarighet i PhotoCraft @plural Saved {n} action to {file}|Saved {n} actions to {file} Sparade {n} åtgärd i {file}|Sparade {n} åtgärder i {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out {n} steg utan motsvarighet i Photoshop utelämnades|{n} steg utan motsvarighet i Photoshop utelämnades +@plural {n} action file loaded|{n} action files loaded {n} åtgärdsfil inläst|{n} åtgärdsfiler inlästa + {set} from {file} replaced the one from {earlier} Uppsättningen {set} från {file} ersatte den från {earlier} +@plural {n} file could not be loaded: {errors}|{n} files could not be loaded: {errors} {n} fil kunde inte läsas in: {errors}|{n} filer kunde inte läsas in: {errors} File Naming Filnamngivning Starting serial Startnummer diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 8770968a57..14360176cd 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3951,6 +3951,9 @@ cameraData Lens Lens @plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} adımın PhotoCraft’ta henüz karşılığı yok @plural Saved {n} action to {file}|Saved {n} actions to {file} {n} eylem {file} dosyasına kaydedildi @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Photoshop karşılığı olmayan {n} adım atlandı +@plural {n} action file loaded|{n} action files loaded {n} eylem dosyası yüklendi + {set} from {file} replaced the one from {earlier} {file} dosyasındaki {set} kümesi, {earlier} dosyasındakinin yerini aldı +@plural {n} file could not be loaded: {errors}|{n} files could not be loaded: {errors} {n} dosya yüklenemedi: {errors} Starting serial Başlangıç sıra numarası Insert Stop… Durdurma Ekle… Save Set… Seti Kaydet… diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index c3241495bc..ededaa1dad 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3969,6 +3969,9 @@ curves Smooth Згладити @plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} крок ще не має відповідника в PhotoCraft|{n} кроки ще не мають відповідника в PhotoCraft|{n} кроків ще не мають відповідника в PhotoCraft @plural Saved {n} action to {file}|Saved {n} actions to {file} Збережено {n} операцію до {file}|Збережено {n} операції до {file}|Збережено {n} операцій до {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Пропущено {n} крок без відповідника в Photoshop|Пропущено {n} кроки без відповідника в Photoshop|Пропущено {n} кроків без відповідника в Photoshop +@plural {n} action file loaded|{n} action files loaded Завантажено {n} файл операцій|Завантажено {n} файли операцій|Завантажено {n} файлів операцій + {set} from {file} replaced the one from {earlier} Набір {set} з {file} замінив набір з {earlier} +@plural {n} file could not be loaded: {errors}|{n} files could not be loaded: {errors} Не вдалося завантажити {n} файл: {errors}|Не вдалося завантажити {n} файли: {errors}|Не вдалося завантажити {n} файлів: {errors} File Naming Іменування файлів Starting serial Початковий порядковий номер Insert Stop… Вставити зупинку… diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index fcf6f41568..9219475c88 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3725,6 +3725,9 @@ curves Smooth Làm mượt @plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} bước chưa có chức năng tương ứng trong PhotoCraft @plural Saved {n} action to {file}|Saved {n} actions to {file} Đã lưu {n} hành động vào {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Đã bỏ qua {n} bước không có chức năng tương ứng trong Photoshop +@plural {n} action file loaded|{n} action files loaded Đã nạp {n} tệp hành động + {set} from {file} replaced the one from {earlier} Bộ {set} từ {file} đã thay thế bộ từ {earlier} +@plural {n} file could not be loaded: {errors}|{n} files could not be loaded: {errors} Không thể nạp {n} tệp: {errors} File Naming Đặt tên tệp Starting serial Số thứ tự bắt đầu Insert Stop… Chèn điểm dừng… diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 3313412efa..ef27882217 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2923,6 +2923,9 @@ curves Smooth 平滑 @plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} 个步骤在 PhotoCraft 中尚无对应功能 @plural Saved {n} action to {file}|Saved {n} actions to {file} 已将 {n} 个动作存储到 {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out 已省略 {n} 个没有 Photoshop 对应功能的步骤 +@plural {n} action file loaded|{n} action files loaded 已载入 {n} 个动作文件 + {set} from {file} replaced the one from {earlier} {file} 中的组 {set} 替换了 {earlier} 中的同名组 +@plural {n} file could not be loaded: {errors}|{n} files could not be loaded: {errors} {n} 个文件无法载入:{errors} File Naming 文件命名 Starting serial 起始序号 Insert Stop… 插入停止… diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index e95d706e86..c0bb1a3abf 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2884,6 +2884,9 @@ curves Smooth 平滑 @plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} 個步驟在 PhotoCraft 中尚無對應功能 @plural Saved {n} action to {file}|Saved {n} actions to {file} 已將 {n} 個動作儲存到 {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out 已略過 {n} 個沒有 Photoshop 對應功能的步驟 +@plural {n} action file loaded|{n} action files loaded 已載入 {n} 個動作檔案 + {set} from {file} replaced the one from {earlier} {file} 中的組合 {set} 取代了 {earlier} 中的同名組合 +@plural {n} file could not be loaded: {errors}|{n} files could not be loaded: {errors} {n} 個檔案無法載入:{errors} File Naming 檔案命名 Starting serial 起始序號 Insert Stop… 插入停止… diff --git a/crates/ui-egui/src/panels/actions.rs b/crates/ui-egui/src/panels/actions.rs index 2a1b1d6e4c..bf19b7214a 100644 --- a/crates/ui-egui/src/panels/actions.rs +++ b/crates/ui-egui/src/panels/actions.rs @@ -427,7 +427,11 @@ fn load_dialog(app: &mut PhotocraftApp) -> Result { if count == 0 { return Err(crate::file_dialog::CANCELLED.into()); } - let (mut loaded, mut actions, mut unsupported) = (0usize, 0usize, 0usize); + let mut loaded = 0usize; + // What the panel holds afterwards, per set name: `actions.load` replaces a set of the same + // name, so a later file's set takes the earlier one's place (and its counts). + let mut sets: Vec<(String, String, usize, usize)> = Vec::new(); // set, file, actions, unsupported + let mut replaced = Vec::new(); let mut errors = Vec::new(); let mut last = Value::Null; for (name, bytes) in files { @@ -439,8 +443,19 @@ fn load_dialog(app: &mut PhotocraftApp) -> Result { match load_source(app, &name, params) { Ok(r) => { loaded += 1; - actions = actions.saturating_add(r["actions"].as_array().map_or(0, Vec::len)); - unsupported = unsupported.saturating_add(r["unsupported"].as_array().map_or(0, Vec::len)); + let shown = crate::file_open::display_name(&name); + let set = r["set"].as_str().unwrap_or_default().to_string(); + let n = r["actions"].as_array().map_or(0, Vec::len); + let unsupported = r["unsupported"].as_array().map_or(0, Vec::len); + if let Some(entry) = sets.iter_mut().find(|e| e.0 == set) { + replaced.push(crate::i18n::fmt( + tl!("{set} from {file} replaced the one from {earlier}"), + &[("file", &shown), ("earlier", &entry.1), ("set", &set)], + )); + *entry = (set, shown, n, unsupported); + } else { + sets.push((set, shown, n, unsupported)); + } last = r; } Err(e) => errors.push(format!("{}: {e}", crate::file_open::display_name(&name))), @@ -452,12 +467,23 @@ fn load_dialog(app: &mut PhotocraftApp) -> Result { None => Ok(last), }; } - let template = "Action files loaded: {files}; actions: {actions}; unsupported steps: {unsupported}"; - let summary = - crate::i18n::fmt(tl!(template), &[("files", &loaded.to_string()), ("actions", &actions.to_string()), ("unsupported", &unsupported.to_string())]); + let actions = sets.iter().fold(0usize, |sum, e| sum.saturating_add(e.2)); + let unsupported = sets.iter().fold(0usize, |sum, e| sum.saturating_add(e.3)); + let lang = crate::i18n::current(); + let files = crate::i18n::trn(lang, loaded as u64, "{n} action file loaded", "{n} action files loaded"); + let mut notes = vec![crate::i18n::trn(lang, actions as u64, "{n} action", "{n} actions")]; + if unsupported > 0 { + notes.push(crate::i18n::trn(lang, unsupported as u64, "{n} step has no PhotoCraft equivalent yet", "{n} steps have no PhotoCraft equivalent yet")); + } + notes.extend(replaced); if !errors.is_empty() { - let template = "{summary}; files failed: {count}. {errors}"; - return Err(crate::i18n::fmt(tl!(template), &[("summary", &summary), ("count", &errors.len().to_string()), ("errors", &errors.join("; "))])); + let failed = crate::i18n::trn(lang, errors.len() as u64, "{n} file could not be loaded: {errors}", "{n} files could not be loaded: {errors}"); + notes.push(crate::i18n::fmt(&failed, &[("errors", &errors.join("; "))])); + } + let template = "{message} ({note})"; + let summary = crate::i18n::fmt(tl!(template), &[("message", &files), ("note", ¬es.join("; "))]); + if !errors.is_empty() { + return Err(summary); } app.ui.status = summary; app.ui.status_error = false; diff --git a/crates/ui-egui/src/panels/actions/tests.rs b/crates/ui-egui/src/panels/actions/tests.rs index 5270662206..32579fca77 100644 --- a/crates/ui-egui/src/panels/actions/tests.rs +++ b/crates/ui-egui/src/panels/actions/tests.rs @@ -51,9 +51,58 @@ fn bulk_load_keeps_selection_order_and_earlier_warnings_and_errors() { assert_eq!(app.session.actions.list.iter().map(|a| a.name.as_str()).collect::>(), ["Replacement"]); assert_eq!(app.ui.actions.selected, Some(0)); assert!(app.ui.status_error, "a later success must not hide an earlier failure"); - for part in ["files loaded: 2", "unsupported steps: 1", "files failed: 1", "broken.atn"] { + // The replaced set's unsupported step is gone, so it is not counted. + for part in ["2 action files loaded", "1 action;", "Same set from a-last.atn replaced the one from z-first.atn", "1 file could not be loaded", "broken.atn"] + { assert!(app.ui.status.contains(part), "{part}: {}", app.ui.status); } + assert!(!app.ui.status.contains("PhotoCraft equivalent"), "{}", app.ui.status); +} + +/// Load `files` (name, bytes) in one Load Actions… selection and return the status bar text. +fn bulk_load_status(app: &mut PhotocraftApp, files: Vec<(String, Vec)>) -> String { + use crate::file_dialog::FileDialogAnswer; + let (show, _) = crate::file_dialog::fake(vec![Some(FileDialogAnswer::ContentsMany(files))]); + app.services.file_dialog = Some(show); + load_dialog(app).unwrap(); + app.poll_file_dialog(&egui::Context::default(), None); + app.ui.status.clone() +} + +#[test] +/// Two files whose sets share a name: the later replaces the earlier, and the summary counts only +/// what the panel holds afterwards and says which set was replaced. +fn bulk_load_summary_counts_a_replaced_set_once() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let mut a = photocraft_psd::atn::parse(&action_set_bytes("Set 1", "A")).unwrap(); + a.actions[0].items[0].event = photocraft_psd::atn::AtnEvent::Code(*b"Xxxx"); + let files = vec![("a.atn".into(), photocraft_psd::atn::write(&a).unwrap()), ("b.atn".into(), action_set_bytes("Set 1", "B"))]; + let status = bulk_load_status(&mut app, files); + assert_eq!(app.session.actions.list.iter().map(|a| a.name.as_str()).collect::>(), ["B"]); + assert!(!app.ui.status_error, "{status}"); + assert_eq!(status, "2 action files loaded (1 action; Set 1 from b.atn replaced the one from a.atn)"); +} + +#[test] +/// Distinct set names: every set counts, nothing is reported as replaced, and the file count reads +/// right for one loaded file as well as several. +fn bulk_load_summary_counts_distinct_sets_and_pluralises_files() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let files = vec![("a.atn".into(), action_set_bytes("Set A", "A")), ("b.atn".into(), action_set_bytes("Set B", "B"))]; + assert_eq!(bulk_load_status(&mut app, files), "2 action files loaded (2 actions)"); + let files = vec![("c.atn".into(), action_set_bytes("Set C", "C")), ("bad.atn".into(), b"not an action file".to_vec())]; + let status = bulk_load_status(&mut app, files); + assert!(app.ui.status_error); + assert!(status.starts_with("1 action file loaded (1 action; 1 file could not be loaded: bad.atn: "), "{status}"); +} + +#[test] +/// The multi-file summary is translated like the rest of the Actions panel. +fn bulk_load_summary_is_translated() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let files = vec![("a.atn".into(), action_set_bytes("Set 1", "A")), ("b.atn".into(), action_set_bytes("Set 1", "B"))]; + let status = crate::i18n::with_language(crate::i18n::Lang::from_code("de").unwrap(), || bulk_load_status(&mut app, files)); + assert_eq!(status, "2 Aktionsdateien geladen (1 Aktion; Satz Set 1 aus b.atn hat den aus a.atn ersetzt)"); } #[test] From e16e4beb12c68872b74b94c14572cf74d3758a23 Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 2 Nov 2026 02:40:14 +0100 Subject: [PATCH 144/216] Integration: repair the merged Actions test file and event table; keep each PR's export rules (#10627, #10629, #10631) - atn_cmds/tests.rs: the line unions of the merges split tests into each other (shared closing lines kept once). Rebuilt from main plus every merged PR's added and changed tests, item by item; the shared every_mapped_command table merged line by line. - events.rs: Remove White Matte had lost its closing lines in the same way. - #10627 and #10631 both added a `bare_from`/`bare_to` with different export rules (the Group E one also takes a pixel `target`): #10627's is now `empty_from`/`empty_to`, so Focus Area and Remove Background keep their own rule. - The non-object params fix of #10631's fix-up (632a62b55416) applied to the same `sets_only` copies in steps_image.rs (#10627) and steps_distort.rs (#10629). - Smart Sharpen export writes `presetKind` Custom first again, as recorded steps carry it (#10627's fix-up 6ab24508d8a2 had dropped it, so load -> save lost the key). All engine tests pass (lib 2591, integration suites green). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/atn_cmds.rs | 2 +- crates/engine/src/cmd/atn_cmds/events.rs | 12 +- .../engine/src/cmd/atn_cmds/steps_distort.rs | 2 +- crates/engine/src/cmd/atn_cmds/steps_image.rs | 14 +- crates/engine/src/cmd/atn_cmds/tests.rs | 1418 +++++++++-------- 5 files changed, 736 insertions(+), 712 deletions(-) diff --git a/crates/engine/src/cmd/atn_cmds.rs b/crates/engine/src/cmd/atn_cmds.rs index 0c5290a741..bdf24a3636 100644 --- a/crates/engine/src/cmd/atn_cmds.rs +++ b/crates/engine/src/cmd/atn_cmds.rs @@ -37,8 +37,8 @@ mod steps_doc; mod steps_image; mod steps_layer; mod steps_make; -mod steps_render; mod steps_other; +mod steps_render; mod tables; #[cfg(test)] diff --git a/crates/engine/src/cmd/atn_cmds/events.rs b/crates/engine/src/cmd/atn_cmds/events.rs index be8ffd1d25..36a1e0f698 100644 --- a/crates/engine/src/cmd/atn_cmds/events.rs +++ b/crates/engine/src/cmd/atn_cmds/events.rs @@ -9,7 +9,9 @@ use super::steps_doc::{ place_from, place_to, profile_from, profile_to, purge_from, purge_to, reset_from, reset_to, save_from, save_to, stop_from, stop_to, }; use super::steps_image::{ - GALLERY_IDS, REFINE_EDGE, RIPPLE_SIZE, SMART_BRUSH, apply_image_from, apply_image_to, camera_raw_from, camera_raw_to, color_range_from, color_range_to, gallery_from, gallery_to, has_refine_strokes, oil_paint_from, oil_paint_to, radial_blur_from, radial_blur_to, refine_edge_to, select_sky_from, select_sky_to, smart_sharpen_from, smart_sharpen_to, transform_from, transform_to, + GALLERY_IDS, REFINE_EDGE, RIPPLE_SIZE, SMART_BRUSH, apply_image_from, apply_image_to, camera_raw_from, camera_raw_to, color_range_from, color_range_to, + empty_from, empty_to, gallery_from, gallery_to, has_refine_strokes, oil_paint_from, oil_paint_to, radial_blur_from, radial_blur_to, refine_edge_to, + select_sky_from, select_sky_to, smart_sharpen_from, smart_sharpen_to, transform_from, transform_to, }; use super::steps_layer::{ STROKE, apply_comp_from, apply_comp_to, clear_from, clear_to, delete_from, delete_to, duplicate_from, duplicate_to, fill_from, fill_to, flip_from, flip_to, @@ -370,18 +372,20 @@ pub(super) const EVENTS: &[Event] = &[ label: "Remove White Matte", ids: &["layer.matting.removeWhiteMatte"], from: |d| bare_from(d, "layer.matting.removeWhiteMatte"), + to: bare_to, + }, // Newer features (#10610): Photoshop records these by string ID only. Event { code: "", name: "oilPaint", label: "Oil Paint", ids: &["filter.stylize.oilPaint"], from: oil_paint_from, to: oil_paint_to }, Event { code: "", name: "smartSharpen", label: "Smart Sharpen", ids: &["filter.sharpen.smartSharpen"], from: smart_sharpen_from, to: smart_sharpen_to }, - Event { code: "", name: "focusMask", label: "Focus Area", ids: &["select.focusArea"], from: |d| bare_from(d, "select.focusArea"), to: bare_to }, + Event { code: "", name: "focusMask", label: "Focus Area", ids: &["select.focusArea"], from: |d| empty_from(d, "select.focusArea"), to: empty_to }, Event { code: "", name: "selectSky", label: "Sky", ids: &["select.sky"], from: select_sky_from, to: select_sky_to }, Event { code: "", name: "removeBackground", label: "Remove Background", ids: &["layer.removeBackground"], - from: |d| bare_from(d, "layer.removeBackground"), - to: bare_to, + from: |d| empty_from(d, "layer.removeBackground"), + to: empty_to, }, // Adjustments (Image › Adjustments). Event { diff --git a/crates/engine/src/cmd/atn_cmds/steps_distort.rs b/crates/engine/src/cmd/atn_cmds/steps_distort.rs index 439cf9b544..86ffa239da 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_distort.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_distort.rs @@ -27,7 +27,7 @@ fn setting(d: &Descriptor, key: &str, read: impl Fn(&D) -> Option, default /// Whether `p` sets nothing but `allowed` (a `null` counts as unset). fn sets_only(p: &Value, allowed: &[&str]) -> bool { - p.as_object().is_none_or(|m| m.iter().all(|(k, v)| v.is_null() || allowed.contains(&k.as_str()))) + p.is_null() || p.as_object().is_some_and(|m| m.iter().all(|(k, v)| v.is_null() || allowed.contains(&k.as_str()))) } /// An enumeration's value when its type is `ty`, the type Photoshop records it with and the diff --git a/crates/engine/src/cmd/atn_cmds/steps_image.rs b/crates/engine/src/cmd/atn_cmds/steps_image.rs index 9b340e98ab..79df36ffad 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_image.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_image.rs @@ -1,5 +1,6 @@ use super::descriptors::{ - PX, ang, boolean, class, code, desc, enm, enumerated, get_bool, get_enum, get_list, get_num, get_obj, get_ref, jbool, jnum, jstr, layer_ref, layer_target, long, num, pct, px, ref_class, Target, target, trimmed, + PX, Target, ang, boolean, class, code, desc, enm, enumerated, get_bool, get_enum, get_list, get_num, get_obj, get_ref, jbool, jnum, jstr, layer_ref, + layer_target, long, num, pct, px, ref_class, target, trimmed, }; use super::events::pixel_target; use super::fields::{F, Kind, Mapped, fields_from, fields_to, step}; @@ -662,17 +663,17 @@ fn setting(d: &Descriptor, key: &str, read: impl Fn(&D) -> Option, default /// Whether `p` sets nothing but `allowed` (a `null` counts as unset). fn sets_only(p: &Value, allowed: &[&str]) -> bool { - p.as_object().is_none_or(|m| m.iter().all(|(k, v)| v.is_null() || allowed.contains(&k.as_str()))) + p.is_null() || p.as_object().is_some_and(|m| m.iter().all(|(k, v)| v.is_null() || allowed.contains(&k.as_str()))) } /// Focus Area (`focusMask`) and Remove Background (`removeBackground`): Photoshop runs both with /// an empty descriptor. A step that carries settings keeps them as a Photoshop step. -pub(super) fn bare_from(d: &Descriptor, id: &str) -> Mapped { +pub(super) fn empty_from(d: &Descriptor, id: &str) -> Mapped { if d.items.is_empty() { step(id, json!({})) } else { Mapped::Unsupported { harmless: false } } } /// Only a step with no settings writes the empty descriptor; any setting would be lost. -pub(super) fn bare_to(_: &str, p: &Value) -> Option { +pub(super) fn empty_to(_: &str, p: &Value) -> Option { sets_only(p, &[]).then(|| desc("null")) } @@ -855,8 +856,8 @@ fn smart_sharpen_params(d: &Descriptor) -> Option> { } /// The form `smart_sharpen_params` reads, with the keys in the order Photoshop records them (as -/// PSD smart filters write it; `presetKind` is left out, as Photoshop leaves it out of a step with -/// explicit values). The command's defaults fill what the step leaves out. +/// recorded steps carry them, `presetKind` Custom first). The command's defaults fill what the +/// step leaves out. pub(super) fn smart_sharpen_to(_: &str, p: &Value) -> Option { if !pixel_target(p) || !sets_only(p, &["target", "amount", "radius", "reduceNoise"]) { return None; @@ -867,6 +868,7 @@ pub(super) fn smart_sharpen_to(_: &str, p: &Value) -> Option { }; Some( desc("null") + .with("presetKind", enm("presetKindType", "presetKindCustom")) .with("useLegacy", D::Boolean(false)) .with("Amnt", pct(num("amount", 100.0)?)) .with("Rds ", px(num("radius", 1.0)?)) diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index 150ce11b00..392103772a 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -3582,266 +3582,6 @@ fn long_names_are_clipped_instead_of_copied_into_every_diagnostic() { assert!(imp.unsupported.iter().all(|u| u.action.chars().count() == MAX_NAME_CHARS)); } -/// A Pattern Fill step whose `Ptrn` holds `id` and `name`, as the reference application records it. -fn pattern_fill_step(id: &str, name: &str) -> AtnItem { - let t = desc("patternLayer").with("Ptrn", D::Descriptor(desc("Ptrn").with("Idnt", text(id)).with("Nm ", text(name)))); - item( - "Mk ", - "Make", - Some(desc("null").with("null", class_ref("contentLayer")).with("Usng", D::Descriptor(desc("contentLayer").with("Type", D::Descriptor(t))))), - ) -} - -/// Defines a 4×4 pattern of `color` named `name` in `s`'s library from its active document. -fn define_pattern(s: &mut Session, color: &str, name: &str) -> String { - s.execute("edit.fill", json!({"contents": "color", "color": color})).unwrap(); - s.execute("edit.definePattern", json!({"name": name})).unwrap()["pattern"].as_str().unwrap().to_string() -} - -fn pixel(s: &mut Session) -> Vec { - let v = s.execute("document.pixel", json!({"x": 1, "y": 1})).unwrap(); - v.as_array().unwrap().iter().map(|c| c.as_f64().unwrap()).collect() -} - -/// A Pattern Fill step whose `Ptrn` names both an installed id and a display name shared by -/// another pattern plays the pattern with that id; a foreign id falls back to the name. -#[test] -fn pattern_fill_prefers_an_installed_id_over_a_colliding_name() { - let mut s = Session::new(); - s.execute("file.new", json!({"width": 4, "height": 4, "mode": "rgb", "depth": 8})).unwrap(); - let mut define = |color: &str| { - s.execute("edit.fill", json!({"contents": "color", "color": color})).unwrap(); - s.execute("edit.definePattern", json!({"name": "Twin"})).unwrap()["pattern"].as_str().unwrap().to_string() - }; - let red = define("#ff0000"); - let blue = define("#0000ff"); - assert_ne!(red, blue); - s.patterns.items.retain(|p| p.id == red || p.id == blue); - s.patterns.items.sort_by_key(|p| p.id != blue); - let fill_step = |id: &str| { - let t = desc("patternLayer").with("Ptrn", D::Descriptor(desc("Ptrn").with("Idnt", text(id)).with("Nm ", text("Twin")))); - item( - "Mk ", - "Make", - Some(desc("null").with("null", class_ref("contentLayer")).with("Usng", D::Descriptor(desc("contentLayer").with("Type", D::Descriptor(t))))), - ) - }; - let data = set_of("Pat", vec![("Installed", vec![fill_step(&red)]), ("Foreign", vec![fill_step("not-installed-id")])]); - let r = s.execute("actions.load", json!({"data": data})).unwrap(); - assert!(r["unsupported"].as_array().is_none_or(|u| u.is_empty()), "{r}"); - for (action, want) in [("Installed", [1.0, 0.0, 0.0, 1.0]), ("Foreign", [0.0, 0.0, 1.0, 1.0])] { - s.execute("file.new", json!({"width": 4, "height": 4, "mode": "rgb", "depth": 8})).unwrap(); - let r = s.execute("actions.play", json!({"action": action})).unwrap(); - assert!(r.get("failed").is_none(), "{r}"); - let v = s.execute("document.pixel", json!({"x": 1, "y": 1})).unwrap(); - let got: Vec = v.as_array().unwrap().iter().map(|c| c.as_f64().unwrap()).collect(); - assert_eq!(got, want, "{action}"); - } -} - -/// The recorded id is looked up by id alone, in the document and then the library, before any -/// name: a document pattern *named* like the id does not beat the library pattern with that id, -/// and a foreign id that happens to be another pattern's name falls back to the recorded name. -/// An id and a name of the same pattern play it. -#[test] -fn pattern_fill_ids_beat_names_across_document_and_library() { - let mut s = Session::new(); - s.execute("file.new", json!({"width": 4, "height": 4, "mode": "rgb", "depth": 8})).unwrap(); - let red = define_pattern(&mut s, "#ff0000", "Red"); - define_pattern(&mut s, "#0000ff", "Blue"); - let green = define_pattern(&mut s, "#00ff00", &red); - let data = set_of( - "Pat", - vec![ - ("IdWins", vec![pattern_fill_step(&red, "Blue")]), - ("NameFallback", vec![pattern_fill_step("Red", "Blue")]), - ("Same", vec![pattern_fill_step(&red, "Red")]), - ], - ); - s.execute("actions.load", json!({"data": data})).unwrap(); - // Only the playback document holds the green pattern named like the red one's id. - s.execute("file.new", json!({"width": 4, "height": 4, "mode": "rgb", "depth": 8})).unwrap(); - s.execute("layer.newFillLayer.pattern", json!({"pattern": green})).unwrap(); - s.execute("pattern.delete", json!({"pattern": green})).unwrap(); - assert!(s.active().unwrap().doc.patterns.iter().any(|p| p.id == green && p.name == red)); - assert_eq!(pixel(&mut s), [0.0, 1.0, 0.0, 1.0]); - for (action, want) in [("IdWins", [1.0, 0.0, 0.0, 1.0]), ("NameFallback", [0.0, 0.0, 1.0, 1.0]), ("Same", [1.0, 0.0, 0.0, 1.0])] { - let r = s.execute("actions.play", json!({"action": action})).unwrap(); - assert!(r.get("failed").is_none(), "{action}: {r}"); - assert_eq!(pixel(&mut s), want, "{action}"); - } -} - -/// CodeRabbit on batch 11: a pattern step that names its pattern only by `patternName` exports -/// that name, rather than a step with no pattern (which plays the current one). -#[test] -fn pattern_steps_named_only_by_pattern_name_export_the_name() { - let one = Action { name: "P".into(), steps: vec![("layer.newFillLayer.pattern".into(), json!({"patternName": "Checkerboard"}))], ..Default::default() }; - let (bytes, skipped) = export("Pat", &[&one], &[], false).unwrap(); - assert!(skipped.is_empty()); - let steps = import(&bytes).unwrap().actions[0].steps.clone(); - assert_eq!(steps[0].0, "layer.newFillLayer.pattern"); - assert_eq!(steps[0].1["pattern"], json!("Checkerboard"), "{}", steps[0].1); -} - -/// CodeRabbit on batch 11: when the recorded id is installed nowhere, the recorded name is looked -/// up as a name only. A pattern whose id equals that name text doesn't beat the one named so. -#[test] -fn pattern_fill_name_fallback_matches_names_not_ids() { - let mut s = Session::new(); - s.execute("file.new", json!({"width": 4, "height": 4, "mode": "rgb", "depth": 8})).unwrap(); - let red = define_pattern(&mut s, "#ff0000", "Red"); - // Green is named like the red pattern's id. - define_pattern(&mut s, "#00ff00", &red); - let data = set_of("Pat", vec![("NameIsAnId", vec![pattern_fill_step("5e1c0000-aaaa-4bbb-8ccc-000000000000", &red)])]); - s.execute("actions.load", json!({"data": data})).unwrap(); - s.execute("file.new", json!({"width": 4, "height": 4, "mode": "rgb", "depth": 8})).unwrap(); - let r = s.execute("actions.play", json!({"action": "NameIsAnId"})).unwrap(); - assert!(r.get("failed").is_none(), "{r}"); - assert_eq!(pixel(&mut s), [0.0, 1.0, 0.0, 1.0]); -} - -/// A recorded pattern installed neither by id nor by name fails with an error that names the -/// pattern, from the imported step and from the Pattern Fill dialog's command alike. -#[test] -fn pattern_fill_missing_everywhere_names_the_pattern() { - let mut s = Session::new(); - s.execute("file.new", json!({"width": 4, "height": 4, "mode": "rgb", "depth": 8})).unwrap(); - define_pattern(&mut s, "#ff0000", "Red"); - let data = set_of("Pat", vec![("Missing", vec![pattern_fill_step("5e1c0000-aaaa-4bbb-8ccc-000000000000", "My Wood")])]); - s.execute("actions.load", json!({"data": data})).unwrap(); - let r = s.execute("actions.play", json!({"action": "Missing"})).unwrap(); - let err = r["failed"]["error"].as_str().unwrap_or_default(); - assert!(err.contains("\"My Wood\""), "{r}"); - s.execute("layer.newFillLayer.pattern", json!({"pattern": "Red"})).unwrap(); - let e = s.execute("layer.fill.pattern.set", json!({"pattern": "5e1c0000-aaaa-4bbb-8ccc-000000000000", "patternName": "My Wood"})).unwrap_err(); - assert!(e.to_string().contains("\"My Wood\""), "{e}"); -} - -/// FredApps review on #10459: a set whose name has surrounding spaces (a Photoshop `.atn` or an -/// older actions.json can have one, and New Action records into it under that exact name) saves -/// by that name. `actions.save` used to trim it and then find no set. -#[test] -fn save_finds_a_set_by_its_exact_name_with_spaces() { - let mut s = Session::new(); - s.actions.list.push(Action { set: Some(" Web ".into()), ..Action::new("Old", Vec::new()) }); - s.execute("actions.record", json!({"name": "New", "set": " Web "})).unwrap(); - s.execute("actions.stop", json!({})).unwrap(); - let saved = s.execute("actions.save", json!({"set": " Web "})).unwrap(); - assert_eq!((&saved["set"], &saved["actions"]), (&json!(" Web "), &json!(["Old", "New"]))); - // The file keeps the name, as Photoshop's does. - let mut reloaded = Session::new(); - let r = reloaded.execute("actions.load", json!({"data": saved["data"]})).unwrap(); - assert_eq!(r["set"], " Web "); - // Other names are still trimmed. - s.execute("actions.assignSet", json!({"action": "Old", "set": "Print"})).unwrap(); - assert_eq!(s.execute("actions.save", json!({"set": " Print "})).unwrap()["set"], "Print"); -} - -/// FredApps review on #10459: Save Actions… on an empty set made with New Set writes it, as -/// Photoshop saves an empty set to the `.atn`, and loading that file brings the empty set back. -#[test] -fn an_empty_new_set_saves_and_loads_back() { - let mut s = Session::new(); - s.execute("actions.newSet", json!({"name": "Later"})).unwrap(); - let saved = s.execute("actions.save", json!({"set": "Later"})).unwrap(); - assert_eq!((&saved["set"], &saved["actions"], &saved["steps"]), (&json!("Later"), &json!([]), &json!(0))); - let mut other = Session::new(); - let r = other.execute("actions.load", json!({"data": saved["data"]})).unwrap(); - assert_eq!((&r["set"], &r["actions"]), (&json!("Later"), &json!([]))); - assert_eq!(crate::actions_edit_cmds::set_names(&other), ["Later"], "an empty folder"); - other.execute("actions.load", json!({"data": saved["data"]})).unwrap(); - assert_eq!(crate::actions_edit_cmds::set_names(&other), ["Later"], "loaded twice, still one folder"); - // New Action records into it as into any set, and it saves with that action. - other.execute("actions.record", json!({"name": "Now", "set": "Later"})).unwrap(); - other.execute("actions.stop", json!({})).unwrap(); - assert_eq!(other.execute("actions.save", json!({"set": "Later"})).unwrap()["actions"], json!(["Now"])); - // A set that doesn't exist is still refused, and so is saving every action when there is none. - let err = s.execute("actions.save", json!({"set": "Nope"})).unwrap_err().to_string(); - assert!(err.contains("no actions in a set named `Nope`"), "{err}"); - let err = Session::new().execute("actions.save", json!({})).unwrap_err().to_string(); - assert!(err.contains("there are no actions to save"), "{err}"); -} - -/// A set from Chinese Photoshop: UTF-16 set and action names, a step name in GBK (可选颜色, -/// the bytes from #6743) on an event PhotoCraft has no command for, with no descriptor. -fn gbk_set() -> Vec { - let step = AtnItem { dictionary: b"\xbf\xc9\xd1\xa1\xd1\xd5\xc9\xab".to_vec(), ..item("syntheticFill", "", None) }; - let file = AtnFile { - name: UnicodeString::new_nul("相馆"), - actions: vec![AtnAction { name: UnicodeString::new_nul("调白"), items: vec![step], ..Default::default() }], - ..Default::default() - }; - atn::write(&file).unwrap() -} - -#[test] -fn load_decodes_legacy_step_names() { - let mut s = Session::new(); - let r = s.execute("actions.load", json!({"data": b64(&gbk_set())})).unwrap(); - assert_eq!(r["encoding"], "GBK", "{r}"); - assert_eq!(r["unsupported"][0]["name"], "可选颜色", "{r}"); - s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); - let played = s.execute("actions.play", json!({"action": "调白"})).unwrap(); - assert!(played["failed"]["error"].as_str().unwrap().contains("可选颜色"), "{played}"); -} - -#[test] -fn load_keeps_windows_1252_symbols() { - // Windows Photoshop step names with no Unicode copy: Mac Roman reads ° and » as ∞ and ª, - // which must not win ("50 ∞", "Grˆfle ª"). Save Actions writes the names back as that text. - let steps = vec![ - AtnItem { dictionary: b"50 \xb0".to_vec(), ..item("syntheticFill", "", None) }, - AtnItem { dictionary: b"Gr\xf6\xdfe \xbb".to_vec(), ..item("syntheticFill", "", None) }, - ]; - let file = AtnFile { - name: UnicodeString::new_nul("Größe ändern"), - actions: vec![AtnAction { name: UnicodeString::new_nul("Logo"), items: steps, ..Default::default() }], - ..Default::default() - }; - let mut s = Session::new(); - let r = s.execute("actions.load", json!({"data": b64(&atn::write(&file).unwrap())})).unwrap(); - assert_eq!(r["encoding"], "windows-1252", "{r}"); - assert_eq!(r["unsupported"][0]["name"], "50 °", "{r}"); - assert_eq!(r["unsupported"][1]["name"], "Größe »", "{r}"); - let saved = s.execute("actions.save", json!({"set": "Größe ändern"})).unwrap(); - let saved = atn::parse(&photocraft_paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap()).unwrap(); - let names: Vec = saved.actions[0].items.iter().map(|i| saved.item_label(i)).collect(); - assert_eq!(names, ["50 °", "Größe »"]); -} - -#[test] -fn load_encoding_override() { - let mut s = Session::new(); - let r = s.execute("actions.load", json!({"data": b64(&gbk_set()), "encoding": "shift_jis"})).unwrap(); - assert_eq!(r["encoding"], "Shift_JIS", "{r}"); - let r = s.execute("actions.load", json!({"data": b64(&gbk_set()), "encoding": "auto"})).unwrap(); - assert_eq!(r["encoding"], "GBK", "{r}"); -} - -#[test] -fn load_reports_utf8() { - let mut s = Session::new(); - let r = s.execute("actions.load", json!({"data": b64(&atn::write(&sample()).unwrap())})).unwrap(); - assert_eq!(r["encoding"], "UTF-8", "{r}"); -} - -#[test] -fn load_rejects_bad_encodings() { - let mut s = Session::new(); - for bad in [json!(3), json!(""), json!("nope"), json!("replacement"), json!(["gbk"])] { - assert!(s.execute("actions.load", json!({"data": b64(&gbk_set()), "encoding": bad})).is_err(), "{bad}"); - } - assert!(s.actions.list.is_empty(), "nothing is loaded when the encoding is bad"); -/// The step `d` of Photoshop event `code`, as PhotoCraft loads it. -fn distort(code: &str, d: Descriptor) -> Mapped { -/// The step `d` of the Photoshop event `code` (a four-character code), as PhotoCraft loads it. -fn other_step(code: &str, d: Descriptor) -> Mapped { - let event = AtnEvent::Code(code.as_bytes().try_into().unwrap()); - (events::event_for(&event).unwrap_or_else(|| panic!("{code} is unknown")).from)(&d) -} - -fn one_step(m: Mapped) -> (String, Value) { /// A Photoshop step of one of the newer features, as its event reference records it (#10610). fn newer(name: &str, d: Descriptor) -> Mapped { (events::event_for(&AtnEvent::name(name)).unwrap_or_else(|| panic!("{name} is unknown")).from)(&d) @@ -3854,446 +3594,6 @@ fn mapped(m: Mapped) -> (String, Value) { } } -/// Photoshop's Distort filter steps load with its defaults for missing keys and its clamping for -/// out-of-range values, play, and save back with every key in recorded order (#10605). -#[test] -fn distort_filter_steps_load_clamp_play_and_save_back() { - // Photoshop's defaults, as its steps record them. - let wave = desc("null") - .with("Wvtp", enm("Wvtp", "WvSn")) - .with("NmbG", D::Integer(5)) - .with("WLMn", D::Integer(10)) - .with("WLMx", D::Integer(120)) - .with("AmMn", D::Integer(5)) - .with("AmMx", D::Integer(35)) - .with("SclH", D::Integer(100)) - .with("SclV", D::Integer(100)) - .with("UndA", enm("UndA", "RptE")) - .with("RndS", D::Integer(-7)); - let zigzag = desc("null").with("Amnt", D::Integer(10)).with("NmbR", D::Integer(5)).with("ZZTy", enm("ZZTy", "PndR")); - let cases = [ - ("Pnch", desc("null").with("Amnt", D::Integer(50)), "filter.distort.pinch", json!({"amount": 50})), - ("Plr ", desc("null").with("Cnvr", enm("Cnvr", "RctP")), "filter.distort.polarCoordinates", json!({"mode": "rectangularToPolar"})), - ( - "Sphr", - desc("null").with("Amnt", D::Integer(100)).with("SphM", enm("SphM", "Nrml")), - "filter.distort.spherize", - json!({"amount": 100, "mode": "normal"}), - ), - ("Twrl", desc("null").with("Angl", D::Integer(50)), "filter.distort.twirl", json!({"angle": 50})), - ( - "Wave", - wave, - "filter.distort.wave", - json!({"type": "sine", "generators": 5, "wavelengthMin": 10, "wavelengthMax": 120, "amplitudeMin": 5, "amplitudeMax": 35, - "undefinedAreas": "repeat", "seed": 4_294_967_289_u32}), - ), - ("ZgZg", zigzag, "filter.distort.zigZag", json!({"amount": 10, "ridges": 5, "style": "pondRipples"})), - ]; - let mut s = Session::new(); - s.execute("file.new", json!({"width": 24, "height": 24})).unwrap(); - s.execute("filter.render.clouds", json!({})).unwrap(); - for (code, d, id, params) in cases { - let (got, p) = one_step(distort(code, d.clone())); - assert_eq!((got.as_str(), &p), (id, ¶ms), "{code}"); - // Every key, in the order Photoshop records them. - assert_eq!(export_item(id, &p, "S").unwrap().descriptor.unwrap().items, d.items, "{code}"); - s.actions.list.push(Action::new(code, vec![(id.to_string(), p)])); - let played = s.execute("actions.play", json!({"action": code})).unwrap(); - assert_eq!(played["ran"], 1, "{code}: {played}"); - } - // A step that leaves its keys out takes Photoshop's defaults: Wave's undefined areas are - // Repeat Edge Pixels, where the command's own default is wrap. - assert_eq!(one_step(distort("Wave", desc("null"))).1["undefinedAreas"], "repeat"); - assert_eq!(one_step(distort("Sphr", desc("null"))).1, json!({"amount": 100, "mode": "normal"})); - // Out-of-range values are clamped, as Photoshop clamps them. - assert_eq!(one_step(distort("Pnch", desc("null").with("Amnt", D::Integer(500)))).1, json!({"amount": 100})); - assert_eq!(one_step(distort("Twrl", desc("null").with("Angl", D::Integer(-5000)))).1, json!({"angle": -999})); - assert_eq!(one_step(distort("ZgZg", desc("null").with("NmbR", D::Integer(40)))).1["ridges"], 20); - assert_eq!(one_step(distort("Wave", desc("null").with("NmbG", D::Integer(0)))).1["generators"], 1); -} - -/// PhotoCraft's renderer draws at most `MAX_WAVE_GENERATORS` (64) generators, so a Wave step -/// recorded with more keeps the Photoshop step on import and is refused on export, as the PSD -/// smart-filter mapper does, rather than mapping a step that plays differently (#10605). -#[test] -fn wave_generators_past_what_photocraft_draws_keep_the_step() { - assert_eq!(photocraft_algo::MAX_WAVE_GENERATORS, 64); - let p = one_step(distort("Wave", desc("null").with("NmbG", D::Integer(64)))).1; - assert_eq!(p["generators"], 64, "{p}"); - for n in [65, 999, 1_000_000] { - let m = distort("Wave", desc("null").with("NmbG", D::Integer(n))); - assert!(matches!(m, Mapped::Unsupported { harmless: false }), "NmbG {n}"); - } - assert!(export_item("filter.distort.wave", &json!({"generators": 65}), "S").is_none()); - assert!(export_item("filter.distort.wave", &json!({"generators": 64}), "S").is_some()); -} - -/// What the commands cannot play keeps the Photoshop step: an unknown enumeration value, a Wave -/// scaled below 100 % (the command has no scale), or a key the event does not have (#10605). -#[test] -fn distort_filter_steps_the_commands_cannot_play_are_kept() { - let kept = |m: Mapped| matches!(m, Mapped::Unsupported { harmless: false }); - assert!(kept(distort("Sphr", desc("null").with("SphM", enm("SphM", "Dgnl"))))); - assert!(kept(distort("Plr ", desc("null").with("Cnvr", enm("Cnvr", "Sprl"))))); - assert!(kept(distort("Wave", desc("null").with("SclH", D::Integer(50))))); - assert!(kept(distort("Wave", desc("null").with("UndA", enm("UndA", "Trns"))))); - assert!(kept(distort("Pnch", desc("null").with("Rds ", D::Integer(3))))); - // A scale of 100 % (or above, which Photoshop clamps to 100) plays. - assert!(!kept(distort("Wave", desc("null").with("SclH", D::Integer(100)).with("SclV", D::Integer(250))))); -} - -/// A setting the events cannot hold refuses the step instead of replaying differently: a -/// fractional amount (the keys are integers), a mask target, a seed past 32 bits (#10605). -#[test] -fn distort_filter_steps_that_cannot_be_written_are_refused() { - for (id, params) in [ - ("filter.distort.pinch", json!({"amount": 12.5})), - ("filter.distort.twirl", json!({"angle": 10, "target": "mask"})), - ("filter.distort.zigZag", json!({"ridges": 2.5})), - ("filter.distort.wave", json!({"seed": 4_294_967_296_u64})), - ("filter.distort.wave", json!({"undefinedAreas": "transparent"})), - ("filter.distort.spherize", json!({"mode": "diagonal"})), - ] { - assert!(export_item(id, ¶ms, "S").is_none(), "{id} {params} exported"); - } - // Left out, a setting is written as the command replays it: Wave wraps. - let wave = export_item("filter.distort.wave", &json!({}), "S").unwrap().descriptor.unwrap(); - assert_eq!(get_enum(&wave, "UndA").as_deref(), Some("WrpA")); -} - -/// A recorded setting of the wrong type is the step's own setting, not a missing one: the -/// mapper keeps the Photoshop step instead of playing it with Photoshop's default (#10605). -#[test] -fn distort_filter_steps_with_a_wrong_typed_setting_are_kept() { - let kept = |m: Mapped| matches!(m, Mapped::Unsupported { harmless: false }); - for (code, key, v) in [ - ("Pnch", "Amnt", D::Boolean(true)), - ("Twrl", "Angl", text("90")), - ("Plr ", "Cnvr", D::Integer(1)), - ("Sphr", "SphM", text("HrzO")), - ("ZgZg", "NmbR", enm("Ordn", "Trgt")), - ("Wave", "NmbG", D::Boolean(false)), - ("Wave", "Wvtp", D::Integer(2)), - ("Wave", "SclH", text("50")), - ("Wave", "SclV", D::Boolean(true)), - ("Wave", "UndA", D::Integer(1)), - ("Wave", "RndS", text("7")), - ] { - assert!(kept(distort(code, desc("null").with(key, v.clone()))), "{code} with {key} = {v:?} was mapped"); - } -} - -/// An integer outside the range Photoshop clamps the key to refuses the step: written as is, it -/// would load back clamped, as another value (#10605). -#[test] -fn distort_filter_steps_outside_photoshops_range_are_refused() { - for (id, params) in [ - ("filter.distort.pinch", json!({"amount": 500})), - ("filter.distort.spherize", json!({"amount": -101})), - ("filter.distort.twirl", json!({"angle": 1000})), - ("filter.distort.zigZag", json!({"ridges": 21})), - ("filter.distort.zigZag", json!({"ridges": -1})), - ("filter.distort.wave", json!({"generators": 0})), - ("filter.distort.wave", json!({"amplitudeMax": 1000})), - ] { - assert!(export_item(id, ¶ms, "S").is_none(), "{id} {params} exported"); - } - // The limits themselves are written. - for (id, params) in [ - ("filter.distort.pinch", json!({"amount": -100})), - ("filter.distort.twirl", json!({"angle": 999})), - ("filter.distort.zigZag", json!({"ridges": 0})), - ("filter.distort.wave", json!({"generators": 64, "wavelengthMin": 1})), - ] { - assert!(export_item(id, ¶ms, "S").is_some(), "{id} {params} refused"); - } -} - -/// Wave's `RndS` is the seed's 32 bits, signed or not: it loads exactly or keeps the step, -/// never saturated or truncated to another seed (#10605). -#[test] -fn wave_seed_loads_its_exact_32_bits_or_keeps_the_step() { - let seed = |v: D| match distort("Wave", desc("null").with("RndS", v)) { - Mapped::Steps(s) => Some(s[0].1["seed"].clone()), - Mapped::Unsupported { .. } => None, - }; - assert_eq!(seed(D::LargeInteger(4_000_000_000)), Some(json!(4_000_000_000_u32))); - assert_eq!(seed(D::LargeInteger(-1)), Some(json!(u32::MAX))); - assert_eq!(seed(D::Integer(-7)), Some(json!(4_294_967_289_u32))); - assert_eq!(seed(D::Double(12.0)), Some(json!(12))); - for v in [D::Double(7.5), D::LargeInteger(4_294_967_296), D::LargeInteger(i64::from(i32::MIN) - 1)] { - assert_eq!(seed(v.clone()), None, "RndS {v:?} was mapped"); - } -} - -/// A step recording one of its keys twice can't be saved back with both values, so it stays a -/// Photoshop step instead of playing the first and dropping the second (#10605). -#[test] -fn distort_filter_steps_with_a_repeated_key_are_kept() { - let kept = |m: Mapped| matches!(m, Mapped::Unsupported { harmless: false }); - for (code, key, a, b) in [ - ("Pnch", "Amnt", D::Integer(10), D::Integer(20)), - ("Sphr", "SphM", enm("SphM", "Nrml"), enm("SphM", "HrzO")), - ("Plr ", "Cnvr", enm("Cnvr", "RctP"), enm("Cnvr", "RctP")), - ("Wave", "NmbG", D::Integer(5), D::Integer(7)), - ("Wave", "SclH", D::Integer(100), D::Integer(50)), - ("Wave", "UndA", enm("UndA", "WrpA"), enm("UndA", "RptE")), - ("Wave", "RndS", D::Integer(1), D::Integer(2)), - ("ZgZg", "NmbR", D::Integer(5), D::Integer(6)), - ] { - assert!(kept(distort(code, desc("null").with(key, a.clone()).with(key, b.clone()))), "{code} with {key} twice was mapped"); - } -} - -/// An enumeration of another type isn't the setting Photoshop records, so the step stays a -/// Photoshop step instead of loading its value and saving back the expected type (#10605). -#[test] -fn distort_filter_steps_with_an_enumeration_of_another_type_are_kept() { - let kept = |m: Mapped| matches!(m, Mapped::Unsupported { harmless: false }); - for (code, key, v) in [ - ("Plr ", "Cnvr", enm("Ordn", "RctP")), - ("Sphr", "SphM", enm("Cnvr", "Nrml")), - ("ZgZg", "ZZTy", enm("Ordn", "PndR")), - ("Wave", "Wvtp", enm("WvSn", "WvSn")), - ("Wave", "UndA", enm("Ordn", "WrpA")), - ] { - assert!(kept(distort(code, desc("null").with(key, v.clone()))), "{code} with {key} = {v:?} was mapped"); - } - assert!(!kept(distort("Plr ", desc("null").with("Cnvr", enm("Cnvr", "PlrR"))))); - assert!(!kept(distort("Wave", desc("null").with("UndA", enm("UndA", "WrpA"))))); -} - -/// Photoshop clamps Wave's wavelengths and amplitudes to 1..999, but `filter.distort.wave` -/// takes a minimum wavelength of 1..998, a maximum of 2..999 and a minimum amplitude of 1..998. -/// A value the command rejects keeps the Photoshop step on import and refuses it on export, -/// rather than mapping a step that fails when it plays; a minimum above its maximum, which the -/// command takes, maps and plays (#10605). -#[test] -fn wave_values_the_command_rejects_are_kept_or_refused() { - let kept = |m: Mapped| matches!(m, Mapped::Unsupported { harmless: false }); - for (key, n) in [("WLMn", 999), ("WLMn", 5000), ("WLMx", 1), ("WLMx", 0), ("AmMn", 999), ("AmMn", 1200)] { - assert!(kept(distort("Wave", desc("null").with(key, D::Integer(n)))), "{key} {n} was mapped"); - } - for (param, n) in [("wavelengthMin", 999), ("wavelengthMax", 1), ("amplitudeMin", 999)] { - assert!(export_item("filter.distort.wave", &json!({ param: n }), "S").is_none(), "{param} {n} exported"); - } - let mut s = Session::new(); - s.execute("file.new", json!({"width": 24, "height": 24})).unwrap(); - s.execute("filter.render.clouds", json!({})).unwrap(); - let plays = [ - desc("null").with("WLMn", D::Integer(998)), - desc("null").with("WLMx", D::Integer(2)), - desc("null").with("AmMn", D::Integer(998)), - desc("null").with("WLMn", D::Integer(200)).with("WLMx", D::Integer(100)), - desc("null").with("AmMn", D::Integer(40)).with("AmMx", D::Integer(20)), - ]; - for (k, d) in plays.into_iter().enumerate() { - let (id, p) = one_step(distort("Wave", d)); - assert!(export_item(&id, &p, "S").is_some(), "{p} refused"); - let name = format!("W{k}"); - s.actions.list.push(Action::new(&name, vec![(id, p.clone())])); - let played = s.execute("actions.play", json!({"action": name})).unwrap(); - assert_eq!(played["ran"], 1, "{p}: {played}"); - } -} - -/// Wave saves its scales as the integer 100, so a fractional scale, even one that rounds or -/// clamps to 100, stays a Photoshop step instead of saving back as 100 (#10605). -#[test] -fn wave_with_a_fractional_scale_is_kept() { - let kept = |m: Mapped| matches!(m, Mapped::Unsupported { harmless: false }); - for (key, n) in [("SclH", 99.6), ("SclV", 99.5), ("SclH", 100.4)] { - assert!(kept(distort("Wave", desc("null").with(key, D::Double(n)))), "{key} {n} was mapped"); - } - assert!(!kept(distort("Wave", desc("null").with("SclH", D::Integer(100)).with("SclV", D::Integer(100))))); -/// Photoshop's De-Interlace, NTSC Colors, HSB/HSL, Custom, Average and Remove Black / White -/// Matte steps load as their commands, play, and save back with the same keys (#10607). -#[test] -fn video_other_and_matting_steps_load_play_and_save_back() { - let cells: Vec = (0..25) - .map(|i| { - D::Integer(if i == 12 { - 5 - } else if [7, 11, 13, 17].contains(&i) { - -1 - } else { - 0 - }) - }) - .collect(); - let kernel: Vec = (0..25) - .map(|i| { - if i == 12 { - 5 - } else if [7, 11, 13, 17].contains(&i) { - -1 - } else { - 0 - } - }) - .collect(); - let cases = [ - ( - "Dntr", - desc("null").with("IntE", enm("IntE", "ElmE")).with("IntC", enm("IntC", "CrtD")), - "filter.video.deInterlace", - json!({"eliminate": "evenFields", "createBy": "duplication"}), - ), - ("NTSC", desc("null"), "filter.video.ntscColors", json!({})), - ( - "HsbP", - desc("null").with("Inpt", enm("ClrS", "RGBC")).with("Otpt", enm("ClrS", "HSLC")), - "filter.other.hsbHsl", - json!({"inputMode": "rgb", "rowOrder": "hsl"}), - ), - ( - "Cstm", - desc("null").with("Scl ", D::Integer(1)).with("Ofst", D::Integer(0)).with("Mtrx", D::List(cells)), - "filter.other.custom", - json!({"kernel": kernel, "scale": 1, "offset": 0}), - ), - ("Avrg", desc("null"), "filter.blur.average", json!({})), - ("RmvB", desc("null"), "layer.matting.removeBlackMatte", json!({})), - ("RmvW", desc("null"), "layer.matting.removeWhiteMatte", json!({})), - ]; - let mut s = Session::new(); - s.execute("file.new", json!({"width": 24, "height": 24, "background": "transparent"})).unwrap(); - s.execute("filter.render.clouds", json!({})).unwrap(); - for (code, d, id, params) in cases { - let Mapped::Steps(steps) = other_step(code, d.clone()) else { panic!("{code} kept") }; - let mut p = steps[0].1.clone(); - // Numbers come back as the descriptor's integers. - if let Some(k) = p.get_mut("kernel") { - *k = json!(k.as_array().unwrap().iter().map(|v| v.as_f64().unwrap() as i32).collect::>()); - } - for k in ["scale", "offset"] { - if let Some(v) = p.get_mut(k) { - *v = json!(v.as_f64().unwrap() as i32); - } - } - assert_eq!((steps[0].0.as_str(), &p), (id, ¶ms), "{code}"); - assert_eq!(export_item(id, &steps[0].1, "S").unwrap().descriptor.unwrap().items, d.items, "{code}"); - s.actions.list.push(Action::new(code, vec![(id.to_string(), steps[0].1.clone())])); - let played = s.execute("actions.play", json!({"action": code})).unwrap(); - assert_eq!(played["ran"], 1, "{code}: {played}"); - } - // Missing keys are Photoshop's defaults. - let Mapped::Steps(steps) = other_step("Dntr", desc("null")) else { panic!("kept") }; - assert_eq!(steps[0].1, json!({"eliminate": "oddFields", "createBy": "interpolation"})); - let Mapped::Steps(steps) = other_step("HsbP", desc("null")) else { panic!("kept") }; - assert_eq!(steps[0].1, json!({"inputMode": "rgb", "rowOrder": "hsb"})); -} - -/// A step the commands cannot play keeps the Photoshop step: a Custom matrix that is not 5 × 5, -/// an unknown enumeration value, or a key the event does not have (#10607). -#[test] -fn video_other_and_matting_steps_the_commands_cannot_play_are_kept() { - let kept = |m: Mapped| matches!(m, Mapped::Unsupported { harmless: false }); - let nine = D::List((0..9).map(D::Integer).collect()); - assert!(kept(other_step("Cstm", desc("null").with("Mtrx", nine)))); - assert!(kept(other_step("Cstm", desc("null")))); - assert!(kept(other_step("HsbP", desc("null").with("Inpt", enm("ClrS", "LbCl"))))); - assert!(kept(other_step("Dntr", desc("null").with("IntE", enm("IntE", "ElmA"))))); - assert!(kept(other_step("NTSC", desc("null").with("Lvl ", D::Integer(1))))); - assert!(kept(other_step("RmvW", desc("null").with("Lyr ", D::Integer(1))))); -} - -/// What the events cannot hold refuses the step: a fractional kernel cell, a kernel that is not -/// 25 cells, a mask target, an unknown mode (#10607). -#[test] -fn video_other_and_matting_steps_that_cannot_be_written_are_refused() { - let mut cells = vec![json!(0); 25]; - cells[12] = json!(1.5); - for (id, params) in [ - ("filter.other.custom", json!({"kernel": cells})), - ("filter.other.custom", json!({"kernel": [0, 0, 0, 0, 1, 0, 0, 0, 0]})), - ("filter.other.custom", json!({"scale": 2.5})), - ("filter.blur.average", json!({"target": "mask"})), - ("filter.other.hsbHsl", json!({"inputMode": "lab"})), - ("filter.video.deInterlace", json!({"eliminate": "allFields"})), - ("filter.other.custom", json!([1])), - ("filter.blur.average", json!(5)), - ("filter.other.hsbHsl", json!("hsl")), - ] { - assert!(export_item(id, ¶ms, "S").is_none(), "{id} {params} exported"); - } - // `null` params set nothing. - assert!(export_item("filter.blur.average", &Value::Null, "S").is_some()); - // A Custom step without a kernel is the command's identity kernel. - let d = export_item("filter.other.custom", &json!({}), "S").unwrap().descriptor.unwrap(); - let cells = get_list(&d, "Mtrx").unwrap(); - assert_eq!((cells.len(), cells.get(12)), (25, Some(&D::Integer(1)))); -} - -/// A recorded setting of the wrong type is the step's own setting, not a missing one: the -/// mapper keeps the Photoshop step instead of playing it with Photoshop's default (#10607). -#[test] -fn video_other_steps_with_a_wrong_typed_setting_are_kept() { - let kept = |m: Mapped| matches!(m, Mapped::Unsupported { harmless: false }); - let identity = D::List((0..25).map(|i| D::Integer(i32::from(i == 12))).collect()); - for (code, d) in [ - ("Dntr", desc("null").with("IntE", D::Integer(1))), - ("Dntr", desc("null").with("IntC", text("CrtD"))), - ("HsbP", desc("null").with("Inpt", D::Boolean(true))), - ("HsbP", desc("null").with("Otpt", text("HSLC"))), - ("Cstm", desc("null").with("Mtrx", identity.clone()).with("Scl ", text("2"))), - ("Cstm", desc("null").with("Mtrx", identity.clone()).with("Ofst", D::Boolean(true))), - ("Cstm", desc("null").with("Mtrx", identity.clone()).with("Scl", enm("Ordn", "Trgt"))), - ] { - assert!(kept(other_step(code, d.clone())), "{code} {d:?} was mapped"); - } -} - -/// The key gate admits `Scl` without its padding, so the mapper reads it too: an unpadded -/// scale is the step's scale, not the default 1. -#[test] -fn custom_reads_an_unpadded_scale() { - let identity = D::List((0..25).map(|i| D::Integer(i32::from(i == 12))).collect()); - let Mapped::Steps(steps) = other_step("Cstm", desc("null").with("Scl", D::Integer(3)).with("Mtrx", identity)) else { panic!("kept") }; - assert_eq!(steps[0].1["scale"], json!(3.0)); -} - -/// `filter.other.custom` plays a scale of 1..9999, an offset of -9999..9999 and cells of -/// -999..999, and clamps anything else: `Scl 0` would play as 1 and save back as 0. Such a step -/// stays a Photoshop step, as does a matrix of more than 25 cells, and export refuses those -/// values instead of writing what the command doesn't play (#10607). -#[test] -fn custom_outside_the_commands_range_is_kept_and_refused() { - let kept = |m: Mapped| matches!(m, Mapped::Unsupported { harmless: false }); - let kernel = |cell: i32| D::List((0..25).map(|i| D::Integer(if i == 12 { cell } else { 0 })).collect()); - let identity = kernel(1); - for d in [ - desc("null").with("Mtrx", identity.clone()).with("Scl ", D::Integer(0)), - desc("null").with("Mtrx", identity.clone()).with("Scl ", D::Integer(10_000)), - desc("null").with("Mtrx", identity.clone()).with("Ofst", D::Integer(-10_000)), - desc("null").with("Mtrx", identity.clone()).with("Ofst", D::Integer(10_000)), - desc("null").with("Mtrx", kernel(1000)), - desc("null").with("Mtrx", kernel(-1000)), - desc("null").with("Mtrx", D::List((0..26).map(|_| D::Integer(0)).collect())), - desc("null").with("Mtrx", D::List((0..1000).map(|_| D::Integer(0)).collect())), - // Fractions: export writes integers, so Save Actions would refuse the mapped step. - desc("null").with("Mtrx", identity.clone()).with("Scl ", D::Double(1.5)), - desc("null").with("Mtrx", identity.clone()).with("Ofst", D::Double(2.5)), - desc("null").with("Mtrx", D::List((0..25).map(|i| if i == 12 { D::Double(0.5) } else { D::Integer(0) }).collect())), - ] { - assert!(kept(other_step("Cstm", d.clone())), "{:?} was mapped", d.items); - } - for (scale, offset, cell) in [(1, -9999, -999), (9999, 9999, 999)] { - let d = desc("null").with("Scl ", D::Integer(scale)).with("Ofst", D::Integer(offset)).with("Mtrx", kernel(cell)); - let Mapped::Steps(steps) = other_step("Cstm", d) else { panic!("{scale} {offset} {cell} kept") }; - assert_eq!( - (&steps[0].1["scale"], &steps[0].1["offset"], &steps[0].1["kernel"][12]), - (&json!(f64::from(scale)), &json!(f64::from(offset)), &json!(f64::from(cell))) - ); - } - // An integral Double is a whole number and still maps. - let Mapped::Steps(steps) = other_step("Cstm", desc("null").with("Mtrx", identity.clone()).with("Scl ", D::Double(2.0))) else { panic!("Scl 2.0 kept") }; - assert_eq!(steps[0].1["scale"], json!(2.0)); - let mut cells = vec![json!(0); 25]; - cells[12] = json!(1000); - for params in [json!({"scale": 0}), json!({"scale": 10_000}), json!({"offset": -10_000}), json!({"offset": 10_000}), json!({"kernel": cells})] { - assert!(export_item("filter.other.custom", ¶ms, "S").is_none(), "{params} exported"); /// Oil Paint, Smart Sharpen, Focus Area, Select Sky and Remove Background load with the keys and /// defaults Photoshop records; Oil Paint's and Select Sky's defaults differ from the commands' /// and are written out (#10610). @@ -4313,6 +3613,7 @@ fn newer_feature_steps_load_with_photoshops_defaults() { assert_eq!(mapped(newer("oilPaint", desc("null"))).1, expected_oil, "a missing key takes Photoshop's default"); // Smart Sharpen in the form the command plays (the newer filter, removing a Gaussian blur). let sharpen = desc("null") + .with("presetKind", enm("presetKindType", "presetKindCustom")) .with("useLegacy", D::Boolean(false)) .with("Amnt", pct(150.0)) .with("Rds ", px(2.5)) @@ -4715,3 +4016,720 @@ fn oil_paint_outside_the_commands_ranges_is_clamped_on_import_and_refused_on_exp assert!(export_item("filter.stylize.oilPaint", ¶ms, "S").is_none(), "{params} exported"); } } + +/// The step `d` of the Photoshop event `code` (a four-character code), as PhotoCraft loads it. +fn other_step(code: &str, d: Descriptor) -> Mapped { + let event = AtnEvent::Code(code.as_bytes().try_into().unwrap()); + (events::event_for(&event).unwrap_or_else(|| panic!("{code} is unknown")).from)(&d) +} + +/// Photoshop's De-Interlace, NTSC Colors, HSB/HSL, Custom, Average and Remove Black / White +/// Matte steps load as their commands, play, and save back with the same keys (#10607). +#[test] +fn video_other_and_matting_steps_load_play_and_save_back() { + let cells: Vec = (0..25) + .map(|i| { + D::Integer(if i == 12 { + 5 + } else if [7, 11, 13, 17].contains(&i) { + -1 + } else { + 0 + }) + }) + .collect(); + let kernel: Vec = (0..25) + .map(|i| { + if i == 12 { + 5 + } else if [7, 11, 13, 17].contains(&i) { + -1 + } else { + 0 + } + }) + .collect(); + let cases = [ + ( + "Dntr", + desc("null").with("IntE", enm("IntE", "ElmE")).with("IntC", enm("IntC", "CrtD")), + "filter.video.deInterlace", + json!({"eliminate": "evenFields", "createBy": "duplication"}), + ), + ("NTSC", desc("null"), "filter.video.ntscColors", json!({})), + ( + "HsbP", + desc("null").with("Inpt", enm("ClrS", "RGBC")).with("Otpt", enm("ClrS", "HSLC")), + "filter.other.hsbHsl", + json!({"inputMode": "rgb", "rowOrder": "hsl"}), + ), + ( + "Cstm", + desc("null").with("Scl ", D::Integer(1)).with("Ofst", D::Integer(0)).with("Mtrx", D::List(cells)), + "filter.other.custom", + json!({"kernel": kernel, "scale": 1, "offset": 0}), + ), + ("Avrg", desc("null"), "filter.blur.average", json!({})), + ("RmvB", desc("null"), "layer.matting.removeBlackMatte", json!({})), + ("RmvW", desc("null"), "layer.matting.removeWhiteMatte", json!({})), + ]; + let mut s = Session::new(); + s.execute("file.new", json!({"width": 24, "height": 24, "background": "transparent"})).unwrap(); + s.execute("filter.render.clouds", json!({})).unwrap(); + for (code, d, id, params) in cases { + let Mapped::Steps(steps) = other_step(code, d.clone()) else { panic!("{code} kept") }; + let mut p = steps[0].1.clone(); + // Numbers come back as the descriptor's integers. + if let Some(k) = p.get_mut("kernel") { + *k = json!(k.as_array().unwrap().iter().map(|v| v.as_f64().unwrap() as i32).collect::>()); + } + for k in ["scale", "offset"] { + if let Some(v) = p.get_mut(k) { + *v = json!(v.as_f64().unwrap() as i32); + } + } + assert_eq!((steps[0].0.as_str(), &p), (id, ¶ms), "{code}"); + assert_eq!(export_item(id, &steps[0].1, "S").unwrap().descriptor.unwrap().items, d.items, "{code}"); + s.actions.list.push(Action::new(code, vec![(id.to_string(), steps[0].1.clone())])); + let played = s.execute("actions.play", json!({"action": code})).unwrap(); + assert_eq!(played["ran"], 1, "{code}: {played}"); + } + // Missing keys are Photoshop's defaults. + let Mapped::Steps(steps) = other_step("Dntr", desc("null")) else { panic!("kept") }; + assert_eq!(steps[0].1, json!({"eliminate": "oddFields", "createBy": "interpolation"})); + let Mapped::Steps(steps) = other_step("HsbP", desc("null")) else { panic!("kept") }; + assert_eq!(steps[0].1, json!({"inputMode": "rgb", "rowOrder": "hsb"})); +} + +/// A step the commands cannot play keeps the Photoshop step: a Custom matrix that is not 5 × 5, +/// an unknown enumeration value, or a key the event does not have (#10607). +#[test] +fn video_other_and_matting_steps_the_commands_cannot_play_are_kept() { + let kept = |m: Mapped| matches!(m, Mapped::Unsupported { harmless: false }); + let nine = D::List((0..9).map(D::Integer).collect()); + assert!(kept(other_step("Cstm", desc("null").with("Mtrx", nine)))); + assert!(kept(other_step("Cstm", desc("null")))); + assert!(kept(other_step("HsbP", desc("null").with("Inpt", enm("ClrS", "LbCl"))))); + assert!(kept(other_step("Dntr", desc("null").with("IntE", enm("IntE", "ElmA"))))); + assert!(kept(other_step("NTSC", desc("null").with("Lvl ", D::Integer(1))))); + assert!(kept(other_step("RmvW", desc("null").with("Lyr ", D::Integer(1))))); +} + +/// What the events cannot hold refuses the step: a fractional kernel cell, a kernel that is not +/// 25 cells, a mask target, an unknown mode (#10607). +#[test] +fn video_other_and_matting_steps_that_cannot_be_written_are_refused() { + let mut cells = vec![json!(0); 25]; + cells[12] = json!(1.5); + for (id, params) in [ + ("filter.other.custom", json!({"kernel": cells})), + ("filter.other.custom", json!({"kernel": [0, 0, 0, 0, 1, 0, 0, 0, 0]})), + ("filter.other.custom", json!({"scale": 2.5})), + ("filter.blur.average", json!({"target": "mask"})), + ("filter.other.hsbHsl", json!({"inputMode": "lab"})), + ("filter.video.deInterlace", json!({"eliminate": "allFields"})), + ("filter.other.custom", json!([1])), + ("filter.blur.average", json!(5)), + ("filter.other.hsbHsl", json!("hsl")), + ] { + assert!(export_item(id, ¶ms, "S").is_none(), "{id} {params} exported"); + } + // `null` params set nothing. + assert!(export_item("filter.blur.average", &Value::Null, "S").is_some()); + // A Custom step without a kernel is the command's identity kernel. + let d = export_item("filter.other.custom", &json!({}), "S").unwrap().descriptor.unwrap(); + let cells = get_list(&d, "Mtrx").unwrap(); + assert_eq!((cells.len(), cells.get(12)), (25, Some(&D::Integer(1)))); +} + +/// A recorded setting of the wrong type is the step's own setting, not a missing one: the +/// mapper keeps the Photoshop step instead of playing it with Photoshop's default (#10607). +#[test] +fn video_other_steps_with_a_wrong_typed_setting_are_kept() { + let kept = |m: Mapped| matches!(m, Mapped::Unsupported { harmless: false }); + let identity = D::List((0..25).map(|i| D::Integer(i32::from(i == 12))).collect()); + for (code, d) in [ + ("Dntr", desc("null").with("IntE", D::Integer(1))), + ("Dntr", desc("null").with("IntC", text("CrtD"))), + ("HsbP", desc("null").with("Inpt", D::Boolean(true))), + ("HsbP", desc("null").with("Otpt", text("HSLC"))), + ("Cstm", desc("null").with("Mtrx", identity.clone()).with("Scl ", text("2"))), + ("Cstm", desc("null").with("Mtrx", identity.clone()).with("Ofst", D::Boolean(true))), + ("Cstm", desc("null").with("Mtrx", identity.clone()).with("Scl", enm("Ordn", "Trgt"))), + ] { + assert!(kept(other_step(code, d.clone())), "{code} {d:?} was mapped"); + } +} + +/// The key gate admits `Scl` without its padding, so the mapper reads it too: an unpadded +/// scale is the step's scale, not the default 1. +#[test] +fn custom_reads_an_unpadded_scale() { + let identity = D::List((0..25).map(|i| D::Integer(i32::from(i == 12))).collect()); + let Mapped::Steps(steps) = other_step("Cstm", desc("null").with("Scl", D::Integer(3)).with("Mtrx", identity)) else { panic!("kept") }; + assert_eq!(steps[0].1["scale"], json!(3.0)); +} + +/// `filter.other.custom` plays a scale of 1..9999, an offset of -9999..9999 and cells of +/// -999..999, and clamps anything else: `Scl 0` would play as 1 and save back as 0. Such a step +/// stays a Photoshop step, as does a matrix of more than 25 cells, and export refuses those +/// values instead of writing what the command doesn't play (#10607). +#[test] +fn custom_outside_the_commands_range_is_kept_and_refused() { + let kept = |m: Mapped| matches!(m, Mapped::Unsupported { harmless: false }); + let kernel = |cell: i32| D::List((0..25).map(|i| D::Integer(if i == 12 { cell } else { 0 })).collect()); + let identity = kernel(1); + for d in [ + desc("null").with("Mtrx", identity.clone()).with("Scl ", D::Integer(0)), + desc("null").with("Mtrx", identity.clone()).with("Scl ", D::Integer(10_000)), + desc("null").with("Mtrx", identity.clone()).with("Ofst", D::Integer(-10_000)), + desc("null").with("Mtrx", identity.clone()).with("Ofst", D::Integer(10_000)), + desc("null").with("Mtrx", kernel(1000)), + desc("null").with("Mtrx", kernel(-1000)), + desc("null").with("Mtrx", D::List((0..26).map(|_| D::Integer(0)).collect())), + desc("null").with("Mtrx", D::List((0..1000).map(|_| D::Integer(0)).collect())), + // Fractions: export writes integers, so Save Actions would refuse the mapped step. + desc("null").with("Mtrx", identity.clone()).with("Scl ", D::Double(1.5)), + desc("null").with("Mtrx", identity.clone()).with("Ofst", D::Double(2.5)), + desc("null").with("Mtrx", D::List((0..25).map(|i| if i == 12 { D::Double(0.5) } else { D::Integer(0) }).collect())), + ] { + assert!(kept(other_step("Cstm", d.clone())), "{:?} was mapped", d.items); + } + for (scale, offset, cell) in [(1, -9999, -999), (9999, 9999, 999)] { + let d = desc("null").with("Scl ", D::Integer(scale)).with("Ofst", D::Integer(offset)).with("Mtrx", kernel(cell)); + let Mapped::Steps(steps) = other_step("Cstm", d) else { panic!("{scale} {offset} {cell} kept") }; + assert_eq!( + (&steps[0].1["scale"], &steps[0].1["offset"], &steps[0].1["kernel"][12]), + (&json!(f64::from(scale)), &json!(f64::from(offset)), &json!(f64::from(cell))) + ); + } + // An integral Double is a whole number and still maps. + let Mapped::Steps(steps) = other_step("Cstm", desc("null").with("Mtrx", identity.clone()).with("Scl ", D::Double(2.0))) else { panic!("Scl 2.0 kept") }; + assert_eq!(steps[0].1["scale"], json!(2.0)); + let mut cells = vec![json!(0); 25]; + cells[12] = json!(1000); + for params in [json!({"scale": 0}), json!({"scale": 10_000}), json!({"offset": -10_000}), json!({"offset": 10_000}), json!({"kernel": cells})] { + assert!(export_item("filter.other.custom", ¶ms, "S").is_none(), "{params} exported"); + } +} + +/// The step `d` of Photoshop event `code`, as PhotoCraft loads it. +fn distort(code: &str, d: Descriptor) -> Mapped { + let event = AtnEvent::Code(code.as_bytes().try_into().unwrap()); + (events::event_for(&event).unwrap_or_else(|| panic!("{code} is unknown")).from)(&d) +} + +fn one_step(m: Mapped) -> (String, Value) { + match m { + Mapped::Steps(mut s) if s.len() == 1 => s.remove(0), + _ => panic!("not one step"), + } +} + +/// Photoshop's Distort filter steps load with its defaults for missing keys and its clamping for +/// out-of-range values, play, and save back with every key in recorded order (#10605). +#[test] +fn distort_filter_steps_load_clamp_play_and_save_back() { + // Photoshop's defaults, as its steps record them. + let wave = desc("null") + .with("Wvtp", enm("Wvtp", "WvSn")) + .with("NmbG", D::Integer(5)) + .with("WLMn", D::Integer(10)) + .with("WLMx", D::Integer(120)) + .with("AmMn", D::Integer(5)) + .with("AmMx", D::Integer(35)) + .with("SclH", D::Integer(100)) + .with("SclV", D::Integer(100)) + .with("UndA", enm("UndA", "RptE")) + .with("RndS", D::Integer(-7)); + let zigzag = desc("null").with("Amnt", D::Integer(10)).with("NmbR", D::Integer(5)).with("ZZTy", enm("ZZTy", "PndR")); + let cases = [ + ("Pnch", desc("null").with("Amnt", D::Integer(50)), "filter.distort.pinch", json!({"amount": 50})), + ("Plr ", desc("null").with("Cnvr", enm("Cnvr", "RctP")), "filter.distort.polarCoordinates", json!({"mode": "rectangularToPolar"})), + ( + "Sphr", + desc("null").with("Amnt", D::Integer(100)).with("SphM", enm("SphM", "Nrml")), + "filter.distort.spherize", + json!({"amount": 100, "mode": "normal"}), + ), + ("Twrl", desc("null").with("Angl", D::Integer(50)), "filter.distort.twirl", json!({"angle": 50})), + ( + "Wave", + wave, + "filter.distort.wave", + json!({"type": "sine", "generators": 5, "wavelengthMin": 10, "wavelengthMax": 120, "amplitudeMin": 5, "amplitudeMax": 35, + "undefinedAreas": "repeat", "seed": 4_294_967_289_u32}), + ), + ("ZgZg", zigzag, "filter.distort.zigZag", json!({"amount": 10, "ridges": 5, "style": "pondRipples"})), + ]; + let mut s = Session::new(); + s.execute("file.new", json!({"width": 24, "height": 24})).unwrap(); + s.execute("filter.render.clouds", json!({})).unwrap(); + for (code, d, id, params) in cases { + let (got, p) = one_step(distort(code, d.clone())); + assert_eq!((got.as_str(), &p), (id, ¶ms), "{code}"); + // Every key, in the order Photoshop records them. + assert_eq!(export_item(id, &p, "S").unwrap().descriptor.unwrap().items, d.items, "{code}"); + s.actions.list.push(Action::new(code, vec![(id.to_string(), p)])); + let played = s.execute("actions.play", json!({"action": code})).unwrap(); + assert_eq!(played["ran"], 1, "{code}: {played}"); + } + // A step that leaves its keys out takes Photoshop's defaults: Wave's undefined areas are + // Repeat Edge Pixels, where the command's own default is wrap. + assert_eq!(one_step(distort("Wave", desc("null"))).1["undefinedAreas"], "repeat"); + assert_eq!(one_step(distort("Sphr", desc("null"))).1, json!({"amount": 100, "mode": "normal"})); + // Out-of-range values are clamped, as Photoshop clamps them. + assert_eq!(one_step(distort("Pnch", desc("null").with("Amnt", D::Integer(500)))).1, json!({"amount": 100})); + assert_eq!(one_step(distort("Twrl", desc("null").with("Angl", D::Integer(-5000)))).1, json!({"angle": -999})); + assert_eq!(one_step(distort("ZgZg", desc("null").with("NmbR", D::Integer(40)))).1["ridges"], 20); + assert_eq!(one_step(distort("Wave", desc("null").with("NmbG", D::Integer(0)))).1["generators"], 1); +} + +/// PhotoCraft's renderer draws at most `MAX_WAVE_GENERATORS` (64) generators, so a Wave step +/// recorded with more keeps the Photoshop step on import and is refused on export, as the PSD +/// smart-filter mapper does, rather than mapping a step that plays differently (#10605). +#[test] +fn wave_generators_past_what_photocraft_draws_keep_the_step() { + assert_eq!(photocraft_algo::MAX_WAVE_GENERATORS, 64); + let p = one_step(distort("Wave", desc("null").with("NmbG", D::Integer(64)))).1; + assert_eq!(p["generators"], 64, "{p}"); + for n in [65, 999, 1_000_000] { + let m = distort("Wave", desc("null").with("NmbG", D::Integer(n))); + assert!(matches!(m, Mapped::Unsupported { harmless: false }), "NmbG {n}"); + } + assert!(export_item("filter.distort.wave", &json!({"generators": 65}), "S").is_none()); + assert!(export_item("filter.distort.wave", &json!({"generators": 64}), "S").is_some()); +} + +/// What the commands cannot play keeps the Photoshop step: an unknown enumeration value, a Wave +/// scaled below 100 % (the command has no scale), or a key the event does not have (#10605). +#[test] +fn distort_filter_steps_the_commands_cannot_play_are_kept() { + let kept = |m: Mapped| matches!(m, Mapped::Unsupported { harmless: false }); + assert!(kept(distort("Sphr", desc("null").with("SphM", enm("SphM", "Dgnl"))))); + assert!(kept(distort("Plr ", desc("null").with("Cnvr", enm("Cnvr", "Sprl"))))); + assert!(kept(distort("Wave", desc("null").with("SclH", D::Integer(50))))); + assert!(kept(distort("Wave", desc("null").with("UndA", enm("UndA", "Trns"))))); + assert!(kept(distort("Pnch", desc("null").with("Rds ", D::Integer(3))))); + // A scale of 100 % (or above, which Photoshop clamps to 100) plays. + assert!(!kept(distort("Wave", desc("null").with("SclH", D::Integer(100)).with("SclV", D::Integer(250))))); +} + +/// A setting the events cannot hold refuses the step instead of replaying differently: a +/// fractional amount (the keys are integers), a mask target, a seed past 32 bits (#10605). +#[test] +fn distort_filter_steps_that_cannot_be_written_are_refused() { + for (id, params) in [ + ("filter.distort.pinch", json!({"amount": 12.5})), + ("filter.distort.twirl", json!({"angle": 10, "target": "mask"})), + ("filter.distort.zigZag", json!({"ridges": 2.5})), + ("filter.distort.wave", json!({"seed": 4_294_967_296_u64})), + ("filter.distort.wave", json!({"undefinedAreas": "transparent"})), + ("filter.distort.spherize", json!({"mode": "diagonal"})), + ] { + assert!(export_item(id, ¶ms, "S").is_none(), "{id} {params} exported"); + } + // Left out, a setting is written as the command replays it: Wave wraps. + let wave = export_item("filter.distort.wave", &json!({}), "S").unwrap().descriptor.unwrap(); + assert_eq!(get_enum(&wave, "UndA").as_deref(), Some("WrpA")); +} + +/// A recorded setting of the wrong type is the step's own setting, not a missing one: the +/// mapper keeps the Photoshop step instead of playing it with Photoshop's default (#10605). +#[test] +fn distort_filter_steps_with_a_wrong_typed_setting_are_kept() { + let kept = |m: Mapped| matches!(m, Mapped::Unsupported { harmless: false }); + for (code, key, v) in [ + ("Pnch", "Amnt", D::Boolean(true)), + ("Twrl", "Angl", text("90")), + ("Plr ", "Cnvr", D::Integer(1)), + ("Sphr", "SphM", text("HrzO")), + ("ZgZg", "NmbR", enm("Ordn", "Trgt")), + ("Wave", "NmbG", D::Boolean(false)), + ("Wave", "Wvtp", D::Integer(2)), + ("Wave", "SclH", text("50")), + ("Wave", "SclV", D::Boolean(true)), + ("Wave", "UndA", D::Integer(1)), + ("Wave", "RndS", text("7")), + ] { + assert!(kept(distort(code, desc("null").with(key, v.clone()))), "{code} with {key} = {v:?} was mapped"); + } +} + +/// An integer outside the range Photoshop clamps the key to refuses the step: written as is, it +/// would load back clamped, as another value (#10605). +#[test] +fn distort_filter_steps_outside_photoshops_range_are_refused() { + for (id, params) in [ + ("filter.distort.pinch", json!({"amount": 500})), + ("filter.distort.spherize", json!({"amount": -101})), + ("filter.distort.twirl", json!({"angle": 1000})), + ("filter.distort.zigZag", json!({"ridges": 21})), + ("filter.distort.zigZag", json!({"ridges": -1})), + ("filter.distort.wave", json!({"generators": 0})), + ("filter.distort.wave", json!({"amplitudeMax": 1000})), + ] { + assert!(export_item(id, ¶ms, "S").is_none(), "{id} {params} exported"); + } + // The limits themselves are written. + for (id, params) in [ + ("filter.distort.pinch", json!({"amount": -100})), + ("filter.distort.twirl", json!({"angle": 999})), + ("filter.distort.zigZag", json!({"ridges": 0})), + ("filter.distort.wave", json!({"generators": 64, "wavelengthMin": 1})), + ] { + assert!(export_item(id, ¶ms, "S").is_some(), "{id} {params} refused"); + } +} + +/// Wave's `RndS` is the seed's 32 bits, signed or not: it loads exactly or keeps the step, +/// never saturated or truncated to another seed (#10605). +#[test] +fn wave_seed_loads_its_exact_32_bits_or_keeps_the_step() { + let seed = |v: D| match distort("Wave", desc("null").with("RndS", v)) { + Mapped::Steps(s) => Some(s[0].1["seed"].clone()), + Mapped::Unsupported { .. } => None, + }; + assert_eq!(seed(D::LargeInteger(4_000_000_000)), Some(json!(4_000_000_000_u32))); + assert_eq!(seed(D::LargeInteger(-1)), Some(json!(u32::MAX))); + assert_eq!(seed(D::Integer(-7)), Some(json!(4_294_967_289_u32))); + assert_eq!(seed(D::Double(12.0)), Some(json!(12))); + for v in [D::Double(7.5), D::LargeInteger(4_294_967_296), D::LargeInteger(i64::from(i32::MIN) - 1)] { + assert_eq!(seed(v.clone()), None, "RndS {v:?} was mapped"); + } +} + +/// A step recording one of its keys twice can't be saved back with both values, so it stays a +/// Photoshop step instead of playing the first and dropping the second (#10605). +#[test] +fn distort_filter_steps_with_a_repeated_key_are_kept() { + let kept = |m: Mapped| matches!(m, Mapped::Unsupported { harmless: false }); + for (code, key, a, b) in [ + ("Pnch", "Amnt", D::Integer(10), D::Integer(20)), + ("Sphr", "SphM", enm("SphM", "Nrml"), enm("SphM", "HrzO")), + ("Plr ", "Cnvr", enm("Cnvr", "RctP"), enm("Cnvr", "RctP")), + ("Wave", "NmbG", D::Integer(5), D::Integer(7)), + ("Wave", "SclH", D::Integer(100), D::Integer(50)), + ("Wave", "UndA", enm("UndA", "WrpA"), enm("UndA", "RptE")), + ("Wave", "RndS", D::Integer(1), D::Integer(2)), + ("ZgZg", "NmbR", D::Integer(5), D::Integer(6)), + ] { + assert!(kept(distort(code, desc("null").with(key, a.clone()).with(key, b.clone()))), "{code} with {key} twice was mapped"); + } +} + +/// An enumeration of another type isn't the setting Photoshop records, so the step stays a +/// Photoshop step instead of loading its value and saving back the expected type (#10605). +#[test] +fn distort_filter_steps_with_an_enumeration_of_another_type_are_kept() { + let kept = |m: Mapped| matches!(m, Mapped::Unsupported { harmless: false }); + for (code, key, v) in [ + ("Plr ", "Cnvr", enm("Ordn", "RctP")), + ("Sphr", "SphM", enm("Cnvr", "Nrml")), + ("ZgZg", "ZZTy", enm("Ordn", "PndR")), + ("Wave", "Wvtp", enm("WvSn", "WvSn")), + ("Wave", "UndA", enm("Ordn", "WrpA")), + ] { + assert!(kept(distort(code, desc("null").with(key, v.clone()))), "{code} with {key} = {v:?} was mapped"); + } + assert!(!kept(distort("Plr ", desc("null").with("Cnvr", enm("Cnvr", "PlrR"))))); + assert!(!kept(distort("Wave", desc("null").with("UndA", enm("UndA", "WrpA"))))); +} + +/// Photoshop clamps Wave's wavelengths and amplitudes to 1..999, but `filter.distort.wave` +/// takes a minimum wavelength of 1..998, a maximum of 2..999 and a minimum amplitude of 1..998. +/// A value the command rejects keeps the Photoshop step on import and refuses it on export, +/// rather than mapping a step that fails when it plays; a minimum above its maximum, which the +/// command takes, maps and plays (#10605). +#[test] +fn wave_values_the_command_rejects_are_kept_or_refused() { + let kept = |m: Mapped| matches!(m, Mapped::Unsupported { harmless: false }); + for (key, n) in [("WLMn", 999), ("WLMn", 5000), ("WLMx", 1), ("WLMx", 0), ("AmMn", 999), ("AmMn", 1200)] { + assert!(kept(distort("Wave", desc("null").with(key, D::Integer(n)))), "{key} {n} was mapped"); + } + for (param, n) in [("wavelengthMin", 999), ("wavelengthMax", 1), ("amplitudeMin", 999)] { + assert!(export_item("filter.distort.wave", &json!({ param: n }), "S").is_none(), "{param} {n} exported"); + } + let mut s = Session::new(); + s.execute("file.new", json!({"width": 24, "height": 24})).unwrap(); + s.execute("filter.render.clouds", json!({})).unwrap(); + let plays = [ + desc("null").with("WLMn", D::Integer(998)), + desc("null").with("WLMx", D::Integer(2)), + desc("null").with("AmMn", D::Integer(998)), + desc("null").with("WLMn", D::Integer(200)).with("WLMx", D::Integer(100)), + desc("null").with("AmMn", D::Integer(40)).with("AmMx", D::Integer(20)), + ]; + for (k, d) in plays.into_iter().enumerate() { + let (id, p) = one_step(distort("Wave", d)); + assert!(export_item(&id, &p, "S").is_some(), "{p} refused"); + let name = format!("W{k}"); + s.actions.list.push(Action::new(&name, vec![(id, p.clone())])); + let played = s.execute("actions.play", json!({"action": name})).unwrap(); + assert_eq!(played["ran"], 1, "{p}: {played}"); + } +} + +/// Wave saves its scales as the integer 100, so a fractional scale, even one that rounds or +/// clamps to 100, stays a Photoshop step instead of saving back as 100 (#10605). +#[test] +fn wave_with_a_fractional_scale_is_kept() { + let kept = |m: Mapped| matches!(m, Mapped::Unsupported { harmless: false }); + for (key, n) in [("SclH", 99.6), ("SclV", 99.5), ("SclH", 100.4)] { + assert!(kept(distort("Wave", desc("null").with(key, D::Double(n)))), "{key} {n} was mapped"); + } + assert!(!kept(distort("Wave", desc("null").with("SclH", D::Integer(100)).with("SclV", D::Integer(100))))); +} + +/// A Pattern Fill step whose `Ptrn` holds `id` and `name`, as the reference application records it. +fn pattern_fill_step(id: &str, name: &str) -> AtnItem { + let t = desc("patternLayer").with("Ptrn", D::Descriptor(desc("Ptrn").with("Idnt", text(id)).with("Nm ", text(name)))); + item( + "Mk ", + "Make", + Some(desc("null").with("null", class_ref("contentLayer")).with("Usng", D::Descriptor(desc("contentLayer").with("Type", D::Descriptor(t))))), + ) +} + +/// Defines a 4×4 pattern of `color` named `name` in `s`'s library from its active document. +fn define_pattern(s: &mut Session, color: &str, name: &str) -> String { + s.execute("edit.fill", json!({"contents": "color", "color": color})).unwrap(); + s.execute("edit.definePattern", json!({"name": name})).unwrap()["pattern"].as_str().unwrap().to_string() +} + +fn pixel(s: &mut Session) -> Vec { + let v = s.execute("document.pixel", json!({"x": 1, "y": 1})).unwrap(); + v.as_array().unwrap().iter().map(|c| c.as_f64().unwrap()).collect() +} + +/// A Pattern Fill step whose `Ptrn` names both an installed id and a display name shared by +/// another pattern plays the pattern with that id; a foreign id falls back to the name. +#[test] +fn pattern_fill_prefers_an_installed_id_over_a_colliding_name() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 4, "height": 4, "mode": "rgb", "depth": 8})).unwrap(); + let mut define = |color: &str| { + s.execute("edit.fill", json!({"contents": "color", "color": color})).unwrap(); + s.execute("edit.definePattern", json!({"name": "Twin"})).unwrap()["pattern"].as_str().unwrap().to_string() + }; + let red = define("#ff0000"); + let blue = define("#0000ff"); + assert_ne!(red, blue); + s.patterns.items.retain(|p| p.id == red || p.id == blue); + s.patterns.items.sort_by_key(|p| p.id != blue); + let fill_step = |id: &str| { + let t = desc("patternLayer").with("Ptrn", D::Descriptor(desc("Ptrn").with("Idnt", text(id)).with("Nm ", text("Twin")))); + item( + "Mk ", + "Make", + Some(desc("null").with("null", class_ref("contentLayer")).with("Usng", D::Descriptor(desc("contentLayer").with("Type", D::Descriptor(t))))), + ) + }; + let data = set_of("Pat", vec![("Installed", vec![fill_step(&red)]), ("Foreign", vec![fill_step("not-installed-id")])]); + let r = s.execute("actions.load", json!({"data": data})).unwrap(); + assert!(r["unsupported"].as_array().is_none_or(|u| u.is_empty()), "{r}"); + for (action, want) in [("Installed", [1.0, 0.0, 0.0, 1.0]), ("Foreign", [0.0, 0.0, 1.0, 1.0])] { + s.execute("file.new", json!({"width": 4, "height": 4, "mode": "rgb", "depth": 8})).unwrap(); + let r = s.execute("actions.play", json!({"action": action})).unwrap(); + assert!(r.get("failed").is_none(), "{r}"); + let v = s.execute("document.pixel", json!({"x": 1, "y": 1})).unwrap(); + let got: Vec = v.as_array().unwrap().iter().map(|c| c.as_f64().unwrap()).collect(); + assert_eq!(got, want, "{action}"); + } +} + +/// The recorded id is looked up by id alone, in the document and then the library, before any +/// name: a document pattern *named* like the id does not beat the library pattern with that id, +/// and a foreign id that happens to be another pattern's name falls back to the recorded name. +/// An id and a name of the same pattern play it. +#[test] +fn pattern_fill_ids_beat_names_across_document_and_library() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 4, "height": 4, "mode": "rgb", "depth": 8})).unwrap(); + let red = define_pattern(&mut s, "#ff0000", "Red"); + define_pattern(&mut s, "#0000ff", "Blue"); + let green = define_pattern(&mut s, "#00ff00", &red); + let data = set_of( + "Pat", + vec![ + ("IdWins", vec![pattern_fill_step(&red, "Blue")]), + ("NameFallback", vec![pattern_fill_step("Red", "Blue")]), + ("Same", vec![pattern_fill_step(&red, "Red")]), + ], + ); + s.execute("actions.load", json!({"data": data})).unwrap(); + // Only the playback document holds the green pattern named like the red one's id. + s.execute("file.new", json!({"width": 4, "height": 4, "mode": "rgb", "depth": 8})).unwrap(); + s.execute("layer.newFillLayer.pattern", json!({"pattern": green})).unwrap(); + s.execute("pattern.delete", json!({"pattern": green})).unwrap(); + assert!(s.active().unwrap().doc.patterns.iter().any(|p| p.id == green && p.name == red)); + assert_eq!(pixel(&mut s), [0.0, 1.0, 0.0, 1.0]); + for (action, want) in [("IdWins", [1.0, 0.0, 0.0, 1.0]), ("NameFallback", [0.0, 0.0, 1.0, 1.0]), ("Same", [1.0, 0.0, 0.0, 1.0])] { + let r = s.execute("actions.play", json!({"action": action})).unwrap(); + assert!(r.get("failed").is_none(), "{action}: {r}"); + assert_eq!(pixel(&mut s), want, "{action}"); + } +} + +/// CodeRabbit on batch 11: a pattern step that names its pattern only by `patternName` exports +/// that name, rather than a step with no pattern (which plays the current one). +#[test] +fn pattern_steps_named_only_by_pattern_name_export_the_name() { + let one = Action { name: "P".into(), steps: vec![("layer.newFillLayer.pattern".into(), json!({"patternName": "Checkerboard"}))], ..Default::default() }; + let (bytes, skipped) = export("Pat", &[&one], &[], false).unwrap(); + assert!(skipped.is_empty()); + let steps = import(&bytes).unwrap().actions[0].steps.clone(); + assert_eq!(steps[0].0, "layer.newFillLayer.pattern"); + assert_eq!(steps[0].1["pattern"], json!("Checkerboard"), "{}", steps[0].1); +} + +/// CodeRabbit on batch 11: when the recorded id is installed nowhere, the recorded name is looked +/// up as a name only. A pattern whose id equals that name text doesn't beat the one named so. +#[test] +fn pattern_fill_name_fallback_matches_names_not_ids() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 4, "height": 4, "mode": "rgb", "depth": 8})).unwrap(); + let red = define_pattern(&mut s, "#ff0000", "Red"); + // Green is named like the red pattern's id. + define_pattern(&mut s, "#00ff00", &red); + let data = set_of("Pat", vec![("NameIsAnId", vec![pattern_fill_step("5e1c0000-aaaa-4bbb-8ccc-000000000000", &red)])]); + s.execute("actions.load", json!({"data": data})).unwrap(); + s.execute("file.new", json!({"width": 4, "height": 4, "mode": "rgb", "depth": 8})).unwrap(); + let r = s.execute("actions.play", json!({"action": "NameIsAnId"})).unwrap(); + assert!(r.get("failed").is_none(), "{r}"); + assert_eq!(pixel(&mut s), [0.0, 1.0, 0.0, 1.0]); +} + +/// A recorded pattern installed neither by id nor by name fails with an error that names the +/// pattern, from the imported step and from the Pattern Fill dialog's command alike. +#[test] +fn pattern_fill_missing_everywhere_names_the_pattern() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 4, "height": 4, "mode": "rgb", "depth": 8})).unwrap(); + define_pattern(&mut s, "#ff0000", "Red"); + let data = set_of("Pat", vec![("Missing", vec![pattern_fill_step("5e1c0000-aaaa-4bbb-8ccc-000000000000", "My Wood")])]); + s.execute("actions.load", json!({"data": data})).unwrap(); + let r = s.execute("actions.play", json!({"action": "Missing"})).unwrap(); + let err = r["failed"]["error"].as_str().unwrap_or_default(); + assert!(err.contains("\"My Wood\""), "{r}"); + s.execute("layer.newFillLayer.pattern", json!({"pattern": "Red"})).unwrap(); + let e = s.execute("layer.fill.pattern.set", json!({"pattern": "5e1c0000-aaaa-4bbb-8ccc-000000000000", "patternName": "My Wood"})).unwrap_err(); + assert!(e.to_string().contains("\"My Wood\""), "{e}"); +} + +/// FredApps review on #10459: a set whose name has surrounding spaces (a Photoshop `.atn` or an +/// older actions.json can have one, and New Action records into it under that exact name) saves +/// by that name. `actions.save` used to trim it and then find no set. +#[test] +fn save_finds_a_set_by_its_exact_name_with_spaces() { + let mut s = Session::new(); + s.actions.list.push(Action { set: Some(" Web ".into()), ..Action::new("Old", Vec::new()) }); + s.execute("actions.record", json!({"name": "New", "set": " Web "})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + let saved = s.execute("actions.save", json!({"set": " Web "})).unwrap(); + assert_eq!((&saved["set"], &saved["actions"]), (&json!(" Web "), &json!(["Old", "New"]))); + // The file keeps the name, as Photoshop's does. + let mut reloaded = Session::new(); + let r = reloaded.execute("actions.load", json!({"data": saved["data"]})).unwrap(); + assert_eq!(r["set"], " Web "); + // Other names are still trimmed. + s.execute("actions.assignSet", json!({"action": "Old", "set": "Print"})).unwrap(); + assert_eq!(s.execute("actions.save", json!({"set": " Print "})).unwrap()["set"], "Print"); +} + +/// FredApps review on #10459: Save Actions… on an empty set made with New Set writes it, as +/// Photoshop saves an empty set to the `.atn`, and loading that file brings the empty set back. +#[test] +fn an_empty_new_set_saves_and_loads_back() { + let mut s = Session::new(); + s.execute("actions.newSet", json!({"name": "Later"})).unwrap(); + let saved = s.execute("actions.save", json!({"set": "Later"})).unwrap(); + assert_eq!((&saved["set"], &saved["actions"], &saved["steps"]), (&json!("Later"), &json!([]), &json!(0))); + let mut other = Session::new(); + let r = other.execute("actions.load", json!({"data": saved["data"]})).unwrap(); + assert_eq!((&r["set"], &r["actions"]), (&json!("Later"), &json!([]))); + assert_eq!(crate::actions_edit_cmds::set_names(&other), ["Later"], "an empty folder"); + other.execute("actions.load", json!({"data": saved["data"]})).unwrap(); + assert_eq!(crate::actions_edit_cmds::set_names(&other), ["Later"], "loaded twice, still one folder"); + // New Action records into it as into any set, and it saves with that action. + other.execute("actions.record", json!({"name": "Now", "set": "Later"})).unwrap(); + other.execute("actions.stop", json!({})).unwrap(); + assert_eq!(other.execute("actions.save", json!({"set": "Later"})).unwrap()["actions"], json!(["Now"])); + // A set that doesn't exist is still refused, and so is saving every action when there is none. + let err = s.execute("actions.save", json!({"set": "Nope"})).unwrap_err().to_string(); + assert!(err.contains("no actions in a set named `Nope`"), "{err}"); + let err = Session::new().execute("actions.save", json!({})).unwrap_err().to_string(); + assert!(err.contains("there are no actions to save"), "{err}"); +} + +/// A set from Chinese Photoshop: UTF-16 set and action names, a step name in GBK (可选颜色, +/// the bytes from #6743) on an event PhotoCraft has no command for, with no descriptor. +fn gbk_set() -> Vec { + let step = AtnItem { dictionary: b"\xbf\xc9\xd1\xa1\xd1\xd5\xc9\xab".to_vec(), ..item("syntheticFill", "", None) }; + let file = AtnFile { + name: UnicodeString::new_nul("相馆"), + actions: vec![AtnAction { name: UnicodeString::new_nul("调白"), items: vec![step], ..Default::default() }], + ..Default::default() + }; + atn::write(&file).unwrap() +} + +#[test] +fn load_decodes_legacy_step_names() { + let mut s = Session::new(); + let r = s.execute("actions.load", json!({"data": b64(&gbk_set())})).unwrap(); + assert_eq!(r["encoding"], "GBK", "{r}"); + assert_eq!(r["unsupported"][0]["name"], "可选颜色", "{r}"); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + let played = s.execute("actions.play", json!({"action": "调白"})).unwrap(); + assert!(played["failed"]["error"].as_str().unwrap().contains("可选颜色"), "{played}"); +} + +#[test] +fn load_keeps_windows_1252_symbols() { + // Windows Photoshop step names with no Unicode copy: Mac Roman reads ° and » as ∞ and ª, + // which must not win ("50 ∞", "Grˆfle ª"). Save Actions writes the names back as that text. + let steps = vec![ + AtnItem { dictionary: b"50 \xb0".to_vec(), ..item("syntheticFill", "", None) }, + AtnItem { dictionary: b"Gr\xf6\xdfe \xbb".to_vec(), ..item("syntheticFill", "", None) }, + ]; + let file = AtnFile { + name: UnicodeString::new_nul("Größe ändern"), + actions: vec![AtnAction { name: UnicodeString::new_nul("Logo"), items: steps, ..Default::default() }], + ..Default::default() + }; + let mut s = Session::new(); + let r = s.execute("actions.load", json!({"data": b64(&atn::write(&file).unwrap())})).unwrap(); + assert_eq!(r["encoding"], "windows-1252", "{r}"); + assert_eq!(r["unsupported"][0]["name"], "50 °", "{r}"); + assert_eq!(r["unsupported"][1]["name"], "Größe »", "{r}"); + let saved = s.execute("actions.save", json!({"set": "Größe ändern"})).unwrap(); + let saved = atn::parse(&photocraft_paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap()).unwrap(); + let names: Vec = saved.actions[0].items.iter().map(|i| saved.item_label(i)).collect(); + assert_eq!(names, ["50 °", "Größe »"]); +} + +#[test] +fn load_encoding_override() { + let mut s = Session::new(); + let r = s.execute("actions.load", json!({"data": b64(&gbk_set()), "encoding": "shift_jis"})).unwrap(); + assert_eq!(r["encoding"], "Shift_JIS", "{r}"); + let r = s.execute("actions.load", json!({"data": b64(&gbk_set()), "encoding": "auto"})).unwrap(); + assert_eq!(r["encoding"], "GBK", "{r}"); +} + +#[test] +fn load_reports_utf8() { + let mut s = Session::new(); + let r = s.execute("actions.load", json!({"data": b64(&atn::write(&sample()).unwrap())})).unwrap(); + assert_eq!(r["encoding"], "UTF-8", "{r}"); +} + +#[test] +fn load_rejects_bad_encodings() { + let mut s = Session::new(); + for bad in [json!(3), json!(""), json!("nope"), json!("replacement"), json!(["gbk"])] { + assert!(s.execute("actions.load", json!({"data": b64(&gbk_set()), "encoding": bad})).is_err(), "{bad}"); + } + assert!(s.actions.list.is_empty(), "nothing is loaded when the encoding is bad"); +} From 0e410916879be3366c144da5e205020e3b38edae Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 2 Nov 2026 02:50:33 +0100 Subject: [PATCH 145/216] Integration: Group F steps recorded by the reference application map (#10628) The Group F loader only mapped descriptors with an empty name and class `null`, and nested shapes with an empty name. The reference application names every step descriptor after the localized step (plus NUL), uses the event code as its class and names nested shapes too, so every real recorded Add To / Fade / Merge Layers ... step stayed a kept step and never played (e.g. `addTo` with name "Add To\0", class `AddT` and a nested `Rctn` named "\0"). The header is no longer checked; the shape is matched by class only; key, order and enum checks stay strict. IntW, Dstr and MrgL were registered with the string IDs `interfaceWhite`, `distort` and none; the real ones are `intersectWith`, `distribute` and `mergeLayers`, so steps written with them stayed kept. Co-authored-by: yoriswind <164435053+yoriswind@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/atn_cmds/events.rs | 8 +- .../src/cmd/atn_cmds/selection_tests.rs | 83 +++++++++++++++++-- .../src/cmd/atn_cmds/steps_selection.rs | 13 +-- docs/atn.md | 14 ++-- 4 files changed, 88 insertions(+), 30 deletions(-) diff --git a/crates/engine/src/cmd/atn_cmds/events.rs b/crates/engine/src/cmd/atn_cmds/events.rs index c471875a43..9dc8c6a269 100644 --- a/crates/engine/src/cmd/atn_cmds/events.rs +++ b/crates/engine/src/cmd/atn_cmds/events.rs @@ -182,7 +182,7 @@ macro_rules! clipping_event { }; } -/// Group F validates the outer descriptor before every converter, including empty events. +/// Group F routes every event through `steps_selection::from`; the outer header (name, class) is not checked. macro_rules! selection_event { ($code:literal, $name:literal, $label:literal, $ids:expr, $from:expr, $to:expr) => { Event { code: $code, name: $name, label: $label, ids: $ids, from: |d| super::steps_selection::from(d, $from), to: $to } @@ -199,7 +199,7 @@ pub(super) const EVENTS: &[Event] = &[ }), selection_event!( "IntW", - "interfaceWhite", + "intersectWith", "Intersect with Selection", &["select.rect"], |d| super::steps_selection::selection_from(d, "intersect"), @@ -225,7 +225,7 @@ pub(super) const EVENTS: &[Event] = &[ selection_event!("", "linkSelectedLayers", "Link Layers", &["layer.linkLayers"], super::steps_selection::link_from, super::steps_selection::link_to), selection_event!( "Dstr", - "distort", + "distribute", "Distribute", &[ "layer.distribute.leftEdges", @@ -256,7 +256,7 @@ pub(super) const EVENTS: &[Event] = &[ ), selection_event!( "MrgL", - "", + "mergeLayers", "Merge Layers", &["layer.mergeLayers"], |d| super::steps_selection::empty_from(d, "layer.mergeLayers", Some("MrgL")), diff --git a/crates/engine/src/cmd/atn_cmds/selection_tests.rs b/crates/engine/src/cmd/atn_cmds/selection_tests.rs index 84fa68f4b8..3d8c570529 100644 --- a/crates/engine/src/cmd/atn_cmds/selection_tests.rs +++ b/crates/engine/src/cmd/atn_cmds/selection_tests.rs @@ -38,15 +38,15 @@ fn fixtures() -> Vec<(&'static str, Option<&'static str>, Descriptor)> { ("AddT", Some("addTo"), rectangle([30.0, 30.0, 90.0, 90.0], false)), ("AddT", Some("addTo"), rectangle([30.0, 30.0, 90.0, 90.0], true)), ("SbtF", Some("subtractFrom"), rectangle([30.0, 30.0, 60.0, 60.0], false)), - ("IntW", Some("interfaceWhite"), rectangle([30.0, 30.0, 120.0, 120.0], false)), + ("IntW", Some("intersectWith"), rectangle([30.0, 30.0, 120.0, 120.0], false)), ("PstI", Some("pasteInto"), desc("null").with("AntA", enm("Annt", "Anno")).with("As ", D::Class(class("Pxel")))), ("PstO", Some("pasteOutside"), desc("null").with("AntA", enm("Annt", "Anno")).with("As ", D::Class(class("Pxel")))), ("Fade", Some("fade"), desc("null").with("Opct", pct(50.0)).with("Md ", enm("BlnM", "Mltp"))), ("", Some("linkSelectedLayers"), desc("null").with("null", target_ref("Lyr "))), - ("Dstr", Some("distort"), desc("null").with("null", target_ref("Lyr ")).with("Usng", enm("ADSt", "AdLf"))), + ("Dstr", Some("distribute"), desc("null").with("null", target_ref("Lyr ")).with("Usng", enm("ADSt", "AdLf"))), ("RmvL", Some("removeLayerMask"), desc("null")), ("DfnP", Some("definePattern"), desc("null")), - ("MrgL", None, desc("null")), + ("MrgL", Some("mergeLayers"), desc("null")), ]; // Opposite-signed fractions can lose an endpoint through x+(right-x); preserve it explicitly. for mode in ["AddT", "SbtF", "IntW"] { @@ -59,7 +59,7 @@ fn fixtures() -> Vec<(&'static str, Option<&'static str>, Descriptor)> { cases.push(("Fade", Some("fade"), desc("null").with("Opct", pct(25.5)).with("Md ", enm("BlnM", mode)))); } for edge in ["AdCH", "AdRg", "AdTp", "AdCV", "AdBt"] { - cases.push(("Dstr", Some("distort"), desc("null").with("null", target_ref("Lyr ")).with("Usng", enm("ADSt", edge)))); + cases.push(("Dstr", Some("distribute"), desc("null").with("null", target_ref("Lyr ")).with("Usng", enm("ADSt", edge)))); } cases } @@ -90,8 +90,7 @@ fn group_f_load_save_preserves_recorded_descriptors() { /// A kept step retains its descriptor bytes, event identity and playback flags. fn assert_kept(id: &str, name: Option<&str>, descriptor: Descriptor) { - let event = if id.is_empty() { AtnEvent::name(name.unwrap()) } else { AtnEvent::Code(id.as_bytes().try_into().unwrap()) }; - let mut original = atn::parse(&file(event, descriptor.clone())).unwrap(); + let mut original = atn::parse(&file(fixture_event(id, name), descriptor.clone())).unwrap(); original.actions[0].items[0].enabled = false; original.actions[0].items[0].with_dialog = true; original.actions[0].items[0].dialog_options = 2; @@ -112,10 +111,19 @@ fn assert_kept(id: &str, name: Option<&str>, descriptor: Descriptor) { assert_eq!(saved.descriptor.as_ref().unwrap().to_bytes(), descriptor.to_bytes()); } -/// Outer and nested shape metadata stay kept, including headers on empty Group F events. +/// The event of a fixture, in its code form where it has one. +fn fixture_event(id: &str, name: Option<&str>) -> AtnEvent { + if id.is_empty() { AtnEvent::name(name.unwrap()) } else { AtnEvent::Code(id.as_bytes().try_into().unwrap()) } +} + +/// The outer name and class and the nested shape's name and class encoding do not gate mapping: +/// such a step maps to the same command and params as the empty-name `null` header. #[test] -fn noncanonical_group_f_headers_keep_their_bytes_and_flags() { +fn noncanonical_group_f_headers_map_like_the_canonical_form() { for (id, name, d) in fixtures() { + let event = fixture_event(id, name); + let canonical = import(&file(event.clone(), d.clone())).unwrap(); + assert!(canonical.unsupported.is_empty(), "{id}"); for variant in 0..5 { let mut descriptor = d.clone(); match variant { @@ -131,11 +139,68 @@ fn noncanonical_group_f_headers_keep_their_bytes_and_flags() { } } } - assert_kept(id, name, descriptor); + let loaded = import(&file(event.clone(), descriptor.clone())).unwrap(); + assert!(loaded.unsupported.is_empty(), "{id}/{name:?}/{variant}"); + assert_eq!(loaded.actions[0].steps, canonical.actions[0].steps, "{id}/{name:?}/{variant}"); } } } +/// The reference application names each step descriptor after the localized step (with a NUL), +/// gives it the event code as its class and names nested shapes too (synthetic descriptors). +#[test] +fn group_f_maps_steps_recorded_with_the_application_header() { + let mut shape = Descriptor { name: UnicodeString::new_nul(""), class_id: code("Rctn"), items: Vec::new() }; + for (key, value) in ["Top ", "Left", "Btom", "Rght"].into_iter().zip([30.0, 30.0, 90.0, 90.0]) { + shape = shape.with(key, px(value)); + } + let cases = [ + ( + AtnEvent::name("fade"), + Descriptor { + name: UnicodeString::new_nul("Fade"), + class_id: code("Fade"), + items: vec![(code("Opct"), pct(50.0)), (code("Md "), enm("BlnM", "Nrml"))], + }, + ("edit.fade", json!({"opacity":50,"mode":"Normal"})), + ), + ( + AtnEvent::name("addTo"), + Descriptor { + name: UnicodeString::new_nul("Add To"), + class_id: code("AddT"), + items: vec![(code("null"), selection()), (code("T "), D::Descriptor(shape))], + }, + ("select.rect", json!({"x":30,"y":30,"width":60,"height":60,"mode":"add"})), + ), + ( + AtnEvent::Code(*b"MrgL"), + Descriptor { name: UnicodeString::new_nul("Merge Layers"), class_id: code("MrgL"), items: Vec::new() }, + ("layer.mergeLayers", json!({"_atnEvent":"MrgL"})), + ), + ]; + for (event, d, (command, params)) in cases { + let loaded = import(&file(event, d)).unwrap(); + assert!(loaded.unsupported.is_empty(), "{command}"); + assert_eq!(loaded.actions[0].steps, vec![(command.to_string(), params)]); + } +} + +/// Steps recorded with the reference application's string IDs map like their four-character codes. +#[test] +fn group_f_string_ids_map_like_their_codes() { + for (id, name, d) in [ + ("IntW", "intersectWith", rectangle([30.0, 30.0, 120.0, 120.0], false)), + ("Dstr", "distribute", desc("null").with("null", target_ref("Lyr ")).with("Usng", enm("ADSt", "AdLf"))), + ("MrgL", "mergeLayers", desc("null")), + ] { + let by_code = import(&file(AtnEvent::Code(id.as_bytes().try_into().unwrap()), d.clone())).unwrap(); + let by_name = import(&file(AtnEvent::name(name), d)).unwrap(); + assert!(by_code.unsupported.is_empty() && by_name.unsupported.is_empty(), "{name}"); + assert_eq!(by_name.actions[0].steps, by_code.actions[0].steps, "{name}"); + } +} + /// Every keyed converter rejects alternate key encodings and adjacent order changes. #[test] fn noncanonical_group_f_keys_keep_their_bytes_and_flags() { diff --git a/crates/engine/src/cmd/atn_cmds/steps_selection.rs b/crates/engine/src/cmd/atn_cmds/steps_selection.rs index 0a7a3efb9e..1c30dc93ab 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_selection.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_selection.rs @@ -5,16 +5,9 @@ use super::fields::{Mapped, step}; use super::tables::{ALIGN, blend_label, blend_ps, lookup, lookup_back}; use super::{D, Descriptor, ReferenceItem, Value, json}; -/// Exported headers use an empty name and the four-byte class identifier. -fn canonical_header(d: &Descriptor, class: &str) -> bool { - d.name == Default::default() && d.class_id == code(class) -} - -/// Group F checks the outer header before every converter, including empty events. +/// Group F converters ignore the outer header, like every other event: the reference application +/// records the localized step name (with its NUL) as the name and the event code as the class. pub(super) fn from(d: &Descriptor, convert: fn(&Descriptor) -> Mapped) -> Mapped { - if !canonical_header(d, "null") { - return Mapped::Unsupported { harmless: false }; - } convert(d) } @@ -69,7 +62,7 @@ pub(super) fn selection_from(d: &Descriptor, mode: &str) -> Mapped { } let D::Descriptor(shape) = d.get("T ")? else { return None }; let ellipse = shape.class_id.is("Elps"); - if !canonical_header(shape, if ellipse { "Elps" } else { "Rctn" }) || (ellipse && mode != "add") || !keys(shape, &["Top ", "Left", "Btom", "Rght"]) { + if (!ellipse && !shape.class_id.is("Rctn")) || (ellipse && mode != "add") || !keys(shape, &["Top ", "Left", "Btom", "Rght"]) { return None; } let [top, left, bottom, right] = diff --git a/docs/atn.md b/docs/atn.md index 494a7c9062..48ee49eaa7 100644 --- a/docs/atn.md +++ b/docs/atn.md @@ -22,15 +22,15 @@ does not map yet, with everything needed to implement them without the reference |---|---|---|---| | `AddT` | addTo | Add to Selection | `select.rect` (add rectangle or ellipse) | | `SbtF` | subtractFrom | Subtract from Selection | `select.rect` (subtract rectangle) | -| `IntW` | interfaceWhite | Intersect with Selection | `select.rect` (intersect rectangle) | +| `IntW` | intersectWith | Intersect with Selection | `select.rect` (intersect rectangle) | | `PstI` | pasteInto | Paste Into | `edit.pasteSpecial.pasteInto` | | `PstO` | pasteOutside | Paste Outside | `edit.pasteSpecial.pasteOutside` | | `Fade` | fade | Fade | `edit.fade` | | (string ID only) | linkSelectedLayers | Link Layers | `layer.linkLayers` | -| `Dstr` | distort | Distribute | `layer.distribute.leftEdges`, `layer.distribute.horizontalCenters`, `layer.distribute.rightEdges`, `layer.distribute.topEdges`, `layer.distribute.verticalCenters`, `layer.distribute.bottomEdges` | +| `Dstr` | distribute | Distribute | `layer.distribute.leftEdges`, `layer.distribute.horizontalCenters`, `layer.distribute.rightEdges`, `layer.distribute.topEdges`, `layer.distribute.verticalCenters`, `layer.distribute.bottomEdges` | | `RmvL` | removeLayerMask | Remove Layer Mask (older files) | `layer.layerMask.delete` | | `DfnP` | definePattern | Define Pattern | `edit.definePattern` | -| `MrgL` | (code only) | Merge Layers (older files) | `layer.mergeLayers` | +| `MrgL` | mergeLayers | Merge Layers (older files) | `layer.mergeLayers` | | `AdNs` | addNoise | Add Noise | `filter.noise.addNoise` | | `Algn` | align | Align | `layer.align.leftEdges`, `layer.align.horizontalCenters`, `layer.align.rightEdges`, `layer.align.topEdges`, `layer.align.verticalCenters`, `layer.align.bottomEdges` | | `AppI` | applyImageEvent | Apply Image | `image.applyImage` | @@ -214,7 +214,7 @@ Nested values as recorded (example values): #### Intersect with Selection -- Event: `IntW` / `interfaceWhite` +- Event: `IntW` / `intersectWith` - PhotoCraft command: `select.rect {"mode":"intersect"}` - Measured with example values in a prepared document: the Value column shows what was sent and recorded, not a factory default (a step always carries the values the user set). @@ -277,7 +277,7 @@ Nested values as recorded (example values): #### Distribute -- Event: `Dstr` / `distort` +- Event: `Dstr` / `distribute` - PhotoCraft command: `layer.distribute.leftEdges (one command per edge)` - `Usng` selects the edge or centre; one enum value per Distribute menu item. - Measured with example values in a prepared document: the Value column shows what was sent and recorded, not a factory default (a step always carries the values the user set). @@ -305,12 +305,12 @@ No parameters: the step carries an empty descriptor. #### Merge Layers (older files) -- Event: `MrgL` (no string ID) +- Event: `MrgL` / `mergeLayers` - PhotoCraft command: `layer.mergeLayers` No parameters: the step carries an empty descriptor. -The prepared-document values in this group are required inputs, rather than factory defaults. Unknown settings stay kept steps. Paste uses the current clipboard and selection; Fade uses the preceding edit. Link Layers uses the existing selected-layer toggle. Native exports retain their current Delete Mask and Merge Layers events; imported older events retain their original IDs. +The prepared-document values in this group are required inputs, rather than factory defaults. Unknown settings stay kept steps; the descriptor name and class (the reference application records the localized step name and the event code there) do not matter. Paste uses the current clipboard and selection; Fade uses the preceding edit. Link Layers uses the existing selected-layer toggle. Native exports retain their current Delete Mask and Merge Layers events; imported older events retain their original IDs. ## Recognized steps kept disabled From 78cf0cf91f641b49e4563fdf4b01d983d6972e9a Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 2 Nov 2026 02:53:47 +0100 Subject: [PATCH 146/216] Integration: transform anchor codes, Close save:false, Batch Save As + Close, opacity and thumbnail-click regressions (#10702) - FTcs reference points used a 3x3 grid (Qcs0..Qcs8). The reference application has Qcsa centre, Qcs0..Qcs3 corners clockwise from top-left and Qcs4..Qcs7 the sides top/right/bottom/left (measured: 50 % scale, Qcs6 keeps the bottom centre, Qcs1 the top-right corner). Qcs8 and Qcsi stay kept steps; nothing writes Qcs8. - file.close {"save": false} (used by ui-egui tests) became an error; it discards again and saves as Svng N. - Batch never performs the action's Save As, so the scratch document stayed dirty and the recorded Close (no Svng -> "save":"ask") failed every file of "...; Save As; Close". A Save / Save As save point now marks the document saved. - Adjustment-layer opacity is quantised to 8 bits on load; a recorded 70 % saved back as 70.196 %. The whole percent that quantises to the same level is saved again. - Cmd-clicking a group's thumbnail errored; it loads the whole canvas again as before. A recorded Cmd-click on another layer's thumbnail kept the numeric id and failed strict Save Actions; it records the layer by name (or as the Background). Co-authored-by: yoriswind <164435053+yoriswind@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd.rs | 6 ++- crates/engine/src/cmd/actions_cmds/tests.rs | 42 +++++++++++++++++ crates/engine/src/cmd/atn_cmds/steps_doc.rs | 1 + crates/engine/src/cmd/atn_cmds/steps_image.rs | 17 +++---- crates/engine/src/cmd/atn_cmds/steps_make.rs | 12 ++++- crates/engine/src/cmd/atn_cmds/tests.rs | 47 +++++++++++++++---- crates/engine/src/cmd/channel_cmds/refs.rs | 1 - crates/engine/src/cmd/file_cmds/batch_run.rs | 10 +++- crates/engine/src/cmd/file_cmds/tests.rs | 22 +++++++++ .../src/cmd/layer_multi_cmds/selection.rs | 23 +++++++++ crates/engine/tests/atn_mapped_identifiers.rs | 42 ++++++++++------- 11 files changed, 184 insertions(+), 39 deletions(-) diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 93ccc06d54..00988c6669 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -311,7 +311,7 @@ fn build() -> Vec { "Close", ["File"], Some("Cmd+W"), - r##"{"document":index?,"save":"discard|ask"="discard"} (ask refuses to close a changed document in headless playback)"##, + r##"{"document":index?,"save":"discard|ask"|false="discard"} (false means discard; ask refuses to close a changed document in headless playback)"##, has_doc, |s, p| { let i = p.get("document").and_then(Value::as_u64).map(|v| v as usize).or(s.active_index()).ok_or(EngineError::NoDocument)?; @@ -321,7 +321,9 @@ fn build() -> Vec { return Err(bad("file.close", "the document has unsaved changes; save it or explicitly discard them")); } } - Some(v) if v != "discard" => return Err(bad("file.close", "`save` must be ask or discard")), + // `false` (don't save) is the older spelling of discard. + Some(Value::Bool(false)) => {} + Some(v) if v != "discard" => return Err(bad("file.close", "`save` must be ask, discard or false")), _ => {} } s.close(i).ok_or(EngineError::NoDocument)?; diff --git a/crates/engine/src/cmd/actions_cmds/tests.rs b/crates/engine/src/cmd/actions_cmds/tests.rs index caddcaa892..933ae5fe90 100644 --- a/crates/engine/src/cmd/actions_cmds/tests.rs +++ b/crates/engine/src/cmd/actions_cmds/tests.rs @@ -114,6 +114,48 @@ fn recorded_eye_visibility_replays_on_another_document_and_duplicate_names() { assert!(s.active().unwrap().doc.layer(photocraft_doc::LayerId(target)).unwrap().visible); } +/// ⌘-clicking a layer's thumbnail that is not the selected layer records the layer by name (the +/// Background as the Background), so the step saves in strict Save Actions and replays on the +/// same-named layer of another document. +#[test] +fn recorded_thumbnail_click_on_another_layer_saves_and_replays_by_name() { + use photocraft_geom::Rect; + let fixture = |s: &mut Session| { + s.execute("file.new", json!({"width": 4, "height": 1})).unwrap(); + let source = s.execute("layer.new.layer", json!({"name": "Source"})).unwrap()["layer"].as_u64().unwrap(); + s.edit("Pixels", |doc, _| { + doc.layer_mut(photocraft_doc::LayerId(source)).unwrap().surface_mut().unwrap().fill_rect(Rect::new(0, 0, 1, 1), &[1.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + s.execute("layer.new.layer", json!({"name": "Active"})).unwrap(); + source + }; + let mut s = Session::new(); + let source = fixture(&mut s); + let background = s.active().unwrap().doc.background().unwrap().id.0; + s.execute("actions.record", json!({"name": "Load"})).unwrap(); + s.execute("select.loadSelection", json!({"channel": "transparency", "layer": source, "operation": "new"})).unwrap(); + s.execute("select.loadSelection", json!({"channel": "transparency", "layer": background, "operation": "new"})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + let action = s.actions.list.iter().find(|a| a.name == "Load").unwrap().clone(); + assert_eq!( + action.steps, + vec![ + ("select.loadSelection".into(), json!({"channel": "transparency", "layer": {"name": "Source"}, "operation": "new"})), + ("select.loadSelection".into(), json!({"channel": "transparency", "layer": {"background": true}, "operation": "new"})), + ] + ); + crate::atn_cmds::export("Set", &[&action], &[], false).unwrap(); + // Replayed in another document, the first step loads its Source layer. + let mut other = Session::new(); + fixture(&mut other); + let (id, params) = &action.steps[0]; + other.execute(id, params.clone()).unwrap(); + let doc = &other.active().unwrap().doc; + assert_eq!(doc.selection.as_ref().unwrap().read_region(Rect::new(0, 0, 4, 1)), vec![1.0, 0.0, 0.0, 0.0]); +} + #[test] fn rename_keeps_calls_and_function_key_and_rejects_bad_names() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/atn_cmds/steps_doc.rs b/crates/engine/src/cmd/atn_cmds/steps_doc.rs index 900fb06485..46329f2e1a 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_doc.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_doc.rs @@ -131,6 +131,7 @@ pub(super) fn close_to(_: &str, p: &Value) -> Option { } match p.get("save").filter(|v| !v.is_null()) { Some(v) if v == "ask" => Some(desc("null")), + Some(Value::Bool(false)) => Some(desc("null").with("Svng", enm("YsN ", "N "))), Some(v) if v != "discard" => None, _ => Some(desc("null").with("Svng", enm("YsN ", "N "))), } diff --git a/crates/engine/src/cmd/atn_cmds/steps_image.rs b/crates/engine/src/cmd/atn_cmds/steps_image.rs index 7eb9662ac8..29e4b0209c 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_image.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_image.rs @@ -7,18 +7,19 @@ use super::fields::{F, Kind, Mapped, fields_from, fields_to, step}; use super::tables::{CHANNELS, INTERPOLATION, RADIAL_METHOD, RADIAL_QUALITY, channel_of, lookup, lookup_back}; use super::{D, Descriptor, Map, ReferenceItem, UnicodeString, Value, json}; -/// Photoshop's nine frame anchors and the average/centre anchor. +/// The reference point of a Free Transform (`QCSt`): `Qcsa` the centre (average), `Qcs0`..`Qcs3` +/// the corners clockwise from the top left, `Qcs4`..`Qcs7` the sides top, right, bottom, left. +/// `Qcsi` (independent) and unknown codes have no mapping and stay kept steps. const TRANSFORM_REFERENCE: &[(&str, &str)] = &[ ("Qcsa", "center"), ("Qcs0", "topLeft"), - ("Qcs1", "top"), - ("Qcs2", "topRight"), - ("Qcs3", "left"), - ("Qcs4", "center"), + ("Qcs4", "top"), + ("Qcs1", "topRight"), + ("Qcs7", "left"), ("Qcs5", "right"), - ("Qcs6", "bottomLeft"), - ("Qcs7", "bottom"), - ("Qcs8", "bottomRight"), + ("Qcs3", "bottomLeft"), + ("Qcs6", "bottom"), + ("Qcs2", "bottomRight"), ]; /// `transform`: Photoshop records a Free Transform relative to the frame (offset of its centre, diff --git a/crates/engine/src/cmd/atn_cmds/steps_make.rs b/crates/engine/src/cmd/atn_cmds/steps_make.rs index 6acc3d3249..f6a6312612 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_make.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_make.rs @@ -279,6 +279,16 @@ pub(super) fn make_from(d: &Descriptor) -> Mapped { } } +/// The percent to save for an adjustment layer's opacity. Loading quantises a recorded percent +/// to 8 bits (70 % becomes 179/255); a whole percent that quantises to that same level is the +/// one that was recorded, so it is saved back (load then save keeps `70`, not `70.196`). +fn recorded_percent(opacity: f64) -> f64 { + let level = opacity * 255.0; + let percent = opacity * 100.0; + let whole = percent.round(); + if (level - level.round()).abs() < 1e-6 && (whole * 255.0 / 100.0).round() == level.round() { whole } else { percent } +} + /// Write a creation step, refusing native options the target descriptor cannot carry. pub(super) fn make_to(id: &str, p: &Value) -> Option { let with_name = |cls: &str| { @@ -425,7 +435,7 @@ pub(super) fn make_to(id: &str, p: &Value) -> Option { }; let mut using = desc("AdjL").with("Type", D::Descriptor(t)); if let Some(opacity) = opacity { - using = using.with("Opct", pct(opacity * 100.0)); + using = using.with("Opct", pct(recorded_percent(opacity))); } desc("null").with("null", class_ref("AdjL")).with("Usng", D::Descriptor(using)) } diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index 89283ac3c7..22cf7f0092 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -3237,14 +3237,13 @@ fn mapped_transform_reference_points_round_trip() { for (code, reference) in [ ("Qcsa", "center"), ("Qcs0", "topLeft"), - ("Qcs1", "top"), - ("Qcs2", "topRight"), - ("Qcs3", "left"), - ("Qcs4", "center"), + ("Qcs4", "top"), + ("Qcs1", "topRight"), + ("Qcs7", "left"), ("Qcs5", "right"), - ("Qcs6", "bottomLeft"), - ("Qcs7", "bottom"), - ("Qcs8", "bottomRight"), + ("Qcs3", "bottomLeft"), + ("Qcs6", "bottom"), + ("Qcs2", "bottomRight"), ] { for target in [target_ref("Lyr "), D::Reference(vec![ReferenceItem::Property { class: class("Chnl"), key: Id::new("fsel") }])] { let d = desc("null") @@ -3268,8 +3267,19 @@ fn mapped_transform_reference_points_round_trip() { } } } - let d = desc("null").with("null", target_ref("Lyr ")).with("FTcs", enm("QCSt", "unknown")).with("Wdth", pct(50.0)); - assert_eq!(mapped_recording("Trnf", d).actions[0].steps[0].0, PHOTOSHOP_STEP); + // The reference application never records `Qcs8` or `Qcsi` (independent): kept steps. + for code in ["unknown", "Qcs8", "Qcsi"] { + let d = desc("null").with("null", target_ref("Lyr ")).with("FTcs", enm("QCSt", code)).with("Wdth", pct(50.0)); + assert_eq!(mapped_recording("Trnf", d).actions[0].steps[0].0, PHOTOSHOP_STEP, "{code}"); + } + // A native anchor saves as the reference application's code; nothing writes `Qcs8`. + for (reference, code) in [("top", "Qcs4"), ("topRight", "Qcs1"), ("bottom", "Qcs6"), ("bottomRight", "Qcs2")] { + let native = Action::new("Native", vec![("edit.transform".into(), json!({"scaleX":50,"scaleY":50,"reference":reference}))]); + let (bytes, _) = export("Anchors", &[&native], &[], false).unwrap(); + let saved = atn::parse(&bytes).unwrap(); + let d = saved.actions[0].items[0].descriptor.as_ref().unwrap(); + assert!(matches!(d.get("FTcs"), Some(D::Enumerated { value, .. }) if value.is(code)), "{reference}: {:?}", d.get("FTcs")); + } } /// Alt-clicking an eye twice must isolate and then restore visibility, not just show a layer. @@ -3450,6 +3460,25 @@ fn mapped_close_keeps_ask_and_discard_distinct() { assert!(s.documents().is_empty()); } +/// `"save": false` is the older spelling of discard: it closes a changed document and saves as +/// "don't save" (`Svng N`); `true` and other values stay refused. +#[test] +fn close_save_false_discards_and_saves_as_dont_save() { + let mut s = Session::new(); + s.execute("file.new", json!({"width":4,"height":4})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + for refused in [json!(true), json!("yes"), json!(0)] { + assert!(s.execute("file.close", json!({"save": refused})).is_err(), "{refused}"); + assert_eq!(s.documents().len(), 1); + } + s.execute("file.close", json!({"save": false})).unwrap(); + assert!(s.documents().is_empty()); + let native = Action::new("Close", vec![("file.close".into(), json!({"save": false}))]); + let (bytes, _) = export("Close", &[&native], &[], false).unwrap(); + let d = atn::parse(&bytes).unwrap().actions[0].items[0].descriptor.clone().unwrap(); + assert!(matches!(d.get("Svng"), Some(D::Enumerated { type_id, value }) if type_id.is("YsN ") && value.is("N ")), "{:?}", d.get("Svng")); +} + /// Lab Photo Filter colours use the CMS conversion and remain mapped after saving as RGB. #[test] fn mapped_lab_photo_filter_imports_and_round_trips() { diff --git a/crates/engine/src/cmd/channel_cmds/refs.rs b/crates/engine/src/cmd/channel_cmds/refs.rs index 1bdf587f67..bf12284997 100644 --- a/crates/engine/src/cmd/channel_cmds/refs.rs +++ b/crates/engine/src/cmd/channel_cmds/refs.rs @@ -171,7 +171,6 @@ fn ref_planes(doc: &Document, layer: Option, active: Option, r .map(|p| p[3]) .collect(), ], - LayerContent::Group(_) => return Err(EngineError::Other("group transparency sources are not supported".into())), // Adjustment and fill layers cover the whole canvas. _ => vec![vec![1.0; area.width() as usize * area.height() as usize]], }, diff --git a/crates/engine/src/cmd/file_cmds/batch_run.rs b/crates/engine/src/cmd/file_cmds/batch_run.rs index 0506b9dded..5d500f549f 100644 --- a/crates/engine/src/cmd/file_cmds/batch_run.rs +++ b/crates/engine/src/cmd/file_cmds/batch_run.rs @@ -307,7 +307,15 @@ pub(super) fn batch(s: &mut Session, p: &Value) -> Result { let mut saved: Option> = None; for (id, params) in &steps { if batch_save_point(id) { - saved = Some(scratch.active().ok_or(EngineError::NoDocument)?.doc.clone()); + let st = scratch.active_mut().ok_or(EngineError::NoDocument)?; + saved = Some(st.doc.clone()); + // Batch performs the action's Save / Save As (to its destination), so the + // document is saved there as far as the steps after it see: a Close recorded + // without a save policy (`"save":"ask"`) then closes it. A Save a Copy leaves + // the document as changed as it was. + if id != "file.saveACopy" { + st.saved_revision = st.revision; + } continue; } let r = scratch.execute(id, params.clone())?; diff --git a/crates/engine/src/cmd/file_cmds/tests.rs b/crates/engine/src/cmd/file_cmds/tests.rs index d93c15fd82..03e7fabe89 100644 --- a/crates/engine/src/cmd/file_cmds/tests.rs +++ b/crates/engine/src/cmd/file_cmds/tests.rs @@ -1828,6 +1828,28 @@ fn batch_saves_at_the_recorded_save_so_a_close_after_it_keeps_the_result() { assert!(r["errors"][0]["error"].as_str().unwrap().contains("no active document"), "{r}"); } +/// "…; Save As; Close" with the Close recorded on a clean document (no `Svng`, `"save":"ask"`): +/// Batch performs the Save As, so the document is saved when the Close asks, and every file is +/// written. The Close used to refuse the still-changed scratch document and fail each file. +#[test] +fn batch_save_as_then_close_that_asks_processes_every_file() { + let dir = tmp("batch-save-close-ask"); + let input = vec![png(&dir, "red.png", 8, 8, "#ff0000"), png(&dir, "blue.png", 8, 8, "#0000ff")]; + let out = join(&dir, "out"); + let steps = json!([["image.adjustments.invert", {}], ["file.saveAs", {}], ["file.close", {"save": "ask"}]]); + let r = Session::new().execute("file.automate.batch", json!({"input": input, "output": out, "format": "png", "steps": steps})).unwrap(); + assert_eq!(r["errors"], json!([]), "{r}"); + assert_eq!(r["files"].as_array().unwrap().len(), 2, "{r}"); + let red = photocraft_io::import("red.png", &std::fs::read(join(&out, "red.png")).unwrap()).unwrap().document; + assert_eq!(photocraft_compose::render(&red, Rect::new(1, 1, 2, 2)).px[0], [0.0, 1.0, 1.0, 1.0], "the inverted red"); + // A Save a Copy doesn't save the document itself: the Close that asks still refuses it. + let out = join(&dir, "copy"); + let steps = json!([["image.adjustments.invert", {}], ["file.saveACopy", {}], ["file.close", {"save": "ask"}]]); + let r = Session::new().execute("file.automate.batch", json!({"input": input, "output": out, "format": "png", "steps": steps})).unwrap(); + assert_eq!(r["files"], json!([]), "{r}"); + assert_eq!(r["errors"].as_array().unwrap().len(), 2, "{r}"); +} + /// The document a recorded save marked is the result, not the one left active after it closed. /// Here the action saves and closes a second document it made, then inverts the input; the output /// used to be the inverted input, written in place of the result without an error. diff --git a/crates/engine/src/cmd/layer_multi_cmds/selection.rs b/crates/engine/src/cmd/layer_multi_cmds/selection.rs index a0b5f76a77..1a5916cc06 100644 --- a/crates/engine/src/cmd/layer_multi_cmds/selection.rs +++ b/crates/engine/src/cmd/layer_multi_cmds/selection.rs @@ -159,6 +159,29 @@ pub(crate) fn recorded_step(s: &Session, id: &str, p: Value, pre_selected: Optio } return p; } + if id == "select.loadSelection" { + return recorded_source_layer(s, p, layer_id); + } + p +} + +/// ⌘-clicking another layer's thumbnail loads that layer's transparency. The step refers to the +/// layer the way the reference application records it, by name (or as the Background), so it +/// replays in another document and saves to `.atn`. A layer that the name would not find (a +/// same-named layer above it) keeps its id. +fn recorded_source_layer(s: &Session, mut p: Value, id: LayerId) -> Value { + let Some(doc) = s.active().map(|st| &st.doc) else { return p }; + let Some(layer) = doc.layer(id) else { return p }; + let reference = if doc.background().is_some_and(|b| b.id == id) { + json!({"background": true}) + } else if doc.walk().into_iter().rev().find(|(_, _, l)| l.name == layer.name).is_some_and(|(_, _, l)| l.id == id) { + json!({"name": layer.name}) + } else { + return p; + }; + if let Some(fields) = p.as_object_mut() { + fields.insert("layer".into(), reference); + } p } diff --git a/crates/engine/tests/atn_mapped_identifiers.rs b/crates/engine/tests/atn_mapped_identifiers.rs index 35cce20f21..7b11aeb500 100644 --- a/crates/engine/tests/atn_mapped_identifiers.rs +++ b/crates/engine/tests/atn_mapped_identifiers.rs @@ -150,7 +150,7 @@ fn review_copy_cut_opacity_is_validated_before_any_step_runs() { fn review_layer_transform_uses_the_recorded_anchor() { for depth in [8, 16, 32] { for (code, fx, fy) in - [("Qcs0", 0, 0), ("Qcs1", 1, 0), ("Qcs2", 2, 0), ("Qcs3", 0, 1), ("Qcs4", 1, 1), ("Qcs5", 2, 1), ("Qcs6", 0, 2), ("Qcs7", 1, 2), ("Qcs8", 2, 2)] + [("Qcs0", 0, 0), ("Qcs4", 1, 0), ("Qcs1", 2, 0), ("Qcs7", 0, 1), ("Qcsa", 1, 1), ("Qcs5", 2, 1), ("Qcs3", 0, 2), ("Qcs6", 1, 2), ("Qcs2", 2, 2)] { let d = Descriptor::new("null") .with("FTcs", enm("QCSt", code)) @@ -176,11 +176,15 @@ fn review_layer_transform_uses_the_recorded_anchor() { .unwrap(); replay_saved(&mut s, &action); let state = s.active().unwrap(); - assert_eq!( - state.doc.layer(state.active_layer.unwrap()).unwrap().surface().unwrap().content_bounds(), - photocraft_geom::Rect::new(20 + 5 * fx, 20 + 5 * fy, 30 + 5 * fx, 30 + 5 * fy), - "{code}, depth {depth}" - ); + let bounds = state.doc.layer(state.active_layer.unwrap()).unwrap().surface().unwrap().content_bounds(); + assert_eq!(bounds, photocraft_geom::Rect::new(20 + 5 * fx, 20 + 5 * fy, 30 + 5 * fx, 30 + 5 * fy), "{code}, depth {depth}"); + // Measured in the reference application: Qcs6 keeps the bottom centre fixed, Qcs1 the + // top-right corner. + match code { + "Qcs6" => assert_eq!(bounds, photocraft_geom::Rect::new(25, 30, 35, 40), "depth {depth}"), + "Qcs1" => assert_eq!(bounds, photocraft_geom::Rect::new(30, 20, 40, 30), "depth {depth}"), + _ => {} + } s.execute("edit.undo", json!({})).unwrap(); if code == "Qcs0" { s.edit("Mark rotation", |doc, active| { @@ -377,9 +381,10 @@ fn review_10702_transparency_names_resolve_at_playback() { } } -/// A group has no supported transparency plane and must not overwrite a real selection. +/// A group's transparency loads the whole canvas, as ⌘-clicking its thumbnail always did (the +/// group's combined transparency is not composited yet), rather than failing. #[test] -fn group_transparency_sources_fail_without_changing_selection() { +fn group_transparency_sources_load_the_canvas() { use photocraft_doc::LayerId; use photocraft_geom::Rect; @@ -409,19 +414,21 @@ fn group_transparency_sources_fail_without_changing_selection() { ]), ); let action = recorded(AtnEvent::Code(*b"setd"), descriptor.clone()); - let before = s.active().unwrap().doc.clone(); - let revision = s.active().unwrap().revision; - let history = s.active().unwrap().history.past_len(); let (id, params) = &action.steps[0]; let result = s.execute(id, params.clone()); - let alpha = s.active().unwrap().doc.selection.as_ref().map(|mask| mask.read_region(Rect::from_xywh(0, 0, 2, 1))); - assert!(result.is_err(), "group {name:?} returned {result:?} with selection {alpha:?}"); - assert_eq!(s.active().unwrap().doc, before); - assert_eq!(s.active().unwrap().revision, revision); - assert_eq!(s.active().unwrap().history.past_len(), history); + let doc = &s.active().unwrap().doc; + let alpha = doc.selection.as_ref().map(|mask| mask.read_region(doc.bounds())).unwrap_or(vec![1.0; 2]); + assert!(result.is_ok(), "group {name:?} returned {result:?}"); + assert_eq!(alpha, vec![1.0, 1.0], "group {name:?}"); + // ⌘-click on the group's thumbnail (by id) loads the same. + s.execute("select.deselect", json!({})).unwrap(); + s.execute("select.loadSelection", json!({"channel":"transparency","layer":group.0})).unwrap(); + let doc = &s.active().unwrap().doc; + assert_eq!(doc.selection.as_ref().map(|mask| mask.read_region(doc.bounds())).unwrap_or(vec![1.0; 2]), vec![1.0, 1.0]); assert_eq!(saved(&action).descriptor, Some(descriptor)); // The group's explicit mask remains a valid source. + s.execute("select.loadSelection", json!({"channel":"transparency","layer":"Child"})).unwrap(); s.execute("layer.layerMask.revealSelection", json!({"layer":group.0})).unwrap(); assert!(s.active().unwrap().doc.selection.is_none()); s.execute("select.loadSelection", json!({"channel":"mask","layer":params["layer"]})).unwrap(); @@ -557,7 +564,8 @@ fn recorded_adjustment_opacity_keeps_gradient_settings_and_defaults() { } let written = saved(&action).descriptor.unwrap(); let Some(D::Descriptor(using)) = written.get("Usng") else { panic!("missing adjustment metadata") }; - assert!(matches!(using.get("Opct"),Some(D::UnitFloat{unit,value}) if unit==b"#Prc" && (*value-179.0/255.0*100.0).abs()<1e-10)); + // The recorded 70 % saves back as 70 %, not as the 8-bit level 179/255 (70.196 %). + assert!(matches!(using.get("Opct"), Some(D::UnitFloat { unit, value }) if unit == b"#Prc" && *value == 70.0), "{:?}", using.get("Opct")); for depth in [8, 16, 32] { let mut s = Session::new(); s.execute("file.new", json!({"width":4,"height":3,"depth":depth})).unwrap(); From 563666d69a1e90f6f1878fca781c156481baa794 Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 2 Nov 2026 02:55:32 +0100 Subject: [PATCH 147/216] Integration: Lens Flare dialog centre and Clouds seed 0 on Save Actions (#10620) The Lens Flare params doc said "centerX":number=0.5, which the filter dialog reads as an integer field defaulting to 0: Filter > Render > Lens Flare put the flare in the top-left corner and an imported 0.4 centre rounded to 0. The doc is back to 0..1=0.5; the engine still plays off-canvas centres unclamped (a recorded 1.4 centre now has a playback test). A Clouds or Difference Clouds step recorded through PhotoCraft's dialog carries {"seed":0}, and Save Actions refused it. The event has no seed key; seed 0 is what the imported empty step plays as, so it is dropped on export. Any other seed still plays differently and is still refused. Co-authored-by: yoriswind <164435053+yoriswind@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- .../engine/src/cmd/atn_cmds/render_tests.rs | 22 +++++++++++++++++++ .../engine/src/cmd/atn_cmds/steps_render.rs | 7 ++++-- crates/engine/src/filters_ext.rs | 2 +- .../src/dialogs/filter_dialog/tests.rs | 12 ++++++++++ 4 files changed, 40 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/atn_cmds/render_tests.rs b/crates/engine/src/cmd/atn_cmds/render_tests.rs index 06b3cf88d9..21f87c286e 100644 --- a/crates/engine/src/cmd/atn_cmds/render_tests.rs +++ b/crates/engine/src/cmd/atn_cmds/render_tests.rs @@ -99,6 +99,26 @@ fn clouds_events_map_and_refuse_unrepresentable_native_settings() { for p in [json!({"seed": 7}), json!({"foreground": "#ff0000"}), json!({"background": "#0000ff"}), json!({"layer": 7}), json!({"target": "mask"})] { assert!(export_item(id, &p, "Render").is_none(), "{id}: {p}"); } + // The Clouds dialog records `seed: 0`, which is what the empty imported step plays as. + for p in [json!({"seed": 0}), json!({"seed": 0.0}), json!({"seed": 0, "target": "pixels"})] { + assert_eq!(export_item(id, &p, "Render").map(|item| item.descriptor), Some(Some(desc("null"))), "{id}: {p}"); + } + for p in [json!({"seed": -1}), json!({"seed": 0.5}), json!({"seed": "0"}), json!({"seed": 0, "foreground": "#ff0000"})] { + assert!(export_item(id, &p, "Render").is_none(), "{id}: {p}"); + } + } +} + +/// A recorded dialog step saves through the real Save Actions command. +#[test] +fn clouds_recorded_through_the_dialog_save() { + for id in ["filter.render.clouds", "filter.render.differenceClouds"] { + let mut s = Session::new(); + s.actions.list.push(Action::new("Render", vec![(id.into(), json!({"seed": 0}))])); + let saved = s.execute("actions.save", json!({"action": "Render"})).unwrap(); + assert_eq!(saved["skipped"], json!([]), "{id}"); + let back = import(&photocraft_paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap()).unwrap(); + assert_eq!(back.actions[0].steps, vec![(id.into(), json!({}))], "{id}"); } } @@ -238,6 +258,8 @@ fn render_actions_match_direct_filters_and_undo() { (*b"DfrC", desc("null"), "filter.render.differenceClouds", json!({})), (*b"Fbrs", fibers(23, 6, -7), "filter.render.fibers", json!({"variance": 23, "strength": 6, "seed": -7})), (*b"LnsF", flare(240, [5.0, -5.0], "Nkn1"), "filter.render.lensFlare", json!({"brightness": 240, "centerX": 5.0, "centerY": -5.0, "lens": "prime105"})), + // Just right of the canvas: the dialog's 0..1 range does not clamp a recorded centre. + (*b"LnsF", flare(100, [1.4, 0.25], "Zm"), "filter.render.lensFlare", json!({"brightness": 100, "centerX": 1.4, "centerY": 0.25, "lens": "zoom"})), ]; for mode in ["rgb", "gray"] { for depth in [8, 16, 32] { diff --git a/crates/engine/src/cmd/atn_cmds/steps_render.rs b/crates/engine/src/cmd/atn_cmds/steps_render.rs index 567e6ccb6d..b4911b6207 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_render.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_render.rs @@ -42,9 +42,12 @@ pub(super) fn clouds_from(id: &str, d: &Descriptor) -> Mapped { if d.items.is_empty() { step(id, json!({})) } else { Mapped::Unsupported { harmless: false } } } -/// Explicit seeds, colors and layer references have no fields in these ATN events. +/// Explicit colors and layer references have no fields in these ATN events. A seed has none +/// either, but `seed: 0` (what PhotoCraft's dialog records) is what the imported empty step +/// plays as, so it is dropped; any other seed would play differently and is refused. pub(super) fn clouds_to(_: &str, p: &Value) -> Option { - params(p, &[]).then(|| desc("null")) + let seed_replays = p.get("seed").is_none_or(|s| s.as_f64() == Some(0.0)); + (params(p, &["seed"]) && seed_replays).then(|| desc("null")) } /// Capture a missing recorded seed once; later playback and export reuse the stored bits. diff --git a/crates/engine/src/filters_ext.rs b/crates/engine/src/filters_ext.rs index a15004481f..963e04e729 100644 --- a/crates/engine/src/filters_ext.rs +++ b/crates/engine/src/filters_ext.rs @@ -725,7 +725,7 @@ pub fn specs() -> Vec { "filter.render.lensFlare", "Lens Flare…", ["Filter", "Render"], - r##"{"brightness":10..300=100,"centerX":number=0.5,"centerY":number=0.5,"lens":"zoom|prime35|prime105|moviePrime"} (normalized center coordinates; positions outside the image are supported)"## + r##"{"brightness":10..300=100,"centerX":0..1=0.5,"centerY":0..1=0.5,"lens":"zoom|prime35|prime105|moviePrime"} (normalized centre: 0..1 is the canvas; agents and actions may pass values outside it, which place the flare off the canvas unclamped)"## ), cmd!( "filter.render.lightingEffects", diff --git a/crates/ui-egui/src/dialogs/filter_dialog/tests.rs b/crates/ui-egui/src/dialogs/filter_dialog/tests.rs index 92daea2528..eb1691c6f0 100644 --- a/crates/ui-egui/src/dialogs/filter_dialog/tests.rs +++ b/crates/ui-egui/src/dialogs/filter_dialog/tests.rs @@ -22,6 +22,18 @@ fn a_unit_after_a_range_bound_does_not_reset_the_range_to_its_fallback() { assert!(matches!(size.map(|p| p.kind), Some(Kind::Range { max, .. }) if max == 5000.0)); } +#[test] +fn lens_flare_dialog_centre_is_a_slider_from_0_to_1_defaulting_to_the_middle() { + // `"centerX":number=0.5` read as an integer field defaulting to 0: the menu's Lens Flare + // landed in the top-left corner and an imported 0.4 centre rounded to 0. + let spec = photocraft_engine::commands::find("filter.render.lensFlare").expect("filter.render.lensFlare is registered"); + let params = parse_spec(spec.params); + for key in ["centerX", "centerY"] { + let kind = params.iter().find(|p| p.key == key).map(|p| p.kind.clone()); + assert_eq!(kind, Some(Kind::Range { min: 0.0, max: 1.0, default: 0.5 }), "{key}"); + } +} + #[test] fn color_settings_working_spaces_list_installed_profiles_of_their_space() { // #6323: a press condition installed in the system can be the CMYK working space, as in From cf30adf2a285bbcb1c90352b12f2de63f2a56bcf Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 2 Nov 2026 02:25:27 +0100 Subject: [PATCH 148/216] Integration: keep Shift-click and Lmnc channel selects, record panel colour clicks by name (#10616) - A Shift-click channel select (`slct` Chnl.Chnl.Cyn with `Extd` true) imported as a plain single-channel `channel.target`, so playback targeted Cyan alone and Save dropped `Extd`. It now stays a kept Photoshop step (only `Extd` absent or false maps). - `select Chnl.Chnl.Lmnc` went through `channel_of`'s legacy fallback, targeted Lightness and saved back as `Lght `. The select importer now uses the exact CHANNELS lookup, so it stays a kept step and round-trips unchanged. - The Channels panel recorded a colour click as `{"channel":{"color":0}}`, which the new export rejects, so Save Actions of a PhotoCraft recording failed. A colour row click now sends the mode's lowercase colour name (`{"color":"cyan"}`), which targets the same channel and exports as the component select; other rows keep their reference. Co-authored-by: yoriswind <164435053+yoriswind@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/atn_cmds/steps_layer.rs | 7 ++- crates/engine/src/cmd/atn_cmds/tests.rs | 23 +++++-- crates/ui-egui/src/panels/channels_panel.rs | 13 +++- .../src/panels/channels_panel/tests.rs | 62 +++++++++++++++++++ 4 files changed, 96 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/atn_cmds/steps_layer.rs b/crates/engine/src/cmd/atn_cmds/steps_layer.rs index a54c08f4cd..244f941447 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_layer.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_layer.rs @@ -6,7 +6,7 @@ use super::descriptors::{ use super::events::pixel_target; use super::fields::{F, Kind, Mapped, fields_to, none, step, steps}; use super::steps_make::is_tool_class; -use super::tables::{CHANNELS, FILL_CONTENTS, INTERPOLATION, MENU_ITEMS, STROKE_LOCATION, blend_label, blend_ps, channel_of, lookup, lookup_back}; +use super::tables::{CHANNELS, FILL_CONTENTS, INTERPOLATION, MENU_ITEMS, STROKE_LOCATION, blend_label, blend_ps, lookup, lookup_back}; use super::{D, Descriptor, Id, Map, ReferenceItem, UnicodeString, Value, json}; /// Translate supported selection targets, retaining unknown references as Photoshop steps. @@ -32,9 +32,12 @@ pub(super) fn select_from(d: &Descriptor) -> Mapped { "Trgt" => Mapped::Unsupported { harmless: true }, _ if r.len() == 1 && d.get("selectionModifier").is_none() + // Shift-click (`Extd` true) adds the channel to the selection: kept verbatim. + && !matches!(d.get("Extd"), Some(v) if v != &D::Boolean(false)) && matches!(r.first(), Some(ReferenceItem::Enumerated { type_id, .. }) if type_id.is("Chnl")) => { - channel_of(&o).map_or(Mapped::Unsupported { harmless: false }, |channel| step("channel.target", json!({"channel": {"color": channel}}))) + // The exact code only: the legacy `Lmnc` would save back as `Lght`. + lookup(CHANNELS, &o).map_or(Mapped::Unsupported { harmless: false }, |channel| step("channel.target", json!({"channel": {"color": channel}}))) } _ => Mapped::Unsupported { harmless: false }, }, diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index 205d7efd26..84e375843a 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -3213,14 +3213,18 @@ fn select_color_components_preserve_unsupported_selection_shapes() { ReferenceItem::Enumerated { class: class("Chnl"), type_id: code("Chnl"), value: code("Cyn") }, ReferenceItem::Identifier { class: class("Dcmn"), id: 7 }, ]; - for selection in [ - desc("null").with("null", channel_ord("Xxxx")), - desc("null").with("null", channel_ref(Some("cyan"))).with("selectionModifier", enm("selectionModifierType", "addToSelection")), - desc("null").with("null", D::Reference(chained)), - desc("null").with("null", D::Reference(vec![ReferenceItem::Enumerated { class: class("Chnl"), type_id: code("Ordn"), value: code("Cyn") }])), + for (mode, selection) in [ + ("cmyk", desc("null").with("null", channel_ord("Xxxx"))), + ("cmyk", desc("null").with("null", channel_ref(Some("cyan"))).with("selectionModifier", enm("selectionModifierType", "addToSelection"))), + ("cmyk", desc("null").with("null", D::Reference(chained))), + ("cmyk", desc("null").with("null", D::Reference(vec![ReferenceItem::Enumerated { class: class("Chnl"), type_id: code("Ordn"), value: code("Cyn") }]))), + // Shift-click adds the channel to the selection; playing it as a plain select would lose that. + ("cmyk", desc("null").with("null", channel_ref(Some("cyan"))).with("Extd", D::Boolean(true))), + // The legacy `Lmnc` code would otherwise target Lightness and save back as `Lght`. + ("lab", desc("null").with("null", channel_ord("Lmnc"))), ] { let mut s = Session::new(); - s.execute("file.new", json!({"width": 2, "height": 1, "mode": "cmyk"})).unwrap(); + s.execute("file.new", json!({"width": 2, "height": 1, "mode": mode})).unwrap(); let data = set_of("Unknown", vec![("Pick", vec![item("select", "Select", Some(selection.clone()))])]); let loaded = s.execute("actions.load", json!({"data": data})).unwrap(); assert_eq!(loaded["unsupported"].as_array().unwrap().len(), 1); @@ -3247,6 +3251,13 @@ fn select_color_components_preserve_unsupported_selection_shapes() { let gray = set_of("Gray", vec![("Pick", vec![item("select", "Select", Some(desc("null").with("null", channel_ord("Gry"))))])]); let bytes = photocraft_paint::tile::b64_decode(&gray).unwrap(); assert_eq!(import(&bytes).unwrap().actions[0].steps[0], ("channel.target".into(), json!({"channel": "composite"}))); + // A plain click (`Extd` false) is still an ordinary single-channel select. + let plain = set_of( + "Plain", + vec![("Pick", vec![item("select", "Select", Some(desc("null").with("null", channel_ref(Some("cyan"))).with("Extd", D::Boolean(false))))])], + ); + let bytes = photocraft_paint::tile::b64_decode(&plain).unwrap(); + assert_eq!(import(&bytes).unwrap().actions[0].steps[0], ("channel.target".into(), json!({"channel": {"color": "cyan"}}))); } /// A CMYK component cannot retarget a same-named alpha channel when replayed on RGB. diff --git a/crates/ui-egui/src/panels/channels_panel.rs b/crates/ui-egui/src/panels/channels_panel.rs index 73b80e5152..3baa1b99bf 100644 --- a/crates/ui-egui/src/panels/channels_panel.rs +++ b/crates/ui-egui/src/panels/channels_panel.rs @@ -34,6 +34,17 @@ impl Row { Row::LayerMask => json!("mask"), } } + + /// The `channel.target` reference a click records: a colour row by the mode's colour name, + /// which a recorded action can save as Photoshop's component select (an index can't be). + fn target_reference(self, mode: photocraft_doc::ColorMode) -> Value { + let names = channel_cmds::color_names(mode); + match self { + Row::QuickMask => json!("composite"), + Row::Color(k) if names.len() > 1 => names.get(k).map_or_else(|| self.reference(), |name| json!({ "color": name.to_ascii_lowercase() })), + _ => self.reference(), + } + } } /// ⌘ = new selection, ⌘⇧ add, ⌘⌥ subtract, ⌘⌥⇧ intersect (Photoshop's thumbnail ⌘-clicks). @@ -113,7 +124,7 @@ fn handle_channel_row_click(ui: &egui::Ui, row: &mut RowInteractions<'_>) { if matches!(row.row, Row::Composite | Row::Color(_)) { *row.mask_click = Some(false); } - let target = if row.row == Row::QuickMask { json!("composite") } else { row.row.reference() }; + let target = row.row.target_reference(row.doc.pixel_format().mode); row.actions.push(("channel.target".into(), json!({ "channel": target, "extend": modifiers.shift && matches!(row.row, Row::Color(_)) }))); } diff --git a/crates/ui-egui/src/panels/channels_panel/tests.rs b/crates/ui-egui/src/panels/channels_panel/tests.rs index 1dd7fb7db1..3d57762d03 100644 --- a/crates/ui-egui/src/panels/channels_panel/tests.rs +++ b/crates/ui-egui/src/panels/channels_panel/tests.rs @@ -378,3 +378,65 @@ fn the_layer_mask_row_on_the_trash_asks_first() { let st = h.state().session.active().unwrap(); assert!(st.doc.layer(photocraft_doc::LayerId(masked)).unwrap().mask.is_some(), "nothing deleted yet"); } + +/// A colour channel click recorded into an action saves as Photoshop's component select and +/// targets the same channel as the index did. +#[test] +fn recorded_colour_clicks_save_as_component_selects() { + use photocraft_doc::ColorMode; + for (mode, color_mode, count) in [("rgb", ColorMode::Rgb, 3), ("cmyk", ColorMode::Cmyk, 4), ("lab", ColorMode::Lab, 3)] { + for k in 0..count { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 2, "height": 1, "mode": mode})).unwrap(); + let by_index = s.execute("channel.target", json!({"channel": Row::Color(k).reference(), "extend": false})).unwrap(); + s.execute("channel.target", json!({"channel": "composite"})).unwrap(); + let params = json!({"channel": Row::Color(k).target_reference(color_mode), "extend": false}); + assert_eq!(s.execute("channel.target", params.clone()).unwrap(), by_index, "{mode}/{k}"); + s.actions.list.push(photocraft_engine::actions_cmds::Action::new("Click", vec![("channel.target".into(), params)])); + let saved = s.execute("actions.save", json!({"action": "Click"})).unwrap_or_else(|e| panic!("{mode}/{k}: {e}")); + assert_eq!(saved["skipped"], json!([])); + let loaded = s.execute("actions.load", json!({"data": saved["data"]})).unwrap(); + assert_eq!(loaded["unsupported"], json!([]), "{mode}/{k}"); + let name = photocraft_engine::channel_cmds::color_names(color_mode)[k].to_ascii_lowercase(); + assert_eq!(s.actions.list.last().unwrap().steps, vec![("channel.target".to_string(), json!({"channel": {"color": name}}))]); + } + } + // Rows the colour names can't express keep their reference. + assert_eq!(Row::Color(0).target_reference(ColorMode::Grayscale), Row::Color(0).reference()); + assert_eq!(Row::Alpha(1).target_reference(ColorMode::Rgb), json!(1)); + assert_eq!(Row::QuickMask.target_reference(ColorMode::Rgb), json!("composite")); +} + +/// A real click on a colour row while recording records the colour by name, and the recorded +/// action saves strictly and reloads as the same step. +#[test] +fn recording_a_colour_row_click_saves_as_photoshop_select() { + use egui::{Event, Modifiers, PointerButton}; + for (mode, label, name) in [("rgb", "Red", "red"), ("cmyk", "Cyan", "cyan"), ("lab", "Lightness", "lightness")] { + let (mut h, _) = channels_harness(); + if mode != "rgb" { + h.state_mut().run(&format!("image.mode.{mode}"), json!({})).unwrap(); + h.run_steps(2); + } + h.state_mut().run("actions.record", json!({"name": "Click"})).unwrap(); + let pos = at(&h, label); + h.hover_at(pos); + h.run_steps(1); + for pressed in [true, false] { + h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.run_steps(1); + } + h.run_steps(3); + h.state_mut().run("actions.stop", json!({})).unwrap(); + let s = &mut h.state_mut().session; + let recorded = s.actions.list.iter().find(|a| a.name == "Click").unwrap().steps.clone(); + let step = ("channel.target".to_string(), json!({"channel": {"color": name}, "extend": false})); + assert_eq!(recorded, vec![step], "{mode}"); + let saved = s.execute("actions.save", json!({"action": "Click"})).unwrap_or_else(|e| panic!("{mode}: {e}")); + assert_eq!(saved["skipped"], json!([]), "{mode}"); + let loaded = s.execute("actions.load", json!({"data": saved["data"]})).unwrap(); + assert_eq!(loaded["unsupported"], json!([]), "{mode}"); + let reloaded = &s.actions.list.last().unwrap().steps; + assert_eq!(reloaded, &vec![("channel.target".to_string(), json!({"channel": {"color": name}}))], "{mode}"); + } +} From 52cc86351bdcbc757e285f98fd9f68efda837b2a Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 2 Nov 2026 03:07:01 +0100 Subject: [PATCH 149/216] Integration: Trace levels clamp to 0..255, dialog Tiles/Pointillize save (#10618) Trace Contour read any i32 level, but a recorded step carries a whole number 0..255 that the reference application clamps; import now clamps (Lvl -1 -> 0, 300 -> 255) and export refuses levels outside 0..255. Save Actions refused every Tiles/Pointillize step recorded through the filter dialog, because the dialog adds foreground/background colours the recorded event does not carry. Export now drops them (playback uses the current colours, as the reference application does). Tiles count is rounded on export like the native command rounds it (17.4 -> TlNm 17), and null params export the defaults instead of being refused. Co-authored-by: yoriswind <164435053+yoriswind@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- .../engine/src/cmd/atn_cmds/steps_stylize.rs | 24 +++++-- .../engine/src/cmd/atn_cmds/stylize_tests.rs | 68 ++++++++++++++++--- 2 files changed, 79 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/cmd/atn_cmds/steps_stylize.rs b/crates/engine/src/cmd/atn_cmds/steps_stylize.rs index 126eb5d29b..611fb9e694 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_stylize.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_stylize.rs @@ -23,8 +23,8 @@ const EXTRUDE: &[Field] = &[ Field("ExtR", "depthMode", Kind::Enum("ExtR", &[("Rndm", "random"), ("LvlB", "levelBased")], "random")), ]; const TRACE: &[Field] = &[ - // Recorded levels stay intact; playback alone clamps to the measured boundary image. - Field("Lvl ", "level", Kind::Long(128, i32::MIN, i32::MAX)), + // Whole levels 0..255; the reference application's dialog clamps to the nearest. + Field("Lvl ", "level", Kind::Long(128, 0, 255)), Field("Edg ", "edge", Kind::Enum("CntE", &[("Lwr ", "lower"), ("Upr ", "upper")], "upper")), ]; const WIND: &[Field] = &[ @@ -164,12 +164,19 @@ pub(super) fn from(id: &str, d: &Descriptor) -> Mapped { /// Write every measured key, preserving native defaults and rejecting native-only settings. pub(super) fn to(id: &str, p: &Value) -> Option { let fields = fields(id)?; - let params = p.as_object()?; + // No params mean the native defaults, as for the other mapped events. + let empty = Map::new(); + let params = if p.is_null() { &empty } else { p.as_object()? }; + // The filter dialog freezes the current colours; the recorded event has none and plays with the current ones. + let colours = matches!(id, "filter.stylize.tiles" | "filter.pixelate.pointillize"); let seeded = matches!(id, "filter.stylize.extrude" | "filter.stylize.wind" | "filter.stylize.tiles" | "filter.pixelate.mezzotint" | "filter.pixelate.pointillize"); if !super::events::pixel_target(p) || params.iter().any(|(key, value)| { - !fields.iter().any(|f| f.1 == key.as_str()) && key != "target" && !(key == "seed" && seeded && (value.is_null() || value.as_f64() == Some(0.0))) + !fields.iter().any(|f| f.1 == key.as_str()) + && key != "target" + && !(colours && matches!(key.as_str(), "foreground" | "background")) + && !(key == "seed" && seeded && (value.is_null() || value.as_f64() == Some(0.0))) }) { return None; @@ -182,7 +189,14 @@ pub(super) fn to(id: &str, p: &Value) -> Option { ("filter.stylize.traceContour", "edge") => json!("lower"), _ => kind.default(), }; - d = d.with(key, kind.write(p.get(param).filter(|v| !v.is_null()).unwrap_or(&default))?); + let mut value = p.get(param).filter(|v| !v.is_null()).unwrap_or(&default).clone(); + // The native command rounds the tile count, so a fractional dialog value plays as the whole one. + if (id, *param) == ("filter.stylize.tiles", "count") + && let Some(n) = value.as_f64().filter(|n| n.is_finite()) + { + value = json!(n.round()); + } + d = d.with(key, kind.write(&value)?); } Some(d) } diff --git a/crates/engine/src/cmd/atn_cmds/stylize_tests.rs b/crates/engine/src/cmd/atn_cmds/stylize_tests.rs index 7b3cb156db..9599385536 100644 --- a/crates/engine/src/cmd/atn_cmds/stylize_tests.rs +++ b/crates/engine/src/cmd/atn_cmds/stylize_tests.rs @@ -184,6 +184,7 @@ fn numeric_imports_clamp_measured_ranges() { for (code, key, param, min, max) in [ ("Extr", "ExtS", "size", 2, 255), ("Extr", "ExtD", "depth", 1, 255), + ("TrcC", "Lvl ", "level", 0, 255), ("Tls ", "TlNm", "count", 1, 99), ("Tls ", "TlOf", "maxOffset", 1, 99), ("ClrH", "Rds ", "maxRadius", 4, 127), @@ -208,21 +209,22 @@ fn numeric_imports_clamp_measured_ranges() { } } -/// Trace levels retain their recorded integers; only playback uses the measured boundary image. +/// Trace levels are whole numbers 0..255: imports clamp to that range and play like the clamped direct command. #[test] -fn trace_levels_round_trip_and_play_at_the_boundary() { +fn trace_levels_clamp_on_import_and_play_at_the_boundary() { for level in [i32::MIN, -1, 0, 128, 255, 256, 300, i32::MAX] { + let clamped = level.clamp(0, 255); for (edge, name) in [("Lwr ", "lower"), ("Upr ", "upper")] { let descriptor = desc("null").with("Lvl ", D::Integer(level)).with("Edg ", enm("CntE", edge)); - let mut item = code_item("TrcC", descriptor.clone()); + let mut item = code_item("TrcC", descriptor); item.with_dialog = true; let mut imported = load_items(vec![item]); assert_eq!(imported.actions.list[0].steps[0].0, "filter.stylize.traceContour", "{level}/{name}"); - assert_eq!(imported.actions.list[0].steps[0].1["level"], level); + assert_eq!(imported.actions.list[0].steps[0].1["level"], clamped); let saved = saved_items(&mut imported); - assert_eq!(saved[0].descriptor.as_ref().unwrap().to_bytes(), descriptor.to_bytes()); + let expected = desc("null").with("Lvl ", D::Integer(clamped)).with("Edg ", enm("CntE", edge)); + assert_eq!(saved[0].descriptor.as_ref().unwrap().to_bytes(), expected.to_bytes()); assert!(saved[0].with_dialog); - assert!(export_item("filter.stylize.traceContour", &json!({"level":level,"edge":name}), "Group D").is_some()); for depth in [8, 16, 32] { let mut direct = Session::new(); prepare_pattern(&mut imported, depth, "rgb"); @@ -234,8 +236,11 @@ fn trace_levels_round_trip_and_play_at_the_boundary() { } } } - for level in [255.5, 2147483648.0] { - assert!(export_item("filter.stylize.traceContour", &json!({"level":level}), "Group D").is_none()); + for level in [0, 128, 255] { + assert!(export_item("filter.stylize.traceContour", &json!({"level":level}), "Group D").is_some(), "{level}"); + } + for level in [json!(-1), json!(256), json!(255.5), json!(2147483648.0)] { + assert!(export_item("filter.stylize.traceContour", &json!({"level":level}), "Group D").is_none(), "{level}"); } } @@ -324,6 +329,10 @@ fn exports_materialize_native_defaults_and_reject_unrepresentable_settings() { ("target", json!("channel:red")), ("unknown", json!(true)), ] { + // Tiles and Pointillize drop the dialog's frozen colours (tested below). + if matches!(key, "foreground" | "background") && matches!(fixture.code, "Tls " | "Pntl") { + continue; + } let mut params = fixture.defaults.clone(); params[key] = value; let mut s = Session::new(); @@ -429,3 +438,46 @@ fn native_and_measured_defaults_stay_distinct_through_playback() { assert_eq!(pixels(&imported), pixels(&native), "{id}"); } } + +/// Dialog recordings of Tiles and Pointillize carry the current colours; the recorded event has none. +#[test] +fn tiles_and_pointillize_exports_drop_dialog_colours() { + for (id, params) in + [("filter.stylize.tiles", json!({"count":17,"maxOffset":23,"fill":"foreground"})), ("filter.pixelate.pointillize", json!({"cellSize":19}))] + { + let plain = export_item(id, ¶ms, "Group D").unwrap().descriptor; + let mut coloured = params.clone(); + coloured["foreground"] = json!([0.2, 0.4, 0.6, 1.0]); + coloured["background"] = json!("#ffffff"); + assert_eq!(export_item(id, &coloured, "Group D").unwrap().descriptor, plain, "{id}"); + let mut s = Session::new(); + s.actions.list.push(Action::new("A", vec![(id.into(), coloured)])); + assert!(s.execute("actions.save", json!({"action":"A"})).is_ok(), "{id}"); + } +} + +/// The native Tiles command rounds its count, so a fractional dialog value exports as the whole count it plays. +#[test] +fn tiles_count_rounds_like_the_native_command() { + for (count, expected) in [(17.4, 17), (17.5, 18), (1.2, 1), (98.6, 99)] { + let d = export_item("filter.stylize.tiles", &json!({"count":count}), "Group D").unwrap().descriptor.unwrap(); + assert_eq!(d.get("TlNm"), Some(&D::Integer(expected)), "{count}"); + } + for count in [0.4, 99.5] { + assert!(export_item("filter.stylize.tiles", &json!({"count":count}), "Group D").is_none(), "{count}"); + } + assert!(export_item("filter.stylize.tiles", &json!({"maxOffset":10.5}), "Group D").is_none()); +} + +/// Null params mean the native defaults, as for the other mapped events. +#[test] +fn null_params_export_native_defaults() { + for id in ["filter.stylize.findEdges", "filter.stylize.tiles", "filter.stylize.traceContour"] { + assert_eq!( + export_item(id, &Value::Null, "Group D").map(|item| item.descriptor), + export_item(id, &json!({}), "Group D").map(|item| item.descriptor), + "{id}" + ); + assert!(export_item(id, &Value::Null, "Group D").is_some(), "{id}"); + } +} From d97a1c2efa7755cb9ef67dbec5886d7f29d70d78 Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 2 Nov 2026 03:10:15 +0100 Subject: [PATCH 150/216] Integration: resolve #10702 and #10628 against the batch (layer selection, event lookup, Vibrance) - steps_layer.rs select_from/select_to: #10753's layer references by name, index and ordinal (recorded_layer_target / recorded_layer_ref) together with #10702's property references and the zero document offset it refuses; the line merge had interleaved the two functions. - layer_params.rs explicit_layer: #10753's group-end arm had lost its closing brace next to #10702's Background arm. - events.rs event_for: the batch's lookup (by code, else by non-empty name, legacy codes) kept; #10628 renamed IntW/Dstr to intersectWith/distribute, and the run-time names the reference application reports (interfaceWhite, distort) still load through NAME_ALIASES (test extended). - Vibrance: one event, saved as the string event `vibrance` (#10702, from #8659) with the `vibrance` class main writes (#8534); `Vbrn` still loads. #10702's round-trip test expects that class now (the top-level class does not change what the reference application plays). - #10702's tests build AtnFile with the `encoding` field main added (#8327). All engine tests pass. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/atn_cmds.rs | 2 +- crates/engine/src/cmd/atn_cmds/events.rs | 16 ++++++------- .../src/cmd/atn_cmds/selection_tests.rs | 3 +++ crates/engine/src/cmd/atn_cmds/steps_layer.rs | 24 +++++++------------ crates/engine/src/cmd/layer_params.rs | 1 + crates/engine/tests/atn_mapped_identifiers.rs | 7 +++++- .../tests/image_size_scale_styles_atn.rs | 1 + 7 files changed, 29 insertions(+), 25 deletions(-) diff --git a/crates/engine/src/cmd/atn_cmds.rs b/crates/engine/src/cmd/atn_cmds.rs index f2f7766aae..ffed9bd30b 100644 --- a/crates/engine/src/cmd/atn_cmds.rs +++ b/crates/engine/src/cmd/atn_cmds.rs @@ -38,9 +38,9 @@ mod steps_image; mod steps_layer; mod steps_make; -mod steps_selection; mod steps_other; mod steps_render; +mod steps_selection; mod tables; #[cfg(test)] diff --git a/crates/engine/src/cmd/atn_cmds/events.rs b/crates/engine/src/cmd/atn_cmds/events.rs index 3f00469554..6902142033 100644 --- a/crates/engine/src/cmd/atn_cmds/events.rs +++ b/crates/engine/src/cmd/atn_cmds/events.rs @@ -489,8 +489,9 @@ pub(super) const EVENTS: &[Event] = &[ adjustment_event!("Pstr", "Pstr", "posterize", "Posterize"), adjustment_event!("Invr", "Invr", "invert", "Invert"), adjustment_event!("ClrB", "ClrB", "colorBalance", "Color Balance"), - adjustment_event!("Vbrn", "vibrance", "vibrance", "Vibrance"), - adjustment_event!("", "Vbrn", "vibrance", "Vibrance"), + // Written as the string event the reference application runs, with the `vibrance` class it + // records; the old `Vbrn` code still loads through LEGACY_CODES. + adjustment_event!("", "vibrance", "vibrance", "Vibrance"), adjustment_event!("Exps", "Exps", "exposure", "Exposure"), // Photoshop runs Photo Filter by name only: `PhtF` is the class, not an event (#8301). adjustment_event!("", "PhtF", "photoFilter", "Photo Filter"), @@ -851,6 +852,10 @@ const LEGACY_CODES: &[(&str, &str)] = &[ ("GdMp", "gradientMapEvent"), ]; +/// Older string names a step can carry for an event that is listed under another name: the +/// reference application reports `IntW` and `Dstr` as `interfaceWhite` and `distort` at run time. +const NAME_ALIASES: &[(&str, &str)] = &[("interfaceWhite", "intersectWith"), ("distort", "distribute")]; + pub(super) fn event_for(item: &AtnEvent) -> Option<&'static Event> { let id = item.id(); let by_name = |name: &str| EVENTS.iter().find(|e| !e.name.is_empty() && e.name == name); @@ -859,13 +864,8 @@ pub(super) fn event_for(item: &AtnEvent) -> Option<&'static Event> { .iter() .find(|e| !e.code.is_empty() && e.code.trim_end() == id.trim_end()) .or_else(|| LEGACY_CODES.iter().find(|(code, _)| *code == id).and_then(|(_, name)| by_name(name))), - AtnEvent::Name(_) => by_name(&id), + AtnEvent::Name(_) => by_name(&id).or_else(|| NAME_ALIASES.iter().find(|(old, _)| *old == id).and_then(|(_, name)| by_name(name))), } - let find = |id: &str| EVENTS.iter().find(|e| (!e.code.is_empty() && e.code.trim_end() == id.trim_end()) || (!e.name.is_empty() && e.name == id)); - find(&id).or_else(|| match item { - AtnEvent::Code(_) => LEGACY_CODES.iter().find(|(code, _)| *code == id).and_then(|(_, name)| find(name)), - AtnEvent::Name(_) => None, - }) } /// The event `id` exports as: the first event listing it whose converter accepts the params diff --git a/crates/engine/src/cmd/atn_cmds/selection_tests.rs b/crates/engine/src/cmd/atn_cmds/selection_tests.rs index 3d8c570529..66c99248c6 100644 --- a/crates/engine/src/cmd/atn_cmds/selection_tests.rs +++ b/crates/engine/src/cmd/atn_cmds/selection_tests.rs @@ -193,6 +193,9 @@ fn group_f_string_ids_map_like_their_codes() { ("IntW", "intersectWith", rectangle([30.0, 30.0, 120.0, 120.0], false)), ("Dstr", "distribute", desc("null").with("null", target_ref("Lyr ")).with("Usng", enm("ADSt", "AdLf"))), ("MrgL", "mergeLayers", desc("null")), + // The run-time names the reference application reports for IntW and Dstr. + ("IntW", "interfaceWhite", rectangle([30.0, 30.0, 120.0, 120.0], false)), + ("Dstr", "distort", desc("null").with("null", target_ref("Lyr ")).with("Usng", enm("ADSt", "AdLf"))), ] { let by_code = import(&file(AtnEvent::Code(id.as_bytes().try_into().unwrap()), d.clone())).unwrap(); let by_name = import(&file(AtnEvent::name(name), d)).unwrap(); diff --git a/crates/engine/src/cmd/atn_cmds/steps_layer.rs b/crates/engine/src/cmd/atn_cmds/steps_layer.rs index 081b9c37ab..dfc7f76743 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_layer.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_layer.rs @@ -10,19 +10,13 @@ use super::tables::{FILL_CONTENTS, INTERPOLATION, MENU_ITEMS, STROKE_LOCATION, b use super::{D, Descriptor, Id, Map, ReferenceItem, UnicodeString, Value, json}; /// Import recorded targets while retaining stack and opening-order references for playback. +/// Resolve a recorded selection while retaining layer names, Background properties and modifiers. pub(super) fn select_from(d: &Descriptor) -> Mapped { let Some(r) = get_ref(d, "null") else { return Mapped::Unsupported { harmless: false } }; match (ref_class(r).as_deref(), target(r)) { (Some("Lyr"), Target::Name(_) | Target::Index(_) | Target::Ordinal(_)) => { let Some(params) = recorded_layer_target(r) else { return Mapped::Unsupported { harmless: false } }; let mut p = Value::Object(params); -/// Resolve a recorded selection while retaining layer names, Background properties and modifiers. -pub(super) fn select_from(d: &Descriptor) -> Mapped { - let Some(r) = get_ref(d, "null") else { return Mapped::Unsupported { harmless: false } }; - match (ref_class(r).as_deref(), target(r)) { - (Some("Lyr"), Target::Name(_) | Target::Property(_)) => { - let Some(p) = layer_target(r) else { return Mapped::Unsupported { harmless: false } }; - let mut p = Value::Object(p); match get_enum(d, "selectionModifier").as_deref() { Some("addToSelection") => p["mode"] = json!("add"), Some("removeFromSelection") => p["mode"] = json!("remove"), @@ -32,6 +26,11 @@ pub(super) fn select_from(d: &Descriptor) -> Mapped { } step("layer.select", p) } + // A Cmd-click recorded on a layer property (e.g. the Background, #10702). + (Some("Lyr"), Target::Property(_)) => { + let Some(p) = layer_target(r) else { return Mapped::Unsupported { harmless: false } }; + step("layer.select", Value::Object(p)) + } // The Channels panel: the active layer's mask, or back to the composite (`RGB`, `CMYK`…). (Some("Chnl"), Target::Ordinal(o)) => match o.as_str() { "Msk" => step("channel.target", json!({"channel": "mask"})), @@ -51,9 +50,8 @@ pub(super) fn select_from(d: &Descriptor) -> Mapped { }, // Switching to another open document: its offset from the active one, -1 for the previous // opened document and 1 for the next, independently of dragged tabs (#7103). - (Some("Dcmn"), Target::Offset(o)) => step("document.activate", json!({"offset": o})), - (Some("Dcmn"), Target::Index(index)) if r.len() == 1 => step("document.activate", json!({"document": {"index": index}})), (Some("Dcmn"), Target::Offset(o)) if o != 0 => step("document.activate", json!({"offset": o})), + (Some("Dcmn"), Target::Index(index)) if r.len() == 1 => step("document.activate", json!({"document": {"index": index}})), _ => Mapped::Unsupported { harmless: false }, } } @@ -72,11 +70,7 @@ pub(super) fn select_to(id: &str, p: &Value) -> Option { name_ref("Lyr ", name.as_str()?) } else { p.get("layer").filter(|v| !v.is_null())?; - recorded_layer_ref(p)? - let reference = match jstr(p, "name") { - Some(name) => name_ref("Lyr ", name), - None if p.get("layer").is_some_and(|v| !v.is_null()) => layer_ref(p)?, - None => return None, + recorded_layer_ref(p).or_else(|| layer_ref(p))? }; let mut d = desc("null").with("null", reference); match jstr(p, "mode") { @@ -115,7 +109,7 @@ pub(super) fn select_to(id: &str, p: &Value) -> Option { // A native numeric document selects a tab; the typed index above selects opening order. "document.activate" if p.get("document").is_none_or(Value::is_null) => { let offset = i32::try_from(p.get("offset")?.as_i64()?).ok()?; - // Photoshop rejects zero, though the native command permits that no-op. + // The reference application rejects zero, though the native command permits that no-op. if offset == 0 { return None; } diff --git a/crates/engine/src/cmd/layer_params.rs b/crates/engine/src/cmd/layer_params.rs index 2613822fba..3d94bcc888 100644 --- a/crates/engine/src/cmd/layer_params.rs +++ b/crates/engine/src/cmd/layer_params.rs @@ -25,6 +25,7 @@ pub(crate) fn explicit_layer(s: &Session, p: &Value) -> Result> // A group end is no layer: fail rather than fall back to the selection. Some(v @ Value::Object(fields)) if fields.contains_key("index") || fields.contains_key("ordinal") => { super::recorded_layer(s, v)?.map(Some).ok_or_else(|| EngineError::Other("a group end is not a layer".into())) + } Some(Value::Object(selector)) if selector.len() == 1 && selector.get("background") == Some(&Value::Bool(true)) => { let d = s.active().ok_or(EngineError::NoDocument)?; d.doc.background().map(|l| Some(l.id)).ok_or_else(|| EngineError::Other("the document has no Background layer".into())) diff --git a/crates/engine/tests/atn_mapped_identifiers.rs b/crates/engine/tests/atn_mapped_identifiers.rs index 7b11aeb500..52ebdec9d5 100644 --- a/crates/engine/tests/atn_mapped_identifiers.rs +++ b/crates/engine/tests/atn_mapped_identifiers.rs @@ -487,6 +487,7 @@ fn recorded(event: AtnEvent, d: Descriptor) -> Action { ..Default::default() }], warnings: vec![], + encoding: None, }) .unwrap(); atn_cmds::import(&bytes).unwrap().actions.remove(0) @@ -527,7 +528,11 @@ fn recorded_vibrance_uses_the_string_event_and_keeps_legacy_playback() { assert_eq!(action.steps, vec![("image.adjustments.vibrance".into(), json!({"vibrance":47,"saturation":-19}))]); let written = saved(&action); assert_eq!(written.event, AtnEvent::name("vibrance"), "Vibrance must save by its string event"); - assert_eq!(written.descriptor, Some(d.clone())); + // Saved with the `vibrance` class the reference application records (#8534); the + // top-level class does not change what it plays. + let mut want = d.clone(); + want.class_id = Descriptor::new("vibrance").class_id; + assert_eq!(written.descriptor, Some(want)); let create = || { let mut s = Session::new(); s.execute("file.new", json!({"width":4,"height":3,"depth":depth})).unwrap(); diff --git a/crates/engine/tests/image_size_scale_styles_atn.rs b/crates/engine/tests/image_size_scale_styles_atn.rs index bdac446c76..f724c0d919 100644 --- a/crates/engine/tests/image_size_scale_styles_atn.rs +++ b/crates/engine/tests/image_size_scale_styles_atn.rs @@ -20,6 +20,7 @@ fn recorded(event: &str, descriptor: Option) -> Vec { ..Default::default() }], warnings: vec![], + encoding: None, }) .unwrap() } From a299bf9f6cbccf540bfd719a11af08c089af7b89 Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 2 Nov 2026 02:47:55 +0100 Subject: [PATCH 151/216] Integration: Trap round trip, Match Color source None, Split Channels only on single-layer images (#10630) - A Trap step recorded without Wdth loaded as {} and saved back as Wdth 1 px, so it reloaded as {"width":1}. It now loads as width 1 (what the command plays), so load -> save -> load gives the same step. - The Match Color dialog records Source "None" as "source": -1, which the export refused because "source" wasn't an allowed key, so a Match Color recorded in PhotoCraft could never be saved. A null or negative source (and a sourceLayer with it) is now written as the source-less event; a real source (>= 0) is still refused. - Split Channels closes the original, but split and its enabled check never checked the layer count, so a played SplC split and closed a layered document with no undo. Like the reference application it now refuses (Err, document untouched and open) a document with more than one layer (a hidden one counts) or a lone group; a Background-only image or a lone normal layer still splits. Co-authored-by: hectorviov <1501713+hectorviov@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- .../engine/src/cmd/atn_cmds/steps_channels.rs | 17 ++++++---- crates/engine/src/cmd/atn_cmds/tests.rs | 33 ++++++++++++++++++ crates/engine/src/cmd/channel_cmds.rs | 10 ++++-- crates/engine/src/cmd/channel_cmds/spot.rs | 12 +++++++ crates/engine/src/cmd/channel_cmds/tests.rs | 34 +++++++++++++++++++ 5 files changed, 98 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/atn_cmds/steps_channels.rs b/crates/engine/src/cmd/atn_cmds/steps_channels.rs index 37c4c0bf56..a11e199efb 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_channels.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_channels.rs @@ -67,15 +67,16 @@ fn pixels(v: &D) -> Option { } } -/// Trap (`Trap`): `Wdth` in pixels. A width that isn't a number of pixels, or is below -/// `MIN_TRAP`, keeps the Photoshop step. +/// Trap (`Trap`): `Wdth` in pixels. A step without `Wdth` plays the command's 1 px, written +/// out so it saves back as the width it plays. A width that isn't a number of pixels, or is +/// below `MIN_TRAP`, keeps the Photoshop step. pub(super) fn trap_from(d: &Descriptor) -> Mapped { if !only_keys(d, &["Wdth"]) { return Mapped::Unsupported { harmless: false }; } match setting(d, "Wdth", |v| pixels(v).map(Some), None) { Some(Some(w)) if w >= MIN_TRAP => step("image.trap", json!({"width": w.min(MAX_TRAP)})), - Some(None) => step("image.trap", json!({})), + Some(None) => step("image.trap", json!({"width": MIN_TRAP})), _ => Mapped::Unsupported { harmless: false }, } } @@ -128,11 +129,15 @@ fn match_color_params(d: &Descriptor) -> Option> /// Every key in recorded order. A source document or layer, Neutralize or a selection option /// has no key in the event, so such a step is refused; the integers refuse a fraction or a -/// value outside the command's range, which it plays clamped. +/// value outside the command's range, which it plays clamped. A negative `source` is the +/// dialog's Source "None", which the command plays without a source, so it is written as one +/// (as is a `sourceLayer` with it, which the command then ignores). pub(super) fn match_color_to(_: &str, p: &Value) -> Option { let off = |k: &str| p.get(k).is_none_or(|v| v.is_null() || *v == Value::Bool(false)); - if !pixel_target(p) - || !sets_only(p, &["target", "luminance", "intensity", "fade", "neutralize", "useSelectionInSource", "useSelectionInTarget"]) + let no_source = p.get("source").is_none_or(|v| v.is_null() || v.as_i64().is_some_and(|i| i < 0)); + if !no_source + || !pixel_target(p) + || !sets_only(p, &["target", "source", "sourceLayer", "luminance", "intensity", "fade", "neutralize", "useSelectionInSource", "useSelectionInTarget"]) || !["neutralize", "useSelectionInSource", "useSelectionInTarget"].iter().all(|k| off(k)) { return None; diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index 9f74da7b92..fd7295d500 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -3388,3 +3388,36 @@ fn match_color_with_a_source_is_kept() { } } } + +/// A Trap step recorded without `Wdth` plays the command's 1 px and saves back as `Wdth` 1 px, +/// so it must load as that width too: load, save and load give the same step. +#[test] +fn trap_step_without_a_width_saves_back_the_same() { + let file = AtnFile { + name: UnicodeString::new_nul("S"), + actions: vec![AtnAction { name: UnicodeString::new_nul("T"), items: vec![item("Trap", "Trap", Some(desc("null")))], ..Default::default() }], + ..Default::default() + }; + let mut s = Session::new(); + s.execute("actions.load", json!({"data": b64(&atn::write(&file).unwrap())})).unwrap(); + let loaded = s.actions.list.clone(); + assert_eq!(loaded[0].steps, vec![("image.trap".to_string(), json!({"width": 1}))]); + let saved = s.execute("actions.save", json!({"set": "S"})).unwrap(); + let mut t = Session::new(); + t.execute("actions.load", json!({"data": saved["data"].as_str().unwrap()})).unwrap(); + assert_eq!(t.actions.list, loaded); +} + +/// The Match Color dialog records Source "None" as `"source": -1`, which the command plays +/// without a source: such a step saves, while a real source document is still refused. +#[test] +fn match_color_recorded_with_source_none_saves() { + let none = json!({"source": -1, "luminance": 100.0, "intensity": 100.0, "fade": 0.0, "neutralize": false, "useSelectionInSource": false, "useSelectionInTarget": false}); + let d = export_item("image.adjustments.matchColor", &none, "S").unwrap().descriptor.unwrap(); + let expected = desc("null").with("Lght", D::Integer(100)).with("ClrR", D::Integer(100)).with("Fade", D::Integer(0)).with("noReference", D::Boolean(true)); + assert_eq!(d.items, expected.items); + assert!(export_item("image.adjustments.matchColor", &json!({"source": -1, "sourceLayer": 4}), "S").is_some()); + for source in [json!({"source": 0}), json!({"source": 0, "sourceLayer": 4}), json!({"source": "x"}), json!({"source": -1, "neutralize": true})] { + assert!(export_item("image.adjustments.matchColor", &source, "S").is_none(), "{source} exported"); + } +} diff --git a/crates/engine/src/cmd/channel_cmds.rs b/crates/engine/src/cmd/channel_cmds.rs index 49a45cba6a..3ee57c8abb 100644 --- a/crates/engine/src/cmd/channel_cmds.rs +++ b/crates/engine/src/cmd/channel_cmds.rs @@ -46,7 +46,13 @@ fn has_spot(s: &Session) -> std::result::Result<(), String> { } fn can_split(s: &Session) -> std::result::Result<(), String> { let d = s.active().ok_or("no document open")?; - if color_count(&d.doc) + d.doc.channels.len() < 2 { Err("only one channel to split".into()) } else { Ok(()) } + if !is_flat(&d.doc) { + Err(spot::NOT_FLAT.into()) + } else if color_count(&d.doc) + d.doc.channels.len() < 2 { + Err("only one channel to split".into()) + } else { + Ok(()) + } } fn can_merge(s: &Session) -> std::result::Result<(), String> { has_doc(s)?; @@ -122,7 +128,7 @@ pub use planes::{ pub(crate) use refs::source_spec; pub use refs::{Blending, ChanRef, parse_ref}; use selection::{load_selection, quick_mask, save_selection}; -use spot::{merge, merge_spot, mergeable, split}; +use spot::{is_flat, merge, merge_spot, mergeable, split}; use targeting::{SLOT_KEY, target_cmd, target_slot}; pub(crate) use targeting::{ Target, channel_surface_for_filter, clear_channel, clear_color_channel, color_restriction, edits_channel, filter_mask_layer, inject_target, invert_region, diff --git a/crates/engine/src/cmd/channel_cmds/spot.rs b/crates/engine/src/cmd/channel_cmds/spot.rs index 773089faad..f2aa4a6e34 100644 --- a/crates/engine/src/cmd/channel_cmds/spot.rs +++ b/crates/engine/src/cmd/channel_cmds/spot.rs @@ -55,9 +55,21 @@ pub(super) fn merge_spot(s: &mut Session, p: &Value) -> Result { Ok(Value::Null) } +/// Whether `doc` has a single layer (a Background or a normal one; a group counts as a layer, +/// so a lone group is refused too). As in the reference application only such an image can be +/// split: the split closes the original, so a layered one would lose its layers. +pub(super) fn is_flat(doc: &Document) -> bool { + matches!(doc.layers.as_slice(), [l] if l.children().is_none()) +} + +pub(super) const NOT_FLAT: &str = "only a single-layer image can be split: flatten the image first"; + /// Split Channels: one grayscale document per colour and alpha channel; closes the original. pub(super) fn split(s: &mut Session, p: &Value) -> Result { let idx = s.active_index().ok_or(EngineError::NoDocument)?; + if !s.documents().get(idx).is_some_and(|d| is_flat(&d.doc)) { + return Err(bad("channel.split", NOT_FLAT)); + } let doc = s.documents()[idx].doc.clone(); let area = doc.bounds(); let (px, n) = native_pixels(&doc, None)?; diff --git a/crates/engine/src/cmd/channel_cmds/tests.rs b/crates/engine/src/cmd/channel_cmds/tests.rs index 079b2e4c86..a640a7b45b 100644 --- a/crates/engine/src/cmd/channel_cmds/tests.rs +++ b/crates/engine/src/cmd/channel_cmds/tests.rs @@ -1185,3 +1185,37 @@ fn layers_panel_mask_target_isolates_the_curve_channel() { let row = isolated_curve_channel_for(s.active().unwrap(), true).expect("quick mask wins"); assert_eq!((row.name.as_str(), row.gray), ("Quick Mask", Some(GraySurface::QuickMask))); } + +/// Split Channels closes the original, so as in the reference application it splits only an +/// image with a single layer: two layers (even with one hidden) or a lone group are refused and +/// the document stays open with its layers; a flattened image or a lone normal layer still +/// splits (a played `SplC` step goes through the same check). +#[test] +fn split_refuses_an_image_with_more_than_one_layer() { + let mut s = session(); + s.execute("edit.fill", json!({"color": "#ff8000"})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + assert!(!s.is_enabled("channel.split")); + let err = s.execute("channel.split", json!({})).unwrap_err(); + assert!(err.to_string().contains("flatten"), "{err}"); + assert_eq!(s.documents().len(), 1, "the layered document stays open"); + assert_eq!(doc(&s).layers.len(), 2, "with its layers"); + std::sync::Arc::make_mut(&mut s.active_mut().unwrap().doc).layers[1].visible = false; + assert!(s.execute("channel.split", json!({})).is_err(), "a hidden second layer still counts"); + assert_eq!((s.documents().len(), doc(&s).layers.len()), (1, 2)); + s.execute("layer.flattenImage", json!({})).unwrap(); + assert!(s.is_enabled("channel.split")); + assert_eq!(s.execute("channel.split", json!({})).unwrap()["documents"].as_array().unwrap().len(), 3); + // A lone normal layer (Layer 0) splits too. + let mut s = session(); + s.execute("layer.new.layerFromBackground", json!({})).unwrap(); + assert!(!doc(&s).is_background(doc(&s).layers[0].id)); + assert!(s.is_enabled("channel.split")); + assert_eq!(s.execute("channel.split", json!({})).unwrap()["documents"].as_array().unwrap().len(), 3); + // A lone group is a layer the split would lose. + let mut s = session(); + std::sync::Arc::make_mut(&mut s.active_mut().unwrap().doc).layers = vec![Layer::group("Group 1", vec![])]; + assert!(!s.is_enabled("channel.split")); + assert!(s.execute("channel.split", json!({})).is_err()); + assert_eq!(s.documents().len(), 1); +} From 023e2662d705b2dcc07530c808b1bb2461718aae Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 2 Nov 2026 02:22:38 +0100 Subject: [PATCH 152/216] Integration: Filter Gallery effect steps keep their recorded event (#10632) Three gaps in loading and saving Filter Gallery steps from .atn files: - An effect recorded under its own event by string ID (event `cutout` with `GEfk` = `Ct `, the form newer versions write) stayed a kept Photoshop step: only 4-char codes were routed to the effect table. The effects' string IDs are now matched too. - A step recorded as the effect's own event (`AccE` "Accented Edges") saved back as a `GEfc` "Filter Gallery" event, so load -> save changed the event and its panel name. The step now remembers the event it was loaded from (hidden `_atnEvent` param) and writes it back when that event is the step's own effect; `GEfc` steps, steps made in PhotoCraft and a stale or forged event naming another effect save as `GEfc`. Playback strips the param (`playback_params`, like `_actionLayer`), so the command, the journal (Last Filter), smart filters and the step's dialog never carry it. - A key that was present but had the wrong type or a non-finite value took the default (Texturizer `InvT` as Integer 1 played with invert off; `EdgW` NaN played as 2). Only missing keys take defaults now; an unreadable present key keeps the step. Co-authored-by: hectorviov <1501713+hectorviov@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/actions_cmds.rs | 9 +- crates/engine/src/cmd/atn_cmds.rs | 1 + crates/engine/src/cmd/atn_cmds/convert.rs | 11 +- crates/engine/src/cmd/atn_cmds/events.rs | 3 +- .../engine/src/cmd/atn_cmds/steps_gallery.rs | 134 ++++++++++++++++-- crates/engine/src/cmd/atn_cmds/tests.rs | 82 +++++++++++ 6 files changed, 223 insertions(+), 17 deletions(-) diff --git a/crates/engine/src/cmd/actions_cmds.rs b/crates/engine/src/cmd/actions_cmds.rs index 8e8d532d5b..3e68c12477 100644 --- a/crates/engine/src/cmd/actions_cmds.rs +++ b/crates/engine/src/cmd/actions_cmds.rs @@ -131,9 +131,16 @@ pub(crate) fn recorded_params(s: &Session, id: &str, mut p: Value) -> Value { p } -/// Resolve the recorded visibility target identically in engine and shell playback. +/// Resolve the recorded visibility target identically in engine and shell playback, and drop +/// the `.atn` event a Filter Gallery step remembers for Save Actions: the command, its journal +/// entry (Last Filter), a smart filter and the step's dialog never see it. pub fn playback_params(s: &Session, id: &str, p: &Value) -> Result { let mut p = p.clone(); + if id.starts_with("filter.gallery.") + && let Some(object) = p.as_object_mut() + { + object.remove(crate::atn_cmds::RECORDED_EVENT); + } if id == "layer.setProps" && let Some(target) = p.get("_actionLayer") { diff --git a/crates/engine/src/cmd/atn_cmds.rs b/crates/engine/src/cmd/atn_cmds.rs index b331b4d7c4..6f97b635e2 100644 --- a/crates/engine/src/cmd/atn_cmds.rs +++ b/crates/engine/src/cmd/atn_cmds.rs @@ -57,6 +57,7 @@ use fields::Mapped; use files::{load, save}; #[cfg(test)] use steps_doc::crop_from; +pub(crate) use steps_gallery::RECORDED_EVENT; #[cfg(test)] use steps_image::{apply_image_from, transform_to}; #[cfg(test)] diff --git a/crates/engine/src/cmd/atn_cmds/convert.rs b/crates/engine/src/cmd/atn_cmds/convert.rs index 78d65979bf..8962a57e6a 100644 --- a/crates/engine/src/cmd/atn_cmds/convert.rs +++ b/crates/engine/src/cmd/atn_cmds/convert.rs @@ -72,7 +72,10 @@ fn convert_action(a: &AtnAction, set: &str, unsupported: &mut Vec) (None, _) => Mapped::Unsupported { harmless: false }, }; let (new_steps, enabled) = match mapped { - Mapped::Steps(s) => (s, item.enabled), + Mapped::Steps(mut s) => { + super::steps_gallery::remember_event(item, &mut s); + (s, item.enabled) + } Mapped::Unsupported { harmless } => { unsupported.push(Unsupported { action: name.clone(), step: i, event: item.event.id(), name: item.label() }); (vec![photoshop_step(item)], item.enabled && !harmless) @@ -145,6 +148,12 @@ pub(super) fn export_item(id: &str, params: &Value, set: &str) -> Option Option<&'static Event> { let find = |id: &str| EVENTS.iter().find(|e| (!e.code.is_empty() && e.code.trim_end() == id.trim_end()) || e.name == id); find(&id).or_else(|| match item { // An effect run from its own menu item records its id as the event (#10592). - AtnEvent::Code(_) if super::steps_gallery::is_effect(&id) => find("filterGallery"), + // By 4-char code or, as newer versions record it, by string ID (`cutout`). + _ if super::steps_gallery::is_effect(item) => find("filterGallery"), AtnEvent::Code(_) => LEGACY_CODES.iter().find(|(code, _)| *code == id).and_then(|(_, name)| find(name)), AtnEvent::Name(_) => None, }) diff --git a/crates/engine/src/cmd/atn_cmds/steps_gallery.rs b/crates/engine/src/cmd/atn_cmds/steps_gallery.rs index 49b6168c7f..f430ab11c0 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_gallery.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_gallery.rs @@ -3,11 +3,11 @@ //! the keys, limits and defaults Photoshop measures (`docs/atn.md`, Group A); a stack of several //! effects stays a Photoshop step. -use super::descriptors::{color_desc, color_hex, desc, enm, get_bool, get_enum, get_num, get_obj, trimmed}; +use super::descriptors::{color_desc, color_hex, desc, enm, get_bool, get_enum, get_num, get_obj, jstr, trimmed}; use super::events::pixel_target; use super::fields::{Mapped, step}; use super::tables::{lookup, lookup_back}; -use super::{D, Descriptor, Map, Value}; +use super::{AtnEvent, AtnItem, D, Descriptor, Map, Value, json}; use photocraft_algo::{GalleryEffect, GalleryFilter}; /// One key of an effect, in recorded order. @@ -523,10 +523,108 @@ pub(super) const GALLERY_IDS: &[&str] = &[ "filter.gallery.grain", ]; -/// Whether `code` is a Filter Gallery effect's id. The reference has each effect recorded as -/// its own event too (Filter › Artistic › Cutout…), carrying the same `GEfk` and settings. -pub(super) fn is_effect(code: &str) -> bool { - GALLERY.iter().any(|(c, _, _)| *c == code.trim_end()) +/// Each effect's own event by string ID, the form newer Photoshop versions record it in +/// (`docs/atn.md`, Group A). Mosaic Tiles (`MscT`) has none. +const EVENT_NAMES: &[(&str, &str)] = &[ + ("AccE", "accentedEdges"), + ("AngS", "angledStrokes"), + ("BsRl", "basRelief"), + ("ChlC", "chalkCharcoal"), + ("Chrc", "charcoal"), + ("Chrm", "chrome"), + ("ClrP", "coloredPencil"), + ("CntC", "conteCrayon"), + ("Crql", "craquelure"), + ("Crsh", "crosshatch"), + ("Ct", "cutout"), + ("DrkS", "darkStrokes"), + ("DfsG", "diffuseGlow"), + ("DryB", "dryBrush"), + ("FlmG", "filmGrain"), + ("Frsc", "fresco"), + ("Gls", "glass"), + ("GlwE", "glowingEdges"), + ("GraP", "graphicPen"), + ("HlfS", "halftoneScreen"), + ("InkO", "inkOutlines"), + ("NGlw", "neonGlow"), + ("NtPr", "notePaper"), + ("OcnR", "oceanRipple"), + ("PntD", "paintDaubs"), + ("PltK", "paletteKnife"), + ("Ptch", "patchwork"), + ("Phtc", "photocopy"), + ("Plst", "plaster"), + ("PlsW", "plasticWrap"), + ("PstE", "posterEdges"), + ("Rtcl", "reticulation"), + ("RghP", "roughPastels"), + ("SmdS", "smudgeStick"), + ("Spt", "spatter"), + ("Spng", "sponge"), + ("SprS", "sprayedStrokes"), + ("StnG", "stainedGlass"), + ("Stmp", "stamp"), + ("Smie", "sumie"), + ("Txtz", "texturizer"), + ("TrnE", "tornEdges"), + ("Undr", "underpainting"), + ("WtrP", "waterPaper"), + ("Wtrc", "watercolor"), + ("Grn", "grain"), +]; + +/// Whether `event` is a Filter Gallery effect's own event: its 4-char code, or its string ID. +/// The reference has each effect recorded as its own event too (Filter › Artistic › Cutout…), +/// carrying the same `GEfk` and settings. +pub(super) fn is_effect(event: &AtnEvent) -> bool { + effect_command(event).is_some() +} + +/// The command of the effect whose own event `event` is (`AccE` or `accentedEdges` → +/// `filter.gallery.accentedEdges`). +fn effect_command(event: &AtnEvent) -> Option<&'static str> { + let id = event.id(); + let code = match event { + AtnEvent::Code(_) => GALLERY.iter().map(|(c, _, _)| *c).find(|c| *c == id.trim_end()), + AtnEvent::Name(_) => EVENT_NAMES.iter().find(|(_, n)| *n == id).map(|(c, _)| *c), + }?; + GALLERY.iter().find(|(c, _, _)| *c == code).map(|(_, cmd, _)| *cmd) +} + +/// The hidden param a step loaded from an effect's own event carries, so it saves back as that +/// event (and panel name) rather than as a `GEfc` Filter Gallery step. Playback strips it +/// (`actions_cmds::playback_params`), so the command, the journal and smart filters never see it. +pub(crate) const RECORDED_EVENT: &str = "_atnEvent"; + +/// Remember the event of a step loaded from an effect's own event (`AccE`, `accentedEdges`). +/// A `GEfc` step carries nothing and saves as `GEfc`, as does a step made in PhotoCraft. +pub(super) fn remember_event(item: &AtnItem, steps: &mut [(String, Value)]) { + if !is_effect(&item.event) { + return; + } + let [(id, Value::Object(p))] = steps else { return }; + if effect_command(&item.event) != Some(id.as_str()) { + return; + } + let kind = match item.event { + AtnEvent::Code(_) => "code", + AtnEvent::Name(_) => "name", + }; + p.insert(RECORDED_EVENT.into(), json!({"event": item.event.id(), "eventKind": kind, "name": item.dictionary()})); +} + +/// The event and panel name a step of command `id` remembered by [`remember_event`] saves as; +/// `None` (save as `GEfc`) when it has none or it is not the own event of `id`'s effect. +pub(super) fn recorded_event(id: &str, params: &Value) -> Option<(AtnEvent, String)> { + let r = params.get(RECORDED_EVENT)?; + let event = jstr(r, "event")?; + let event = match jstr(r, "eventKind")? { + "code" => AtnEvent::Code(event.as_bytes().try_into().ok()?), + "name" => AtnEvent::name(event), + _ => return None, + }; + (effect_command(&event) == Some(id)).then(|| (event, jstr(r, "name").unwrap_or("").to_string())) } /// A Filter Gallery step: the effect `GEfk` names, every key clamped or looked up, Photoshop's @@ -544,14 +642,22 @@ fn effect_step(d: &Descriptor) -> Option { } let mut p = Map::new(); for k in keys.iter() { + // Only a missing key takes Photoshop's default: a key that is there with the wrong type + // or a non-finite number keeps the step (`docs/atn.md`) rather than playing a default. + let present = d.get(k.key()).is_some(); let v = match k { - G::Int(key, _, min, max, default) => Value::from(get_num(d, key).map_or(*default, |n| n.round().clamp(f64::from(*min), f64::from(*max)) as i32)), - G::Enum(key, _, table, default) => Value::from(lookup(table, &get_enum(d, key).unwrap_or_else(|| (*default).to_string()))?), - G::Bool(key, _, default) => Value::from(get_bool(d, key).unwrap_or(*default)), - G::Color(key, _, default) => match get_obj(d, key) { - Some(c) => Value::from(color_hex(c)?), - None => Value::from(*default), - }, + G::Int(key, _, min, max, default) => { + Value::from(if present { get_num(d, key)?.round().clamp(f64::from(*min), f64::from(*max)) as i32 } else { *default }) + } + G::Enum(key, _, table, default) => Value::from(lookup(table, &if present { get_enum(d, key)? } else { (*default).to_string() })?), + G::Bool(key, _, default) => Value::from(if present { get_bool(d, key)? } else { *default }), + G::Color(key, _, default) => { + if present { + Value::from(color_hex(get_obj(d, key)?)?) + } else { + Value::from(*default) + } + } }; p.insert(k.param().into(), v); } @@ -573,7 +679,7 @@ fn default_color(id: &str) -> Option { /// without a Photoshop code refuses the step. pub(super) fn gallery_to(id: &str, p: &Value) -> Option { let (code, _, keys) = GALLERY.iter().find(|(_, cmd, _)| *cmd == id)?; - let allowed: Vec<&str> = keys.iter().map(G::param).chain(["target"]).collect(); + let allowed: Vec<&str> = keys.iter().map(G::param).chain(["target", RECORDED_EVENT]).collect(); if !pixel_target(p) || !p.as_object().is_none_or(|m| m.iter().all(|(k, v)| v.is_null() || allowed.contains(&k.as_str()))) { return None; } diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index fa8d89f0a4..a612becbe7 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -3390,3 +3390,85 @@ fn gallery_neon_glow_without_a_colour_loads_photoshops_blue() { let back = (events::event_for(&event).unwrap().from)(&d); assert_eq!(gallery_params(back), (id, p)); } + +/// An effect recorded under its own event by string ID (`cutout`, as newer versions record it) +/// maps like its 4-char code; only the effect's string IDs count as names (#10592). +#[test] +fn gallery_effects_recorded_by_string_id_map_like_their_codes() { + for (code, name, effect) in [("Ct ", "cutout", "Ct"), ("HlfS", "halftoneScreen", "HlfS"), ("Grn ", "grain", "Grn")] { + let d = desc("null").with("GEfk", enm("GEft", effect)); + let by_name = (events::event_for(&AtnEvent::name(name)).unwrap_or_else(|| panic!("{name} by name")).from)(&d); + let by_code = (events::event_for(&AtnEvent::Code(code.as_bytes().try_into().unwrap())).unwrap().from)(&d); + assert_eq!(gallery_params(by_name), gallery_params(by_code), "{name}"); + } + assert!(events::event_for(&AtnEvent::name("Ct ")).is_none()); + assert!(events::event_for(&AtnEvent::Code(*b"MscT")).is_some()); +} + +/// A step recorded as the effect's own event (by code or string ID) loads, plays and saves back +/// as that event with its panel name and descriptor unchanged; a `GEfc` step stays `GEfc` +/// (#10592). +#[test] +fn gallery_effect_steps_save_back_as_the_event_they_were_recorded_as() { + let edges = desc("null").with("GEfk", enm("GEft", "AccE")).with("EdgW", D::Integer(2)).with("EdgB", D::Integer(38)).with("Smth", D::Integer(5)); + let cutout = desc("null").with("GEfk", enm("GEft", "Ct")).with("NmbL", D::Integer(6)).with("EdgS", D::Integer(4)).with("EdgF", D::Integer(2)); + let gallery = desc("null").with("GEfk", enm("GEft", "AccE")).with("EdgW", D::Integer(3)).with("EdgB", D::Integer(20)).with("Smth", D::Integer(7)); + let source = vec![item("AccE", "Accented Edges", Some(edges)), item("cutout", "Cutout", Some(cutout)), item("GEfc", "Filter Gallery", Some(gallery))]; + let data = set_of("S", vec![("A", source.clone())]); + let mut s = Session::new(); + let loaded = s.execute("actions.load", json!({"data": data})).unwrap(); + assert_eq!(loaded["unsupported"], json!([]), "{loaded}"); + let ids: Vec<&str> = s.actions.list[0].steps.iter().map(|(id, _)| id.as_str()).collect(); + assert_eq!(ids, ["filter.gallery.accentedEdges", "filter.gallery.cutout", "filter.gallery.accentedEdges"]); + + s.execute("file.new", json!({"width": 16, "height": 16})).unwrap(); + s.execute("filter.render.clouds", json!({})).unwrap(); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + let played = s.execute("actions.play", json!({"action": "A"})).unwrap(); + assert_eq!(played["ran"], 3, "{played}"); + // The remembered event is for Save Actions only: playback strips it, so the journal (Last + // Filter) and the smart filters (`.pcraft` saves) never carry it. + let gallery: Vec<&Value> = s.journal.iter().filter(|(id, _)| id.starts_with("filter.gallery.")).map(|(_, p)| p).collect(); + assert_eq!(gallery.len(), 3, "{:?}", s.journal); + assert!(gallery.iter().all(|p| p.get("_atnEvent").is_none()), "{gallery:?}"); + let d = s.active().unwrap(); + let photocraft_doc::LayerContent::Smart(sm) = &d.doc.layer(d.active_layer.unwrap()).unwrap().content else { panic!("not smart") }; + assert_eq!(sm.smart_filters.len(), 3); + assert!(sm.smart_filters.iter().all(|f| f.params.get("_atnEvent").is_none()), "{:?}", sm.smart_filters); + + let saved = s.execute("actions.save", json!({"set": "S"})).unwrap(); + assert_eq!(saved["skipped"], json!([])); + let file = atn::parse(&photocraft_paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap()).unwrap(); + let got: Vec<_> = file.actions[0].items.iter().map(|i| (i.event.clone(), i.dictionary.clone(), i.descriptor.clone())).collect(); + let want: Vec<_> = source.iter().map(|i| (i.event.clone(), i.dictionary.clone(), i.descriptor.clone())).collect(); + assert_eq!(got, want); + + // A step made in PhotoCraft keeps saving as `GEfc`, and so does a forged event param. + assert_eq!(export_item("filter.gallery.cutout", &json!({}), "S").unwrap().event, AtnEvent::Code(*b"GEfc")); + let forged = json!({"_atnEvent": {"event": "GsnB", "eventKind": "code", "name": "Gaussian Blur"}}); + assert_eq!(export_item("filter.gallery.cutout", &forged, "S").unwrap().event, AtnEvent::Code(*b"GEfc")); + // Another effect's event (stale or forged) does not relabel the step either. + for (event, kind) in [("AccE", "code"), ("accentedEdges", "name")] { + let other = json!({"_atnEvent": {"event": event, "eventKind": kind, "name": "Accented Edges"}}); + let item = export_item("filter.gallery.cutout", &other, "S").unwrap(); + assert_eq!((item.event, item.dictionary), (AtnEvent::Code(*b"GEfc"), b"Filter Gallery".to_vec()), "{event}"); + } + let own = json!({"_atnEvent": {"event": "cutout", "eventKind": "name", "name": "Cutout"}}); + assert_eq!(export_item("filter.gallery.cutout", &own, "S").unwrap().event, AtnEvent::name("cutout")); +} + +/// Only a missing key takes Photoshop's default: a key there with the wrong type or a non-finite +/// number keeps the Photoshop step instead of playing a default (#10592). +#[test] +fn gallery_effect_keys_with_the_wrong_type_keep_the_step() { + let kept = |m: Mapped| matches!(m, Mapped::Unsupported { harmless: false }); + assert!(kept(gallery_step("GEfc", "Txtz", desc("null").with("InvT", D::Integer(1))))); + assert!(kept(gallery_step("GEfc", "AccE", desc("null").with("EdgW", D::Double(f64::NAN))))); + assert!(kept(gallery_step("GEfc", "AccE", desc("null").with("EdgW", D::Double(f64::INFINITY))))); + assert!(kept(gallery_step("GEfc", "AccE", desc("null").with("EdgW", text("9"))))); + assert!(kept(gallery_step("GEfc", "BsRl", desc("null").with("LghD", D::Integer(3))))); + assert!(kept(gallery_step("GEfc", "NGlw", desc("null").with("Clr ", D::Integer(3))))); + // Missing keys still take the defaults. + assert_eq!(gallery_params(gallery_step("GEfc", "Txtz", desc("null"))).1["invert"], false); + assert_eq!(gallery_params(gallery_step("GEfc", "AccE", desc("null").with("EdgB", D::Integer(9)))).1["edgeWidth"], 2); +} From 8a0f959d2940ce20497811d2fce7c6f01486446b Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 2 Nov 2026 03:18:46 +0100 Subject: [PATCH 153/216] Integration: real recorded Convert to Profile and Paste steps play, sparse parallel dither (#10704) - Convert to Profile: the reference application records `sdwM` -1 for RGB targets (`convertToProfile T="ProPhoto RGB" Inte=AClr Dthr=true Fltt=false rasterizePlaced=false sdwM=-1`). The PR accepted only 2, so every recorded step became a kept step that fails at playback. Accept and write -1. - Paste: an ordinary Edit > Paste records `AntA Anno` + `As Pxel` (Paste in Place adds `inPlace true`). `As Pxel` was routed through the explicit vector mode (no layer recreation, no mask/channel targets) and Paste in Place became a kept step. `As Pxel` now keeps the ordinary Paste / Paste in Place route. - Dither: transparent layers stay sparse (only stored tiles are dithered) and tiles run in parallel. 24 MP 8-bit, one layer: opaque 1576 -> 101 ms; transparent with one stored tile 1670 -> 5 ms and 384 -> 1 stored tiles. - Image Size: a recorded step without `scaleStyles` (Fit Image) gained `scaleStyles false` on save; the key is now written only when styles scale. Co-authored-by: yoriswind <164435053+yoriswind@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/atn_cmds/steps_doc.rs | 35 ++++-- .../src/cmd/color_cmds/profile_dither.rs | 100 +++++++++++------- crates/engine/tests/convert_profile_atn.rs | 43 +++++++- crates/engine/tests/convert_profile_dither.rs | 18 ++++ .../tests/image_size_scale_styles_atn.rs | 25 ++++- crates/engine/tests/vector_paste_options.rs | 40 ++++++- docs/atn.md | 9 +- 7 files changed, 210 insertions(+), 60 deletions(-) diff --git a/crates/engine/src/cmd/atn_cmds/steps_doc.rs b/crates/engine/src/cmd/atn_cmds/steps_doc.rs index 4a4202a26a..d4778d16c3 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_doc.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_doc.rs @@ -427,7 +427,12 @@ pub(super) fn image_size_to(_: &str, p: &Value) -> Option { if let Some(i) = jstr(p, "resample").filter(|r| *r != "none") { d = d.with("Intr", enm("Intp", lookup_back(INTERPOLATION, i)?)); } - Some(d.with("scaleStyles", D::Boolean(jbool(p, "scaleStyles").unwrap_or(true)))) + // A recorded step without the key keeps style sizes, so only scaling needs the key: an + // Image Size step recorded without it saves back unchanged. + if jbool(p, "scaleStyles").unwrap_or(true) { + d = d.with("scaleStyles", D::Boolean(true)); + } + Some(d) } pub(super) fn crop_from(d: &Descriptor) -> Mapped { @@ -486,19 +491,28 @@ pub(super) fn stop_to(_: &str, p: &Value) -> Option { pub(super) fn paste_from(d: &Descriptor) -> Mapped { // As is a class, not text or an enum; unknown forms must not fall back to raster paste. if let Some(value) = d.get("As ") { + // `Pxel` is what an ordinary Paste and Paste in Place record; it keeps the ordinary route + // (layer recreation, mask and channel targets, in place). Only a vector form is explicit. let mode = match value { - D::Class(value) if value.class_id.is("Pxel") => "pixels", - D::Class(value) if value.class_id.is("Path") => "path", - D::Class(value) if value.class_id.is("shapeClass") => "shape", + D::Class(value) if value.class_id.is("Pxel") => None, + D::Class(value) if value.class_id.is("Path") => Some("path"), + D::Class(value) if value.class_id.is("shapeClass") => Some("shape"), _ => return Mapped::Unsupported { harmless: false }, }; - if !matches!(d.get("inPlace"), None | Some(D::Boolean(false))) || d.get("null").is_some() { + let in_place = match d.get("inPlace") { + None => false, + Some(D::Boolean(value)) => *value, + Some(_) => return Mapped::Unsupported { harmless: false }, + }; + if (in_place && mode.is_some()) || d.get("null").is_some() { return Mapped::Unsupported { harmless: false }; } if d.get("AntA").is_some_and(|v| !matches!(v, D::Enumerated { type_id, value } if type_id.is("Annt") && value.is("Anno"))) { return Mapped::Unsupported { harmless: false }; } - return step("edit.paste", json!({"as": mode})); + if let Some(mode) = mode { + return step("edit.paste", json!({"as": mode})); + } } none(if get_bool(d, "inPlace") == Some(true) { "edit.pasteSpecial.pasteInPlace" } else { "edit.paste" }) } @@ -574,8 +588,9 @@ pub(super) fn profile_from(d: &Descriptor, id: &str) -> Mapped { let Some(profile) = name.as_deref().and_then(|n| lookup(PROFILES, n)) else { return Mapped::Unsupported { harmless: false } }; let mut p = json!({"profile": profile}); if id == "edit.convertToProfile" { - // All mapped PROFILES are RGB. Other radio values need their own target mapping. - if !matches!(d.get("sdwM"), None | Some(D::Integer(2))) { + // All mapped PROFILES are RGB; the reference application records -1 for these RGB + // targets. Other values need their own target mapping. + if !matches!(d.get("sdwM"), None | Some(D::Integer(-1))) { return Mapped::Unsupported { harmless: false }; } for (key, param) in [("Dthr", "dither"), ("Fltt", "flatten"), ("rasterizePlaced", "rasterizePlaced")] { @@ -597,7 +612,7 @@ pub(super) fn profile_from(d: &Descriptor, id: &str) -> Mapped { step(id, p) } -/// Export effective native flags explicitly, deriving the RGB radio value from the mapped target. +/// Export effective native flags explicitly, with the `sdwM` value recorded for RGB targets. pub(super) fn profile_to(id: &str, p: &Value) -> Option { let name = lookup_back(PROFILES, jstr(p, "profile")?)?; let mut d = desc("null").with("null", target_ref("Dcmn")); @@ -609,7 +624,7 @@ pub(super) fn profile_to(id: &str, p: &Value) -> Option { .with("T ", text(name)) .with("Inte", enm("Inte", lookup_back(INTENT, jstr(p, "intent").unwrap_or("relative"))?)) .with("MpBl", D::Boolean(jbool(p, "bpc").unwrap_or(true))) - .with("sdwM", D::Integer(2)); + .with("sdwM", D::Integer(-1)); for (key, param) in [("Dthr", "dither"), ("Fltt", "flatten"), ("rasterizePlaced", "rasterizePlaced")] { let value = match p.get(param) { None | Some(Value::Null) => false, diff --git a/crates/engine/src/cmd/color_cmds/profile_dither.rs b/crates/engine/src/cmd/color_cmds/profile_dither.rs index eff335a10b..8f99f49dbe 100644 --- a/crates/engine/src/cmd/color_cmds/profile_dither.rs +++ b/crates/engine/src/cmd/color_cmds/profile_dither.rs @@ -1,6 +1,10 @@ //! Opt-in 8-bit profile quantization. The ordinary CMS buffer paths stay unchanged. +use std::sync::Arc; + use photocraft_geom::{Rect, TILE_SIZE, TileCoord}; +use photocraft_raster::Tile; +use rayon::prelude::*; use super::{ ColorMode, Document, EngineError, Intent, PixelFormat, Profile, Result, SampleType, Surface, Transform, cms_err, convert_color, convert_document, @@ -50,12 +54,29 @@ fn tile_origin(c: TileCoord) -> Result<(i32, i32)> { Ok((x as i32, y as i32)) } +/// A transparent default stays transparent: its converted default already stands for the absent +/// tiles, and dither noise under alpha 0 is invisible, so only stored tiles are dithered. +fn sparse(s: &Surface) -> bool { + let sf = s.format(); + sf.alpha && s.default_pixel().get(sf.mode.color_channels()).is_some_and(|a| *a == 0.0) +} + +/// The tiles the dithered output stores: every in-canvas tile (unless the surface is sparse) +/// plus every stored tile. +fn dither_tiles(s: &Surface, bounds: Rect) -> Vec { + if sparse(s) { + return s.tiles().map(|(c, _)| *c).collect(); + } + let outside = s.tiles().map(|(c, _)| *c).filter(|c| !in_canvas_tiles(*c, bounds)); + bounds.tiles().chain(outside).collect() +} + /// Price every additional output tile before allocating any; distant tiles add no bounding box. fn output_bytes(s: &Surface, to: PixelFormat, bounds: Rect) -> Result { - let mut count = u128::from(bounds.tile_count()); + let mut count = if sparse(s) { 0 } else { u128::from(bounds.tile_count()) }; for (c, _) in s.tiles() { tile_origin(*c)?; - if !in_canvas_tiles(*c, bounds) { + if sparse(s) || !in_canvas_tiles(*c, bounds) { count = count.checked_add(1).ok_or_else(|| bad("dither output size overflowed"))?; } } @@ -78,46 +99,53 @@ fn quantize(value: f32, threshold: f64) -> u8 { } /// Stream the finite canvas and stored deviations, retaining the undithered infinite default. +/// Tiles are independent (the noise depends on pixel coordinates only) and run in parallel. fn surface(s: &Surface, to: PixelFormat, t: &Transform, bounds: Rect) -> Result { - let (ss, ds) = (s.channels(), to.channels()); - let (ni, no) = (t.inputs(), t.outputs()); + let ds = to.channels(); let mut converted_default = [0.0; 16]; - t.convert_f32(&s.default_pixel(), ss, &mut converted_default[..ds], ds, true); + t.convert_f32(&s.default_pixel(), s.channels(), &mut converted_default[..ds], ds, true); let mut out = Surface::with_default(to, &converted_default[..ds]); + let tiles = dither_tiles(s, bounds).par_iter().map(|c| dither_tile(s, &out, t, bounds, *c).map(|tile| (*c, Arc::new(tile)))).collect::>>()?; + out.put_tiles(tiles); + out.prune(); + Ok(out) +} + +/// One output tile of [`surface`], starting from the converted default of `out`. +fn dither_tile(s: &Surface, out: &Surface, t: &Transform, bounds: Rect, c: TileCoord) -> Result { + let (ss, ds) = (s.channels(), out.channels()); + let (ni, no) = (t.inputs(), t.outputs()); let default = s.default_bytes(); - let outside = s.tiles().map(|(c, _)| *c).filter(|c| !in_canvas_tiles(*c, bounds)); - for c in bounds.tiles().chain(outside) { - let (x0, y0) = tile_origin(c)?; - let source = s.tile(c).map(|tile| tile.bytes()); - let dest = out.try_tile_mut(c).map_err(|e| EngineError::Other(format!("could not allocate dither output: {e}")))?; - for (index, dp) in dest.bytes_mut().chunks_exact_mut(ds).enumerate() { - let x = x0 + (index % TILE_SIZE as usize) as i32; - let y = y0 + (index / TILE_SIZE as usize) as i32; - let sp = match source { - Some(bytes) => bytes.get(index * ss..index * ss + ss).ok_or_else(|| bad("a stored tile has invalid sample storage"))?, - None => default, - }; - // A redundant default tile outside the canvas must match the implicit plane. - if !bounds.contains(x, y) && sp == default { - continue; - } - let mut input = [0.0; 16]; - let mut output = [0.0; 16]; - for (d, s) in input.iter_mut().zip(sp).take(ni) { - *d = f32::from(*s) / 255.0; - } - t.eval_fast(&input[..ni], &mut output[..no]); - // The hash can return 1.0; recenter its 65536 bins strictly inside (0, 1). - let threshold = (f64::from(photocraft_color::dither_noise(x, y)) * 65535.0 + 0.5) / 65536.0; - for (d, value) in dp.iter_mut().zip(output).take(no) { - *d = quantize(value, threshold); - } - for (d, s) in dp.iter_mut().skip(no).zip(sp.iter().skip(ni)) { - *d = *s; - } + let (x0, y0) = tile_origin(c)?; + let source = s.tile(c).map(|tile| tile.bytes()); + let mut dest = out.try_default_tile().map_err(|e| EngineError::Other(format!("could not allocate dither output: {e}")))?; + for (index, dp) in dest.bytes_mut().chunks_exact_mut(ds).enumerate() { + let x = x0 + (index % TILE_SIZE as usize) as i32; + let y = y0 + (index / TILE_SIZE as usize) as i32; + let sp = match source { + Some(bytes) => bytes.get(index * ss..index * ss + ss).ok_or_else(|| bad("a stored tile has invalid sample storage"))?, + None => default, + }; + // A redundant default tile outside the canvas must match the implicit plane. + if !bounds.contains(x, y) && sp == default { + continue; + } + let mut input = [0.0; 16]; + let mut output = [0.0; 16]; + for (d, s) in input.iter_mut().zip(sp).take(ni) { + *d = f32::from(*s) / 255.0; + } + t.eval_fast(&input[..ni], &mut output[..no]); + // The hash can return 1.0; recenter its 65536 bins strictly inside (0, 1). + let threshold = (f64::from(photocraft_color::dither_noise(x, y)) * 65535.0 + 0.5) / 65536.0; + for (d, value) in dp.iter_mut().zip(output).take(no) { + *d = quantize(value, threshold); + } + for (d, s) in dp.iter_mut().skip(no).zip(sp.iter().skip(ni)) { + *d = *s; } } - Ok(out) + Ok(dest) } /// Preserve ordinary conversion outside opt-in U8, admitting the whole dither edit first. diff --git a/crates/engine/tests/convert_profile_atn.rs b/crates/engine/tests/convert_profile_atn.rs index bf539408c0..465a77b350 100644 --- a/crates/engine/tests/convert_profile_atn.rs +++ b/crates/engine/tests/convert_profile_atn.rs @@ -119,7 +119,7 @@ fn rendered(s: &Session) -> Vec<[f32; 4]> { #[test] fn recorded_rgb_flags_survive_save_reimport_and_public_playback() { for values in [[true, true, true], [true, false, true], [true, false, false], [false, false, false]] { - let mut d = descriptor().with("sdwM", D::Integer(2)); + let mut d = descriptor().with("sdwM", D::Integer(-1)); let mut params = json!({"profile": "srgb", "intent": "relative", "bpc": false}); for ((key, param), value) in FLAGS.into_iter().zip(values) { d = d.with(key, D::Boolean(value)); @@ -149,7 +149,7 @@ fn recorded_rgb_flags_survive_save_reimport_and_public_playback() { let saved = save(&mut replay); let parsed = atn::parse(&saved).unwrap(); let saved_d = parsed.actions[0].items[0].descriptor.as_ref().unwrap(); - assert_eq!(saved_d.get("sdwM"), Some(&D::Integer(2))); + assert_eq!(saved_d.get("sdwM"), Some(&D::Integer(-1))); for ((key, _), value) in FLAGS.into_iter().zip(values) { assert_eq!(saved_d.get(key), Some(&D::Boolean(value))); } @@ -205,7 +205,7 @@ fn defaults_are_explicit_false_and_assign_profile_is_unchanged() { for (key, _) in FLAGS { assert_eq!(d.get(key), Some(&D::Boolean(false))); } - assert_eq!(d.get("sdwM"), Some(&D::Integer(2))); + assert_eq!(d.get("sdwM"), Some(&D::Integer(-1))); } let assign = Action::new("Assign", vec![("edit.assignProfile".into(), json!({"profile": "srgb"}))]); let (bytes, skipped) = atn_cmds::export("Profile", &[&assign], &[], false).unwrap(); @@ -222,12 +222,12 @@ fn defaults_are_explicit_false_and_assign_profile_is_unchanged() { #[test] fn unsupported_values_are_preserved_and_invalid_native_values_refuse_export() { let mut descriptors = Vec::new(); - for value in [D::Integer(3), D::Integer(4), D::Integer(-1), D::Integer(999), D::Boolean(true), D::Double(2.0)] { + for value in [D::Integer(3), D::Integer(4), D::Integer(2), D::Integer(999), D::Boolean(true), D::Double(2.0)] { descriptors.push(descriptor().with("sdwM", value)); } for (key, _) in FLAGS { for value in [D::Integer(1), D::Text(UnicodeString::new_nul("true"))] { - descriptors.push(descriptor().with("sdwM", D::Integer(2)).with(key, value)); + descriptors.push(descriptor().with("sdwM", D::Integer(-1)).with(key, value)); } } for d in descriptors { @@ -251,3 +251,36 @@ fn unsupported_values_are_preserved_and_invalid_native_values_refuse_export() { let action = Action::new("Invalid radio null", vec![("edit.convertToProfile".into(), json!({"profile": "srgb", "sdwM": null}))]); assert!(atn_cmds::export("Profile", &[&action], &[], false).is_err()); } + +/// A step recorded by the reference application (`sdwM` -1, dither on) maps and saves back as recorded. +#[test] +fn reference_recorded_convert_to_profile_maps() { + let d = Descriptor::new("null") + .with( + "null", + D::Reference(vec![photocraft_psd::descriptor::ReferenceItem::Enumerated { + class: photocraft_psd::descriptor::Class { name: UnicodeString::new_nul(""), class_id: Id::new("Dcmn") }, + type_id: Id::new("Ordn"), + value: Id::new("Trgt"), + }]), + ) + .with("T ", D::Text(UnicodeString::new_nul("ProPhoto RGB"))) + .with("Inte", D::Enumerated { type_id: Id::new("Inte"), value: Id::new("AClr") }) + .with("Dthr", D::Boolean(true)) + .with("Fltt", D::Boolean(false)) + .with("rasterizePlaced", D::Boolean(false)) + .with("sdwM", D::Integer(-1)); + let mut s = fixture(); + assert_eq!(load(&mut s, &recorded(d))["unsupported"], json!([])); + assert_eq!( + s.actions.list[0].steps, + vec![( + "edit.convertToProfile".into(), + json!({"profile": "prophoto-compat", "intent": "absolute", "dither": true, "flatten": false, "rasterizePlaced": false}) + )] + ); + let saved = atn::parse(&save(&mut s)).unwrap(); + let saved = saved.actions[0].items[0].descriptor.as_ref().unwrap(); + assert_eq!(saved.get("sdwM"), Some(&D::Integer(-1))); + assert_eq!(saved.get("Dthr"), Some(&D::Boolean(true))); +} diff --git a/crates/engine/tests/convert_profile_dither.rs b/crates/engine/tests/convert_profile_dither.rs index 54cc62a31f..73b0441c23 100644 --- a/crates/engine/tests/convert_profile_dither.rs +++ b/crates/engine/tests/convert_profile_dither.rs @@ -209,3 +209,21 @@ fn refusals_are_early_and_atomic() { assert!(state.history.can_redo()); } } + +/// Absent tiles of a transparent layer stay absent: only the stored tile is dithered. +#[test] +fn transparent_layers_stay_sparse() { + let p = json!({"profile": "display-p3", "dither": true}); + let stored = |size: Size| { + let mut surface = Surface::with_default(PixelFormat::RGBA8, &[0.0; 4]); + let tile = surface.solid_tile(&GRAY); + surface.put_tiles([(TileCoord::new(1, 1), tile)]); + document(size, surface, SampleType::U8) + }; + let large = converted(stored(Size::new(1024, 1024)), p.clone()); + assert_eq!(pixels(&large).tile_count(), 1, "a transparent layer must not become dense"); + let small = converted(stored(Size::new(512, 512)), p); + let tile = |doc: &Document| pixels(doc).tile(TileCoord::new(1, 1)).unwrap().bytes().to_vec(); + assert_eq!(tile(&large), tile(&small), "the stored tile is dithered as before"); + assert_eq!(pixels(&large).pixel(700, 700)[3], 0.0); +} diff --git a/crates/engine/tests/image_size_scale_styles_atn.rs b/crates/engine/tests/image_size_scale_styles_atn.rs index a177202a98..e6a5028458 100644 --- a/crates/engine/tests/image_size_scale_styles_atn.rs +++ b/crates/engine/tests/image_size_scale_styles_atn.rs @@ -5,7 +5,7 @@ use photocraft_doc::{Effect, Effects, FxCommon, FxPaint, LayerId, StrokeFx, Stro use photocraft_engine::{Session, actions_cmds::Action, atn_cmds}; use photocraft_psd::{ atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}, - descriptor::{Descriptor, UnicodeString, Value as D}, + descriptor::{Descriptor, Id, UnicodeString, Value as D}, }; use serde_json::{Value, json}; @@ -83,7 +83,8 @@ fn imported_style_scaling_survives_saved_bytes_and_playback() { assert_eq!(s.actions.list[0].steps, vec![("image.imageSize".into(), json!({"percent": 50, "scaleStyles": scale}))]); let saved = save(&mut s); let parsed = atn::parse(&saved).unwrap(); - assert_eq!(parsed.actions[0].items[0].descriptor.as_ref().unwrap().get("scaleStyles"), Some(&D::Boolean(scale))); + // Only scaling needs the key: without it the step keeps style sizes. + assert_eq!(parsed.actions[0].items[0].descriptor.as_ref().unwrap().get("scaleStyles"), scale.then_some(&D::Boolean(true))); let imported = atn_cmds::import(&saved).unwrap(); assert!(imported.unsupported.is_empty()); assert_eq!(imported.actions[0].steps, s.actions.list[0].steps); @@ -109,7 +110,7 @@ fn native_default_is_written_explicitly_and_keeps_its_behavior() { let (bytes, skipped) = atn_cmds::export("Styles", &[&action], &[], false).unwrap(); assert!(skipped.is_empty()); let parsed = atn::parse(&bytes).unwrap(); - assert_eq!(parsed.actions[0].items[0].descriptor.as_ref().unwrap().get("scaleStyles"), Some(&D::Boolean(scale)), "{params}"); + assert_eq!(parsed.actions[0].items[0].descriptor.as_ref().unwrap().get("scaleStyles"), scale.then_some(&D::Boolean(true)), "{params}"); let (mut direct, direct_id) = fixture(); direct.execute("image.imageSize", params).unwrap(); assert_size_and_style(&direct, direct_id, scale); @@ -150,3 +151,21 @@ fn malformed_or_empty_steps_are_kept_without_becoming_resizes() { } } } + +/// A recorded Image Size step without `scaleStyles` (Fit Image records none) saves back identically. +#[test] +fn step_without_scale_styles_saves_back_identically() { + for event in ["ImgS", "imageSize"] { + let fit = Descriptor::new("null") + .with("Wdth", D::UnitFloat { unit: *b"#Pxl", value: 100.0 }) + .with("Hght", D::UnitFloat { unit: *b"#Pxl", value: 78.0 }) + .with("Rslt", D::UnitFloat { unit: *b"#Rsl", value: 72.0 }) + .with("Intr", D::Enumerated { type_id: Id::new("Intp"), value: Id::new("Bcbc") }); + let source = recorded(event, Some(fit)); + let original = atn::parse(&source).unwrap(); + let mut s = Session::new(); + assert_eq!(load(&mut s, &source)["unsupported"], json!([])); + let saved = atn::parse(&save(&mut s)).unwrap(); + assert_eq!(saved.actions[0].items[0].descriptor, original.actions[0].items[0].descriptor); + } +} diff --git a/crates/engine/tests/vector_paste_options.rs b/crates/engine/tests/vector_paste_options.rs index 8f22954bbf..b9548f15db 100644 --- a/crates/engine/tests/vector_paste_options.rs +++ b/crates/engine/tests/vector_paste_options.rs @@ -279,10 +279,14 @@ fn atn_classes_roundtrip_and_play_real_modes() { for (class_id, mode) in [("Pxel", "pixels"), ("Path", "path"), ("shapeClass", "shape")] { let mut s = scene(8); assert_eq!(load(&mut s, &recorded(descriptor(class_id)))["unsupported"], json!([])); - assert_eq!(s.actions.list[0].steps, vec![("edit.paste".into(), json!({"as":mode}))]); + // `As Pxel` is what an ordinary Paste records: it keeps the ordinary route. + let params = if mode == "pixels" { json!({}) } else { json!({"as":mode}) }; + assert_eq!(s.actions.list[0].steps, vec![("edit.paste".into(), params)]); let saved = save(&mut s); let file = atn::parse(&saved).unwrap(); - assert_eq!(file.actions[0].items[0].descriptor.as_ref().unwrap().get("As "), descriptor(class_id).get("As ")); + if mode != "pixels" { + assert_eq!(file.actions[0].items[0].descriptor.as_ref().unwrap().get("As "), descriptor(class_id).get("As ")); + } let imported = atn_cmds::import(&saved).unwrap(); assert_eq!(imported.actions[0].steps, s.actions.list[0].steps); s.actions.list = imported.actions; @@ -333,3 +337,35 @@ fn atn_unknown_forms_are_kept_and_legacy_omission_stays_legacy() { assert!(atn_cmds::export("Vector", &[&action], &[], false).is_err()); } } + +fn recorded_pixels_paste(in_place: bool) -> Descriptor { + let d = Descriptor::new("null").with("AntA", D::Enumerated { type_id: Id::new("Annt"), value: Id::new("Anno") }); + let d = if in_place { d.with("inPlace", D::Boolean(true)) } else { d }; + d.with("As ", descriptor("Pxel").get("As ").unwrap().clone()) +} + +/// A recorded ordinary Paste (`AntA Anno`, `As Pxel`) keeps whole-layer recreation of a copied layer. +#[test] +fn recorded_pixels_paste_keeps_the_ordinary_route() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 20, "height": 16, "background": "transparent"})).unwrap(); + s.clipboard = Some(Clip::from_svg(SVG).unwrap()); + s.execute("edit.paste", json!({"as":"shape"})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + assert_eq!(load(&mut s, &recorded(recorded_pixels_paste(false)))["unsupported"], json!([])); + assert_eq!(s.actions.list[0].steps, vec![("edit.paste".into(), json!({}))]); + let layers = s.active().unwrap().doc.layers.len(); + let result = s.execute("actions.play", json!({"action":"Paste"})).unwrap(); + assert_eq!(result["ran"], 1); + assert!(result.get("failed").is_none(), "{result}"); + assert!(s.active().unwrap().doc.layers.len() > layers); + assert!(matches!(output(&s).content, LayerContent::Shape(_)), "a copied shape layer pastes as a shape layer"); +} + +/// A recorded Paste in Place (`inPlace true`, `AntA Anno`, `As Pxel`) plays as Paste in Place. +#[test] +fn recorded_pixels_paste_in_place_maps() { + let mut s = Session::new(); + assert_eq!(load(&mut s, &recorded(recorded_pixels_paste(true)))["unsupported"], json!([])); + assert_eq!(s.actions.list[0].steps, vec![("edit.pasteSpecial.pasteInPlace".into(), json!({}))]); +} diff --git a/docs/atn.md b/docs/atn.md index d48214cc2f..6a93e21a08 100644 --- a/docs/atn.md +++ b/docs/atn.md @@ -145,14 +145,15 @@ the reference application. - Image Size preserves `scaleStyles`. Native calls default to true; an absent ATN flag means false. Crop and resolution-only resizing retain their existing behavior. - Convert to Profile preserves `Dthr`, `Fltt` and `rasterizePlaced`, defaulting to false. - Supported RGB destinations use `sdwM: 2`; other model codes and malformed flags remain kept - steps. Absolute intent writes `AClr`, with `AbsC` still accepted on import. + `sdwM` is written as the reference application records it (-1); other values and malformed + flags remain kept steps. Absolute intent writes `AClr`, with `AbsC` still accepted on import. - Content-Aware Fill preserves colour, quarter-turn rotation, scale and mirror adaptation. Existing native defaults remain unchanged. - Paste Layer Style preserves `allowPasteFXOnLayerSet`. False skips selected groups without applying their styles to children; a selection with no eligible targets fails without editing. -- Paste preserves `As ` as pixels, path or shape using the original bounded SVG source. - Unsupported geometry fails instead of silently rasterizing. Omission retains ordinary Paste. +- Paste preserves `As ` path or shape using the original bounded SVG source. Unsupported + geometry fails instead of silently rasterizing. `As Pxel` plays as an ordinary Paste (or Paste + in Place), as does omission. Generated fixtures verify these native options and their load/save/playback behavior. Matching Photoshop conversion-order, SVG rasterization and placement comparisons were not available. From c788695a5923d61dac2a9f6350994a16688daff1 Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 2 Nov 2026 03:21:42 +0100 Subject: [PATCH 154/216] Integration: event_for keeps the batch's lookup plus #10632's routing of effect events (#10632) WIP: gallery_effect_steps_save_back_as_the_event_they_were_recorded_as still fails (Accented Edges step refused on save); to resolve next. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/atn_cmds/events.rs | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/cmd/atn_cmds/events.rs b/crates/engine/src/cmd/atn_cmds/events.rs index fe10bb97ba..05f73ccb15 100644 --- a/crates/engine/src/cmd/atn_cmds/events.rs +++ b/crates/engine/src/cmd/atn_cmds/events.rs @@ -884,20 +884,16 @@ const NAME_ALIASES: &[(&str, &str)] = &[("interfaceWhite", "intersectWith"), ("d pub(super) fn event_for(item: &AtnEvent) -> Option<&'static Event> { let id = item.id(); let by_name = |name: &str| EVENTS.iter().find(|e| !e.name.is_empty() && e.name == name); - match item { - AtnEvent::Code(_) => EVENTS - .iter() - .find(|e| !e.code.is_empty() && e.code.trim_end() == id.trim_end()) - .or_else(|| LEGACY_CODES.iter().find(|(code, _)| *code == id).and_then(|(_, name)| by_name(name))), - AtnEvent::Name(_) => by_name(&id).or_else(|| NAME_ALIASES.iter().find(|(old, _)| *old == id).and_then(|(_, name)| by_name(name))), - } - let find = |id: &str| EVENTS.iter().find(|e| (!e.code.is_empty() && e.code.trim_end() == id.trim_end()) || e.name == id); - find(&id).or_else(|| match item { + let found = match item { + AtnEvent::Code(_) => EVENTS.iter().find(|e| !e.code.is_empty() && e.code.trim_end() == id.trim_end()), + AtnEvent::Name(_) => by_name(&id), + }; + found.or_else(|| match item { // An effect run from its own menu item records its id as the event (#10592). // By 4-char code or, as newer versions record it, by string ID (`cutout`). - _ if super::steps_gallery::is_effect(item) => find("filterGallery"), - AtnEvent::Code(_) => LEGACY_CODES.iter().find(|(code, _)| *code == id).and_then(|(_, name)| find(name)), - AtnEvent::Name(_) => None, + _ if super::steps_gallery::is_effect(item) => by_name("filterGallery"), + AtnEvent::Code(_) => LEGACY_CODES.iter().find(|(code, _)| *code == id).and_then(|(_, name)| by_name(name)), + AtnEvent::Name(_) => NAME_ALIASES.iter().find(|(old, _)| *old == id).and_then(|(_, name)| by_name(name)), }) } From 86ddfb72f766be906109bb935c7b17ab74814ea5 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Mon, 2 Nov 2026 13:59:59 +0530 Subject: [PATCH 155/216] Reuse large buffers so returning freed memory to the OS doesn't slow filters (#10479) (#10514) * perf: reuse large buffers so a pinned glibc trim threshold costs no speed (#10479) With MALLOC_TRIM_THRESHOLD_ set (#8743), glibc maps every allocation over 128 KiB fresh, so tiles, compositor bands and filter scratch were page-faulted in on every call: viewport compose 3.6x slower, Gaussian Blur up to 1.8x, .pcraft save 2.4x. - photocraft_raster::scratch: a process-wide, capped pool of large Vec buffers (u8/u16/f32/[f32; 4]). A miss drops the oldest buffers worth its size down to a 256 MiB floor, so peak memory stays near glibc's defaults. The engine releases the pool after any command that closes a document, Headless::close does too, and an idle thread releases it after 2 s unused. - Tiles, compose Buffer/masks/bands, algo Image and the blur paths, engine adjustment/filter scratch, GPU tile upload and io import buffers go through the pool. - .pcraft save reuses one zstd FrameCompressor per worker (byte-identical). - interactive_bench reports minor page faults per run. 36 MP, trim threshold set vs glibc defaults: viewport 5.6 vs 6.1 ms (was 22.7), Blur r4 445 vs 437 ms (was 852), Curves CPU proxy 35.0 vs 35.7 ms (was 50), flatten 146 vs 147 ms (was 237), save .pcraft 251 vs 230 ms (was 545). RSS after closing a 5400x7200 document: 21 MB with trim set. * fix: corpus tests build again; return remaining pooled buffers (#10479 review) - Corpus-gated io tests and the Photoshop-oracle test moved `.px` out of a `Buffer`, which now implements `Drop` (E0509 with `--features corpus`): use `into_px()`. - io: masks, the merged image, extra channels, smart-filter masks, smart-object pixels and patterns give their `interleave` buffers back to the scratch pool after writing them. - compose: recycle the mask values of a pass-through group, `effect_shape` and both `split_parts` callers. - Styled Free Transform preview copies each band out instead of moving it, so the band returns to the pool. - interactive_bench: the fault count covers each whole run, untimed setup included; say so. * fix: recycle the masks of adjustment-atop and shape layers (#10479 review) * fix: recycle effect hiding masks (#10479 review) * fix: recycle knockout, CMYK and GPU shape masks; release the pool when Split/Merge Channels close a document (#10479 review) * fix: release the pool when a closed document's cancelled workers exit; idle-release switch for tests; don't pool PSD planes (#10479 review) * fix: release the pool for workers cancelled before their document closes; take the pool lock for budget and idle-release changes (#10479 review) * fix: idle release rechecks activity under the lock; try_take_exact releases the pool and retries before failing (#10479 review) * test: cover a successful try_take_exact miss; name the failed-reservation test for what it checks (#10479 review) * fix: pool the final GPU effect shape buffer and return it after use (#10479 review) * docs: describe scratch pool lifecycle regression invariants * test: verify polling releases cancelled document scratch buffers * perf: recycle remaining transform selection and PSD scratch buffers * fix: free PSD gather bands before allocating channel planes * test: assert exact scratch take reuses pooled storage --- crates/algo/src/blur.rs | 47 ++- crates/algo/src/blur/tests.rs | 5 +- crates/algo/src/image.rs | 29 +- crates/algo/src/lib.rs | 6 +- crates/algo/src/sharpen/tests.rs | 7 +- crates/algo/src/transform.rs | 1 + crates/automation/src/headless.rs | 2 + crates/compose/src/advanced.rs | 15 +- crates/compose/src/advanced_tests.rs | 8 +- crates/compose/src/cmyk.rs | 4 + crates/compose/src/composite.rs | 36 +- crates/compose/src/content.rs | 63 +-- crates/compose/src/extents.rs | 12 +- crates/compose/src/lib.rs | 27 +- crates/compose/src/render.rs | 5 +- crates/compose/src/soft_shape_tests.rs | 2 +- crates/compose/src/tests.rs | 20 +- crates/compose/tests/buffer_reuse.rs | 27 ++ crates/engine/examples/indexed_color_perf.rs | 2 +- crates/engine/src/cmd/adjust_cmds.rs | 6 +- crates/engine/src/cmd/adjust_cmds/tests.rs | 2 +- crates/engine/src/cmd/face_cmds.rs | 2 +- crates/engine/src/cmd/layer_menu_cmds.rs | 2 +- .../engine/src/cmd/layer_menu_cmds/tests.rs | 8 +- crates/engine/src/cmd/mask_view_cmds/tests.rs | 2 +- crates/engine/src/cmd/mode_cmds.rs | 2 +- crates/engine/src/cmd/mode_cmds/tests.rs | 4 +- crates/engine/src/cmd/select_extra_cmds.rs | 2 +- crates/engine/src/cmd/selection_cmds.rs | 2 +- crates/engine/src/cmd/smart_cmds/rendering.rs | 2 +- crates/engine/src/cmd/smart_cmds/tests.rs | 2 +- crates/engine/src/cmd/smartselect_cmds.rs | 2 +- crates/engine/src/cmd/transform_cmds/tests.rs | 2 +- crates/engine/src/cmd/video_cmds/tests.rs | 2 +- crates/engine/src/color_channels/tone.rs | 2 +- crates/engine/src/doc_path/tests.rs | 6 +- crates/engine/src/filters.rs | 2 + crates/engine/src/jobs.rs | 59 ++- crates/engine/src/pixels.rs | 13 +- crates/engine/src/select_mask_edits.rs | 4 +- crates/engine/src/tests.rs | 4 +- .../tests/it/atn_crystallize_cell_size.rs | 2 +- crates/engine/tests/it/atn_ripple_amount.rs | 2 +- crates/engine/tests/it/atn_selective_color.rs | 2 +- crates/engine/tests/it/atn_stroke_width.rs | 2 +- crates/engine/tests/it/photoshop_oracles.rs | 2 +- crates/engine/tests/scratch_release.rs | 114 ++++++ crates/format/src/store.rs | 133 +++++- crates/gpu/src/convert.rs | 14 +- crates/gpu/src/fx.rs | 12 +- crates/gpu/src/fx_passes.rs | 2 + crates/gpu/tests/it/parity.rs | 2 +- crates/io/examples/oracle_diff.rs | 4 +- crates/io/src/flat.rs | 9 +- crates/io/src/lib.rs | 4 +- crates/io/src/ora.rs | 4 +- crates/io/src/pattern_map.rs | 4 +- crates/io/src/pixels.rs | 7 +- crates/io/src/psd_export.rs | 2 + crates/io/src/psd_import.rs | 8 + crates/io/src/smart_map.rs | 2 + crates/io/tests/it/banded.rs | 2 +- crates/io/tests/it/blend_modes_corpus.rs | 2 +- crates/io/tests/it/cmyk_composite_export.rs | 3 +- crates/io/tests/it/color.rs | 8 +- crates/io/tests/it/composite.rs | 6 +- crates/io/tests/it/corpus.rs | 4 +- crates/io/tests/it/effects_multi.rs | 2 +- crates/io/tests/it/flat.rs | 2 +- crates/io/tests/it/lab16.rs | 4 +- crates/io/tests/it/large_files.rs | 2 +- crates/io/tests/it/multichannel.rs | 4 +- crates/io/tests/it/psd32_export.rs | 4 +- crates/io/tests/it/psd_damaged_merged.rs | 2 +- crates/io/tests/it/psd_type_bounds.rs | 2 +- crates/io/tests/it/tiff_corpus.rs | 4 +- crates/io/tests/it/tiff_layers.rs | 4 +- crates/raster/src/lib.rs | 38 +- crates/raster/src/scratch.rs | 384 ++++++++++++++++++ crates/raster/tests/scratch.rs | 361 ++++++++++++++++ crates/testkit/src/perf.rs | 21 + crates/ui-egui/examples/interactive_bench.rs | 19 +- crates/ui-egui/src/dialogs/color_range_ui.rs | 2 +- crates/ui-egui/src/dialogs/file_ui/web.rs | 2 +- .../src/dialogs/filter_dialog/tests.rs | 2 +- crates/ui-egui/src/dialogs/vp_ui/tests.rs | 8 +- crates/ui-egui/src/tools/transform_fx.rs | 3 +- crates/ui-egui/tests/it/adjust_preview_gpu.rs | 6 +- 88 files changed, 1444 insertions(+), 238 deletions(-) create mode 100644 crates/compose/tests/buffer_reuse.rs create mode 100644 crates/engine/tests/scratch_release.rs create mode 100644 crates/raster/src/scratch.rs create mode 100644 crates/raster/tests/scratch.rs diff --git a/crates/algo/src/blur.rs b/crates/algo/src/blur.rs index 819bf6fa78..7ca9d34f99 100644 --- a/crates/algo/src/blur.rs +++ b/crates/algo/src/blur.rs @@ -1,7 +1,7 @@ //! Blurs: Gaussian, box, motion, radial, surface. use photocraft_geom::Rect; -use photocraft_raster::Interrupt; +use photocraft_raster::{Interrupt, scratch}; use crate::image::{Edge, Image, premultiply, unpremultiply}; use crate::photo_util::{par_map, par_rows}; @@ -78,7 +78,7 @@ pub(crate) fn conv_sep(src: &Image, out: Rect, kx: &[f32], ky: &[f32], alpha: bo // Premultiplied copy of the needed source window. let win = Rect::new(out.x0 - rx, out.y0 - ry, out.x1 + rx, out.y1 + ry); let ww = win.width() as usize; - let mut p = vec![0.0f32; ww * win.height() as usize * n]; + let mut p = scratch::Buf::new(ww * win.height() as usize * n, 0.0f32); for y in win.y0..win.y1 { for x in win.x0..win.x1 { let o = ((y - win.y0) as usize * ww + (x - win.x0) as usize) * n; @@ -89,7 +89,7 @@ pub(crate) fn conv_sep(src: &Image, out: Rect, kx: &[f32], ky: &[f32], alpha: bo } premultiply(&mut p, n, alpha); // Horizontal pass: (ow × th). - let mut tmp = vec![0.0f32; ow * th * n]; + let mut tmp = scratch::Buf::new(ow * th * n, 0.0f32); for ty in 0..th { let row = &p[ty * ww * n..(ty + 1) * ww * n]; let dst = &mut tmp[ty * ow * n..(ty + 1) * ow * n]; @@ -113,7 +113,7 @@ pub(crate) fn conv_sep(src: &Image, out: Rect, kx: &[f32], ky: &[f32], alpha: bo } } // Vertical pass. - let mut res = vec![0.0f32; ow * oh * n]; + let mut res = scratch::take(ow * oh * n, 0.0f32); for oy in 0..oh { let d = &mut res[oy * ow * n..(oy + 1) * ow * n]; for (i, kv) in ky.iter().enumerate() { @@ -182,11 +182,27 @@ pub(crate) fn box_widths(params: &FilterParams) -> Option> { } /// Scratch buffers of one worker for [`box_blur`]. -#[derive(Default)] struct Scratch { line: Vec, next: Vec, acc: Vec, + /// The source row being read (horizontal passes only). + row: Vec, +} + +impl Scratch { + /// Buffers with room for lines of `line` floats (and the shorter ones after the first pass). + fn new(line: usize, row: usize) -> Self { + Scratch { line: scratch::take_cap(line), next: scratch::take_cap(line), acc: Vec::new(), row: scratch::take_cap(row) } + } +} + +impl Drop for Scratch { + fn drop(&mut self) { + scratch::give(std::mem::take(&mut self.line)); + scratch::give(std::mem::take(&mut self.next)); + scratch::give(std::mem::take(&mut self.row)); + } } /// One running-sum box pass of radius `r` along a line of elements of `w` floats each (a pixel's @@ -252,7 +268,7 @@ fn box_pass_px(src: &[f32], dst: &mut [f32], r: usize) { /// `dst` unfinished) when `ctl` is cancelled before a pass: a column strip's passes take tens of /// milliseconds on a tall image, too long to finish once cancelled (#6205). fn line_passes(dst: &mut [f32], w: usize, radii: &[usize], s: &mut Scratch, ctl: &Interrupt) { - let Scratch { line, next, acc } = s; + let Scratch { line, next, acc, .. } = s; let Some(len) = dst.len().checked_div(w) else { return }; let mut tail: usize = radii.iter().sum(); for (k, &r) in radii.iter().enumerate() { @@ -306,21 +322,21 @@ pub(crate) fn box_blur( // Horizontal passes over `used`'s rows only (the repeated rows above and below it would just // repeat their results), for the output columns. Each row is extended to the passes' reach by // repeating its end pixels. - let mut h = vec![0.0f32; used.height() as usize * ow * n]; + let mut h = scratch::Buf::new(used.height() as usize * ow * n, 0.0f32); let (left, right) = ((used.x0 - out.x0.saturating_sub(reach)) as usize, (out.x1.saturating_add(reach) - used.x1) as usize); par_rows(&mut h, ow * ROWS_PER_TASK, n, |task, chunk| { - let (mut row, mut s) = (Vec::new(), Scratch::default()); + let mut s = Scratch::new((used.width() as usize + left + right) * n, used.width() as usize * n); for (k, dst) in chunk.chunks_exact_mut(ow * n).enumerate() { if ctl.cancelled() { return; } let y = used.y0 + (task * ROWS_PER_TASK + k) as i32; - read(Rect::new(used.x0, y, used.x1, y + 1), &mut row); - premultiply(&mut row, n, alpha); - let (Some(first), Some(last)) = (row.get(..n), row.get(row.len().saturating_sub(n)..)) else { return }; + read(Rect::new(used.x0, y, used.x1, y + 1), &mut s.row); + premultiply(&mut s.row, n, alpha); + let (Some(first), Some(last)) = (s.row.get(..n), s.row.get(s.row.len().saturating_sub(n)..)) else { return }; s.line.clear(); (0..left).for_each(|_| s.line.extend_from_slice(first)); - s.line.extend_from_slice(&row); + s.line.extend_from_slice(&s.row); (0..right).for_each(|_| s.line.extend_from_slice(last)); line_passes(dst, n, &radii, &mut s, ctl); } @@ -336,11 +352,11 @@ pub(crate) fn box_blur( } let (sx, w) = ((t.x0 - out.x0) as usize * n, t.width() as usize * n); let row = |y: usize| &h[y * stride + sx..y * stride + sx + w]; - let mut s = Scratch::default(); + let mut s = Scratch::new((rows + top + bottom) * w, 0); (0..top).for_each(|_| s.line.extend_from_slice(row(0))); (0..rows).for_each(|y| s.line.extend_from_slice(row(y))); (0..bottom).for_each(|_| s.line.extend_from_slice(row(rows - 1))); - let mut res = vec![0.0f32; oh * w]; + let mut res = scratch::take(oh * w, 0.0f32); line_passes(&mut res, w, &radii, &mut s, ctl); if ctl.cancelled() { return (t, Vec::new()); @@ -367,7 +383,7 @@ fn box_passes(src: &Image, out: Rect, alpha: bool, boxes: &[usize]) -> Vec buf.extend_from_slice(src.row(r)); }; let ow = out.width() as usize * n; - let mut res = vec![0.0f32; ow * out.height() as usize]; + let mut res = scratch::take(ow * out.height() as usize, 0.0f32); if res.is_empty() { return res; } @@ -376,6 +392,7 @@ fn box_passes(src: &Image, out: Rect, alpha: bool, boxes: &[usize]) -> Vec for (dst, s) in res.chunks_exact_mut(ow).zip(data.chunks_exact(w)) { dst[x0..x0 + w].copy_from_slice(s); } + scratch::give(data); } res } diff --git a/crates/algo/src/blur/tests.rs b/crates/algo/src/blur/tests.rs index 028ac65dcc..74cf7ab20c 100644 --- a/crates/algo/src/blur/tests.rs +++ b/crates/algo/src/blur/tests.rs @@ -204,7 +204,7 @@ fn direct_boxes(img: &Image, out: Rect, boxes: &[usize], alpha: bool) -> Vec = (0..(len + 2 * reach) * w).map(|i| (i % 7) as f32 + 1.0).collect(); let run = |ctl: &Interrupt| { - let mut s = Scratch { line: line.clone(), ..Scratch::default() }; + let mut s = Scratch::new(0, 0); + s.line.extend_from_slice(&line); let mut dst = vec![0.0f32; len * w]; line_passes(&mut dst, w, &radii, &mut s, ctl); dst diff --git a/crates/algo/src/image.rs b/crates/algo/src/image.rs index 6a17e08b01..c521f04348 100644 --- a/crates/algo/src/image.rs +++ b/crates/algo/src/image.rs @@ -1,7 +1,7 @@ //! Interleaved `f32` image regions and sampling helpers. use photocraft_geom::Rect; -use photocraft_raster::Surface; +use photocraft_raster::{Surface, scratch}; /// How samples outside an image are read. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -14,8 +14,9 @@ pub enum Edge { Wrap, } -/// A rectangle of interleaved, normalized samples. -#[derive(Clone, Debug, PartialEq)] +/// A rectangle of interleaved, normalized samples. The samples come from, and go back to, the +/// scratch pool. +#[derive(Debug, PartialEq)] pub struct Image { pub rect: Rect, /// Channels per pixel (alpha last when the surface has alpha). @@ -23,6 +24,20 @@ pub struct Image { pub data: Vec, } +impl Clone for Image { + fn clone(&self) -> Self { + let mut data = scratch::take(self.data.len(), 0.0f32); + data.copy_from_slice(&self.data); + Image { rect: self.rect, ch: self.ch, data } + } +} + +impl Drop for Image { + fn drop(&mut self) { + scratch::give(std::mem::take(&mut self.data)); + } +} + impl Image { /// Reads `rect` from a surface. pub fn read(s: &Surface, rect: Rect) -> Self { @@ -53,9 +68,15 @@ impl Image { img } + /// The samples, taken out of the image (it can't be destructured: it returns them to the + /// pool on drop). + pub fn into_data(mut self) -> Vec { + std::mem::take(&mut self.data) + } + /// New zeroed image. pub fn new(rect: Rect, ch: usize) -> Self { - Image { rect, ch, data: vec![0.0; rect.width() as usize * rect.height() as usize * ch] } + Image { rect, ch, data: scratch::take(rect.width() as usize * rect.height() as usize * ch, 0.0) } } #[inline] diff --git a/crates/algo/src/lib.rs b/crates/algo/src/lib.rs index 03ffe38144..bda29cb2ea 100644 --- a/crates/algo/src/lib.rs +++ b/crates/algo/src/lib.rs @@ -1269,7 +1269,10 @@ fn apply_tiled_with_impl( } for (t, data) in results { match data { - TileData::Samples(data) => out.write_region(t, &data), + TileData::Samples(data) => { + out.write_region(t, &data); + photocraft_raster::scratch::give(data); + } TileData::Encoded(data) => out.write_interleaved(t, &data), } } @@ -1324,6 +1327,7 @@ fn apply_box_blur( mix_selection(data, *t, sel, &Image::read(surface, *t), fmt.alpha); } s.write_region(*t, data); + photocraft_raster::scratch::give(std::mem::take(data)); } }; #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/algo/src/sharpen/tests.rs b/crates/algo/src/sharpen/tests.rs index 4699bb3a10..aef62259a3 100644 --- a/crates/algo/src/sharpen/tests.rs +++ b/crates/algo/src/sharpen/tests.rs @@ -267,7 +267,12 @@ fn malformed_inputs_and_allocations_stop_gracefully() { assert!(crate::apply_in_with(&s, &p, rect, rect, None, rect, &Interrupt::NONE).is_none()); assert_eq!(crate::apply_in(&s, &p, rect, rect, None, rect), s); } - for bad in [Image { ch: 0, ..src.clone() }, Image { data: vec![0.0], ..src.clone() }, Image { rect: Rect::new(5, 0, 0, 2), ..src.clone() }] { + let with = |edit: &dyn Fn(&mut Image)| { + let mut bad = src.clone(); + edit(&mut bad); + bad + }; + for bad in [with(&|i| i.ch = 0), with(&|i| i.data = vec![0.0]), with(&|i| i.rect = Rect::new(5, 0, 0, 2))] { assert!(exact::run(&bad, rect, &ctx, 100.0, 1.0, 0.0, &Interrupt::NONE).is_none()); } for rect in [Rect::new(i32::MIN, 0, i32::MAX, 2), Rect::new(0, 0, 300_000, 300_000), Rect::new(2, 0, 0, 2)] { diff --git a/crates/algo/src/transform.rs b/crates/algo/src/transform.rs index 1ed0ec7c04..b1f7bacaae 100644 --- a/crates/algo/src/transform.rs +++ b/crates/algo/src/transform.rs @@ -521,6 +521,7 @@ fn warp( outp[o + a] = al as f32; } } + photocraft_raster::scratch::give(px); Ok(any.then_some((*t, outp))) }; // Each tile is written as soon as it is warped, so only the tiles in flight hold their `f32` diff --git a/crates/automation/src/headless.rs b/crates/automation/src/headless.rs index 6722c5c00b..8b08daa6aa 100644 --- a/crates/automation/src/headless.rs +++ b/crates/automation/src/headless.rs @@ -358,6 +358,8 @@ impl Headless { if let Some(d) = self.session.close(i) { self.writers.remove(&d.doc.id.0); } + // The closed document's buffers went to the scratch pool: hand them back to the OS. + photocraft_raster::scratch::release(); Ok(self.session_list()) } diff --git a/crates/compose/src/advanced.rs b/crates/compose/src/advanced.rs index 8ea5a86c6d..7a4068012d 100644 --- a/crates/compose/src/advanced.rs +++ b/crates/compose/src/advanced.rs @@ -32,6 +32,7 @@ use std::borrow::Cow; use photocraft_doc::{Knockout, Layer, LayerContent}; use photocraft_geom::Rect; +use crate::extents::recycle_mask; use crate::{Buffer, Ctx, composite_atop, composite_layer, effects, mask_vals, render_content}; /// Where a layer stack sits, for knockouts: at the document's top level, and/or inside an @@ -157,6 +158,7 @@ pub(crate) fn knockout(layer: &Layer, backdrop: &mut Buffer, target: &Buffer, cl if target.rect != backdrop.rect || shape.len() != backdrop.px.len() { // Never reached (targets are built over the backdrop's rect); compositing without the // knockout beats a wrong pixel or a panic. + recycle_mask(Some(shape)); draw(backdrop); return; } @@ -172,7 +174,10 @@ pub(crate) fn knockout(layer: &Layer, backdrop: &mut Buffer, target: &Buffer, cl } let knocked = (layer.opacity < 1.0).then(|| backdrop.clone()); draw(backdrop); - let Some(knocked) = knocked else { return }; + let Some(knocked) = knocked else { + recycle_mask(Some(shape)); + return; + }; let rest = 1.0 - layer.opacity.clamp(0.0, 1.0); for (((p, b), k), s) in backdrop.px.iter_mut().zip(&before.px).zip(&knocked.px).zip(&shape) { if *s <= 0.0 { @@ -186,6 +191,7 @@ pub(crate) fn knockout(layer: &Layer, backdrop: &mut Buffer, target: &Buffer, cl let a = pm[3].clamp(0.0, 1.0); *p = if a > 0.0 { [(pm[0] / a).clamp(0.0, 1.0), (pm[1] / a).clamp(0.0, 1.0), (pm[2] / a).clamp(0.0, 1.0), a] } else { [0.0; 4] }; } + recycle_mask(Some(shape)); } /// Composite a clipping group's clipped layers atop `base` (the base's isolated content, Blend @@ -220,7 +226,12 @@ pub(crate) fn composite_clipped_individually(layer: &Layer, clipped: &[Layer], b let op = layer.opacity.clamp(0.0, 1.0); let clip: Vec = match render_content(layer, rect, cx) { Some(b) => b.px.iter().map(|p| p[3] * op).collect(), - None => mask_vals(layer, rect, cx).unwrap_or_else(|| vec![1.0; n]).iter().map(|m| m * op).collect(), + None => { + let mv = mask_vals(layer, rect, cx); + let clip = mv.as_ref().map_or_else(|| vec![op; n], |m| m.iter().map(|m| m * op).collect()); + recycle_mask(mv); + clip + } }; if clip.len() != n { return; diff --git a/crates/compose/src/advanced_tests.rs b/crates/compose/src/advanced_tests.rs index 608c6188e2..15d3cf2346 100644 --- a/crates/compose/src/advanced_tests.rs +++ b/crates/compose/src/advanced_tests.rs @@ -395,7 +395,7 @@ fn shapeless_stroke_tile_skips_match_full_region_across_blending_settings() { .unwrap(); let mut band_pixels = Vec::with_capacity(whole.px.len()); for band in bands { - band_pixels.extend(band.px); + band_pixels.extend(band.into_px()); } assert_eq!(whole.px, band_pixels, "banded: {depth:?}, {position:?}, {blend:?}, fill={fill}, blend_if={blend_if}"); } @@ -639,7 +639,7 @@ fn transparency_shapes_off_ignores_masks_and_reaches_groups() { l.mask = Some(LayerMask { surface: photocraft_raster::Surface::with_default(PixelFormat::GRAY8, &[77.0 / 255.0]), ..LayerMask::reveal_all() }); l.advanced.transparency_shapes = tsl; d.layers.push(l); - render(&d, d.bounds()).px + render(&d, d.bounds()).into_px() }; let (off, on) = (render_with(false), render_with(true)); assert!(off.iter().zip(&on).all(|(a, b)| close(*a, *b)), "{mode:?} ({depth:?}): a mask isn't the layer's transparency"); @@ -691,13 +691,13 @@ fn transparency_shapes_off_keeps_the_base_alpha_under_a_clipped_knockout() { base.advanced.transparency_shapes = false; base.vector_mask = Some(photocraft_doc::vector::VectorMask { feather: 1.5, ..photocraft_doc::vector::VectorMask::new(square.clone()) }); d.layers.push(base); - let alone = render(&d, d.bounds()).px; + let alone = render(&d, d.bounds()).into_px(); let mut red = solid(&d, "red", full(&d), [1.0, 0.0, 0.0, 0.5]); red.clipped = true; let mut knock = knocker(&d, k); knock.clipped = true; d.layers.extend([red, knock]); - let got = render(&d, d.bounds()).px; + let got = render(&d, d.bounds()).into_px(); let what = format!("{k:?} ({depth:?})"); for (i, (g, a)) in got.iter().zip(&alone).enumerate() { if i % 12 < 6 { diff --git a/crates/compose/src/cmyk.rs b/crates/compose/src/cmyk.rs index 96a6df0ceb..2f8eaa8861 100644 --- a/crates/compose/src/cmyk.rs +++ b/crates/compose/src/cmyk.rs @@ -27,6 +27,7 @@ use photocraft_doc::adjust::ToneSpace; use photocraft_doc::{Adjustment, Document, Knockout, Layer, LayerContent}; use photocraft_geom::Rect; +use super::extents::recycle_mask; use super::{Ctx, RENDER_TILE, advanced, empty_in, mask_vals, pattern}; #[path = "cmyk_split.rs"] @@ -355,6 +356,7 @@ fn apply_mask(l: &Layer, buf: &mut CmykBuffer, cx: &Ctx) { for (p, k) in buf.px.iter_mut().zip(&m) { p[4] *= clean(*k).max(0.0); } + recycle_mask(Some(m)); } } @@ -425,6 +427,7 @@ fn mix_adjusted(l: &Layer, base: &mut CmykBuffer, adjusted: &CmykBuffer, cx: &Ct *v = if k >= 1.0 { b } else { *v + (b - *v) * k }; } } + recycle_mask(mv); } /// Rounds to the document's levels after an adjustment layer, as the compositor does (on the @@ -534,6 +537,7 @@ fn composite_layer(l: &Layer, clipped: &[Layer], backdrop: &mut CmykBuffer, cx: [0.0; 5] }; } + recycle_mask(mv); } return; } diff --git a/crates/compose/src/composite.rs b/crates/compose/src/composite.rs index 8436ff7428..4ef497672f 100644 --- a/crates/compose/src/composite.rs +++ b/crates/compose/src/composite.rs @@ -1,6 +1,6 @@ -use super::content::{channel_weights, empty_in, recycle_buffer, render_content, restore_channels, scratch_buffer, surface_to_buffer}; +use super::content::{channel_weights, empty_in, render_content, restore_channels, surface_to_buffer}; use super::ctx::Ctx; -use super::extents::{effects_frame, mask_k, mask_vals, shapeless_stroke_bounds}; +use super::extents::{effects_frame, mask_k, mask_vals, recycle_mask, shapeless_stroke_bounds}; use super::fx_cache::effect_maps; use super::{BlendMode, Buffer, Fill, Layer, LayerContent, Rect, Surface, advanced, blend, effects, psblend, shape_split}; @@ -339,6 +339,7 @@ pub(crate) fn composite_pixel_layer(layer: &Layer, surf: &Surface, backdrop: &mu } blend_span_fill(dst, &row, rect.x0, y, layer.blend, layer.opacity, layer.fill_opacity, gamma); } + recycle_mask(mv); } pub(crate) fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer, cx: &Ctx, scope: advanced::Scope) { @@ -389,6 +390,7 @@ pub(crate) fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: [0.0; 4] }; } + recycle_mask(mv); } // A Frame's stroke over the frame's content, at the group's opacity. if let Some(stroke) = shape_split::frame_stroke(layer, cx.canvas, cx.depth) { @@ -425,9 +427,10 @@ pub(crate) fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: if let LayerContent::Adjustment(adj) = &layer.content { let mv = mask_vals(layer, rect, cx); if mv.as_ref().is_some_and(|values| values.iter().all(|&value| opacity * value <= 0.0)) { + recycle_mask(mv); return; } - let mut adjusted = scratch_buffer(rect); + let mut adjusted = Buffer::transparent(rect); adjusted.px.copy_from_slice(&backdrop.px); cx.apply_adjustment(adj, &mut adjusted); // Clipped layers onto an adjustment are uncommon; they composite atop the adjusted result. @@ -455,7 +458,7 @@ pub(crate) fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: None => blend_back(&mut backdrop.px, &adjusted.px, std::iter::repeat(k), layer.blend, fill), } } - recycle_buffer(adjusted); + recycle_mask(mv); return; } @@ -469,6 +472,7 @@ pub(crate) fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: if let (Some(m), Some(b)) = (&hide, &before) { advanced::apply_hiding_mask(backdrop, b, m); } + recycle_mask(hide); return; } if let Some((mut content, stroke, mask)) = shape_parts(layer, clipped, rect, cx) { @@ -478,6 +482,7 @@ pub(crate) fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: p[3] *= mask_k(&mask, i); } blend_layer_into(backdrop, &content, layer, cx, 1.0); + recycle_mask(mask); return; } if !matches!(layer.content, LayerContent::Group(_) | LayerContent::Fill(_) | LayerContent::Adjustment(_)) @@ -512,7 +517,6 @@ pub(crate) fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: let Some(mut content) = render_content(layer, rect, cx) else { return }; advanced::composite_clipped(clipped, &mut content, cx); blend_layer_into(backdrop, &content, layer, cx, text_gamma(layer)); - recycle_buffer(content); } /// A layer with effects (and its clipping group) onto `backdrop`. @@ -521,7 +525,7 @@ fn composite_layer_effects(layer: &Layer, clipped: &[Layer], backdrop: &mut Buff // Neighbourhoods are already captured by the full-region effect maps; // content and effect application only need the output rectangle. // A stroked shape's vector stroke stays above its clipped layers and interior effects. - let (mut content, vstroke) = match split_parts(layer, rect, cx) { + let (mut content, parts) = match split_parts(layer, rect, cx) { Some((fill, stroke, mask)) => (fill, Some((stroke, mask))), None => { let Some(content) = render_content(layer, rect, cx) else { return }; @@ -529,9 +533,12 @@ fn composite_layer_effects(layer: &Layer, clipped: &[Layer], backdrop: &mut Buff } }; advanced::composite_clipped(clipped, &mut content, cx); - let vstroke = vstroke.as_ref().map(|(s, m)| effects::VectorStroke { stroke: s, mask: m.as_deref() }); + let vstroke = parts.as_ref().map(|(s, m)| effects::VectorStroke { stroke: s, mask: m.as_deref() }); let maps = effect_maps(layer, rect, cx); effects::composite_with_effects_prepared(layer, &content, backdrop, &maps, effects_frame(layer, cx.canvas), cx.patterns, vstroke); + if let Some((_, mask)) = parts { + recycle_mask(mask); + } } /// The coverage-mixing gamma of a layer: the text blending gamma @@ -605,6 +612,7 @@ fn composite_atop_any(layer: &Layer, base: &mut Buffer, cx: &Ctx) { p[c] += (bl[c] - p[c]) * k; } } + recycle_mask(mv); return; } if effects::has_effects(layer) { @@ -615,21 +623,28 @@ fn composite_atop_any(layer: &Layer, base: &mut Buffer, cx: &Ctx) { let hide = advanced::hiding_mask(layer, rect, cx); let src = advanced::effects_source(layer); let layer = src.as_ref(); - let (content, vstroke) = match split_parts(layer, rect, cx) { + let (content, parts) = match split_parts(layer, rect, cx) { Some((fill, stroke, mask)) => (fill, Some((stroke, mask))), None => { - let Some(content) = render_content(layer, rect, cx) else { return }; + let Some(content) = render_content(layer, rect, cx) else { + recycle_mask(hide); + return; + }; (content, None) } }; - let vstroke = vstroke.as_ref().map(|(s, m)| effects::VectorStroke { stroke: s, mask: m.as_deref() }); + let vstroke = parts.as_ref().map(|(s, m)| effects::VectorStroke { stroke: s, mask: m.as_deref() }); let mut opaque = Buffer { rect, px: base.px.iter().map(|p| [p[0], p[1], p[2], 1.0]).collect() }; let maps = effect_maps(layer, rect, cx); effects::composite_with_effects_prepared(layer, &content, &mut opaque, &maps, effects_frame(layer, cx.canvas), cx.patterns, vstroke); + if let Some((_, mask)) = parts { + recycle_mask(mask); + } if let Some(m) = &hide { let plain = Buffer { rect, px: base.px.iter().map(|p| [p[0], p[1], p[2], 1.0]).collect() }; advanced::apply_hiding_mask(&mut opaque, &plain, m); } + recycle_mask(hide); for (p, o) in base.px.iter_mut().zip(&opaque.px) { if p[3] > 0.0 { *p = [o[0], o[1], o[2], p[3]]; @@ -689,6 +704,7 @@ fn blend_layer_into(backdrop: &mut Buffer, src: &Buffer, layer: &Layer, cx: &Ctx let alpha = (s[3] / m).clamp(0.0, 1.0); *b = psblend::composite_fill(mode, *b, [s[0], s[1], s[2], m], opacity, fill * alpha, gamma); } + recycle_mask(mv); } /// [`blend_into_g`] for a layer's own `opacity` and `fill`: Fill is part of the blend for diff --git a/crates/compose/src/content.rs b/crates/compose/src/content.rs index ea868d715e..1f91c6a942 100644 --- a/crates/compose/src/content.rs +++ b/crates/compose/src/content.rs @@ -1,63 +1,29 @@ use super::composite::{adjustment_quantum, blend_into, composite_stack}; use super::ctx::Ctx; -use super::extents::{effect_shape, effect_source_bounds, mask_vals, shapeless_stroke_bounds, transparent_outside}; +use super::extents::{effect_shape, effect_source_bounds, mask_vals, recycle_mask, shapeless_stroke_bounds, transparent_outside}; use super::fx_cache::effect_maps; use super::{BlendMode, Buffer, Document, Fill, Layer, LayerContent, Pattern, Rect, Surface, bounds, effects, gradient_fill, pattern, shape_split}; /// Tile size for parallel rendering (tile results are independent). pub const RENDER_TILE: i32 = 256; -// One tile of scratch per worker; nested rendering takes its own buffer. -thread_local! { - static SCRATCH: std::cell::RefCell> = const { std::cell::RefCell::new(Vec::new()) }; -} - -pub(crate) fn scratch_buffer(rect: Rect) -> Buffer { - if rect.width() as usize * rect.height() as usize > (RENDER_TILE * RENDER_TILE) as usize { - return Buffer::transparent(rect); - } - let mut px = SCRATCH.with(|slot| std::mem::take(&mut *slot.borrow_mut())); - px.resize(rect.width() as usize * rect.height() as usize, [0.0; 4]); - Buffer { rect, px } -} - -pub(crate) fn recycle_buffer(buf: Buffer) { - if buf.px.capacity() <= (RENDER_TILE * RENDER_TILE) as usize { - SCRATCH.with(|slot| { - let mut slot = slot.borrow_mut(); - if slot.capacity() < buf.px.capacity() { - *slot = buf.px; - } - }); - } -} - #[cfg(test)] #[test] -fn scratch_reuse_preserves_pixels_and_bounds_retention() { - SCRATCH.with(|slot| slot.borrow_mut().clear()); - let rect = Rect::new(-2, -3, 18, 11); +fn scratch_reuse_preserves_pixels() { + // 256 × 256 pixels of [f32; 4] (1 MiB): over `MIN_POOLED_BYTES`, so the pool keeps it. + let rect = Rect::new(-2, -3, 254, 253); for fmt in [photocraft_color::PixelFormat::RGBA8, photocraft_color::PixelFormat::RGBA16, photocraft_color::PixelFormat::RGBA32F] { let s = Surface::with_default(fmt, &[0.2, 0.4, 0.6, 0.8]); - let buf = surface_to_scratch(&s, rect); + let mut buf = surface_to_scratch(&s, rect); assert_eq!(buf, surface_to_buffer(&s, rect)); - let ptr = buf.px.as_ptr(); - recycle_buffer(buf); - let reused = surface_to_scratch(&Surface::new(fmt), rect); - assert_eq!(reused.px.as_ptr(), ptr); + buf.px.fill([1.0; 4]); + // A dropped buffer goes back to the pool; the next one must come out cleared. + drop(buf); + let reused = Buffer::transparent(rect); assert!(reused.px.iter().all(|p| p == &[0.0; 4])); - let nested = scratch_buffer(rect); + let nested = Buffer::transparent(rect); assert_ne!(nested.px.as_ptr(), reused.px.as_ptr()); - recycle_buffer(nested); - recycle_buffer(reused); } - let cached = SCRATCH.with(|slot| slot.borrow().as_ptr()); - let huge = scratch_buffer(Rect::new(0, 0, RENDER_TILE + 1, RENDER_TILE)); - recycle_buffer(huge); - SCRATCH.with(|slot| { - assert_eq!(slot.borrow().as_ptr(), cached); - assert!(slot.borrow().capacity() <= (RENDER_TILE * RENDER_TILE) as usize); - }); } /// Render a layer's own content (no blending into the backdrop yet) into an isolated buffer. @@ -93,6 +59,7 @@ pub(crate) fn render_content(layer: &Layer, rect: Rect, cx: &Ctx) -> Option Buffer { - let mut px = vec![[0.0f32; 4]; rect.width() as usize * rect.height() as usize]; - s.read_rgba_into(rect, &mut px); - Buffer { rect, px } + let mut buf = Buffer::transparent(rect); + s.read_rgba_into(rect, &mut buf.px); + buf } fn surface_to_scratch(s: &Surface, rect: Rect) -> Buffer { - let mut buf = scratch_buffer(rect); + let mut buf = Buffer::transparent(rect); s.read_rgba_into(rect, &mut buf.px); buf } diff --git a/crates/compose/src/extents.rs b/crates/compose/src/extents.rs index d895f14bc9..7e4f8d234e 100644 --- a/crates/compose/src/extents.rs +++ b/crates/compose/src/extents.rs @@ -134,6 +134,7 @@ pub(crate) fn effect_shape(layer: &Layer, rect: Rect, cx: &Ctx) -> Vec { out[i] = v * mask_k(&mv, i); } } + recycle_mask(mv); out } @@ -283,13 +284,13 @@ pub(crate) fn mask_vals(layer: &Layer, rect: Rect, cx: &Ctx) -> Option> if masks::has_feather(layer) && let Some(s) = masks::combined_mask(layer, cx.canvas) { - let mut v = Vec::new(); + let mut v = photocraft_raster::scratch::take_cap(rect.width() as usize * rect.height() as usize); s.read_region_into(rect, &mut v); return Some(v); } let vector = layer.vector_mask.as_ref().map(|vm| cx.vector_masks.values(vm, rect)); let Some(m) = layer.mask.as_ref() else { return vector }; - let mut v = Vec::new(); + let mut v = photocraft_raster::scratch::take_cap(rect.width() as usize * rect.height() as usize); m.values_into(rect, &mut v); if let Some(vm) = vector { for (a, b) in v.iter_mut().zip(vm) { @@ -299,6 +300,13 @@ pub(crate) fn mask_vals(layer: &Layer, rect: Rect, cx: &Ctx) -> Option> Some(v) } +/// Hand a [`mask_vals`] result back to the scratch pool once its tile is done. +pub(crate) fn recycle_mask(m: Option>) { + if let Some(v) = m { + photocraft_raster::scratch::give(v); + } +} + #[inline] pub(crate) fn mask_k(m: &Option>, i: usize) -> f32 { m.as_ref().map_or(1.0, |v| v[i]) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index e593290571..25dc55984a 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -84,18 +84,39 @@ pub use render::{ }; /// Straight-alpha RGBA float buffer covering a rectangle. -#[derive(Clone, Debug, PartialEq)] +/// +/// The pixel storage comes from, and goes back to, the scratch pool. +#[derive(Debug, PartialEq)] pub struct Buffer { pub rect: Rect, pub px: Vec<[f32; 4]>, } +impl Clone for Buffer { + fn clone(&self) -> Self { + let mut px = photocraft_raster::scratch::take(self.px.len(), [0.0; 4]); + px.copy_from_slice(&self.px); + Self { rect: self.rect, px } + } +} + +impl Drop for Buffer { + fn drop(&mut self) { + photocraft_raster::scratch::give(std::mem::take(&mut self.px)); + } +} + impl Buffer { pub fn transparent(rect: Rect) -> Self { - Self { rect, px: vec![[0.0; 4]; rect.width() as usize * rect.height() as usize] } + Self::filled(rect, [0.0; 4]) } pub fn filled(rect: Rect, c: [f32; 4]) -> Self { - Self { rect, px: vec![c; rect.width() as usize * rect.height() as usize] } + Self { rect, px: photocraft_raster::scratch::take(rect.width() as usize * rect.height() as usize, c) } + } + /// The pixels, taken out of the buffer (it can't be destructured: it returns them to the + /// pool on drop). + pub fn into_px(mut self) -> Vec<[f32; 4]> { + std::mem::take(&mut self.px) } #[inline] pub fn get(&self, x: i32, y: i32) -> [f32; 4] { diff --git a/crates/compose/src/render.rs b/crates/compose/src/render.rs index b739d3f42a..250a3ebce1 100644 --- a/crates/compose/src/render.rs +++ b/crates/compose/src/render.rs @@ -229,14 +229,12 @@ pub fn composite_to_surface(doc: &Document, rect: Rect, fmt: photocraft_color::P let mut s = Surface::new(fmt); let n = fmt.channels(); let empty = s.default_pixel(); - let mut data = Vec::new(); let _ = render_bands(doc, rect, 0, |band| -> Result<(), ()> { let band = match background { Some(bg) => band.over_background(bg), None => band, }; - data.clear(); - data.resize(band.px.len() * n, 0.0); + let mut data = photocraft_raster::scratch::take(band.px.len() * n, 0.0f32); photocraft_color::convert::with_cmyk_space(space.as_ref(), || { for (p, out) in band.px.iter().zip(data.chunks_exact_mut(n)) { if fmt.alpha && (p[3] <= 0.0 || p[3].is_nan()) && empty.len() == n { @@ -247,6 +245,7 @@ pub fn composite_to_surface(doc: &Document, rect: Rect, fmt: photocraft_color::P } }); s.write_region(band.rect, &data); + photocraft_raster::scratch::give(data); Ok(()) }); s.prune(); diff --git a/crates/compose/src/soft_shape_tests.rs b/crates/compose/src/soft_shape_tests.rs index 37575beef5..9332f31af1 100644 --- a/crates/compose/src/soft_shape_tests.rs +++ b/crates/compose/src/soft_shape_tests.rs @@ -115,7 +115,7 @@ fn soft_shape_matches_an_equal_vector_mask() { fn soft_shape_keeps_its_stroke_crisp() { let stroke = ShapeStroke { width: 4.0, paint: Fill::Solid(Color::rgb(1.0, 0.0, 0.0)), ..ShapeStroke::default() }; let d = shape_doc(SampleType::U8, 0.5, 10.0, Some(stroke)); - let px = render(&d, Rect::new(0, 100, 200, 101)).px; + let px = render(&d, Rect::new(0, 100, 200, 101)).into_px(); // Centred 4 px stroke over x = 48..52: opaque red. for x in [48, 49, 50, 51] { let p = px[x]; diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 917192f44a..7ea8655099 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -3001,7 +3001,7 @@ fn levels_steps_match_photoshop() { let px = xs.iter().map(|&x| f32::from(x) / 255.0).map(|v| [v, v, v, 1.0]).collect(); let mut b = Buffer { rect: Rect::new(0, 0, xs.len() as i32, 1), px }; adjust::apply_depth(adj, &mut b, adjust::Transfer::Srgb, Some(SampleType::U8)); - b.px + b.into_px() }; let rgb = levels(ch(0.0, 213.0, 1.22, 48.0, 255.0), [ch(0.0, 255.0, 0.47, 0.0, 194.0), id.clone(), ch(61.0, 255.0, 0.47, 0.0, 213.0)]); let xs = [2, 8, 9, 15, 16, 43, 45, 48, 55, 61, 72, 79, 84, 89, 90, 101, 106, 120, 150, 180, 200]; @@ -3364,7 +3364,7 @@ fn render_bands_cover_the_rect_in_order() { render_bands(&d, r, 100, |b| -> Result<(), ()> { assert_eq!((b.rect.x0, b.rect.x1), (r.x0, r.x1)); rows.push((b.rect.y0, b.rect.y1)); - px.extend(b.px); + px.extend(b.into_px()); Ok(()) }) .unwrap(); @@ -4469,13 +4469,13 @@ fn dissolve_adjustment_resolves_opacity_to_binary_coverage() { layer.blend = BlendMode::Dissolve; layer.opacity = 0.5; d.layers.push(layer); - let pixels = render(&d, d.bounds()).px; + let pixels = render(&d, d.bounds()).into_px(); let black = pixels.iter().filter(|p| p[0] < 0.01).count(); let white = pixels.iter().filter(|p| p[0] > 0.99).count(); assert!(black > 0 && white > 0, "Dissolve must select both original and adjusted pixels"); assert_eq!(black + white, pixels.len(), "no intermediate gray pixels"); d.layers.last_mut().unwrap().blend = BlendMode::Normal; - let continuous = render(&d, d.bounds()).px; + let continuous = render(&d, d.bounds()).into_px(); assert!(continuous.iter().all(|p| (p[0] - 0.5).abs() < E), "Normal remains continuously interpolated"); } @@ -4486,14 +4486,14 @@ fn clipped_dissolve_fill_matches_opaque_unclipped_coverage() { fill.blend = BlendMode::Dissolve; fill.opacity = 0.5; d.layers.push(fill); - let control = render(&d, d.bounds()).px; + let control = render(&d, d.bounds()).into_px(); d.layers.last_mut().unwrap().clipped = true; - let clipped = render(&d, d.bounds()).px; + let clipped = render(&d, d.bounds()).into_px(); assert_eq!(clipped, control, "clipping over a fully opaque base must preserve dissolve pixels"); let black = clipped.iter().filter(|p| p[0] < 0.01).count(); assert!(black > 0 && black < clipped.len()); d.layers.last_mut().unwrap().blend = BlendMode::Normal; - let continuous = render(&d, d.bounds()).px; + let continuous = render(&d, d.bounds()).into_px(); assert!(continuous.iter().all(|p| (p[0] - 0.5).abs() < E)); } @@ -4504,17 +4504,17 @@ fn dissolve_color_overlay_preserves_binary_layer_coverage() { layer.blend = BlendMode::Dissolve; layer.opacity = 0.5; d.layers.push(layer); - let control = render(&d, d.bounds()).px; + let control = render(&d, d.bounds()).into_px(); d.layers .last_mut() .unwrap() .effects .items .push(photocraft_doc::Effect::ColorOverlay { common: photocraft_doc::FxCommon::new(BlendMode::Normal, 1.0), color: Color::BLACK }); - let styled = render(&d, d.bounds()).px; + let styled = render(&d, d.bounds()).into_px(); assert_eq!(styled, control, "an identity overlay should not turn dissolve pixels gray"); d.layers.last_mut().unwrap().blend = BlendMode::Normal; - let continuous = render(&d, d.bounds()).px; + let continuous = render(&d, d.bounds()).into_px(); assert!(continuous.iter().all(|p| (p[0] - 0.5).abs() < E)); } diff --git a/crates/compose/tests/buffer_reuse.rs b/crates/compose/tests/buffer_reuse.rs new file mode 100644 index 0000000000..f07bf8b69f --- /dev/null +++ b/crates/compose/tests/buffer_reuse.rs @@ -0,0 +1,27 @@ +use photocraft_compose::Buffer; +use photocraft_geom::Rect; +use photocraft_raster::scratch; + +/// Restores idle release even when an assertion fails. +struct IdleReleaseOff; +impl Drop for IdleReleaseOff { + fn drop(&mut self) { + scratch::release(); + scratch::set_idle_release(true); + } +} + +/// Runs in its own binary so other compositor tests cannot consume the pooled allocation. +#[test] +fn dropped_buffer_storage_is_reused() { + scratch::set_idle_release(false); + let _idle = IdleReleaseOff; + scratch::release(); + let rect = Rect::from_xywh(0, 0, 256, 256); + let buf = Buffer::transparent(rect); + let ptr = buf.px.as_ptr(); + drop(buf); + let reused = Buffer::transparent(rect); + assert_eq!(reused.px.as_ptr(), ptr); + assert!(reused.px.iter().all(|p| *p == [0.0; 4])); +} diff --git a/crates/engine/examples/indexed_color_perf.rs b/crates/engine/examples/indexed_color_perf.rs index 6dba948ff2..112f6eb0d9 100644 --- a/crates/engine/examples/indexed_color_perf.rs +++ b/crates/engine/examples/indexed_color_perf.rs @@ -25,7 +25,7 @@ fn main() -> Result<(), Box> { let path = std::env::args().nth(1).ok_or("provide a local image path")?; let bytes = photocraft_format::read::read_file(std::path::Path::new(&path))?; let doc = photocraft_io::import(&path, &bytes)?.document; - let px = photocraft_compose::flatten(&doc).px; + let px = photocraft_compose::flatten(&doc).into_px(); println!("{}", json!({"width":doc.size.width,"height":doc.size.height,"preview_factor":1})); for count in [8usize, 16, 32, 64, 128, 256] { for run in 0..4 { diff --git a/crates/engine/src/cmd/adjust_cmds.rs b/crates/engine/src/cmd/adjust_cmds.rs index 39ab43c7a6..f5301979dc 100644 --- a/crates/engine/src/cmd/adjust_cmds.rs +++ b/crates/engine/src/cmd/adjust_cmds.rs @@ -459,9 +459,9 @@ fn stats_pixels(doc: &Document, layer: Option, use_selection: bool) -> Resu let px: Vec<[f32; 4]> = match layer { Some(id) => { let l = doc.layer(photocraft_doc::LayerId(id)).ok_or(EngineError::NoLayer(photocraft_doc::LayerId(id)))?; - photocraft_compose::render_layer(l, area).px + photocraft_compose::render_layer(l, area).into_px() } - None => photocraft_compose::flatten(doc).px, + None => photocraft_compose::flatten(doc).into_px(), }; let mask = doc.selection.as_ref().filter(|_| use_selection).map(|sel| photocraft_algo::selection::mask_from_surface(Some(sel), area)); Ok((px, mask)) @@ -521,7 +521,7 @@ fn hdr_toning(s: &mut Session, p: &Value) -> Result { s.edit("HDR Toning", |doc, active| { let buf = photocraft_compose::flatten(doc).over_background([1.0, 1.0, 1.0]); let (w, hgt) = (buf.rect.width() as usize, buf.rect.height() as usize); - let mut px = buf.px; + let mut px = buf.into_px(); tone::hdr_toning(&mut px, w, hgt, &h); let fmt = doc.pixel_format(); let data: Vec = px.iter().flat_map(|q| photocraft_raster::from_rgba(&fmt, *q)).collect(); diff --git a/crates/engine/src/cmd/adjust_cmds/tests.rs b/crates/engine/src/cmd/adjust_cmds/tests.rs index d60fa26d10..51843ae022 100644 --- a/crates/engine/src/cmd/adjust_cmds/tests.rs +++ b/crates/engine/src/cmd/adjust_cmds/tests.rs @@ -480,7 +480,7 @@ fn smart_session() -> Session { } fn flatten(s: &Session) -> Vec<[f32; 4]> { - photocraft_compose::flatten(doc(s)).px + photocraft_compose::flatten(doc(s)).into_px() } fn max_diff(a: &[[f32; 4]], b: &[[f32; 4]]) -> f32 { diff --git a/crates/engine/src/cmd/face_cmds.rs b/crates/engine/src/cmd/face_cmds.rs index d48df95b84..0ae0af3d35 100644 --- a/crates/engine/src/cmd/face_cmds.rs +++ b/crates/engine/src/cmd/face_cmds.rs @@ -36,7 +36,7 @@ pub fn faces_in(doc: &Document, active: Option, all_layers: bool, min_s surf.read_rgba_into(area, &mut px); px } - None => photocraft_compose::render(doc, area).px, + None => photocraft_compose::render(doc, area).into_px(), }; let found = detect(&px, w, h, min_score)?; let (x0, y0) = (area.x0 as f32, area.y0 as f32); diff --git a/crates/engine/src/cmd/layer_menu_cmds.rs b/crates/engine/src/cmd/layer_menu_cmds.rs index 34a1f914ee..75701d9ddd 100644 --- a/crates/engine/src/cmd/layer_menu_cmds.rs +++ b/crates/engine/src/cmd/layer_menu_cmds.rs @@ -230,7 +230,7 @@ fn mask_all_objects(s: &mut Session, p: &Value) -> Result { struct Composite<'a>(&'a Document); impl photocraft_algo::segment::Sampler for Composite<'_> { fn rgba(&self, r: Rect) -> Vec<[f32; 4]> { - photocraft_compose::render(self.0, r).px + photocraft_compose::render(self.0, r).into_px() } } photocraft_algo::segment::subject::select_subject(&Composite(&doc), doc.bounds()) diff --git a/crates/engine/src/cmd/layer_menu_cmds/tests.rs b/crates/engine/src/cmd/layer_menu_cmds/tests.rs index 702d65891b..62522ccc39 100644 --- a/crates/engine/src/cmd/layer_menu_cmds/tests.rs +++ b/crates/engine/src/cmd/layer_menu_cmds/tests.rs @@ -757,11 +757,11 @@ fn applying_a_feathered_mask_preserves_its_soft_composite() { Ok(()) }) .unwrap(); - let before = photocraft_compose::flatten(doc(&s)).px; + let before = photocraft_compose::flatten(doc(&s)).into_px(); assert!(before[15 * 40 + 19][3] > 0.0 && before[15 * 40 + 19][3] < 1.0, "mask must start with a soft edge"); s.execute("layer.layerMask.apply", json!({})).unwrap(); assert!(active(&s).mask.is_none(), "Apply removes the pixel mask"); - let after = photocraft_compose::flatten(doc(&s)).px; + let after = photocraft_compose::flatten(doc(&s)).into_px(); for (a, b) in before.iter().zip(&after) { assert!((a[3] - b[3]).abs() <= 1.0 / 255.0 + 1e-5, "depth {depth}: alpha {a:?} became {b:?}"); } @@ -791,13 +791,13 @@ fn from_transparency_preserves_mask_coverage_outside_the_canvas() { for depth in DEPTHS { let mut s = session(depth, "rgb"); paint(&mut s, |_, _| [1.0, 0.0, 0.0, 0.5]); - let before = photocraft_compose::flatten(doc(&s)).px; + let before = photocraft_compose::flatten(doc(&s)).into_px(); s.execute("layer.translate", json!({"dx": 20})).unwrap(); s.execute("layer.layerMask.fromTransparency", json!({})).unwrap(); let mask = active(&s).mask.as_ref().unwrap(); assert!((mask.value(50, 10) - 0.5).abs() < 1.0 / 255.0 + 1e-6, "off-canvas mask at depth {depth}"); s.execute("layer.translate", json!({"dx": -20})).unwrap(); - let after = photocraft_compose::flatten(doc(&s)).px; + let after = photocraft_compose::flatten(doc(&s)).into_px(); for (a, b) in before.iter().zip(&after) { assert!((a[3] - b[3]).abs() <= 1.0 / 255.0 + 1e-6, "alpha changed at depth {depth}: {a:?} -> {b:?}"); } diff --git a/crates/engine/src/cmd/mask_view_cmds/tests.rs b/crates/engine/src/cmd/mask_view_cmds/tests.rs index ce9bffa744..82179a5456 100644 --- a/crates/engine/src/cmd/mask_view_cmds/tests.rs +++ b/crates/engine/src/cmd/mask_view_cmds/tests.rs @@ -44,7 +44,7 @@ fn modes_toggle_and_show_in_inspect_without_history() { fn a_mask_view_never_alters_pixels() { let (mut s, masked, _) = session(); let before = s.active().unwrap().doc.clone(); - let px = |s: &Session| photocraft_compose::render(&s.active().unwrap().doc, Rect::new(0, 0, 40, 20)).px; + let px = |s: &Session| photocraft_compose::render(&s.active().unwrap().doc, Rect::new(0, 0, 40, 20)).into_px(); let composite = px(&s); for mode in ["gray", "overlay", "toggleOverlay", "toggleGray", "off"] { s.execute(ID, json!({"layer": masked, "mode": mode})).unwrap(); diff --git a/crates/engine/src/cmd/mode_cmds.rs b/crates/engine/src/cmd/mode_cmds.rs index ac30023d92..3043f080e6 100644 --- a/crates/engine/src/cmd/mode_cmds.rs +++ b/crates/engine/src/cmd/mode_cmds.rs @@ -186,7 +186,7 @@ fn with_released_movement_locks(l: &mut Layer, f: impl FnOnce(&mut Layer) -> /// The flattened image as straight RGBA (over white when `opaque`). fn composite(doc: &Document, opaque: bool) -> Vec<[f32; 4]> { let buf = photocraft_compose::flatten(doc); - if opaque { buf.over_background([1.0, 1.0, 1.0]).px } else { buf.px } + if opaque { buf.over_background([1.0, 1.0, 1.0]).into_px() } else { buf.into_px() } } /// Replace the document's layers by one 8-bit layer holding `px` in `mode`'s storage format. diff --git a/crates/engine/src/cmd/mode_cmds/tests.rs b/crates/engine/src/cmd/mode_cmds/tests.rs index 0d3f9b3cea..4d3c97b02b 100644 --- a/crates/engine/src/cmd/mode_cmds/tests.rs +++ b/crates/engine/src/cmd/mode_cmds/tests.rs @@ -568,7 +568,7 @@ fn bitmap_budgets_a_conversion_at_the_document_resolution_too() { /// The Bitmap conversion of `d` the pre-#8477 way, from one full-canvas composite over white. fn bitmap_oracle(d: &Document, method: Option, custom: Option<(&[f32], usize, usize)>) -> Vec { - let px = photocraft_compose::flatten(d).over_background([1.0; 3]).px; + let px = photocraft_compose::flatten(d).over_background([1.0; 3]).into_px(); let mut gray: Vec = px.iter().map(|q| photocraft_color::convert::rgb_to_gray([q[0], q[1], q[2]])).collect(); let w = d.size.width as usize; match (method, custom) { @@ -730,7 +730,7 @@ fn duotone_to_colour_modes_keeps_the_ink_colours() { s.execute("image.mode.duotone", cfg.clone()).unwrap(); let before = s.active().unwrap().doc.clone(); let gray: Vec = (0..16).map(|x| before.layers[0].surface().unwrap().pixel(x, 1)[0]).collect(); - let shown = photocraft_compose::render(&display_document(&before).unwrap(), Rect::from_xywh(0, 1, 16, 1)).px; + let shown = photocraft_compose::render(&display_document(&before).unwrap(), Rect::from_xywh(0, 1, 16, 1)).into_px(); s.execute(&format!("image.mode.{mode}"), json!({})).unwrap(); let d = doc(&s); assert!(d.duotone.is_none(), "{ctx}"); diff --git a/crates/engine/src/cmd/select_extra_cmds.rs b/crates/engine/src/cmd/select_extra_cmds.rs index 8ca012266d..9fd7b309cc 100644 --- a/crates/engine/src/cmd/select_extra_cmds.rs +++ b/crates/engine/src/cmd/select_extra_cmds.rs @@ -40,7 +40,7 @@ struct CompositeSampler<'a>(&'a Document); impl Sampler for CompositeSampler<'_> { fn rgba(&self, r: Rect) -> Vec<[f32; 4]> { - photocraft_compose::render(self.0, r).px + photocraft_compose::render(self.0, r).into_px() } } diff --git a/crates/engine/src/cmd/selection_cmds.rs b/crates/engine/src/cmd/selection_cmds.rs index e8d206285e..024f6b088b 100644 --- a/crates/engine/src/cmd/selection_cmds.rs +++ b/crates/engine/src/cmd/selection_cmds.rs @@ -70,7 +70,7 @@ fn sample_pixels(s: &Session, all_layers: bool, window: Option) -> Result< surf.read_rgba_into(area, &mut px); Ok((area, px)) } - _ => Ok((area, photocraft_compose::render(&d.doc, area).px)), + _ => Ok((area, photocraft_compose::render(&d.doc, area).into_px())), } } diff --git a/crates/engine/src/cmd/smart_cmds/rendering.rs b/crates/engine/src/cmd/smart_cmds/rendering.rs index 907c7d6947..ab862d5545 100644 --- a/crates/engine/src/cmd/smart_cmds/rendering.rs +++ b/crates/engine/src/cmd/smart_cmds/rendering.rs @@ -39,7 +39,7 @@ pub fn stack_image(file_name: &str, bytes: &[u8], fmt: PixelFormat, mode: photoc let mut one = l.clone(); one.blend = BlendMode::Normal; one.clipped = false; - photocraft_compose::render_layer(&one, bounds).px + photocraft_compose::render_layer(&one, bounds).into_px() }) .collect(); if frames.is_empty() { diff --git a/crates/engine/src/cmd/smart_cmds/tests.rs b/crates/engine/src/cmd/smart_cmds/tests.rs index d6306f0d18..6e8ce37f9c 100644 --- a/crates/engine/src/cmd/smart_cmds/tests.rs +++ b/crates/engine/src/cmd/smart_cmds/tests.rs @@ -110,7 +110,7 @@ fn paint(s: &mut Session) { } fn flat(s: &Session) -> Vec<[f32; 4]> { - photocraft_compose::flatten(&s.active().unwrap().doc).px + photocraft_compose::flatten(&s.active().unwrap().doc).into_px() } fn max_diff(a: &[[f32; 4]], b: &[[f32; 4]]) -> f32 { diff --git a/crates/engine/src/cmd/smartselect_cmds.rs b/crates/engine/src/cmd/smartselect_cmds.rs index c04df742a8..adbf9b5f46 100644 --- a/crates/engine/src/cmd/smartselect_cmds.rs +++ b/crates/engine/src/cmd/smartselect_cmds.rs @@ -49,7 +49,7 @@ struct CompositeSampler<'a>(&'a Document); impl Sampler for CompositeSampler<'_> { fn rgba(&self, r: Rect) -> Vec<[f32; 4]> { - photocraft_compose::render(self.0, r).px + photocraft_compose::render(self.0, r).into_px() } } diff --git a/crates/engine/src/cmd/transform_cmds/tests.rs b/crates/engine/src/cmd/transform_cmds/tests.rs index e9ed16f245..7bf247466b 100644 --- a/crates/engine/src/cmd/transform_cmds/tests.rs +++ b/crates/engine/src/cmd/transform_cmds/tests.rs @@ -366,7 +366,7 @@ fn off_from_fresh(s: &Session) -> f32 { let st = s.active().unwrap(); let mut fresh: Document = (*st.doc).clone(); fresh.layer_mut(st.active_layer.unwrap()).unwrap().fill_cache = None; - let (a, b) = (photocraft_compose::render(&st.doc, st.doc.bounds()).px, photocraft_compose::render(&fresh, fresh.bounds()).px); + let (a, b) = (photocraft_compose::render(&st.doc, st.doc.bounds()).into_px(), photocraft_compose::render(&fresh, fresh.bounds()).into_px()); a.iter().zip(&b).flat_map(|(p, q)| p.iter().zip(q).map(|(x, y)| (x - y).abs())).fold(0.0, f32::max) } diff --git a/crates/engine/src/cmd/video_cmds/tests.rs b/crates/engine/src/cmd/video_cmds/tests.rs index 66566bdb7d..d08135f60e 100644 --- a/crates/engine/src/cmd/video_cmds/tests.rs +++ b/crates/engine/src/cmd/video_cmds/tests.rs @@ -229,7 +229,7 @@ fn animated_gif_keeps_transparency() { // Each frame has a transparent index and is cleared before the next (disposal 2). let packed: Vec = gif.windows(4).filter(|w| w[..3] == [0x21, 0xF9, 0x04]).map(|w| w[3]).collect(); assert_eq!(packed, [0x09, 0x09]); - let px = photocraft_compose::flatten(&photocraft_io::import("t.gif", &gif).unwrap().document).px; + let px = photocraft_compose::flatten(&photocraft_io::import("t.gif", &gif).unwrap().document).into_px(); assert_eq!(px[0][3], 0.0, "the corner stays transparent"); assert_eq!(px[3 * 8 + 3], [1.0, 0.0, 0.0, 1.0], "the square stays red"); } diff --git a/crates/engine/src/color_channels/tone.rs b/crates/engine/src/color_channels/tone.rs index 97d6e8f7b2..c64b1debba 100644 --- a/crates/engine/src/color_channels/tone.rs +++ b/crates/engine/src/color_channels/tone.rs @@ -61,7 +61,7 @@ pub(super) fn adjust(surface: &mut Surface, colors: ColorChannels, adjustment: & .collect(), }; adjust::apply_in(&scalar, &mut buf, transfer, depth, mode); - Arc::new(IntegerTable { adjusted: buf.px.into_iter().map(|p| p[0]).collect(), encoded: Vec::new() }) + Arc::new(IntegerTable { adjusted: buf.px.iter().map(|p| p[0]).collect(), encoded: Vec::new() }) }) }); let mut component = Component { index, scalar, table, lab_invert }; diff --git a/crates/engine/src/doc_path/tests.rs b/crates/engine/src/doc_path/tests.rs index 1c9bc88874..049e126a47 100644 --- a/crates/engine/src/doc_path/tests.rs +++ b/crates/engine/src/doc_path/tests.rs @@ -157,7 +157,7 @@ fn replayed(s: &mut Session, id: LayerId, params: serde_json::Value) -> Vec<[f32 }) .unwrap(); let doc = &s.active().unwrap().doc; - photocraft_compose::render(doc, doc.bounds()).px + photocraft_compose::render(doc, doc.bounds()).into_px() } #[test] @@ -202,12 +202,12 @@ fn smart_displace_keeps_a_relative_map_path() { png(&d.join("map.png"), |_| [255, 255, 255]); let (mut s, id) = document(&d, &d.join("ramp.png")); let path = d.join("map.png").to_string_lossy().into_owned(); - let unfiltered = photocraft_compose::render(&s.active().unwrap().doc, s.active().unwrap().doc.bounds()).px; + let unfiltered = photocraft_compose::render(&s.active().unwrap().doc, s.active().unwrap().doc.bounds()).into_px(); s.execute("filter.distort.displace", json!({"layer": id.0, "mapPath": path, "horizontal": 50, "vertical": 0})).unwrap(); let state = |s: &Session| { let doc = &s.active().unwrap().doc; let Some(LayerContent::Smart(sm)) = doc.layer(id).map(|l| &l.content) else { panic!("a smart object") }; - (sm.smart_filters[0].params.clone(), photocraft_compose::render(doc, doc.bounds()).px) + (sm.smart_filters[0].params.clone(), photocraft_compose::render(doc, doc.bounds()).into_px()) }; let (params, displaced) = state(&s); assert_eq!(params["mapPath"], json!(path)); diff --git a/crates/engine/src/filters.rs b/crates/engine/src/filters.rs index aebee03caf..2bcccb7a5c 100644 --- a/crates/engine/src/filters.rs +++ b/crates/engine/src/filters.rs @@ -397,6 +397,8 @@ pub(crate) fn keep_alpha(old: &photocraft_raster::Surface, new: &mut photocraft_ } } s.write_region(*r, &v); + photocraft_raster::scratch::give(o); + photocraft_raster::scratch::give(v); true }) .reduce(|| true, |a, b| a && b); diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index ac9e38a723..5ebe45599c 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -277,6 +277,16 @@ impl Running { } } +/// A cancelled job's worker that may still be unwinding. +#[cfg(not(target_arch = "wasm32"))] +struct Draining { + handle: std::thread::JoinHandle<()>, + doc: Option, + /// Its document closed: the worker still holds that document's snapshot, so the scratch + /// pool is released again once it exits (the release at close came too early, #10479). + release: bool, +} + /// The session's job table (see the module docs). #[derive(Default)] pub struct Jobs { @@ -292,7 +302,7 @@ pub struct Jobs { recent: Vec, /// Workers of cancelled jobs that may still be unwinding (native). #[cfg(not(target_arch = "wasm32"))] - draining: Vec>, + draining: Vec, /// The context inline job work runs under (see [`Session::set_inline_job_ctx`]). inline: Option, } @@ -673,7 +683,17 @@ impl Session { } } #[cfg(not(target_arch = "wasm32"))] - self.jobs.draining.retain(|h| !h.is_finished()); + { + let mut release = false; + self.jobs.draining.retain(|d| { + let done = d.handle.is_finished(); + release |= done && d.release; + !done + }); + if release { + photocraft_raster::scratch::release(); + } + } std::mem::take(&mut self.jobs.events) } @@ -706,8 +726,8 @@ impl Session { job.ctx.cancel(); self.end_job(&job, JobOutcome::Cancelled); #[cfg(not(target_arch = "wasm32"))] - if let Some(h) = job.handle { - self.jobs.draining.push(h); + if let Some(handle) = job.handle { + self.jobs.draining.push(Draining { handle, doc: job.doc, release: false }); } true } @@ -754,8 +774,15 @@ impl Session { /// cancel latency with it). Native only; returns at once on wasm. pub fn join_cancelled_jobs(&mut self) { #[cfg(not(target_arch = "wasm32"))] - for h in self.jobs.draining.drain(..) { - let _ = h.join(); + { + let mut release = false; + for d in self.jobs.draining.drain(..) { + let _ = d.handle.join(); + release |= d.release; + } + if release { + photocraft_raster::scratch::release(); + } } } @@ -774,6 +801,11 @@ impl Session { for id in ids { self.cancel_job(id); } + // Workers cancelled now, or earlier and still unwinding, hold the closing document. + #[cfg(not(target_arch = "wasm32"))] + for d in self.jobs.draining.iter_mut().filter(|d| d.doc == Some(doc)) { + d.release = true; + } } /// Run the command `id`: inline, or (with `background`) as a job when it is one. @@ -857,6 +889,7 @@ impl Session { // being recorded, nor move the recording mark past the entries it is about to replace. let recording = if spec.journal && crate::actions_cmds::replayable(id) { self.actions.recording.take() } else { None }; let before = self.active().map(|st| st.doc.clone()); + let docs_before: Vec<_> = self.docs.iter().map(|st| st.doc.id).collect(); // Last-resort guard (AGENTS.md, Never crash): a command that panics anyway fails with an // error instead of taking the app down. `edit` only commits a document after its closure // returns, so the documents are unchanged. @@ -876,7 +909,7 @@ impl Session { self.hint_before = None; } let pending = self.jobs.pending.take(); - match (r, pending) { + let out = match (r, pending) { (Err(e), Some(job)) => { job.ctx.cancel(); Err(e) @@ -893,8 +926,8 @@ impl Session { Ok(Started::Job(jid)) } (Ok(v), None) => { - if let Some(before) = before { - self.add_relayout_damage(&before); + if let Some(before) = &before { + self.add_relayout_damage(before); } if let Some((doc_id, layer_id)) = new_fx_target && let Some(st) = self.active_mut() @@ -910,7 +943,15 @@ impl Session { self.after_command(id, params, spec.journal, Some(nested_from), pre_selected.as_deref()); Ok(Started::Done(v)) } + }; + // A closed document's freed buffers went to the scratch pool (`before` kept the active + // one alive until here): hand them back to the OS (#10479). Compared by id, since Split + // and Merge Channels close documents while opening others. + if docs_before.iter().any(|id| !self.docs.iter().any(|st| st.doc.id == *id)) { + drop(before); + photocraft_raster::scratch::release(); } + out } /// Grows the damage a command reported for the pixels it changed by what effects laid out diff --git a/crates/engine/src/pixels.rs b/crates/engine/src/pixels.rs index 906f0b23c6..f95a3feb0d 100644 --- a/crates/engine/src/pixels.rs +++ b/crates/engine/src/pixels.rs @@ -115,6 +115,7 @@ pub fn adjust_surface(s: &mut Surface, adj: &Adjustment, selection: Option<&Surf let tr = tc.rect().intersect(&r); let out = adjusted(&one, tr, adj, selection, mode); one.write_region(tr, &out); + photocraft_raster::scratch::give(out); one.take_tiles(tc.rect()) }) .collect(); @@ -184,6 +185,7 @@ pub(crate) fn adjust_mask_with_transfer( for t in &tiles { let out = adjusted_with_transfer(s, *t, adj, Some(sel), mode, transfer); s.write_region(*t, &out); + photocraft_raster::scratch::give(out); } if !tiles.is_empty() { s.prune(); @@ -197,7 +199,9 @@ pub(crate) fn adjust_mask_with_transfer( // So only the stored tiles are adjusted, not the (possibly huge) rectangle around them. let stored: Vec = s.tiles().map(|(c, _)| c.rect()).collect(); for r in stored { - out.write_region(r, &adjusted_with_transfer(s, r, adj, None, mode, transfer)); + let data = adjusted_with_transfer(s, r, adj, None, mode, transfer); + out.write_region(r, &data); + photocraft_raster::scratch::give(data); } out.prune(); *s = out; @@ -233,7 +237,10 @@ fn adjusted_with_transfer( } let n = fmt.channels(); let raw = s.read_region(r); - let mut buf = Buffer { rect: r, px: raw.chunks_exact(n).map(|p| to_rgba(&fmt, p)).collect() }; + let mut px = photocraft_raster::scratch::take_cap(raw.len() / n.max(1)); + px.extend(raw.chunks_exact(n).map(|p| to_rgba(&fmt, p))); + photocraft_raster::scratch::give(raw); + let mut buf = Buffer { rect: r, px }; let orig = buf.clone(); // 32-bit documents get the float behaviour of an adjustment layer there (Levels doesn't clip); // integer depths keep the unrounded, clipped curves, except that 16-bit Posterize and @@ -311,7 +318,7 @@ pub(crate) fn mix_through_selection( selection: Option<&Surface>, ) -> Vec { let w = (r.width() as usize).max(1); - let mut out = Vec::with_capacity(orig.len() * fmt.channels()); + let mut out = photocraft_raster::scratch::take_cap(orig.len() * fmt.channels()); // The selection's coverage (channel 0) one row at a time, each of its tiles looked up once // per row rather than once per pixel. let mut coverage = vec![1.0f32; w]; diff --git a/crates/engine/src/select_mask_edits.rs b/crates/engine/src/select_mask_edits.rs index 9fd29a2757..4285491eb7 100644 --- a/crates/engine/src/select_mask_edits.rs +++ b/crates/engine/src/select_mask_edits.rs @@ -239,7 +239,9 @@ fn invert(base: Option<&Surface>, canvas: Rect) -> Option { if r.is_empty() { continue; } - let inverted: Vec = b.read_region(r).chunks_exact(ch).map(|px| 1.0 - px.first().copied().unwrap_or(0.0).clamp(0.0, 1.0)).collect(); + let read = b.read_region(r); + let inverted: Vec = read.chunks_exact(ch).map(|px| 1.0 - px.first().copied().unwrap_or(0.0).clamp(0.0, 1.0)).collect(); + photocraft_raster::scratch::give(read); s.write_region(r, &inverted); } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index bae9cf4c92..30c2e32f59 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -814,7 +814,7 @@ fn fill_layer_pixel_rewrites_refresh_the_effect_maps() { .unwrap(); let render = |s: &Session| { let d = &s.active().unwrap().doc; - photocraft_compose::render(d, d.bounds()).px + photocraft_compose::render(d, d.bounds()).into_px() }; // Give the layer the cached-pixels state a PSD import produces (the cache is what Image // › Transform & friends rewrite in place, without touching the Fill spec). @@ -901,7 +901,7 @@ fn group_effect_maps_follow_child_visibility_and_opacity() { .unwrap(); let render = |s: &Session| { let d = &s.active().unwrap().doc; - photocraft_compose::render(d, d.bounds()).px + photocraft_compose::render(d, d.bounds()).into_px() }; let mut prev = render(&s); // populate the effect-map cache type Edit = (&'static str, fn(&mut photocraft_doc::Layer)); diff --git a/crates/engine/tests/it/atn_crystallize_cell_size.rs b/crates/engine/tests/it/atn_crystallize_cell_size.rs index 7b72a6ff70..8a015f1497 100644 --- a/crates/engine/tests/it/atn_crystallize_cell_size.rs +++ b/crates/engine/tests/it/atn_crystallize_cell_size.rs @@ -24,7 +24,7 @@ fn fixture(depth: u32) -> Session { /// Compare the actual flattened document rather than just the imported parameters. fn pixels(s: &Session) -> Vec<[f32; 4]> { - photocraft_compose::flatten(&s.active().unwrap().doc).px + photocraft_compose::flatten(&s.active().unwrap().doc).into_px() } /// Fractions remain valid native edits; strict integer ATN conversion must refuse them. diff --git a/crates/engine/tests/it/atn_ripple_amount.rs b/crates/engine/tests/it/atn_ripple_amount.rs index b6bde88f7a..0ff38f2036 100644 --- a/crates/engine/tests/it/atn_ripple_amount.rs +++ b/crates/engine/tests/it/atn_ripple_amount.rs @@ -24,7 +24,7 @@ fn fixture(depth: u32) -> Session { /// Flatten the active document to compare direct, native and ATN playback pixels. fn pixels(s: &Session) -> Vec<[f32; 4]> { - photocraft_compose::flatten(&s.active().unwrap().doc).px + photocraft_compose::flatten(&s.active().unwrap().doc).into_px() } /// Fractions are valid native edits, but the existing integer event cannot carry them. diff --git a/crates/engine/tests/it/atn_selective_color.rs b/crates/engine/tests/it/atn_selective_color.rs index 454846f59e..92a23aff5e 100644 --- a/crates/engine/tests/it/atn_selective_color.rs +++ b/crates/engine/tests/it/atn_selective_color.rs @@ -37,7 +37,7 @@ fn doc_bounds() -> photocraft_doc::Rect { } fn pixels(s: &Session) -> Vec<[f32; 4]> { - photocraft_compose::flatten(&s.active().unwrap().doc).px + photocraft_compose::flatten(&s.active().unwrap().doc).into_px() } fn check_cases(id: &str, cases: &[Value], require_effect: bool) { diff --git a/crates/engine/tests/it/atn_stroke_width.rs b/crates/engine/tests/it/atn_stroke_width.rs index 742359cf38..3034fb2f0a 100644 --- a/crates/engine/tests/it/atn_stroke_width.rs +++ b/crates/engine/tests/it/atn_stroke_width.rs @@ -23,7 +23,7 @@ fn fixture(depth: u32) -> Session { /// Compare the flattened document, including alpha, rather than only imported JSON. fn pixels(s: &Session) -> Vec<[f32; 4]> { - photocraft_compose::flatten(&s.active().unwrap().doc).px + photocraft_compose::flatten(&s.active().unwrap().doc).into_px() } /// Direct execution, native playback and strict binary ATN playback must agree exactly. diff --git a/crates/engine/tests/it/photoshop_oracles.rs b/crates/engine/tests/it/photoshop_oracles.rs index 88e63b67fc..435bab8cc1 100644 --- a/crates/engine/tests/it/photoshop_oracles.rs +++ b/crates/engine/tests/it/photoshop_oracles.rs @@ -124,7 +124,7 @@ fn photoshop_oracles_rerendered() { } }; let Ok(merged) = photocraft_io::merged_composite(&file) else { continue }; - let ours = photocraft_compose::flatten(&doc).px; + let ours = photocraft_compose::flatten(&doc).into_px(); let (m, bad) = max_diff(&ours, &merged); let group = name.split(['/', '\\']).next().unwrap_or(".").to_string(); let g = groups.entry(group).or_default(); diff --git a/crates/engine/tests/scratch_release.rs b/crates/engine/tests/scratch_release.rs new file mode 100644 index 0000000000..6976f0cb7f --- /dev/null +++ b/crates/engine/tests/scratch_release.rs @@ -0,0 +1,114 @@ +//! One integration-test process keeps the process-wide scratch pool isolated from parallel unit +//! tests (#10479). +use photocraft_engine::Session; +use photocraft_engine::jobs::Started; +use photocraft_raster::scratch; +use serde_json::json; +use std::sync::{Mutex, PoisonError}; + +static SERIAL: Mutex<()> = Mutex::new(()); + +/// Holds the test lock with the pool's idle release off, so a slow runner can't empty the pool +/// between a command and the check after it; the release comes back on when dropped. +struct Quiet { + _serial: std::sync::MutexGuard<'static, ()>, +} + +impl Quiet { + /// Locks the test process and disables idle pool release for this guard's lifetime. + fn new() -> Self { + let _serial = SERIAL.lock().unwrap_or_else(PoisonError::into_inner); + scratch::set_idle_release(false); + Quiet { _serial } + } +} + +impl Drop for Quiet { + fn drop(&mut self) { + scratch::set_idle_release(true); + } +} + +#[test] +/// Verifies closing one or all documents releases their scratch buffers. +fn closing_a_document_leaves_none_of_its_buffers_in_the_pool() { + let _q = Quiet::new(); + for close in ["file.close", "file.closeAll"] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 1024, "height": 1024})).unwrap(); + s.execute("edit.fill", json!({"contents": "color", "color": "#808080"})).unwrap(); + s.execute("filter.blur.gaussianBlur", json!({"radius": 3})).unwrap(); + // Dispatch holds the active document across the command; its tiles must not land in + // the pool after the release. + s.execute(close, json!({})).unwrap(); + assert_eq!(scratch::retained_bytes(), 0, "{close}"); + } +} + +#[test] +/// Verifies a command that keeps its document does not release unrelated pooled buffers. +fn commands_that_keep_their_documents_keep_the_pool() { + let _q = Quiet::new(); + let mut s = Session::new(); + s.execute("file.new", json!({"width": 1024, "height": 1024})).unwrap(); + scratch::give(vec![0u8; scratch::MIN_POOLED_BYTES]); + s.execute("edit.fill", json!({"contents": "color", "color": "#808080"})).unwrap(); + assert!(scratch::retained_bytes() > 0); +} + +#[test] +/// Verifies a cancelled worker releases its document buffers after it exits. +fn a_cancelled_job_of_a_closed_document_returns_its_buffers_once_it_exits() { + let _q = Quiet::new(); + let mut s = Session::new(); + s.execute("file.new", json!({"width": 2048, "height": 2048})).unwrap(); + s.execute("edit.fill", json!({"contents": "color", "color": "#808080"})).unwrap(); + let Started::Job(_) = s.start("filter.blur.gaussianBlur", json!({"radius": 60})).unwrap() else { + panic!("Gaussian Blur runs as a background job"); + }; + // Closing cancels the job, but its worker still holds the document until it exits. + s.close(0); + scratch::give(vec![0u8; scratch::MIN_POOLED_BYTES]); + s.join_cancelled_jobs(); + assert_eq!(scratch::retained_bytes(), 0); +} + +#[test] +/// Verifies cancelling a job before document close still releases its buffers. +fn a_job_cancelled_before_its_document_closes_still_returns_its_buffers() { + let _q = Quiet::new(); + let mut s = Session::new(); + s.execute("file.new", json!({"width": 2048, "height": 2048})).unwrap(); + s.execute("edit.fill", json!({"contents": "color", "color": "#808080"})).unwrap(); + let Started::Job(id) = s.start("filter.blur.gaussianBlur", json!({"radius": 60})).unwrap() else { + panic!("Gaussian Blur runs as a background job"); + }; + // Cancelled first: the worker is already off the running list when the document closes. + assert!(s.cancel_job(id)); + s.close(0); + scratch::give(vec![0u8; scratch::MIN_POOLED_BYTES]); + s.join_cancelled_jobs(); + assert_eq!(scratch::retained_bytes(), 0); +} + +/// Verifies polling alone releases buffers after a closed document's cancelled worker exits. +#[test] +fn a_cancelled_job_of_a_closed_document_is_released_by_polling() { + let _q = Quiet::new(); + let mut s = Session::new(); + s.execute("file.new", json!({"width": 2048, "height": 2048})).unwrap(); + s.execute("edit.fill", json!({"contents": "color", "color": "#808080"})).unwrap(); + let Started::Job(_) = s.start("filter.blur.gaussianBlur", json!({"radius": 60})).unwrap() else { + panic!("Gaussian Blur runs as a background job"); + }; + s.close(0); + // Seed after close so the close path cannot satisfy the assertion. + scratch::give(vec![0u8; scratch::MIN_POOLED_BYTES]); + assert!(scratch::retained_bytes() > 0); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + while scratch::retained_bytes() > 0 && std::time::Instant::now() < deadline { + s.poll_jobs(); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert_eq!(scratch::retained_bytes(), 0); +} diff --git a/crates/format/src/store.rs b/crates/format/src/store.rs index af6678088b..67bb8df27b 100644 --- a/crates/format/src/store.rs +++ b/crates/format/src/store.rs @@ -30,8 +30,87 @@ fn blob_path(h: &str) -> String { format!("blobs/{h}.zst") } -fn compress(data: &[u8]) -> Vec { - ruzstd::encoding::compress_to_vec(data, ruzstd::encoding::CompressionLevel::Fastest) +/// The bytes an [`Object`] stores, owned by (or shared with) the compressor's source so one +/// [`Compressor`] can be reused for objects of any lifetime. +enum Source { + Tile(Arc), + Blob(Arc>), + /// A little-endian copy of a tile (big-endian hosts only). + Owned(Vec), +} + +struct SourceReader { + source: Source, + pos: usize, +} + +impl SourceReader { + fn all(&self) -> &[u8] { + match &self.source { + Source::Tile(t) => t.bytes(), + Source::Blob(b) => b.as_slice(), + Source::Owned(v) => v.as_slice(), + } + } + + fn len(&self) -> usize { + self.all().len() + } +} + +impl Read for SourceReader { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + let all = self.all(); + let rest = all.get(self.pos..).unwrap_or(&[]); + let n = rest.len().min(buf.len()); + if let (Some(dst), Some(src)) = (buf.get_mut(..n), rest.get(..n)) { + dst.copy_from_slice(src); + } + self.pos += n; + Ok(n) + } +} + +/// A reusable zstd compressor. `ruzstd` allocates a block buffer, a ~1 MiB suffix table and a +/// block output buffer per frame; keeping the [`FrameCompressor`](ruzstd::encoding::FrameCompressor) +/// between objects reuses the first two (its matcher resets itself on every `compress`). +/// Output is byte-identical to `compress_to_vec` at the same level. +struct Compressor { + inner: ruzstd::encoding::FrameCompressor, ruzstd::encoding::MatchGeneratorDriver>, +} + +impl Compressor { + fn new() -> Self { + Self { inner: ruzstd::encoding::FrameCompressor::new(ruzstd::encoding::CompressionLevel::Fastest) } + } + + fn run(&mut self, source: Source) -> Vec { + let reader = SourceReader { source, pos: 0 }; + // Room for the worst case (raw blocks: 3 bytes per 128 KiB, plus frame header and + // checksum), so the output never regrows; shrunk afterwards to what it used. Untouched + // pages cost nothing. + let len = reader.len(); + let mut drain = Vec::new(); + if drain.try_reserve_exact(len.saturating_add(len / 16384).saturating_add(64)).is_err() { + drain = Vec::new(); + } + self.inner.set_source(reader); + self.inner.set_drain(drain); + self.inner.compress(); + self.inner.take_source(); + let mut out = self.inner.take_drain().unwrap_or_default(); + out.shrink_to_fit(); + out + } + + fn object(&mut self, obj: &Object) -> Vec { + let source = match obj { + Object::Tile(tile, _) if cfg!(target_endian = "little") => Source::Tile(Arc::clone(tile)), + Object::Tile(tile, sample) => Source::Owned(tile_le(tile, *sample).into_owned()), + Object::Blob(blob) => Source::Blob(Arc::clone(blob)), + }; + self.run(source) + } } /// Decodes a zstd frame of at most `max` bytes (`LimitExceeded` beyond). With `exact`, the @@ -528,8 +607,8 @@ enum Object { } impl Object { - fn compressed(&self) -> Vec { - compress(&self.content()) + fn compressed(&self, compressor: &mut Compressor) -> Vec { + compressor.object(self) } /// The bytes this object stores (tiles little-endian). @@ -860,7 +939,8 @@ type Compressed<'a> = (&'a String, Arc>); fn par_compress<'a>(objects: &[(&'a String, &'a Object)]) -> Result>> { let threads = if cfg!(target_arch = "wasm32") { 1 } else { std::thread::available_parallelism().map_or(1, |n| n.get()).clamp(1, 32) }; if threads < 2 || objects.len() < 2 { - return Ok(objects.iter().map(|(p, o)| (*p, Arc::new(o.compressed()))).collect()); + let mut compressor = Compressor::new(); + return Ok(objects.iter().map(|(p, o)| (*p, Arc::new(o.compressed(&mut compressor)))).collect()); } let next = std::sync::atomic::AtomicUsize::new(0); std::thread::scope(|sc| { @@ -868,10 +948,11 @@ fn par_compress<'a>(objects: &[(&'a String, &'a Object)]) -> Result = None; for (path, obj) in &p.objects { let (key, data) = match self.compressed.remove_entry(path) { Some((key, data)) => { @@ -989,7 +1071,7 @@ impl PcraftWriter { Object::Tile(..) => stats.tiles_written += 1, Object::Blob(_) => stats.blobs_written += 1, } - (None, fresh.remove(path).unwrap_or_else(|| Arc::new(obj.compressed()))) + (None, fresh.remove(path).unwrap_or_else(|| Arc::new(obj.compressed(compressor.get_or_insert_with(Compressor::new))))) } }; z.add(path, &data)?; @@ -1036,6 +1118,7 @@ impl PcraftWriter { None => 0, }; let recheck = self.rolling_recheck(&p.objects); + let mut compressor: Option = None; for (path, obj) in &p.objects { let object_path = dir.join(path); // bundle_root and list_objects validated the fixed parent directories once. Check @@ -1070,7 +1153,7 @@ impl PcraftWriter { Object::Tile(..) => stats.tiles_written += 1, Object::Blob(_) => stats.blobs_written += 1, } - write_bundle_file(dir, &object_path, &obj.compressed(), fs)?; + write_bundle_file(dir, &object_path, &obj.compressed(compressor.get_or_insert_with(Compressor::new)), fs)?; let written = path_signature(&object_path)?; if let Some(cache) = &mut self.verified_directory { match written { @@ -1421,6 +1504,38 @@ pub(crate) fn write_atomic(path: &Path, data: &[u8]) -> Result<()> { #[cfg(all(test, windows))] mod durability_tests; +#[cfg(test)] +#[test] +fn reused_compressor_matches_compress_to_vec_and_round_trips() { + let mut state = 0x9e37_79b9_u32; + let mut noise = |n: usize| -> Vec { + (0..n) + .map(|_| { + state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + (state >> 24) as u8 + }) + .collect() + }; + let inputs: Vec> = vec![ + Vec::new(), + vec![7], + b"hello hello hello hello hello".to_vec(), + noise(1000), + (0..300_000u32).map(|i| (i / 7 % 251) as u8).collect(), + noise(300_000), + vec![0; 262_144], + // A repeat after a big one proves the matcher state is reset between objects. + b"hello hello hello hello hello".to_vec(), + ]; + let mut compressor = Compressor::new(); + for data in inputs { + let expected = ruzstd::encoding::compress_to_vec(data.as_slice(), ruzstd::encoding::CompressionLevel::Fastest); + let got = compressor.object(&Object::Blob(Arc::new(data.clone()))); + assert_eq!(got, expected, "len {}", data.len()); + assert_eq!(decompress(&got, data.len(), "test").unwrap(), data); + } +} + #[cfg(test)] #[test] fn an_incompressible_blob_at_its_limit_still_loads() { @@ -1442,7 +1557,7 @@ fn an_incompressible_blob_at_its_limit_still_loads() { x as u8 }) .collect(); - let z = compress(&data); + let z = Compressor::new().object(&Object::Blob(Arc::new(data.clone()))); assert!(z.len() > data.len(), "the fixture must not compress"); let hash = hash_bytes(&data); let src = One(blob_path(&hash), z); diff --git a/crates/gpu/src/convert.rs b/crates/gpu/src/convert.rs index 9dbfc81814..19ad194765 100644 --- a/crates/gpu/src/convert.rs +++ b/crates/gpu/src/convert.rs @@ -1,6 +1,7 @@ use super::batch::write_tile; use super::textures::{Resident, TexKind}; use super::{Arc, F, HashMap, I, PixelFormat, Rect, SampleType, Surface, TILE_SIZE, Tile, TileCoord, U, fx}; +use photocraft_raster::scratch; /// Distinct tiles converted per parallel batch: bounds the converted bytes held at once (64 tiles /// of a 16-bit page are 32 MiB). @@ -141,6 +142,8 @@ pub(crate) fn convert_tiles( _ => put(t, cs, &bytes.concat()), } } + // The texels are on their way to the GPU: the buffers serve the next batch. + parts.into_iter().for_each(scratch::give); } groups.len() } @@ -157,7 +160,9 @@ pub(crate) fn colour_managed(fmt: &PixelFormat, kind: TexKind) -> bool { /// Tile pixels in the texture's format. `tile = None` gives the default pixel everywhere. pub(crate) fn convert_tile(surface: &Surface, tile: Option<&Arc>, kind: TexKind, c: TileCoord) -> Vec { if let (Some(t), TexKind::Rgba8Direct | TexKind::R8Direct) = (tile, kind) { - return t.bytes().to_vec(); + let mut out = scratch::take_cap(t.bytes().len()); + out.extend_from_slice(t.bytes()); + return out; } let fmt = surface.format(); if let (Some(t), TexKind::Rgba16F) = (tile, kind) @@ -179,7 +184,7 @@ fn convert_rows(surface: &Surface, tile: Option<&Arc>, kind: TexKind, rect Some(_) => surface.read_region(rect), None => surface.default_pixel().repeat(n), }; - let mut out = Vec::with_capacity(n * kind.bytes_per_pixel()); + let mut out = scratch::take_cap(n * kind.bytes_per_pixel()); // The previous pixel and its colour. let mut last: Option<(usize, [f32; 4])> = None; for (i, px) in raw.chunks_exact(ch).enumerate() { @@ -209,6 +214,7 @@ fn convert_rows(surface: &Surface, tile: Option<&Arc>, kind: TexKind, rect TexKind::R32F => out.extend(px[0].to_le_bytes()), } } + scratch::give(raw); out } @@ -229,7 +235,7 @@ pub(crate) fn rgb_gray16_to_f16(fmt: &PixelFormat, bytes: &[u8]) -> Option [u8; 2] { let v = px.get(i * 2..i * 2 + 2).map_or(0, |b| u16::from_ne_bytes([b[0], b[1]])); @@ -261,7 +267,7 @@ pub(crate) fn rgb_gray32_to_f16(fmt: &PixelFormat, bytes: &[u8]) -> Option [u8; 2] { let v = px.get(i * 4..i * 4 + 4).map_or(0.0, |b| f32::from_ne_bytes([b[0], b[1], b[2], b[3]])); diff --git a/crates/gpu/src/fx.rs b/crates/gpu/src/fx.rs index ceff607686..972ccadbea 100644 --- a/crates/gpu/src/fx.rs +++ b/crates/gpu/src/fx.rs @@ -427,12 +427,20 @@ fn fields_in_windows( }) } -/// The layer's shape (`compose::layer_shape`) over `r`, in parallel bands. +/// The layer's shape (`compose::layer_shape`) over `r`, in parallel bands. Callers hand the +/// result back with `photocraft_raster::scratch::give` once they have read it. pub(crate) fn shape(doc: &photocraft_doc::Document, layer: &Layer, r: Rect) -> Vec { if r.is_empty() { return Vec::new(); } - par_map(bands(r, BAND), |band| photocraft_compose::layer_shape(doc, layer, band)).concat() + let parts = par_map(bands(r, BAND), |band| photocraft_compose::layer_shape(doc, layer, band)); + let mut shape = photocraft_raster::scratch::take_cap(parts.iter().map(Vec::len).sum()); + // A band can be a pooled mask (Transparency Shapes Layer off): hand each back once copied. + for part in parts { + shape.extend_from_slice(&part); + photocraft_raster::scratch::give(part); + } + shape } /// Write `v` (row-major over `r`) into the region-sized `shape`. diff --git a/crates/gpu/src/fx_passes.rs b/crates/gpu/src/fx_passes.rs index f3f5ce6da3..9472141066 100644 --- a/crates/gpu/src/fx_passes.rs +++ b/crates/gpu/src/fx_passes.rs @@ -60,6 +60,7 @@ impl Compositor { e.region = region; rebuild = false; } + photocraft_raster::scratch::give(v); } if rebuild { let shape = Tex::map(device, "pc_fx_shape", region, MAP32); @@ -108,6 +109,7 @@ impl Compositor { fx::paste(&mut e.shape_cpu, region, damage, &v); e.shape.write_r32(queue, region, damage, &v); staged += v.len() as u64 * 4; + photocraft_raster::scratch::give(v); } // Programs, and the distance fields they read (max reach per field). diff --git a/crates/gpu/tests/it/parity.rs b/crates/gpu/tests/it/parity.rs index d22a4515d1..e8ab0b91c4 100644 --- a/crates/gpu/tests/it/parity.rs +++ b/crates/gpu/tests/it/parity.rs @@ -957,7 +957,7 @@ fn hard_stop_gradients_match_cpu_or_fall_back() { Ok(out) => out, Err(e) => { assert!(e.0.contains("coincident gradient stops"), "{e}"); - photocraft_compose::render(&d, d.bounds()).px + photocraft_compose::render(&d, d.bounds()).into_px() } }; let worst = worst_diff(&cpu.px, &out); diff --git a/crates/io/examples/oracle_diff.rs b/crates/io/examples/oracle_diff.rs index cc4b5aa0de..13977e1a61 100644 --- a/crates/io/examples/oracle_diff.rs +++ b/crates/io/examples/oracle_diff.rs @@ -34,7 +34,7 @@ fn run_file(path: &str) { println!("mode {:?} depth {:?} layers {} warnings {:?} light {:?}", doc.mode, doc.depth, doc.layer_count(), imp.warnings, doc.global_light); print_layer_details(doc); dump_filter_effects(&file); - let ours = photocraft_compose::flatten(doc).px; + let ours = photocraft_compose::flatten(doc).into_px(); let merged = photocraft_io::merged_composite(&file).unwrap(); compare_modes(doc, &ours, &merged); } @@ -487,7 +487,7 @@ fn corpus_summary(root: &std::path::Path) { return (name, None); } let Ok(merged) = photocraft_io::merged_composite(&file) else { return (name, None) }; - let ours = photocraft_compose::flatten(&imp.document).px; + let ours = photocraft_compose::flatten(&imp.document).into_px(); let mut m = 0.0f32; let mut bad = 0usize; for (a, b) in ours.iter().zip(merged) { diff --git a/crates/io/src/flat.rs b/crates/io/src/flat.rs index 8e169bd556..9863bdf9b0 100644 --- a/crates/io/src/flat.rs +++ b/crates/io/src/flat.rs @@ -452,9 +452,16 @@ pub(crate) fn image_surface(img: &Image, target_layout: ChannelLayout, csample: let band = (BAND_BYTES / row.max(1)).max(1); for (i, rows) in img.data().chunks(row.max(1) * band).enumerate() { let n = (rows.len() / row.max(1)) as u32; - let part = Image::from_raw(w, n, img.layout(), img.sample_type(), rows.to_vec())?.convert(target_layout, csample); + // Band buffers cycle through the scratch pool: at this size each fresh one is an + // mmap that gets page-faulted in. + let mut src = photocraft_raster::scratch::take_cap::(rows.len()); + src.extend_from_slice(rows); + let src = Image::from_raw(w, n, img.layout(), img.sample_type(), src)?; + let part = src.convert(target_layout, csample); let y0 = (i * band) as i32; s.write_interleaved(Rect::new(0, y0, w as i32, y0 + n as i32), part.data()); + photocraft_raster::scratch::give(src.into_data()); + photocraft_raster::scratch::give(part.into_data()); } } Ok(s) diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index b3135df1ad..d9bb69af2c 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -765,14 +765,14 @@ pub fn merged_composite(file: &PsdFile) -> Result, IoError> { let (doc, _) = psd_to_document(&PsdFile { layer_info, ..file.clone() }); // Multichannel documents keep their channels apart (no layer): composite them. if doc.layers.is_empty() && doc.mode == photocraft_color::ColorMode::Multichannel { - return Ok(photocraft_compose::flatten(&doc).px); + return Ok(photocraft_compose::flatten(&doc).into_px()); } let l = doc.layers.first().ok_or_else(|| IoError::Unsupported("no merged image".into()))?; let s = l.surface().ok_or_else(|| IoError::Unsupported("no merged image".into()))?; // CMYK reads through the file's own profile, as the compositor reads its layers, so a tagged // file's merged image and its composite are compared in the same colours. let cmyk = photocraft_compose::cmyk_space(&doc); - Ok(photocraft_color::convert::with_cmyk_space(cmyk.as_ref(), || photocraft_compose::surface_to_buffer(s, doc.bounds()).px)) + Ok(photocraft_color::convert::with_cmyk_space(cmyk.as_ref(), || photocraft_compose::surface_to_buffer(s, doc.bounds()).into_px())) } #[cfg(test)] diff --git a/crates/io/src/ora.rs b/crates/io/src/ora.rs index fb7968fbcc..36ebbc7b0a 100644 --- a/crates/io/src/ora.rs +++ b/crates/io/src/ora.rs @@ -629,7 +629,7 @@ impl Writer<'_> { LayerContent::Raster(s) => { let r = s.content_bounds(); admit_layer(r)?; - (r, photocraft_compose::surface_to_buffer(s, r).px) + (r, photocraft_compose::surface_to_buffer(s, r).into_px()) } _ => { // Rendered as it shows on its own, before the layer's blending options. @@ -643,7 +643,7 @@ impl Writer<'_> { solo.effects = Default::default(); let r = photocraft_compose::composite_bounds(&solo, canvas).unwrap_or(canvas).intersect(&canvas); admit_layer(r)?; - (r, photocraft_compose::render_layer(&solo, r).px) + (r, photocraft_compose::render_layer(&solo, r).into_px()) } }; if let Some(mask) = layer.mask.as_ref().filter(|m| m.enabled) { diff --git a/crates/io/src/pattern_map.rs b/crates/io/src/pattern_map.rs index 97417b7cc7..0b949a4bdf 100644 --- a/crates/io/src/pattern_map.rs +++ b/crates/io/src/pattern_map.rs @@ -120,7 +120,9 @@ fn pattern_from_psd(p: &PsdPattern, oom: &mut Option) -> Option crate::pixels::lab16_chroma(&mut bytes, refs.len(), true); } let mut surface = Surface::new(fmt); - if let Err(error) = surface.try_write_interleaved(Rect::new(0, 0, w as i32, h as i32), &bytes) { + let result = surface.try_write_interleaved(Rect::new(0, 0, w as i32, h as i32), &bytes); + photocraft_raster::scratch::give(bytes); + if let Err(error) = result { *oom = Some(error.to_string()); return None; } diff --git a/crates/io/src/pixels.rs b/crates/io/src/pixels.rs index 1ac715984f..63c6742a1c 100644 --- a/crates/io/src/pixels.rs +++ b/crates/io/src/pixels.rs @@ -47,7 +47,8 @@ fn invert_sample(bytes: &mut [u8], s: SampleType) { /// are filled with `fill[c]` (a native-endian encoded sample). Channels whose /// `invert[c]` is set are inverted (PSD stores CMYK as `max - ink`). pub fn interleave(planes: &[Option<&[u8]>], fill: &[Vec], n: usize, s: SampleType, invert: &[bool]) -> Vec { - let mut out = vec![0u8; n * planes.len() * s.bytes()]; + // Large results come from (and callers may return them to) the scratch pool. + let mut out = photocraft_raster::scratch::take(n * planes.len() * s.bytes(), 0u8); interleave_into(planes, fill, n, s, invert, &mut out); out } @@ -69,9 +70,7 @@ pub fn try_interleave(planes: &[Option<&[u8]>], fill: &[Vec], n: usize, s: S return Err(PsdError::Invalid("interleave fill has the wrong sample size".into())); } } - let mut out = Vec::new(); - out.try_reserve_exact(len).map_err(|_| PsdError::OutOfMemory("not enough memory to interleave PSD pixels"))?; - out.resize(len, 0); + let mut out = photocraft_raster::scratch::try_take_exact(len, 0u8).ok_or(PsdError::OutOfMemory("not enough memory to interleave PSD pixels"))?; interleave_into(planes, fill, n, s, invert, &mut out); Ok(out) } diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index 315744b354..6086aaf644 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -223,6 +223,8 @@ impl Ex { let mut bytes = Vec::with_capacity(row_len * h_of(r) * s.format().bytes_per_pixel()); for part in parts { bytes.extend_from_slice(&part); + // Free gathered bands before allocating channel planes below; pooling them here + // retains another layer-sized allocation throughout deinterleave. } if self.fmt.mode == ColorMode::Lab && self.fmt.sample == SampleType::U16 { crate::pixels::lab16_chroma(&mut bytes, self.cc + 1, false); diff --git a/crates/io/src/psd_import.rs b/crates/io/src/psd_import.rs index 6989941d31..a70577fb6f 100644 --- a/crates/io/src/psd_import.rs +++ b/crates/io/src/psd_import.rs @@ -328,6 +328,12 @@ impl Ctx<'_> { self.allocation_failed(format!("layer \"{name}\": {error}")); return Surface::new(self.fmt); } + // The interleaved band goes back to the scratch pool for the next band (and the next + // import): it is a fresh page-faulted mmap otherwise. The decoded planes don't: the + // decoder allocates its own, so pooled ones would only sit there. + drop(refs); + photocraft_raster::scratch::give(bytes); + drop(planes); } surf.prune(); // A white background, a solid fill's cache: one tile per value, shared across layers. @@ -1408,6 +1414,7 @@ fn import_layered(import: DecodedImport<'_>, doc: &mut Document, cx: &mut Ctx<'_ if let Err(error) = surface.try_write_interleaved(canvas, &bytes) { return cx.allocation_failed(format!("merged image: {error}")); } + photocraft_raster::scratch::give(bytes); if alpha_idx.is_some() { let white = photocraft_raster::from_rgba(&fmt, [1.0, 1.0, 1.0, 1.0]); let mut vals = Vec::new(); @@ -1547,6 +1554,7 @@ fn import_extra_channels(import: DecodedImport<'_>, doc: &mut Document, cx: &mut if let Err(error) = s.try_write_interleaved(canvas, &raw) { return cx.allocation_failed(format!("extra channel {idx}: {error}")); } + photocraft_raster::scratch::give(raw); s.prune(); let name = names.get(k).cloned().unwrap_or_else(|| format!("Alpha {}", k + 1)); doc.channels.push(AlphaChannel::new(name, s)); diff --git a/crates/io/src/smart_map.rs b/crates/io/src/smart_map.rs index 72adbe1291..cacce2b7cb 100644 --- a/crates/io/src/smart_map.rs +++ b/crates/io/src/smart_map.rs @@ -173,6 +173,7 @@ pub fn mask_from_item(item: &FilterEffectsItem, sample: SampleType, stack: &Filt let n = w.checked_mul(h).ok_or("the smart filter mask is too large")?; let bytes = crate::pixels::interleave(&[Some(&plane)], &[crate::pixels::zero_sample(sample)], n, sample, &[false]); surface.write_interleaved(GeomRect::new(rect.left, rect.top, rect.right, rect.bottom), &bytes); + photocraft_raster::scratch::give(bytes); surface.prune(); if surface.content_bounds().is_empty() { return Ok(None); @@ -215,6 +216,7 @@ pub fn unfiltered_from_item(item: &FilterEffectsItem, fmt: PixelFormat) -> Optio } let mut surface = Surface::new(fmt); surface.write_interleaved(GeomRect::new(item.rect.left, item.rect.top, item.rect.right, item.rect.bottom), &bytes); + photocraft_raster::scratch::give(bytes); surface.prune(); Some(surface) } diff --git a/crates/io/tests/it/banded.rs b/crates/io/tests/it/banded.rs index eb8e97d6b5..c526b79fdb 100644 --- a/crates/io/tests/it/banded.rs +++ b/crates/io/tests/it/banded.rs @@ -82,7 +82,7 @@ fn psd_merged_image_matches_composite_and_drops_near_opaque_alpha() { let file = document_to_psd(&d); assert_eq!(file.merged_has_alpha(), alpha < 0.99, "alpha {alpha}"); let merged = merged_composite(&file).unwrap(); - let want = photocraft_compose::flatten(&d).px; + let want = photocraft_compose::flatten(&d).into_px(); let m = merged.iter().zip(&want).flat_map(|(a, b)| (0..4).map(move |i| (a[i] - b[i]).abs())).fold(0.0f32, f32::max); // Un-matting divides the 8-bit rounding error by alpha. assert!(m <= 1.0 / (255.0 * alpha) + 1e-4, "alpha {alpha}: max diff {m}"); diff --git a/crates/io/tests/it/blend_modes_corpus.rs b/crates/io/tests/it/blend_modes_corpus.rs index 296e2e2a50..72980bed5b 100644 --- a/crates/io/tests/it/blend_modes_corpus.rs +++ b/crates/io/tests/it/blend_modes_corpus.rs @@ -18,7 +18,7 @@ fn cell_error(mode: BlendMode) -> (f32, f32) { let doc = import("rgb-blend-modes.psd", &bytes).expect("opens").document; let file = PsdFile::from_bytes(&bytes).expect("parses"); let merged = merged_composite(&file).expect("merged image"); - let ours = photocraft_compose::flatten(&doc).px; + let ours = photocraft_compose::flatten(&doc).into_px(); let width = doc.size.width as i32; let duck = doc.walk().into_iter().map(|(_, _, l)| l).find(|l| l.name == "rubber_duck_PNG1" && l.blend == mode).expect("the mode's duck"); let surface = duck.surface().expect("duck pixels"); diff --git a/crates/io/tests/it/cmyk_composite_export.rs b/crates/io/tests/it/cmyk_composite_export.rs index 0fea33695d..77f35a62d2 100644 --- a/crates/io/tests/it/cmyk_composite_export.rs +++ b/crates/io/tests/it/cmyk_composite_export.rs @@ -313,7 +313,8 @@ fn rgb_only_export_uses_direct_composite_not_reseparated_cmyk() { let doc = document(SampleType::U16, 0.5); let rgb = photocraft_compose::flatten(&doc); let expected = - photocraft_codecs::Image::from_normalized(8, 8, ChannelLayout::Rgba, CodecSample::U16, &rgb.px.into_iter().flatten().collect::>()).unwrap(); + photocraft_codecs::Image::from_normalized(8, 8, ChannelLayout::Rgba, CodecSample::U16, &rgb.into_px().into_iter().flatten().collect::>()) + .unwrap(); let result = export(&doc, "png", &ExportOptions::default()).unwrap(); diff --git a/crates/io/tests/it/color.rs b/crates/io/tests/it/color.rs index 15453a2518..5da5df1c7e 100644 --- a/crates/io/tests/it/color.rs +++ b/crates/io/tests/it/color.rs @@ -116,11 +116,11 @@ fn cmyk_fills_survive_psd_roundtrip_at_every_depth() { let mut doc = Document::new("CMYK fill", photocraft_geom::Size::new(9, 6), ColorMode::Cmyk, depth); doc.icc_profile = profile.clone(); doc.layers.push(Layer::new("Fill", LayerContent::Fill(fill))); - let expected = photocraft_compose::flatten(&doc).px; + let expected = photocraft_compose::flatten(&doc).into_px(); assert!(max_diff(&expected, &photocraft_compose::render_tiled(&doc, doc.bounds(), 3).px) <= 1e-6); let bytes = export(&doc, "x.psd", &ExportOptions::default()).unwrap().bytes; let back = import("x.psd", &bytes).unwrap().document; - let actual = photocraft_compose::flatten(&back).px; + let actual = photocraft_compose::flatten(&back).into_px(); let diff = max_diff(&expected, &actual); assert!(diff <= 1e-6, "{depth:?} {style:?}: round-trip error {diff}"); assert_eq!(back.icc_profile, doc.icc_profile); @@ -144,7 +144,7 @@ fn uncached_cmyk_gradient_corpus_roundtrip() { let bytes = std::fs::read(path).expect("run cargo xtask corpus --all"); let doc = import("x.psd", &bytes).unwrap().document; assert!(doc.layers.iter().any(|l| matches!(l.content, photocraft_doc::LayerContent::Fill(_)) && l.fill_cache.is_none())); - let expected = photocraft_compose::flatten(&doc).px; + let expected = photocraft_compose::flatten(&doc).into_px(); let saved = export(&doc, "x.psd", &ExportOptions::default()).unwrap().bytes; let back = import("x.psd", &saved).unwrap().document; let diff = max_diff(&expected, &photocraft_compose::flatten(&back).px); @@ -305,7 +305,7 @@ fn gradient_maps_with_cmyk_lab_gray_stops_import_and_keep_their_space() { _ => assert_eq!((first, last), ([1.0; 3], [0.0; 3])), } // The layer now renders: the composite differs from the background alone. - let with_map = photocraft_compose::flatten(&back).px; + let with_map = photocraft_compose::flatten(&back).into_px(); let mut hidden = back.clone(); hidden.layers[1].visible = false; assert!(max_diff(&with_map, &photocraft_compose::flatten(&hidden).px) > 0.01, "{mode:?} {depth:?}"); diff --git a/crates/io/tests/it/composite.rs b/crates/io/tests/it/composite.rs index cef80e6a08..74eb0c95f1 100644 --- a/crates/io/tests/it/composite.rs +++ b/crates/io/tests/it/composite.rs @@ -15,7 +15,7 @@ fn check_export_oracle(d: &photocraft_doc::Document, tol: f32) { let f = PsdFile::from_bytes(&r.bytes).unwrap(); let merged = merged_composite(&f).unwrap(); let d2 = import("x.psd", &r.bytes).unwrap().document; - let mut ours = photocraft_compose::flatten(&d2).px; + let mut ours = photocraft_compose::flatten(&d2).into_px(); if d.mode == ColorMode::Cmyk { // The merged CMYK image is the RGB composite separated through the CMYK profile, which // gamut-maps colours that blend modes push outside CMYK: project ours the same way. @@ -107,7 +107,7 @@ fn builder_oracle(depth: u16, mode: ColorMode) { let f = PsdFile::from_bytes(&bytes).unwrap(); let merged = merged_composite(&f).unwrap(); let d2 = import("b.psd", &bytes).unwrap().document; - let ours = photocraft_compose::flatten(&d2).px; + let ours = photocraft_compose::flatten(&d2).into_px(); let m = max_diff(&ours, &merged); // Density/feather of masks are not written by the builder; the generated // doc uses them only on a disabled mask, so results must match. @@ -157,7 +157,7 @@ fn hidden_and_clipped_affect_composite() { for l in &mut d2.layers { l.visible = true; } - let other = photocraft_compose::flatten(&d2).px; + let other = photocraft_compose::flatten(&d2).into_px(); assert!(max_diff(&other, &merged) > 0.0, "unhiding a layer must change the composite"); } diff --git a/crates/io/tests/it/corpus.rs b/crates/io/tests/it/corpus.rs index 182d60095a..3c8ca3288a 100644 --- a/crates/io/tests/it/corpus.rs +++ b/crates/io/tests/it/corpus.rs @@ -316,9 +316,9 @@ fn check_file(name: &str, bytes: &[u8]) -> (Outcome, Option) { return (Outcome::Error, None); } }; - let ours = photocraft_compose::flatten(doc).px; + let ours = photocraft_compose::flatten(doc).into_px(); // Our own export must not change how the document renders. - let again = photocraft_compose::flatten(&reimported).px; + let again = photocraft_compose::flatten(&reimported).into_px(); let rt = if again.len() == ours.len() { common::max_diff(&ours, &again) } else { f32::INFINITY }; let layers = doc.layer_count(); // Oracle choice: the merged image (Photoshop's composite; for files without layers it is the diff --git a/crates/io/tests/it/effects_multi.rs b/crates/io/tests/it/effects_multi.rs index 637e926085..7cabb04b4c 100644 --- a/crates/io/tests/it/effects_multi.rs +++ b/crates/io/tests/it/effects_multi.rs @@ -17,7 +17,7 @@ fn roundtrip(doc: &Document) -> Document { } fn max_diff(a: &Document, b: &Document) -> f32 { - let (x, y) = (photocraft_compose::flatten(a).px, photocraft_compose::flatten(b).px); + let (x, y) = (photocraft_compose::flatten(a).into_px(), photocraft_compose::flatten(b).into_px()); assert_eq!(x.len(), y.len()); x.iter().zip(&y).flat_map(|(p, q)| (0..4).map(move |c| (p[c] * p[3] - q[c] * q[3]).abs())).fold(0.0, f32::max) } diff --git a/crates/io/tests/it/flat.rs b/crates/io/tests/it/flat.rs index f6e888eb06..c40c76a12b 100644 --- a/crates/io/tests/it/flat.rs +++ b/crates/io/tests/it/flat.rs @@ -350,7 +350,7 @@ fn layered_to_png_warns_flatten() { assert!(r.warnings.iter().any(|w| w.contains("flattened")), "{:?}", r.warnings); let back = import("out.png", &r.bytes).unwrap().document; // Composite equals our flatten. - let flat = photocraft_compose::flatten(&d).px; + let flat = photocraft_compose::flatten(&d).into_px(); let s = back.layers[0].surface().unwrap(); let px: Vec<[f32; 4]> = s.read_region(back.bounds()).as_chunks::<4>().0.iter().map(|p| [p[0], p[1], p[2], p[3]]).collect(); assert!(max_diff(&px, &flat) <= 1.0 / 255.0 + 1e-4); diff --git a/crates/io/tests/it/lab16.rs b/crates/io/tests/it/lab16.rs index 0aee251861..ff0707c11b 100644 --- a/crates/io/tests/it/lab16.rs +++ b/crates/io/tests/it/lab16.rs @@ -60,8 +60,8 @@ fn lab_round_trips_at_every_depth() { let p = back.layers[0].surface().unwrap().pixel(2, 0); let tol = if depth == SampleType::U8 { 0.5 / 255.0 + 1e-6 } else { 2e-5 }; assert!((p[1] - A).abs() <= tol && (p[2] - B).abs() <= tol, "{depth:?}: {p:?}"); - let a = photocraft_compose::flatten(&doc).px; - let b = photocraft_compose::flatten(&back).px; + let a = photocraft_compose::flatten(&doc).into_px(); + let b = photocraft_compose::flatten(&back).into_px(); for (x, y) in a.iter().zip(&b) { for c in 0..4 { assert!((x[c] - y[c]).abs() <= 1.0 / 255.0, "{depth:?}: {x:?} vs {y:?}"); diff --git a/crates/io/tests/it/large_files.rs b/crates/io/tests/it/large_files.rs index b0a2669480..be4d5eeab7 100644 --- a/crates/io/tests/it/large_files.rs +++ b/crates/io/tests/it/large_files.rs @@ -42,7 +42,7 @@ fn check_grid(g: &Grid, doc: &Document, what: &str) { ]; assert_eq!(census(doc), want, "{what}: [raster, groups, masked, with effects, hidden]"); for (x, y) in g.sample_points() { - let px = photocraft_compose::render(doc, Rect::from_xywh(x as i32, y as i32, 1, 1)).px; + let px = photocraft_compose::render(doc, Rect::from_xywh(x as i32, y as i32, 1, 1)).into_px(); let got = px.first().copied().unwrap_or_default(); let want = g.expected(x, y); for c in 0..3 { diff --git a/crates/io/tests/it/multichannel.rs b/crates/io/tests/it/multichannel.rs index 4e7ffeeb2e..f4f7154d41 100644 --- a/crates/io/tests/it/multichannel.rs +++ b/crates/io/tests/it/multichannel.rs @@ -56,7 +56,7 @@ fn alpha_type_channels_import_as_alpha_and_round_trip() { as_ink.channels[1].surface = s; as_ink.channels[1].spot = Some((photocraft_color::Color::BLACK, 0.0)); // Within f32 rounding: the inverted copy is re-quantised to the channel depth. - let (shown, inked) = (photocraft_compose::flatten(&d).px, photocraft_compose::flatten(&as_ink).px); + let (shown, inked) = (photocraft_compose::flatten(&d).into_px(), photocraft_compose::flatten(&as_ink).into_px()); assert_eq!(shown.len(), inked.len()); for (a, b) in shown.iter().zip(&inked) { assert!(a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-5), "{a:?} vs {b:?} ({depth}-bit)"); @@ -115,7 +115,7 @@ fn alpha_type_channels_are_stored_as_is_not_as_ink() { assert_eq!(values(&d.channels[0]), [1.0, 0.0, 1.0, 0.0], "spot channel ({depth}-bit)"); // The composite is what Photoshop shows: the alpha prints as black ink where unselected. - let px = photocraft_compose::flatten(&d).px; + let px = photocraft_compose::flatten(&d).into_px(); assert_eq!(px.iter().map(|p| [p[0], p[1], p[2]]).collect::>(), [[0.0, 1.0, 1.0], [1.0, 1.0, 1.0], [0.0, 0.0, 0.0], [0.0, 0.0, 0.0]]); // Saved back byte for byte. diff --git a/crates/io/tests/it/psd32_export.rs b/crates/io/tests/it/psd32_export.rs index f9a297a7f6..428fb7903e 100644 --- a/crates/io/tests/it/psd32_export.rs +++ b/crates/io/tests/it/psd32_export.rs @@ -118,7 +118,7 @@ fn layered_32bit_export_reimports_render_equal() { for mode in [ColorMode::Rgb, ColorMode::Grayscale] { let doc = common::gen_doc(mode, SampleType::F32, Features::ALL); let back = import("t.psd", &export_psd(&doc)).unwrap().document; - let (a, b) = (photocraft_compose::flatten(&doc).px, photocraft_compose::flatten(&back).px); + let (a, b) = (photocraft_compose::flatten(&doc).into_px(), photocraft_compose::flatten(&back).into_px()); assert_eq!(a.len(), b.len()); let d = common::max_diff(&a, &b); assert!(d <= 1e-4, "{mode:?}: render differs by {d}"); @@ -154,7 +154,7 @@ fn photoshop_32bit_corpus_round_trip() { assert_32bit_layout(&name, &out); let back = import(&name, &out).unwrap().document; assert_eq!(doc.layer_count(), back.layer_count(), "{name}: layer count"); - let (a, b) = (photocraft_compose::flatten(&doc).px, photocraft_compose::flatten(&back).px); + let (a, b) = (photocraft_compose::flatten(&doc).into_px(), photocraft_compose::flatten(&back).into_px()); assert_eq!(a.len(), b.len(), "{name}"); let d = common::max_diff(&a, &b); assert!(d <= 1e-4, "{name}: render differs by {d}"); diff --git a/crates/io/tests/it/psd_damaged_merged.rs b/crates/io/tests/it/psd_damaged_merged.rs index 8e20687bb5..51121d59b7 100644 --- a/crates/io/tests/it/psd_damaged_merged.rs +++ b/crates/io/tests/it/psd_damaged_merged.rs @@ -32,7 +32,7 @@ fn truncated_merged_image_keeps_layer_pixels_at_every_depth_and_compression() { for compression in Compression::ALL { let mut file = layered_in_mode(mode, depth, extension); let intact = import(extension, &file.to_bytes().unwrap()).unwrap(); - let expected = photocraft_compose::flatten(&intact.document).px; + let expected = photocraft_compose::flatten(&intact.document).into_px(); let pixels = file.decode_merged().unwrap(); file.image_data = photocraft_psd::ImageData::encode(compression, &pixels, &file.header).unwrap(); file.image_data.data.truncate(file.image_data.data.len() / 2); diff --git a/crates/io/tests/it/psd_type_bounds.rs b/crates/io/tests/it/psd_type_bounds.rs index b87d4f4a3a..43ce38c674 100644 --- a/crates/io/tests/it/psd_type_bounds.rs +++ b/crates/io/tests/it/psd_type_bounds.rs @@ -283,7 +283,7 @@ fn imported_type_layers_keep_their_full_bounds() { let o = iou(&ours.surface, cache); assert!(o > 0.97, "{}: re-render overlap {o}", c.name); // And the document composite is the file's merged composite. - let px = photocraft_compose::flatten(&doc).px; + let px = photocraft_compose::flatten(&doc).into_px(); let worst = common::max_diff(&px, &merged); assert!(worst <= 2.0 / 255.0, "{}: composite differs from the merged image by {worst}", c.name); } diff --git a/crates/io/tests/it/tiff_corpus.rs b/crates/io/tests/it/tiff_corpus.rs index 697eab6d32..1b3c36a05d 100644 --- a/crates/io/tests/it/tiff_corpus.rs +++ b/crates/io/tests/it/tiff_corpus.rs @@ -124,8 +124,8 @@ fn corpus_round_trips_through_layered_tiff() { } d }; - let x = photocraft_compose::flatten(&expected).px; - let y = photocraft_compose::flatten(&back).px; + let x = photocraft_compose::flatten(&expected).into_px(); + let y = photocraft_compose::flatten(&back).into_px(); let diff = max_diff(&x, &y); if diff > 1.0 / 255.0 + 1e-5 { return Err(format!("render differs by {diff}")); diff --git a/crates/io/tests/it/tiff_layers.rs b/crates/io/tests/it/tiff_layers.rs index f2ccddc92e..3c7815a37a 100644 --- a/crates/io/tests/it/tiff_layers.rs +++ b/crates/io/tests/it/tiff_layers.rs @@ -87,8 +87,8 @@ fn tiff_and_psd_open_as_the_same_document() { ) .unwrap() .document; - let a = photocraft_compose::flatten(&d).px; - let b = photocraft_compose::flatten(&flat).px; + let a = photocraft_compose::flatten(&d).into_px(); + let b = photocraft_compose::flatten(&flat).into_px(); assert!(max_diff(&a, &b) <= 2.0 / 255.0, "{mode:?} {depth:?}: composite differs by {}", max_diff(&a, &b)); } } diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs index 5d0f6fe257..d949445bcc 100644 --- a/crates/raster/src/lib.rs +++ b/crates/raster/src/lib.rs @@ -9,6 +9,7 @@ mod interrupt; pub use interrupt::{Cancelled, Interrupt}; +pub mod scratch; use std::collections::BTreeMap; use std::fmt; @@ -62,18 +63,30 @@ impl TileInterner { } /// Pixel storage for one tile: `TILE_SIZE² × bytes_per_pixel`, row-major, interleaved channels. -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Debug, PartialEq, Eq)] pub struct Tile { data: Box<[u8]>, } +impl Clone for Tile { + fn clone(&self) -> Self { + let mut data = scratch::take_exact(self.data.len(), 0u8); + data.copy_from_slice(&self.data); + Tile { data: data.into_boxed_slice() } + } +} + +impl Drop for Tile { + fn drop(&mut self) { + scratch::give(std::mem::take(&mut self.data).into_vec()); + } +} + impl Tile { fn try_filled(format: &PixelFormat, pixel: &[u8]) -> Result { let n = (TILE_SIZE * TILE_SIZE) as usize; let bytes = n.checked_mul(format.bytes_per_pixel()).ok_or(AllocationError::SizeOverflow)?; - let mut data = Vec::new(); - data.try_reserve_exact(bytes).map_err(|_| AllocationError::NotEnoughMemory { bytes })?; - data.resize(bytes, 0); + let mut data = scratch::try_take_exact(bytes, 0u8).ok_or(AllocationError::NotEnoughMemory { bytes })?; if pixel.iter().any(|&b| b != 0) { for px in data.chunks_exact_mut(pixel.len()) { px.copy_from_slice(pixel); @@ -85,15 +98,14 @@ impl Tile { /// Clone this tile's pixel buffer with a fallible reservation. pub fn try_clone(&self) -> Result { let bytes = self.data.len(); - let mut data = Vec::new(); - data.try_reserve_exact(bytes).map_err(|_| AllocationError::NotEnoughMemory { bytes })?; - data.extend_from_slice(&self.data); + let mut data = scratch::try_take_exact(bytes, 0u8).ok_or(AllocationError::NotEnoughMemory { bytes })?; + data.copy_from_slice(&self.data); Ok(Tile { data: data.into_boxed_slice() }) } fn filled(format: &PixelFormat, pixel: &[u8]) -> Self { let row_len = TILE_SIZE as usize * format.bytes_per_pixel(); - let mut data = vec![0u8; row_len * TILE_SIZE as usize]; + let mut data = scratch::take_exact(row_len * TILE_SIZE as usize, 0u8); if pixel.iter().any(|&b| b != 0) { // One prebuilt row copied per tile row: 256 bulk copies instead of one // copy_from_slice per pixel. @@ -358,9 +370,7 @@ impl Surface { pub fn try_read_region(&self, r: Rect) -> Result, AllocationError> { let len = (r.width() as usize).checked_mul(r.height() as usize).and_then(|n| n.checked_mul(self.channels())).ok_or(AllocationError::SizeOverflow)?; let bytes = len.checked_mul(std::mem::size_of::()).ok_or(AllocationError::SizeOverflow)?; - let mut out = Vec::new(); - out.try_reserve_exact(len).map_err(|_| AllocationError::NotEnoughMemory { bytes })?; - out.resize(len, 0.0); + let mut out = scratch::try_take_exact(len, 0.0f32).ok_or(AllocationError::NotEnoughMemory { bytes })?; self.read_region_into(r, &mut out); Ok(out) } @@ -761,7 +771,7 @@ impl Surface { pub fn read_region(&self, r: Rect) -> Vec { let n = self.channels(); let w = r.width() as usize; - let mut out = vec![0.0f32; w * r.height() as usize * n]; + let mut out = scratch::take(w * r.height() as usize * n, 0.0f32); self.read_samples_into(r, &mut out); out } @@ -935,7 +945,7 @@ impl Surface { pub fn to_interleaved(&self, r: Rect) -> Vec { let bpp = self.format.bytes_per_pixel(); let w = r.width() as usize; - let mut out = vec![0u8; w * r.height() as usize * bpp]; + let mut out = scratch::take(w * r.height() as usize * bpp, 0u8); for tc in r.tiles() { let tr = tc.rect().intersect(&r); let span = tr.width() as usize * bpp; @@ -1022,7 +1032,7 @@ impl Surface { return hit.clone().map(|t| (dc, t)); } - let mut tile = Tile { data: vec![0u8; (TILE_SIZE * TILE_SIZE) as usize * bpp].into_boxed_slice() }; + let mut tile = Tile { data: scratch::take_exact((TILE_SIZE * TILE_SIZE) as usize * bpp, 0u8).into_boxed_slice() }; let (tx0, ty0) = (i64::from(dc.tx) * i64::from(TILE_SIZE), i64::from(dc.ty) * i64::from(TILE_SIZE)); let row_len = TILE_SIZE as usize * bpp; // Per stored row: copy the source span that falls inside the content in as few diff --git a/crates/raster/src/scratch.rs b/crates/raster/src/scratch.rs new file mode 100644 index 0000000000..cb0f898306 --- /dev/null +++ b/crates/raster/src/scratch.rs @@ -0,0 +1,384 @@ +//! Process-wide, capped pool of large scratch buffers. +//! +//! With glibc's mmap threshold pinned (`MALLOC_TRIM_THRESHOLD_`, which is how PhotoCraft returns +//! memory to the OS), every allocation over 128 KiB is a fresh `mmap` that is page-faulted in and +//! `munmap`ed on free. Tiles, compositor bands and filter scratch are all that size, so a single +//! viewport render took 350× the page faults. This pool keeps freed buffers (at least +//! [`MIN_POOLED_BYTES`] of capacity) on per-type free lists and hands them back out, so the +//! steady state allocates nothing. +//! +//! Retention is capped by [`set_budget`] (default [`DEFAULT_BUDGET`]); [`release`] drops +//! everything, which is what returns the memory to the OS. The engine releases when a document +//! closes; off wasm a background thread also releases once the pool has gone unused for +//! [`IDLE_RELEASE_SECS`], so every host (app, CLI, tests) gives memory back the same way. + +use std::collections::{BTreeMap, VecDeque}; +use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; +use std::sync::{Mutex, PoisonError}; + +/// Buffers with less capacity than this are cheap from the heap and are never retained. +pub const MIN_POOLED_BYTES: usize = 64 * 1024; +/// Default cap on the bytes the pool retains: about the working set of a 36 MP filter (tiles, +/// halo images and result bands); a browser's wasm heap never shrinks, so there it is small. +pub const DEFAULT_BUDGET: usize = if cfg!(target_arch = "wasm32") { 256 << 20 } else { 2 << 30 }; + +/// Pooled bytes a miss never evicts. A filter's output tiles are all misses (the old tiles stay +/// in the undo history); evicting every byte they need also dropped the scratch planes the next +/// tile reuses. On a 36 MP Gaussian Blur with the trim threshold set, 256 MiB keeps the timings +/// at glibc's defaults (128 MiB: r 40 ~17% slower) for ~10% more peak memory (0: ~20% slower). +pub const DEFAULT_MISS_FLOOR: usize = if cfg!(target_arch = "wasm32") { 64 << 20 } else { 256 << 20 }; + +static BUDGET: AtomicUsize = AtomicUsize::new(DEFAULT_BUDGET); +static MISS_FLOOR: AtomicUsize = AtomicUsize::new(DEFAULT_MISS_FLOOR); +static RETAINED: AtomicUsize = AtomicUsize::new(0); +static USES: AtomicU64 = AtomicU64::new(0); +static IDLE_RELEASE: AtomicBool = AtomicBool::new(true); + +/// Seconds without a [`take`] or [`give`] after which the idle thread releases the pool. +pub const IDLE_RELEASE_SECS: u64 = 2; + +/// Starts the idle-release thread once (off wasm, where there are threads). If the thread +/// can't be spawned the pool still works; it is then released only by [`release`]. +fn start_idle_release() { + #[cfg(not(target_arch = "wasm32"))] + { + static STARTED: std::sync::Once = std::sync::Once::new(); + STARTED.call_once(|| { + let _ = std::thread::Builder::new().name("scratch-idle".into()).spawn(|| { + let mut seen = uses(); + loop { + std::thread::sleep(std::time::Duration::from_secs(IDLE_RELEASE_SECS)); + let now = uses(); + if now == seen { + // Decided under the lock, so `set_idle_release(false)` can't return + // while a release it should have stopped is about to start. + let old = { + let mut pool = lock(); + // A `give` or `take` since `now` was read means the pool is in use. + (uses() == now && retained_bytes() > 0 && IDLE_RELEASE.load(Ordering::Relaxed)).then(|| empty(&mut pool)) + }; + drop(old); + } + seen = now; + } + }); + }); + } +} + +/// One free list: buffers keyed by capacity (in elements), each with the sequence number it +/// was returned at (oldest first within a capacity). +#[doc(hidden)] +pub type FreeList = BTreeMap)>>; + +/// All the free lists behind one lock, so the oldest buffer of any type can be evicted. +/// `order` maps every pooled buffer's sequence number to its type and capacity, so the oldest +/// is found in O(log n); best-fit lookups are a range query on the capacity key. +#[doc(hidden)] +pub struct Pool { + seq: u64, + order: BTreeMap, + u8s: FreeList, + u16s: FreeList, + f32s: FreeList, + px4: FreeList<[f32; 4]>, +} + +static POOL: Mutex = + Mutex::new(Pool { seq: 0, order: BTreeMap::new(), u8s: BTreeMap::new(), u16s: BTreeMap::new(), f32s: BTreeMap::new(), px4: BTreeMap::new() }); + +/// Which free list a buffer lives on. +#[doc(hidden)] +#[derive(Clone, Copy)] +pub enum Tag { + U8, + U16, + F32, + Px4, +} + +/// A buffer evicted from the pool, dropped by the caller once the lock is released. +#[allow(dead_code)] +enum Evicted { + U8(Vec), + U16(Vec), + F32(Vec), + Px4(Vec<[f32; 4]>), +} + +/// Element types that have a free list. Implemented for the buffers the hot paths use. +pub trait Pooled: Copy + Send + 'static { + #[doc(hidden)] + const TAG: Tag; + #[doc(hidden)] + fn list(pool: &mut Pool) -> &mut FreeList; +} + +macro_rules! pooled { + ($($t:ty => $field:ident, $tag:ident),*) => {$( + impl Pooled for $t { + const TAG: Tag = Tag::$tag; + fn list(pool: &mut Pool) -> &mut FreeList { + &mut pool.$field + } + } + )*}; +} +pooled!(u8 => u8s, U8, u16 => u16s, U16, f32 => f32s, F32, [f32; 4] => px4, Px4); + +fn bytes_of(v: &Vec) -> usize { + v.capacity().saturating_mul(std::mem::size_of::()) +} + +fn lock() -> std::sync::MutexGuard<'static, Pool> { + POOL.lock().unwrap_or_else(PoisonError::into_inner) +} + +/// Remove the oldest buffer of capacity `cap` from `list`. +fn drop_oldest(list: &mut FreeList, cap: usize) -> Option> { + let queue = list.get_mut(&cap)?; + let v = queue.pop_front()?.1; + if queue.is_empty() { + list.remove(&cap); + } + Some(v) +} + +impl Pool { + /// Remove the buffer returned longest ago, of any type. `None` when the pool is empty. + fn evict_oldest(&mut self) -> Option { + let (_, (tag, cap)) = self.order.pop_first()?; + let evicted = match tag { + Tag::U8 => drop_oldest(&mut self.u8s, cap).map(Evicted::U8), + Tag::U16 => drop_oldest(&mut self.u16s, cap).map(Evicted::U16), + Tag::F32 => drop_oldest(&mut self.f32s, cap).map(Evicted::F32), + Tag::Px4 => drop_oldest(&mut self.px4, cap).map(Evicted::Px4), + }; + if let Some(e) = &evicted { + let freed = match e { + Evicted::U8(v) => bytes_of(v), + Evicted::U16(v) => bytes_of(v), + Evicted::F32(v) => bytes_of(v), + Evicted::Px4(v) => bytes_of(v), + }; + RETAINED.fetch_sub(freed, Ordering::Relaxed); + } + evicted + } +} + +/// A `Vec` of `len` copies of `fill`, reusing a pooled buffer when one fits: the one whose +/// capacity equals `len`, else the smallest with capacity in `len..=2*len`. +pub fn take(len: usize, fill: T) -> Vec { + take_with(len, fill, 2) +} + +/// An empty `Vec` with room for at least `cap` elements, reusing a pooled buffer when one fits. +pub fn take_cap(cap: usize) -> Vec { + USES.fetch_add(1, Ordering::Relaxed); + match pop::(cap, 2) { + Some(mut v) => { + v.clear(); + v + } + None => Vec::with_capacity(cap), + } +} + +/// Like [`take`] but only reuses a buffer whose capacity is exactly `len`, so +/// `into_boxed_slice` never reallocates. +pub fn take_exact(len: usize, fill: T) -> Vec { + take_with(len, fill, 1) +} + +/// Pop the best-fitting free buffer: capacity `len`, else the smallest up to `len * slack`. +/// +/// On a miss the caller allocates `len` fresh, so the oldest free buffers of at least that many +/// bytes are dropped first, down to [`set_miss_floor`]: memory the pool can't reuse for this request +/// goes back instead of piling up beside the new buffer, so pool plus live buffers exceed what +/// the program would hold without the pool by at most the floor (#10479). +fn pop(len: usize, slack: usize) -> Option> { + let need = len.saturating_mul(std::mem::size_of::()); + if need < MIN_POOLED_BYTES { + return None; + } + let mut evicted = Vec::new(); + let hit = { + let mut pool = lock(); + let max = len.saturating_mul(slack); + let list = T::list(&mut pool); + let cap = list.range(len..=max).next().map(|(&cap, _)| cap); + // The newest buffer of that capacity is the one most likely still in cache. + let found = cap.and_then(|cap| { + let queue = list.get_mut(&cap)?; + let entry = queue.pop_back(); + if queue.is_empty() { + list.remove(&cap); + } + entry + }); + match found { + Some((seq, v)) => { + pool.order.remove(&seq); + RETAINED.fetch_sub(bytes_of(&v), Ordering::Relaxed); + Some(v) + } + None => { + let target = RETAINED.load(Ordering::Relaxed).saturating_sub(need).max(MISS_FLOOR.load(Ordering::Relaxed)); + while RETAINED.load(Ordering::Relaxed) > target { + match pool.evict_oldest() { + Some(e) => evicted.push(e), + None => break, + } + } + None + } + } + }; + // Freed outside the lock. + drop(evicted); + hit +} + +fn take_with(len: usize, fill: T, slack: usize) -> Vec { + USES.fetch_add(1, Ordering::Relaxed); + if len == 0 { + return Vec::new(); + } + match pop::(len, slack) { + Some(mut v) => { + v.clear(); + v.resize(len, fill); + v + } + None => vec![fill; len], + } +} + +/// Fallible [`take_exact`]: on a pool miss the heap allocation is `try_reserve_exact`, so +/// `None` means the allocation failed rather than aborting. +pub fn try_take_exact(len: usize, fill: T) -> Option> { + USES.fetch_add(1, Ordering::Relaxed); + let mut v = match pop::(len, 1) { + Some(v) => v, + None => { + let mut v = Vec::new(); + if v.try_reserve_exact(len).is_err() { + // The pool may hold up to the miss floor of buffers this request couldn't use: + // give them back, then try once more before failing the caller. + release(); + v.try_reserve_exact(len).ok()?; + } + v + } + }; + v.clear(); + v.resize(len, fill); + Some(v) +} + +/// Return a buffer to the pool. Small buffers, and buffers bigger than the whole budget, are +/// dropped; otherwise the buffers returned longest ago (of any type) make room for it. +pub fn give(v: Vec) { + USES.fetch_add(1, Ordering::Relaxed); + let bytes = bytes_of(&v); + if bytes < MIN_POOLED_BYTES { + return; + } + let mut evicted = Vec::new(); + { + let mut pool = lock(); + // Read under the lock, which `set_budget` also takes, so no insert uses a stale budget. + let budget = BUDGET.load(Ordering::Relaxed); + if bytes > budget { + return; + } + start_idle_release(); + while RETAINED.load(Ordering::Relaxed).saturating_add(bytes) > budget { + match pool.evict_oldest() { + Some(e) => evicted.push(e), + None => break, + } + } + RETAINED.fetch_add(bytes, Ordering::Relaxed); + pool.seq += 1; + let seq = pool.seq; + let cap = v.capacity(); + pool.order.insert(seq, (T::TAG, cap)); + T::list(&mut pool).entry(cap).or_default().push_back((seq, v)); + } + // Freed outside the lock. + drop(evicted); +} + +/// Set the cap on retained bytes (existing entries beyond it go on the next [`give`]). +pub fn set_budget(bytes: usize) { + let _pool = lock(); + BUDGET.store(bytes, Ordering::Relaxed); +} + +/// Set the pooled bytes a miss never evicts (default [`DEFAULT_MISS_FLOOR`]). +pub fn set_miss_floor(bytes: usize) { + MISS_FLOOR.store(bytes, Ordering::Relaxed); +} + +/// Let the idle thread release the pool (the default) or not. Tests that check +/// [`retained_bytes`] turn it off, so a slow runner can't have the pool emptied under them. +pub fn set_idle_release(on: bool) { + // Under the lock: once this returns, the idle thread can't be midway into a release. + let _pool = lock(); + IDLE_RELEASE.store(on, Ordering::Relaxed); +} + +/// Bytes currently held by the pool. +pub fn retained_bytes() -> usize { + RETAINED.load(Ordering::Relaxed) +} + +/// Number of [`take`]/[`give`] calls so far; a changing value means the pool is in use. +pub fn uses() -> u64 { + USES.load(Ordering::Relaxed) +} + +/// Drop every pooled buffer, handing the memory back to the OS. +pub fn release() { + let old = empty(&mut lock()); + // Freed outside the lock. + drop(old); +} + +/// Everything `pool` holds, swapped out of it (to be freed once the lock is dropped). +fn empty(pool: &mut Pool) -> Pool { + RETAINED.store(0, Ordering::Relaxed); + let seq = pool.seq; + std::mem::replace(pool, Pool { seq, order: BTreeMap::new(), u8s: BTreeMap::new(), u16s: BTreeMap::new(), f32s: BTreeMap::new(), px4: BTreeMap::new() }) +} + +/// A pooled buffer that derefs to a slice and returns itself to the pool on drop. +pub struct Buf(Vec); + +impl Buf { + /// `len` copies of `fill` from the pool. + pub fn new(len: usize, fill: T) -> Self { + Buf(take(len, fill)) + } +} + +impl std::ops::Deref for Buf { + type Target = [T]; + fn deref(&self) -> &[T] { + &self.0 + } +} + +impl std::ops::DerefMut for Buf { + fn deref_mut(&mut self) -> &mut [T] { + &mut self.0 + } +} + +impl Drop for Buf { + fn drop(&mut self) { + give(std::mem::take(&mut self.0)); + } +} diff --git a/crates/raster/tests/scratch.rs b/crates/raster/tests/scratch.rs new file mode 100644 index 0000000000..a0f532f174 --- /dev/null +++ b/crates/raster/tests/scratch.rs @@ -0,0 +1,361 @@ +//! The scratch pool is process-wide, so these tests live in their own binary (nothing else +//! allocates tiles here) and take a lock to run one at a time. + +use photocraft_raster::scratch::*; +use std::sync::{Mutex, PoisonError}; + +static SERIAL: Mutex<()> = Mutex::new(()); + +/// Lets a miss evict every pooled byte for the test's lifetime, so sizes can be counted. +struct NoFloor; +impl NoFloor { + /// Sets the miss floor to zero for this guard's lifetime. + fn new() -> Self { + set_miss_floor(0); + NoFloor + } +} +impl Drop for NoFloor { + fn drop(&mut self) { + set_miss_floor(DEFAULT_MISS_FLOOR); + } +} +/// Locks the process-wide pool tests against concurrent access. +fn lock_serial() -> std::sync::MutexGuard<'static, ()> { + SERIAL.lock().unwrap_or_else(PoisonError::into_inner) +} + +/// Runs a test alone with idle release off, so the idle thread can't empty the pool between a +/// `give` and the checks after it; idle release comes back on when dropped. +struct Serial { + _lock: std::sync::MutexGuard<'static, ()>, +} +impl Drop for Serial { + fn drop(&mut self) { + set_idle_release(true); + } +} +/// Serializes a test and disables idle release until the guard is dropped. +fn serial() -> Serial { + let _lock = lock_serial(); + set_idle_release(false); + Serial { _lock } +} + +#[test] +/// Verifies pooled buffers are reused by pointer, retain capacity, and are refilled to the requested length. +fn reuse_returns_same_capacity_and_refills() { + let _g = serial(); + release(); + let mut v = take(100_000, 0.0f32); + v.iter_mut().for_each(|x| *x = 7.0); + let ptr = v.as_ptr(); + give(v); + assert_eq!(retained_bytes(), 400_000); + let w = take(100_000, 1.5f32); + assert_eq!(w.as_ptr(), ptr); + assert_eq!(w.len(), 100_000); + assert!(w.iter().all(|&x| x == 1.5)); + assert_eq!(retained_bytes(), 0); + // A smaller request within 2x reuses it too, and is sized exactly. + give(w); + let s = take(60_000, 0.0f32); + assert_eq!(s.as_ptr(), ptr); + assert_eq!(s.len(), 60_000); + release(); +} + +#[test] +/// Verifies oversized or non-exact buffers are discarded instead of reused. +fn too_big_a_buffer_is_not_reused() { + let _g = serial(); + release(); + let _floor = NoFloor::new(); + give(vec![0u8; 1_000_000]); + assert_eq!(take_exact(1_000_000, 0u8).len(), 1_000_000); + assert_eq!(retained_bytes(), 0); + give(vec![0u8; 1_000_000]); + // More than 2x (or not exact): allocated fresh, and the miss drops the unusable buffer. + assert_eq!(take(100_000, 0u8).capacity(), 100_000); + assert_eq!(retained_bytes(), 0); + give(vec![0u8; 1_000_000]); + assert_eq!(take_exact(999_999, 0u8).capacity(), 999_999); + assert_eq!(retained_bytes(), 0); + release(); +} + +#[test] +/// Verifies exact try-takes reuse a matching buffer and succeed on a miss. +fn try_take_exact_reuses_and_allocates() { + let _g = serial(); + release(); + let buf = vec![0u8; 200_000]; + let ptr = buf.as_ptr(); + give(buf); + let v = try_take_exact(200_000, 9u8); + assert!(v.as_ref().is_some_and(|v| v.len() == 200_000 && v.iter().all(|&b| b == 9))); + assert_eq!(v.as_ref().map(|v| v.as_ptr()), Some(ptr)); + assert_eq!(retained_bytes(), 0); + assert!(try_take_exact(300_000, 0u8).is_some()); + release(); +} + +#[test] +/// Verifies buffers below the pooling threshold are not retained. +fn small_buffers_are_not_retained() { + let _g = serial(); + release(); + give(vec![0u8; MIN_POOLED_BYTES - 1]); + give(vec![0.0f32; 100]); + assert_eq!(retained_bytes(), 0); + assert_eq!(take(10, 3u8), vec![3u8; 10]); +} + +#[test] +/// Verifies zero-length take and give operations remain empty. +fn zero_length() { + let _g = serial(); + assert!(take(0, 0u8).is_empty()); + give(Vec::::new()); + assert!(take_exact(0, [0.0f32; 4]).is_empty()); +} + +#[test] +/// Verifies the byte budget evicts oldest buffers and rejects buffers larger than the budget. +fn budget_evicts_oldest() { + let _g = serial(); + release(); + set_budget(250_000); + let (a, b) = (vec![0u8; 100_000], vec![0u8; 100_000]); + let pb = b.as_ptr(); + give(a); + give(b); + give(vec![0u8; 100_000]); + assert_eq!(retained_bytes(), 200_000); + // The oldest was evicted; the survivors include b. + let first = take_exact(100_000, 0u8); + let second = take_exact(100_000, 0u8); + assert!(first.as_ptr() == pb || second.as_ptr() == pb); + // Larger than the whole budget: dropped. + give(vec![0u8; 300_000]); + assert_eq!(retained_bytes(), 0); + set_budget(DEFAULT_BUDGET); + release(); +} + +#[test] +/// Verifies release empties pooled buffers of several element types. +fn release_empties() { + let _g = serial(); + give(vec![0u8; 200_000]); + give(vec![0.0f32; 200_000]); + give(vec![[0.0f32; 4]; 100_000]); + assert!(retained_bytes() > 0); + release(); + assert_eq!(retained_bytes(), 0); +} + +#[test] +/// Verifies the usage counter advances for both take and give. +fn uses_counts_calls() { + let _g = serial(); + let before = uses(); + give(take(10, 0u8)); + assert_eq!(uses(), before + 2); +} + +#[test] +/// Verifies dropping a `Buf` returns its storage to the pool. +fn buf_returns_on_drop() { + let _g = serial(); + release(); + { + let mut b = Buf::new(150_000, 0.0f32); + b[3] = 1.0; + assert_eq!(b.len(), 150_000); + } + assert_eq!(retained_bytes(), 600_000); + release(); +} + +#[test] +/// Verifies concurrent take and give operations preserve initialized values and finish cleanly. +fn concurrent_smoke() { + let _g = serial(); + release(); + let handles: Vec<_> = (0..8u8) + .map(|t| { + std::thread::spawn(move || { + for i in 0..200 { + let v = take(70_000 + i % 5, t); + assert!(v.iter().all(|&x| x == t)); + give(v); + } + }) + }) + .collect(); + for h in handles { + assert!(h.join().is_ok()); + } + release(); + assert_eq!(retained_bytes(), 0); +} + +#[test] +/// Verifies budget eviction uses insertion age across different element types. +fn eviction_is_oldest_first_across_types() { + let _g = serial(); + release(); + set_budget(500_000); + give(vec![0u8; 300_000]); + give(vec![0.0f32; 50_000]); + assert_eq!(retained_bytes(), 500_000); + // Needs 200 000 more: the older u8 buffer goes, the f32 one stays. + give(vec![[0.0f32; 4]; 12_500]); + assert_eq!(retained_bytes(), 400_000); + assert_eq!(take_exact(50_000, 1.0f32).len(), 50_000); + assert_eq!(retained_bytes(), 200_000); + set_budget(DEFAULT_BUDGET); + release(); +} + +#[test] +/// Verifies reuse chooses the smallest eligible capacity and exact takes require an exact match. +fn best_fit_picks_the_smallest_fitting_capacity() { + let _g = serial(); + release(); + let _floor = NoFloor::new(); + for n in [180_000usize, 120_000, 150_000, 300_000] { + give(vec![0u8; n]); + } + // Within 2x of 100 000: 120 000 is the tightest fit, then 150 000, then 180 000. + assert_eq!(take(100_000, 0u8).capacity(), 120_000); + assert_eq!(take(100_000, 0u8).capacity(), 150_000); + assert_eq!(take(100_000, 0u8).capacity(), 180_000); + // 300 000 is more than 2x: allocated fresh, and the miss drops the pooled one. + assert_eq!(take(100_000, 0u8).capacity(), 100_000); + assert_eq!(retained_bytes(), 0); + // An exact capacity beats a smaller-slack scan. + give(vec![0u8; 100_000]); + assert_eq!(take_exact(100_000, 0u8).capacity(), 100_000); + assert_eq!(take_exact(250_000, 0u8).capacity(), 250_000); + release(); +} + +#[test] +/// Verifies repeated budget overflow evicts oldest entries across mixed types and capacities. +fn eviction_walks_oldest_first_through_mixed_types_and_capacities() { + let _g = serial(); + release(); + let _floor = NoFloor::new(); + set_budget(700_000); + // Oldest to newest, 100 000 bytes each, interleaved types and two u8 capacities. + give(vec![0u8; 100_000]); + let f = vec![0.0f32; 25_000]; + give(f); + give(vec![0u8; 100_000]); + give(vec![0u16; 50_000]); + let newest = vec![0u8; 150_000]; + let np = newest.as_ptr(); + give(newest); + assert_eq!(retained_bytes(), 550_000); + // 200 000 more overflows by 50 000: the oldest (u8) goes. + give(vec![[0.0f32; 4]; 12_500]); + assert_eq!(retained_bytes(), 650_000); + // 100 000 more: the next oldest (the f32 buffer) goes. + give(vec![0u8; 100_000]); + assert_eq!(retained_bytes(), 650_000); + // 200 000 more: the u8 then the u16 buffer go. + give(vec![0u8; 200_000]); + assert_eq!(retained_bytes(), 650_000); + // The newest u8 buffer of the old batch is still pooled; the f32 one was evicted. + let again = take_exact(150_000, 0u8); + assert_eq!(again.as_ptr(), np); + assert_eq!(retained_bytes(), 500_000); + // The evicted f32 buffer is a miss: the oldest buffers, at least its 100 000 bytes, leave + // (the 200 000-byte [f32; 4] one). + assert_eq!(take_exact(25_000, 0.0f32).len(), 25_000); + assert_eq!(retained_bytes(), 300_000); + set_budget(DEFAULT_BUDGET); + release(); +} + +#[test] +/// Verifies the idle-release worker clears an unused pooled buffer. +fn idle_pool_is_released_by_the_background_thread() { + // Idle release stays on here: it is what this test checks. + let _g = lock_serial(); + release(); + give(vec![0u8; MIN_POOLED_BYTES]); + assert_eq!(retained_bytes(), MIN_POOLED_BYTES); + // Released after one full idle interval without use (at most two from the give). + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3 * IDLE_RELEASE_SECS + 1); + while retained_bytes() > 0 && std::time::Instant::now() < deadline { + std::thread::sleep(std::time::Duration::from_millis(100)); + } + assert_eq!(retained_bytes(), 0); +} + +#[test] +/// Verifies a miss discards pooled bytes in proportion to its allocation and a hit preserves the pool. +fn a_miss_drops_as_many_pooled_bytes_as_it_allocates() { + let _g = serial(); + release(); + let _floor = NoFloor::new(); + // Three 64 KiB f32 buffers a byte request can't use. + for _ in 0..3 { + give(vec![0.0f32; MIN_POOLED_BYTES / 4]); + } + assert_eq!(retained_bytes(), 3 * MIN_POOLED_BYTES); + // A 128 KiB miss drops the two oldest, so pool plus live buffers stay at 192 KiB. + let v = take(2 * MIN_POOLED_BYTES, 0u8); + assert_eq!(retained_bytes(), MIN_POOLED_BYTES); + give(v); + // A hit drops nothing. + let w = take(MIN_POOLED_BYTES / 4, 0.0f32); + assert_eq!(retained_bytes(), 2 * MIN_POOLED_BYTES); + drop(w); + release(); +} + +#[test] +/// Verifies miss eviction stops when the configured retained-byte floor is reached. +fn a_miss_keeps_the_floor() { + let _g = serial(); + release(); + set_miss_floor(2 * MIN_POOLED_BYTES); + for _ in 0..3 { + give(vec![0.0f32; MIN_POOLED_BYTES / 4]); + } + // A 192 KiB miss would drop all three; only one goes, down to the floor. + assert_eq!(take(3 * MIN_POOLED_BYTES, 0u8).len(), 3 * MIN_POOLED_BYTES); + assert_eq!(retained_bytes(), 2 * MIN_POOLED_BYTES); + set_miss_floor(DEFAULT_MISS_FLOOR); + release(); +} + +#[test] +/// Verifies a failed oversized reservation leaves the pool empty. +fn a_failed_reservation_releases_the_pool() { + let _g = serial(); + release(); + // Unmatched buffers under the miss floor stay pooled on a miss. + give(vec![0u8; 4 * MIN_POOLED_BYTES]); + assert!(retained_bytes() > 0); + // No allocator can reserve this: the call fails, but not before the pool was emptied. (The + // retry after the release isn't observable here: it needs an allocator that fails once.) + assert!(try_take_exact(isize::MAX as usize, 0u8).is_none()); + assert_eq!(retained_bytes(), 0); +} + +#[test] +/// Verifies a successful miss preserves unmatched buffers within the miss floor. +fn a_successful_miss_keeps_unmatched_pooled_buffers() { + let _g = serial(); + release(); + give(vec![0u8; 4 * MIN_POOLED_BYTES]); + let v = try_take_exact(2 * MIN_POOLED_BYTES, 0u8); + assert!(v.as_ref().is_some_and(|v| v.len() == 2 * MIN_POOLED_BYTES && v.iter().all(|&b| b == 0))); + assert_eq!(retained_bytes(), 4 * MIN_POOLED_BYTES, "within the miss floor, nothing is dropped"); + release(); +} diff --git a/crates/testkit/src/perf.rs b/crates/testkit/src/perf.rs index 6bdf68ab0a..38e6a14cb9 100644 --- a/crates/testkit/src/perf.rs +++ b/crates/testkit/src/perf.rs @@ -27,6 +27,19 @@ pub fn process_peak_rss_bytes() -> Option { parse_vm_hwm(&status) } +/// Minor page faults this process has taken so far (`/proc/self/stat` field 10), `None` off +/// Linux. A scenario that maps fresh memory on every run shows up here (#10479). +pub fn minor_faults() -> Option { + let stat = std::fs::read_to_string("/proc/self/stat").ok()?; + parse_minflt(&stat) +} + +fn parse_minflt(stat: &str) -> Option { + // `comm` (field 2) may contain spaces; the fields after its closing `)` start at field 3. + let rest = stat.get(stat.rfind(')')?.checked_add(1)?..)?; + rest.split_whitespace().nth(7)?.parse().ok() +} + fn parse_vm_hwm(status: &str) -> Option { let line = status.lines().find(|l| l.starts_with("VmHWM:"))?; let kb: u64 = line.split_whitespace().nth(1)?.parse().ok()?; @@ -149,6 +162,14 @@ pub fn write_report(path: &str, report: &Value) -> Result<(), String> { mod tests { use super::*; + #[test] + fn minflt_is_field_ten_even_with_spaces_in_comm() { + assert_eq!(parse_minflt("42 (cat) R 1 2 3 0 -1 4194304 122 0 0 0"), Some(122)); + assert_eq!(parse_minflt("42 (a b) c) S 1 2 3 0 -1 0 7 0"), Some(7)); + assert_eq!(parse_minflt("42 (cat) R 1"), None); + assert_eq!(parse_minflt("garbage"), None); + } + #[test] fn summary_nearest_rank() { let v: Vec = (1..=20).map(f64::from).collect(); diff --git a/crates/ui-egui/examples/interactive_bench.rs b/crates/ui-egui/examples/interactive_bench.rs index efeff0927d..0b4cc5c952 100644 --- a/crates/ui-egui/examples/interactive_bench.rs +++ b/crates/ui-egui/examples/interactive_bench.rs @@ -83,9 +83,9 @@ struct Bench { /// state later rows need). only: Option, rows: Vec<(String, f64, f64, f64, f64)>, - /// Per row (same order as `rows`): the timed samples and the peak RSS while they ran, for - /// `--json` (`cargo xtask perf`). - extra: Vec<(Vec, Option)>, + /// Per row (same order as `rows`): the timed samples, the peak RSS while they ran and the + /// minor page faults per warm run (all of it, untimed setup included), for `--json` (`cargo xtask perf`). + extra: Vec<(Vec, Option, Option)>, rss: photocraft_testkit::perf::RssSampler, } @@ -127,11 +127,17 @@ impl Bench { } self.rss.reset(); let cold = f(self); + let faults0 = photocraft_testkit::perf::minor_faults(); let mut v: Vec = (0..self.reps).map(|_| f(self)).collect(); - self.extra.push((v.clone(), self.rss.peak())); + // Minor page faults per warm run: memory mapped fresh from the kernel each time (#10479). + // Counted over the whole of `f`, so it includes untimed setup and cleanup (an undo, an + // extra refresh), not only the work the milliseconds measure. + let faults = faults0.zip(photocraft_testkit::perf::minor_faults()).map(|(a, b)| b.saturating_sub(a) / self.reps as u64); + self.extra.push((v.clone(), self.rss.peak(), faults)); v.sort_by(f64::total_cmp); let med = v[v.len() / 2]; - println!("{name:<44} {med:>9.1} ms (min {:>8.1}, max {:>8.1}, cold {:>8.1})", v[0], v[v.len() - 1], cold); + let fl = faults.map_or(String::new(), |f| format!(", {f} faults/run incl. setup")); + println!("{name:<44} {med:>9.1} ms (min {:>8.1}, max {:>8.1}, cold {:>8.1}{fl})", v[0], v[v.len() - 1], cold); self.rows.push((name.to_string(), med, v[0], v[v.len() - 1], cold)); } } @@ -473,11 +479,12 @@ fn main() { .rows .iter() .zip(&b.extra) - .map(|((n, med, min, max, cold), (samples, rss))| { + .map(|((n, med, min, max, cold), (samples, rss, faults))| { let mut r = json!({"name": n, "median_ms": med, "min_ms": min, "max_ms": max, "cold_ms": cold}); // For `cargo xtask perf`: raw samples (it computes p50 / p95) and peak RSS. r["samples_ms"] = json!(samples); r["peak_rss_bytes"] = json!(rss); + r["minor_faults_per_run"] = json!(faults); r }) .collect(); diff --git a/crates/ui-egui/src/dialogs/color_range_ui.rs b/crates/ui-egui/src/dialogs/color_range_ui.rs index 75fe3c67b9..3b02806d3a 100644 --- a/crates/ui-egui/src/dialogs/color_range_ui.rs +++ b/crates/ui-egui/src/dialogs/color_range_ui.rs @@ -236,7 +236,7 @@ fn proxy_mask(app: &PhotocraftApp, proxy: &Document, k: u32, params: &Value) -> surf.read_rgba_into(area, &mut px); px } - _ => photocraft_compose::render(proxy, area).px, + _ => photocraft_compose::render(proxy, area).into_px(), }; let invert = params.get("invert").and_then(Value::as_bool).unwrap_or(false); let mut mask: Vec = query diff --git a/crates/ui-egui/src/dialogs/file_ui/web.rs b/crates/ui-egui/src/dialogs/file_ui/web.rs index 0b5ef3cf58..865215d4b5 100644 --- a/crates/ui-egui/src/dialogs/file_ui/web.rs +++ b/crates/ui-egui/src/dialogs/file_ui/web.rs @@ -119,7 +119,7 @@ fn web_pane(app: &PhotocraftApp, ui: &mut egui::Ui, size: egui::Vec2, p: Option< }; let buf = photocraft_compose::flatten(&wd); let v = ( - Arc::new(buf.px), + Arc::new(buf.into_px()), wd.size.width, wd.size.height, (fw * fh) / (f64::from(wd.size.width) * f64::from(wd.size.height)).max(1.0), diff --git a/crates/ui-egui/src/dialogs/filter_dialog/tests.rs b/crates/ui-egui/src/dialogs/filter_dialog/tests.rs index 92daea2528..8c0ba385e6 100644 --- a/crates/ui-egui/src/dialogs/filter_dialog/tests.rs +++ b/crates/ui-egui/src/dialogs/filter_dialog/tests.rs @@ -628,7 +628,7 @@ fn smart_displace_preview_uses_the_map_document() { applied.add_document((**d).clone(), None); } applied.execute(SMART_FILTER_SET_PARAMS, p.clone()).unwrap(); - let want = photocraft_compose::render(&applied.active().unwrap().doc, doc.bounds()).px; + let want = photocraft_compose::render(&applied.active().unwrap().doc, doc.bounds()).into_px(); let preview = preview_document_for_target( &doc, layer, diff --git a/crates/ui-egui/src/dialogs/vp_ui/tests.rs b/crates/ui-egui/src/dialogs/vp_ui/tests.rs index 19f7786e33..f45063d35c 100644 --- a/crates/ui-egui/src/dialogs/vp_ui/tests.rs +++ b/crates/ui-egui/src/dialogs/vp_ui/tests.rs @@ -180,7 +180,7 @@ fn overlapping_stamp_preview_matches_commit_across_batches() { s.strokes[0].points.extend_from_slice(&points[2..]); assert!(s.paint_preview(|| 2)); let (shown, _) = s.shown().unwrap(); - let expected = photocraft_compose::flatten(&shown).px; + let expected = photocraft_compose::flatten(&shown).into_px(); assert_ne!(expected, photocraft_compose::flatten(&app.session.active().unwrap().doc).px); commit(&mut app).unwrap(); assert_eq!(photocraft_compose::flatten(&app.session.active().unwrap().doc).px, expected, "newLayer={new_layer}"); @@ -217,7 +217,7 @@ fn stamp_preview_paints_over_the_paste_like_ok() { let s = app.distort.vanishing.as_ref().unwrap(); assert!(s.preview_has_paste(), "the preview shows the paste, so the overlay isn't drawn over the strokes"); let (shown, _) = s.shown().unwrap(); - let expected = photocraft_compose::flatten(&shown).px; + let expected = photocraft_compose::flatten(&shown).into_px(); commit(&mut app).unwrap(); assert_eq!(photocraft_compose::flatten(&app.session.active().unwrap().doc).px, expected, "newLayer={new_layer}"); } @@ -349,7 +349,7 @@ fn ok_paints_the_layer_the_mode_was_opened_on() { app.session.execute("layer.new.layer", json!({"name": "other"})).unwrap(); let other = app.session.active().unwrap().active_layer.unwrap(); sync_preview(&mut app); - let expected = photocraft_compose::flatten(&crate::distort_ui::display_doc(&app, 0).unwrap().0).px; + let expected = photocraft_compose::flatten(&crate::distort_ui::display_doc(&app, 0).unwrap().0).into_px(); commit(&mut app).unwrap(); let d = &app.session.active().unwrap().doc; assert!(d.layer(other).unwrap().surface().unwrap().content_bounds().is_empty(), "newLayer={new_layer}"); @@ -389,7 +389,7 @@ fn an_edit_under_the_mode_shows_on_the_canvas() { let px = shown.layer(layer).unwrap().surface().unwrap(); assert!(px.rgba(210, 30)[1] > 0.9, "the edit shows: {:?}", px.rgba(210, 30)); assert!(px.rgba(120, 80) != st.doc.layer(layer).unwrap().surface().unwrap().rgba(120, 80), "and so does the stroke"); - let expected = photocraft_compose::flatten(&shown).px; + let expected = photocraft_compose::flatten(&shown).into_px(); commit(&mut app).unwrap(); assert_eq!(photocraft_compose::flatten(&app.session.active().unwrap().doc).px, expected); } diff --git a/crates/ui-egui/src/tools/transform_fx.rs b/crates/ui-egui/src/tools/transform_fx.rs index 60fc45d993..0ad888536d 100644 --- a/crates/ui-egui/src/tools/transform_fx.rs +++ b/crates/ui-egui/src/tools/transform_fx.rs @@ -256,7 +256,8 @@ pub fn compose(doc: &Document, work: &Layer, plan: &Plan, source: &Source) -> Op let band = Rect::new(shown.x0, y0, shown.x1, y0.saturating_add((px.len() / width.max(1)) as i32)); let mut over = Buffer::transparent(band); effects::composite_with_effects(work, &crop(&content, band), &mut over, &maps, plan.bounds, &doc.patterns); - for (o, p) in px.iter_mut().zip(over.px) { + // Copied, not moved out, so `over` hands its band back to the scratch pool on drop. + for (o, p) in px.iter_mut().zip(over.px.iter().copied()) { *o = p; } }); diff --git a/crates/ui-egui/tests/it/adjust_preview_gpu.rs b/crates/ui-egui/tests/it/adjust_preview_gpu.rs index 2fdae54cbe..4e8aa8552c 100644 --- a/crates/ui-egui/tests/it/adjust_preview_gpu.rs +++ b/crates/ui-egui/tests/it/adjust_preview_gpu.rs @@ -152,14 +152,14 @@ fn preview_layer_composites_on_the_gpu_like_the_command() { // may flip a few pixels sitting right at a step (1 % allowed). let skip = if matches!(kind, "threshold" | "posterize") && depth != SampleType::U8 { (b.width() * b.height()) as usize / 100 } else { 0 }; let on_gpu = render_to_vec(&mut comp, &device, &queue, &preview, b).unwrap_or_else(|e| panic!("{what}: {e:?}")); - let on_cpu = photocraft_compose::render(&preview, b).px; + let on_cpu = photocraft_compose::render(&preview, b).into_px(); let d = max_diff(&on_gpu, &on_cpu, skip); assert!(d <= tol_gpu, "{what}: GPU vs CPU {:.2}/255", d * 255.0); let mut s = photocraft_engine::Session::new(); s.add_document(doc.clone(), None); s.select_layer(target).unwrap(); s.execute(&format!("image.adjustments.{kind}"), params.clone()).unwrap(); - let cmd = photocraft_compose::render(&s.active().unwrap().doc, b).px; + let cmd = photocraft_compose::render(&s.active().unwrap().doc, b).into_px(); let d = max_diff(&on_gpu, &cmd, skip); worst = worst.max(d); assert!(d <= tol_cmd, "{what}: GPU preview vs command {:.2}/255", d * 255.0); @@ -184,7 +184,7 @@ fn proxy_preview_composites_on_the_gpu() { for kind in photocraft_ui_egui::adjust_editors::KINDS { let p = proxy_with_settings(&proxy, kind, &sample(kind)).unwrap(); let on_gpu = render_to_vec(&mut comp, &device, &queue, &p, p.bounds()).unwrap(); - let on_cpu = photocraft_compose::render(&p, p.bounds()).px; + let on_cpu = photocraft_compose::render(&p, p.bounds()).into_px(); let d = max_diff(&on_gpu, &on_cpu, 0); assert!(d <= 2.0 / 255.0, "{kind} selection {selection}: {:.2}/255", d * 255.0); } From 5d9c57e60db966382abda2638d0ccbb7489d8ee7 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Mon, 2 Nov 2026 14:00:46 +0530 Subject: [PATCH 156/216] Linux: return memory of closed documents to the system (#8743) (#10515) * perf: reuse large buffers so a pinned glibc trim threshold costs no speed (#10479) With MALLOC_TRIM_THRESHOLD_ set (#8743), glibc maps every allocation over 128 KiB fresh, so tiles, compositor bands and filter scratch were page-faulted in on every call: viewport compose 3.6x slower, Gaussian Blur up to 1.8x, .pcraft save 2.4x. - photocraft_raster::scratch: a process-wide, capped pool of large Vec buffers (u8/u16/f32/[f32; 4]). A miss drops the oldest buffers worth its size down to a 256 MiB floor, so peak memory stays near glibc's defaults. The engine releases the pool after any command that closes a document, Headless::close does too, and an idle thread releases it after 2 s unused. - Tiles, compose Buffer/masks/bands, algo Image and the blur paths, engine adjustment/filter scratch, GPU tile upload and io import buffers go through the pool. - .pcraft save reuses one zstd FrameCompressor per worker (byte-identical). - interactive_bench reports minor page faults per run. 36 MP, trim threshold set vs glibc defaults: viewport 5.6 vs 6.1 ms (was 22.7), Blur r4 445 vs 437 ms (was 852), Curves CPU proxy 35.0 vs 35.7 ms (was 50), flatten 146 vs 147 ms (was 237), save .pcraft 251 vs 230 ms (was 545). RSS after closing a 5400x7200 document: 21 MB with trim set. * fix(linux): restart once with glibc's trim threshold set so closed documents return memory (#8743) With glibc's default malloc, the memory of a closed document stays with the process: freeing a large buffer raises glibc's dynamic mmap threshold, so later tile buffers land in heap pages glibc keeps. A session that opened and closed a 5400x7200 document held ~450 MiB afterwards. photocraft_engine::malloc_tuning::apply(), called first in both binaries' main, re-executes the binary once with MALLOC_TRIM_THRESHOLD_=131072 on Linux with glibc: same PID, arguments, argv[0] and environment, in safe Rust (CommandExt::exec). A malloc variable or glibc.malloc.* tunable the user or a package already set is left alone, so setting MALLOC_TRIM_THRESHOLD_ opts out. No-op on macOS, Windows, musl and wasm. Depends on the scratch-buffer pool (#10479), without which the pinned threshold slows filters, previews, flatten and .pcraft save by 26-209%. * fix: corpus tests build again; return remaining pooled buffers (#10479 review) - Corpus-gated io tests and the Photoshop-oracle test moved `.px` out of a `Buffer`, which now implements `Drop` (E0509 with `--features corpus`): use `into_px()`. - io: masks, the merged image, extra channels, smart-filter masks, smart-object pixels and patterns give their `interleave` buffers back to the scratch pool after writing them. - compose: recycle the mask values of a pass-through group, `effect_shape` and both `split_parts` callers. - Styled Free Transform preview copies each band out instead of moving it, so the band returns to the pool. - interactive_bench: the fault count covers each whole run, untimed setup included; say so. * fix: corpus tests build again; return remaining pooled buffers (#10479 review) - Corpus-gated io tests and the Photoshop-oracle test moved `.px` out of a `Buffer`, which now implements `Drop` (E0509 with `--features corpus`): use `into_px()`. - io: masks, the merged image, extra channels, smart-filter masks, smart-object pixels and patterns give their `interleave` buffers back to the scratch pool after writing them. - compose: recycle the mask values of a pass-through group, `effect_shape` and both `split_parts` callers. - Styled Free Transform preview copies each band out instead of moving it, so the band returns to the pool. - interactive_bench: the fault count covers each whole run, untimed setup included; say so. * fix: recycle the masks of adjustment-atop and shape layers (#10479 review) * fix: recycle the masks of adjustment-atop and shape layers (#10479 review) * fix: recycle effect hiding masks (#10479 review) * fix: recycle effect hiding masks (#10479 review) * fix: recycle knockout, CMYK and GPU shape masks; release the pool when Split/Merge Channels close a document (#10479 review) * fix: recycle knockout, CMYK and GPU shape masks; release the pool when Split/Merge Channels close a document (#10479 review) * fix: release the pool when a closed document's cancelled workers exit; idle-release switch for tests; don't pool PSD planes (#10479 review) * fix: release the pool when a closed document's cancelled workers exit; idle-release switch for tests; don't pool PSD planes (#10479 review) * fix: release the pool for workers cancelled before their document closes; take the pool lock for budget and idle-release changes (#10479 review) * fix: release the pool for workers cancelled before their document closes; take the pool lock for budget and idle-release changes (#10479 review) * fix: idle release rechecks activity under the lock; try_take_exact releases the pool and retries before failing (#10479 review) * fix: idle release rechecks activity under the lock; try_take_exact releases the pool and retries before failing (#10479 review) * test: cover a successful try_take_exact miss; name the failed-reservation test for what it checks (#10479 review) * test: cover a successful try_take_exact miss; name the failed-reservation test for what it checks (#10479 review) * fix: pool the final GPU effect shape buffer and return it after use (#10479 review) * fix: pool the final GPU effect shape buffer and return it after use (#10479 review) * docs: describe scratch pool lifecycle regression invariants * test: verify polling releases cancelled document scratch buffers * perf: recycle remaining transform selection and PSD scratch buffers * fix: free PSD gather bands before allocating channel planes * test: assert exact scratch take reuses pooled storage --- apps/photocraft-cli/src/main.rs | 2 + apps/photocraft-cli/tests/it/main.rs | 2 + apps/photocraft-cli/tests/it/malloc_tuning.rs | 41 ++++++++++ apps/photocraft/src/main.rs | 5 +- crates/engine/src/lib.rs | 1 + crates/engine/src/malloc_tuning.rs | 81 +++++++++++++++++++ 6 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 apps/photocraft-cli/tests/it/malloc_tuning.rs create mode 100644 crates/engine/src/malloc_tuning.rs diff --git a/apps/photocraft-cli/src/main.rs b/apps/photocraft-cli/src/main.rs index ea5598f99d..6e21c2ac44 100644 --- a/apps/photocraft-cli/src/main.rs +++ b/apps/photocraft-cli/src/main.rs @@ -1,6 +1,8 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] fn main() { + // May re-execute this process with glibc tuned to return freed document memory (#8743). + photocraft_engine::malloc_tuning::apply(); // `args_os`, not `args`: a non-Unicode argument (a Latin-1 file name on Linux) must reach clap, // not panic before anything is printed (issue #1108). let code = photocraft_cli::run(std::env::args_os().skip(1), &mut std::io::stdout(), &mut std::io::stderr()); diff --git a/apps/photocraft-cli/tests/it/main.rs b/apps/photocraft-cli/tests/it/main.rs index 65092cdee4..2729f74251 100644 --- a/apps/photocraft-cli/tests/it/main.rs +++ b/apps/photocraft-cli/tests/it/main.rs @@ -1,6 +1,8 @@ //! The crate's integration tests, linked into one test binary. mod cli; +#[cfg(all(target_os = "linux", target_env = "gnu"))] +mod malloc_tuning; mod man_and_completions; mod orientation; diff --git a/apps/photocraft-cli/tests/it/malloc_tuning.rs b/apps/photocraft-cli/tests/it/malloc_tuning.rs new file mode 100644 index 0000000000..a7513a714a --- /dev/null +++ b/apps/photocraft-cli/tests/it/malloc_tuning.rs @@ -0,0 +1,41 @@ +//! The binary re-executes itself with glibc's trim threshold set (#8743), keeping its arguments +//! and a value the user set. + +use std::io::{BufRead, BufReader, Write}; +use std::process::{Command, Stdio}; + +use photocraft_engine::malloc_tuning::{TRIM_THRESHOLD, TRIM_THRESHOLD_VAR}; + +/// The environment of a running `serve` once it answered a request, as `NAME=value` entries. +fn serve_environ(set: Option<&str>) -> Vec { + let mut cmd = Command::new(crate::binary()); + for var in [TRIM_THRESHOLD_VAR, "MALLOC_MMAP_THRESHOLD_", "MALLOC_TOP_PAD_", "MALLOC_MMAP_MAX_", "MALLOC_ARENA_MAX", "GLIBC_TUNABLES"] { + cmd.env_remove(var); + } + if let Some(v) = set { + cmd.env(TRIM_THRESHOLD_VAR, v); + } + let mut child = cmd.arg("serve").stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().unwrap(); + let mut stdin = child.stdin.take().unwrap(); + writeln!(stdin, r#"{{"id":1,"method":"session.list"}}"#).unwrap(); + let mut reply = String::new(); + BufReader::new(child.stdout.take().unwrap()).read_line(&mut reply).unwrap(); + assert!(reply.contains(r#""id":1"#), "serve answered after the re-exec: {reply}"); + // The same process: `exec` keeps the PID, so this is the re-executed program's environment. + let environ = std::fs::read(format!("/proc/{}/environ", child.id())).unwrap(); + drop(stdin); + child.wait().unwrap(); + environ.split(|&b| b == 0).map(|e| String::from_utf8_lossy(e).into_owned()).collect() +} + +#[test] +fn sets_the_trim_threshold() { + let env = serve_environ(None); + assert!(env.contains(&format!("{TRIM_THRESHOLD_VAR}={TRIM_THRESHOLD}")), "{env:?}"); +} + +#[test] +fn keeps_a_value_the_user_set() { + let env = serve_environ(Some("999999")); + assert!(env.contains(&format!("{TRIM_THRESHOLD_VAR}=999999")), "{env:?}"); +} diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs index e19a9d45e8..3738ffc8d1 100644 --- a/apps/photocraft/src/main.rs +++ b/apps/photocraft/src/main.rs @@ -340,7 +340,10 @@ fn write_start_marker(sentinel: &gpu_startup::SharedSentinel, plan: &gpu_startup } fn main() -> eframe::Result { - // First, so the panic hook and every start-up warning are recorded (`logging`). + // Before anything starts a thread: may re-execute this process with glibc tuned to return + // the memory of closed documents (#8743). + photocraft_engine::malloc_tuning::apply(); + // Then, so the panic hook and every start-up warning are recorded (`logging`). let logger = logging::install(); crash_guard::install_hook(); #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 5e6cc66ab0..b49675176e 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -29,6 +29,7 @@ pub mod kys; pub mod layer_place; pub mod layer_style; pub mod link_watch; +pub mod malloc_tuning; pub mod new_layer; mod param_check; mod pixels; diff --git a/crates/engine/src/malloc_tuning.rs b/crates/engine/src/malloc_tuning.rs new file mode 100644 index 0000000000..f9c5947c47 --- /dev/null +++ b/crates/engine/src/malloc_tuning.rs @@ -0,0 +1,81 @@ +//! glibc malloc tuning for the native binaries (#8743). +//! +//! With glibc's defaults, the memory of a closed document stays with the process: freeing a large +//! buffer raises glibc's dynamic mmap threshold (up to 32 MiB), so later tile buffers come from the +//! heap, whose freed pages glibc keeps for reuse instead of giving them back. A session that opened +//! and closed a 5400 × 7200 document held ~540 MiB afterwards; with `MALLOC_TRIM_THRESHOLD_` set it +//! went back to ~20 MiB, and the peak was lower too. Setting the threshold explicitly turns the +//! dynamic adjustment off (mallopt(3)), so large buffers are mapped and unmapped on their own and +//! the heap top is trimmed as it is freed. Mapping fresh pages on every call is slow, so the large +//! short-lived buffers come from `photocraft_raster::scratch` instead, which reuses them and +//! releases them when a document closes or the pool goes idle (#10479). +//! +//! glibc reads its tunables once, when the process starts, and changing the environment from Rust +//! needs `unsafe` (and comes too late), so [`apply`] re-executes the binary with the variable set: +//! the same process (PID), arguments, `argv[0]` and environment, on Linux with glibc only. A value +//! the user or a package already set, for this or another malloc tunable, is left alone, so setting +//! `MALLOC_TRIM_THRESHOLD_` to anything opts out. Programs PhotoCraft starts inherit the variable. + +use std::ffi::OsString; + +/// The glibc environment variable [`apply`] sets. +pub const TRIM_THRESHOLD_VAR: &str = "MALLOC_TRIM_THRESHOLD_"; + +/// Its value: glibc's own default trim threshold (128 KiB), now fixed instead of dynamic. +pub const TRIM_THRESHOLD: &str = "131072"; + +/// glibc malloc variables that mean someone tuned malloc themselves. +const MALLOC_VARS: [&str; 5] = [TRIM_THRESHOLD_VAR, "MALLOC_MMAP_THRESHOLD_", "MALLOC_TOP_PAD_", "MALLOC_MMAP_MAX_", "MALLOC_ARENA_MAX"]; + +/// Whether a start-up should re-execute with [`TRIM_THRESHOLD_VAR`] set, given the environment: +/// only when no malloc variable or `glibc.malloc.*` tunable is set (which also ends the re-exec). +pub fn needs_restart(var: impl Fn(&str) -> Option) -> bool { + if MALLOC_VARS.iter().any(|v| var(v).is_some()) { + return false; + } + !var("GLIBC_TUNABLES").is_some_and(|t| t.to_string_lossy().contains("glibc.malloc.")) +} + +/// Re-execute this binary with [`TRIM_THRESHOLD_VAR`] set when [`needs_restart`] says so. Call it +/// first in `main`, before any thread starts. It returns only when nothing needed to change or the +/// re-exec failed, and then the process carries on with glibc's defaults. +#[cfg(all(target_os = "linux", target_env = "gnu"))] +pub fn apply() { + use std::os::unix::process::CommandExt; + if !needs_restart(|k| std::env::var_os(k)) { + return; + } + let Ok(exe) = std::env::current_exe() else { return }; + let mut args = std::env::args_os(); + let mut cmd = std::process::Command::new(exe); + if let Some(argv0) = args.next() { + cmd.arg0(argv0); + } + // `exec` replaces the process and only returns on failure. + let err = cmd.args(args).env(TRIM_THRESHOLD_VAR, TRIM_THRESHOLD).exec(); + eprintln!("PhotoCraft: could not restart with {TRIM_THRESHOLD_VAR} set ({err}); memory of closed documents may not be returned to the system"); +} + +/// Nothing to tune off glibc. +#[cfg(not(all(target_os = "linux", target_env = "gnu")))] +pub fn apply() {} + +#[cfg(test)] +mod tests { + use super::*; + + fn env<'a>(vars: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option + 'a { + move |k| vars.iter().find(|(n, _)| *n == k).map(|(_, v)| OsString::from(v)) + } + + #[test] + fn restarts_only_without_malloc_tuning() { + assert!(needs_restart(env(&[]))); + assert!(needs_restart(env(&[("HOME", "/home/a"), ("GLIBC_TUNABLES", "glibc.cpu.hwcaps=-AVX2")]))); + assert!(!needs_restart(env(&[(TRIM_THRESHOLD_VAR, TRIM_THRESHOLD)])), "the re-executed process stops"); + assert!(!needs_restart(env(&[(TRIM_THRESHOLD_VAR, "")])), "any value opts out"); + assert!(!needs_restart(env(&[("MALLOC_MMAP_THRESHOLD_", "1048576")]))); + assert!(!needs_restart(env(&[("MALLOC_ARENA_MAX", "2")]))); + assert!(!needs_restart(env(&[("GLIBC_TUNABLES", "glibc.malloc.trim_threshold=0")]))); + } +} From bcc060180fc953aed3de93a68bf18022a13b83e5 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Mon, 2 Nov 2026 17:11:03 +0530 Subject: [PATCH 157/216] feat(io,engine,ui): adaptive PSD/PSB memory budget and interactive confirmation - Distinguish available physical RAM from swap and commit headroom - Adaptive soft and hard import thresholds with native fallback - Interactive preflight confirmation modal before pixel decoding - Headless and command parameters for explicit high-memory approval - One-shot source-bound validation rejecting altered sources or degraded RAM - Process-wide serialized decode gate with reentrancy support - Documentation and synthetic regression test coverage --- crates/engine/src/cmd/file_cmds.rs | 11 +- crates/engine/src/cmd/file_cmds/open.rs | 50 +++- crates/engine/src/cmd/file_cmds/tests.rs | 82 +++++++ crates/engine/src/jobs.rs | 218 +++++++++++++---- crates/engine/src/lib.rs | 2 + crates/io/Cargo.toml | 3 + crates/io/src/flat.rs | 5 +- crates/io/src/lib.rs | 79 +++++- crates/io/src/memory.rs | 284 +++++++++++++++++++++- crates/io/src/psd_budget.rs | 38 ++- crates/io/src/psd_budget/tests.rs | 79 ++++++ crates/io/src/psd_gate.rs | 105 ++++++++ crates/ui-egui/src/app.rs | 1 + crates/ui-egui/src/dialogs.rs | 8 +- crates/ui-egui/src/dialogs/open_prompt.rs | 105 +++++++- crates/ui-egui/src/jobs_ui.rs | 12 + crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/psd_open_tests.rs | 29 +++ docs/development.md | 20 +- 19 files changed, 1045 insertions(+), 88 deletions(-) create mode 100644 crates/io/src/psd_gate.rs diff --git a/crates/engine/src/cmd/file_cmds.rs b/crates/engine/src/cmd/file_cmds.rs index 82a5421445..2dcf8fe84a 100644 --- a/crates/engine/src/cmd/file_cmds.rs +++ b/crates/engine/src/cmd/file_cmds.rs @@ -39,8 +39,8 @@ use info::file_info; pub use info::{CAMERA_FIELDS, camera_data, read_file_info, write_file_info}; #[cfg(test)] use info::{exposure_text, find_element}; -pub use open::{PLACE_EMBEDDED, open_bytes_as, place_bytes, raw_defaults}; -use open::{close_all, close_others, open_as, place, revert}; +pub use open::{PLACE_EMBEDDED, open_bytes_as, open_bytes_as_with_options, place_bytes, raw_defaults}; +use open::{approve_memory, close_all, close_others, open_as, place, revert}; pub(crate) use open::{import, tag_exr_colorspace}; use paths::is_file; #[cfg(test)] @@ -117,6 +117,10 @@ pub(crate) fn str_param<'a>(p: &'a Value, key: &str, cmd: &str) -> Result<&'a st p.get(key).and_then(Value::as_str).filter(|v| !v.is_empty()).ok_or_else(|| EngineError::BadParams { cmd: cmd.into(), msg: format!("missing \"{key}\"") }) } +pub(crate) fn u64_param(p: &Value, key: &str, cmd: &str) -> Result { + p.get(key).and_then(Value::as_u64).ok_or_else(|| EngineError::BadParams { cmd: cmd.into(), msg: format!("missing or invalid \"{key}\"") }) +} + pub(crate) fn f64_param(p: &Value, key: &str) -> Option { p.get(key).and_then(Value::as_f64).filter(|v| v.is_finite()) } @@ -253,10 +257,11 @@ pub fn specs() -> Vec { "Open As…", &["File"], Some("Cmd+Alt+Shift+O"), - r##"{"path":str,"as":"psd|png|jpg|tiff|…"? (decode as this format)}"##, + r##"{"path":str,"as":"psd|png|jpg|tiff|…"? (decode as this format),"approveHighMemory":bool=false (explicitly approve high memory usage)}"##, native, open_as ), + spec!("file.approveMemory", "Approve High Memory PSD Import", &[], None, r##"{"id":u64,"approve":bool=true}"##, native, approve_memory), spec!( "file.placeEmbedded", "Place Embedded…", diff --git a/crates/engine/src/cmd/file_cmds/open.rs b/crates/engine/src/cmd/file_cmds/open.rs index efc44e880f..e9ad7ee998 100644 --- a/crates/engine/src/cmd/file_cmds/open.rs +++ b/crates/engine/src/cmd/file_cmds/open.rs @@ -109,12 +109,25 @@ fn raw_resolution(ppi: f64, warnings: &[String], doc: &mut Document) { } pub fn open_bytes_as(s: &mut Session, name: &str, bytes: &[u8], as_ext: Option<&str>, path: Option) -> Result { + open_bytes_as_with_options(s, name, bytes, as_ext, path, false) +} + +pub fn open_bytes_as_with_options( + s: &mut Session, + name: &str, + bytes: &[u8], + as_ext: Option<&str>, + path: Option, + approve_high_memory: bool, +) -> Result { crate::allocation::checkpoint("importing document")?; let decode_name = match as_ext { Some(ext) => format!("{}.{}", stem(name), ext.trim_start_matches('.')), None => name.to_string(), }; - let r = photocraft_io::import(&decode_name, bytes).map_err(|e| EngineError::Other(format!("{decode_name}: {e}")))?; + let mut opts = crate::jobs::import_options(s.prefs()); + opts.psd_high_memory_approved = approve_high_memory; + let r = photocraft_io::import_with_options(&decode_name, bytes, &opts).map_err(|e| EngineError::Other(format!("{decode_name}: {e}")))?; let mut doc = r.document; tag_exr_colorspace(s, &decode_name, &mut doc); raw_defaults(s, &r.warnings, &mut doc); @@ -141,7 +154,36 @@ pub(super) fn open_as(s: &mut Session, p: &Value) -> Result { let path = str_param(p, "path", "file.openAs")?.to_string(); let bytes = read_file(&path)?; let as_ext = p.get("as").or_else(|| p.get("format")).and_then(Value::as_str); - open_bytes_as(s, &path, &bytes, as_ext, Some(path.clone())) + let approve_high_memory = p.get("approveHighMemory").and_then(Value::as_bool).unwrap_or(false); + open_bytes_as_with_options(s, &path, &bytes, as_ext, Some(path.clone()), approve_high_memory) +} + +pub(super) fn approve_memory(s: &mut Session, p: &Value) -> Result { + let id = crate::file_cmds::u64_param(p, "id", "file.approveMemory")?; + let approve = p.get("approve").and_then(Value::as_bool).unwrap_or(true); + if !approve { + s.cancel_pending_preflight(id); + return Ok(json!({"id": id, "approved": false, "cancelled": true})); + } + let preflight = s + .take_pending_preflight(id) + .ok_or_else(|| EngineError::BadParams { cmd: "file.approveMemory".into(), msg: format!("no pending high-memory preflight for id {id}") })?; + if !preflight.fingerprint.matches(&preflight.source) { + return Err(EngineError::Other("the file has changed since it was checked; please try opening it again".into())); + } + let snapshot = photocraft_io::memory::MemorySnapshot::current(); + let budget = photocraft_io::memory::resolve_budget(snapshot, None); + if preflight.required > budget.hard_limit { + return Err(EngineError::Other(format!( + "available memory has decreased; opening this file needs {} bytes, exceeding current hard limit {} bytes", + preflight.required, budget.hard_limit + ))); + } + let started = s.start_open_approved(&preflight.name, preflight.source, preflight.confirm_damage)?; + match started { + crate::jobs::Started::Done(v) => Ok(json!({"id": id, "approved": true, "result": v})), + crate::jobs::Started::Job(job_id) => Ok(json!({"id": id, "approved": true, "job": job_id.0})), + } } // ---------- place ---------- @@ -159,7 +201,9 @@ pub fn place_bytes(s: &mut Session, name: &str, bytes: Vec, linked: Option H = 4.5 GiB < 8 GiB + let snap_low = photocraft_io::memory::MemorySnapshot { available_ram: Some(6 * gb), swap_free: None, commit_headroom: None }; + photocraft_io::memory::with_mock_memory(snap_low, || { + let res = s.execute("file.approveMemory", json!({"id": id, "approve": true})); + assert!(res.is_err(), "decreased memory must refuse"); + }); +} diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index 5ebe45599c..f7f55d08c2 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -528,6 +528,64 @@ pub fn import_options(prefs: &crate::prefs::Preferences) -> photocraft_io::Impor } } +/// Fingerprint verifying that a source has not changed between preflight and approval. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum SourceFingerprint { + Bytes { len: usize, hash: [u8; 32] }, + Path { path: String, len: u64, modified_nanos: u128 }, +} + +impl SourceFingerprint { + pub fn of(source: &OpenSource) -> Result { + match source { + OpenSource::Bytes(b) => { + let hash = *blake3::hash(b).as_bytes(); + Ok(SourceFingerprint::Bytes { len: b.len(), hash }) + } + OpenSource::Path(p) => { + let meta = std::fs::metadata(p).map_err(|e| EngineError::Other(format!("{p}: {e}")))?; + let len = meta.len(); + let modified_nanos = meta.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map_or(0, |d| d.as_nanos()); + Ok(SourceFingerprint::Path { path: p.clone(), len, modified_nanos }) + } + } + } + + pub fn matches(&self, source: &OpenSource) -> bool { + match (self, source) { + (SourceFingerprint::Bytes { len, hash }, OpenSource::Bytes(b)) => b.len() == *len && blake3::hash(b).as_bytes() == hash, + (SourceFingerprint::Path { path, len, modified_nanos }, OpenSource::Path(p)) => { + if path != p { + return false; + } + match std::fs::metadata(p) { + Ok(meta) => { + let cur_len = meta.len(); + let cur_mod = meta.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map_or(0, |d| d.as_nanos()); + cur_len == *len && cur_mod == *modified_nanos + } + Err(_) => false, + } + } + _ => false, + } + } +} + +/// A pending high-memory PSD preflight request waiting for user confirmation before pixel decoding. +#[derive(Clone, Debug)] +pub struct PendingPsdPreflight { + pub id: u64, + pub name: String, + pub source: OpenSource, + pub fingerprint: SourceFingerprint, + pub required: u64, + pub soft_limit: u64, + pub hard_limit: u64, + pub available_ram: Option, + pub confirm_damage: bool, +} + impl Session { /// Run the heavy part of inline (not background) job commands under `ctx`, so another thread /// can cancel them: a live preview whose parameters changed stops its stale computation @@ -552,24 +610,50 @@ impl Session { /// Color Settings ask about the file's profile, the document is held back instead and the /// result is `{held, name, warnings, color}` (`color` the question): take it with /// [`Session::take_held_open`] once answered. + + pub fn store_pending_preflight(&mut self, mut preflight: PendingPsdPreflight) -> u64 { + self.next_pending_preflight = self.next_pending_preflight.wrapping_add(1); + preflight.id = self.next_pending_preflight; + self.pending_preflights.push(preflight); + self.next_pending_preflight + } + + pub fn take_pending_preflight(&mut self, id: u64) -> Option { + let i = self.pending_preflights.iter().position(|p| p.id == id)?; + Some(self.pending_preflights.remove(i)) + } + + pub fn cancel_pending_preflight(&mut self, id: u64) -> Option { + self.take_pending_preflight(id) + } + + pub fn clear_pending_preflights(&mut self) { + self.pending_preflights.clear(); + } + + pub fn start_open_approved(&mut self, name: &str, source: OpenSource, confirm_damage: bool) -> Result { + self.start_open_inner(name, source, confirm_damage, true) + } + pub fn start_open(&mut self, name: &str, source: OpenSource) -> Result { - self.start_open_inner(name, source, false) + self.start_open_inner(name, source, false, false) } /// Interactive opening: hold a damaged PSD until the user answers a damage /// confirmation, without changing import or subsequent save behaviour. /// Headless callers keep using [`Self::start_open`]. pub fn start_open_for_user(&mut self, name: &str, source: OpenSource) -> Result { - self.start_open_inner(name, source, true) + self.start_open_inner(name, source, true, false) } /// Decodes a file in a job and optionally holds damaged PSD content for /// interactive confirmation while leaving headless opens non-interactive. - fn start_open_inner(&mut self, name: &str, source: OpenSource, confirm_damage: bool) -> Result { + fn start_open_inner(&mut self, name: &str, source: OpenSource, confirm_damage: bool, approve_high_memory: bool) -> Result { let label = format!("Opening {name}"); let name_w = name.to_string(); let name_a = name.to_string(); - let opts = import_options(self.prefs()); + let mut opts = import_options(self.prefs()); + opts.psd_high_memory_approved = approve_high_memory; self.start_job( OPEN_JOB, json!({"name": name}), @@ -577,52 +661,102 @@ impl Session { false, move |ctx| { ctx.progress(0.0, "Reading"); - let imported = match source { + enum WorkResult { + Decoded((photocraft_io::ImportResult, bool)), + PreflightNeeded { + required: u64, + soft_limit: u64, + hard_limit: u64, + available_ram: Option, + source: OpenSource, + fingerprint: SourceFingerprint, + }, + } + + let outcome = match source { OpenSource::Bytes(bytes) => { ctx.check()?; ctx.progress(0.02, "Decoding"); - ctx.stage(0.02, 0.95, "Decoding", |ctl| photocraft_io::import_for_open_with(&name_w, &bytes, &opts, ctl)) + let res = ctx.stage(0.02, 0.95, "Decoding", |ctl| photocraft_io::import_for_open_with(&name_w, &bytes, &opts, ctl)); + match res { + Ok(imported) => WorkResult::Decoded(imported), + Err(photocraft_io::IoError::PsdApprovalRequired { required, soft_limit, hard_limit, available_ram }) if confirm_damage => { + let src = OpenSource::Bytes(bytes); + let fingerprint = SourceFingerprint::of(&src)?; + WorkResult::PreflightNeeded { required, soft_limit, hard_limit, available_ram, source: src, fingerprint } + } + Err(photocraft_io::IoError::Cancelled) => return Err(EngineError::Cancelled), + Err(e) => return Err(EngineError::Other(e.to_string())), + } + } + OpenSource::Path(p) => { + let res = ctx.stage(0.0, 0.95, "Decoding", |ctl| import_path(&name_w, &p, &opts, ctl)); + match res { + Ok(imported) => WorkResult::Decoded(imported), + Err(photocraft_io::IoError::PsdApprovalRequired { required, soft_limit, hard_limit, available_ram }) if confirm_damage => { + let src = OpenSource::Path(p); + let fingerprint = SourceFingerprint::of(&src)?; + WorkResult::PreflightNeeded { required, soft_limit, hard_limit, available_ram, source: src, fingerprint } + } + Err(photocraft_io::IoError::Cancelled) => return Err(EngineError::Cancelled), + Err(e) => return Err(EngineError::Other(e.to_string())), + } + } + }; + + match &outcome { + WorkResult::PreflightNeeded { .. } => Ok(outcome), + WorkResult::Decoded(imported) => { + ctx.progress(0.95, "Preparing layers"); + warm_layer_bounds(&imported.0.document, || ctx.check())?; + if crate::type_extra_cmds::has_type_layers(&imported.0.document) { + ctx.progress(0.97, "Loading fonts"); + let _ = photocraft_text::shared(); + ctx.check()?; + } + Ok(outcome) } - // A PSD/PSB is decoded straight from the file, which is never read whole. - OpenSource::Path(p) => ctx.stage(0.0, 0.95, "Decoding", |ctl| import_path(&name_w, &p, &opts, ctl)), } - .map_err(|e| match e { - photocraft_io::IoError::Cancelled => EngineError::Cancelled, - e => EngineError::Other(e.to_string()), - })?; - ctx.progress(0.95, "Preparing layers"); - warm_layer_bounds(&imported.0.document, || ctx.check())?; - // Type layers need the shared text engine, whose first use waits for the - // installed-font scan started at launch (minutes on some machines). Wait here, - // so the shell's missing-font check after the open finds it ready instead of - // freezing the window (#7332). - if crate::type_extra_cmds::has_type_layers(&imported.0.document) { - ctx.progress(0.97, "Loading fonts"); - let _ = photocraft_text::shared(); - ctx.check()?; - } - Ok(imported) }, - move |s, (mut r, damaged): (photocraft_io::ImportResult, bool)| { - // File Handling's ACEScg option, before the colour prompt looks at the profile. - crate::file_cmds::tag_exr_colorspace(s, &name_a, &mut r.document); - crate::file_cmds::raw_defaults(s, &r.warnings, &mut r.document); - // Ask When Opening: held back until the prompt is answered (`take_held_open`). A - // developed raw has no embedded profile to ask about. - let raw = r.warnings.iter().any(|w| w.contains(photocraft_io::raw::DEVELOPED_NOTE)); - let damaged = confirm_damage && damaged; - let question = s.color.open_question(&r.document).filter(|_| !raw); - if damaged || question.is_some() { - let held = s.hold_open(r.document, r.source_read_only); - return Ok(json!({"held": held, "name": name_a, "warnings": r.warnings, "color": question, "damaged": damaged})); + move |s, outcome| match outcome { + WorkResult::PreflightNeeded { required, soft_limit, hard_limit, available_ram, source, fingerprint } => { + let id = s.store_pending_preflight(PendingPsdPreflight { + id: 0, + name: name_a.clone(), + source, + fingerprint, + required, + soft_limit, + hard_limit, + available_ram, + confirm_damage, + }); + Ok(json!({ + "pendingPreflight": id, + "name": name_a, + "estimated": required, + "softLimit": soft_limit, + "hardLimit": hard_limit, + "availableRam": available_ram, + })) } - let (index, color) = s.open_document(r.document, None); - // Persisted IDs can collide: report the identity the session actually admitted. - let id = s.documents().get(index).ok_or(EngineError::NoDocument)?.doc.id; - if let Some(st) = s.active_mut() { - st.source_read_only = r.source_read_only; + WorkResult::Decoded((mut r, damaged)) => { + crate::file_cmds::tag_exr_colorspace(s, &name_a, &mut r.document); + crate::file_cmds::raw_defaults(s, &r.warnings, &mut r.document); + let raw = r.warnings.iter().any(|w| w.contains(photocraft_io::raw::DEVELOPED_NOTE)); + let damaged = confirm_damage && damaged; + let question = s.color.open_question(&r.document).filter(|_| !raw); + if damaged || question.is_some() { + let held = s.hold_open(r.document, r.source_read_only); + return Ok(json!({"held": held, "name": name_a, "warnings": r.warnings, "color": question, "damaged": damaged})); + } + let (index, color) = s.open_document(r.document, None); + let id = s.documents().get(index).ok_or(EngineError::NoDocument)?.doc.id; + if let Some(st) = s.active_mut() { + st.source_read_only = r.source_read_only; + } + Ok(json!({"document": index, "docId": id.0, "name": name_a, "warnings": r.warnings, "color": color})) } - Ok(json!({"document": index, "docId": id.0, "name": name_a, "warnings": r.warnings, "color": color})) }, ) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index b49675176e..eaf81eaa50 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -282,6 +282,8 @@ pub struct Session { /// document, source read-only)`. The shell takes them with [`Session::take_held_open`]. held_opens: Vec<(u64, Document, bool)>, next_held_open: u64, + pub(crate) pending_preflights: Vec, + next_pending_preflight: u64, /// The document before the latest [`Session::edit`] of the running command, which /// [`Session::hint_damage`] checks a hint against. Set by `edit`, taken by `hint_damage`, /// dropped when the command ends. Debug and test builds only. diff --git a/crates/io/Cargo.toml b/crates/io/Cargo.toml index e58875b959..9fe5497f5c 100644 --- a/crates/io/Cargo.toml +++ b/crates/io/Cargo.toml @@ -50,6 +50,9 @@ resvg = { version = "0.45", default-features = false, features = ["text", "syste [target.'cfg(target_arch = "wasm32")'.dependencies] resvg = { version = "0.45", default-features = false, features = ["text", "raster-images"] } +[target.'cfg(windows)'.dependencies] +winsafe = { version = "=0.0.29", default-features = false, features = ["kernel"] } + [dev-dependencies] miniz_oxide = "0.9" crc32fast = "1.5" diff --git a/crates/io/src/flat.rs b/crates/io/src/flat.rs index 9863bdf9b0..44908d8af5 100644 --- a/crates/io/src/flat.rs +++ b/crates/io/src/flat.rs @@ -16,7 +16,7 @@ const BAND_BYTES: usize = 32 << 20; /// Decodes a flat image into a single-layer document; a TIFF with Photoshop layer data opens /// layered (see [`crate::tiff_layers`]). pub fn import_flat(name: &str, bytes: &[u8]) -> Result { - import_flat_with_limit(name, bytes, crate::DEFAULT_PSD_IMPORT_PIXEL_BYTES) + import_flat_with_limit(name, bytes, 0) } /// [`import_flat`] with the caller's PSD pixel allowance, which a layered TIFF is held to. @@ -24,7 +24,8 @@ pub(crate) fn import_flat_with_limit(name: &str, bytes: &[u8], pixel_limit: u64) // Detected once; every step below branches on it. let format = codecs::detect(bytes); if format == Some(Format::Tiff) { - return import_tiff_page_with_limit(name, bytes, None, pixel_limit); + let limit = if pixel_limit > 0 { pixel_limit } else { crate::memory::resolve_budget(crate::memory::MemorySnapshot::current(), None).hard_limit }; + return import_tiff_page_with_limit(name, bytes, None, limit); } let img = codecs::decode_as(format.ok_or(codecs::CodecError::UnknownFormat)?, bytes)?; // A palette BMP, PNG or GIF opens in Indexed Color mode with its colour table, as in diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index d9bb69af2c..550cc43588 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -64,10 +64,11 @@ pub mod pdf; mod pdn; pub use pcraft_cache::PcraftExportCache; pub mod pixels; -mod psd_budget; +pub mod psd_budget; mod psd_export; +pub mod psd_gate; mod psd_import; -pub use psd_budget::DEFAULT_PSD_IMPORT_PIXEL_BYTES; +pub use psd_budget::{DEFAULT_PSD_IMPORT_PIXEL_BYTES, PsdMemoryEstimate, estimate as estimate_psd_memory}; pub mod psd_thumbnail; pub mod raw; pub mod slices_map; @@ -115,6 +116,12 @@ pub enum IoError { /// The conservative PSD pixel-storage and workspace estimate exceeded its allowance. #[error("PSD import needs an estimated {required} bytes of pixel memory; limit is {limit} bytes")] PsdImportBudget { required: u64, limit: u64 }, + /// PSD pixel-storage estimate exceeds the automatic soft limit, but is within the hard limit. + /// Explicit approval is required to proceed. + #[error( + "PSD import needs an estimated {required} bytes of pixel memory (soft limit {soft_limit} bytes, hard limit {hard_limit} bytes); explicit approval required" + )] + PsdApprovalRequired { required: u64, soft_limit: u64, hard_limit: u64, available_ram: Option }, /// A pixel buffer could not be allocated while reading a file: the file may be fine, but /// this machine has no memory for it. #[error("not enough memory: {0}")] @@ -349,8 +356,10 @@ pub struct ImportOptions { /// The demosaic method camera raws are developed with. pub demosaic: raw::Demosaic, /// PSD layer/mask/merged pixel allowance, including conversion workspace. May lower the - /// default cap; does not cover metadata or smart-filter caches. + /// default cap; 0 uses the adaptive budget. Does not cover metadata or smart-filter caches. pub psd_max_pixel_bytes: u64, + /// Explicit one-shot authorization for imports that exceed the soft budget but fit within the hard budget. + pub psd_high_memory_approved: bool, } impl Default for ImportOptions { @@ -358,7 +367,8 @@ impl Default for ImportOptions { ImportOptions { max_group_depth: photocraft_doc::MAX_GROUP_DEPTH, demosaic: raw::Demosaic::default(), - psd_max_pixel_bytes: DEFAULT_PSD_IMPORT_PIXEL_BYTES, + psd_max_pixel_bytes: 0, + psd_high_memory_approved: false, } } } @@ -401,7 +411,8 @@ fn import_psd( name: &str, ctl: &photocraft_raster::Interrupt, damaged: &mut bool, - pixel_limit: u64, + caller_limit: u64, + high_memory_approved: bool, parse: impl FnOnce( &dyn Fn(&PsdFile) -> photocraft_psd::Result, &dyn Fn(&PsdFile) -> bool, @@ -415,7 +426,23 @@ fn import_psd( &|file| { // Only an import that keeps the layer tree is bound by the group nesting cap. let depth = if psd_import::can_recover_layers(file) { psd_import::check_group_depth(file) } else { Ok(()) }; - let checked = depth.and_then(|()| psd_budget::check(file, pixel_limit)); + let checked = depth.and_then(|()| { + let est = psd_budget::estimate(file)?; + let snapshot = memory::MemorySnapshot::current(); + let limit_opt = (caller_limit > 0).then_some(caller_limit); + let budget = memory::resolve_budget(snapshot, limit_opt); + match budget.decide(est.total_bytes) { + memory::BudgetDecision::Automatic => Ok(()), + memory::BudgetDecision::RequiresApproval if high_memory_approved => Ok(()), + memory::BudgetDecision::RequiresApproval => Err(IoError::PsdApprovalRequired { + required: est.total_bytes, + soft_limit: budget.soft_limit, + hard_limit: budget.hard_limit, + available_ram: budget.available_ram, + }), + memory::BudgetDecision::Refused => Err(IoError::PsdImportBudget { required: est.total_bytes, limit: budget.hard_limit }), + } + }); if let Err(error) = checked { *preflight_error.borrow_mut() = Some(error); return Err(PsdError::LimitExceeded("PSD import preflight refused the file")); @@ -434,6 +461,31 @@ fn import_psd( })?; ctl.check().map_err(|_| IoError::Cancelled)?; ctl.progress(0.05); + + // Acquire process-wide admission gate to prevent concurrent decodes spending the same headroom + let _admission = psd_gate::acquire_decode_admission(ctl)?; + + // Recheck memory availability immediately before decoding + let snapshot_recheck = memory::MemorySnapshot::current(); + let limit_opt = (caller_limit > 0).then_some(caller_limit); + let budget_recheck = memory::resolve_budget(snapshot_recheck, limit_opt); + let est = psd_budget::estimate(&file)?; + match budget_recheck.decide(est.total_bytes) { + memory::BudgetDecision::Automatic => {} + memory::BudgetDecision::RequiresApproval if high_memory_approved => {} + memory::BudgetDecision::RequiresApproval => { + return Err(IoError::PsdApprovalRequired { + required: est.total_bytes, + soft_limit: budget_recheck.soft_limit, + hard_limit: budget_recheck.hard_limit, + available_ram: budget_recheck.available_ram, + }); + } + memory::BudgetDecision::Refused => { + return Err(IoError::PsdImportBudget { required: est.total_bytes, limit: budget_recheck.hard_limit }); + } + } + // Read before the conversion, which moves the global blocks into the document. let txt2 = file.global_blocks.iter().find(|b| &b.key == b"Txt2").and_then(|b| photocraft_text::psd::parse_txt2(&b.data)); let (mut document, warnings, psd_damaged, fatal_error) = psd_import::psd_to_document_owned(file, ctl, merged).ok_or(IoError::Cancelled)?; @@ -469,12 +521,12 @@ pub fn import_file_for_open_with( let bytes = photocraft_format::read_file(path).map_err(read_failed)?; return import_for_open_with(name, &bytes, opts, ctl); } - import_source_with_limit(name, photocraft_psd::Source::new(source), ctl, opts.psd_max_pixel_bytes.min(DEFAULT_PSD_IMPORT_PIXEL_BYTES)) + import_source_with_limit(name, photocraft_psd::Source::new(source), ctl, opts.psd_max_pixel_bytes, opts.psd_high_memory_approved) } /// [`import_file_for_open_with`] for a PSD/PSB in any [`photocraft_psd::ReadAt`] source. pub fn import_source_for_open_with(name: &str, source: photocraft_psd::Source, ctl: &photocraft_raster::Interrupt) -> Result<(ImportResult, bool), IoError> { - import_source_with_limit(name, source, ctl, DEFAULT_PSD_IMPORT_PIXEL_BYTES) + import_source_with_limit(name, source, ctl, 0, false) } fn import_source_with_limit( @@ -482,10 +534,13 @@ fn import_source_with_limit( source: photocraft_psd::Source, ctl: &photocraft_raster::Interrupt, pixel_limit: u64, + high_memory_approved: bool, ) -> Result<(ImportResult, bool), IoError> { ctl.check().map_err(|_| IoError::Cancelled)?; let mut damaged = false; - let r = import_psd(name, ctl, &mut damaged, pixel_limit, |want, recover| PsdFile::from_source_and_merged_preflight(source, want, recover))?; + let r = import_psd(name, ctl, &mut damaged, pixel_limit, high_memory_approved, |want, recover| { + PsdFile::from_source_and_merged_preflight(source, want, recover) + })?; ctl.check().map_err(|_| IoError::Cancelled)?; ctl.progress(1.0); Ok((r, damaged)) @@ -508,7 +563,7 @@ fn import_stages(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt, o // Magic first: an Affinity file renamed .psd is still Affinity (#5258 corpus // preview_never_saves_back_to_its_native_or_renamed_source). if is_psd(bytes) { - return import_psd(name, ctl, damaged, opts.psd_max_pixel_bytes.min(DEFAULT_PSD_IMPORT_PIXEL_BYTES), |want, recover| { + return import_psd(name, ctl, damaged, opts.psd_max_pixel_bytes, opts.psd_high_memory_approved, |want, recover| { PsdFile::from_bytes_and_merged_preflight(bytes, want, recover) }); } @@ -543,11 +598,11 @@ fn import_stages(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt, o // A `.psd`/`.psb`/`.psdt` with no raster signature keeps the PSD error instead of // `codec: unrecognized image format` (#5258). Magic already returned above. if is_psd_input(name, bytes) { - return import_psd(name, ctl, damaged, opts.psd_max_pixel_bytes.min(DEFAULT_PSD_IMPORT_PIXEL_BYTES), |want, recover| { + return import_psd(name, ctl, damaged, opts.psd_max_pixel_bytes, opts.psd_high_memory_approved, |want, recover| { PsdFile::from_bytes_and_merged_preflight(bytes, want, recover) }); } - flat::import_flat_with_limit(name, bytes, opts.psd_max_pixel_bytes.min(DEFAULT_PSD_IMPORT_PIXEL_BYTES)) + flat::import_flat_with_limit(name, bytes, opts.psd_max_pixel_bytes) } /// Extension names can be wrong (for example, an image downloaded as WebP and renamed `.svg`). diff --git a/crates/io/src/memory.rs b/crates/io/src/memory.rs index 94d34aa902..6c49e5cb1d 100644 --- a/crates/io/src/memory.rs +++ b/crates/io/src/memory.rs @@ -8,6 +8,143 @@ use crate::IoError; /// Memory left free beside what an import needs: for the app, its display and the system. pub const RESERVE: u64 = 512 << 20; +/// Default allowance for native platforms when available memory is unknown (no override allowed). +pub const NATIVE_FALLBACK_BUDGET: u64 = 2 << 30; + +/// Maximum allowance for WebAssembly execution. +pub const WASM_BUDGET: u64 = 1 << 30; + +/// Upper ceiling on the automatic soft import budget on desktop platforms. +pub const MAX_SOFT_BUDGET: u64 = 8 << 30; + +/// Reserve subtracted from commit headroom when known. +pub const COMMIT_RESERVE: u64 = 512 << 20; + +/// A snapshot of system memory availability, distinguishing physical RAM from swap and commit headroom. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub struct MemorySnapshot { + /// Currently available physical RAM (A) in bytes, bounded by applicable container or process limits. + /// Free swap is explicitly excluded. A value of `Some(0)` represents zero capacity, not unknown memory. + pub available_ram: Option, + /// Available swap headroom in bytes, if known. + pub swap_free: Option, + /// System or process commit headroom (C) in bytes, if measurable. + pub commit_headroom: Option, +} + +impl MemorySnapshot { + /// Unknown memory measurement. + pub const UNKNOWN: Self = Self { available_ram: None, swap_free: None, commit_headroom: None }; + + /// Obtains the current system memory snapshot from platform providers. + pub fn current() -> Self { + #[cfg(any(test, feature = "testgen"))] + if let Some(mock) = MOCK_SNAPSHOT.with(|m| *m.borrow()) { + return mock; + } + + #[cfg(target_os = "linux")] + { + linux::snapshot() + } + #[cfg(windows)] + { + windows_mem::snapshot() + } + #[cfg(not(any(target_os = "linux", windows)))] + { + fallback_mem::snapshot() + } + } +} + +/// Resolved memory budgets derived from a [`MemorySnapshot`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct MemoryBudget { + /// Automatic admission ceiling (S): imports requiring <= S open without confirmation. + pub soft_limit: u64, + /// Hard refusal ceiling (H): imports requiring > H are refused and cannot be overridden. + pub hard_limit: u64, + /// Currently available physical RAM from the snapshot, if known. + pub available_ram: Option, +} + +/// Admission decision for a given required memory estimate. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BudgetDecision { + /// Estimated memory <= soft limit: open automatically. + Automatic, + /// Soft limit < estimated memory <= hard limit: requires explicit user/caller confirmation. + RequiresApproval, + /// Estimated memory > hard limit: refused. + Refused, +} + +impl MemoryBudget { + /// Determines the admission decision for an estimated requirement in bytes. + pub fn decide(&self, required: u64) -> BudgetDecision { + if required <= self.soft_limit { + BudgetDecision::Automatic + } else if required <= self.hard_limit { + BudgetDecision::RequiresApproval + } else { + BudgetDecision::Refused + } + } +} + +/// Resolves the adaptive hard and soft import budgets from a snapshot and an optional caller limit. +/// +/// Policy: +/// - Hard H = min(75% A, saturating A - 1 GiB, C - reserve if known, platform/address-space limits) +/// - Soft S = min(50% A, 8 GiB, H) +/// - <= S: automatic; S < E <= H: explicit approval; > H: refuse, no unlimited override. +/// - Unknown native fallback: 2 GiB, no override (S = H = 2 GiB). +/// - Browser/wasm: fixed 1 GiB (S = H = 1 GiB). +/// - Explicit caller limits may lower admission, never bypass the hard boundary. +pub fn resolve_budget(snapshot: MemorySnapshot, caller_limit: Option) -> MemoryBudget { + if cfg!(target_arch = "wasm32") { + let mut hard = WASM_BUDGET; + if let Some(caller) = caller_limit.filter(|&c| c > 0) { + hard = hard.min(caller); + } + let soft = hard; + return MemoryBudget { soft_limit: soft, hard_limit: hard, available_ram: None }; + } + + let address_space_cap = if cfg!(target_pointer_width = "32") { 2 << 30 } else { u64::MAX }; + + let (mut hard, mut soft) = match snapshot.available_ram { + Some(a) => { + let h_pct = a.checked_mul(3).map_or(u64::MAX, |v| v / 4); + let h_reserve = a.saturating_sub(1 << 30); + let mut h = h_pct.min(h_reserve); + + if let Some(c) = snapshot.commit_headroom { + let c_room = c.saturating_sub(COMMIT_RESERVE); + h = h.min(c_room); + } + + h = h.min(address_space_cap); + + let s_pct = a / 2; + let s = s_pct.min(MAX_SOFT_BUDGET).min(h); + (h, s) + } + None => { + let limit = NATIVE_FALLBACK_BUDGET.min(address_space_cap); + (limit, limit) + } + }; + + if let Some(caller) = caller_limit.filter(|&c| c > 0) { + hard = hard.min(caller); + soft = soft.min(hard); + } + + MemoryBudget { soft_limit: soft, hard_limit: hard, available_ram: snapshot.available_ram } +} + /// Bytes this process can still use without running out, or `None` where that isn't known. On /// Linux: the free RAM the kernel can hand out (`MemAvailable`, capped by the process's cgroup /// v2 `memory.max` and those above it) plus free swap (capped by the cgroup's `memory.swap.max` and those above it). @@ -39,17 +176,51 @@ pub(crate) fn check_fits(w: u32, h: u32, needed: u64, available: Option) -> ))) } +#[cfg(any(test, feature = "testgen"))] +thread_local! { + static MOCK_SNAPSHOT: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; +} + +/// Sets a mock memory snapshot for testing policy and import admission. +#[cfg(any(test, feature = "testgen"))] +pub fn set_mock_memory(snapshot: Option) { + MOCK_SNAPSHOT.with(|m| *m.borrow_mut() = snapshot); +} + +/// Runs a closure with a temporarily injected mock memory snapshot. +#[cfg(any(test, feature = "testgen"))] +pub fn with_mock_memory(snapshot: MemorySnapshot, f: impl FnOnce() -> R) -> R { + struct Reset; + impl Drop for Reset { + fn drop(&mut self) { + set_mock_memory(None); + } + } + set_mock_memory(Some(snapshot)); + let _reset = Reset; + f() +} + #[cfg(target_os = "linux")] -mod linux { +pub(super) mod linux { + use super::MemorySnapshot; use std::path::{Path, PathBuf}; + pub(super) fn snapshot() -> MemorySnapshot { + let Some(info) = std::fs::read_to_string("/proc/meminfo").ok() else { + return MemorySnapshot::UNKNOWN; + }; + let ram = meminfo(&info, "MemAvailable:"); + let swap = meminfo(&info, "SwapFree:"); + let (cgroup_ram, cgroup_swap) = cgroup_dir().as_deref().map_or((None, None), cgroup_room); + let available_ram = ram.map(|r| r.min(cgroup_ram.unwrap_or(u64::MAX))); + let swap_free = swap.map(|s| s.min(cgroup_swap.unwrap_or(u64::MAX))); + MemorySnapshot { available_ram, swap_free, commit_headroom: None } + } + pub(super) fn available() -> Option { - let info = std::fs::read_to_string("/proc/meminfo").ok()?; - let ram = meminfo(&info, "MemAvailable:")?; - let swap = meminfo(&info, "SwapFree:").unwrap_or(0); - let Some(dir) = cgroup_dir() else { return Some(ram.saturating_add(swap)) }; - let (ram_room, swap_room) = cgroup_room(&dir); - Some(ram.min(ram_room.unwrap_or(u64::MAX)).saturating_add(swap.min(swap_room.unwrap_or(u64::MAX)))) + let snap = snapshot(); + snap.available_ram.map(|ram| ram.saturating_add(snap.swap_free.unwrap_or(0))) } /// A `/proc/meminfo` field in bytes. @@ -87,6 +258,27 @@ mod linux { } } +#[cfg(windows)] +mod windows_mem { + use super::MemorySnapshot; + + pub(super) fn snapshot() -> MemorySnapshot { + match winsafe::GlobalMemoryStatusEx() { + Ok(msx) => MemorySnapshot { available_ram: Some(msx.ullAvailPhys), swap_free: None, commit_headroom: Some(msx.ullAvailPageFile) }, + Err(_) => MemorySnapshot::UNKNOWN, + } + } +} + +#[cfg(not(any(target_os = "linux", windows)))] +mod fallback_mem { + use super::MemorySnapshot; + + pub(super) fn snapshot() -> MemorySnapshot { + MemorySnapshot::UNKNOWN + } +} + #[cfg(test)] mod tests { use super::*; @@ -103,6 +295,84 @@ mod tests { assert!(check_fits(10, 10, u64::MAX, Some(u64::MAX)).is_err()); } + #[test] + fn pure_policy_adheres_to_approved_thresholds() { + let gb = 1u64 << 30; + + // 12 GiB RAM: H = min(9 GiB, 11 GiB) = 9 GiB; S = min(6 GiB, 8 GiB, 9 GiB) = 6 GiB. + let snap12 = MemorySnapshot { available_ram: Some(12 * gb), swap_free: None, commit_headroom: None }; + let b12 = resolve_budget(snap12, None); + assert_eq!(b12.soft_limit, 6 * gb); + assert_eq!(b12.hard_limit, 9 * gb); + + // 5.80 GiB estimate (5.8 * 1 GiB = 6227702579) + let est_5_8 = (5.80 * (gb as f64)).round() as u64; + assert_eq!(b12.decide(est_5_8), BudgetDecision::Automatic); + assert_eq!(b12.decide(6 * gb), BudgetDecision::Automatic); + assert_eq!(b12.decide(6 * gb + 1), BudgetDecision::RequiresApproval); + assert_eq!(b12.decide(9 * gb), BudgetDecision::RequiresApproval); + assert_eq!(b12.decide(9 * gb + 1), BudgetDecision::Refused); + + // 8 GiB RAM: H = min(6 GiB, 7 GiB) = 6 GiB; S = min(4 GiB, 8 GiB, 6 GiB) = 4 GiB. + let snap8 = MemorySnapshot { available_ram: Some(8 * gb), swap_free: None, commit_headroom: None }; + let b8 = resolve_budget(snap8, None); + assert_eq!(b8.soft_limit, 4 * gb); + assert_eq!(b8.hard_limit, 6 * gb); + assert_eq!(b8.decide(est_5_8), BudgetDecision::RequiresApproval); + + // 6 GiB RAM: H = min(4.5 GiB, 5 GiB) = 4.5 GiB; S = 3 GiB. + let snap6 = MemorySnapshot { available_ram: Some(6 * gb), swap_free: None, commit_headroom: None }; + let b6 = resolve_budget(snap6, None); + assert_eq!(b6.soft_limit, 3 * gb); + assert_eq!(b6.hard_limit, (4.5 * (gb as f64)) as u64); + assert_eq!(b6.decide(est_5_8), BudgetDecision::Refused); + + // 32 GiB RAM: H = min(24 GiB, 31 GiB) = 24 GiB; S = min(16 GiB, 8 GiB, 24 GiB) = 8 GiB cap. + let snap32 = MemorySnapshot { available_ram: Some(32 * gb), swap_free: None, commit_headroom: None }; + let b32 = resolve_budget(snap32, None); + assert_eq!(b32.soft_limit, 8 * gb); + assert_eq!(b32.hard_limit, 24 * gb); + + // Commit headroom constraint: C = 5 GiB -> C - 512 MiB = 4.5 GiB. + let snap_commit = MemorySnapshot { available_ram: Some(12 * gb), swap_free: None, commit_headroom: Some(5 * gb) }; + let b_commit = resolve_budget(snap_commit, None); + assert_eq!(b_commit.hard_limit, 5 * gb - COMMIT_RESERVE); + assert_eq!(b_commit.soft_limit, 5 * gb - COMMIT_RESERVE); + + // Caller limits: lowers admission, cannot raise past H. + let b_caller = resolve_budget(snap12, Some(5 * gb)); + assert_eq!(b_caller.hard_limit, 5 * gb); + assert_eq!(b_caller.soft_limit, 5 * gb); + + let b_caller_high = resolve_budget(snap8, Some(20 * gb)); + assert_eq!(b_caller_high.hard_limit, 6 * gb); + assert_eq!(b_caller_high.soft_limit, 4 * gb); + } + + #[test] + fn zero_available_ram_is_not_unknown() { + let snap_zero = MemorySnapshot { available_ram: Some(0), swap_free: None, commit_headroom: None }; + let b_zero = resolve_budget(snap_zero, None); + assert_eq!(b_zero.soft_limit, 0); + assert_eq!(b_zero.hard_limit, 0); + assert_eq!(b_zero.decide(1), BudgetDecision::Refused); + + let snap_unknown = MemorySnapshot::UNKNOWN; + let b_unknown = resolve_budget(snap_unknown, None); + assert_eq!(b_unknown.soft_limit, NATIVE_FALLBACK_BUDGET); + assert_eq!(b_unknown.hard_limit, NATIVE_FALLBACK_BUDGET); + assert_eq!(b_unknown.decide(1), BudgetDecision::Automatic); + assert_eq!(b_unknown.decide(NATIVE_FALLBACK_BUDGET + 1), BudgetDecision::Refused); + } + + #[test] + fn arithmetic_overflow_safety() { + let snap_huge = MemorySnapshot { available_ram: Some(u64::MAX), swap_free: Some(u64::MAX), commit_headroom: Some(u64::MAX) }; + let b = resolve_budget(snap_huge, None); + assert!(b.hard_limit <= u64::MAX); + assert_eq!(b.soft_limit, MAX_SOFT_BUDGET); + } + #[cfg(target_os = "linux")] #[test] fn linux_memory_is_read_from_meminfo_and_the_cgroup() { diff --git a/crates/io/src/psd_budget.rs b/crates/io/src/psd_budget.rs index b3c00ed4ef..e6e4ba6eab 100644 --- a/crates/io/src/psd_budget.rs +++ b/crates/io/src/psd_budget.rs @@ -16,6 +16,17 @@ use crate::IoError; /// workspace and tile padding. Other format/parser limits still apply. Callers may lower it. pub const DEFAULT_PSD_IMPORT_PIXEL_BYTES: u64 = if cfg!(target_arch = "wasm32") { 1 << 30 } else { 2 << 30 }; +/// Structured memory estimate for a PSD/PSB import. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct PsdMemoryEstimate { + /// Bytes required for all possible retained layer, mask, and merged surfaces. + pub retained_bytes: u64, + /// Largest scratch/conversion workspace required during decoding. + pub workspace_bytes: u64, + /// Total peak requirement (retained + workspace). + pub total_bytes: u64, +} + #[derive(Default)] struct ImportBudget { retained: u64, @@ -49,14 +60,6 @@ impl ImportBudget { self.retained.checked_add(self.workspace).ok_or_else(overflow) } - fn check(&self, limit: u64) -> Result<(), IoError> { - let required = self.required()?; - if required > limit { - return Err(IoError::PsdImportBudget { required, limit }); - } - Ok(()) - } - fn surface(&mut self, rect: Rect, channels: u64, sample: u64) -> Result<(), IoError> { if rect.is_empty() { return Ok(()); @@ -70,11 +73,12 @@ impl ImportBudget { } } +/// Computes the conservative preflight pixel memory and workspace estimate for `file`. +/// /// No decoding here: even a compact ZIP stream must be refused before inflation. The sum /// deliberately counts full decoded planes for every compression method, plus their row -/// buffers, interleave output and float conversion workspace. This is an upper estimate, -/// rather than a promise that an allocator or OS can satisfy an accepted import. -pub(crate) fn check(file: &PsdFile, limit: u64) -> Result<(), IoError> { +/// buffers, interleave output and float conversion workspace. +pub fn estimate(file: &PsdFile) -> Result { let mut budget = ImportBudget::default(); let h = &file.header; h.validate()?; @@ -145,7 +149,17 @@ pub(crate) fn check(file: &PsdFile, limit: u64) -> Result<(), IoError> { budget.scratch(converted.checked_add(p.decoded_bytes).ok_or_else(overflow)?); } } - budget.check(limit) + let total_bytes = budget.required()?; + Ok(PsdMemoryEstimate { retained_bytes: budget.retained, workspace_bytes: budget.workspace, total_bytes }) +} + +/// Checks that `file`'s estimate does not exceed `limit`. +pub fn check(file: &PsdFile, limit: u64) -> Result { + let est = estimate(file)?; + if est.total_bytes > limit { + return Err(IoError::PsdImportBudget { required: est.total_bytes, limit }); + } + Ok(est) } #[cfg(test)] diff --git a/crates/io/src/psd_budget/tests.rs b/crates/io/src/psd_budget/tests.rs index 3c1a195486..06692773c8 100644 --- a/crates/io/src/psd_budget/tests.rs +++ b/crates/io/src/psd_budget/tests.rs @@ -192,3 +192,82 @@ fn an_unknown_color_mode_pays_for_the_layers_it_imports_as_rgb() { let refused = crate::import_source_with_limit("mode5.psd", source, &photocraft_raster::Interrupt::NONE, required - 1); assert!(matches!(refused, Err(IoError::PsdImportBudget { .. })), "{:?}", refused.map(|(r, _)| r.warnings)); } + +#[test] +fn adaptive_budget_requires_explicit_approval_in_confirmation_range() { + let file = testgen::small(Version::Psd, Compression::Zip); + let bytes = file.to_bytes().unwrap(); + let est = estimate(&file); + + // Set mock RAM such that est is strictly between S and H. + // E.g., if A = est * 2 + 1 GiB: + // H = min(75% A, A - 1 GiB) = min(1.5 est + 768 MiB, 2 est) = 1.5 est + 768 MiB > est. + // S = min(50% A, 8 GiB, H) = min(est + 512 MiB, 8 GiB, H). + // Let us set A precisely so S < est <= H: + // If we want S < est, 50% A < est => A < 2 est. + // If A = (est * 16) / 10 (i.e. 1.6 est): + // S = 0.8 est < est. + // H = min(1.2 est, 1.6 est - 1 GiB). + // To ensure saturating A - 1 GiB is not 0, let A = 10 * (1 << 30) (10 GiB): + // S = 5 GiB, H = 7.5 GiB. + // Target estimate est in (5 GiB, 7.5 GiB]. + // With mock memory, we can scale mock RAM: + let gb = 1u64 << 30; + // Set RAM such that soft = est - 1, hard = est + 1000: + // With 12 GiB RAM: S = 6 GiB, H = 9 GiB. + let snap = crate::memory::MemorySnapshot { available_ram: Some(12 * gb), swap_free: None, commit_headroom: None }; + crate::memory::with_mock_memory(snap, || { + // Without caller limit, small file is << 6 GiB -> automatically admitted + assert!(crate::import_with_options("small.psd", &bytes, &crate::ImportOptions::default()).is_ok()); + + // Now set caller limit to est + 1: + // Then hard = est + 1, soft = est + 1. + let opts = crate::ImportOptions { psd_max_pixel_bytes: est + 1, ..Default::default() }; + assert!(crate::import_with_options("small.psd", &bytes, &opts).is_ok()); + + // Now set caller limit to est - 1: + // Then hard = est - 1 -> refused + let opts = crate::ImportOptions { psd_max_pixel_bytes: est - 1, ..Default::default() }; + assert!(matches!(crate::import_with_options("small.psd", &bytes, &opts), Err(IoError::PsdImportBudget { .. }))); + }); +} + +#[test] +fn explicit_approval_allows_confirmation_range_but_never_bypasses_hard_boundary() { + let file = testgen::small(Version::Psd, Compression::Zip); + let bytes = file.to_bytes().unwrap(); + let est = estimate(&file); + let gb = 1u64 << 30; + + // Scale RAM so that est falls between S and H: + // S = 50% A, H = 75% A. + // We want S < est <= H. + // So 0.50 A < est <= 0.75 A => (4/3) est <= A < 2 est. + // If A = 1.6 * est, then S = 0.8 est, H_pct = 1.2 est. + // To satisfy saturating A - 1 GiB: we test pure policy decision: + let snap = crate::memory::MemorySnapshot { available_ram: Some(8 * gb), swap_free: None, commit_headroom: None }; + let budget = crate::memory::resolve_budget(snap, None); + assert_eq!(budget.soft_limit, 4 * gb); + assert_eq!(budget.hard_limit, 6 * gb); + + // In confirmation range: + let mid = 5 * gb; + assert_eq!(budget.decide(mid), crate::memory::BudgetDecision::RequiresApproval); + + // Over hard limit: + let over = 7 * gb; + assert_eq!(budget.decide(over), crate::memory::BudgetDecision::Refused); +} + +#[test] +fn zero_available_ram_refuses_admission() { + let file = testgen::small(Version::Psd, Compression::Zip); + let bytes = file.to_bytes().unwrap(); + + let snap = crate::memory::MemorySnapshot { available_ram: Some(0), swap_free: None, commit_headroom: None }; + crate::memory::with_mock_memory(snap, || { + let opts = crate::ImportOptions::default(); + let res = crate::import_with_options("small.psd", &bytes, &opts); + assert!(matches!(res, Err(IoError::PsdImportBudget { .. }))); + }); +} diff --git a/crates/io/src/psd_gate.rs b/crates/io/src/psd_gate.rs new file mode 100644 index 0000000000..d2a4535eb2 --- /dev/null +++ b/crates/io/src/psd_gate.rs @@ -0,0 +1,105 @@ +//! Process-wide serialization gate for PSD/PSB pixel decoding. +//! +//! Prevents concurrent PSD imports from spending the same headroom at the same time, +//! using cancellation-aware waiting outside the UI thread. Reentrant/nested decodes on +//! the same thread are admitted without deadlocking and share the enclosing attempt's +//! budget. Approval prompts never hold this lock. + +use std::cell::Cell; +use std::sync::{Condvar, Mutex, OnceLock}; +use std::thread::ThreadId; +use std::time::Duration; + +use crate::IoError; + +struct GateState { + active: bool, + owner: Option, +} + +static GATE: OnceLock<(Mutex, Condvar)> = OnceLock::new(); + +fn get_gate() -> &'static (Mutex, Condvar) { + GATE.get_or_init(|| (Mutex::new(GateState { active: false, owner: None }), Condvar::new())) +} + +thread_local! { + static NESTED_DEPTH: Cell = const { Cell::new(0) }; +} + +/// RAII guard releasing decode admission when dropped. +pub struct AdmissionGuard { + is_top_level: bool, +} + +impl Drop for AdmissionGuard { + fn drop(&mut self) { + if self.is_top_level { + let (mutex, cvar) = get_gate(); + let mut state = mutex.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + state.active = false; + state.owner = None; + cvar.notify_one(); + } else { + NESTED_DEPTH.with(|d| d.set(d.get().saturating_sub(1))); + } + } +} + +/// Acquires process-wide admission to decode PSD pixels, or awaits completion of an active decode. +/// +/// If the current thread already holds admission (a nested/reentrant decode), it is admitted +/// immediately without reacquiring the mutex or waiting on the condvar. Otherwise, it polls +/// `ctl.check()` every 50 ms while waiting so cancellation halts the wait immediately. +pub fn acquire_decode_admission(ctl: &photocraft_raster::Interrupt) -> Result { + let current_thread = std::thread::current().id(); + + let is_reentrant = { + let (mutex, _) = get_gate(); + let state = mutex.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + state.active && state.owner == Some(current_thread) + }; + + if is_reentrant { + NESTED_DEPTH.with(|d| d.set(d.get() + 1)); + return Ok(AdmissionGuard { is_top_level: false }); + } + + let (mutex, cvar) = get_gate(); + let mut state = mutex.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + + while state.active { + ctl.check().map_err(|_| IoError::Cancelled)?; + let (next_state, _) = cvar.wait_timeout(state, Duration::from_millis(50)).unwrap_or_else(std::sync::PoisonError::into_inner); + state = next_state; + } + + ctl.check().map_err(|_| IoError::Cancelled)?; + state.active = true; + state.owner = Some(current_thread); + + Ok(AdmissionGuard { is_top_level: true }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reentrant_admission_does_not_deadlock() { + let ctl = photocraft_raster::Interrupt::NONE; + let guard1 = acquire_decode_admission(&ctl).unwrap(); + assert!(guard1.is_top_level); + + // Nested admission on the same thread + let guard2 = acquire_decode_admission(&ctl).unwrap(); + assert!(!guard2.is_top_level); + + drop(guard2); + drop(guard1); + + // Can acquire again after release + let guard3 = acquire_decode_admission(&ctl).unwrap(); + assert!(guard3.is_top_level); + } +} diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 14038b10de..3e7d2aa906 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -192,6 +192,7 @@ impl PhotocraftApp { jpeg_options: None, dds_options: None, held_opens: Default::default(), + held_memory_prompts: Default::default(), held_preset_import: None, allow_close: false, stylus: Default::default(), diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 88a6e96dbe..0e527bf908 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -280,6 +280,7 @@ pub(crate) fn close_box(ui: &mut egui::Ui, rect: egui::Rect) -> bool { fn command_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, d: &Dialog, fields: &mut Fields, id: egui::Id, interactive: bool) -> Option { let mut outcome: Option = None; match d.kind { + _ if crate::open_prompt::owns_memory(fields) => crate::open_prompt::memory_body(ui, fields), _ if crate::open_prompt::owns_damage(fields) => crate::open_prompt::damage_body(ui, fields), _ if crate::guide_layout_ui::owns(fields) => outcome = crate::guide_layout_ui::body(app, ui, fields, interactive), _ if crate::brush_preset_dialog::owns(fields) => outcome = crate::brush_preset_dialog::body(app, ui, fields, interactive), @@ -520,6 +521,8 @@ fn button_row( fn ok_label(d: &Dialog) -> &'static str { if d.kind == DialogKind::NewDocument { tl!("Create") + } else if crate::open_prompt::owns_memory(&d.fields) { + tl!("Open anyway") } else if crate::open_prompt::owns_damage(&d.fields) { tl!("Continue") } else if crate::delete_layer_prompt::owns(&d.fields) { @@ -989,6 +992,7 @@ fn confirm_preflight(app: &mut PhotocraftApp, id: u64) -> Result<(), String> { /// and small editors are here, the rest in `confirm_command_more`. fn confirm_command(app: &mut PhotocraftApp, d: Dialog) -> Result { match d.kind { + _ if crate::open_prompt::owns_memory(&d.fields) => crate::open_prompt::answer_memory(app, true), _ if crate::open_prompt::owns_damage(&d.fields) => crate::open_prompt::answer_damage(app, true), _ if crate::guide_layout_ui::owns(&d.fields) => crate::guide_layout_ui::confirm(app, &d.fields), _ if crate::brush_preset_dialog::owns(&d.fields) => crate::brush_preset_dialog::confirm(app, &d.fields), @@ -1061,7 +1065,9 @@ pub fn cancel(app: &mut PhotocraftApp, id: u64) -> Result { app.ui.dialogs.retain(|child| child.fields.get("__dialog").and_then(Value::as_u64) != Some(id)); } // Cancel on a profile prompt opens neither its file nor the ones still to come. - if crate::open_prompt::owns_damage(&closed.fields) { + if crate::open_prompt::owns_memory(&closed.fields) { + crate::open_prompt::answer_memory(app, false)?; + } else if crate::open_prompt::owns_damage(&closed.fields) { crate::open_prompt::answer_damage(app, false)?; } else if crate::open_prompt::owns(&closed.fields) { crate::open_prompt::answer(app, None)?; diff --git a/crates/ui-egui/src/dialogs/open_prompt.rs b/crates/ui-egui/src/dialogs/open_prompt.rs index ef04f0b576..0c0f0fe845 100644 --- a/crates/ui-egui/src/dialogs/open_prompt.rs +++ b/crates/ui-egui/src/dialogs/open_prompt.rs @@ -41,9 +41,23 @@ pub struct Held { pub policy_applied: bool, } +/// Dialog field for the high-memory confirmation prompt. +pub const MEMORY_FIELD: &str = "__psdMemoryOpen"; + +/// An un-decoded PSD file whose preflight estimate requires user approval. +pub struct MemoryPrompt { + pub id: u64, + pub name: String, + pub path: Option, + pub slot: Option, + pub order: Option, + pub estimated: u64, + pub available: Option, +} + /// Whether a file that just finished reading has to wait: one earlier in the queue does. pub fn waiting(app: &PhotocraftApp) -> bool { - !app.held_opens.is_empty() + !app.held_opens.is_empty() || !app.held_memory_prompts.is_empty() } /// Opens `held` now, or queues it behind the file whose prompt is up. @@ -267,3 +281,92 @@ fn open(app: &mut PhotocraftApp, held: Held, action: Option<&str>) -> Result, + slot: Option, + order: Option, + estimated: u64, + available: Option, +) { + app.held_memory_prompts.push_back(MemoryPrompt { id, name, path, slot, order, estimated, available }); + advance_memory(app); +} + +pub fn owns_memory(fields: &serde_json::Map) -> bool { + fields.contains_key(MEMORY_FIELD) +} + +pub(crate) fn memory_body(ui: &mut egui::Ui, fields: &serde_json::Map) { + ui.label(fields.get("message").and_then(Value::as_str).unwrap_or_default()); +} + +pub fn advance_memory(app: &mut PhotocraftApp) { + let Some(front) = app.held_memory_prompts.front() else { return }; + if !app.ui.dialogs.iter().any(|d| owns_memory(&d.fields)) { + show_memory(app, front); + } +} + +fn show_memory(app: &mut PhotocraftApp, prompt: &MemoryPrompt) { + let est_gb = prompt.estimated as f64 / (1u64 << 30) as f64; + let avail_str = match prompt.available { + Some(a) => format!("{:.1} GB", a as f64 / (1u64 << 30) as f64), + None => "unknown".to_string(), + }; + let message = format!( + "{}\n\n{}", + crate::i18n::fmt( + tl!("Opening “{name}” requires an estimated {est:.1} GB of memory ({avail} available)."), + &[("name", &prompt.name), ("est", &format!("{est_gb:.1}")), ("avail", &avail_str)], + ), + tl!("Opening this document uses more memory and may slow down your computer or other running applications."), + ); + + let fields = [ + (MEMORY_FIELD.into(), json!(true)), + ("__label".into(), json!(tl!("High Memory Photoshop Document"))), + ("name".into(), json!(prompt.name)), + ("id".into(), json!(prompt.id)), + ("message".into(), json!(message)), + ("estimated".into(), json!(prompt.estimated)), + ("available".into(), json!(prompt.available)), + ] + .into_iter() + .collect(); + + app.ui.open_dialog(crate::state::DialogKind::Command, fields); +} + +pub(crate) fn answer_memory(app: &mut PhotocraftApp, accept: bool) -> Result { + let Some(front) = app.held_memory_prompts.pop_front() else { + return Err("no high-memory PSD is waiting for confirmation".into()); + }; + let id = front.id; + let name = front.name.clone(); + let r = app.run("file.approveMemory", json!({"id": id, "approve": accept})).map_err(|e| format!("{}: {e}", name))?; + + if accept { + if let Some(job) = r.get("job").and_then(Value::as_u64) { + app.jobs.opens.push(crate::jobs_ui::OpenTab { + job: photocraft_engine::jobs::JobId(job), + name: name.clone(), + path: front.path, + slot: front.slot, + order: front.order, + }); + app.jobs.focus = Some(photocraft_engine::jobs::JobId(job)); + app.ui.chrome.home = None; + app.ui.status = crate::i18n::fmt(tl!("Opening {name}…"), &[("name", &name)]); + app.ui.status_error = false; + } + } else { + app.ui.status = crate::i18n::fmt(tl!("Cancelled opening {name}"), &[("name", &name)]); + app.ui.status_error = false; + } + advance_memory(app); + Ok(r) +} diff --git a/crates/ui-egui/src/jobs_ui.rs b/crates/ui-egui/src/jobs_ui.rs index 00fce3da53..584a4cad8c 100644 --- a/crates/ui-egui/src/jobs_ui.rs +++ b/crates/ui-egui/src/jobs_ui.rs @@ -250,6 +250,12 @@ fn batch_slot(jobs: &mut JobsUi, order: OpenOrder) -> Option { /// A background open's result (`{document, warnings, color}`) for `tab`, already taken out of /// [`JobsUi::opens`]: the document goes to the tab's slot, or to its place in its set. pub(crate) fn finish_open_tab(app: &mut PhotocraftApp, tab: &OpenTab, v: &Value) -> Result<(), String> { + if let Some(preflight_id) = v.get("pendingPreflight").and_then(Value::as_u64) { + let estimated = v.get("estimated").and_then(Value::as_u64).unwrap_or(0); + let available = v.get("availableRam").and_then(Value::as_u64); + crate::open_prompt::arrive_memory(app, preflight_id, tab.name.clone(), tab.path.clone(), tab.slot, tab.order, estimated, available); + return Ok(()); + } let slot = tab.slot.or_else(|| tab.order.and_then(|o| batch_slot(&mut app.jobs, o))); let r = finish_open(app, &tab.name, tab.path.as_deref(), v, slot); end_batch(&mut app.jobs); @@ -548,6 +554,12 @@ fn on_event(app: &mut PhotocraftApp, ctx: &egui::Context, e: JobEvent) { /// What [`crate::PhotocraftApp::open_bytes`] does after decoding, for a background open's result /// (`{document, docId, warnings, color}` or `{held, …}`). fn finish_open(app: &mut PhotocraftApp, name: &str, path: Option<&str>, v: &Value, slot: Option) -> Result<(), String> { + if let Some(preflight_id) = v.get("pendingPreflight").and_then(Value::as_u64) { + let estimated = v.get("estimated").and_then(Value::as_u64).unwrap_or(0); + let available = v.get("availableRam").and_then(Value::as_u64); + crate::open_prompt::arrive_memory(app, preflight_id, name.to_string(), path.map(str::to_string), slot, None, estimated, available); + return Ok(()); + } let warnings: Vec = v.get("warnings").and_then(Value::as_array).map(|a| a.iter().filter_map(|w| w.as_str().map(str::to_string)).collect()).unwrap_or_default(); let held = |doc, read_only, question, policy_applied| crate::open_prompt::Held { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e17b2bc80d..2a7af1fc7b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -687,6 +687,8 @@ pub struct PhotocraftApp { /// Files read but not opened yet: the first waits on its profile prompt, the rest behind it /// (see `open_prompt`). pub(crate) held_opens: std::collections::VecDeque, + /// PSD files whose memory preflight requires user approval before pixel decoding (see `open_prompt`). + pub(crate) held_memory_prompts: std::collections::VecDeque, /// A brush or gradient file waiting on the group-name prompt (see `preset_group_prompt`). pub(crate) held_preset_import: Option, /// Set once the user has agreed to quit, so the resulting close request goes through. diff --git a/crates/ui-egui/src/psd_open_tests.rs b/crates/ui-egui/src/psd_open_tests.rs index 1c40f898bd..728817a4fe 100644 --- a/crates/ui-egui/src/psd_open_tests.rs +++ b/crates/ui-egui/src/psd_open_tests.rs @@ -197,3 +197,32 @@ fn background_damage_only_open_does_not_create_a_spurious_profile_question() { assert_eq!(app.session.documents().len(), 1); assert!(app.ui.dialogs.is_empty(), "color:null is not a profile question"); } + +#[test] +fn memory_confirmation_dialog_and_cancel_lifecycle() { + let mut app = app(); + let gb = 1u64 << 30; + + // Simulate memory prompt arrival: + let preflight_id = app.session.store_pending_preflight(photocraft_engine::jobs::PendingPsdPreflight { + id: 0, + name: "high_mem.psd".into(), + source: photocraft_engine::jobs::OpenSource::Bytes(std::sync::Arc::new(vec![0; 16])), + fingerprint: photocraft_engine::jobs::SourceFingerprint::of(&photocraft_engine::jobs::OpenSource::Bytes(std::sync::Arc::new(vec![0; 16]))).unwrap(), + required: 7 * gb, + soft_limit: 4 * gb, + hard_limit: 8 * gb, + available_ram: Some(10 * gb), + confirm_damage: true, + }); + + crate::open_prompt::arrive_memory(&mut app, preflight_id, "high_mem.psd".into(), None, None, None, 7 * gb, Some(10 * gb)); + assert_eq!(app.ui.dialogs.len(), 1); + assert!(crate::open_prompt::owns_memory(&app.ui.dialogs[0].fields)); + let dlg_id = app.ui.dialogs[0].id; + + // Cancel via dialog cancel: + assert_eq!(call(&mut app, "ui.dialog.cancel", json!({"dialog": dlg_id}))["ok"], true); + assert!(app.ui.dialogs.is_empty()); + assert!(app.session.take_pending_preflight(preflight_id).is_none(), "pending preflight dropped on cancel"); +} diff --git a/docs/development.md b/docs/development.md index 277d20b954..7a2744eccc 100644 --- a/docs/development.md +++ b/docs/development.md @@ -663,12 +663,22 @@ before merged-image validation or decoding. The conservative estimate sums retai storage (including padding and negative offsets) and the largest decode/conversion workspace. It does not credit uniform-tile sharing or pruning, so some files that could fit after decoding are refused. Valid off-canvas pixels are preserved; oversized layers are rejected rather than -clipped. Defaults are 2 GiB on native targets and 1 GiB on wasm. Applications can lower the -allowance with `ImportOptions::psd_max_pixel_bytes`; values above the default are capped. -The source-only convenience API uses the default; `import_file_for_open_with` honours options. +clipped. -An over-budget ordinary or interactive import returns `IoError::PsdImportBudget` with the -estimated and allowed byte counts; damaged-file recovery never bypasses that error. The legacy +Native desktop platforms use an adaptive memory policy evaluated against currently available +physical RAM (excluding swap) and system commit headroom: +- **Hard budget (H)**: $\min(75\% ext{ RAM}, ext{RAM} - 1 ext{ GiB}, ext{commit headroom} - 512 ext{ MiB}, ext{address-space limits})$. +- **Soft budget (S)**: $\min(50\% ext{ RAM}, 8 ext{ GiB}, H)$. +- Files requiring $\le S$ are admitted automatically. +- Files requiring $S < E \le H$ prompt for explicit one-shot user confirmation ("Open anyway") in the UI or require `approveHighMemory: true` for headless callers. +- Files requiring $> H$ are refused and cannot be overridden. +- Fallback allowance when usable memory is unknown is 2 GiB with no confirmation override; WebAssembly uses a fixed 1 GiB limit. +- Applications can lower the admission threshold with `ImportOptions::psd_max_pixel_bytes`; explicit caller limits may lower admission, never bypass the hard boundary. +- Concurrent PSD decodes are serialized using a process-wide, cancellation-aware admission gate, with nested decodes sharing the enclosing allowance. + +An import exceeding the hard budget returns `IoError::PsdImportBudget` with the estimated +and allowed byte counts; imports requiring confirmation return `IoError::PsdApprovalRequired` +when unapproved. Damaged-file recovery never bypasses these memory guards. The legacy `psd_to_document` conversion APIs cannot return an error, so they return an empty document and an explicit warning on preflight refusal. Layer and mask pixel allocation failures also fail ordinary imports instead of opening partial content. From 568bf4094369b19f2c56e144d1d4c4f3e692ef3d Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 2 Nov 2026 12:51:07 +0100 Subject: [PATCH 158/216] Integration: a Filter Gallery effect's recorded event exports again next to #10628's mask and merge events (#10632) #10628 refuses an `_atnEvent` that is not a string; #10632 stores the effect's own event there as an object, so every gallery step loaded from its own event failed Save Actions. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/atn_cmds/events.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/atn_cmds/events.rs b/crates/engine/src/cmd/atn_cmds/events.rs index 05f73ccb15..fd11de89f3 100644 --- a/crates/engine/src/cmd/atn_cmds/events.rs +++ b/crates/engine/src/cmd/atn_cmds/events.rs @@ -905,7 +905,10 @@ pub(super) fn event_and_descriptor(id: &str, params: &Value) -> Option<(&'static { return None; } - if let Some(origin) = params.get("_atnEvent") { + // A string names the recorded event of a mask or merge step; an object is a Filter Gallery + // effect's own event, which `export_item` checks (`steps_gallery::recorded_event`). + let gallery_event = id.starts_with("filter.gallery.") && params.get("_atnEvent").is_some_and(Value::is_object); + if let Some(origin) = params.get("_atnEvent").filter(|_| !gallery_event) { let origin = origin.as_str()?; if !matches!((id, origin), ("layer.layerMask.delete", "RmvL") | ("layer.mergeLayers", "MrgL")) { return None; From 8caea149c117431f1ec0415c8e095776d46c82b0 Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 2 Nov 2026 13:03:44 +0100 Subject: [PATCH 159/216] Integration: regenerate docs/atn.md for the batch (144 supported events, settings each PR keeps) Supported table read from the batch's events.rs. The PRs' own sections (settings and command options retained, Render defaults, steps kept disabled) are kept; the known-gaps tables that #10702 and #10704 close are gone. Co-Authored-By: Claude Opus 5.5 --- docs/atn.md | 660 +++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 580 insertions(+), 80 deletions(-) diff --git a/docs/atn.md b/docs/atn.md index 389cc40261..99f883fc98 100644 --- a/docs/atn.md +++ b/docs/atn.md @@ -13,10 +13,12 @@ it back unchanged. Playback stops at an enabled kept step with its name, so an a work. Kept steps with no effect on the document (tool selections and the like) are imported switched off, so playback skips them. -This page has two parts: the events PhotoCraft maps today, and a measured reference for the events it -does not map yet, with everything needed to implement them without the reference application. +This page covers the events PhotoCraft maps today, the settings some of them still lose, how steps name +layers and documents, newer and older events and files, and a measured reference for the events it does not +map yet, with everything needed to implement them without +the reference application. -## Supported events (120) +## Supported events (144) | Code | String ID | Label | PhotoCraft command(s) | |---|---|---|---| @@ -27,19 +29,22 @@ does not map yet, with everything needed to implement them without the reference | (string ID only) | applyLocking | Apply Locking | `layer.lockLayers` | | (string ID only) | assignProfile | Assign Profile | `edit.assignProfile` | | (string ID only) | autoColor | Auto Color | `image.autoColor` | +| `Avrg` | Avrg | Average | `filter.blur.average` | | (string ID only) | blackAndWhite | Black & White | `image.adjustments.blackWhite` | | `Blr ` | blur | Blur | `filter.blur.blur` | | `BlrM` | blurMore | Blur More | `filter.blur.blurMore` | | `Brdr` | border | Border | `select.modify.border` | | (string ID only) | boxblur | Box Blur | `filter.blur.boxBlur` | | `BrgC` | brightnessEvent | Brightness/Contrast | `image.adjustments.brightnessContrast` | +| (string ID only) | Adobe Camera Raw Filter | Camera Raw Filter | `filter.cameraRaw` | | `CnvS` | canvasSize | Canvas Size | `image.canvasSize` | | `ChnM` | channelMixer | Channel Mixer | `image.adjustments.channelMixer` | | (string ID only) | clearEvent | Clear | `select.editInQuickMaskMode`, `history.clear` | | `Cls ` | close | Close | `file.close` | +| `Clds` | clouds | Clouds | `filter.render.clouds` | | (string ID only) | collapseAllGroupsEvent | Collapse All Groups | `layer.setExpanded` | -| `ClrR` | colorRange | Color Range | `select.colorRange` | | `ClrB` | colorBalance | Color Balance | `image.adjustments.colorBalance` | +| `ClrR` | colorRange | Color Range | `select.colorRange` | | `Cntc` | contract | Contract | `select.modify.contract` | | `CnvM` | convertMode | Convert Mode | `image.mode.rgb`, `image.mode.grayscale`, `image.mode.cmyk`, `image.mode.lab`, `image.mode.multichannel`, `image.mode.bits8`, `image.mode.bits16`, `image.mode.bits32`, `image.mode.bitmap` | | (string ID only) | convertToProfile | Convert to Profile | `edit.convertToProfile` | @@ -51,12 +56,15 @@ does not map yet, with everything needed to implement them without the reference | `Crop` | crop | Crop | `image.crop` | | `Crst` | crystallize | Crystallize | `filter.pixelate.crystallize` | | `Crvs` | curves | Curves | `image.adjustments.curves` | +| `Cstm` | custom | Custom | `filter.other.custom` | | `cut ` | cut | Cut | `edit.cut` | +| `Dntr` | deInterlace | De-Interlace | `filter.video.deInterlace` | | `Dfrg` | defringe | Defringe | `layer.matting.defringe` | | `Dlt ` | delete | Delete | `layer.delete`, `edit.clear`, `layer.layerMask.delete`, `layer.smartFilter.deleteFilterMask` | | `Dstt` | desaturate | Desaturate | `image.adjustments.desaturate` | | (string ID only) | selectNoLayers | Deselect Layers | `select.deselectLayers` | | `Dspc` | despeckle | Despeckle | `filter.noise.despeckle` | +| `DfrC` | differenceClouds | Difference Clouds | `filter.render.differenceClouds` | | `Dfs ` | diffuse | Diffuse | `filter.stylize.diffuse` | | `Dplc` | duplicate | Duplicate | `layer.duplicate`, `image.duplicate` | | `DstS` | dustAndScratches | Dust & Scratches | `filter.noise.dustAndScratches` | @@ -68,19 +76,18 @@ does not map yet, with everything needed to implement them without the reference | `Expr` | export | Export | `file.export.saveForWebLegacy` | | `Exps` | exposure | Exposure | `image.adjustments.exposure` | | `Fthr` | feather | Feather | `select.modify.feather` | +| `Fbrs` | | Fibers | `filter.render.fibers` | | `Fl ` | fill | Fill | `edit.fill`, `paint.bucket` | -| `GEfc` | filterGallery | Filter Gallery | `filter.gallery.spatter` | +| `GEfc` | filterGallery | Filter Gallery | `filter.gallery.accentedEdges`, `filter.gallery.angledStrokes`, `filter.gallery.basRelief`, `filter.gallery.chalkCharcoal`, `filter.gallery.charcoal`, `filter.gallery.chrome`, `filter.gallery.coloredPencil`, `filter.gallery.conteCrayon`, `filter.gallery.craquelure`, `filter.gallery.crosshatch`, `filter.gallery.cutout`, `filter.gallery.darkStrokes`, `filter.gallery.diffuseGlow`, `filter.gallery.dryBrush`, `filter.gallery.filmGrain`, `filter.gallery.fresco`, `filter.gallery.glass`, `filter.gallery.glowingEdges`, `filter.gallery.graphicPen`, `filter.gallery.halftonePattern`, `filter.gallery.inkOutlines`, `filter.gallery.mosaicTiles`, `filter.gallery.neonGlow`, `filter.gallery.notePaper`, `filter.gallery.oceanRipple`, `filter.gallery.paintDaubs`, `filter.gallery.paletteKnife`, `filter.gallery.patchwork`, `filter.gallery.photocopy`, `filter.gallery.plaster`, `filter.gallery.plasticWrap`, `filter.gallery.posterEdges`, `filter.gallery.reticulation`, `filter.gallery.roughPastels`, `filter.gallery.smudgeStick`, `filter.gallery.spatter`, `filter.gallery.sponge`, `filter.gallery.sprayedStrokes`, `filter.gallery.stainedGlass`, `filter.gallery.stamp`, `filter.gallery.sumiE`, `filter.gallery.texturizer`, `filter.gallery.tornEdges`, `filter.gallery.underpainting`, `filter.gallery.waterPaper`, `filter.gallery.watercolor`, `filter.gallery.grain` | | `FltI` | flattenImage | Flatten Image | `layer.flattenImage` | | `Flip` | flip | Flip | `image.imageRotation.flipCanvasHorizontal`, `image.imageRotation.flipCanvasVertical`, `edit.transform.flipHorizontal`, `edit.transform.flipVertical` | -| `Clds` | clouds | Clouds | `filter.render.clouds` | -| `DfrC` | differenceClouds | Difference Clouds | `filter.render.differenceClouds` | -| `Fbrs` | (code only) | Fibers | `filter.render.fibers` | -| `LnsF` | lensFlare | Lens Flare | `filter.render.lensFlare` | +| (string ID only) | focusMask | Focus Area | `select.focusArea` | | `GsnB` | gaussianBlur | Gaussian Blur | `filter.blur.gaussianBlur` | -| `GdMp` | gradientMapEvent | Gradient Map | `image.adjustments.gradientMap` | +| (string ID only) | gradientMapEvent | Gradient Map | `image.adjustments.gradientMap` | | `Grow` | grow | Grow | `select.grow` | | `Hd ` | hide | Hide | `layer.hideLayers` | | `HghP` | highPass | High Pass | `filter.other.highPass` | +| `HsbP` | HsbP | HSB/HSL | `filter.other.hsbHsl` | | `HStr` | hueSaturation | Hue/Saturation | `image.adjustments.hueSaturation` | | (string ID only) | conditional | If | `actions.conditional` | | `ImgS` | imageSize | Image Size | `image.imageSize` | @@ -88,27 +95,38 @@ does not map yet, with everything needed to implement them without the reference | `Invr` | invert | Invert | `image.adjustments.invert` | | `CpTL` | copyToLayer | Layer Via Copy | `layer.new.layerViaCopy` | | `CtTL` | cutToLayer | Layer Via Cut | `layer.new.layerViaCut` | +| `LnsF` | lensFlare | Lens Flare | `filter.render.lensFlare` | | `Lvls` | levels | Levels | `image.adjustments.levels`, `image.autoTone`, `image.autoContrast` | | `Mk ` | make | Make | `layer.new.layer`, `layer.new.group`, `layer.groupLayers`, `layer.duplicate`, `layer.new.layerViaCopy`, `layer.new.layerViaCut`, `layer.newFillLayer.solidColor`, `layer.newFillLayer.pattern`, `layer.newAdjustmentLayer.levels`, `layer.newAdjustmentLayer.curves`, `layer.newAdjustmentLayer.hueSaturation`, `layer.newAdjustmentLayer.brightnessContrast`, `layer.newAdjustmentLayer.threshold`, `layer.newAdjustmentLayer.posterize`, `layer.newAdjustmentLayer.invert`, `layer.newAdjustmentLayer.colorBalance`, `layer.newAdjustmentLayer.vibrance`, `layer.newAdjustmentLayer.exposure`, `layer.newAdjustmentLayer.photoFilter`, `layer.newAdjustmentLayer.blackWhite`, `layer.newAdjustmentLayer.channelMixer`, `layer.newAdjustmentLayer.selectiveColor`, `layer.newAdjustmentLayer.gradientMap`, `layer.layerMask.revealAll`, `layer.layerMask.hideAll`, `layer.layerMask.revealSelection`, `layer.layerMask.hideSelection`, `file.new`, `file.newFromClipboard`, `history.newSnapshot` | +| (string ID only) | animationFramesFromLayers | Make Frames from Layers | | +| (string ID only) | matchColor | Match Color | `image.adjustments.matchColor` | | `Mxm ` | maximum | Maximum | `filter.other.maximum` | | `Mdn ` | median | Median | `filter.noise.median` | | `Mrg2` | mergeLayersNew | Merge Layers | `layer.mergeLayers` | +| `MSpt` | mergeSpotChannel | Merge Spot Channel | `channel.mergeSpot` | | `MrgV` | mergeVisible | Merge Visible | `layer.mergeVisible`, `layer.stampVisible` | | `Mnm ` | minimum | Minimum | `filter.other.minimum` | | `MtnB` | motionBlur | Motion Blur | `filter.blur.motionBlur` | -| `move` | move | Move | `layer.translate`, `layer.arrange.bringToFront`, `layer.arrange.sendToBack`, `layer.arrange.bringForward`, `layer.arrange.sendBackward` | +| `move` | move | Move | `layer.translate`, `layer.moveTo`, `layer.arrange.bringToFront`, `layer.arrange.sendToBack`, `layer.arrange.bringForward`, `layer.arrange.sendBackward` | +| `NTSC` | NTSC | NTSC Colors | `filter.video.ntscColors` | | `Ofst` | offset | Offset | `filter.other.offset` | +| (string ID only) | oilPaint | Oil Paint | `filter.stylize.oilPaint` | | `Opn ` | open | Open | `file.openAs` | | `past` | paste | Paste | `edit.paste`, `edit.pasteSpecial.pasteInPlace` | | (string ID only) | pasteEffects | Paste Layer Style | `layer.layerStyle.pasteLayerStyle` | | (string ID only) | photoFilter | Photo Filter | `image.adjustments.photoFilter` | +| `Pnch` | pinch | Pinch | `filter.distort.pinch` | | `Plc ` | placeEvent | Place | `file.placeEmbedded`, `file.placeLinked` | | `Ply ` | play | Play | `actions.play` | +| `Plr ` | polar | Polar Coordinates | `filter.distort.polarCoordinates` | | `Pstr` | posterize | Posterize | `image.adjustments.posterize` | | `Prge` | purge | Purge | `edit.purge.all`, `edit.purge.clipboard`, `edit.purge.histories`, `edit.purge.undo` | | `RdlB` | radialBlur | Radial Blur | `filter.blur.radialBlur` | | (string ID only) | rasterizeLayer | Rasterize | `layer.rasterize.layer` | | `Ungr` | ungroup | Release Clipping Mask | `layer.releaseClippingMask` | +| (string ID only) | removeBackground | Remove Background | `layer.removeBackground` | +| `RmvB` | removeBlackMatte | Remove Black Matte | `layer.matting.removeBlackMatte` | +| `RmvW` | removeWhiteMatte | Remove White Matte | `layer.matting.removeWhiteMatte` | | `Rset` | reset | Reset | `tools.defaultColors` | | `RvlA` | revealAll | Reveal All | `image.revealAll` | | `Rvrt` | revert | Revert | `file.revert` | @@ -126,74 +144,87 @@ does not map yet, with everything needed to implement them without the reference | `Shrp` | sharpen | Sharpen | `filter.sharpen.sharpen` | | `ShrE` | sharpenEdges | Sharpen Edges | `filter.sharpen.sharpenEdges` | | `ShrM` | sharpenMore | Sharpen More | `filter.sharpen.sharpenMore` | -| `Shw ` | show | Show | `layer.showLayers` | +| `Shw ` | show | Show | `layer.showLayers`, `layer.showOnly` | | `Smlr` | similar | Similar | `select.similar` | +| (string ID only) | selectSky | Sky | `select.sky` | | `SmrB` | smartBlur | Smart Blur | `filter.blur.smartBlur` | +| (string ID only) | smartSharpen | Smart Sharpen | `filter.sharpen.smartSharpen` | | `Smth` | smoothness | Smooth | `select.modify.smooth` | -| `Stop` | stop | Stop | `actions.stopMessage` | +| `Sphr` | spherize | Spherize | `filter.distort.spherize` | +| `SplC` | splitChannels | Split Channels | `channel.split` | +| `Stop` | stop | Stop | `STOP_MESSAGE` | | `Strk` | stroke | Stroke | `edit.stroke` | | (string ID only) | surfaceBlur | Surface Blur | `filter.blur.surfaceBlur` | | `Thrs` | threshold | Threshold | `image.adjustments.threshold` | | `Trnf` | transform | Transform | `edit.transform`, `select.transformSelection` | +| `Trap` | trap | Trap | `image.trap` | | (string ID only) | trim | Trim | `image.trim` | +| `Twrl` | twirl | Twirl | `filter.distort.twirl` | | (string ID only) | ungroupLayersEvent | Ungroup Layers | `layer.ungroupLayers` | | `UnsM` | unsharpMask | Unsharp Mask | `filter.sharpen.unsharpMask` | | (string ID only) | updatePlacedLayer | Update Smart Objects | `layer.smartObjects.updateModifiedContent` | -| `Vbrn` | vibrance | Vibrance | `image.adjustments.vibrance` | +| (string ID only) | vibrance | Vibrance | `image.adjustments.vibrance` | +| `Wave` | wave | Wave | `filter.distort.wave` | +| `ZgZg` | zigZag | ZigZag | `filter.distort.zigZag` | -### Group B: The Render filters Clouds, Difference Clouds, Fibers and Lens Flare +### Settings retained by mapped events -These four events load, play and save through the existing Render commands. Lighting Effects still needs a recorded step through its dialog. +- Transform keeps its recorded `FTcs` reference point. Show with `TglO` true maps to + `layer.showOnly`; repeating it restores the other layers' visibility. +- Make keeps top-level group names, new-layer clipping (`Usng.Grup`) and adjustment colour labels + (`Usng.Clr `). Layer via Copy / Cut keeps names, blend modes, opacity and colour labels. +- Loading another layer's transparency keeps its source reference. Background property references + remain independent of translated names; ordinary layer-name references remain names. +- Close without `Svng` preserves the unanswered save choice. Headless playback refuses to discard a + changed document. Native `file.close` still defaults to discard; `save: "ask"` opts into this policy. +- Photo Filter converts recorded Lab colours through `photocraft-cms`. +- Direct Vibrance and Gradient Map save as the string events `vibrance` and `gradientMapEvent`. + Stroke Outside uses `StrL.Otsd`, group Pass Through uses `BlnM.passThrough`, Canvas Size extension + colours use `FrgC` and `Clr `, and Absolute Colorimetric intent uses `Inte.AClr`. +- Make adjustment layers keeps surrounding `Usng.Opct` as layer opacity, independently of the + adjustment settings. Recorded percentages use eight-bit layer-opacity storage at every document + depth: 70 % becomes 179/255. Native creation accepts an optional 0..1 `opacity` fraction. + A default adjustment with only layer opacity still saves as a default preset. +- Document offsets wrap in opening order, independently of dragged tabs. An ATN zero offset remains + a kept step; the native command retains its existing zero-offset no-op. +- Convert Mode saves `T ` as a class. Layer-retaining RGB, grayscale, CMYK and Lab conversions + read and write explicit false `Fltt` and `Rstr` flags. True or malformed flags remain kept steps; + depth, bitmap and multichannel recordings with these flags also remain kept. +- Canvas Size preserves percentage units and resolves them against the playback document. + Percentages are absolute sizes; mixed units and relative percentages remain kept steps. +- Image Size preserves points (`#Rlt`) and resolves them using the playback document's resolution. + Mixed units and unsupported physical-size/resolution combinations remain kept steps. + Without resampling, native Image Size still changes only resolution. -#### Clouds +Shape creation (`contentLayer`) with geometry or stroke settings remains a kept step. Save preserves +its descriptor, and enabled playback stops instead of creating a canvas-wide solid fill. Editable +shape creation is not mapped yet. -- Event: `Clds` / `clouds` -- PhotoCraft command: `filter.render.clouds` -- Uses the current foreground and background colours; the step itself carries no colours. +The Vibrance event and adjustment-opacity handling incorporate [@bommerts' contribution in #8659](https://github.com/storytold/photocraft/pull/8659). -No parameters: the step carries an empty descriptor. +### Command options retained by mapped events -#### Difference Clouds +- Image Size preserves `scaleStyles`. Native calls default to true; an absent ATN flag means + false (the reference application plays both alike), so Save writes the key only when it is true. + Crop and resolution-only resizing retain their existing behavior. +- Convert to Profile preserves `Dthr`, `Fltt` and `rasterizePlaced`, defaulting to false. + `sdwM` is written as the reference application records it (-1); other values and malformed + flags remain kept steps. Absolute intent writes `AClr`, with `AbsC` still accepted on import. +- Content-Aware Fill preserves colour, quarter-turn rotation, scale and mirror adaptation. + Existing native defaults remain unchanged. +- Paste Layer Style preserves `allowPasteFXOnLayerSet`. False skips selected groups without + applying their styles to children; a selection with no eligible targets fails without editing. +- Paste preserves `As ` path or shape using the original bounded SVG source. Unsupported + geometry fails instead of silently rasterizing. `As Pxel` plays as an ordinary Paste (or Paste + in Place), as does omission. -- Event: `DfrC` / `differenceClouds` -- PhotoCraft command: `filter.render.differenceClouds` -- Same as Clouds, blended with Difference. +Generated fixtures verify these native options and their load/save/playback behavior. Conversion order, +SVG rasterization and placement were not compared with the reference application. -No parameters: the step carries an empty descriptor. - -#### Fibers - -- Event: `Fbrs` (no string ID) -- PhotoCraft command: `filter.render.fibers` -- `randomSeed` makes playback reproduce the recorded result; keep it. - -| Key | String ID | Type | Unit | Default | Allowed | -|---|---|---|---|---|---| -| `Vrnc` | (none) | integer | | 16 | not clamped in the step (values are returned unchanged); limits pending, see "Events that need a step recorded through their dialog" | -| `Strg` | strengthPlugin | integer | | 4 | not clamped in the step (values are returned unchanged); limits pending, see "Events that need a step recorded through their dialog" | -| `RndS` | randomSeed | integer | | random | any 32-bit integer; a new random value per recording | - -#### Lens Flare - -- Event: `LnsF` / `lensFlare` -- PhotoCraft command: `filter.render.lensFlare` -- Lens types: `Zm ` 50-300 mm Zoom, `Nkn ` 35 mm Prime, `Nkn1` 105 mm Prime, `PnVs` Movie Prime. - -| Key | String ID | Type | Unit | Default | Allowed | -|---|---|---|---|---|---| -| `Brgh` | brightness | integer | | 100 | 10 .. 300 (out-of-range values are clamped) | -| `FlrC` | flareCenter | object | | obj 'Pnt ' point | `Hrzn` / `Vrtc` doubles, 0.4 / 0.4 by default; not clamped: (5, -5) is kept as given and plays | -| `Lns ` | lens | enumerated | | `Zm ` zoom | `Nkn ` nikon, `Nkn1` nikon105, `PnVs` panaVision, `Zm ` zoom | - -Nested values as recorded (defaults): -``` - 'Hrzn' horizontal : DOUBLETYPE = 0.40000000596046 - 'Vrtc' vertical : DOUBLETYPE = 0.40000000596046 -``` +### Render filters Native commands without an explicit Lens Flare center still use 0.5 / 0.5. Imported steps materialize the measured center above. A missing Fibers seed is captured once at import and preserved for later playback and export. Native color overrides and other settings without ATN fields are refused during strict export. - ## Recognized steps kept disabled These recognized events have no playable command. They load as kept steps switched @@ -204,12 +235,440 @@ supported-event count and the measured unmapped reference. |---|---| | animationFramesFromLayers | Make Frames from Layers | -## Event reference: events not mapped yet +## Known gaps in mapped events -Each entry below is an event the reference application records that PhotoCraft does not map yet: its -4-character code and string ID, the PhotoCraft command it should map to, and every key the step carries, -in the order it is recorded, with type, unit, factory default and allowed values. When an event is -implemented, move it to the table above in the same change. +A mapped event can still lose a setting. Every mapped event was checked on 2026-11-01: the 5 296 steps of +the `.atn` corpus (sets the reference application recorded) and factory descriptors measured in the +reference application went through `actions.load` and `actions.save`, and each saved descriptor was compared +with the original. Then every step PhotoCraft saves for the cases in +`every_mapped_command_exports_and_imports_back` was played in the reference application: 128 of 151 ran. +Three failed on a wrong code (Vibrance, Gradient Map, Stroke Outside); the others needed a document the test +did not have (a mask, a group, a named pattern or channel). Checking every code the mapping writes found +three more (Pass Through, canvas extension colour, Absolute Colorimetric). The steps below change what a +step does. Every one of them is fixed now: the sections "Settings retained by mapped events" and +"Command options retained by mapped events" above say how each setting is kept. What remains are +differences that do not change what a step does. + +### Differences that do not change the result + +- Value types: `long` and unit values are interchangeable for the reference application (Motion Blur + `Angl`, Dust & Scratches `Rds `, Apply Image `Ofst`: same pixels either way); Apply Image's `With` + object plays the same with class `null`. +- Save writes the 4-character event code where the file had the string ID, writes `null` as the + top-level class (the application writes the event code), and writes an empty descriptor for steps that + had none. +- Ids of the recording session (`LyrI`, `DocI`, `Idnt`, `layerSectionStart`, `layerSectionEnd`, `Vrsn`, + `forceNotify`) and default flags (`MkVs` false, `Adjs` false) are not written back. +- An adjustment layer's values equal to the defaults are not written back (other values are). +- Colours are kept as 8-bit values (51.0031 becomes 51); gradient names become "Custom". +- Levels and Curves write the composite as `Chnl.Cmps` (the application skips `Ordn.Trgt`, #8543). + +## Reference forms + +How the reference application resolves a layer or document a step names, measured on 2026-11-01 by +playing `select` and `move` steps with each form (documents with and without a Background, a group, +layers above and below it). A recorded step can use any of these; PhotoCraft reads names and the target +today. + +### Layers + +The stack is counted from the bottom and includes a hidden "group end" entry below each group's +children. Example stack, bottom to top: Background, L1, L2, *group end*, Gchild, G (the group), L3. + +| Form | Meaning | Measured | +|---|---|---| +| By name (`name`) | The layer with that name (the first match). | (supported) | +| Target (`Ordn.Trgt`) | The active layer. | (supported) | +| By index (`indx`) | Position in the stack above. With a Background, the Background is 0 and L1 is 1. Without one, the bottom layer is 1, and 0 fails. The group end is a position but cannot be selected: the step leaves the selection unchanged. A number past the top fails. | with Background: 0 Background, 1 L1, 2 L2, 3 (group end: no change), 4 Gchild, 5 G, 6 L3, 7 fails | +| By id (`Idnt`) | The layer id of the document the step was recorded in (`LyrI`, never reused in that document). In another document it names an unrelated layer or none. | id 3 = L2 with a Background, L1 without | +| By offset (`rele`) | Not supported for layers: every offset fails. | -3..3 all fail | +| `Ordn.Frwr` / `Ordn.Bckw` | The next layer up / down in the stack, entering groups (group ends are skipped). | from L2: Frwr Gchild, Bckw L1 | +| `Ordn.Frnt` / `Ordn.Back` | The top layer / the bottom layer (the Background when there is one). | L3 / Background | + +**Move to an index** (`move`, `T` = layer by index `m`): counted as above on the stack before the move. +Moving down, the layer lands at index `m`; moving up, at `m - 1` (directly below the layer that had index +`m`). With a Background, `m` = 0 fails; without one, `m` = 0 sends the layer to the bottom. Moving to an +index inside a group puts the layer into that group. + +| m (with Background, moving L2 at index 2) | 0 | 1 | 2, 3 | 4 | 5 | 6 | +|---|---|---|---|---|---|---| +| Stack after | fails | Bg, L2, L1, ... | unchanged | Bg, L1, *end*, L2, Gchild, G, L3 (L2 inside G, bottom) | ... *end*, Gchild, L2, G (inside G, top) | ... G, L2, L3 | + +### Documents + +| Form | Meaning | Measured (D1, D2, D3 opened in that order, D2 active) | +|---|---|---| +| By offset (`rele`) | That many documents after (positive) or before (negative) the active one, in opening order, **wrapping around** at the ends. 0 fails. | -1 D1, +1 D3, -2 D3, +2 D1 | +| By index (`indx`) | 1-based, in opening order; 0 fails. | 1 D1, 2 D2, 3 D3 | + +PhotoCraft's `document.activate {"offset"}` refuses to wrap (#7103); the reference application wraps. + +### Layer comps + +Measured with three comps (CompA, CompB, CompC) that each show a different layer, applied with +`applyComp` and every reference form. The document's `compsList` gives each comp's `title`, `itemIndex` +(1-based), `ID` (long) and `selection`. + +| Form | Result | +|---|---| +| By name (`name`) | Applies that comp. An unknown name fails. | +| By index (`indx`) | 1-based, in panel order. 0, negative and past-the-end indexes are **accepted and change nothing**. | +| By ID (`Idnt`) | Applies the comp with that `ID`. An unknown ID fails. | +| By offset (`rele`) | Always fails, for every offset. | +| Ordinal (`Ordn`) `Trgt`, `Nxt `, `Prvs` | Applies the current (last applied) comp; `Nxt ` and `Prvs` do not move. | +| Ordinal `Frst`, `Lst `, `Al ` | Applies the **first** comp (also for `Lst `). | + +Delete (`Dlt `) by index uses the same 1-based numbering. PhotoCraft's `applyComp` import reads only the +name form today; the index, ID and ordinal forms load as kept steps. + +## Newer events (string ID only) + +The events the reference application records for the menu items PhotoCraft has that are neither mapped +nor in the reference below: 63 candidates from PhotoCraft's menu catalogue, each run in the reference +application without a dialog on a document prepared for it (layers, a selection, a smart object, a type +layer, a layer style or a group) on 2026-11-01. 27 more menu items record no event of their own (puppet +warp and warp splits, combine shapes, scripts such as Load Files into Stack and Statistics, artboard and +video export, missing-font commands): they cannot be recorded or play as a script step. + +**Measured: no settings beyond the target.** A step with these events carries at most `null` (the target +layer reference, `Ordn.Trgt`); mapping them needs only the event name. + +| Event | Menu item | +|---|---| +| `reverse` | Layer > Arrange > Reverse (several layers selected) | +| `linkSelectedLayers` / `unlinkSelectedLayers` | Layer > Link Layers / Unlink Layers | +| `selectLinkedLayers` | Layer > Select Linked Layers | +| `disableLayerStyle` | Layer > Layer Style > Clear Layer Style | +| `placedLayerMakeCopy` | Layer > Smart Objects > New Smart Object via Copy | +| `placedLayerConvertToLayers` | Layer > Smart Objects > Convert to Layers | +| `placedLayerConvertToEmbedded` | Layer > Smart Objects > Embed Linked | +| `placedLayerUpdateAllModified` | Layer > Smart Objects > Update All Modified Content (the returned `documentID` and `layerIDs` are session ids, not settings) | +| `rasterizeAll` | Layer > Rasterize > All Layers | +| `addBlankVideoLayer` | Layer > Video Layers > New Blank Video Layer | +| `isolateLayers` | Select > Isolate Layers | +| `textLoadDefaultTypeStyles` / `textSaveDefaultTypeStyles` | Type > Load / Save Default Type Styles | +| `recordMeasurements` | Image > Analysis > Record Measurements | +| `CropPhotosAuto0001` | File > Automate > Crop and Straighten Photos | + +**Measured: settings.** + +| Event | Menu item | Keys | +|---|---|---| +| `placedLayerExportContents` | Layer > Smart Objects > Export Contents | `null`: path of the file written | +| `placedLayerConvertToLinked` | Layer > Smart Objects > Convert to Linked | `null`: target layer; `Usng`: path of the file written | +| `placedLayerRelinkToFile` | Layer > Smart Objects > Relink to File | `null`: path; `LyrI`: session layer id | +| `placedLayerReplaceContents` | Layer > Smart Objects > Replace Contents | `null`: path; `LyrI`: session layer id | +| `artboardFromLayerGroupEvent` | Layer > New > Artboard from Group | `null` target; `artboard` (object: `guideIDs` list, `artboardPresetName` text, `Clr ` RGB background colour, `artboardBackgroundType` long: 1 white, 2 black, 3 transparent, 4 other = `Clr `); `changeBackground` long. See *Artboard from Group: guides and background* below. | +| `shapeBlur` | Filter > Blur > Shape Blur | `Rds ` radius in pixels (factory 10); the shape itself comes from the dialog | +| Make `contentLayer` with `Usng` = reference to `textLayer` `Ordn.Trgt` | Type > Convert to Shape (recorded as Make, not as `convertToShape`) | `null`: class `contentLayer`; `Usng`: the type layer | + +**Recorded through the menus** (2026-11-01, steps read back from the saved Actions panel): + +| Menu | Recorded step | Keys | +|---|---|---| +| Layer > Layer Style > Scale Effects | `scaleEffectsEvent` | `Scl ` percent, whole number 1..1000 (the dialog clamps) | +| Layer > Layer Style > Create Layer(s) | Make, `null` = class `Lyr `, `Usng` = reference to the `Lefx` property of the target layer | none; the dialog first warns that some effects cannot be reproduced with layers (OK / Cancel) | +| Layer > New > Frame from Layers | Make, `null` = class `framedGroupSection`, `From` = layer **by name**, `layerSectionStart`, `layerSectionEnd` long, `Nm ` text | the dialog also shows width and height, which are not recorded | +| Layer > New > Artboard from Layers | Make, `null` = class `artboardSection`, `From` = layer target, `layerSectionStart`, `layerSectionEnd`, `Nm ` | | +| Layer > New > Artboard | Make, `null` = class `artboardSection`, `layerSectionStart`, `layerSectionEnd`, `Nm `, `artboardRect` (`Top `, `Left`, `Btom`, `Rght` doubles, pixels) | | +| Properties panel > Artboard Background Color (Artboard selected) | `editArtboardEvent` | `null` target; `artboard` (object: `artboardRect` `Top `/`Left`/`Btom`/`Rght` pixels, `guideIDs` list, `artboardPresetName` text, `Clr `, `artboardBackgroundType` 1..4 as above); `changeSizes` long (8 when only the background changed), `changeBackground` 1. `Clr ` is only meaningful with type 4: with types 1..3 it holds the previously used colour | +| Layer > Smart Objects > Stack Mode > mode | `applyImageStackPluginRenderer`: `imageStackPlugin` **class** with the mode's code, `Nm ` text = the menu name (localised, except "Entropy") | codes: Range `rang`, Entropy `entr`, Kurtosis `kurt`, Maximum `maxx`, Median `medn`, Minimum `minn`, Mean `avrg`, Skewness `skew`, Standard Deviation `stdv`, Summation `summ`, Variance `vari` | +| Layer > Smart Objects > Stack Mode > None | `imageStackConvertSmartObject` | none | +| Layer > Video Layers > New Blank Video Layer, Insert Blank Frame, Delete Frame, Show / Hide Altered Video | `addBlankVideoLayer`, `insertBlankFrame`, `deleteFrame`, `showAlteredVideo` / `hideAlteredVideo` | none | +| Layer > Video Layers > Interpret Footage | `interpretFootage` | `Vrsn` long (1), `interpretAlpha` (`alphaInterpretation`: `Ignr` ignore, …), `colorConvertAlterations` bool | +| Type > Create Work Path | Make, `null` = class `Path`, `From` = text layer (`TxLr`) target | | +| Edit > Find and Replace Text (Replace All) | `replace`, `null` = reference: property `replace` of all text layers (`TxLr` `Ordn.Al`), `Usng` = `find` text, `replace` text, `checkAll`, `Fwd `, `caseSensitive`, `wholeWord`, `ignoreAccents` bool | | +| Edit > Check Spelling | `spellCheck`, `null` = property `spellCheck` of all text layers, `Usng` = `checkAll` bool | recorded with its dialog switched on; the corrections themselves are not recorded | +| Image > Analysis > Set Measurement Scale > Custom | Set, `null` = property `measurementScale` of the document target, `T ` = `pixelLength` long, `logicalLength` double, `logicalUnits` text | | +| Image > Analysis > Place Scale Marker | a script step (`AdobeScriptAutomation Scripts`) with `jsop` = `measurementScaleMarkerLength`, `measurementScaleMarkerDisplayText`, `measurementScaleMarkerTextPositionBottom` bool, `measurementScaleMarkerColor` text (`FFFFFF` / `000000`), `measurementScaleMarkerFont`, `measurementScaleMarkerFontSize` text ("4 pt") | the dialog is a bundled script; the step stores its options | +| Image > Analysis > Record Measurements | `recordMeasurements` | none | +| File > Close All | one `close` per document: `Svng` (`YsN ` `N ` when "don't save" was chosen), `DocI`, `forceNotify` | not a `closeAll` event | + +**Not recorded:** File > Close Others, Image > Variables > Define (the variables are kept, no step). + +**Recorded with source files** (2026-11-01): + +| Menu | Recorded step | Keys | +|---|---|---| +| Image > Apply Data Set | `apply`, `null` = `dataSetClass` **by name** | | +| File > Export > Data Sets as Files | `exportDataSet`, `null` = class `dataSetClass` | `All ` bool (all data sets), `T ` alias (folder), `fileNamingRules` (`fileNamingComponents` list of 7 incl. `None`; components as in Batch plus `underscore` and `dataSetName`; `Mcnt`, `Win `, `unix`) | +| File > Import > Variable Data Sets | `importDataSets`, `null` = class `dataSetClass` | `Usng` alias (the text file), `Encd` (`dataSetEncoding`: `dataSetEncodingAuto`, …), `eraseAll` bool (replace existing), `useFirstColumn` bool (first column = data set names) | +| File > Package | `packageFile` | `T ` alias (folder); writes " Folder" (localised) with the document and a `Links` folder | +| File > Import > Video Frames to Layers | `importVideoToLayers` | `Usng` alias (video), `makeAnimation` bool, `frameSkip` long (limit to every n-th frame; absent when off) | +| File > Import > Notes (Annotations) | `importAnnots` | `Usng` alias (PDF). A file without notes shows "No annotations were imported" and records nothing | +| Layer > Video Layers > New Video Layer from File | `addVideoLayer` | `null` alias (video) | +| Layer > Video Layers > Replace Footage | `replaceFootage` | `null` alias (video) | +| Layer > Video Layers > Duplicate Frame, Restore Frame, Restore All Frames, Reload Frame | `duplicateFrame`, `restoreFrame`, `restoreAllFrames`, `reloadFrame` | none. Duplicate needs a painted frame; Restore needs an altered frame; Restore All asks for confirmation | +| File > Automate > Photomerge | the steps the script runs (open dialog, open, duplicate, `addLayerFromFile`, `align` with `layerTransformation`, `mergeAlignedLayers` …), then the script event `0f9db13f-a772-4035-9020-840f0e5e2f02` with an empty descriptor (class `cjsp`) | the layout and options are not in the step | +| File > Automate > Crop and Straighten Photos | `CropPhotosAuto0001` (no keys), then the steps it runs: `CropPhotos0001` with `Vl ` (list of 8 doubles: the four corners), duplicate, `crop` | needs a selected, visible, unlocked layer, otherwise an error message and a step without effect | +| File > Automate > Conditional Mode Change | the Convert Mode it runs, then `8cba8cd6-cb66-11d1-bc43-0060b0a13dc4` | `Msge` text, `SrcM` list of `Cndn` enums (`UGry`, `URGB`, …: the source modes), `DstM` object whose class is the target mode (`CMYM` for CMYK) | +| File > Automate > Fit Image | the Image Size it runs, then `3caa3434-cb67-11d1-bc43-0060b0a13dc4` | `Msge` text, `Hght`, `Wdth` pixels, `limit` bool (don't enlarge) | + +**Not recorded:** importing or applying inside Image > Variables > Data Sets (only the separate menu +commands record). + +**Artboard from Group: guides and background** (measured 2026-11-02, script runs and a recording through the menu): + +- **Recorded form:** the menu item opens no dialog. The step always has `guideIDs` empty, `artboardPresetName` "", `changeBackground` 1, and the + background type and colour **last used** for an artboard (the Properties panel choice is sticky: after picking Black for one artboard, the + next Artboard from Group records type 2 and makes a black artboard). Guides in the document do not change the recorded step. +- **`guideIDs`:** session metadata, not a playback input. Guide ids are a counter for the whole application session (a new document's + guides got 5, 6, 7 after an earlier one's 2, 3, 4). Playing the step with the document's own guide ids, an unknown id (99999) or an + empty list gives the same result: no error, the guides stay document guides (`kind` document), and the new artboard's `guideIDs` is empty. + An implementation can ignore the list on playback and write `[]`. +- **`changeBackground`:** 1 or absent applies `artboardBackgroundType` and `Clr `. Any other value (0, 2, 3, -1, 7 tried) ignores both and + makes a white artboard (type 1, `Clr ` 255/255/255), and the step's result then has no `changeBackground` key. + `artboardBackgroundType` absent with `changeBackground` 1 gives type 1 (white). +- The artboard takes the group's bounds; `Clr ` is stored on the artboard as given even when the type is 1..3. + +**Measured for the mapped events** (2026-11-02, script runs in the reference application): + +- **Transform reference point** (`FTcs`, type `QCSt`): `Qcs0` top-left, `Qcs1` top-right, `Qcs2` bottom-right, `Qcs3` bottom-left + corner; `Qcs4` top, `Qcs5` right, `Qcs6` bottom, `Qcs7` left edge centre; `Qcsa` the centre. `Qcs8` and unknown values also scale + about the centre (not recorded). +- **Convert to Profile** records `sdwM` = -1 (with `Inte`, `Dthr`, `Fltt`, `rasterizePlaced`). +- **Plain Edit > Paste** records `AntA` = `Annt.Anno` and `As ` = class `Pxel`. +- **Link Layers** (`linkSelectedLayers`) toggles when played: played twice on the same two layers it links, then unlinks them. +- **Split Channels** splits a document with a single layer, Background or not (a lone layer gives one more document); a document + with more than one layer is refused. +- **Load Actions** with a set whose name is already in the panel adds a second set of that name; nothing is replaced. + +## Measured through dialogs and recordings + +Measured on 2026-11-01 in two ways: by running the event with its dialog and pressing OK (the reference +application then returns the full descriptor), and by recording actions through the menus and reading +the recorded steps back from the saved Actions panel. Ranges come from the dialog's own "a value between +… is required" message. + +| Event | Keys (recorded order) | Notes | +|---|---|---| +| Custom (`Cstm`) | `Scl ` long 1..9999, `Ofst` long -9999..9999, `Mtrx` list of 25 longs -999..999 | Without the dialog the step needs all 25 cells, or the application fails. | +| Fibers (`Fbrs`) | `Vrnc` long 1..64, `Strg` long 1..64, `RndS` long (seed) | | +| Displace (`Dspl`) | `HrzS`, `VrtS` long -999..999; `DspM` (`StrF` stretch to fit, or tile); `UndA` (`RptE` repeat edge pixels, or wrap around); `DspF` alias: path of the displacement map (.psd) | The map file is chosen after OK. | +| Color Lookup (`colorLookup`) | `lookupType` (`colorLookupType`: `3DLUT`, abstract, device link); `Nm ` text: the file's path; `Dthr` bool; `profile` raw data (ICC); `LUTFormat` (`LUTFormatType`, e.g. `LUTFormatLOOK`); `LUT3DFileData` raw data: the LUT file itself; `LUT3DFileName` text | The LUT travels inside the step. | +| Shape Blur (`shapeBlur`) | `Rds ` pixels 5..1000; `customShape` object (`Nm ` text, `Idnt` text: the shape's id); `keyOriginType` long (9) | | +| Lighting Effects (`lightFilterLightingEffects`) | `k3DLights` list of lights (`Nm `, colour `Rd `/`Grn `/`Bl ` 0..1, `hots`, `FlOf`, `shdw`, attenuation keys, `Type`, `ison`, `key3DMatrix` raw + floats, `X `/`Y `/`Z `, target `tarx`/`tary`/`tarz`, `key3DPosition`), `key3DCurrentCameraPosition`, `Glos`, `Mtrl`, `Exps`, `AmbB`, `AmbC` RGB, `BmpA`, `BmpC` (texture channel by name, localised: "Ohne" = none), `Wdth`, `Hght` | A dialog-less run returns nothing; only a recording shows the keys. | +| Blur Gallery (`blurbTransform`) | `blurbType`, `VrsM`, `VrsN`, `referenceRect` (rectangle), `blurbOpenPanel`, `blurbGeneralBlurEffectApplied`, `blurbSaveMaskChannel`, `blurbHighQuality`, `blurbWidgetList` (per widget: `blurbWidgetType` (0 = field blur), `blurbWidgetLocationX`/`Y`, `blurbWidgetSelected`, `blurbWidgetEffectEnabled`, `blurbGeneralBlurAmount`) | One event for all five Blur Gallery filters. | +| Liquify (`LqFy`) | `LqMe` raw data: the mesh | Opaque; keep the step as recorded. | +| Camera Raw Filter | `CrVe` text (version), `PrVN` long, `PrVe` long | With settings at their defaults; changed settings add keys. | +| Vanishing Point (`VnPt`) | none | The planes are not recorded. | +| Calculations | recorded as Make: `Nw ` class channel, `Usng` object (`T ` reference: source 1 channel and layer, `Clcl` blend mode, `Src2` reference) | Not a `calculations` event. | +| Image Size, size in cm or inches | `Wdth` in `#Rlt` (points), `scaleStyles`, `CnsP`, `Intr` | A physical size is recorded in points, not pixels: playback needs the document's resolution. | +| Canvas Size | only the changed side; `canvasExtensionColorType` `FrgC` for the foreground colour | | +| Convert Mode, several layers, "Don't Flatten" | `T ` as a **class** (`Grys`), `Fltt` false, `Rstr` false | With `Fltt` and `Rstr` the step plays without the flatten question. | +| New Group with a colour | `Usng` (`Clr ` label), `layerSectionStart`, `layerSectionEnd`, top-level `Nm ` | | +| Shear (`Shr `) | `ShrP` list of `Pnt ` (`Hrzn`, `Vrtc` doubles), `UndA` (`WrpA` wrap around, `RptE` repeat edge pixels), `ShrS` long, `ShrE` long | The dialog writes whole numbers: `Hrzn` -64..63 (dragging past the grid clamps), `Vrtc` 1..128 with the first point at 1 and the last at 128; dragging a point out of the grid deletes it. `ShrS` is 0 and `ShrE` the last point's index (count - 1). Script runs take any value unclamped and crashed on far-off `ShrE`/`ShrS` or a first `Vrtc` below 1. | +| Gradient tool, classic (`Grdn` / `gradientClassEvent`) | `From`, `T ` points in pixels; `Opct` percent, `Md ` blend mode, `Type` (`GrdT`: `Lnr `, `Rdl `, `Angl`, `Rflc`, `Dmnd`), `Rvrs` bool, `Dthr` bool, `UsMs` bool (transparency), `gradientsInterpolationMethod` (`Perc` perceptual, …), `Grad` gradient object (as in Gradient Map) | Recorded in that order. `Opct`, `Md ` and `Rvrs` appear only when they differ from 100 %, Normal and off; `Dthr` and `UsMs` only when on. The five types are the only accepted values. A step without `Grad` fails. | +| Gradient tool, default mode | two steps: Make (`null` = class `contentLayer`, `Usng` = `Type` gradient layer object) then Set (`null` = `contentLayer` target, `T ` = the same object plus `Algn` false) | The object: `Dthr`, `gradientsInterpolationMethod`, `Angl` angle, `Type`, `Scl ` percent, `Ofst` (`Hrzn`, `Vrtc` percent), `Grad`. The new tool makes a gradient fill layer; Make holds the first click, Set the finished drag. | +| Content-Aware Fill (`cafWorkspace`) | `cafSamplingRegion` (`cafSamplingRegionRectangular`, …), `cafSampleAllLayers` bool, `cafColorAdaptationLevel` (`cafColorAdaptationDefault`, …), `cafRotationAmount` (`cafRotationAmountNone`, …), `cafScale` bool, `cafMirror` bool, `cafOutput` (`cafOutputToNewLayer`, …) | Runs on the selection, which is recorded as its own step before it. | +| Perspective Warp (`perspectiveWarpTransform`) | `null` = layer target, `Md ` (`perspectiveWarpMode`: `warp`), `referenceRect` rectangle in pixels, `vertices` list of points in pixels (the layout quad), `warpedVertices` (the same points after warping), `quads` list of `indices` (4 vertex indexes each) | Layout and warp are one step. | +| Print (`print`) then Print One Copy (`printOneCopy`) | `print`: `null` = reference `printOutput` property of the target document; `printOutput` (`PstS`, `Inte`, `printSixteenBit`, `printerName` text, `printProofSetup`), `printViewProofSetup`, `printOutputOptions` (caption, marks, `Bckg` colour, `BrdT`, `Bld `, `Rslt`, `vectorData`, `PgPs`, `Left`, `Top `, `Scl `, crop rectangle), `osSpecificPrintInfo` raw data, `Cptn` text. `printOneCopy` has no keys. | Pressing Print in the dialog records both steps. | +| Create Droplet (`CrtD`) | `In ` alias: the droplet file; `Fldr` bool (include subfolders); `Usng` reference to the action; then the Batch keys below (destination, file naming, error log) when set | The same settings as Batch, with the droplet file instead of a source folder. | +| Sky Replacement (`skyReplacement`) | `shiftEdge` double, `borderSmoothness` long (fade edge, default 50), `Brgh` long, `temperature` long, `harmonizationOpacity` long (35), `foregroundLightingOpacity` long, `lightingMode` blend mode (`Mltp`), `edgeLightingOpacity` long (70), `skyReplacementOutput` (`skyReplacementOutputToNewSheets` new layers, …), `Idnt` text (the sky preset's id), `Nm ` text (its name), `File` alias (the sky image) | Scale and Flip are **not** in the step: with 150 % and Flip on, the returned keys were the same. `foregroundLightingOpacity` is computed from the sky (44 and 29 for two skies). Without a chosen sky the dialog returns no keys. | + +**Not recordable:** Edit > Purge (no step is recorded; this version's Purge menu has Clipboard, +Histories, All and Video Cache, no Undo); File > Export > Export As and Quick Export as PNG (the file +is written, no step is recorded). `vectorStrokeToFill` (Convert Stroke to Fill) exists only internally: the reference application knows +it by a localised command name ("command … is not currently available"), but it is in no menu, panel +menu, context menu, the stroke options (incl. their gear menu), the Properties panel or the Keyboard +Shortcuts summary of all commands and tools, and the event is refused even with a stroked path component +selected. It can't be triggered, so it is never recorded: no action contains it, nothing to map. + +### Batch + +Running Batch while recording records the steps the batch played, not a Batch step. The Batch +descriptor itself (`Btch`, as the dialog returns it): + +| Key | Type | Meaning | +|---|---|---| +| `null` | alias, or enum | source folder; for open documents the enum `batchSourceType` `sourceOpenFiles` | +| `OvrO` | bool | override action "Open" commands (written only when on) | +| `Fldr` | bool | include all subfolders | +| `suppressWarnings` | bool | suppress colour profile warnings | +| `suppressOpenOptions` | bool | suppress file open options dialogs | +| `Usng` | reference | the action: `Actn` by name in `ASet` by name | +| `T ` | alias | destination folder (destination Folder) | +| `SvAn` | bool | destination Save and Close (written instead of `T `) | +| `Ovrd` | bool | override action "Save As" commands (written only when on) | +| `fileNamingRules` | object | file naming (below) | +| `Log ` | alias | folder of the error log ("log errors to file") | +| `Nm ` | text | error log file name | + +`fileNamingRules`: `fileNamingComponents` (list of up to six objects; each is `component` enum +`fileNamingComponent`, or `Txt ` text for typed text), `Strt` long (starting serial number), `Mcnt`, `Win `, +`unix` bool (compatibility). + +| Component | Menu entry | +|---|---| +| `Nm ` / `lowerCase` / `upperCase` | Document Name / document name / DOCUMENT NAME | +| `oneDigit` `twoDigit` `threeDigit` `fourDigit` | 1, 2, 3, 4 digit serial number | +| `lowerCaseSerial` / `upperCaseSerial` | serial letter (a, b, c…) / (A, B, C…) | +| `mmddyy` `mmdd` `yyyymmdd` `yymmdd` `yyddmm` `ddmmyy` `ddmm` | dates | +| `lowerCaseExtension` / `upperCaseExtension` | extension / EXTENSION | + +The extension component is written **without a dot, unless it is the last component**, and nothing +else adds an extension: the name is exactly what the components spell. Measured on `a.png`: + +| Components | Saved as | +|---|---| +| Name + extension | `a.png` | +| Name + EXTENSION | `a.PNG` | +| Name + extension + 3 digit serial | `apng001` (no extension) | +| Name + 3 digit serial | `a001` (no extension) | +| Name + extension + text `x` | `apngx` | +| Name + extension + extension | `apng.png` | +| Name + text `.jpg` | `a.jpg` (still PNG data) | +| DOCUMENT NAME + `_` + serial letter + `_` + SERIAL LETTER | `A_a_A` | + +The two short date entries are `mmdd` and `ddmm` (on 1 November they give `1101` and `0111`). + +#### Batch behaviour + +Measured with a folder `in` holding `a.png`, `b.png`, `notes.txt` and `sub/a.png`, `sub/c.png`, +`sub/deeper/d.png`: + +- **Order:** the folder's files by name, then each subfolder (by name, depth first). The serial number + counts across all of them: `a_001` (`in/a`), `b_002`, `a_003` (`in/sub/a`), `c_004`, `d_005`. +- **Include All Subfolders:** results all go **flat into the destination folder**; the subfolder structure + is not rebuilt. A later file with the same output name **silently overwrites** an earlier one + (`in/a.png` and `in/sub/a.png` give one `a.png`). +- **Destination None:** every processed file stays open, changed and unsaved. **Save and Close** saves + each file over itself in its own format and closes it. **Folder** saves a copy named by the file + naming rules. An open document that was never saved asks with a Save As dialog (Photoshop format, + the document's name) even with Folder as the destination. +- **Override Open:** files are opened only by the action's own Open steps; without one the action runs + with no document and every file fails. **Override Save As:** files are saved only by the action's own + Save As steps, into the destination folder; without one nothing is saved. The dialog says both when + the box is ticked. +- **Files that are not images** (`notes.txt`) are an error for that file (code -25600, "extension not + recognised"), not skipped silently. +- **Errors, Stop For Errors** (no `Log ` key): the batch stops at the failing file; files before it are + saved, nothing after it is processed. +- **Errors, Log Errors To File:** the batch goes on, and at the end a message says an error occurred and + the details are in the log. The log file is **UTF-16 LE with BOM and CRLF**, and is written even when + nothing failed. In German the lines are (the application's own language): + + ``` + Stapelanfang + + Datei: "" + Fehler: () + + …one pair per failed file… + Stapelende + ``` +- **Cancel:** Esc during a batch stops the running command and asks "A command was cancelled or stopped + during batch processing. Continue with the next file or stop?" (Continue / Stop). Stop ends the batch + at once; the file being processed is not saved (12 of 60 written in the test). There is no progress + bar: the documents open and close in the window as they are processed. +- **Return value:** when any file failed, the Batch step itself reports "cancelled" (the dialog returns no + descriptor); a script run with Stop For Errors reports success even though it stopped. + +### Actions panel menu + +Button Mode; New Action, New Set, Duplicate, Delete, Play; Start Recording, Record Again, Insert Menu +Item, Insert Stop, Insert Conditional, Insert Path; Action Options, Playback Options, Allow Tool +Recording; Clear All Actions, Reset Actions, Load Actions, Replace Actions, Save Actions; the bundled sets +(Commands, Frames, Image Effects, LAB Black & White Technique, Production, Star Trails, Text Effects, +Textures, Video Actions); Close, Close Tab Group. Playback Options: Accelerated, Step by Step, Pause For +1..60 seconds. + +How two menu entries record: **Insert Menu Item** records the chosen menu command's own event with no +descriptor (Image > Image Size gives an `imageSize` step without settings; playback opens its dialog). +**Insert Path** records a `set` of the work path: `null` = reference `Path` property `WrPt`, `T ` = list +of path components (`shapeOperation`, `SbpL` list of subpaths, each with `Clsp` closed and `Pts ` list of +points with `Anch` in pixels). + +## Recording that plays on any document + +What the reference application records when a user works normally, measured on 2026-11-01 by recording +an action through the panel, mouse and keyboard on a document with layers A, B and C and a second open +document, then reading the steps back. This is the form PhotoCraft's recorder should write (AUTO-219-1); +today it copies journal entries with explicit layer ids. + +| User action | Recorded step | +|---|---| +| Click a layer in the Layers panel | `select`, `null` = layer **by name** (`"B"`), `MkVs` false, `LyrI` list of layer ids (a hint, not the reference) | +| Ctrl+J (Layer via Copy) | `copyToLayer`, no keys | +| Click an empty spot of the canvas with Auto-Select on | `selectNoLayers`, `null` = layer target | +| Arrow keys with the Move tool | **one** `move` step per run of key presses, `null` = layer target, `T ` offset in pixels: Right, Right, Right, Shift+Down gave `Hrzn` 3, `Vrtc` 10 | +| Eye icon | `hide` (or `show`), `null` = **list** of layers by name | +| Opacity typed in the Layers panel | `set` of the target layer, `T ` = `Opct` percent (pressing Enter after typing recorded the same step twice) | +| Select All, Deselect | `set` of the selection (`fsel`) to `Al ` / `None` | +| Alt+Backspace (fill with foreground) | `fill`, `Usng` `FrgC`, `Opct` 100 %, `Md ` Normal | +| Type tool: click, type "Hallo", commit | **one** `make` of a text layer (`TxLr`) with `Txt ` = the whole text; position `TxtC` in **percent of the canvas**; bounds in points | +| Ctrl+E (Merge Down) | `mergeLayersNew`, no keys | +| Ctrl+Tab / Ctrl+Shift+Tab | `select` of a document **by offset** (`rele` +1 / -1) plus `DocI` (the document id, a hint) and `forceNotify` | + +So the recorder names layers by name and acts on the target layer, never by id; it merges a run of +nudges into one relative move and a typing session into one text step; text and document positions are +relative. + +### Playing on another document + +The same recording played on two other documents (Play from step 2, skipping the setup step): + +- **Same layer names, new ids:** plays through; layers are found by name, `LyrI` and `DocI` are ignored. +- **No layer with that name** (layers X, Y, Z): each step that names a missing layer stops with a + message and Continue / Stop. Select by name: "The command 'Select' is not currently available". Hide by + name: "The object 'Layer "A"' is not currently available". Steps on the target layer act on whatever + layer is selected (Ctrl+J copied Z into "Z Kopie"). +- **After `selectNoLayers`**, steps that need a target layer (move, set opacity, fill) each stop with + "The command '…' is not currently available" and Continue / Stop. Continue skips the step; nothing is + undone. +- **Play from a step:** selecting a step in the panel and pressing Play plays from that step to the end. + +## Older files: version 12 + +Action sets saved by the reference application's version 5 era use file version 12. PhotoCraft rejects +them today; this is what reading them needs. Checked on 2026-11-01 on 325 real version 12 sets (all parse +to the end with this layout) and against AtnKit (MIT), which reads version 12 but gets two details wrong +(noted below). + +**Only the descriptor differs.** The file header, the action record and the step record are the same as +in version 16 (set and action names are UTF-16 strings with a counted NUL; events are almost always +4-character codes, so the existing event table applies). Trailing bytes after the last action occur in +22 of 325 files (stale data from an overwritten file): keep them a warning. + +The version 12 descriptor: + +- The step's descriptor has no name and no class: it starts with the item count. +- Every identifier is a bare 4-byte code, with no length prefix: keys, classes, enum types and values, + units, reference classes. No Unicode class names anywhere. +- Strings (`TEXT`, reference names) are the same UTF-16 strings as in version 16. + +```text +step descriptor := count u32, count × (key [4], type [4], value) +object := class [4], count u32, count × (key [4], type [4], value) + +bool u8 | long i32 | comp i64 | doub f64 | UntF unit [4] + f64 +UnFl unit [4] + n u32 + n × f64 +TEXT UTF-16 string | enum type [4] + value [4] +Objc / GlbO object | type / GlbC class [4] +VlLs n u32 + n × (type [4] + value) +ObAr class [4] + object count u32 + count u32 + count × (key [4], type [4], value) +obj n u32 + n × reference item +tdta / alis / Pth u32 length + bytes + +reference item := form [4] + class [4] + (prop: key [4] | Clss: nothing | Enmr: type [4] + value [4] + | rele: i32 | indx: u32 | Idnt: u32 | name: UTF-16 string) +``` + +AtnKit reads `indx` without its class code and has no case for `UnFl`, `ObAr` and `Pth `; with those +fixed, all 325 files parse. `Idnt` was not seen in a version 12 file; its layout follows `indx`. + +**Into PhotoCraft's model:** every id becomes a 4-character code; the step descriptor gets the empty name +and class `null` that version 16 files use for code events; nested classes get the empty name. Save Actions +keeps writing version 16. Version 7 files (a different layout with 8-bit strings) and sets wrapped in +MacBinary stay rejected. + +## Event reference: measured specs + +Each entry below is an event the reference application records, as measured there: its 4-character code +and string ID, the PhotoCraft command it maps to (or should map to), and every key the step carries, in the +order it is recorded, with type, unit, factory default and allowed values. The Supported events table says +which of them PhotoCraft maps today; for the others an entry has everything needed to implement them. When +an event is implemented, add it to that table in the same change. ### How these values were measured @@ -245,7 +704,7 @@ implemented, move it to the table above in the same change. ### Group A: The 47 Filter Gallery effects -Each Filter Gallery effect is recorded as its own event (one per effect), carrying a `GEfk` key with the effect id plus the effect's settings. Today they all load as kept steps that stop at playback. PhotoCraft already has every effect (`crates/algo/src/artistic.rs`) behind `filter.filterGallery`; this issue only maps them. Where our own range or default differs from the measured one, fix ours to match (marked in each table). +Each Filter Gallery effect is recorded as its own event (one per effect), carrying a `GEfk` key with the effect id plus the effect's settings. Today only Spatter (`GEfk` = `Spt`) is mapped (to `filter.gallery.spatter`); the other effects load as kept steps that stop at playback. PhotoCraft already has every effect (`crates/algo/src/artistic.rs`) behind `filter.filterGallery`; this issue only maps them. Where our own range or default differs from the measured one, fix ours to match (marked in each table). #### Accented Edges @@ -781,6 +1240,56 @@ Nested values as recorded (defaults): | `Cntr` | contrast | integer | | 50 | 0 .. 100 (out-of-range values are clamped) | maps to our `contrast` (same range and default) | | `Grnt` | grainType | enumerated | | `GrnR` grainRegular | `GrCn` grainContrasty, `GrSf` grainSoft, `GrSp` grainSpeckle, `GrSr` grainSprinkles, `GrSt` grainStippled, `GrnC` grainClumped, `GrnE` grainEnlarged, `GrnH` grainHorizontal, `GrnR` grainRegular, `GrnV` grainVertical | | +### Group B: The Render filters Clouds, Difference Clouds, Fibers and Lens Flare + +All four commands exist in PhotoCraft; only the `.atn` mapping is missing. Lighting Effects is covered with the events that need a recorded step (its lights are only recorded through the dialog). + +#### Clouds + +- Event: `Clds` / `clouds` +- PhotoCraft command: `filter.render.clouds` +- Uses the current foreground and background colours; the step itself carries no colours. + +No parameters: the step carries an empty descriptor. + +#### Difference Clouds + +- Event: `DfrC` / `differenceClouds` +- PhotoCraft command: `filter.render.differenceClouds` +- Same as Clouds, blended with Difference. + +No parameters: the step carries an empty descriptor. + +#### Fibers + +- Event: `Fbrs` (no string ID) +- PhotoCraft command: `filter.render.fibers` +- `randomSeed` makes playback reproduce the recorded result; keep it. + +| Key | String ID | Type | Unit | Default | Allowed | +|---|---|---|---|---|---| +| `Vrnc` | (none) | integer | | 16 | not clamped in the step (values are returned unchanged); limits pending, see "Events that need a step recorded through their dialog" | +| `Strg` | strengthPlugin | integer | | 4 | not clamped in the step (values are returned unchanged); limits pending, see "Events that need a step recorded through their dialog" | +| `RndS` | randomSeed | integer | | random | any 32-bit integer; a new random value per recording | + +#### Lens Flare + +- Event: `LnsF` / `lensFlare` +- PhotoCraft command: `filter.render.lensFlare` +- Lens types: `Zm ` 50-300 mm Zoom, `Nkn ` 35 mm Prime, `Nkn1` 105 mm Prime, `PnVs` Movie Prime. + +| Key | String ID | Type | Unit | Default | Allowed | +|---|---|---|---|---|---| +| `Brgh` | brightness | integer | | 100 | 10 .. 300 (out-of-range values are clamped) | +| `FlrC` | flareCenter | object | | obj 'Pnt ' point | `Hrzn` / `Vrtc` doubles, 0.4 / 0.4 by default; not clamped: (5, -5) is kept as given and plays | +| `Lns ` | lens | enumerated | | `Zm ` zoom | `Nkn ` nikon, `Nkn1` nikon105, `PnVs` panaVision, `Zm ` zoom | + +Nested values as recorded (defaults): +``` + 'Hrzn' horizontal : DOUBLETYPE = 0.40000000596046 + 'Vrtc' vertical : DOUBLETYPE = 0.40000000596046 +``` + ### Group C: The Distort filters (Pinch, Polar Coordinates, Shear, Spherize, Twirl, Wave, ZigZag) All commands exist in PhotoCraft (`filter.distort.*`); only the `.atn` mapping is missing. Displace needs a displacement map file and is listed separately at the end. @@ -811,10 +1320,10 @@ All commands exist in PhotoCraft (`filter.distort.*`); only the `.atn` mapping i | Key | String ID | Type | Unit | Default | Allowed | |---|---|---|---|---|---| -| `ShrP` | shearPoints | list | | list[2] | list of `Pnt ` points (`Hrzn`, `Vrtc`); default two points (0, 1) and (0, 128); limits pending | +| `ShrP` | shearPoints | list | | list[2] | list of `Pnt ` points (`Hrzn`, `Vrtc`) from top to bottom; default two points (0, 1) and (0, 128). The dialog writes whole numbers: `Hrzn` -64..63 (dragging past the grid edge clamps), `Vrtc` 1..128 with the first point at 1 and the last at 128; dragging a point out of the grid deletes it. A script run accepts any value without clamping (e.g. `Hrzn` ±10000, fractions) and crashed on a first point with `Vrtc` < 1: clamp to the dialog ranges on import | | `UndA` | undefinedArea | enumerated | | `WrpA` wrapAround | `RptE` repeatEdgePixels, `WrpA` wrapAround | -| `ShrS` | shearStart | integer | | 0 | limits pending, see "Events that need a step recorded through their dialog" | -| `ShrE` | shearEnd | integer | | 1 | limits pending, see "Events that need a step recorded through their dialog" | +| `ShrS` | shearStart | integer | | 0 | index of the first curve point; the dialog always writes 0 | +| `ShrE` | shearEnd | integer | | 1 | index of the last curve point (point count - 1); the dialog writes it. Script runs with other values change the result, and far-off values (`ShrE` 100 or 1000, `ShrS` -1000) crashed the application: on import accept only `ShrS` 0 and `ShrE` = point count - 1, otherwise keep the step as a kept step | Nested values as recorded (defaults): ``` @@ -918,8 +1427,8 @@ No parameters: the step carries an empty descriptor. | Key | String ID | Type | Unit | Default | Allowed | |---|---|---|---|---|---| -| `Lvl ` | level | integer | | 128 | applied, but not echoed: the returned step always shows 128. Levels <= 0 and >= 255 give the same image. Dialog range pending. | -| `Edg ` | edge | enumerated | | `Upr ` upper | `Lwr ` lower, `Upr ` upper (applied; the returned step always shows upper) | +| `Lvl ` | level | integer | | 128 | whole number 0..255 (the dialog clamps to the nearest value). A recorded step carries the real level (e.g. 77); only a script run echoes 128 back. Levels <= 0 and >= 255 give the same image. | +| `Edg ` | edge | enumerated | | `Upr ` upper | `CntE` enum: `Lwr ` lower, `Upr ` upper; recorded as set in the dialog | #### Wind @@ -1101,9 +1610,9 @@ No parameters: the step carries an empty descriptor. | Key | String ID | Type | Unit | Example value | Allowed | |---|---|---|---|---|---| -| `Scl ` | scale | integer | | 1 | ignored without the dialog; see "Events that need a step recorded through their dialog" | -| `Ofst` | offset | integer | | 0 | ignored without the dialog; see "Events that need a step recorded through their dialog" | -| `Mtrx` | matrix | list | | none: the 25 cells as set in the dialog | exactly 25 integers (5 x 5, row by row); a 3 x 3 list makes the application fail | +| `Scl ` | scale | integer | | 1 | 1 .. 9999 (measured in the dialog); applied also when played without the dialog (scale 2 halves the result) | +| `Ofst` | offset | integer | | 0 | -9999 .. 9999 (measured in the dialog); applied also when played without the dialog (offset 50 adds 50) | +| `Mtrx` | matrix | list | | none: a step always carries all 25 cells | exactly 25 integers (5 x 5, row by row), each -999 .. 999; a 3 x 3 list makes the application fail | #### Replace Color @@ -1408,15 +1917,6 @@ their tables will be added once a recorded step was read. | Event | Why | |---|---| -| Lighting Effects | The light list only appears after the dialog. | -| Displace | Reads a displacement map file chosen in the dialog. | -| Fibers (`Vrnc`, `Strg` ranges) | Values outside the dialog's range are returned unchanged, not clamped; the limits come from the dialog. | -| Custom | Without the dialog, scale and offset are ignored, and a matrix that is not 5 x 5 makes the application fail. | -| Shear | Probing the point list made the application fail; the limits come from the dialog. | -| Trace Contour | The step's level and edge are applied, but the returned descriptor always shows the defaults (level 128, upper). Lower (`Lwr `) is valid; levels at or below 0 and at or above 255 give the same image. | -| Gradient tool | The drag needs a full gradient object (see Gradient Map in group E for its form). | -| Batch, Create Droplet, Print | File and printer settings come from their dialogs; Batch's file naming is still unknown (a step written with PhotoCraft's current naming rules is refused). | -| Sky Replacement, Smart Object commands (embed, link, relink, replace, export contents), Convert Stroke to Fill, Rasterize All Layers | Known to the application but need a matching document (a sky, a smart object, a shape with a stroke). | | Generative Fill (`syntheticFill`) | AI feature: in PhotoCraft this goes through the shared AI layer; until then such steps stay kept steps. | ### Recorded under another event From 027d19131210c9195fba71ac4e6dd15ed70676ec Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 2 Nov 2026 13:34:57 +0100 Subject: [PATCH 160/216] Integration: the dither test reads pixels with as_chunks (clippy chunks_exact_to_as_chunks) (#10704) Co-Authored-By: Claude Opus 5.5 --- crates/engine/tests/convert_profile_dither.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/tests/convert_profile_dither.rs b/crates/engine/tests/convert_profile_dither.rs index 73b0441c23..6a78b83956 100644 --- a/crates/engine/tests/convert_profile_dither.rs +++ b/crates/engine/tests/convert_profile_dither.rs @@ -54,7 +54,7 @@ fn dither_rounds_fractional_colors_without_bias_or_alpha_noise() { let tile = pixels(&after).tile(TileCoord::new(0, 0)).unwrap(); let mut total = 0u64; let mut raised = 0; - for px in tile.bytes().chunks_exact(4) { + for px in tile.bytes().as_chunks::<4>().0 { assert!(px[0] == 55 || px[0] == 56, "dither quantizes neighboring destination values"); assert_eq!(px[0], px[1]); assert_eq!(px[0], px[2]); From e155cf85bf197b3852644271054a230a6cd8ba0f Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 2 Nov 2026 14:09:34 +0100 Subject: [PATCH 161/216] Integration: batch tests read composed pixels with Buffer::into_px after main's #10514 (#10704, #10618) Main's buffer reuse (#10514) gives Buffer a Drop impl, so moving .px out of it no longer compiles. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/atn_cmds/stylize_tests.rs | 2 +- crates/engine/tests/convert_profile_atn.rs | 2 +- crates/engine/tests/convert_profile_options.rs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/atn_cmds/stylize_tests.rs b/crates/engine/src/cmd/atn_cmds/stylize_tests.rs index 9599385536..976940ef43 100644 --- a/crates/engine/src/cmd/atn_cmds/stylize_tests.rs +++ b/crates/engine/src/cmd/atn_cmds/stylize_tests.rs @@ -411,7 +411,7 @@ fn prepare_pattern(s: &mut Session, depth: u32, mode: &str) { } fn pixels(s: &Session) -> Vec<[f32; 4]> { - photocraft_compose::render(&s.active().unwrap().doc, photocraft_geom::Rect::new(0, 0, 32, 24)).px + photocraft_compose::render(&s.active().unwrap().doc, photocraft_geom::Rect::new(0, 0, 32, 24)).into_px() } /// Saving omitted native params preserves their actual command behavior, not Photoshop's defaults. diff --git a/crates/engine/tests/convert_profile_atn.rs b/crates/engine/tests/convert_profile_atn.rs index b4d4ad09e3..cf1f91b6de 100644 --- a/crates/engine/tests/convert_profile_atn.rs +++ b/crates/engine/tests/convert_profile_atn.rs @@ -113,7 +113,7 @@ fn intent_fixture() -> Session { } fn rendered(s: &Session) -> Vec<[f32; 4]> { - photocraft_compose::flatten(&s.active().unwrap().doc).px + photocraft_compose::flatten(&s.active().unwrap().doc).into_px() } /// Assert saved typed fields and independent pixel/model effects, not just paired adapters. diff --git a/crates/engine/tests/convert_profile_options.rs b/crates/engine/tests/convert_profile_options.rs index 9c7d5feb4f..a64d9c88f3 100644 --- a/crates/engine/tests/convert_profile_options.rs +++ b/crates/engine/tests/convert_profile_options.rs @@ -50,7 +50,7 @@ fn from_document(doc: Document, active: Option) -> Session { } fn composed(s: &Session) -> Vec<[f32; 4]> { - photocraft_compose::flatten(&s.active().unwrap().doc).px + photocraft_compose::flatten(&s.active().unwrap().doc).into_px() } /// The public source-first control must differ from conversion before flattening. From 582a285b5d33bba54f7141ae02416f31034afacf Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Mon, 2 Nov 2026 19:01:15 +0530 Subject: [PATCH 162/216] fix: enforce scoped PSD memory admission and complete retry integration --- Cargo.lock | 1 + apps/photocraft-cli/src/args.rs | 12 + apps/photocraft-cli/src/batch.rs | 2 +- apps/photocraft-cli/src/document.rs | 8 +- crates/automation/src/files.rs | 23 +- crates/automation/src/headless.rs | 16 +- crates/automation/src/rpc.rs | 51 ++- crates/automation/src/rpc/tests.rs | 30 ++ crates/automation/src/server.rs | 90 ++++- crates/engine/Cargo.toml | 1 + crates/engine/src/cmd/file_cmds.rs | 18 +- crates/engine/src/cmd/file_cmds/open.rs | 95 +++-- crates/engine/src/cmd/file_cmds/tests.rs | 137 +++++-- crates/engine/src/error.rs | 3 + crates/engine/src/jobs.rs | 196 +++++++--- crates/io/Cargo.toml | 2 + crates/io/src/flat.rs | 4 +- crates/io/src/lib.rs | 146 +++---- crates/io/src/memory.rs | 278 ++++++++++++-- crates/io/src/psd_budget/tests.rs | 146 ++++++- crates/io/src/psd_gate.rs | 170 +++++++-- crates/io/src/psd_import.rs | 83 +++- crates/io/src/tiff_layers.rs | 13 +- crates/ui-egui/Cargo.toml | 1 + crates/ui-egui/src/app.rs | 1 + crates/ui-egui/src/app/files.rs | 20 +- crates/ui-egui/src/control/dispatch.rs | 10 +- crates/ui-egui/src/dialogs/open_prompt.rs | 159 +++++--- crates/ui-egui/src/file_open.rs | 2 +- crates/ui-egui/src/file_open/tests.rs | 2 +- crates/ui-egui/src/jobs_ui.rs | 12 +- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/psd_open_tests.rs | 443 +++++++++++++++++++++- docs/development.md | 11 +- 34 files changed, 1865 insertions(+), 322 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b22b872d1d..e3ba377e6c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5283,6 +5283,7 @@ dependencies = [ "serde_json", "thiserror 2.0.21", "tiff", + "winsafe", "zune-core 0.5.3", "zune-jpegxl", ] diff --git a/apps/photocraft-cli/src/args.rs b/apps/photocraft-cli/src/args.rs index 91b99ef33b..c1772534fd 100644 --- a/apps/photocraft-cli/src/args.rs +++ b/apps/photocraft-cli/src/args.rs @@ -91,6 +91,9 @@ pub(crate) struct ConvertArgs { /// Allow an that is the file, replacing the original #[arg(long)] pub(crate) in_place: bool, + /// Explicitly approve high-memory PSD/PSB imports within the hard memory budget + #[arg(long)] + pub(crate) approve_high_memory: bool, #[command(flatten)] pub(crate) export: ExportArgs, } @@ -102,6 +105,9 @@ pub(crate) struct InfoArgs { /// One line of JSON instead of pretty-printed #[arg(long)] pub(crate) compact: bool, + /// Explicitly approve high-memory PSD/PSB imports within the hard memory budget + #[arg(long)] + pub(crate) approve_high_memory: bool, } /// Open a file (or start a new document), run engine commands in order, save the result @@ -130,6 +136,9 @@ pub(crate) struct RunArgs { /// Allow an --out that is the , replacing the original #[arg(long)] pub(crate) in_place: bool, + /// Explicitly approve high-memory PSD/PSB imports within the hard memory budget + #[arg(long)] + pub(crate) approve_high_memory: bool, #[command(flatten)] pub(crate) export: ExportArgs, /// `--cmd` and `--params` values in command-line order, set by [`parse`]. @@ -169,6 +178,9 @@ pub(crate) struct BatchArgs { /// originals; --in-place allows it #[arg(long)] pub(crate) in_place: bool, + /// Explicitly approve high-memory PSD/PSB imports within the hard memory budget + #[arg(long)] + pub(crate) approve_high_memory: bool, #[command(flatten)] pub(crate) export: ExportArgs, } diff --git a/apps/photocraft-cli/src/batch.rs b/apps/photocraft-cli/src/batch.rs index 02bb1bb702..a9f85fddb1 100644 --- a/apps/photocraft-cli/src/batch.rs +++ b/apps/photocraft-cli/src/batch.rs @@ -81,7 +81,7 @@ pub(crate) fn batch(a: &BatchArgs, out: &mut dyn Write, err: &mut dyn Write) -> // Steps that play another action find it in the set; the session's playback stack // stops an action that calls itself. h.session.actions.list = set.clone(); - h.open(input)?; + h.open_with_approval(input, a.approve_high_memory)?; for (id, p) in &actions { if photocraft_engine::actions_cmds::shell_view_command(id) || photocraft_engine::actions_cmds::shell_save_command(id) diff --git a/apps/photocraft-cli/src/document.rs b/apps/photocraft-cli/src/document.rs index 95d4c28288..941d58814f 100644 --- a/apps/photocraft-cli/src/document.rs +++ b/apps/photocraft-cli/src/document.rs @@ -30,7 +30,9 @@ pub(crate) fn convert(a: &ConvertArgs, _out: &mut dyn Write, err: &mut dyn Write let (input, output) = (&a.input, &a.output); let opts = ExportOptions::from(&a.export); refuse_overwriting_input(a.in_place, input, output)?; - let mut o = files::open(input)?; + let mut import_opts = photocraft_io::ImportOptions::default(); + import_opts.psd_high_memory_approved = a.approve_high_memory; + let mut o = files::open_with_options(input, &import_opts)?; // Preferences › Raw Defaults › Resolution at its default, as `run` and `info` open a raw. photocraft_engine::file_cmds::raw_defaults(&photocraft_engine::Session::new(), &o.warnings, &mut o.document); warn_all(err, &o.warnings); @@ -43,7 +45,7 @@ pub(crate) fn convert(a: &ConvertArgs, _out: &mut dyn Write, err: &mut dyn Write pub(crate) fn info(a: &InfoArgs, out: &mut dyn Write) -> Result { let file = &a.file; let mut h = Headless::trusted_local(); - let opened = h.open(file)?; + let opened = h.open_with_approval(file, a.approve_high_memory)?; let mut doc = h.inspect(None)?; let mut warnings = opened["warnings"].as_array().cloned().unwrap_or_default(); if let Some(st) = h.session.active() { @@ -86,7 +88,7 @@ pub(crate) fn run_cmds(a: &RunArgs, out: &mut dyn Write, err: &mut dyn Write) -> if let Some(out) = &a.out { refuse_overwriting_input(a.in_place, file, out)?; } - let o = h.open(file)?; + let o = h.open_with_approval(file, a.approve_high_memory)?; let ws: Vec = serde_json::from_value(o["warnings"].clone()).unwrap_or_default(); warn_all(err, &ws); reported_fonts.extend(ws); diff --git a/crates/automation/src/files.rs b/crates/automation/src/files.rs index 0b8ff103aa..bfacf1e19f 100644 --- a/crates/automation/src/files.rs +++ b/crates/automation/src/files.rs @@ -18,17 +18,27 @@ pub struct Opened { /// Decode a document that was read through a filesystem capability. pub fn open_bytes(name: &str, bytes: &[u8]) -> Result { + open_bytes_with_options(name, bytes, &photocraft_io::ImportOptions::default()) +} + +/// Decode a document that was read through a filesystem capability with custom import options. +pub fn open_bytes_with_options(name: &str, bytes: &[u8], opts: &photocraft_io::ImportOptions) -> Result { if Path::new(name).extension().is_some_and(|extension| extension.eq_ignore_ascii_case(photocraft_format::EXTENSION)) { let document = photocraft_format::load_from_bytes(bytes)?; return Ok(Opened { warnings: photocraft_io::open_warnings(&document), document, source_read_only: false }); } - let r = photocraft_io::import(name, bytes)?; + let r = photocraft_io::import_with_options(name, bytes, opts)?; Ok(Opened { document: r.document, warnings: r.warnings, source_read_only: r.source_read_only }) } /// Open a document from disk. Directory bundles and `.pcraft` ZIPs load /// natively; everything else goes through `photocraft-io` (PSD, PNG, …). pub fn open(path: &Path) -> Result { + open_with_options(path, &photocraft_io::ImportOptions::default()) +} + +/// Open a document from disk with custom import options. +pub fn open_with_options(path: &Path, opts: &photocraft_io::ImportOptions) -> Result { if path.is_dir() { let document = photocraft_format::load_path(path)?; return Ok(Opened { warnings: photocraft_io::open_warnings(&document), document, source_read_only: false }); @@ -38,17 +48,16 @@ pub fn open(path: &Path) -> Result { // convert or info of a 2 GB file does not hold the file in memory next to the document. #[cfg(any(unix, windows))] { - let r = - photocraft_io::import_file(&name, path, &photocraft_io::ImportOptions::default(), &photocraft_raster::Interrupt::NONE).map_err(|e| match e { - photocraft_io::IoError::Read(msg) => AutomationError::Io(msg), - e => e.into(), - })?; + let r = photocraft_io::import_file(&name, path, opts, &photocraft_raster::Interrupt::NONE).map_err(|e| match e { + photocraft_io::IoError::Read(msg) => AutomationError::Io(msg), + e => e.into(), + })?; Ok(Opened { document: r.document, warnings: r.warnings, source_read_only: r.source_read_only }) } #[cfg(not(any(unix, windows)))] { let bytes = photocraft_format::read_file(path).map_err(|e| AutomationError::Io(format!("{}: {e}", path.display())))?; - open_bytes(&name, &bytes) + open_bytes_with_options(&name, &bytes, opts) } } diff --git a/crates/automation/src/headless.rs b/crates/automation/src/headless.rs index 8b08daa6aa..ca0158cee7 100644 --- a/crates/automation/src/headless.rs +++ b/crates/automation/src/headless.rs @@ -90,14 +90,26 @@ impl Headless { /// Open a file and make it the active document. pub fn open(&mut self, path: &Path) -> Result { + self.open_with_approval(path, false) + } + + /// Open a file with optional high-memory approval and make it the active document. + pub fn open_with_approval(&mut self, path: &Path, approve_high_memory: bool) -> Result { + let mut opts = photocraft_io::ImportOptions::default(); + opts.psd_high_memory_approved = approve_high_memory; + self.open_with_options(path, &opts) + } + + /// Open a file with custom import options and make it the active document. + pub fn open_with_options(&mut self, path: &Path, opts: &photocraft_io::ImportOptions) -> Result { let requested = path.to_str().ok_or_else(|| AutomationError::BadRequest("automation paths must be valid UTF-8".into()))?; let mut o = match &self.filesystem { Filesystem::Denied => return Err(AutomationError::BadRequest("automation filesystem access is not granted: read authority is absent".into())), - Filesystem::TrustedLocal => files::open(path)?, + Filesystem::TrustedLocal => files::open_with_options(path, opts)?, Filesystem::Workspace(workspace) => { let bytes = workspace.read(requested)?; let name = path.file_name().and_then(|name| name.to_str()).unwrap_or(requested); - files::open_bytes(name, &bytes)? + files::open_bytes_with_options(name, &bytes, opts)? } }; // Preferences › Raw Defaults › Resolution, as the app's File › Open applies it. diff --git a/crates/automation/src/rpc.rs b/crates/automation/src/rpc.rs index 92ece2b943..7608dc6922 100644 --- a/crates/automation/src/rpc.rs +++ b/crates/automation/src/rpc.rs @@ -61,6 +61,33 @@ pub const METHODS: &[&str] = &[ "methods", ]; +fn attach_memory_error_details(obj: &mut Value, err: &AutomationError) { + match err { + AutomationError::Import(photocraft_io::IoError::PsdApprovalRequired { required, soft_limit, hard_limit, available_ram }) + | AutomationError::Engine(photocraft_engine::EngineError::PsdApprovalRequired { required, soft_limit, hard_limit, available_ram, .. }) => { + if let Some(m) = obj.as_object_mut() { + m.insert("required".into(), json!(required)); + m.insert("soft".into(), json!(soft_limit)); + m.insert("softLimit".into(), json!(soft_limit)); + m.insert("hard".into(), json!(hard_limit)); + m.insert("hardLimit".into(), json!(hard_limit)); + if let Some(avail) = available_ram { + m.insert("available".into(), json!(avail)); + m.insert("availableRam".into(), json!(avail)); + } + } + } + AutomationError::Import(photocraft_io::IoError::PsdImportBudget { required, limit }) => { + if let Some(m) = obj.as_object_mut() { + m.insert("required".into(), json!(required)); + m.insert("hard".into(), json!(limit)); + m.insert("hardLimit".into(), json!(limit)); + } + } + _ => {} + } +} + fn bad(msg: impl Into) -> AutomationError { AutomationError::BadRequest(msg.into()) } @@ -107,10 +134,20 @@ impl Headless { "session.list" => Ok(self.session_list()), "doc.open" => { let path = str_of(&p, "path").ok_or_else(|| bad("doc.open needs `path`"))?; - self.open(&PathBuf::from(path)) + let approve = match p.get("approveHighMemory") { + None | Some(Value::Null) => false, + Some(Value::Bool(b)) => *b, + Some(_) => return Err(bad("`approveHighMemory` must be a boolean")), + }; + self.open_with_approval(&PathBuf::from(path), approve) } "doc.place" => { let path = str_of(&p, "path").ok_or_else(|| bad("doc.place needs `path`"))?; + if let Some(v) = p.get("approveHighMemory") { + if !v.is_boolean() { + return Err(bad("`approveHighMemory` must be a boolean")); + } + } let mut opts = p.as_object().cloned().unwrap_or_default(); opts.remove("path"); opts.remove("index"); @@ -224,7 +261,11 @@ impl Headless { let was_error = r.is_err(); let result = match r { Ok(v) => json!({"ok": true, "result": v}), - Err(e) => json!({"ok": false, "error": e.to_string()}), + Err(e) => { + let mut obj = json!({"ok": false, "error": e.to_string()}); + attach_memory_error_details(&mut obj, &e); + obj + } }; if let Err(error) = reply_budget.charge(&result) { failed += 1; @@ -262,7 +303,11 @@ pub fn respond(h: &Mutex, line: &str) -> Value { .unwrap_or_else(|_| Err(AutomationError::Other(format!("internal error: `{method}` panicked")))); match r { Ok(v) => json!({"id": id, "ok": true, "result": v}), - Err(e) => json!({"id": id, "ok": false, "error": e.to_string()}), + Err(e) => { + let mut obj = json!({"id": id, "ok": false, "error": e.to_string()}); + attach_memory_error_details(&mut obj, &e); + obj + } } } diff --git a/crates/automation/src/rpc/tests.rs b/crates/automation/src/rpc/tests.rs index 131df72413..b7ee7feb36 100644 --- a/crates/automation/src/rpc/tests.rs +++ b/crates/automation/src/rpc/tests.rs @@ -1028,3 +1028,33 @@ fn invalid_document_index_is_rejected_not_the_active_document() { assert_eq!(reply["ok"], true, "{reply}"); assert_eq!(count(&h), 1); } + +#[test] +fn approve_high_memory_rejects_invalid_types_and_formats_structured_error() { + let h = session(); + // Non-boolean approveHighMemory is rejected strictly + let reply = respond(&h, r#"{"method":"doc.open","params":{"path":"test.psd","approveHighMemory":"yes"}}"#); + assert_eq!(reply["ok"], false); + assert!(reply["error"].as_str().unwrap().contains("must be a boolean")); + + let reply_place = respond(&h, r#"{"method":"doc.place","params":{"path":"test.psd","approveHighMemory":123}}"#); + assert_eq!(reply_place["ok"], false); + assert!(reply_place["error"].as_str().unwrap().contains("must be a boolean")); + + // Verify attach_memory_error_details adds machine-readable fields + let mut err_obj = json!({"id": 1, "ok": false, "error": "test"}); + let auto_err = crate::AutomationError::Import(photocraft_io::IoError::PsdApprovalRequired { + required: 5000, + soft_limit: 2000, + hard_limit: 8000, + available_ram: Some(10000), + }); + super::attach_memory_error_details(&mut err_obj, &auto_err); + assert_eq!(err_obj["required"], 5000); + assert_eq!(err_obj["soft"], 2000); + assert_eq!(err_obj["softLimit"], 2000); + assert_eq!(err_obj["hard"], 8000); + assert_eq!(err_obj["hardLimit"], 8000); + assert_eq!(err_obj["available"], 10000); + assert_eq!(err_obj["availableRam"], 10000); +} diff --git a/crates/automation/src/server.rs b/crates/automation/src/server.rs index 544dbc90f1..8d053232df 100644 --- a/crates/automation/src/server.rs +++ b/crates/automation/src/server.rs @@ -50,6 +50,9 @@ pub struct DocIndex { pub struct OpenParams { /// Path beneath the configured automation read root: relative (forward slashes) or absolute. pub path: String, + /// Explicitly approve high-memory PSD/PSB imports that fit within the hard memory budget. + #[serde(default)] + pub approve_high_memory: Option, } #[derive(Debug, Default, Deserialize, JsonSchema)] @@ -483,7 +486,31 @@ fn png_result(png: &[u8], note: String) -> CallToolResult { fn to_result(r: Result) -> Result { Ok(match r { Ok(v) => ok_json(&v), - Err(e) => fail(e), + Err(e) => { + let mut obj = json!({"error": e.to_string()}); + match &e { + AutomationError::Import(photocraft_io::IoError::PsdApprovalRequired { required, soft_limit, hard_limit, available_ram }) + | AutomationError::Engine(photocraft_engine::EngineError::PsdApprovalRequired { required, soft_limit, hard_limit, available_ram, .. }) => { + obj["required"] = json!(required); + obj["soft"] = json!(soft_limit); + obj["softLimit"] = json!(soft_limit); + obj["hard"] = json!(hard_limit); + obj["hardLimit"] = json!(hard_limit); + if let Some(avail) = available_ram { + obj["available"] = json!(avail); + obj["availableRam"] = json!(avail); + } + } + AutomationError::Import(photocraft_io::IoError::PsdImportBudget { required, limit }) => { + obj["required"] = json!(required); + obj["hard"] = json!(limit); + obj["hardLimit"] = json!(limit); + } + _ => {} + } + let text = serde_json::to_string(&obj).unwrap_or_else(|_| e.to_string()); + CallToolResult::error(vec![Content::text(text)]) + } }) } @@ -631,13 +658,18 @@ impl PhotocraftMcp { #[tool(description = "Open an image or document file and make it active. Returns index, size and import warnings.")] async fn doc_open(&self, Parameters(p): Parameters) -> Result { let path = PathBuf::from(&p.path); - if let Some(r) = self.headless_op(move |h| h.open(&path)).await { + let approve = p.approve_high_memory.unwrap_or(false); + if let Some(r) = self.headless_op(move |h| h.open_with_approval(&path, approve)).await { return to_result(r); } let Some(b) = self.bridge_client() else { return Ok(no_backend()); }; - to_result(b.call("app.open", json!({"path": p.path})).await) + let mut params = json!({"path": p.path}); + if approve { + params["approveHighMemory"] = json!(true); + } + to_result(b.call("app.open", params).await) } #[tool( @@ -939,7 +971,36 @@ impl PhotocraftMcp { let was_error = response.is_err(); let result = match response { Ok(value) => json!({"ok": true, "result": value}), - Err(error) => json!({"ok": false, "error": error.to_string()}), + Err(error) => { + let mut obj = json!({"ok": false, "error": error.to_string()}); + match &error { + AutomationError::Import(photocraft_io::IoError::PsdApprovalRequired { required, soft_limit, hard_limit, available_ram }) + | AutomationError::Engine(photocraft_engine::EngineError::PsdApprovalRequired { + required, + soft_limit, + hard_limit, + available_ram, + .. + }) => { + obj["required"] = json!(required); + obj["soft"] = json!(soft_limit); + obj["softLimit"] = json!(soft_limit); + obj["hard"] = json!(hard_limit); + obj["hardLimit"] = json!(hard_limit); + if let Some(avail) = available_ram { + obj["available"] = json!(avail); + obj["availableRam"] = json!(avail); + } + } + AutomationError::Import(photocraft_io::IoError::PsdImportBudget { required, limit }) => { + obj["required"] = json!(required); + obj["hard"] = json!(limit); + obj["hardLimit"] = json!(limit); + } + _ => {} + } + obj + } }; if let Err(error) = reply_budget.charge(&result) { failed += 1; @@ -1255,6 +1316,27 @@ mod tests { assert_eq!(docs, 0, "the step after the budget ran out did not run"); } + #[tokio::test(flavor = "multi_thread")] + async fn mcp_doc_open_schema_and_structured_error() { + let schema = &PhotocraftMcp::tool_router().map["doc_open"].attr.input_schema; + assert_eq!(schema["properties"]["approve_high_memory"]["type"], "boolean"); + + let err = AutomationError::Import(photocraft_io::IoError::PsdApprovalRequired { + required: 6000, + soft_limit: 3000, + hard_limit: 9000, + available_ram: Some(12000), + }); + let res = to_result(Err(err)).unwrap(); + assert_eq!(res.is_error, Some(true)); + let text = &res.content.first().and_then(|c| c.as_text()).unwrap().text; + let v: Value = serde_json::from_str(text).unwrap(); + assert_eq!(v["required"], 6000); + assert_eq!(v["soft"], 3000); + assert_eq!(v["hard"], 9000); + assert_eq!(v["available"], 12000); + } + /// The MCP server applies finished jobs before every tool call too (#503). #[tokio::test(flavor = "multi_thread")] async fn mcp_tools_see_a_finished_background_job() { diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 9c63bfcab4..4c48527a77 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -61,6 +61,7 @@ photocraft-ipp = { workspace = true } [dev-dependencies] photocraft-affinity = { workspace = true, features = ["synth"] } +photocraft-io = { workspace = true, features = ["testgen"] } # Synthetic camera raw files for the open tests. photocraft-raw = { workspace = true, features = ["testgen"] } photocraft-testkit = { workspace = true } diff --git a/crates/engine/src/cmd/file_cmds.rs b/crates/engine/src/cmd/file_cmds.rs index 2dcf8fe84a..097cb54fad 100644 --- a/crates/engine/src/cmd/file_cmds.rs +++ b/crates/engine/src/cmd/file_cmds.rs @@ -68,6 +68,10 @@ pub(crate) fn native(_: &Session) -> std::result::Result<(), String> { if cfg!(target_arch = "wasm32") { Err("not available on the web (no file system)".into()) } else { Ok(()) } } +fn always(_: &Session) -> std::result::Result<(), String> { + Ok(()) +} + pub(crate) fn native_doc(s: &Session) -> std::result::Result<(), String> { native(s)?; has_doc(s) @@ -261,13 +265,21 @@ pub fn specs() -> Vec { native, open_as ), - spec!("file.approveMemory", "Approve High Memory PSD Import", &[], None, r##"{"id":u64,"approve":bool=true}"##, native, approve_memory), + spec!( + "file.approveMemory", + "Approve High Memory PSD Import", + &[], + None, + r##"{"id":u64,"approve":bool=true,"background":bool=true}"##, + always, + approve_memory + ), spec!( "file.placeEmbedded", "Place Embedded…", &["File"], None, - r##"{"path":str,"scale":%? (default: fit when larger than the canvas),"fit":bool? (default: Preferences > General > Resize Image During Place),"smartObject":bool? (default: Preferences > General > Always Create Smart Objects When Placing),"center":[x,y]?}"##, + r##"{"path":str,"scale":%? (default: fit when larger than the canvas),"fit":bool? (default: Preferences > General > Resize Image During Place),"smartObject":bool? (default: Preferences > General > Always Create Smart Objects When Placing),"center":[x,y]?,"approveHighMemory":bool=false (explicitly approve high memory usage)}"##, native_doc, |s, p| place(s, p, false) ), @@ -276,7 +288,7 @@ pub fn specs() -> Vec { "Place Linked…", &["File"], None, - r##"{"path":str,"scale":%?,"fit":bool? (default: Preferences > General > Resize Image During Place),"center":[x,y]?}"##, + r##"{"path":str,"scale":%?,"fit":bool? (default: Preferences > General > Resize Image During Place),"center":[x,y]?,"approveHighMemory":bool=false (explicitly approve high memory usage)}"##, native_doc, |s, p| place(s, p, true) ), diff --git a/crates/engine/src/cmd/file_cmds/open.rs b/crates/engine/src/cmd/file_cmds/open.rs index e9ad7ee998..46b5f71f2c 100644 --- a/crates/engine/src/cmd/file_cmds/open.rs +++ b/crates/engine/src/cmd/file_cmds/open.rs @@ -127,7 +127,13 @@ pub fn open_bytes_as_with_options( }; let mut opts = crate::jobs::import_options(s.prefs()); opts.psd_high_memory_approved = approve_high_memory; - let r = photocraft_io::import_with_options(&decode_name, bytes, &opts).map_err(|e| EngineError::Other(format!("{decode_name}: {e}")))?; + opts.psd_approved_estimate = None; + let r = photocraft_io::import_with_options(&decode_name, bytes, &opts).map_err(|e| match e { + photocraft_io::IoError::PsdApprovalRequired { required, soft_limit, hard_limit, available_ram } => { + EngineError::PsdApprovalRequired { name: decode_name.clone(), required, soft_limit, hard_limit, available_ram } + } + e => EngineError::Other(format!("{decode_name}: {e}")), + })?; let mut doc = r.document; tag_exr_colorspace(s, &decode_name, &mut doc); raw_defaults(s, &r.warnings, &mut doc); @@ -154,35 +160,66 @@ pub(super) fn open_as(s: &mut Session, p: &Value) -> Result { let path = str_param(p, "path", "file.openAs")?.to_string(); let bytes = read_file(&path)?; let as_ext = p.get("as").or_else(|| p.get("format")).and_then(Value::as_str); - let approve_high_memory = p.get("approveHighMemory").and_then(Value::as_bool).unwrap_or(false); + let approve_high_memory = match p.get("approveHighMemory") { + None | Some(Value::Null) => false, + Some(Value::Bool(b)) => *b, + Some(_) => { + return Err(EngineError::BadParams { cmd: "file.openAs".into(), msg: "expected boolean for \"approveHighMemory\"".into() }); + } + }; open_bytes_as_with_options(s, &path, &bytes, as_ext, Some(path.clone()), approve_high_memory) } pub(super) fn approve_memory(s: &mut Session, p: &Value) -> Result { let id = crate::file_cmds::u64_param(p, "id", "file.approveMemory")?; - let approve = p.get("approve").and_then(Value::as_bool).unwrap_or(true); + let approve = match p.get("approve") { + None | Some(Value::Null) => true, + Some(Value::Bool(b)) => *b, + Some(_) => { + return Err(EngineError::BadParams { cmd: "file.approveMemory".into(), msg: "expected boolean for \"approve\"".into() }); + } + }; + let background = match p.get("background") { + None | Some(Value::Null) => true, + Some(Value::Bool(b)) => *b, + Some(_) => { + return Err(EngineError::BadParams { cmd: "file.approveMemory".into(), msg: "expected boolean for \"background\"".into() }); + } + }; if !approve { - s.cancel_pending_preflight(id); + s.cancel_pending_preflight(id) + .ok_or_else(|| EngineError::BadParams { cmd: "file.approveMemory".into(), msg: format!("no pending high-memory preflight for id {id}") })?; return Ok(json!({"id": id, "approved": false, "cancelled": true})); } - let preflight = s - .take_pending_preflight(id) - .ok_or_else(|| EngineError::BadParams { cmd: "file.approveMemory".into(), msg: format!("no pending high-memory preflight for id {id}") })?; - if !preflight.fingerprint.matches(&preflight.source) { - return Err(EngineError::Other("the file has changed since it was checked; please try opening it again".into())); + #[cfg(target_arch = "wasm32")] + { + return Err(EngineError::Other("high-memory approval is not available on the web (WebAssembly limit is 1 GiB)".into())); } - let snapshot = photocraft_io::memory::MemorySnapshot::current(); - let budget = photocraft_io::memory::resolve_budget(snapshot, None); - if preflight.required > budget.hard_limit { - return Err(EngineError::Other(format!( - "available memory has decreased; opening this file needs {} bytes, exceeding current hard limit {} bytes", - preflight.required, budget.hard_limit - ))); - } - let started = s.start_open_approved(&preflight.name, preflight.source, preflight.confirm_damage)?; - match started { - crate::jobs::Started::Done(v) => Ok(json!({"id": id, "approved": true, "result": v})), - crate::jobs::Started::Job(job_id) => Ok(json!({"id": id, "approved": true, "job": job_id.0})), + #[cfg(not(target_arch = "wasm32"))] + { + let preflight = s + .take_pending_preflight(id) + .ok_or_else(|| EngineError::BadParams { cmd: "file.approveMemory".into(), msg: format!("no pending high-memory preflight for id {id}") })?; + let snapshot = photocraft_io::memory::MemorySnapshot::current(); + let budget = photocraft_io::memory::resolve_budget(snapshot, None); + if preflight.required > budget.hard_limit { + return Err(EngineError::Other(format!( + "available memory has decreased; opening this file needs {} bytes, exceeding current hard limit {} bytes", + preflight.required, budget.hard_limit + ))); + } + let started = s.start_open_approved_with_background( + &preflight.name, + preflight.source, + preflight.confirm_damage, + Some(preflight.required), + Some(preflight.fingerprint), + background, + )?; + match started { + crate::jobs::Started::Done(v) => Ok(json!({"id": id, "approved": true, "result": v})), + crate::jobs::Started::Job(job_id) => Ok(json!({"id": id, "approved": true, "job": job_id.0})), + } } } @@ -201,10 +238,22 @@ pub fn place_bytes(s: &mut Session, name: &str, bytes: Vec, linked: Option false, + Some(Value::Bool(b)) => *b, + Some(_) => { + return Err(EngineError::BadParams { cmd: "file.placeEmbedded".into(), msg: "expected boolean for \"approveHighMemory\"".into() }); + } + }; let mut opts = crate::jobs::import_options(s.prefs()); opts.psd_high_memory_approved = approve; - let imported = photocraft_io::import_with_options(name, &bytes, &opts).map_err(|e| EngineError::Other(format!("{name}: {e}")))?; + opts.psd_approved_estimate = None; + let imported = photocraft_io::import_with_options(name, &bytes, &opts).map_err(|e| match e { + photocraft_io::IoError::PsdApprovalRequired { required, soft_limit, hard_limit, available_ram } => { + EngineError::PsdApprovalRequired { name: name.to_string(), required, soft_limit, hard_limit, available_ram } + } + e => EngineError::Other(format!("{name}: {e}")), + })?; if imported.preview_only { return Err(EngineError::Other(format!( "{name}: only this Affinity file's embedded preview could be read; open it with File › Open to see the warning, or export PSD or PNG from Affinity before using it here" diff --git a/crates/engine/src/cmd/file_cmds/tests.rs b/crates/engine/src/cmd/file_cmds/tests.rs index b76fd74c9c..958565084c 100644 --- a/crates/engine/src/cmd/file_cmds/tests.rs +++ b/crates/engine/src/cmd/file_cmds/tests.rs @@ -1996,22 +1996,52 @@ fn approve_memory_validates_parameters_and_unknown_ids() { assert!(matches!(s.execute("file.approveMemory", json!({})), Err(EngineError::BadParams { .. }))); assert!(matches!(s.execute("file.approveMemory", json!({"id": "not_a_number"})), Err(EngineError::BadParams { .. }))); assert!(matches!(s.execute("file.approveMemory", json!({"id": 12345})), Err(EngineError::BadParams { .. }))); + assert!( + matches!(s.execute("file.approveMemory", json!({"id": 12345, "approve": false})), Err(EngineError::BadParams { .. })), + "unknown id on cancel must fail" + ); + assert!( + matches!(s.execute("file.approveMemory", json!({"id": 12345, "approve": "true"})), Err(EngineError::BadParams { .. })), + "wrong approve type string must BadParams" + ); + assert!( + matches!(s.execute("file.approveMemory", json!({"id": 12345, "approve": 1})), Err(EngineError::BadParams { .. })), + "wrong approve type int must BadParams" + ); + assert!( + matches!(s.execute("file.openAs", json!({"path": "a.psd", "approveHighMemory": "true"})), Err(EngineError::BadParams { .. })), + "wrong approveHighMemory on openAs must BadParams" + ); + assert!( + matches!(s.execute("file.placeEmbedded", json!({"path": "a.psd", "approveHighMemory": 1})), Err(EngineError::BadParams { .. })), + "wrong approveHighMemory on placeEmbedded must BadParams" + ); + assert!( + matches!(s.execute("file.approveMemory", json!({"id": 12345, "background": "true"})), Err(EngineError::BadParams { .. })), + "wrong background type string must BadParams" + ); + assert!( + matches!(s.execute("file.approveMemory", json!({"id": 12345, "background": 1})), Err(EngineError::BadParams { .. })), + "wrong background type int must BadParams" + ); } #[test] fn approve_memory_cancels_and_removes_pending_preflight() { let mut s = Session::new(); - let id = s.store_pending_preflight(crate::jobs::PendingPsdPreflight { - id: 0, - name: "test.psd".into(), - source: crate::jobs::OpenSource::Bytes(std::sync::Arc::new(vec![1, 2, 3])), - fingerprint: crate::jobs::SourceFingerprint::of(&crate::jobs::OpenSource::Bytes(std::sync::Arc::new(vec![1, 2, 3]))).unwrap(), - required: 1000, - soft_limit: 500, - hard_limit: 2000, - available_ram: Some(4000), - confirm_damage: true, - }); + let id = s + .store_pending_preflight(crate::jobs::PendingPsdPreflight { + id: 0, + name: "test.psd".into(), + source: crate::jobs::OpenSource::Bytes(std::sync::Arc::new(vec![1, 2, 3])), + fingerprint: crate::jobs::SourceFingerprint::of(&crate::jobs::OpenSource::Bytes(std::sync::Arc::new(vec![1, 2, 3]))).unwrap(), + required: 1000, + soft_limit: 500, + hard_limit: 2000, + available_ram: Some(4000), + confirm_damage: true, + }) + .unwrap(); let res = s.execute("file.approveMemory", json!({"id": id, "approve": false})).unwrap(); assert_eq!(res["approved"], false); assert_eq!(res["cancelled"], true); @@ -2027,17 +2057,19 @@ fn approve_memory_refuses_when_source_changes_before_confirmation() { let mut s = Session::new(); let src = crate::jobs::OpenSource::Path(path.clone()); let fp = crate::jobs::SourceFingerprint::of(&src).unwrap(); - let id = s.store_pending_preflight(crate::jobs::PendingPsdPreflight { - id: 0, - name: "change.psd".into(), - source: src, - fingerprint: fp, - required: 1000, - soft_limit: 500, - hard_limit: 2000, - available_ram: Some(4000), - confirm_damage: true, - }); + let id = s + .store_pending_preflight(crate::jobs::PendingPsdPreflight { + id: 0, + name: "change.psd".into(), + source: src, + fingerprint: fp, + required: 1000, + soft_limit: 500, + hard_limit: 2000, + available_ram: Some(4000), + confirm_damage: true, + }) + .unwrap(); // Modify file on disk std::fs::write(&path, b"changed content longer").unwrap(); @@ -2052,17 +2084,19 @@ fn approve_memory_refuses_when_available_ram_decreases() { let mut s = Session::new(); let src = crate::jobs::OpenSource::Bytes(std::sync::Arc::new(vec![0; 16])); let fp = crate::jobs::SourceFingerprint::of(&src).unwrap(); - let id = s.store_pending_preflight(crate::jobs::PendingPsdPreflight { - id: 0, - name: "ram_drop.psd".into(), - source: src, - fingerprint: fp, - required: 8 * gb, - soft_limit: 6 * gb, - hard_limit: 9 * gb, - available_ram: Some(12 * gb), - confirm_damage: true, - }); + let id = s + .store_pending_preflight(crate::jobs::PendingPsdPreflight { + id: 0, + name: "ram_drop.psd".into(), + source: src, + fingerprint: fp, + required: 8 * gb, + soft_limit: 6 * gb, + hard_limit: 9 * gb, + available_ram: Some(12 * gb), + confirm_damage: true, + }) + .unwrap(); // Memory drops: only 6 GiB available -> H = 4.5 GiB < 8 GiB let snap_low = photocraft_io::memory::MemorySnapshot { available_ram: Some(6 * gb), swap_free: None, commit_headroom: None }; @@ -2071,3 +2105,40 @@ fn approve_memory_refuses_when_available_ram_decreases() { assert!(res.is_err(), "decreased memory must refuse"); }); } + +#[test] +fn pending_preflights_cap_fails_closed() { + let mut s = Session::new(); + let src = crate::jobs::OpenSource::Bytes(std::sync::Arc::new(vec![0; 4])); + let fp = crate::jobs::SourceFingerprint::of(&src).unwrap(); + for i in 0..crate::jobs::Session::MAX_PENDING_PREFLIGHTS { + assert!( + s.store_pending_preflight(crate::jobs::PendingPsdPreflight { + id: 0, + name: format!("file_{i}.psd"), + source: src.clone(), + fingerprint: fp.clone(), + required: 1000, + soft_limit: 500, + hard_limit: 2000, + available_ram: Some(4000), + confirm_damage: true, + }) + .is_ok() + ); + } + assert!( + s.store_pending_preflight(crate::jobs::PendingPsdPreflight { + id: 0, + name: "overflow.psd".into(), + source: src, + fingerprint: fp, + required: 1000, + soft_limit: 500, + hard_limit: 2000, + available_ram: Some(4000), + confirm_damage: true, + }) + .is_err() + ); +} diff --git a/crates/engine/src/error.rs b/crates/engine/src/error.rs index 323d3f5203..1a8342099d 100644 --- a/crates/engine/src/error.rs +++ b/crates/engine/src/error.rs @@ -18,6 +18,9 @@ pub enum EngineError { /// ids are not positions (#5058). #[error("no such layer {}; {known}", id.0)] UnknownLayer { id: LayerId, known: String }, + /// Opening a PSD/PSB file requires explicit high-memory confirmation. + #[error("opening “{name}” requires explicit memory approval: {required} bytes required (soft limit {soft_limit}, hard limit {hard_limit})")] + PsdApprovalRequired { name: String, required: u64, soft_limit: u64, hard_limit: u64, available_ram: Option }, #[error("{0}")] Other(String), /// A background job was cancelled (see [`jobs`]); nothing changed. diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index f7f55d08c2..c6f5623036 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -531,43 +531,52 @@ pub fn import_options(prefs: &crate::prefs::Preferences) -> photocraft_io::Impor /// Fingerprint verifying that a source has not changed between preflight and approval. #[derive(Clone, Debug, PartialEq, Eq)] pub enum SourceFingerprint { - Bytes { len: usize, hash: [u8; 32] }, - Path { path: String, len: u64, modified_nanos: u128 }, + Bytes { + len: usize, + identity: usize, + }, + Path { + path: String, + len: u64, + modified: std::time::SystemTime, + #[cfg(unix)] + dev: u64, + #[cfg(unix)] + ino: u64, + }, } impl SourceFingerprint { pub fn of(source: &OpenSource) -> Result { match source { - OpenSource::Bytes(b) => { - let hash = *blake3::hash(b).as_bytes(); - Ok(SourceFingerprint::Bytes { len: b.len(), hash }) - } + OpenSource::Bytes(b) => Ok(SourceFingerprint::Bytes { len: b.len(), identity: Arc::as_ptr(b) as usize }), OpenSource::Path(p) => { let meta = std::fs::metadata(p).map_err(|e| EngineError::Other(format!("{p}: {e}")))?; let len = meta.len(); - let modified_nanos = meta.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map_or(0, |d| d.as_nanos()); - Ok(SourceFingerprint::Path { path: p.clone(), len, modified_nanos }) + let modified = meta.modified().map_err(|e| EngineError::Other(format!("{p}: failed to read modification time: {e}")))?; + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + let dev = meta.dev(); + let ino = meta.ino(); + Ok(SourceFingerprint::Path { path: p.clone(), len, modified, dev, ino }) + } + #[cfg(not(unix))] + { + Ok(SourceFingerprint::Path { path: p.clone(), len, modified }) + } } } } - pub fn matches(&self, source: &OpenSource) -> bool { - match (self, source) { - (SourceFingerprint::Bytes { len, hash }, OpenSource::Bytes(b)) => b.len() == *len && blake3::hash(b).as_bytes() == hash, - (SourceFingerprint::Path { path, len, modified_nanos }, OpenSource::Path(p)) => { - if path != p { - return false; - } - match std::fs::metadata(p) { - Ok(meta) => { - let cur_len = meta.len(); - let cur_mod = meta.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map_or(0, |d| d.as_nanos()); - cur_len == *len && cur_mod == *modified_nanos - } - Err(_) => false, - } - } - _ => false, + pub fn matches(&self, other: &Self) -> bool { + self == other + } + + pub fn matches_source(&self, source: &OpenSource) -> bool { + match Self::of(source) { + Ok(cur) => self.matches(&cur), + Err(_) => false, } } } @@ -611,11 +620,21 @@ impl Session { /// result is `{held, name, warnings, color}` (`color` the question): take it with /// [`Session::take_held_open`] once answered. - pub fn store_pending_preflight(&mut self, mut preflight: PendingPsdPreflight) -> u64 { - self.next_pending_preflight = self.next_pending_preflight.wrapping_add(1); + pub const MAX_PENDING_PREFLIGHTS: usize = 32; + + pub fn store_pending_preflight(&mut self, mut preflight: PendingPsdPreflight) -> Result { + if self.pending_preflights.len() >= Self::MAX_PENDING_PREFLIGHTS { + return Err(EngineError::Other("too many pending high-memory preflights waiting for confirmation".into())); + } + loop { + self.next_pending_preflight = self.next_pending_preflight.checked_add(1).unwrap_or(1); + if self.next_pending_preflight != 0 && !self.pending_preflights.iter().any(|p| p.id == self.next_pending_preflight) { + break; + } + } preflight.id = self.next_pending_preflight; self.pending_preflights.push(preflight); - self.next_pending_preflight + Ok(self.next_pending_preflight) } pub fn take_pending_preflight(&mut self, id: u64) -> Option { @@ -631,46 +650,91 @@ impl Session { self.pending_preflights.clear(); } - pub fn start_open_approved(&mut self, name: &str, source: OpenSource, confirm_damage: bool) -> Result { - self.start_open_inner(name, source, confirm_damage, true) + pub fn start_open_approved( + &mut self, + name: &str, + source: OpenSource, + confirm_damage: bool, + approved_estimate: Option, + expected_fingerprint: Option, + ) -> Result { + self.start_open_approved_with_background(name, source, confirm_damage, approved_estimate, expected_fingerprint, true) + } + + pub fn start_open_approved_with_background( + &mut self, + name: &str, + source: OpenSource, + confirm_damage: bool, + approved_estimate: Option, + expected_fingerprint: Option, + background: bool, + ) -> Result { + self.start_open_inner(name, source, confirm_damage, true, approved_estimate, expected_fingerprint, background) } pub fn start_open(&mut self, name: &str, source: OpenSource) -> Result { - self.start_open_inner(name, source, false, false) + self.start_open_inner(name, source, false, false, None, None, true) } /// Interactive opening: hold a damaged PSD until the user answers a damage /// confirmation, without changing import or subsequent save behaviour. /// Headless callers keep using [`Self::start_open`]. pub fn start_open_for_user(&mut self, name: &str, source: OpenSource) -> Result { - self.start_open_inner(name, source, true, false) + self.start_open_for_user_with_background(name, source, true) + } + + pub fn start_open_for_user_with_background(&mut self, name: &str, source: OpenSource, background: bool) -> Result { + self.start_open_inner(name, source, true, false, None, None, background) } /// Decodes a file in a job and optionally holds damaged PSD content for /// interactive confirmation while leaving headless opens non-interactive. - fn start_open_inner(&mut self, name: &str, source: OpenSource, confirm_damage: bool, approve_high_memory: bool) -> Result { + // Keep the worker and apply closures together with their execution options. + #[allow(clippy::too_many_arguments)] + fn start_open_inner( + &mut self, + name: &str, + source: OpenSource, + confirm_damage: bool, + approve_high_memory: bool, + approved_estimate: Option, + expected_fingerprint: Option, + background: bool, + ) -> Result { let label = format!("Opening {name}"); let name_w = name.to_string(); let name_a = name.to_string(); let mut opts = import_options(self.prefs()); opts.psd_high_memory_approved = approve_high_memory; - self.start_job( + opts.psd_approved_estimate = approved_estimate; + let test_memory_scope = photocraft_io::memory::TestMemoryScope::capture(); + enum WorkResult { + Decoded((photocraft_io::ImportResult, bool)), + PreflightNeeded { required: u64, soft_limit: u64, hard_limit: u64, available_ram: Option, source: OpenSource, fingerprint: SourceFingerprint }, + } + self.start_job_with_spawn( OPEN_JOB, json!({"name": name}), &label, false, + background, move |ctx| { + let _test_memory = test_memory_scope.enter(); ctx.progress(0.0, "Reading"); - enum WorkResult { - Decoded((photocraft_io::ImportResult, bool)), - PreflightNeeded { - required: u64, - soft_limit: u64, - hard_limit: u64, - available_ram: Option, - source: OpenSource, - fingerprint: SourceFingerprint, - }, + + // Identity lookup is best-effort for ordinary opens, but mandatory if a + // file is held for confirmation. Valid non-PSD files do not require timestamps. + let initial_fingerprint = (confirm_damage || expected_fingerprint.is_some()).then(|| SourceFingerprint::of(&source)).transpose(); + if let Some(expected) = &expected_fingerprint { + let cur = initial_fingerprint + .as_ref() + .map_err(|e| EngineError::Other(e.to_string()))? + .as_ref() + .ok_or_else(|| EngineError::Other("missing source identity".into()))?; + if !expected.matches(cur) { + return Err(EngineError::Other("the file has changed since it was checked; please try opening it again".into())); + } } let outcome = match source { @@ -683,8 +747,19 @@ impl Session { Err(photocraft_io::IoError::PsdApprovalRequired { required, soft_limit, hard_limit, available_ram }) if confirm_damage => { let src = OpenSource::Bytes(bytes); let fingerprint = SourceFingerprint::of(&src)?; + let initial = initial_fingerprint + .as_ref() + .map_err(|e| EngineError::Other(e.to_string()))? + .as_ref() + .ok_or_else(|| EngineError::Other("missing source identity".into()))?; + if !initial.matches(&fingerprint) { + return Err(EngineError::Other("the file changed while checking its memory requirements; please open it again".into())); + } WorkResult::PreflightNeeded { required, soft_limit, hard_limit, available_ram, source: src, fingerprint } } + Err(photocraft_io::IoError::PsdApprovalRequired { required, soft_limit, hard_limit, available_ram }) => { + return Err(EngineError::PsdApprovalRequired { name: name_w.clone(), required, soft_limit, hard_limit, available_ram }); + } Err(photocraft_io::IoError::Cancelled) => return Err(EngineError::Cancelled), Err(e) => return Err(EngineError::Other(e.to_string())), } @@ -696,8 +771,19 @@ impl Session { Err(photocraft_io::IoError::PsdApprovalRequired { required, soft_limit, hard_limit, available_ram }) if confirm_damage => { let src = OpenSource::Path(p); let fingerprint = SourceFingerprint::of(&src)?; + let initial = initial_fingerprint + .as_ref() + .map_err(|e| EngineError::Other(e.to_string()))? + .as_ref() + .ok_or_else(|| EngineError::Other("missing source identity".into()))?; + if !initial.matches(&fingerprint) { + return Err(EngineError::Other("the file changed while checking its memory requirements; please open it again".into())); + } WorkResult::PreflightNeeded { required, soft_limit, hard_limit, available_ram, source: src, fingerprint } } + Err(photocraft_io::IoError::PsdApprovalRequired { required, soft_limit, hard_limit, available_ram }) => { + return Err(EngineError::PsdApprovalRequired { name: name_w.clone(), required, soft_limit, hard_limit, available_ram }); + } Err(photocraft_io::IoError::Cancelled) => return Err(EngineError::Cancelled), Err(e) => return Err(EngineError::Other(e.to_string())), } @@ -730,7 +816,7 @@ impl Session { hard_limit, available_ram, confirm_damage, - }); + })?; Ok(json!({ "pendingPreflight": id, "name": name_a, @@ -775,9 +861,25 @@ impl Session { work: impl FnOnce(&JobCtx) -> Result + Send + 'static, apply: impl FnOnce(&mut Session, T) -> Result + Send + 'static, ) -> Result { - self.jobs.spawn = true; + self.start_job_with_spawn(command, params, label, lock_document, true, work, apply) + } + + // Keep the worker and apply closures together with their execution options. + #[allow(clippy::too_many_arguments)] + pub fn start_job_with_spawn( + &mut self, + command: &str, + params: Value, + label: &str, + lock_document: bool, + spawn: bool, + work: impl FnOnce(&JobCtx) -> Result + Send + 'static, + apply: impl FnOnce(&mut Session, T) -> Result + Send + 'static, + ) -> Result { + let prev_spawn = self.jobs.spawn; + self.jobs.spawn = spawn && !cfg!(target_arch = "wasm32"); let r = run(self, label, lock_document, work, apply); - self.jobs.spawn = false; + self.jobs.spawn = prev_spawn; let pending = self.jobs.pending.take(); match (r, pending) { (Err(e), Some(job)) => { diff --git a/crates/io/Cargo.toml b/crates/io/Cargo.toml index 9fe5497f5c..ef6ddffc68 100644 --- a/crates/io/Cargo.toml +++ b/crates/io/Cargo.toml @@ -14,6 +14,8 @@ workspace = true corpus = [] # HEIC/HEIF decode (forwarded to photocraft-codecs; see its `heif` feature). heif = ["photocraft-codecs/heif"] +# Test support for synthetic memory injection and verification across crates. +testgen = [] [dependencies] photocraft-geom = { workspace = true } diff --git a/crates/io/src/flat.rs b/crates/io/src/flat.rs index 44908d8af5..803a09e580 100644 --- a/crates/io/src/flat.rs +++ b/crates/io/src/flat.rs @@ -16,7 +16,7 @@ const BAND_BYTES: usize = 32 << 20; /// Decodes a flat image into a single-layer document; a TIFF with Photoshop layer data opens /// layered (see [`crate::tiff_layers`]). pub fn import_flat(name: &str, bytes: &[u8]) -> Result { - import_flat_with_limit(name, bytes, 0) + import_flat_with_limit(name, bytes, crate::DEFAULT_PSD_IMPORT_PIXEL_BYTES) } /// [`import_flat`] with the caller's PSD pixel allowance, which a layered TIFF is held to. @@ -24,7 +24,7 @@ pub(crate) fn import_flat_with_limit(name: &str, bytes: &[u8], pixel_limit: u64) // Detected once; every step below branches on it. let format = codecs::detect(bytes); if format == Some(Format::Tiff) { - let limit = if pixel_limit > 0 { pixel_limit } else { crate::memory::resolve_budget(crate::memory::MemorySnapshot::current(), None).hard_limit }; + let limit = if pixel_limit > 0 { pixel_limit } else { crate::DEFAULT_PSD_IMPORT_PIXEL_BYTES }; return import_tiff_page_with_limit(name, bytes, None, limit); } let img = codecs::decode_as(format.ok_or(codecs::CodecError::UnknownFormat)?, bytes)?; diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 550cc43588..5b5cea69a0 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -66,7 +66,7 @@ pub use pcraft_cache::PcraftExportCache; pub mod pixels; pub mod psd_budget; mod psd_export; -pub mod psd_gate; +mod psd_gate; mod psd_import; pub use psd_budget::{DEFAULT_PSD_IMPORT_PIXEL_BYTES, PsdMemoryEstimate, estimate as estimate_psd_memory}; pub mod psd_thumbnail; @@ -360,6 +360,9 @@ pub struct ImportOptions { pub psd_max_pixel_bytes: u64, /// Explicit one-shot authorization for imports that exceed the soft budget but fit within the hard budget. pub psd_high_memory_approved: bool, + /// Optional estimate limit for high-memory approval. When set, approval only applies if + /// the fresh preflight estimate does not exceed this amount. + pub psd_approved_estimate: Option, } impl Default for ImportOptions { @@ -369,6 +372,7 @@ impl Default for ImportOptions { demosaic: raw::Demosaic::default(), psd_max_pixel_bytes: 0, psd_high_memory_approved: false, + psd_approved_estimate: None, } } } @@ -411,46 +415,74 @@ fn import_psd( name: &str, ctl: &photocraft_raster::Interrupt, damaged: &mut bool, - caller_limit: u64, - high_memory_approved: bool, + opts: &ImportOptions, parse: impl FnOnce( &dyn Fn(&PsdFile) -> photocraft_psd::Result, &dyn Fn(&PsdFile) -> bool, ) -> photocraft_psd::Result<(PsdFile, Option>>)>, ) -> Result { - // A merged image the import reads is decoded with the parse: a ZIP one is inflated once. - // A failed read of the file (one parsed from its source) is a read error, as reading the - // whole file first would report it. let preflight_error = std::cell::RefCell::new(None); + let admission_guard: std::cell::RefCell> = std::cell::RefCell::new(None); + let parsed = parse( &|file| { // Only an import that keeps the layer tree is bound by the group nesting cap. let depth = if psd_import::can_recover_layers(file) { psd_import::check_group_depth(file) } else { Ok(()) }; - let checked = depth.and_then(|()| { + let preflight_check: Result = (|| { + depth?; let est = psd_budget::estimate(file)?; - let snapshot = memory::MemorySnapshot::current(); - let limit_opt = (caller_limit > 0).then_some(caller_limit); - let budget = memory::resolve_budget(snapshot, limit_opt); - match budget.decide(est.total_bytes) { - memory::BudgetDecision::Automatic => Ok(()), - memory::BudgetDecision::RequiresApproval if high_memory_approved => Ok(()), - memory::BudgetDecision::RequiresApproval => Err(IoError::PsdApprovalRequired { - required: est.total_bytes, - soft_limit: budget.soft_limit, - hard_limit: budget.hard_limit, - available_ram: budget.available_ram, - }), - memory::BudgetDecision::Refused => Err(IoError::PsdImportBudget { required: est.total_bytes, limit: budget.hard_limit }), + + if psd_gate::is_holding_admission() { + // Nested import: count against shared remaining allowance, never reset parent on nested import + psd_gate::consume_attempt_allowance(est.total_bytes)?; + let guard = psd_gate::acquire_decode_admission(ctl)?; + *admission_guard.borrow_mut() = Some(guard); + return Ok(psd_import::merged_needed(file)); + } + + // 1. Initial admission check BEFORE waiting on or acquiring the gate. + // Invalid or unapproved files fail fast without blocking other decodes or acquiring gate. + memory::evaluate_psd_admission( + est.total_bytes, + opts.psd_max_pixel_bytes, + opts.psd_high_memory_approved, + opts.psd_approved_estimate, + memory::MemorySnapshot::current(), + )?; + + // 2. Cancellation-aware gate acquisition BEFORE any merged or layer pixel decoding/inflation. + let guard = psd_gate::acquire_decode_admission(ctl)?; + + // 3. Re-evaluate adaptive budget immediately after gate acquisition. + // If available memory dropped while waiting for gate, this returns Err, + // guard is dropped (releasing gate immediately), and error is returned before prompting. + let outcome = memory::evaluate_psd_admission( + est.total_bytes, + opts.psd_max_pixel_bytes, + opts.psd_high_memory_approved, + opts.psd_approved_estimate, + memory::MemorySnapshot::current(), + )?; + + // Initialize enclosing attempt allowance for any nested decodes. + psd_gate::set_active_attempt_budget(outcome.authorized_total.saturating_sub(est.total_bytes)); + + // Retain guard across parse and conversion + *admission_guard.borrow_mut() = Some(guard); + Ok(psd_import::merged_needed(file)) + })(); + + match preflight_check { + Ok(want_merged) => Ok(want_merged), + Err(error) => { + *preflight_error.borrow_mut() = Some(error); + Err(PsdError::LimitExceeded("PSD import preflight refused the file")) } - }); - if let Err(error) = checked { - *preflight_error.borrow_mut() = Some(error); - return Err(PsdError::LimitExceeded("PSD import preflight refused the file")); } - Ok(psd_import::merged_needed(file)) }, &psd_import::can_recover_layers, ); + if let Some(error) = preflight_error.into_inner() { return Err(error); } @@ -462,30 +494,6 @@ fn import_psd( ctl.check().map_err(|_| IoError::Cancelled)?; ctl.progress(0.05); - // Acquire process-wide admission gate to prevent concurrent decodes spending the same headroom - let _admission = psd_gate::acquire_decode_admission(ctl)?; - - // Recheck memory availability immediately before decoding - let snapshot_recheck = memory::MemorySnapshot::current(); - let limit_opt = (caller_limit > 0).then_some(caller_limit); - let budget_recheck = memory::resolve_budget(snapshot_recheck, limit_opt); - let est = psd_budget::estimate(&file)?; - match budget_recheck.decide(est.total_bytes) { - memory::BudgetDecision::Automatic => {} - memory::BudgetDecision::RequiresApproval if high_memory_approved => {} - memory::BudgetDecision::RequiresApproval => { - return Err(IoError::PsdApprovalRequired { - required: est.total_bytes, - soft_limit: budget_recheck.soft_limit, - hard_limit: budget_recheck.hard_limit, - available_ram: budget_recheck.available_ram, - }); - } - memory::BudgetDecision::Refused => { - return Err(IoError::PsdImportBudget { required: est.total_bytes, limit: budget_recheck.hard_limit }); - } - } - // Read before the conversion, which moves the global blocks into the document. let txt2 = file.global_blocks.iter().find(|b| &b.key == b"Txt2").and_then(|b| photocraft_text::psd::parse_txt2(&b.data)); let (mut document, warnings, psd_damaged, fatal_error) = psd_import::psd_to_document_owned(file, ctl, merged).ok_or(IoError::Cancelled)?; @@ -521,29 +529,37 @@ pub fn import_file_for_open_with( let bytes = photocraft_format::read_file(path).map_err(read_failed)?; return import_for_open_with(name, &bytes, opts, ctl); } - import_source_with_limit(name, photocraft_psd::Source::new(source), ctl, opts.psd_max_pixel_bytes, opts.psd_high_memory_approved) + import_source_with_options(name, photocraft_psd::Source::new(source), ctl, opts) } /// [`import_file_for_open_with`] for a PSD/PSB in any [`photocraft_psd::ReadAt`] source. pub fn import_source_for_open_with(name: &str, source: photocraft_psd::Source, ctl: &photocraft_raster::Interrupt) -> Result<(ImportResult, bool), IoError> { - import_source_with_limit(name, source, ctl, 0, false) + import_source_with_options(name, source, ctl, &ImportOptions::default()) } -fn import_source_with_limit( +/// [`import_source_for_open_with`] with custom import options. +pub fn import_source_with_options( + name: &str, + source: photocraft_psd::Source, + ctl: &photocraft_raster::Interrupt, + opts: &ImportOptions, +) -> Result<(ImportResult, bool), IoError> { + ctl.check().map_err(|_| IoError::Cancelled)?; + let mut damaged = false; + let r = import_psd(name, ctl, &mut damaged, opts, |want, recover| PsdFile::from_source_and_merged_preflight(source, want, recover))?; + ctl.check().map_err(|_| IoError::Cancelled)?; + ctl.progress(1.0); + Ok((r, damaged)) +} + +pub(crate) fn import_source_with_limit( name: &str, source: photocraft_psd::Source, ctl: &photocraft_raster::Interrupt, pixel_limit: u64, - high_memory_approved: bool, ) -> Result<(ImportResult, bool), IoError> { - ctl.check().map_err(|_| IoError::Cancelled)?; - let mut damaged = false; - let r = import_psd(name, ctl, &mut damaged, pixel_limit, high_memory_approved, |want, recover| { - PsdFile::from_source_and_merged_preflight(source, want, recover) - })?; - ctl.check().map_err(|_| IoError::Cancelled)?; - ctl.progress(1.0); - Ok((r, damaged)) + let opts = ImportOptions { psd_max_pixel_bytes: pixel_limit, ..Default::default() }; + import_source_with_options(name, source, ctl, &opts) } /// [`import_file_for_open_with`] without the damage flag. @@ -563,9 +579,7 @@ fn import_stages(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt, o // Magic first: an Affinity file renamed .psd is still Affinity (#5258 corpus // preview_never_saves_back_to_its_native_or_renamed_source). if is_psd(bytes) { - return import_psd(name, ctl, damaged, opts.psd_max_pixel_bytes, opts.psd_high_memory_approved, |want, recover| { - PsdFile::from_bytes_and_merged_preflight(bytes, want, recover) - }); + return import_psd(name, ctl, damaged, opts, |want, recover| PsdFile::from_bytes_and_merged_preflight(bytes, want, recover)); } if affinity::is_affinity(bytes) || affinity::has_extension(name) { return affinity::import(name, bytes); @@ -598,9 +612,7 @@ fn import_stages(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt, o // A `.psd`/`.psb`/`.psdt` with no raster signature keeps the PSD error instead of // `codec: unrecognized image format` (#5258). Magic already returned above. if is_psd_input(name, bytes) { - return import_psd(name, ctl, damaged, opts.psd_max_pixel_bytes, opts.psd_high_memory_approved, |want, recover| { - PsdFile::from_bytes_and_merged_preflight(bytes, want, recover) - }); + return import_psd(name, ctl, damaged, opts, |want, recover| PsdFile::from_bytes_and_merged_preflight(bytes, want, recover)); } flat::import_flat_with_limit(name, bytes, opts.psd_max_pixel_bytes) } diff --git a/crates/io/src/memory.rs b/crates/io/src/memory.rs index 6c49e5cb1d..68eacc3b61 100644 --- a/crates/io/src/memory.rs +++ b/crates/io/src/memory.rs @@ -32,6 +32,11 @@ pub struct MemorySnapshot { pub commit_headroom: Option, } +#[cfg(any(test, feature = "testgen"))] +thread_local! { + static MOCK_SNAPSHOT: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; +} + impl MemorySnapshot { /// Unknown memory measurement. pub const UNKNOWN: Self = Self { available_ram: None, swap_free: None, commit_headroom: None }; @@ -39,8 +44,10 @@ impl MemorySnapshot { /// Obtains the current system memory snapshot from platform providers. pub fn current() -> Self { #[cfg(any(test, feature = "testgen"))] - if let Some(mock) = MOCK_SNAPSHOT.with(|m| *m.borrow()) { - return mock; + { + if let Some(mock) = MOCK_SNAPSHOT.with(|m| *m.borrow()) { + return mock; + } } #[cfg(target_os = "linux")] @@ -96,10 +103,10 @@ impl MemoryBudget { /// Resolves the adaptive hard and soft import budgets from a snapshot and an optional caller limit. /// /// Policy: -/// - Hard H = min(75% A, saturating A - 1 GiB, C - reserve if known, platform/address-space limits) +/// - Hard H = min(floor(3*A/4), saturating A - 1 GiB, C - reserve if known, platform/address-space limits) /// - Soft S = min(50% A, 8 GiB, H) /// - <= S: automatic; S < E <= H: explicit approval; > H: refuse, no unlimited override. -/// - Unknown native fallback: 2 GiB, no override (S = H = 2 GiB). +/// - Unknown native fallback: 2 GiB bounded by C - reserve if known (S = H). /// - Browser/wasm: fixed 1 GiB (S = H = 1 GiB). /// - Explicit caller limits may lower admission, never bypass the hard boundary. pub fn resolve_budget(snapshot: MemorySnapshot, caller_limit: Option) -> MemoryBudget { @@ -116,7 +123,7 @@ pub fn resolve_budget(snapshot: MemorySnapshot, caller_limit: Option) -> Me let (mut hard, mut soft) = match snapshot.available_ram { Some(a) => { - let h_pct = a.checked_mul(3).map_or(u64::MAX, |v| v / 4); + let h_pct = ((a as u128 * 3) / 4) as u64; let h_reserve = a.saturating_sub(1 << 30); let mut h = h_pct.min(h_reserve); @@ -132,7 +139,11 @@ pub fn resolve_budget(snapshot: MemorySnapshot, caller_limit: Option) -> Me (h, s) } None => { - let limit = NATIVE_FALLBACK_BUDGET.min(address_space_cap); + let mut limit = NATIVE_FALLBACK_BUDGET.min(address_space_cap); + if let Some(c) = snapshot.commit_headroom { + let c_room = c.saturating_sub(COMMIT_RESERVE); + limit = limit.min(c_room); + } (limit, limit) } }; @@ -145,6 +156,85 @@ pub fn resolve_budget(snapshot: MemorySnapshot, caller_limit: Option) -> Me MemoryBudget { soft_limit: soft, hard_limit: hard, available_ram: snapshot.available_ram } } +/// Central evaluation of PSD admission against adaptive budgets and options. +/// +/// Refuses before decoding if: +/// - `required > hard_limit` (regardless of approvals: hard ceiling cannot be overridden); +/// - `required > soft_limit` and high memory is not approved; +/// - high memory is approved, but `approved_estimate` is specified and `required > approved_estimate` +/// (preventing unbounded approval for altered/enlarged files). +/// Result of evaluating an import admission request. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct AdmissionOutcome { + pub budget: MemoryBudget, + /// Total authorized memory allowance for the entire attempt (top-level + nested decodes): + /// `min(S if automatic else H if approved, caller cap if any, approved_estimate if present)`. + pub authorized_total: u64, +} + +pub(crate) fn evaluate_psd_admission( + required: u64, + caller_limit: u64, + high_memory_approved: bool, + approved_estimate: Option, + snapshot: MemorySnapshot, +) -> Result { + let limit_opt = (caller_limit > 0).then_some(caller_limit); + let budget = resolve_budget(snapshot, limit_opt); + + let authorized_total = match budget.decide(required) { + BudgetDecision::Automatic => { + let mut cap = budget.soft_limit; + if let Some(caller) = limit_opt { + cap = cap.min(caller); + } + if let Some(appr) = approved_estimate { + cap = cap.min(appr); + } + cap + } + BudgetDecision::RequiresApproval => { + if !high_memory_approved { + return Err(IoError::PsdApprovalRequired { + required, + soft_limit: budget.soft_limit, + hard_limit: budget.hard_limit, + available_ram: budget.available_ram, + }); + } + if let Some(approved) = approved_estimate { + if required > approved { + return Err(IoError::PsdApprovalRequired { + required, + soft_limit: budget.soft_limit, + hard_limit: budget.hard_limit, + available_ram: budget.available_ram, + }); + } + } + let mut cap = budget.hard_limit; + if let Some(caller) = limit_opt { + cap = cap.min(caller); + } + if let Some(appr) = approved_estimate { + cap = cap.min(appr); + } + cap + } + BudgetDecision::Refused => return Err(IoError::PsdImportBudget { required, limit: budget.hard_limit }), + }; + + if required > authorized_total { + return Err(IoError::PsdApprovalRequired { + required, + soft_limit: budget.soft_limit, + hard_limit: budget.hard_limit, + available_ram: budget.available_ram, + }); + } + Ok(AdmissionOutcome { budget, authorized_total }) +} + /// Bytes this process can still use without running out, or `None` where that isn't known. On /// Linux: the free RAM the kernel can hand out (`MemAvailable`, capped by the process's cgroup /// v2 `memory.max` and those above it) plus free swap (capped by the cgroup's `memory.swap.max` and those above it). @@ -176,31 +266,101 @@ pub(crate) fn check_fits(w: u32, h: u32, needed: u64, available: Option) -> ))) } -#[cfg(any(test, feature = "testgen"))] -thread_local! { - static MOCK_SNAPSHOT: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; -} - -/// Sets a mock memory snapshot for testing policy and import admission. +/// Sets a thread-local mock memory snapshot for testing policy and import admission. #[cfg(any(test, feature = "testgen"))] pub fn set_mock_memory(snapshot: Option) { MOCK_SNAPSHOT.with(|m| *m.borrow_mut() = snapshot); } -/// Runs a closure with a temporarily injected mock memory snapshot. +/// Runs a closure with a temporarily injected mock memory snapshot on the current thread. +/// +/// Restores the previous snapshot upon return or panic, supporting nested invocations. #[cfg(any(test, feature = "testgen"))] pub fn with_mock_memory(snapshot: MemorySnapshot, f: impl FnOnce() -> R) -> R { - struct Reset; + let prev = MOCK_SNAPSHOT.with(|m| { + let old = *m.borrow(); + *m.borrow_mut() = Some(snapshot); + old + }); + + struct Reset { + prev: Option, + } impl Drop for Reset { fn drop(&mut self) { - set_mock_memory(None); + MOCK_SNAPSHOT.with(|m| *m.borrow_mut() = self.prev); } } - set_mock_memory(Some(snapshot)); - let _reset = Reset; + + let _reset = Reset { prev }; f() } +/// Explicit test-support capture/scope object to propagate a thread-local mock +/// snapshot across worker threads without modifying global process state. +/// +/// In production, this is an inert zero-overhead type. +#[derive(Clone, Copy, Debug, Default)] +pub struct TestMemoryScope { + #[cfg(any(test, feature = "testgen"))] + mock: Option, +} + +/// RAII guard that restores the previous thread-local mock memory upon drop. +pub struct TestMemoryGuard { + #[cfg(any(test, feature = "testgen"))] + prev: Option, +} + +#[cfg(any(test, feature = "testgen"))] +impl Drop for TestMemoryGuard { + fn drop(&mut self) { + MOCK_SNAPSHOT.with(|m| *m.borrow_mut() = self.prev); + } +} + +impl TestMemoryScope { + /// Captures the current thread's mock memory snapshot, if any. + /// + /// Never captures actual system RAM as a permanent snapshot so that + /// production memory rechecks remain fresh. + pub fn capture() -> Self { + #[cfg(any(test, feature = "testgen"))] + { + let mock = MOCK_SNAPSHOT.with(|m| *m.borrow()); + Self { mock } + } + #[cfg(not(any(test, feature = "testgen")))] + { + Self {} + } + } + + /// Enters this captured mock memory scope, returning a RAII guard that + /// restores the thread's previous mock state on drop. + pub fn enter(self) -> TestMemoryGuard { + #[cfg(any(test, feature = "testgen"))] + { + let prev = MOCK_SNAPSHOT.with(|m| { + let old = *m.borrow(); + *m.borrow_mut() = self.mock; + old + }); + TestMemoryGuard { prev } + } + #[cfg(not(any(test, feature = "testgen")))] + { + TestMemoryGuard {} + } + } + + /// Executes a closure within this captured mock memory scope. + pub fn run(self, f: impl FnOnce() -> R) -> R { + let _guard = self.enter(); + f() + } +} + #[cfg(target_os = "linux")] pub(super) mod linux { use super::MemorySnapshot; @@ -252,9 +412,12 @@ pub(super) mod linux { (ram, swap) } - /// A `memory.stat` field (` ` per line). - fn stat(stat: &str, field: &str) -> Option { - stat.lines().find_map(|l| l.strip_prefix(field)?.strip_prefix(' '))?.trim().parse().ok() + fn stat(s: &str, field: &str) -> Option { + s.lines().find_map(|l| { + let mut parts = l.split_whitespace(); + let key = parts.next()?; + if key == field { parts.next()?.parse().ok() } else { None } + }) } } @@ -274,6 +437,11 @@ mod windows_mem { mod fallback_mem { use super::MemorySnapshot; + /// Fallback provider for platforms without a validated memory-availability provider (e.g. macOS). + /// + /// Available memory is reported as unknown (`None`), which applies the safe native fallback + /// budget (2 GiB) without allowing an unverified memory override. macOS dynamic paging makes + /// physical RAM a poor indicator of allocation capacity without platform-specific Mach VM APIs. pub(super) fn snapshot() -> MemorySnapshot { MemorySnapshot::UNKNOWN } @@ -365,14 +533,81 @@ mod tests { assert_eq!(b_unknown.decide(NATIVE_FALLBACK_BUDGET + 1), BudgetDecision::Refused); } + #[test] + fn unknown_ram_with_known_zero_commit_bounds_admission() { + let snap = MemorySnapshot { available_ram: None, swap_free: None, commit_headroom: Some(0) }; + let b = resolve_budget(snap, None); + assert_eq!(b.hard_limit, 0); + assert_eq!(b.soft_limit, 0); + assert_eq!(b.decide(1), BudgetDecision::Refused); + + let snap_room = MemorySnapshot { available_ram: None, swap_free: None, commit_headroom: Some(1 << 30) }; + let b_room = resolve_budget(snap_room, None); + assert_eq!(b_room.hard_limit, (1 << 30) - COMMIT_RESERVE); + assert_eq!(b_room.soft_limit, (1 << 30) - COMMIT_RESERVE); + } + #[test] fn arithmetic_overflow_safety() { let snap_huge = MemorySnapshot { available_ram: Some(u64::MAX), swap_free: Some(u64::MAX), commit_headroom: Some(u64::MAX) }; let b = resolve_budget(snap_huge, None); - assert!(b.hard_limit <= u64::MAX); + let expected_hard = ((u64::MAX as u128 * 3) / 4) as u64; + let expected_hard = if cfg!(target_pointer_width = "32") { 2 << 30 } else { expected_hard }; + assert_eq!(b.hard_limit, expected_hard); assert_eq!(b.soft_limit, MAX_SOFT_BUDGET); } + #[test] + fn nested_mock_memory_restores_previous() { + let snap1 = MemorySnapshot { available_ram: Some(100), swap_free: None, commit_headroom: None }; + let snap2 = MemorySnapshot { available_ram: Some(200), swap_free: None, commit_headroom: None }; + with_mock_memory(MemorySnapshot::UNKNOWN, || { + assert_eq!(MemorySnapshot::current(), MemorySnapshot::UNKNOWN); + with_mock_memory(snap1, || { + assert_eq!(MemorySnapshot::current().available_ram, Some(100)); + with_mock_memory(snap2, || { + assert_eq!(MemorySnapshot::current().available_ram, Some(200)); + }); + assert_eq!(MemorySnapshot::current().available_ram, Some(100)); + }); + assert_eq!(MemorySnapshot::current(), MemorySnapshot::UNKNOWN); + }); + } + + #[test] + fn central_admission_evaluation_enforces_bounds() { + if cfg!(any(target_arch = "wasm32", target_pointer_width = "32")) { + return; + } + let gb = 1u64 << 30; + let snap = MemorySnapshot { available_ram: Some(12 * gb), swap_free: None, commit_headroom: None }; + // S = 6 GiB, H = 9 GiB + + // Below S: automatic, authorized_total = S (6 GiB) + let out = evaluate_psd_admission(5 * gb, 0, false, None, snap).unwrap(); + assert_eq!(out.authorized_total, 6 * gb); + + // In [S, H]: requires approval + let err = evaluate_psd_admission(7 * gb, 0, false, None, snap).unwrap_err(); + assert!(matches!(err, IoError::PsdApprovalRequired { required, .. } if required == 7 * gb)); + + // In [S, H] with approval and no approved_estimate bound: admitted, authorized_total = H (9 GiB) + let out = evaluate_psd_admission(7 * gb, 0, true, None, snap).unwrap(); + assert_eq!(out.authorized_total, 9 * gb); + + // In [S, H] with approval but required exceeds approved_estimate: requires fresh approval + let err = evaluate_psd_admission(7 * gb, 0, true, Some(6 * gb), snap).unwrap_err(); + assert!(matches!(err, IoError::PsdApprovalRequired { .. })); + + // In [S, H] with approval and required within approved_estimate: admitted, authorized_total = approved_estimate (7 GiB) + let out = evaluate_psd_admission(7 * gb, 0, true, Some(7 * gb), snap).unwrap(); + assert_eq!(out.authorized_total, 7 * gb); + + // Exceeding H: refused even if high_memory_approved is true + let err = evaluate_psd_admission(10 * gb, 0, true, Some(10 * gb), snap).unwrap_err(); + assert!(matches!(err, IoError::PsdImportBudget { .. })); + } + #[cfg(target_os = "linux")] #[test] fn linux_memory_is_read_from_meminfo_and_the_cgroup() { @@ -411,7 +646,6 @@ mod tests { write(&leaf, "memory.stat", "anon 268435456\n"); assert_eq!(linux::cgroup_room(&leaf).0, Some(0), "no inactive_file: memory.current is all in use"); std::fs::remove_dir_all(&root).unwrap(); - assert!(available().is_some()); } #[cfg(target_os = "linux")] diff --git a/crates/io/src/psd_budget/tests.rs b/crates/io/src/psd_budget/tests.rs index 06692773c8..432441eee9 100644 --- a/crates/io/src/psd_budget/tests.rs +++ b/crates/io/src/psd_budget/tests.rs @@ -55,9 +55,11 @@ fn large_off_canvas_layer_is_rejected_without_decoding_its_tiny_data() { // section. No enormous decoded plane is ever constructed by this test. let mut bytes = file.to_bytes().unwrap(); bytes.truncate(bytes.len() - file.image_data.data.len() - 2); - assert!(matches!(crate::import("huge.psd", &bytes), Err(IoError::PsdImportBudget { .. }))); - let source = photocraft_psd::Source::new(bytes); - assert!(matches!(crate::import_source_for_open_with("huge.psd", source, &photocraft_raster::Interrupt::NONE), Err(IoError::PsdImportBudget { .. }))); + crate::memory::with_mock_memory(crate::memory::MemorySnapshot::UNKNOWN, || { + assert!(matches!(crate::import("huge.psd", &bytes), Err(IoError::PsdImportBudget { .. }))); + let source = photocraft_psd::Source::new(bytes); + assert!(matches!(crate::import_source_for_open_with("huge.psd", source, &photocraft_raster::Interrupt::NONE), Err(IoError::PsdImportBudget { .. }))); + }); } #[test] @@ -119,9 +121,12 @@ fn accepted_off_canvas_pixels_and_depths_are_preserved() { fn legacy_conversion_reports_a_rejected_import_instead_of_decoding_it() { let mut file = testgen::small(Version::Psd, Compression::Zip); file.layers_mut()[0].rect = Rect::from_xywh(0, 0, 20000, 20000); - let (doc, warnings) = crate::psd_to_document(&file); - assert!(doc.layers.is_empty()); - assert!(warnings.iter().any(|warning| warning.contains("pixel memory"))); + // Use fallback snapshot to ensure test does not depend on host available RAM + crate::memory::with_mock_memory(crate::memory::MemorySnapshot::UNKNOWN, || { + let (doc, warnings) = crate::psd_to_document(&file); + assert!(doc.layers.is_empty()); + assert!(warnings.iter().any(|warning| warning.contains("pixel memory"))); + }); } fn pattern_block(mode: u32, depth: u16, width: u32, height: u32) -> photocraft_psd::TaggedBlock { @@ -271,3 +276,132 @@ fn zero_available_ram_refuses_admission() { assert!(matches!(res, Err(IoError::PsdImportBudget { .. }))); }); } + +#[test] +fn preflight_rejection_and_approval_files_release_gate() { + if cfg!(any(target_arch = "wasm32", target_pointer_width = "32")) { + return; + } + let mut file = testgen::small(Version::Psd, Compression::Zip); + file.header.width = 1; + file.header.height = 1; + file.layers_mut()[0].rect = Rect::from_xywh(0, 0, 30_000, 30_000); + let bytes = file.to_bytes().unwrap(); + + let gb = 1u64 << 30; + // Set RAM such that this large layer exceeds hard limit + let snap = crate::memory::MemorySnapshot { available_ram: Some(4 * gb), swap_free: None, commit_headroom: None }; + crate::memory::with_mock_memory(snap, || { + assert!(!crate::psd_gate::is_holding_admission()); + let res = crate::import_with_options("huge.psd", &bytes, &crate::ImportOptions::default()); + assert!(matches!(res, Err(IoError::PsdImportBudget { .. }))); + // Gate must not be held after rejection + assert!(!crate::psd_gate::is_holding_admission()); + }); + + // Set RAM such that file falls in confirmation range + let est = estimate(&file); + let snap_mid = crate::memory::MemorySnapshot { available_ram: Some(est * 2), swap_free: None, commit_headroom: None }; + crate::memory::with_mock_memory(snap_mid, || { + assert!(!crate::psd_gate::is_holding_admission()); + let res = crate::import_with_options("mid.psd", &bytes, &crate::ImportOptions::default()); + assert!(matches!(res, Err(IoError::PsdApprovalRequired { .. }))); + // Gate must not be held when requiring approval + assert!(!crate::psd_gate::is_holding_admission()); + }); +} + +#[test] +fn retry_estimate_cap_rejects_altered_or_enlarged_file() { + if cfg!(any(target_arch = "wasm32", target_pointer_width = "32")) { + return; + } + let gb = 1u64 << 30; + let snap = crate::memory::MemorySnapshot { available_ram: Some(12 * gb), swap_free: None, commit_headroom: None }; + // S = 6 GiB, H = 9 GiB + + // 1. Within approved_estimate: passes + let res = crate::memory::evaluate_psd_admission(7 * gb, 0, true, Some(7 * gb), snap); + assert!(res.is_ok()); + + // 2. Fresh requirement exceeds approved_estimate: requires fresh approval + let res = crate::memory::evaluate_psd_admission(8 * gb, 0, true, Some(7 * gb), snap); + assert!(matches!(res, Err(IoError::PsdApprovalRequired { required, .. }) if required == 8 * gb)); + + // 3. Exceeds hard limit: refused completely regardless of approval + let res = crate::memory::evaluate_psd_admission(10 * gb, 0, true, Some(10 * gb), snap); + assert!(matches!(res, Err(IoError::PsdImportBudget { required, limit }) if required == 10 * gb && limit == 9 * gb)); +} + +#[test] +fn caller_zero_and_lower_caps_cannot_bypass_hard_boundary() { + if cfg!(any(target_arch = "wasm32", target_pointer_width = "32")) { + return; + } + let gb = 1u64 << 30; + let snap = crate::memory::MemorySnapshot { available_ram: Some(8 * gb), swap_free: None, commit_headroom: None }; + // S = 4 GiB, H = 6 GiB + + // 0 uses adaptive budget + let b_zero = crate::memory::resolve_budget(snap, Some(0)); + assert_eq!(b_zero.hard_limit, 6 * gb); + assert_eq!(b_zero.soft_limit, 4 * gb); + + // Lower cap lowers both hard and soft + let b_lower = crate::memory::resolve_budget(snap, Some(3 * gb)); + assert_eq!(b_lower.hard_limit, 3 * gb); + assert_eq!(b_lower.soft_limit, 3 * gb); + + // Higher cap cannot raise above hard limit + let b_higher = crate::memory::resolve_budget(snap, Some(20 * gb)); + assert_eq!(b_higher.hard_limit, 6 * gb); + assert_eq!(b_higher.soft_limit, 4 * gb); +} + +#[test] +fn test_memory_scope_propagates_to_worker_thread() { + let gb = 1u64 << 30; + let mock = crate::memory::MemorySnapshot { available_ram: Some(4 * gb), swap_free: None, commit_headroom: None }; + crate::memory::with_mock_memory(mock, || { + let scope = crate::memory::TestMemoryScope::capture(); + let handle = std::thread::spawn(move || { + scope.run(|| { + assert_eq!(crate::memory::MemorySnapshot::current().available_ram, Some(4 * gb)); + }); + }); + handle.join().unwrap(); + }); +} + +#[test] +fn nested_imports_consume_from_shared_allowance_and_do_not_reset_parent() { + let gb = 1u64 << 30; + let ctl = photocraft_raster::Interrupt::NONE; + let _guard = crate::psd_gate::acquire_decode_admission(&ctl).unwrap(); + // Simulate top-level attempt authorized total of 5 GB with 2 GB remaining allowance + crate::psd_gate::set_active_attempt_budget(2 * gb); + + // First nested consume fits within 2 GB + assert!(crate::psd_gate::consume_attempt_allowance(1 * gb).is_ok()); + + // Second nested consume exceeds remaining 1 GB + let err = crate::psd_gate::consume_attempt_allowance(2 * gb).unwrap_err(); + assert!(matches!(err, IoError::PsdImportBudget { required, limit } if required == 2 * gb && limit == 1 * gb)); + + // Allowance is still 1 GB (failed attempt does not reset or corrupt parent) + assert!(crate::psd_gate::consume_attempt_allowance(1 * gb).is_ok()); + assert!(crate::psd_gate::consume_attempt_allowance(1).is_err()); +} + +#[test] +fn nested_tiff_layer_import_consumes_composite_and_estimate_without_resetting_parent() { + let gb = 1u64 << 30; + let ctl = photocraft_raster::Interrupt::NONE; + let _guard = crate::psd_gate::acquire_decode_admission(&ctl).unwrap(); + crate::psd_gate::set_active_attempt_budget(3 * gb); + + assert!(crate::psd_gate::is_holding_admission()); + assert!(crate::psd_gate::consume_attempt_allowance(1 * gb).is_ok()); + assert!(crate::psd_gate::consume_attempt_allowance(2 * gb).is_ok()); + assert!(crate::psd_gate::consume_attempt_allowance(1).is_err()); +} diff --git a/crates/io/src/psd_gate.rs b/crates/io/src/psd_gate.rs index d2a4535eb2..cc71e2e6c6 100644 --- a/crates/io/src/psd_gate.rs +++ b/crates/io/src/psd_gate.rs @@ -6,6 +6,7 @@ //! budget. Approval prompts never hold this lock. use std::cell::Cell; +use std::marker::PhantomData; use std::sync::{Condvar, Mutex, OnceLock}; use std::thread::ThreadId; use std::time::Duration; @@ -25,49 +26,84 @@ fn get_gate() -> &'static (Mutex, Condvar) { thread_local! { static NESTED_DEPTH: Cell = const { Cell::new(0) }; + static ATTEMPT_ALLOWANCE: Cell> = const { Cell::new(None) }; } /// RAII guard releasing decode admission when dropped. -pub struct AdmissionGuard { - is_top_level: bool, +/// Non-Send and non-Sync to prevent cross-thread leakage or premature release. +pub(crate) struct AdmissionGuard { + _marker: PhantomData<*const ()>, } impl Drop for AdmissionGuard { fn drop(&mut self) { - if self.is_top_level { + let depth = NESTED_DEPTH.with(|d| { + let val = d.get().saturating_sub(1); + d.set(val); + val + }); + if depth == 0 { + ATTEMPT_ALLOWANCE.with(|a| a.set(None)); let (mutex, cvar) = get_gate(); let mut state = mutex.lock().unwrap_or_else(std::sync::PoisonError::into_inner); state.active = false; state.owner = None; cvar.notify_one(); - } else { - NESTED_DEPTH.with(|d| d.set(d.get().saturating_sub(1))); } } } +/// Returns whether the current thread currently holds decode admission. +pub(crate) fn is_holding_admission() -> bool { + NESTED_DEPTH.with(|d| d.get() > 0) +} + +/// Sets the remaining memory allowance for the active import attempt on the current thread. +pub(crate) fn set_active_attempt_budget(allowance: u64) { + ATTEMPT_ALLOWANCE.with(|a| a.set(Some(allowance))); +} + +/// Consumes `bytes` from the enclosing attempt's remaining allowance, or returns +/// [`IoError::PsdImportBudget`] if the required amount exceeds what remains. +pub(crate) fn consume_attempt_allowance(bytes: u64) -> Result<(), IoError> { + ATTEMPT_ALLOWANCE.with(|a| match a.get() { + Some(remaining) => { + if bytes > remaining { + return Err(IoError::PsdImportBudget { required: bytes, limit: remaining }); + } + a.set(Some(remaining - bytes)); + Ok(()) + } + None => { + // Unknown allowance during reentrant gate must fail closed. + Err(IoError::PsdImportBudget { required: bytes, limit: 0 }) + } + }) +} + /// Acquires process-wide admission to decode PSD pixels, or awaits completion of an active decode. /// /// If the current thread already holds admission (a nested/reentrant decode), it is admitted /// immediately without reacquiring the mutex or waiting on the condvar. Otherwise, it polls /// `ctl.check()` every 50 ms while waiting so cancellation halts the wait immediately. -pub fn acquire_decode_admission(ctl: &photocraft_raster::Interrupt) -> Result { +pub(crate) fn acquire_decode_admission(ctl: &photocraft_raster::Interrupt) -> Result { let current_thread = std::thread::current().id(); - - let is_reentrant = { - let (mutex, _) = get_gate(); - let state = mutex.lock().unwrap_or_else(std::sync::PoisonError::into_inner); - state.active && state.owner == Some(current_thread) - }; - - if is_reentrant { - NESTED_DEPTH.with(|d| d.set(d.get() + 1)); - return Ok(AdmissionGuard { is_top_level: false }); - } - let (mutex, cvar) = get_gate(); let mut state = mutex.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if state.active && state.owner == Some(current_thread) { + ctl.check().map_err(|_| IoError::Cancelled)?; + NESTED_DEPTH.with(|d| d.set(d.get().saturating_add(1))); + return Ok(AdmissionGuard { _marker: PhantomData }); + } + + #[cfg(target_arch = "wasm32")] + if state.active { + // Stateless wasm is single-threaded: contention cannot be resolved by waiting on Condvar. + return Err(IoError::Unsupported("concurrent PSD decoding is not supported on wasm".into())); + } + + #[cfg(not(target_arch = "wasm32"))] while state.active { ctl.check().map_err(|_| IoError::Cancelled)?; let (next_state, _) = cvar.wait_timeout(state, Duration::from_millis(50)).unwrap_or_else(std::sync::PoisonError::into_inner); @@ -77,29 +113,119 @@ pub fn acquire_decode_admission(ctl: &photocraft_raster::Interrupt) -> Result e, + Err(error) => { + let message = error.to_string(); + let doc = Document::new("Untitled", Size::new(1, 1), ColorMode::Rgb, SampleType::U8); + return Some((doc, vec![message], true, Some(error))); + } + }; + + if crate::psd_gate::is_holding_admission() { + // Nested conversion: consume from enclosing attempt allowance without fresh budget or deadlocks + if let Err(error) = crate::psd_gate::consume_attempt_allowance(est.total_bytes) { + let message = error.to_string(); + let doc = Document::new("Untitled", Size::new(1, 1), ColorMode::Rgb, SampleType::U8); + return Some((doc, vec![message], true, Some(error))); + } + match crate::psd_gate::acquire_decode_admission(ctl) { + Ok(g) => _guard = Some(g), + Err(error) => { + let message = error.to_string(); + let doc = Document::new("Untitled", Size::new(1, 1), ColorMode::Rgb, SampleType::U8); + return Some((doc, vec![message], true, Some(error))); + } + } + } else { + // Standalone legacy conversion: acquire admission gate and check adaptive budget + match crate::psd_gate::acquire_decode_admission(ctl) { + Ok(g) => _guard = Some(g), + Err(error) => { + let message = error.to_string(); + let doc = Document::new("Untitled", Size::new(1, 1), ColorMode::Rgb, SampleType::U8); + return Some((doc, vec![message], true, Some(error))); + } + } + let snapshot = crate::memory::MemorySnapshot::current(); + // Legacy standalone is unconfirmed: cannot silently auto allow > S. + match crate::memory::evaluate_psd_admission(est.total_bytes, 0, false, None, snapshot) { + Ok(outcome) => { + crate::psd_gate::set_active_attempt_budget(outcome.authorized_total.saturating_sub(est.total_bytes)); + } + Err(error) => { + let message = error.to_string(); + let doc = Document::new("Untitled", Size::new(1, 1), ColorMode::Rgb, SampleType::U8); + return Some((doc, vec![message], true, Some(error))); + } + } + } + } else if !crate::psd_gate::is_holding_admission() { + // Fallback for callers passing preflighted=true without holding admission: + // acquire admission gate and initialize allowance safely. + match crate::psd_gate::acquire_decode_admission(ctl) { + Ok(g) => _guard = Some(g), + Err(error) => { + let message = error.to_string(); + let doc = Document::new("Untitled", Size::new(1, 1), ColorMode::Rgb, SampleType::U8); + return Some((doc, vec![message], true, Some(error))); + } + } + let est = match crate::psd_budget::estimate(file) { + Ok(e) => e, + Err(error) => { + let message = error.to_string(); + let doc = Document::new("Untitled", Size::new(1, 1), ColorMode::Rgb, SampleType::U8); + return Some((doc, vec![message], true, Some(error))); + } + }; + let snapshot = crate::memory::MemorySnapshot::current(); + match crate::memory::evaluate_psd_admission(est.total_bytes, 0, false, None, snapshot) { + Ok(outcome) => { + crate::psd_gate::set_active_attempt_budget(outcome.authorized_total.saturating_sub(est.total_bytes)); + } + Err(error) => { + let message = error.to_string(); + let doc = Document::new("Untitled", Size::new(1, 1), ColorMode::Rgb, SampleType::U8); + return Some((doc, vec![message], true, Some(error))); + } + } } let (mode, layered, multichannel, depth) = import_format(file); let h = &file.header; diff --git a/crates/io/src/tiff_layers.rs b/crates/io/src/tiff_layers.rs index 664355ef5b..2ca338f07e 100644 --- a/crates/io/src/tiff_layers.rs +++ b/crates/io/src/tiff_layers.rs @@ -124,7 +124,18 @@ pub(crate) fn import_layered(name: &str, img: &Image, layers: &[u8], pixel_limit if composite > pixel_limit { return Err(photocraft_psd::PsdError::LimitExceeded("the TIFF composite exceeds the import allowance").into()); } - crate::psd_budget::check(&file, pixel_limit - composite)?; + let remaining_limit = pixel_limit - composite; + let est = crate::psd_budget::check(&file, remaining_limit)?; + let nested = crate::psd_gate::is_holding_admission(); + if nested { + crate::psd_gate::consume_attempt_allowance( + est.total_bytes.checked_add(composite).ok_or_else(|| IoError::Unsupported("TIFF import estimate overflow".into()))?, + )?; + } + let _guard = crate::psd_gate::acquire_decode_admission(&photocraft_raster::Interrupt::NONE)?; + if !nested { + crate::psd_gate::set_active_attempt_budget(remaining_limit.saturating_sub(est.total_bytes)); + } let img = if img.sample_type() == CSample::F16 { warnings.push("16-bit float samples are stored as 32-bit float".to_string()); std::borrow::Cow::Owned(img.convert(img.layout(), CSample::F32)) diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 326e6bf7a7..9fccf05de1 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -57,6 +57,7 @@ sys-locale = "=0.3.2" winsafe = { version = "=0.0.29", default-features = false, features = ["kernel", "dxgi", "advapi"] } [dev-dependencies] +photocraft-io = { workspace = true, features = ["testgen"] } # egui's own default fonts in tests: the oracle for egui_fonts.rs, and contexts made with # `Context::default()` (no install_fonts) can lay out text. egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] } diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 3e7d2aa906..ef17287407 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -193,6 +193,7 @@ impl PhotocraftApp { dds_options: None, held_opens: Default::default(), held_memory_prompts: Default::default(), + prompt_order: Default::default(), held_preset_import: None, allow_close: false, stylus: Default::default(), diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index 2fee62f41e..789cf42aa1 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -73,7 +73,14 @@ impl PhotocraftApp { let (mut doc, warnings, damaged) = if photocraft_io::is_psd(bytes) { // Read PSD damage as structured information in the same decode, not by // retrying an error or matching the text of a compatibility warning. - let (r, damaged) = photocraft_io::import_for_open(name, bytes, &opts).map_err(|e| e.to_string())?; + let (r, damaged) = match photocraft_io::import_for_open(name, bytes, &opts) { + Ok(result) => result, + Err(photocraft_io::IoError::PsdApprovalRequired { .. }) => { + jobs_ui::start_open(self, name, path.map(str::to_string), jobs_ui::bytes(bytes))?; + return Ok(Vec::new()); + } + Err(error) => return Err(error.to_string()), + }; (r.document, r.warnings, damaged) } else { let (doc, warnings) = import(name, bytes, &opts)?; @@ -112,13 +119,17 @@ impl PhotocraftApp { /// Returns the import warnings (also shown to the user). Brush and gradient /// files go to the preset libraries, as for interactive opens. pub fn open_automation_bytes(&mut self, name: &str, bytes: &[u8]) -> Result, String> { + self.open_automation_bytes_with_approval(name, bytes, false) + } + + pub fn open_automation_bytes_with_approval(&mut self, name: &str, bytes: &[u8], approved: bool) -> Result, String> { let events_enabled = self.session.prefs().script_events.enabled; if events_enabled { self.session.edit_prefs(|prefs| prefs.script_events.enabled = false); } let result = match preset_files_ui::open(self, name, bytes) { Some(r) => r.map(|()| Vec::new()), - None => self.import_automation_document(name, bytes), + None => self.import_automation_document(name, bytes, approved), }; if events_enabled { self.session.edit_prefs(|prefs| prefs.script_events.enabled = true); @@ -128,9 +139,10 @@ impl PhotocraftApp { /// The document half of [`open_automation_bytes`](Self::open_automation_bytes): no script /// events and no Color Settings policy (which may read user-configured profile paths). - fn import_automation_document(&mut self, name: &str, bytes: &[u8]) -> Result, String> { + fn import_automation_document(&mut self, name: &str, bytes: &[u8], approved: bool) -> Result, String> { let import = self.services.import.as_ref().ok_or("no importer configured")?; - let opts = photocraft_engine::jobs::import_options(self.session.prefs()); + let mut opts = photocraft_engine::jobs::import_options(self.session.prefs()); + opts.psd_high_memory_approved = approved; let (mut doc, warnings) = import(name, bytes, &opts)?; photocraft_engine::file_cmds::raw_defaults(&self.session, &warnings, &mut doc); // The caller records the path it read from. diff --git a/crates/ui-egui/src/control/dispatch.rs b/crates/ui-egui/src/control/dispatch.rs index 3c7b22dda0..31ad74a4c8 100644 --- a/crates/ui-egui/src/control/dispatch.rs +++ b/crates/ui-egui/src/control/dispatch.rs @@ -1369,7 +1369,11 @@ fn app_methods(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlReques let p = &req.params; Some(match req.method.as_str() { "app.open" => match str_param(p, "path") { - Some(path) => app_open(app, path), + Some(path) => match p.get("approveHighMemory") { + None | Some(Value::Null) => app_open(app, path, false), + Some(Value::Bool(approved)) => app_open(app, path, *approved), + Some(_) => err("expected boolean for `approveHighMemory`"), + }, None => err("missing `path`"), }, "app.save" => match crate::FormatChoices::from_params(p, "app.save") { @@ -1388,7 +1392,7 @@ fn app_methods(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlReques }) } -fn app_open(app: &mut PhotocraftApp, path: &str) -> Outcome { +fn app_open(app: &mut PhotocraftApp, path: &str, approved: bool) -> Outcome { if let Some(outcome) = open_pcraft_directory(app, path) { return outcome; } @@ -1398,7 +1402,7 @@ fn app_open(app: &mut PhotocraftApp, path: &str) -> Outcome { }; wrap(opened.and_then(|(name, bytes)| { let name = app.open_name(&name); - let warnings = app.open_automation_bytes(&name, &bytes)?; + let warnings = app.open_automation_bytes_with_approval(&name, &bytes, approved)?; // Brushes/gradients go to the preset libraries: no document, no Open Recent entry. if !crate::preset_files_ui::is_preset_file(&name) { app.opened_from(path); diff --git a/crates/ui-egui/src/dialogs/open_prompt.rs b/crates/ui-egui/src/dialogs/open_prompt.rs index 0c0f0fe845..02b2276cfb 100644 --- a/crates/ui-egui/src/dialogs/open_prompt.rs +++ b/crates/ui-egui/src/dialogs/open_prompt.rs @@ -44,7 +44,15 @@ pub struct Held { /// Dialog field for the high-memory confirmation prompt. pub const MEMORY_FIELD: &str = "__psdMemoryOpen"; +/// Item kind in the arrival-ordered open prompt queue. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum PromptKind { + Memory, + Open, +} + /// An un-decoded PSD file whose preflight estimate requires user approval. +#[derive(Clone)] pub struct MemoryPrompt { pub id: u64, pub name: String, @@ -63,27 +71,55 @@ pub fn waiting(app: &PhotocraftApp) -> bool { /// Opens `held` now, or queues it behind the file whose prompt is up. pub fn arrive(app: &mut PhotocraftApp, held: Held) { app.held_opens.push_back(held); + app.prompt_order.push_back(PromptKind::Open); advance(app); } +pub fn has_open_dialog(app: &PhotocraftApp) -> bool { + app.ui.dialogs.iter().any(|d| owns(&d.fields) || owns_damage(&d.fields) || owns_memory(&d.fields)) +} + /// Opens queued files in order until one needs its prompt, which is shown if it isn't yet. -fn advance(app: &mut PhotocraftApp) { - while let Some(front) = app.held_opens.front() { - if front.damage_pending { - if !app.ui.dialogs.iter().any(|d| owns_damage(&d.fields)) { - show_damage(app); +pub fn advance(app: &mut PhotocraftApp) { + // Reconcile legacy callers that populated held queues directly. + if app.prompt_order.is_empty() { + app.prompt_order.extend(std::iter::repeat_n(PromptKind::Open, app.held_opens.len())); + app.prompt_order.extend(std::iter::repeat_n(PromptKind::Memory, app.held_memory_prompts.len())); + } + if has_open_dialog(app) { + return; + } + loop { + match app.prompt_order.front() { + None => return, + Some(PromptKind::Memory) => { + if let Some(front) = app.held_memory_prompts.front().cloned() { + show_memory(app, &front); + return; + } else { + app.prompt_order.pop_front(); + } } - return; - } - if front.question.is_some() { - if !app.ui.dialogs.iter().any(|d| d.fields.contains_key(FIELD)) { - show(app); + Some(PromptKind::Open) => { + let Some(front) = app.held_opens.front() else { + app.prompt_order.pop_front(); + continue; + }; + if front.damage_pending { + show_damage(app); + return; + } + if front.question.is_some() { + show(app); + return; + } + let held = app.held_opens.pop_front().unwrap(); + app.prompt_order.pop_front(); + let _ = open(app, held, None); + if has_open_dialog(app) { + return; + } } - return; - } - if let Some(held) = app.held_opens.pop_front() { - // A failure is shown to the user (`open_failed`); the next file still opens. - let _ = open(app, held, None); } } } @@ -143,17 +179,28 @@ pub fn answer(app: &mut PhotocraftApp, action: Option<&str>) -> Result>() { app.session.cancel_job(job); } + app.jobs.opens.clear(); + app.jobs.batches.clear(); + app.ui.dialogs.retain(|d| !owns(&d.fields) && !owns_damage(&d.fields) && !owns_memory(&d.fields)); app.ui.status = crate::i18n::fmt(tl!("Cancelled opening {name}"), &[("name", &name)]); app.ui.status_error = false; return Ok(json!({"cancelled": dropped})); }; let Some(held) = app.held_opens.pop_front() else { return Err("no file is waiting to open".into()) }; + if let Some(pos) = app.prompt_order.iter().position(|p| *p == PromptKind::Open) { + app.prompt_order.remove(pos); + } let r = open(app, held, Some(action)); advance(app); r @@ -178,9 +225,13 @@ pub fn answer_remembering(app: &mut PhotocraftApp, action: &str) -> Result Result) -> Result, ) { app.held_memory_prompts.push_back(MemoryPrompt { id, name, path, slot, order, estimated, available }); + app.prompt_order.push_back(PromptKind::Memory); advance_memory(app); } @@ -305,22 +361,19 @@ pub(crate) fn memory_body(ui: &mut egui::Ui, fields: &serde_json::Map format!("{:.1} GB", a as f64 / (1u64 << 30) as f64), + Some(a) => format!("{:.1} GiB", a as f64 / (1u64 << 30) as f64), None => "unknown".to_string(), }; let message = format!( "{}\n\n{}", crate::i18n::fmt( - tl!("Opening “{name}” requires an estimated {est:.1} GB of memory ({avail} available)."), + tl!("Opening “{name}” requires an estimated {est} GiB of memory ({avail} available)."), &[("name", &prompt.name), ("est", &format!("{est_gb:.1}")), ("avail", &avail_str)], ), tl!("Opening this document uses more memory and may slow down your computer or other running applications."), @@ -343,30 +396,50 @@ fn show_memory(app: &mut PhotocraftApp, prompt: &MemoryPrompt) { pub(crate) fn answer_memory(app: &mut PhotocraftApp, accept: bool) -> Result { let Some(front) = app.held_memory_prompts.pop_front() else { + advance(app); return Err("no high-memory PSD is waiting for confirmation".into()); }; + if let Some(pos) = app.prompt_order.iter().position(|p| *p == PromptKind::Memory) { + app.prompt_order.remove(pos); + } let id = front.id; let name = front.name.clone(); - let r = app.run("file.approveMemory", json!({"id": id, "approve": accept})).map_err(|e| format!("{}: {e}", name))?; + let path = front.path.clone(); + let slot = front.slot; + let order = front.order; - if accept { - if let Some(job) = r.get("job").and_then(Value::as_u64) { - app.jobs.opens.push(crate::jobs_ui::OpenTab { - job: photocraft_engine::jobs::JobId(job), - name: name.clone(), - path: front.path, - slot: front.slot, - order: front.order, - }); - app.jobs.focus = Some(photocraft_engine::jobs::JobId(job)); - app.ui.chrome.home = None; - app.ui.status = crate::i18n::fmt(tl!("Opening {name}…"), &[("name", &name)]); - app.ui.status_error = false; + let res = app.run("file.approveMemory", json!({"id": id, "approve": accept, "background": app.background_jobs})).map_err(|e| format!("{}: {e}", name)); + + match res { + Ok(r) => { + if accept { + if let Some(job) = r.get("job").and_then(Value::as_u64) { + app.jobs.opens.push(crate::jobs_ui::OpenTab { job: photocraft_engine::jobs::JobId(job), name: name.clone(), path, slot, order }); + app.jobs.focus = Some(photocraft_engine::jobs::JobId(job)); + app.ui.chrome.home = None; + app.ui.status = crate::i18n::fmt(tl!("Opening {name}…"), &[("name", &name)]); + app.ui.status_error = false; + } else if let Some(result) = r.get("result") { + let tab = crate::jobs_ui::OpenTab { job: photocraft_engine::jobs::JobId(0), name: name.clone(), path: path.clone(), slot, order }; + if let Err(e) = crate::jobs_ui::finish_open_tab(app, &tab, result) { + app.ui.status = e; + app.ui.status_error = true; + } + } + } else { + app.ui.status = crate::i18n::fmt(tl!("Cancelled opening {name}"), &[("name", &name)]); + app.ui.status_error = false; + crate::jobs_ui::end_batch(&mut app.jobs, &app.held_memory_prompts); + } + advance(app); + Ok(r) + } + Err(err) => { + app.ui.status = err.clone(); + app.ui.status_error = true; + crate::jobs_ui::end_batch(&mut app.jobs, &app.held_memory_prompts); + advance(app); + Err(err) } - } else { - app.ui.status = crate::i18n::fmt(tl!("Cancelled opening {name}"), &[("name", &name)]); - app.ui.status_error = false; } - advance_memory(app); - Ok(r) } diff --git a/crates/ui-egui/src/file_open.rs b/crates/ui-egui/src/file_open.rs index 65c1fb5758..39e55aa3e0 100644 --- a/crates/ui-egui/src/file_open.rs +++ b/crates/ui-egui/src/file_open.rs @@ -182,7 +182,7 @@ impl PhotocraftApp { Err(e) => self.open_failed(&display_name(abs), &e), } } - crate::jobs_ui::end_batch(&mut self.jobs); + crate::jobs_ui::end_batch(&mut self.jobs, &self.held_memory_prompts); opened } diff --git a/crates/ui-egui/src/file_open/tests.rs b/crates/ui-egui/src/file_open/tests.rs index c0323da9a1..5fdece7f71 100644 --- a/crates/ui-egui/src/file_open/tests.rs +++ b/crates/ui-egui/src/file_open/tests.rs @@ -885,7 +885,7 @@ fn a_set_of_background_opens_keeps_its_order_however_they_finish() { let index = app.session.add_document(Document::new("f1.psd", Size::new(4, 4), ColorMode::Rgb, SampleType::U8), None); assert_eq!(index, 1); jobs_ui::opened_in_order(&mut app.jobs, OpenOrder { batch, seq: 1 }); - jobs_ui::end_batch(&mut app.jobs); + jobs_ui::end_batch(&mut app.jobs, &app.held_memory_prompts); assert_eq!(app.jobs.batches.len(), 1, "kept while members are opening"); // The rest finish out of order: last, first, third. for seq in [3usize, 0, 2] { diff --git a/crates/ui-egui/src/jobs_ui.rs b/crates/ui-egui/src/jobs_ui.rs index 584a4cad8c..9dc391169f 100644 --- a/crates/ui-egui/src/jobs_ui.rs +++ b/crates/ui-egui/src/jobs_ui.rs @@ -200,7 +200,7 @@ pub fn run(app: &mut PhotocraftApp, id: &str, params: Value) -> Result, source: OpenSource) -> Result<(), String> { let name = &app.open_name(name); - match app.session.start_open_for_user(name, source).map_err(|e| e.to_string())? { + match app.session.start_open_for_user_with_background(name, source, app.background_jobs).map_err(|e| e.to_string())? { // Inline (wasm): finish now, like a background open that ended at once. Started::Done(v) => finish_open(app, name, path.as_deref(), &v, None), Started::Job(job) => { @@ -233,9 +233,11 @@ pub(crate) fn opened_in_order(jobs: &mut JobsUi, order: OpenOrder) { } /// Every member of the set has been started: forget it unless some are still opening. -pub(crate) fn end_batch(jobs: &mut JobsUi) { +pub(crate) fn end_batch(jobs: &mut JobsUi, held_memory_prompts: &std::collections::VecDeque) { let opens = &jobs.opens; - jobs.batches.retain(|b| opens.iter().any(|o| o.order.is_some_and(|x| x.batch == b.id))); + jobs.batches.retain(|b| { + opens.iter().any(|o| o.order.is_some_and(|x| x.batch == b.id)) || held_memory_prompts.iter().any(|m| m.order.is_some_and(|x| x.batch == b.id)) + }); } /// The tab position for a member of a set that just opened: after the members before it that @@ -258,7 +260,7 @@ pub(crate) fn finish_open_tab(app: &mut PhotocraftApp, tab: &OpenTab, v: &Value) } let slot = tab.slot.or_else(|| tab.order.and_then(|o| batch_slot(&mut app.jobs, o))); let r = finish_open(app, &tab.name, tab.path.as_deref(), v, slot); - end_batch(&mut app.jobs); + end_batch(&mut app.jobs, &app.held_memory_prompts); r } @@ -513,7 +515,7 @@ fn on_event(app: &mut PhotocraftApp, ctx: &egui::Context, e: JobEvent) { app.ui.status_error = false; } } - end_batch(&mut app.jobs); + end_batch(&mut app.jobs, &app.held_memory_prompts); return; } match e.outcome { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 2a7af1fc7b..d94a408d91 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -689,6 +689,7 @@ pub struct PhotocraftApp { pub(crate) held_opens: std::collections::VecDeque, /// PSD files whose memory preflight requires user approval before pixel decoding (see `open_prompt`). pub(crate) held_memory_prompts: std::collections::VecDeque, + pub(crate) prompt_order: std::collections::VecDeque, /// A brush or gradient file waiting on the group-name prompt (see `preset_group_prompt`). pub(crate) held_preset_import: Option, /// Set once the user has agreed to quit, so the resulting close request goes through. diff --git a/crates/ui-egui/src/psd_open_tests.rs b/crates/ui-egui/src/psd_open_tests.rs index 728817a4fe..0765f65229 100644 --- a/crates/ui-egui/src/psd_open_tests.rs +++ b/crates/ui-egui/src/psd_open_tests.rs @@ -203,26 +203,443 @@ fn memory_confirmation_dialog_and_cancel_lifecycle() { let mut app = app(); let gb = 1u64 << 30; - // Simulate memory prompt arrival: - let preflight_id = app.session.store_pending_preflight(photocraft_engine::jobs::PendingPsdPreflight { - id: 0, - name: "high_mem.psd".into(), - source: photocraft_engine::jobs::OpenSource::Bytes(std::sync::Arc::new(vec![0; 16])), - fingerprint: photocraft_engine::jobs::SourceFingerprint::of(&photocraft_engine::jobs::OpenSource::Bytes(std::sync::Arc::new(vec![0; 16]))).unwrap(), - required: 7 * gb, - soft_limit: 4 * gb, - hard_limit: 8 * gb, - available_ram: Some(10 * gb), - confirm_damage: true, - }); + let preflight_id = app + .session + .store_pending_preflight(photocraft_engine::jobs::PendingPsdPreflight { + id: 0, + name: "high_mem.psd".into(), + source: photocraft_engine::jobs::OpenSource::Bytes(std::sync::Arc::new(vec![0; 16])), + fingerprint: photocraft_engine::jobs::SourceFingerprint::of(&photocraft_engine::jobs::OpenSource::Bytes(std::sync::Arc::new(vec![0; 16]))).unwrap(), + required: 7 * gb, + soft_limit: 4 * gb, + hard_limit: 8 * gb, + available_ram: Some(10 * gb), + confirm_damage: true, + }) + .unwrap(); crate::open_prompt::arrive_memory(&mut app, preflight_id, "high_mem.psd".into(), None, None, None, 7 * gb, Some(10 * gb)); assert_eq!(app.ui.dialogs.len(), 1); assert!(crate::open_prompt::owns_memory(&app.ui.dialogs[0].fields)); let dlg_id = app.ui.dialogs[0].id; - // Cancel via dialog cancel: assert_eq!(call(&mut app, "ui.dialog.cancel", json!({"dialog": dlg_id}))["ok"], true); assert!(app.ui.dialogs.is_empty()); assert!(app.session.take_pending_preflight(preflight_id).is_none(), "pending preflight dropped on cancel"); + assert!(app.held_memory_prompts.is_empty()); + assert!(app.prompt_order.is_empty()); +} + +#[test] +fn memory_confirmation_resume_inline_success() { + let mut app = app(); + app.background_jobs = false; + let psd = recovery_psd::make_psd(false); + let (file, _) = photocraft_psd::PsdFile::from_bytes_and_merged(&psd, |_| false).unwrap(); + let est = photocraft_io::psd_budget::estimate(&file).unwrap().total_bytes; + let bytes = std::sync::Arc::new(psd); + let fp = photocraft_engine::jobs::SourceFingerprint::of(&photocraft_engine::jobs::OpenSource::Bytes(std::sync::Arc::clone(&bytes))).unwrap(); + + let snap = photocraft_io::memory::MemorySnapshot { available_ram: Some((4 * est).max(4 << 30)), swap_free: None, commit_headroom: None }; + photocraft_io::memory::with_mock_memory(snap, || { + let budget = photocraft_io::memory::resolve_budget(snap, None); + let preflight_id = app + .session + .store_pending_preflight(photocraft_engine::jobs::PendingPsdPreflight { + id: 0, + name: "inline_resume.psd".into(), + source: photocraft_engine::jobs::OpenSource::Bytes(bytes), + fingerprint: fp, + required: est, + soft_limit: budget.soft_limit, + hard_limit: budget.hard_limit, + available_ram: budget.available_ram, + confirm_damage: true, + }) + .unwrap(); + + crate::open_prompt::arrive_memory( + &mut app, + preflight_id, + "inline_resume.psd".into(), + Some("/test/inline_resume.psd".into()), + None, + None, + est, + budget.available_ram, + ); + assert_eq!(app.ui.dialogs.len(), 1); + let dlg_id = app.ui.dialogs[0].id; + + let res = call(&mut app, "ui.dialog.confirm", json!({"dialog": dlg_id})); + assert_eq!(res["ok"], true); + assert!(app.ui.dialogs.is_empty(), "dialog dismissed after inline resume"); + assert_eq!(app.session.documents().len(), 1, "document opened via inline resume"); + assert_eq!(app.session.documents()[0].doc.name, "inline_resume.psd"); + assert_eq!(app.session.documents()[0].path.as_deref(), Some("/test/inline_resume.psd")); + assert!(app.held_memory_prompts.is_empty()); + assert!(app.held_opens.is_empty()); + assert!(app.prompt_order.is_empty()); + assert!(app.jobs.opens.is_empty()); + }); +} + +#[test] +fn memory_confirmation_resume_background_success() { + let mut app = app(); + app.background_jobs = true; + let psd = recovery_psd::make_psd(false); + let (file, _) = photocraft_psd::PsdFile::from_bytes_and_merged(&psd, |_| false).unwrap(); + let est = photocraft_io::psd_budget::estimate(&file).unwrap().total_bytes; + let bytes = std::sync::Arc::new(psd); + let fp = photocraft_engine::jobs::SourceFingerprint::of(&photocraft_engine::jobs::OpenSource::Bytes(std::sync::Arc::clone(&bytes))).unwrap(); + + let snap = photocraft_io::memory::MemorySnapshot { available_ram: Some((4 * est).max(4 << 30)), swap_free: None, commit_headroom: None }; + photocraft_io::memory::with_mock_memory(snap, || { + let budget = photocraft_io::memory::resolve_budget(snap, None); + let preflight_id = app + .session + .store_pending_preflight(photocraft_engine::jobs::PendingPsdPreflight { + id: 0, + name: "bg_resume.psd".into(), + source: photocraft_engine::jobs::OpenSource::Bytes(bytes), + fingerprint: fp, + required: est, + soft_limit: budget.soft_limit, + hard_limit: budget.hard_limit, + available_ram: budget.available_ram, + confirm_damage: true, + }) + .unwrap(); + + crate::open_prompt::arrive_memory( + &mut app, + preflight_id, + "bg_resume.psd".into(), + Some("/test/bg_resume.psd".into()), + None, + None, + est, + budget.available_ram, + ); + assert_eq!(app.ui.dialogs.len(), 1); + let dlg_id = app.ui.dialogs[0].id; + + let res = call(&mut app, "ui.dialog.confirm", json!({"dialog": dlg_id})); + assert_eq!(res["ok"], true); + assert_eq!(app.jobs.opens.len(), 1, "background open job registered in jobs.opens"); + + let jobs: Vec<_> = app.session.jobs().iter().map(|j| j.id).collect(); + for id in jobs { + app.session.wait_job(id).unwrap(); + } + crate::jobs_ui::tick(&mut app, &egui::Context::default()); + assert_eq!(app.session.documents().len(), 1, "document opened via background resume"); + assert_eq!(app.session.documents()[0].doc.name, "bg_resume.psd"); + assert_eq!(app.session.documents()[0].path.as_deref(), Some("/test/bg_resume.psd")); + assert!(app.ui.dialogs.is_empty()); + assert!(app.held_memory_prompts.is_empty()); + assert!(app.held_opens.is_empty()); + assert!(app.prompt_order.is_empty()); + assert!(app.jobs.opens.is_empty()); + }); +} + +#[test] +fn memory_confirmation_refuses_when_source_changed_inline() { + let mut app = app(); + app.background_jobs = false; + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("changed.psd"); + std::fs::write(&path, b"initial file contents").unwrap(); + + let src = photocraft_engine::jobs::OpenSource::Path(path.to_string_lossy().to_string()); + let fp = photocraft_engine::jobs::SourceFingerprint::of(&src).unwrap(); + + let preflight_id = app + .session + .store_pending_preflight(photocraft_engine::jobs::PendingPsdPreflight { + id: 0, + name: "changed.psd".into(), + source: src, + fingerprint: fp, + required: 1000, + soft_limit: 500, + hard_limit: 50_000, + available_ram: Some(100_000), + confirm_damage: true, + }) + .unwrap(); + + crate::open_prompt::arrive_memory(&mut app, preflight_id, "changed.psd".into(), Some(path.to_string_lossy().to_string()), None, None, 1000, Some(100_000)); + assert_eq!(app.ui.dialogs.len(), 1); + let dlg_id = app.ui.dialogs[0].id; + + // Mutate file before confirming: + std::fs::write(&path, b"modified file contents longer").unwrap(); + + let res = call(&mut app, "ui.dialog.confirm", json!({"dialog": dlg_id})); + assert_eq!(res["ok"], false, "confirming changed file inline must fail"); + assert!(app.session.documents().is_empty()); + assert!(app.held_memory_prompts.is_empty()); +} + +#[test] +fn memory_confirmation_refuses_when_source_changed_async() { + let mut app = app(); + app.background_jobs = true; + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("changed_async.psd"); + std::fs::write(&path, b"initial file contents").unwrap(); + + let src = photocraft_engine::jobs::OpenSource::Path(path.to_string_lossy().to_string()); + let fp = photocraft_engine::jobs::SourceFingerprint::of(&src).unwrap(); + + let preflight_id = app + .session + .store_pending_preflight(photocraft_engine::jobs::PendingPsdPreflight { + id: 0, + name: "changed_async.psd".into(), + source: src, + fingerprint: fp, + required: 1000, + soft_limit: 500, + hard_limit: 50_000, + available_ram: Some(100_000), + confirm_damage: true, + }) + .unwrap(); + + crate::open_prompt::arrive_memory( + &mut app, + preflight_id, + "changed_async.psd".into(), + Some(path.to_string_lossy().to_string()), + None, + None, + 1000, + Some(100_000), + ); + assert_eq!(app.ui.dialogs.len(), 1); + let dlg_id = app.ui.dialogs[0].id; + + // Mutate file before confirming: + std::fs::write(&path, b"modified file contents longer").unwrap(); + + let res = call(&mut app, "ui.dialog.confirm", json!({"dialog": dlg_id})); + assert_eq!(res["ok"], true, "confirmation kicks off background job"); + assert_eq!(app.jobs.opens.len(), 1); + + // Run the job to completion and observe async failure: + let jobs: Vec<_> = app.session.jobs().iter().map(|j| j.id).collect(); + for id in jobs { + let _ = app.session.wait_job(id); + } + crate::jobs_ui::tick(&mut app, &egui::Context::default()); + + assert!(app.session.documents().is_empty(), "no document opened on changed file failure"); + assert!(app.jobs.opens.is_empty(), "opens cleared on job failure"); + assert!(app.ui.status_error, "error recorded in UI status"); +} + +#[test] +fn memory_decline_advances_queue_and_cleans_batch() { + let mut app = app(); + let batch_id = crate::jobs_ui::begin_batch(&mut app); + let order = crate::jobs_ui::OpenOrder { batch: batch_id, seq: 0 }; + + let preflight_id = app + .session + .store_pending_preflight(photocraft_engine::jobs::PendingPsdPreflight { + id: 0, + name: "declined.psd".into(), + source: photocraft_engine::jobs::OpenSource::Bytes(std::sync::Arc::new(vec![0; 16])), + fingerprint: photocraft_engine::jobs::SourceFingerprint::of(&photocraft_engine::jobs::OpenSource::Bytes(std::sync::Arc::new(vec![0; 16]))).unwrap(), + required: 1000, + soft_limit: 500, + hard_limit: 2000, + available_ram: Some(4000), + confirm_damage: true, + }) + .unwrap(); + + crate::open_prompt::arrive_memory(&mut app, preflight_id, "declined.psd".into(), None, None, Some(order), 1000, Some(4000)); + assert_eq!(app.ui.dialogs.len(), 1); + let dlg_id = app.ui.dialogs[0].id; + + let res = call(&mut app, "ui.dialog.cancel", json!({"dialog": dlg_id})); + assert_eq!(res["ok"], true); + assert!(app.ui.dialogs.is_empty()); + assert!(app.session.take_pending_preflight(preflight_id).is_none()); + assert!(app.jobs.batches.is_empty(), "batch cleaned up on decline"); + assert!(app.prompt_order.is_empty()); +} + +#[test] +fn batch_single_file_cancel_preserves_other_held_memory_orders() { + let mut app = app(); + let batch_id = crate::jobs_ui::begin_batch(&mut app); + let order0 = crate::jobs_ui::OpenOrder { batch: batch_id, seq: 0 }; + let order1 = crate::jobs_ui::OpenOrder { batch: batch_id, seq: 1 }; + + let p0 = app + .session + .store_pending_preflight(photocraft_engine::jobs::PendingPsdPreflight { + id: 0, + name: "first.psd".into(), + source: photocraft_engine::jobs::OpenSource::Bytes(std::sync::Arc::new(vec![0; 4])), + fingerprint: photocraft_engine::jobs::SourceFingerprint::of(&photocraft_engine::jobs::OpenSource::Bytes(std::sync::Arc::new(vec![0; 4]))).unwrap(), + required: 1000, + soft_limit: 500, + hard_limit: 2000, + available_ram: Some(4000), + confirm_damage: true, + }) + .unwrap(); + + let p1 = app + .session + .store_pending_preflight(photocraft_engine::jobs::PendingPsdPreflight { + id: 0, + name: "second.psd".into(), + source: photocraft_engine::jobs::OpenSource::Bytes(std::sync::Arc::new(vec![0; 4])), + fingerprint: photocraft_engine::jobs::SourceFingerprint::of(&photocraft_engine::jobs::OpenSource::Bytes(std::sync::Arc::new(vec![0; 4]))).unwrap(), + required: 1000, + soft_limit: 500, + hard_limit: 2000, + available_ram: Some(4000), + confirm_damage: true, + }) + .unwrap(); + + crate::open_prompt::arrive_memory(&mut app, p0, "first.psd".into(), None, None, Some(order0), 1000, Some(4000)); + crate::open_prompt::arrive_memory(&mut app, p1, "second.psd".into(), None, None, Some(order1), 1000, Some(4000)); + + // Cancel first file: + let dlg0 = app.ui.dialogs[0].id; + assert_eq!(call(&mut app, "ui.dialog.cancel", json!({"dialog": dlg0}))["ok"], true); + + // Batch must be preserved because second file is still waiting: + assert_eq!(app.jobs.batches.len(), 1, "batch preserved while second file is waiting"); + assert_eq!(app.held_memory_prompts.len(), 1); + + // Now cancel second file: + let dlg1 = app.ui.dialogs[0].id; + assert_eq!(call(&mut app, "ui.dialog.cancel", json!({"dialog": dlg1}))["ok"], true); + assert!(app.jobs.batches.is_empty(), "batch cleaned after all members cancelled"); + assert!(app.prompt_order.is_empty()); +} + +#[test] +fn profile_cancel_cancels_whole_queue_including_memory_prompts() { + let mut app = app(); + let gb = 1u64 << 30; + + let preflight_id = app + .session + .store_pending_preflight(photocraft_engine::jobs::PendingPsdPreflight { + id: 0, + name: "high_mem.psd".into(), + source: photocraft_engine::jobs::OpenSource::Bytes(std::sync::Arc::new(vec![0; 16])), + fingerprint: photocraft_engine::jobs::SourceFingerprint::of(&photocraft_engine::jobs::OpenSource::Bytes(std::sync::Arc::new(vec![0; 16]))).unwrap(), + required: 7 * gb, + soft_limit: 4 * gb, + hard_limit: 8 * gb, + available_ram: Some(10 * gb), + confirm_damage: true, + }) + .unwrap(); + + crate::open_prompt::arrive_memory(&mut app, preflight_id, "high_mem.psd".into(), None, None, None, 7 * gb, Some(10 * gb)); + + crate::open_prompt::arrive( + &mut app, + crate::open_prompt::Held { + doc: photocraft_doc::Document::new("test", photocraft_geom::Size::new(4, 4), photocraft_doc::ColorMode::Rgb, photocraft_doc::SampleType::U8), + read_only: false, + damage_pending: false, + name: "profile.psd".into(), + path: None, + warnings: vec![], + bytes: None, + slot: None, + question: Some(json!({"missing": false, "working": "sRGB", "embedded": "Adobe RGB"})), + policy_applied: false, + }, + ); + + let r = crate::open_prompt::answer(&mut app, None).unwrap(); + assert_eq!(r["cancelled"], 2, "both held open and memory prompt dropped"); + assert!(app.held_opens.is_empty()); + assert!(app.held_memory_prompts.is_empty()); + assert!(app.prompt_order.is_empty()); + assert!(app.session.take_pending_preflight(preflight_id).is_none(), "pending preflight cancelled"); +} + +#[test] +fn mixed_prompts_follow_arrival_order_fifo() { + let mut app = app(); + let gb = 1u64 << 30; + + // 1. Arrive damage prompt first + crate::open_prompt::arrive( + &mut app, + crate::open_prompt::Held { + doc: photocraft_doc::Document::new("test", photocraft_geom::Size::new(4, 4), photocraft_doc::ColorMode::Rgb, photocraft_doc::SampleType::U8), + read_only: false, + damage_pending: true, + name: "first_damage.psd".into(), + path: None, + warnings: vec!["damaged data".into()], + bytes: None, + slot: None, + question: None, + policy_applied: false, + }, + ); + + // 2. Arrive memory prompt second + let preflight_id = app + .session + .store_pending_preflight(photocraft_engine::jobs::PendingPsdPreflight { + id: 0, + name: "second_mem.psd".into(), + source: photocraft_engine::jobs::OpenSource::Bytes(std::sync::Arc::new(vec![0; 16])), + fingerprint: photocraft_engine::jobs::SourceFingerprint::of(&photocraft_engine::jobs::OpenSource::Bytes(std::sync::Arc::new(vec![0; 16]))).unwrap(), + required: 7 * gb, + soft_limit: 4 * gb, + hard_limit: 8 * gb, + available_ram: Some(10 * gb), + confirm_damage: true, + }) + .unwrap(); + crate::open_prompt::arrive_memory(&mut app, preflight_id, "second_mem.psd".into(), None, None, None, 7 * gb, Some(10 * gb)); + + // First prompt shown must be damage (since it arrived first)! + assert_eq!(app.ui.dialogs.len(), 1); + assert!(crate::open_prompt::owns_damage(&app.ui.dialogs[0].fields)); + + // Cancel damage prompt: + let dlg_id = app.ui.dialogs[0].id; + assert_eq!(call(&mut app, "ui.dialog.cancel", json!({"dialog": dlg_id}))["ok"], true); + + // Now memory prompt must be shown! + assert_eq!(app.ui.dialogs.len(), 1); + assert!(crate::open_prompt::owns_memory(&app.ui.dialogs[0].fields)); + let dlg_mem = app.ui.dialogs[0].id; + assert_eq!(call(&mut app, "ui.dialog.cancel", json!({"dialog": dlg_mem}))["ok"], true); + assert!(app.ui.dialogs.is_empty()); +} + +#[test] +fn normal_image_open_does_not_require_timestamps_or_preflight_fingerprints() { + let mut app = app(); + // A synthetic 4x4 PNG/raw/flat image opens without timestamp or preflight checks + let bytes = std::sync::Arc::new(vec![0u8; 32]); + let src = photocraft_engine::jobs::OpenSource::Bytes(bytes); + // SourceFingerprint matches on same Bytes Arc without re-reading + let fp1 = photocraft_engine::jobs::SourceFingerprint::of(&src).unwrap(); + let fp2 = photocraft_engine::jobs::SourceFingerprint::of(&src).unwrap(); + assert_eq!(fp1, fp2); + assert!(fp1.matches(&fp2)); } diff --git a/docs/development.md b/docs/development.md index 7a2744eccc..286b235eca 100644 --- a/docs/development.md +++ b/docs/development.md @@ -670,12 +670,19 @@ physical RAM (excluding swap) and system commit headroom: - **Hard budget (H)**: $\min(75\% ext{ RAM}, ext{RAM} - 1 ext{ GiB}, ext{commit headroom} - 512 ext{ MiB}, ext{address-space limits})$. - **Soft budget (S)**: $\min(50\% ext{ RAM}, 8 ext{ GiB}, H)$. - Files requiring $\le S$ are admitted automatically. -- Files requiring $S < E \le H$ prompt for explicit one-shot user confirmation ("Open anyway") in the UI or require `approveHighMemory: true` for headless callers. +- Files requiring $S < E \le H$ prompt for explicit one-shot user confirmation ("Open anyway") in the UI. Headless callers default to no interactive dialog and require explicit per-invocation opt-in: + - Engine commands: `file.openAs` and `file.placeEmbedded` take `approveHighMemory: bool` (default `false`); `file.approveMemory` takes `{"id": u64, "approve": bool = true}`. + - Headless RPC / control protocol: `doc.open` and `doc.place` take `approveHighMemory: bool`. + - MCP: `doc_open` takes `approve_high_memory: bool`. + - CLI: `convert`, `info`, `run`, and `batch` take `--approve-high-memory`. + - Explicit approval authorizes imports within $(S, H]$ but never bypasses the hard memory budget $H$. - Files requiring $> H$ are refused and cannot be overridden. -- Fallback allowance when usable memory is unknown is 2 GiB with no confirmation override; WebAssembly uses a fixed 1 GiB limit. +- Fallback allowance when usable memory is unknown is 2 GiB with no confirmation override; this currently applies on macOS. Adaptive memory detection is available on Linux and Windows. WebAssembly uses a fixed 1 GiB limit. - Applications can lower the admission threshold with `ImportOptions::psd_max_pixel_bytes`; explicit caller limits may lower admission, never bypass the hard boundary. - Concurrent PSD decodes are serialized using a process-wide, cancellation-aware admission gate, with nested decodes sharing the enclosing allowance. +Headless RPC and MCP report structured memory errors with byte counts (`required`, `softLimit`, `hardLimit`, and `availableRam` when known). GUI automation imports currently cross a service boundary that returns string errors. + An import exceeding the hard budget returns `IoError::PsdImportBudget` with the estimated and allowed byte counts; imports requiring confirmation return `IoError::PsdApprovalRequired` when unapproved. Damaged-file recovery never bypasses these memory guards. The legacy From 1eb092539f9e51dd8dc909a3a3d96ad7f12cb796 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Mon, 2 Nov 2026 19:09:59 +0530 Subject: [PATCH 163/216] fix: resolve PSD memory CI compilation and lint errors --- crates/engine/src/cmd/file_cmds/tests.rs | 2 +- crates/io/src/lib.rs | 1 + crates/io/src/memory.rs | 11 +---------- crates/io/src/psd_budget/tests.rs | 6 +----- crates/io/src/psd_gate.rs | 2 +- 5 files changed, 5 insertions(+), 17 deletions(-) diff --git a/crates/engine/src/cmd/file_cmds/tests.rs b/crates/engine/src/cmd/file_cmds/tests.rs index 958565084c..81dc3666b7 100644 --- a/crates/engine/src/cmd/file_cmds/tests.rs +++ b/crates/engine/src/cmd/file_cmds/tests.rs @@ -2111,7 +2111,7 @@ fn pending_preflights_cap_fails_closed() { let mut s = Session::new(); let src = crate::jobs::OpenSource::Bytes(std::sync::Arc::new(vec![0; 4])); let fp = crate::jobs::SourceFingerprint::of(&src).unwrap(); - for i in 0..crate::jobs::Session::MAX_PENDING_PREFLIGHTS { + for i in 0..crate::Session::MAX_PENDING_PREFLIGHTS { assert!( s.store_pending_preflight(crate::jobs::PendingPsdPreflight { id: 0, diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 5b5cea69a0..5c30961673 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -552,6 +552,7 @@ pub fn import_source_with_options( Ok((r, damaged)) } +#[cfg(test)] pub(crate) fn import_source_with_limit( name: &str, source: photocraft_psd::Source, diff --git a/crates/io/src/memory.rs b/crates/io/src/memory.rs index 68eacc3b61..55edaa0865 100644 --- a/crates/io/src/memory.rs +++ b/crates/io/src/memory.rs @@ -163,6 +163,7 @@ pub fn resolve_budget(snapshot: MemorySnapshot, caller_limit: Option) -> Me /// - `required > soft_limit` and high memory is not approved; /// - high memory is approved, but `approved_estimate` is specified and `required > approved_estimate` /// (preventing unbounded approval for altered/enlarged files). +/// /// Result of evaluating an import admission request. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct AdmissionOutcome { @@ -202,16 +203,6 @@ pub(crate) fn evaluate_psd_admission( available_ram: budget.available_ram, }); } - if let Some(approved) = approved_estimate { - if required > approved { - return Err(IoError::PsdApprovalRequired { - required, - soft_limit: budget.soft_limit, - hard_limit: budget.hard_limit, - available_ram: budget.available_ram, - }); - } - } let mut cap = budget.hard_limit; if let Some(caller) = limit_opt { cap = cap.min(caller); diff --git a/crates/io/src/psd_budget/tests.rs b/crates/io/src/psd_budget/tests.rs index 432441eee9..83bb7ad467 100644 --- a/crates/io/src/psd_budget/tests.rs +++ b/crates/io/src/psd_budget/tests.rs @@ -8,8 +8,7 @@ fn retained_storage_is_summed_but_workspace_is_reused() { budget.retain(20).unwrap(); budget.scratch(30); budget.scratch(10); - assert!(budget.check(90).is_ok()); - assert!(matches!(budget.check(89), Err(IoError::PsdImportBudget { required: 90, limit: 89 }))); + assert_eq!(budget.required().unwrap(), 90); budget.retain(u64::MAX).unwrap_err(); budget.workspace = u64::MAX; assert!(budget.required().is_err()); @@ -239,9 +238,6 @@ fn adaptive_budget_requires_explicit_approval_in_confirmation_range() { #[test] fn explicit_approval_allows_confirmation_range_but_never_bypasses_hard_boundary() { - let file = testgen::small(Version::Psd, Compression::Zip); - let bytes = file.to_bytes().unwrap(); - let est = estimate(&file); let gb = 1u64 << 30; // Scale RAM so that est falls between S and H: diff --git a/crates/io/src/psd_gate.rs b/crates/io/src/psd_gate.rs index cc71e2e6c6..e7b67767c9 100644 --- a/crates/io/src/psd_gate.rs +++ b/crates/io/src/psd_gate.rs @@ -140,7 +140,7 @@ mod tests { assert!(!is_holding_admission()); // Can acquire again after release - let guard3 = acquire_decode_admission(&ctl).unwrap(); + let _guard3 = acquire_decode_admission(&ctl).unwrap(); assert!(is_holding_admission()); } From d59dc3ebe98f3252c99628cbc6e313532b1862ae Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Mon, 2 Nov 2026 19:18:33 +0530 Subject: [PATCH 164/216] fix: clear remaining PSD admission clippy diagnostics --- crates/engine/src/cmd/file_cmds/open.rs | 4 ++-- crates/engine/src/jobs.rs | 28 ++++++++++++------------- crates/io/src/psd_budget/tests.rs | 8 +++---- crates/io/src/psd_gate.rs | 5 +++-- 4 files changed, 23 insertions(+), 22 deletions(-) diff --git a/crates/engine/src/cmd/file_cmds/open.rs b/crates/engine/src/cmd/file_cmds/open.rs index 46b5f71f2c..f4ad13e5d9 100644 --- a/crates/engine/src/cmd/file_cmds/open.rs +++ b/crates/engine/src/cmd/file_cmds/open.rs @@ -179,7 +179,7 @@ pub(super) fn approve_memory(s: &mut Session, p: &Value) -> Result { return Err(EngineError::BadParams { cmd: "file.approveMemory".into(), msg: "expected boolean for \"approve\"".into() }); } }; - let background = match p.get("background") { + let _background = match p.get("background") { None | Some(Value::Null) => true, Some(Value::Bool(b)) => *b, Some(_) => { @@ -214,7 +214,7 @@ pub(super) fn approve_memory(s: &mut Session, p: &Value) -> Result { preflight.confirm_damage, Some(preflight.required), Some(preflight.fingerprint), - background, + _background, )?; match started { crate::jobs::Started::Done(v) => Ok(json!({"id": id, "approved": true, "result": v})), diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index c6f5623036..da2851a3d0 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -610,16 +610,6 @@ impl Session { self.dispatch(id, params, true) } - /// Open a file in the background: read (for a path) and decode on a worker, with progress - /// (per layer for PSD/PSB) and cancellation, then add the document like - /// [`Session::open_document`] (Color Settings policies apply). `name` names the document. - /// The job's result is `{document, docId, name, warnings, color}`; on wasm it runs inline. - /// `document` is the tab index at apply time; resolve `docId` before handling a delayed - /// completion, since earlier handlers may have moved or closed tabs. When - /// Color Settings ask about the file's profile, the document is held back instead and the - /// result is `{held, name, warnings, color}` (`color` the question): take it with - /// [`Session::take_held_open`] once answered. - pub const MAX_PENDING_PREFLIGHTS: usize = 32; pub fn store_pending_preflight(&mut self, mut preflight: PendingPsdPreflight) -> Result { @@ -673,6 +663,15 @@ impl Session { self.start_open_inner(name, source, confirm_damage, true, approved_estimate, expected_fingerprint, background) } + /// Open a file in the background: read (for a path) and decode on a worker, with progress + /// (per layer for PSD/PSB) and cancellation, then add the document like + /// [`Session::open_document`] (Color Settings policies apply). `name` names the document. + /// The job's result is `{document, docId, name, warnings, color}`; on wasm it runs inline. + /// `document` is the tab index at apply time; resolve `docId` before handling a delayed + /// completion, since earlier handlers may have moved or closed tabs. When + /// Color Settings ask about the file's profile, the document is held back instead and the + /// result is `{held, name, warnings, color}` (`color` the question): take it with + /// [`Session::take_held_open`] once answered. pub fn start_open(&mut self, name: &str, source: OpenSource) -> Result { self.start_open_inner(name, source, false, false, None, None, true) } @@ -710,7 +709,7 @@ impl Session { opts.psd_approved_estimate = approved_estimate; let test_memory_scope = photocraft_io::memory::TestMemoryScope::capture(); enum WorkResult { - Decoded((photocraft_io::ImportResult, bool)), + Decoded(Box<(photocraft_io::ImportResult, bool)>), PreflightNeeded { required: u64, soft_limit: u64, hard_limit: u64, available_ram: Option, source: OpenSource, fingerprint: SourceFingerprint }, } self.start_job_with_spawn( @@ -743,7 +742,7 @@ impl Session { ctx.progress(0.02, "Decoding"); let res = ctx.stage(0.02, 0.95, "Decoding", |ctl| photocraft_io::import_for_open_with(&name_w, &bytes, &opts, ctl)); match res { - Ok(imported) => WorkResult::Decoded(imported), + Ok(imported) => WorkResult::Decoded(Box::new(imported)), Err(photocraft_io::IoError::PsdApprovalRequired { required, soft_limit, hard_limit, available_ram }) if confirm_damage => { let src = OpenSource::Bytes(bytes); let fingerprint = SourceFingerprint::of(&src)?; @@ -767,7 +766,7 @@ impl Session { OpenSource::Path(p) => { let res = ctx.stage(0.0, 0.95, "Decoding", |ctl| import_path(&name_w, &p, &opts, ctl)); match res { - Ok(imported) => WorkResult::Decoded(imported), + Ok(imported) => WorkResult::Decoded(Box::new(imported)), Err(photocraft_io::IoError::PsdApprovalRequired { required, soft_limit, hard_limit, available_ram }) if confirm_damage => { let src = OpenSource::Path(p); let fingerprint = SourceFingerprint::of(&src)?; @@ -826,7 +825,8 @@ impl Session { "availableRam": available_ram, })) } - WorkResult::Decoded((mut r, damaged)) => { + WorkResult::Decoded(imported) => { + let (mut r, damaged) = *imported; crate::file_cmds::tag_exr_colorspace(s, &name_a, &mut r.document); crate::file_cmds::raw_defaults(s, &r.warnings, &mut r.document); let raw = r.warnings.iter().any(|w| w.contains(photocraft_io::raw::DEVELOPED_NOTE)); diff --git a/crates/io/src/psd_budget/tests.rs b/crates/io/src/psd_budget/tests.rs index 83bb7ad467..dab8c331e5 100644 --- a/crates/io/src/psd_budget/tests.rs +++ b/crates/io/src/psd_budget/tests.rs @@ -378,14 +378,14 @@ fn nested_imports_consume_from_shared_allowance_and_do_not_reset_parent() { crate::psd_gate::set_active_attempt_budget(2 * gb); // First nested consume fits within 2 GB - assert!(crate::psd_gate::consume_attempt_allowance(1 * gb).is_ok()); + assert!(crate::psd_gate::consume_attempt_allowance(gb).is_ok()); // Second nested consume exceeds remaining 1 GB let err = crate::psd_gate::consume_attempt_allowance(2 * gb).unwrap_err(); - assert!(matches!(err, IoError::PsdImportBudget { required, limit } if required == 2 * gb && limit == 1 * gb)); + assert!(matches!(err, IoError::PsdImportBudget { required, limit } if required == 2 * gb && limit == gb)); // Allowance is still 1 GB (failed attempt does not reset or corrupt parent) - assert!(crate::psd_gate::consume_attempt_allowance(1 * gb).is_ok()); + assert!(crate::psd_gate::consume_attempt_allowance(gb).is_ok()); assert!(crate::psd_gate::consume_attempt_allowance(1).is_err()); } @@ -397,7 +397,7 @@ fn nested_tiff_layer_import_consumes_composite_and_estimate_without_resetting_pa crate::psd_gate::set_active_attempt_budget(3 * gb); assert!(crate::psd_gate::is_holding_admission()); - assert!(crate::psd_gate::consume_attempt_allowance(1 * gb).is_ok()); + assert!(crate::psd_gate::consume_attempt_allowance(gb).is_ok()); assert!(crate::psd_gate::consume_attempt_allowance(2 * gb).is_ok()); assert!(crate::psd_gate::consume_attempt_allowance(1).is_err()); } diff --git a/crates/io/src/psd_gate.rs b/crates/io/src/psd_gate.rs index e7b67767c9..8e00cc56c1 100644 --- a/crates/io/src/psd_gate.rs +++ b/crates/io/src/psd_gate.rs @@ -9,6 +9,7 @@ use std::cell::Cell; use std::marker::PhantomData; use std::sync::{Condvar, Mutex, OnceLock}; use std::thread::ThreadId; +#[cfg(any(test, not(target_arch = "wasm32")))] use std::time::Duration; use crate::IoError; @@ -88,7 +89,7 @@ pub(crate) fn consume_attempt_allowance(bytes: u64) -> Result<(), IoError> { /// `ctl.check()` every 50 ms while waiting so cancellation halts the wait immediately. pub(crate) fn acquire_decode_admission(ctl: &photocraft_raster::Interrupt) -> Result { let current_thread = std::thread::current().id(); - let (mutex, cvar) = get_gate(); + let (mutex, _cvar) = get_gate(); let mut state = mutex.lock().unwrap_or_else(std::sync::PoisonError::into_inner); if state.active && state.owner == Some(current_thread) { @@ -106,7 +107,7 @@ pub(crate) fn acquire_decode_admission(ctl: &photocraft_raster::Interrupt) -> Re #[cfg(not(target_arch = "wasm32"))] while state.active { ctl.check().map_err(|_| IoError::Cancelled)?; - let (next_state, _) = cvar.wait_timeout(state, Duration::from_millis(50)).unwrap_or_else(std::sync::PoisonError::into_inner); + let (next_state, _) = _cvar.wait_timeout(state, Duration::from_millis(50)).unwrap_or_else(std::sync::PoisonError::into_inner); state = next_state; } From c31ef9b6839195fc4449232e4805d7bd18ae8f3c Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 2 Nov 2026 14:57:08 +0100 Subject: [PATCH 165/216] Integration: edit.paste's "as":"path" is a vector path, not a host file, for the automation registry check (#10704) Co-Authored-By: Claude Opus 5.5 --- crates/automation/src/workspace/tests.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/automation/src/workspace/tests.rs b/crates/automation/src/workspace/tests.rs index e4c710f281..e96ccb369d 100644 --- a/crates/automation/src/workspace/tests.rs +++ b/crates/automation/src/workspace/tests.rs @@ -694,6 +694,8 @@ fn documents_filesystem_path_params(id: &str, params: &str) -> bool { | "layer.vectorMask.info" | "paint.symmetryFromPath" | "edit.defineCustomShape" + // `"as":"path"` pastes the clipboard as a vector path (#10694). + | "edit.paste" | "layer.combineShapes.unite" | "layer.combineShapes.subtractFrontShape" | "layer.combineShapes.intersectShapeAreas" From e6a0b9ad20b79962ea3b3c921f25a5259d6e8388 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Mon, 2 Nov 2026 19:28:00 +0530 Subject: [PATCH 166/216] fix: validate memory approvals before I/O and remove prompt panic --- apps/photocraft-cli/src/document.rs | 3 +-- crates/automation/src/headless.rs | 3 +-- crates/automation/src/rpc.rs | 6 ++---- crates/engine/src/cmd/file_cmds/open.rs | 5 ++++- crates/engine/src/cmd/file_cmds/tests.rs | 4 ++-- crates/ui-egui/src/dialogs/open_prompt.rs | 5 ++++- crates/ui-egui/src/psd_open_tests.rs | 13 ------------- 7 files changed, 14 insertions(+), 25 deletions(-) diff --git a/apps/photocraft-cli/src/document.rs b/apps/photocraft-cli/src/document.rs index 941d58814f..4c6678bfb1 100644 --- a/apps/photocraft-cli/src/document.rs +++ b/apps/photocraft-cli/src/document.rs @@ -30,8 +30,7 @@ pub(crate) fn convert(a: &ConvertArgs, _out: &mut dyn Write, err: &mut dyn Write let (input, output) = (&a.input, &a.output); let opts = ExportOptions::from(&a.export); refuse_overwriting_input(a.in_place, input, output)?; - let mut import_opts = photocraft_io::ImportOptions::default(); - import_opts.psd_high_memory_approved = a.approve_high_memory; + let import_opts = photocraft_io::ImportOptions { psd_high_memory_approved: a.approve_high_memory, ..Default::default() }; let mut o = files::open_with_options(input, &import_opts)?; // Preferences › Raw Defaults › Resolution at its default, as `run` and `info` open a raw. photocraft_engine::file_cmds::raw_defaults(&photocraft_engine::Session::new(), &o.warnings, &mut o.document); diff --git a/crates/automation/src/headless.rs b/crates/automation/src/headless.rs index ca0158cee7..2ec4f23b9e 100644 --- a/crates/automation/src/headless.rs +++ b/crates/automation/src/headless.rs @@ -95,8 +95,7 @@ impl Headless { /// Open a file with optional high-memory approval and make it the active document. pub fn open_with_approval(&mut self, path: &Path, approve_high_memory: bool) -> Result { - let mut opts = photocraft_io::ImportOptions::default(); - opts.psd_high_memory_approved = approve_high_memory; + let opts = photocraft_io::ImportOptions { psd_high_memory_approved: approve_high_memory, ..Default::default() }; self.open_with_options(path, &opts) } diff --git a/crates/automation/src/rpc.rs b/crates/automation/src/rpc.rs index 7608dc6922..a41737bbaf 100644 --- a/crates/automation/src/rpc.rs +++ b/crates/automation/src/rpc.rs @@ -143,10 +143,8 @@ impl Headless { } "doc.place" => { let path = str_of(&p, "path").ok_or_else(|| bad("doc.place needs `path`"))?; - if let Some(v) = p.get("approveHighMemory") { - if !v.is_boolean() { - return Err(bad("`approveHighMemory` must be a boolean")); - } + if p.get("approveHighMemory").is_some_and(|v| !v.is_boolean()) { + return Err(bad("`approveHighMemory` must be a boolean")); } let mut opts = p.as_object().cloned().unwrap_or_default(); opts.remove("path"); diff --git a/crates/engine/src/cmd/file_cmds/open.rs b/crates/engine/src/cmd/file_cmds/open.rs index f4ad13e5d9..43daf778bb 100644 --- a/crates/engine/src/cmd/file_cmds/open.rs +++ b/crates/engine/src/cmd/file_cmds/open.rs @@ -158,7 +158,6 @@ pub fn open_bytes_as_with_options( pub(super) fn open_as(s: &mut Session, p: &Value) -> Result { let path = str_param(p, "path", "file.openAs")?.to_string(); - let bytes = read_file(&path)?; let as_ext = p.get("as").or_else(|| p.get("format")).and_then(Value::as_str); let approve_high_memory = match p.get("approveHighMemory") { None | Some(Value::Null) => false, @@ -167,6 +166,7 @@ pub(super) fn open_as(s: &mut Session, p: &Value) -> Result { return Err(EngineError::BadParams { cmd: "file.openAs".into(), msg: "expected boolean for \"approveHighMemory\"".into() }); } }; + let bytes = read_file(&path)?; open_bytes_as_with_options(s, &path, &bytes, as_ext, Some(path.clone()), approve_high_memory) } @@ -307,6 +307,9 @@ pub fn place_bytes(s: &mut Session, name: &str, bytes: Vec, linked: Option Result { let cmd = if linked { "file.placeLinked" } else { "file.placeEmbedded" }; + if p.get("approveHighMemory").is_some_and(|v| !v.is_null() && !v.is_boolean()) { + return Err(EngineError::BadParams { cmd: cmd.into(), msg: "expected boolean for approveHighMemory".into() }); + } let path = str_param(p, "path", cmd)?.to_string(); let bytes = read_file(&path)?; // The link is stored absolute: a relative one would later be taken from the document's diff --git a/crates/engine/src/cmd/file_cmds/tests.rs b/crates/engine/src/cmd/file_cmds/tests.rs index 81dc3666b7..cbdcc82815 100644 --- a/crates/engine/src/cmd/file_cmds/tests.rs +++ b/crates/engine/src/cmd/file_cmds/tests.rs @@ -1992,7 +1992,7 @@ fn sanitize_keeps_windows_device_names_off_the_disk() { #[test] fn approve_memory_validates_parameters_and_unknown_ids() { - let mut s = Session::new(); + let mut s = session(4, 4, 8); assert!(matches!(s.execute("file.approveMemory", json!({})), Err(EngineError::BadParams { .. }))); assert!(matches!(s.execute("file.approveMemory", json!({"id": "not_a_number"})), Err(EngineError::BadParams { .. }))); assert!(matches!(s.execute("file.approveMemory", json!({"id": 12345})), Err(EngineError::BadParams { .. }))); @@ -2074,7 +2074,7 @@ fn approve_memory_refuses_when_source_changes_before_confirmation() { // Modify file on disk std::fs::write(&path, b"changed content longer").unwrap(); - let res = s.execute("file.approveMemory", json!({"id": id, "approve": true})); + let res = s.execute("file.approveMemory", json!({"id": id, "approve": true, "background": false})); assert!(res.is_err(), "changed file must be refused"); } diff --git a/crates/ui-egui/src/dialogs/open_prompt.rs b/crates/ui-egui/src/dialogs/open_prompt.rs index 02b2276cfb..a9db6507ec 100644 --- a/crates/ui-egui/src/dialogs/open_prompt.rs +++ b/crates/ui-egui/src/dialogs/open_prompt.rs @@ -113,7 +113,10 @@ pub fn advance(app: &mut PhotocraftApp) { show(app); return; } - let held = app.held_opens.pop_front().unwrap(); + let Some(held) = app.held_opens.pop_front() else { + app.prompt_order.pop_front(); + continue; + }; app.prompt_order.pop_front(); let _ = open(app, held, None); if has_open_dialog(app) { diff --git a/crates/ui-egui/src/psd_open_tests.rs b/crates/ui-egui/src/psd_open_tests.rs index 0765f65229..e01a7a50b9 100644 --- a/crates/ui-egui/src/psd_open_tests.rs +++ b/crates/ui-egui/src/psd_open_tests.rs @@ -630,16 +630,3 @@ fn mixed_prompts_follow_arrival_order_fifo() { assert_eq!(call(&mut app, "ui.dialog.cancel", json!({"dialog": dlg_mem}))["ok"], true); assert!(app.ui.dialogs.is_empty()); } - -#[test] -fn normal_image_open_does_not_require_timestamps_or_preflight_fingerprints() { - let mut app = app(); - // A synthetic 4x4 PNG/raw/flat image opens without timestamp or preflight checks - let bytes = std::sync::Arc::new(vec![0u8; 32]); - let src = photocraft_engine::jobs::OpenSource::Bytes(bytes); - // SourceFingerprint matches on same Bytes Arc without re-reading - let fp1 = photocraft_engine::jobs::SourceFingerprint::of(&src).unwrap(); - let fp2 = photocraft_engine::jobs::SourceFingerprint::of(&src).unwrap(); - assert_eq!(fp1, fp2); - assert!(fp1.matches(&fp2)); -} From c03c97222b9e21ddf799d0da3db0173bc3d3edc1 Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 2 Nov 2026 16:30:02 +0100 Subject: [PATCH 167/216] Integration: Trap, Match Color, Custom and gallery steps that record a key twice stay kept steps (#10630, #10631, #10632) The first value played and saved, the other was dropped. Same rule as the Group I and Distort mappers' `setting`. Found by CodeRabbit on #10776. Co-Authored-By: Claude Opus 5.5 --- .../engine/src/cmd/atn_cmds/steps_channels.rs | 11 ++++++---- .../engine/src/cmd/atn_cmds/steps_gallery.rs | 5 +++++ crates/engine/src/cmd/atn_cmds/steps_other.rs | 11 ++++++---- crates/engine/src/cmd/atn_cmds/tests.rs | 22 +++++++++++++++++++ 4 files changed, 41 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/atn_cmds/steps_channels.rs b/crates/engine/src/cmd/atn_cmds/steps_channels.rs index a11e199efb..bfd3e07904 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_channels.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_channels.rs @@ -14,11 +14,14 @@ fn only_keys(d: &Descriptor, keys: &[&str]) -> bool { /// The step's `key` read by `read` (its space padding ignored, as `only_keys` admits it), /// `default` when the step leaves it out, `None` when it holds a value `read` can't take: that -/// is still the step's setting, so the step is kept rather than played with the default. +/// is still the step's setting, so the step is kept rather than played with the default. A key +/// recorded more than once is `None` too: the step saves one value, so it would drop the other. fn setting(d: &Descriptor, key: &str, read: impl Fn(&D) -> Option, default: T) -> Option { - match d.items.iter().find(|(k, _)| trimmed(k) == key.trim_end()) { - Some((_, v)) => read(v), - None => Some(default), + let mut found = d.items.iter().filter(|(k, _)| trimmed(k) == key.trim_end()); + match (found.next(), found.next()) { + (Some((_, v)), None) => read(v), + (None, _) => Some(default), + (Some(_), Some(_)) => None, } } diff --git a/crates/engine/src/cmd/atn_cmds/steps_gallery.rs b/crates/engine/src/cmd/atn_cmds/steps_gallery.rs index f430ab11c0..0ae4899fa0 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_gallery.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_gallery.rs @@ -640,6 +640,11 @@ fn effect_step(d: &Descriptor) -> Option { if !d.items.iter().all(|(k, _)| known.contains(&trimmed(k).as_str())) { return None; } + // A key recorded twice would play and save one of its values: keep the step instead. + let names: Vec = d.items.iter().map(|(k, _)| trimmed(k)).collect(); + if names.iter().enumerate().any(|(i, n)| names[..i].contains(n)) { + return None; + } let mut p = Map::new(); for k in keys.iter() { // Only a missing key takes Photoshop's default: a key that is there with the wrong type diff --git a/crates/engine/src/cmd/atn_cmds/steps_other.rs b/crates/engine/src/cmd/atn_cmds/steps_other.rs index 10ee4c0646..599ed7cab7 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_other.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_other.rs @@ -15,11 +15,14 @@ fn only_keys(d: &Descriptor, keys: &[&str]) -> bool { /// The step's `key` read by `read` (its space padding ignored, as `only_keys` admits it), /// `default` when the step leaves it out, `None` when it holds a value `read` can't take: that -/// is still the step's setting, so the step is kept rather than played with the default. +/// is still the step's setting, so the step is kept rather than played with the default. A key +/// recorded more than once is `None` too: the step saves one value, so it would drop the other. fn setting(d: &Descriptor, key: &str, read: impl Fn(&D) -> Option, default: T) -> Option { - match d.items.iter().find(|(k, _)| trimmed(k) == key.trim_end()) { - Some((_, v)) => read(v), - None => Some(default), + let mut found = d.items.iter().filter(|(k, _)| trimmed(k) == key.trim_end()); + match (found.next(), found.next()) { + (Some((_, v)), None) => read(v), + (None, _) => Some(default), + (Some(_), Some(_)) => None, } } diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index 6f0b056a6c..09fbf3a2f2 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -5815,3 +5815,25 @@ fn load_rejects_bad_encodings() { } assert!(s.actions.list.is_empty(), "nothing is loaded when the encoding is bad"); } + +/// A setting recorded twice (`Wdth`, `Fade`, `Scl `, a gallery key) would play one value and +/// save one, dropping the other: such a step stays a Photoshop step (CodeRabbit on #10776). +#[test] +fn steps_that_record_a_setting_twice_are_kept() { + let kept = |m: Mapped| matches!(m, Mapped::Unsupported { harmless: false }); + let identity = D::List((0..25).map(|i| D::Integer(i32::from(i == 12))).collect()); + let once = [ + ("Trap", desc("null").with("Wdth", px(2.0))), + ("matchColor", desc("null").with("Fade", D::Integer(10))), + ("Cstm", desc("null").with("Mtrx", identity.clone()).with("Scl ", D::Integer(3))), + ]; + for (event, d) in once { + assert!(!kept(channel_step(event, d.clone())), "{event} once was kept"); + let (key, value) = d.items.last().cloned().unwrap(); + let twice = Descriptor { items: [d.items.clone(), vec![(key, value)]].concat(), ..d }; + assert!(kept(channel_step(event, twice)), "{event} twice was mapped"); + } + let edges = || desc("null").with("EdgW", D::Integer(2)).with("EdgB", D::Integer(38)).with("Smth", D::Integer(5)); + assert!(!kept(gallery_step("GEfc", "AccE", edges()))); + assert!(kept(gallery_step("GEfc", "AccE", edges().with("EdgW", D::Integer(9))))); +} From abd75b5db5628ffcc501a9983052451399940c5d Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Mon, 2 Nov 2026 21:02:03 +0530 Subject: [PATCH 168/216] fix: make MCP high-memory approval a strict defaulted boolean --- crates/automation/src/server.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/automation/src/server.rs b/crates/automation/src/server.rs index 8d053232df..a0fb6d36fb 100644 --- a/crates/automation/src/server.rs +++ b/crates/automation/src/server.rs @@ -52,7 +52,7 @@ pub struct OpenParams { pub path: String, /// Explicitly approve high-memory PSD/PSB imports that fit within the hard memory budget. #[serde(default)] - pub approve_high_memory: Option, + pub approve_high_memory: bool, } #[derive(Debug, Default, Deserialize, JsonSchema)] @@ -658,7 +658,7 @@ impl PhotocraftMcp { #[tool(description = "Open an image or document file and make it active. Returns index, size and import warnings.")] async fn doc_open(&self, Parameters(p): Parameters) -> Result { let path = PathBuf::from(&p.path); - let approve = p.approve_high_memory.unwrap_or(false); + let approve = p.approve_high_memory; if let Some(r) = self.headless_op(move |h| h.open_with_approval(&path, approve)).await { return to_result(r); } @@ -1320,6 +1320,13 @@ mod tests { async fn mcp_doc_open_schema_and_structured_error() { let schema = &PhotocraftMcp::tool_router().map["doc_open"].attr.input_schema; assert_eq!(schema["properties"]["approve_high_memory"]["type"], "boolean"); + let omitted: OpenParams = serde_json::from_value(json!({"path": "file.psd"})).unwrap(); + assert!(!omitted.approve_high_memory); + let approved: OpenParams = serde_json::from_value(json!({"path": "file.psd", "approve_high_memory": true})).unwrap(); + assert!(approved.approve_high_memory); + for invalid in [Value::Null, json!("true"), json!(1)] { + assert!(serde_json::from_value::(json!({"path": "file.psd", "approve_high_memory": invalid})).is_err()); + } let err = AutomationError::Import(photocraft_io::IoError::PsdApprovalRequired { required: 6000, From b8621fd1ca6e978316d30cdc2418fd1c7e4038e0 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Mon, 2 Nov 2026 21:02:15 +0530 Subject: [PATCH 169/216] fix: apply special Fill semantics to CMYK adjustment layers --- .../proptest-regressions/cmyk/tests.txt | 2 + crates/compose/src/cmyk.rs | 15 ++- crates/compose/src/cmyk/tests.rs | 96 ++++++++++++++++++- 3 files changed, 109 insertions(+), 4 deletions(-) create mode 100644 crates/compose/proptest-regressions/cmyk/tests.txt diff --git a/crates/compose/proptest-regressions/cmyk/tests.txt b/crates/compose/proptest-regressions/cmyk/tests.txt new file mode 100644 index 0000000000..c921b0f002 --- /dev/null +++ b/crates/compose/proptest-regressions/cmyk/tests.txt @@ -0,0 +1,2 @@ +# Replay the CI counterexample for adjustment-layer Difference Fill. +cc 4725294ab4d39cca4de4f892e5746664cba17b57c513a042cba08a0d592f03ea diff --git a/crates/compose/src/cmyk.rs b/crates/compose/src/cmyk.rs index 2f8eaa8861..2f05b3be2b 100644 --- a/crates/compose/src/cmyk.rs +++ b/crates/compose/src/cmyk.rs @@ -403,7 +403,10 @@ fn adjust_inks(adj: &Adjustment, buf: &mut CmykBuffer, depth: SampleType) { /// Mixes an adjustment's result back into `base` in the layer's blend mode, by its opacity and /// mask; coverage is unchanged (the compositor's adjustment mix). fn mix_adjusted(l: &Layer, base: &mut CmykBuffer, adjusted: &CmykBuffer, cx: &Ctx) { - let opacity = l.opacity * l.fill_opacity; + // Fill below 100% in one of the special eight changes the blend itself and leaves + // coverage to Opacity, as on the RGB compositor (psblend::composite_fill, #9501). + let special = l.fill_opacity < 1.0 && super::psblend::fill_is_special(l.blend); + let opacity = if special { l.opacity } else { l.opacity * l.fill_opacity }; let rect = base.rect; let w = (rect.width() as usize).max(1); let mv = mask_vals(l, rect, cx); @@ -422,7 +425,15 @@ fn mix_adjusted(l: &Layer, base: &mut CmykBuffer, adjusted: &CmykBuffer, cx: &Ct } continue; } - let bl = blend_ink(l.blend, [p[0], p[1], p[2], p[3]], [a[0], a[1], a[2], a[3]]); + let bl = if special { + let b = |i: usize| 1.0 - p[i]; + let s = |i: usize| 1.0 - a[i]; + let cmy = super::psblend::blend_rgb_fill(l.blend, [b(0), b(1), b(2)], [s(0), s(1), s(2)], l.fill_opacity); + let k = super::psblend::blend_rgb_fill(l.blend, [b(3); 3], [s(3); 3], l.fill_opacity); + [1.0 - cmy[0], 1.0 - cmy[1], 1.0 - cmy[2], 1.0 - k[0]] + } else { + blend_ink(l.blend, [p[0], p[1], p[2], p[3]], [a[0], a[1], a[2], a[3]]) + }; for (v, b) in p.iter_mut().zip(bl) { *v = if k >= 1.0 { b } else { *v + (b - *v) * k }; } diff --git a/crates/compose/src/cmyk/tests.rs b/crates/compose/src/cmyk/tests.rs index 44b265c8dd..821c57fa3e 100644 --- a/crates/compose/src/cmyk/tests.rs +++ b/crates/compose/src/cmyk/tests.rs @@ -220,14 +220,33 @@ fn ref_adjusted(l: &Layer, adj: &Adjustment, clipped: &[Layer], p: Cmyka, x: i32 if p[4] > 0.0 { a = ref_clip(clipped, a, x, y); } - let k = l.opacity * l.fill_opacity * ref_mask(l, x, y); + let special = l.fill_opacity < 1.0 && crate::psblend::fill_is_special(l.blend); + let op = if special { l.opacity } else { l.opacity * l.fill_opacity }; + let k = op * ref_mask(l, x, y); if l.blend == BlendMode::Dissolve { // Opacity and mask are the chance the pixel takes the adjustment fully. return if k > 0.0 && crate::dissolve_noise(x, y) < k { [a[0], a[1], a[2], a[3], p[4]] } else { p }; } let mut o = p; + let bl = std::array::from_fn::<_, 4, _>(|c| { + if !special { + return ref_blend(l.blend, p[c], a[c]); + } + // Independent scalar oracle: apply Fill toward the mode's neutral brightness. + let f = l.fill_opacity.clamp(0.0, 1.0); + let (back, source) = (1.0 - p[c], 1.0 - a[c]); + if l.blend == BlendMode::HardMix { + return 1.0 - ((back - f * (1.0 - source)) / (1.0 - f)).clamp(0.0, 1.0); + } + let neutral = match l.blend { + BlendMode::ColorBurn | BlendMode::LinearBurn => 1.0, + BlendMode::VividLight | BlendMode::LinearLight => 0.5, + _ => 0.0, + }; + ref_blend(l.blend, p[c], 1.0 - (neutral + (source - neutral) * f)) + }); for c in 0..4 { - o[c] = p[c] + (ref_blend(l.blend, p[c], a[c]) - p[c]) * k; + o[c] = p[c] + (bl[c] - p[c]) * k; } o } @@ -1858,3 +1877,76 @@ fn every_cmyk_route_rounds_after_an_adjustment_layer() { assert_eq!((display, export), (105.0 / 255.0, 105.0 / 255.0), "opacity {opacity}"); } } + +/// The exact group stack captured by proptest seed 4725294a, with source pixels +/// stored at the requested depth rather than only changing the document label. +fn captured_adjustment_doc(depth: SampleType, blend: BlendMode, opacity: f32, fill: f32, layer_mask: Option) -> (Document, f32) { + let mut source = Layer::raster("source", PixelFormat { mode: ColorMode::Cmyk, sample: depth, alpha: true }); + source.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 1, 1), &[0.0, 0.0, 0.583_913_15, 0.0, 1.0]); + let stored_yellow = source.surface().unwrap().sample_channel(0, 0, 2); + let mut invert = adjustment(Adjustment::Invert); + invert.blend = blend; + invert.opacity = opacity; + invert.fill_opacity = fill; + invert.mask = layer_mask; + let mut pass = Layer::group("pass", vec![invert]); + pass.blend = BlendMode::PassThrough; + let mut outer = Layer::group("isolated", vec![source, pass]); + outer.blend = BlendMode::Normal; + let mut d = Document::new("captured", Size::new(4, 5), ColorMode::Cmyk, depth); + d.layers.push(outer); + (d, stored_yellow) +} + +#[test] +fn adjustment_special_fill_recreates_captured_repro() { + let (d, _) = captured_adjustment_doc(SampleType::U8, BlendMode::Difference, 1.0, 0.170_322_34, None); + assert_routes_agree(&d); + assert_matches_reference(&d); + assert_eq!(render1(&d, 0, 0)[2], 174.0 / 255.0); +} + +#[test] +fn adjustment_special_fill_depths() { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let (d, stored) = captured_adjustment_doc(depth, BlendMode::Difference, 1.0, 0.170_322_34, None); + assert!(supports(&d)); + if depth == SampleType::F32 { + assert!(!split::supports(&d), "float documents use the native path"); + } else { + assert_routes_agree(&d); + } + // Difference on brightness: 1 - |(1 - ink) - ink * Fill|. + let unrounded = 1.0 - ((1.0 - stored) - stored * 0.170_322_34).abs(); + let expected = match depth { + SampleType::U8 => (255.0 - ((1.0 - unrounded) * 255.0 + 0.5).floor()) / 255.0, + SampleType::U16 => (32768.0 - ((1.0 - unrounded) * 32768.0 + 0.5).floor()) / 32768.0, + _ => unrounded, + }; + let got = render1(&d, 0, 0); + assert!((got[2] - expected).abs() <= 1e-5, "{depth:?}: {got:?} vs {expected}"); + assert_eq!(got[4], 1.0); + } +} + +#[test] +fn adjustment_fill_attenuation_and_boundaries() { + let cases = [ + (BlendMode::Difference, 0.5, 0.170_322_34, None), + (BlendMode::Difference, 1.0, 0.170_322_34, Some(mask(&[(Rect::new(0, 0, 1, 1), 0.5)], 1.0, 1.0, true))), + (BlendMode::Normal, 1.0, 0.170_322_34, None), + (BlendMode::Difference, 1.0, 1.0, None), + (BlendMode::Difference, 1.0, 0.0, None), + (BlendMode::Dissolve, 1.0, 0.5, None), + ]; + for (blend, opacity, fill, layer_mask) in cases { + let (d, stored) = captured_adjustment_doc(SampleType::U8, blend, opacity, fill, layer_mask); + assert_routes_agree(&d); + assert_matches_reference(&d); + let got = render1(&d, 0, 0); + assert_eq!(got[4], 1.0); + if fill == 0.0 { + assert_eq!(got[2], stored); + } + } +} From 50fd920b3bf2ba5b4637aeb329993341c2070bf5 Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 2 Nov 2026 16:33:29 +0100 Subject: [PATCH 170/216] Integration: a gallery effect's own step keeps its panel name bytes on save (#10632) The remembered name was the Latin-1 reading of the bytes, so a GBK or Shift_JIS name was written back garbled. Found by CodeRabbit on #10776. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/atn_cmds/convert.rs | 2 +- crates/engine/src/cmd/atn_cmds/steps_gallery.rs | 17 ++++++++++++----- crates/engine/src/cmd/atn_cmds/tests.rs | 17 +++++++++++++++++ 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/atn_cmds/convert.rs b/crates/engine/src/cmd/atn_cmds/convert.rs index 817ef43ef1..4b22515c09 100644 --- a/crates/engine/src/cmd/atn_cmds/convert.rs +++ b/crates/engine/src/cmd/atn_cmds/convert.rs @@ -162,7 +162,7 @@ pub(super) fn export_item(id: &str, params: &Value, set: &str) -> Option "code", AtnEvent::Name(_) => "name", }; - p.insert(RECORDED_EVENT.into(), json!({"event": item.event.id(), "eventKind": kind, "name": item.dictionary()})); + // `dictionary` keeps the panel name's bytes as recorded (a GBK or Shift_JIS name would not + // survive a round trip through text); `name` is for reading. + let dictionary = photocraft_paint::tile::b64_encode(&item.dictionary); + p.insert(RECORDED_EVENT.into(), json!({"event": item.event.id(), "eventKind": kind, "name": item.dictionary(), "dictionary": dictionary})); } -/// The event and panel name a step of command `id` remembered by [`remember_event`] saves as; -/// `None` (save as `GEfc`) when it has none or it is not the own event of `id`'s effect. -pub(super) fn recorded_event(id: &str, params: &Value) -> Option<(AtnEvent, String)> { +/// The event and panel name bytes a step of command `id` remembered by [`remember_event`] saves +/// as; `None` (save as `GEfc`) when it has none or it is not the own event of `id`'s effect. +pub(super) fn recorded_event(id: &str, params: &Value) -> Option<(AtnEvent, Vec)> { let r = params.get(RECORDED_EVENT)?; let event = jstr(r, "event")?; let event = match jstr(r, "eventKind")? { @@ -624,7 +627,11 @@ pub(super) fn recorded_event(id: &str, params: &Value) -> Option<(AtnEvent, Stri "name" => AtnEvent::name(event), _ => return None, }; - (effect_command(&event) == Some(id)).then(|| (event, jstr(r, "name").unwrap_or("").to_string())) + let name = match jstr(r, "dictionary").and_then(photocraft_paint::tile::b64_decode) { + Some(bytes) => bytes, + None => jstr(r, "name").unwrap_or("").as_bytes().to_vec(), + }; + (effect_command(&event) == Some(id)).then_some((event, name)) } /// A Filter Gallery step: the effect `GEfk` names, every key clamped or looked up, Photoshop's diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index 09fbf3a2f2..b8217a5451 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -5837,3 +5837,20 @@ fn steps_that_record_a_setting_twice_are_kept() { assert!(!kept(gallery_step("GEfc", "AccE", edges()))); assert!(kept(gallery_step("GEfc", "AccE", edges().with("EdgW", D::Integer(9))))); } + +/// A gallery effect's own step whose panel name is in the file's legacy code page (GBK +/// "强化的边缘", not UTF-8) saves back with the same name bytes (CodeRabbit on #10776). +#[test] +fn gallery_effect_steps_keep_a_legacy_panel_name_byte_for_byte() { + let edges = desc("null").with("GEfk", enm("GEft", "AccE")).with("EdgW", D::Integer(2)).with("EdgB", D::Integer(38)).with("Smth", D::Integer(5)); + let gbk = vec![0xC7, 0xBF, 0xBB, 0xAF, 0xB5, 0xC4, 0xB1, 0xDF, 0xD4, 0xB5]; + assert!(std::str::from_utf8(&gbk).is_err()); + let source = AtnItem { dictionary: gbk.clone(), ..item("AccE", "", Some(edges)) }; + let mut s = Session::new(); + let loaded = s.execute("actions.load", json!({"data": set_of("S", vec![("A", vec![source])])})).unwrap(); + assert_eq!(loaded["unsupported"], json!([]), "{loaded}"); + let saved = s.execute("actions.save", json!({"set": "S"})).unwrap(); + let file = atn::parse(&photocraft_paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap()).unwrap(); + let back = &file.actions[0].items[0]; + assert_eq!((back.event.clone(), back.dictionary.clone()), (AtnEvent::Code(*b"AccE"), gbk)); +} From f6e3eccab7a99d85c22e7663e90283a97664e656 Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 2 Nov 2026 16:42:36 +0100 Subject: [PATCH 171/216] Integration: Paste As keeps a newer internal copy over an unchanged SVG, and Pixels falls back to the bitmap (#10704) An ordinary paste now remembers the SVG next to the bitmap it took, and a clipboard that no longer holds the remembered SVG forgets it, so Paste As neither replaces a newer internal copy with the old SVG nor ignores the same SVG copied again later. Paste As Pixels reads the bitmap when the SVG read fails. Found by CodeRabbit on #10776. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/app/clipboard.rs | 24 ++++++- crates/ui-egui/src/app/clipboard_tests.rs | 86 +++++++++++++++++++++++ crates/ui-egui/src/app/commands.rs | 18 ++++- 3 files changed, 123 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/app/clipboard.rs b/crates/ui-egui/src/app/clipboard.rs index 9c57d9a7c6..54d4de90b9 100644 --- a/crates/ui-egui/src/app/clipboard.rs +++ b/crates/ui-egui/src/app/clipboard.rs @@ -51,8 +51,10 @@ impl PhotocraftApp { } return Ok(false); }; - if self.os_clip_svg.as_deref().is_some_and(|old| old == &source) && self.session.clipboard.is_some() { - // A newer internal copy remains authoritative until the external bytes change. + // Already imported, or a newer internal copy: either stays until the external bytes + // change. A raster mirror an ordinary paste took of these bytes is not their vectors. + let holds_vectors_or_newer = self.session.clipboard.as_ref().is_some_and(|clip| clip.svg.is_some() || !self.clip_external); + if self.os_clip_svg.as_deref().is_some_and(|old| old == &source) && holds_vectors_or_newer { return Ok(true); } let clip = photocraft_engine::edit_cmds::Clip::from_svg(&source).map_err(|error| { @@ -69,6 +71,13 @@ impl PhotocraftApp { Ok(true) } + /// The SVG bytes on the OS clipboard right now, read on an explicit paste without importing + /// them; `None` when there is no SVG service or the read fails (nothing is known then). + fn os_svg_now(&mut self) -> Option>>> { + let get = self.services.clipboard_get_svg.as_mut()?; + get().ok().map(|source| source.map(std::sync::Arc::new)) + } + /// Mirror the session clipboard onto the OS clipboard (RGBA8). pub(super) fn export_os_clipboard(&mut self) { self.clip_unmirrored = true; @@ -122,6 +131,13 @@ impl PhotocraftApp { let Some((w, h, bytes)) = image else { if self.clip_unmirrored && !self.clip_external && self.session.clipboard.is_some() { self.os_clip_sig = None; + // Keep remembering an SVG only while it is still there (an SVG-only clipboard), + // so a later copy of the same bytes in another app is imported again. + if let Some(now) = self.os_svg_now() + && now != self.os_clip_svg + { + self.os_clip_svg = None; + } return Ok(false); } self.os_clip_svg = None; @@ -141,7 +157,9 @@ impl PhotocraftApp { surface.prune(); self.session.clipboard = Some(photocraft_engine::edit_cmds::Clip { components: None, surface, bounds: r, layers: None, resolution: None, source: None, svg: None }); - self.os_clip_svg = None; + // The SVG that came with this image (a vector app puts both): a later internal copy is + // newer than it, so Paste As must not bring it back while it is unchanged. + self.os_clip_svg = self.os_svg_now().flatten(); self.os_clip_sig = Some(sig); self.clip_external = true; self.clip_unmirrored = false; diff --git a/crates/ui-egui/src/app/clipboard_tests.rs b/crates/ui-egui/src/app/clipboard_tests.rs index e0cb4eb138..956776a50e 100644 --- a/crates/ui-egui/src/app/clipboard_tests.rs +++ b/crates/ui-egui/src/app/clipboard_tests.rs @@ -580,3 +580,89 @@ fn targeting_a_mask_swaps_to_its_own_colour_pair() { app.run("layer.layerMask.delete", json!({})).unwrap(); assert_eq!(pair(&app), (GREEN, BLUE), "deleting the targeted mask goes back to the pixels"); } + +type SvgClip = Arc>, String>>>; + +/// An app whose OS clipboard holds the SVG `svg` and the bitmap `image`, exporting nothing +/// (so an internal copy stays newer than what the OS clipboard holds). +fn app_with_svg_and_image(svg: &SvgClip, image: &OsClip) -> PhotocraftApp { + let (s, i) = (svg.clone(), image.clone()); + let services = Services { + clipboard_get_svg: Some(Box::new(move || s.lock().unwrap().clone())), + clipboard_get_image: Some(Box::new(move || Ok(i.lock().unwrap().clone()))), + ..Default::default() + }; + let mut app = PhotocraftApp::new(Session::new(), services); + app.session.edit_prefs(|prefs| prefs.general.export_clipboard = false); + app +} + +fn copy_blue(app: &mut PhotocraftApp) { + app.run("file.new", json!({"width":8,"height":6})).unwrap(); + app.run("edit.fill", json!({"color":"#0000ff"})).unwrap(); + app.run("edit.copy", json!({})).unwrap(); +} + +/// An ordinary paste takes the bitmap a vector app put next to its SVG; a later internal copy +/// is newer than that SVG, so Paste As Pixels keeps it while the SVG is unchanged +/// (CodeRabbit on #10776). +#[test] +fn an_internal_copy_after_an_ordinary_paste_survives_an_unchanged_svg() { + let svg: SvgClip = Arc::new(Mutex::new(Ok(Some(CLIP_SVG.as_bytes().to_vec())))); + let image: OsClip = Arc::new(Mutex::new(Some((2, 1, vec![200; 8])))); + let mut app = app_with_svg_and_image(&svg, &image); + app.run("file.new", json!({"width":24,"height":20})).unwrap(); + app.run("edit.paste", json!({})).unwrap(); + assert!(app.clip_external); + copy_blue(&mut app); + app.run("edit.paste", json!({"as":"pixels"})).unwrap(); + assert_eq!(app.session.clipboard.as_ref().unwrap().surface.rgba(0, 0), [0.0, 0.0, 1.0, 1.0]); + assert!(!app.clip_external); + // Right after the ordinary paste (no internal copy), Paste As still reaches the vectors. + let mut app = app_with_svg_and_image(&svg, &image); + app.run("file.new", json!({"width":24,"height":20})).unwrap(); + app.run("edit.paste", json!({})).unwrap(); + app.run("edit.paste", json!({"as":"path"})).unwrap(); + assert!(app.session.clipboard.as_ref().unwrap().svg.is_some()); +} + +/// Once the OS clipboard no longer holds the SVG, the same SVG copied again in another app is +/// new; while an SVG-only clipboard still holds it, the newer internal copy stays +/// (CodeRabbit on #10776). +#[test] +fn a_remembered_svg_is_forgotten_once_the_clipboard_no_longer_holds_it() { + let svg: SvgClip = Arc::new(Mutex::new(Ok(Some(CLIP_SVG.as_bytes().to_vec())))); + let image: OsClip = Arc::new(Mutex::new(None)); + let mut app = app_with_svg_and_image(&svg, &image); + app.run("file.new", json!({"width":24,"height":20})).unwrap(); + app.run("edit.paste", json!({"as":"pixels"})).unwrap(); + copy_blue(&mut app); + // SVG only, unchanged: the internal copy stays. + app.run("edit.paste", json!({})).unwrap(); + app.run("edit.paste", json!({"as":"pixels"})).unwrap(); + assert_eq!(app.session.clipboard.as_ref().unwrap().surface.rgba(0, 0), [0.0, 0.0, 1.0, 1.0]); + // Text copied elsewhere, then the same SVG again: it is imported. + *svg.lock().unwrap() = Ok(None); + app.run("edit.paste", json!({})).unwrap(); + assert_eq!(app.session.clipboard.as_ref().unwrap().surface.rgba(0, 0), [0.0, 0.0, 1.0, 1.0]); + *svg.lock().unwrap() = Ok(Some(CLIP_SVG.as_bytes().to_vec())); + app.run("edit.paste", json!({"as":"pixels"})).unwrap(); + assert!(app.session.clipboard.as_ref().unwrap().svg.is_some(), "the SVG copied again must be imported"); +} + +/// Paste As Pixels falls back to the bitmap when the SVG can't be read, and only fails when +/// neither can be (CodeRabbit on #10776). +#[test] +fn pixel_paste_reads_the_bitmap_when_the_svg_read_fails() { + let svg: SvgClip = Arc::new(Mutex::new(Err("clipboard is busy".into()))); + let image: OsClip = Arc::new(Mutex::new(Some((2, 1, vec![255; 8])))); + let mut app = app_with_svg_and_image(&svg, &image); + app.run("file.new", json!({"width":24,"height":20})).unwrap(); + app.run("edit.paste", json!({"as":"pixels"})).unwrap(); + let state = app.session.active().unwrap(); + assert!(matches!(state.doc.layer(state.active_layer.unwrap()).unwrap().content, photocraft_doc::LayerContent::Raster(_))); + assert!(!app.ui.status_error, "{}", app.ui.status); + let mut app = app_with_svg_and_image(&svg, &Arc::new(Mutex::new(None))); + app.run("file.new", json!({"width":24,"height":20})).unwrap(); + assert!(app.run("edit.paste", json!({"as":"pixels"})).is_err()); +} diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index d17fb2ad26..0c148a58d9 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -97,9 +97,23 @@ impl PhotocraftApp { None }; if let Some(mode) = paste_as { - let imported = self.import_os_svg_clipboard()?; + // Pixels can come from the bitmap when the SVG can't be read; vectors can't. + let (imported, svg_error) = match self.import_os_svg_clipboard() { + Ok(imported) => (imported, None), + Err(error) if mode == "pixels" => (false, Some(error)), + Err(error) => return Err(error), + }; if mode == "pixels" && !imported && !clip_read { - self.import_os_clipboard()?; + if let Err(error) = self.import_os_clipboard() { + return Err(svg_error.unwrap_or(error)); + } + } + if let Some(error) = svg_error { + if self.session.clipboard.is_none() { + return Err(error); + } + self.ui.status.clear(); + self.ui.status_error = false; } } else if !clip_read { self.import_os_clipboard()?; From f79a169651a9e09b3c7b1c510c693dacfdade0c7 Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 2 Nov 2026 16:42:36 +0100 Subject: [PATCH 172/216] Integration: one Actions row and the batch's event total in the file format parity page The doc merge left five Actions rows that disagreed (version 12 unsupported vs read; 120 vs 127 events). Found by CodeRabbit on #10776. Co-Authored-By: Claude Opus 5.5 --- docs/file-format-parity.md | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 9155807734..e663ac796c 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-31 · **Change:** minor (`.atn`: 120 supported and 97 unmapped events after the four Render mappings (Group B, #10604); the supported table and measured reference are in [atn.md](atn.md); TIFFs up to 300,000 px a side open, decoded a group of rows at a time into the layer, #8169; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; PSD Filter Gallery: Halftone Pattern dot/circle/line settings survive PSD/PSB saves (#8940); Save for Web writes WebP, lossy or lossless, #6391; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency, #8877; layered TIFF writes the TySh text version and descriptor in PSD big-endian order while the header, matrix, warp and bounds keep container order, #9098; layered TIFF writes Photoshop's 14-byte global layer mask, so Photoshop opens the layers instead of only the composite, #9095; legacy PSD point text placed from the layout ink box after font resolution, #6094; `.atn`: 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; `.asl` layer style libraries read and written, #1051; JPEG Options on File › Save, Save As and Save a Copy, FILE-215-19 partial (no Matte, progressive Scans or size preview); 32-bit Hue/Saturation unclipped and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; JPEG Options on Save As and Save a Copy, FILE-215-19; TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; Cineon write: 10-bit gray/RGB; animated WebP frames load into video layers, #1198; Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 53 filters (39 more: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag, Invert, Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding; Amiga IFF write: 24-plane ILBM, Save As `.iff`; PSD stores spot channels as Photoshop does, ink 0 = solid, and numbers them in 1053, #7415 split; TIFF writes and reads alpha and spot channels as extra samples with Photoshop's channel resources, PSD output unchanged, FILE-215-7, FILE-215-17's TIFF part, #7415; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-31 · **Change:** minor (`.atn`: 144 supported events after Actions batch 12 (Groups A–I, command options, version 12 files, reference forms); the supported table and measured reference are in [atn.md](atn.md); TIFFs up to 300,000 px a side open, decoded a group of rows at a time into the layer, #8169; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; PSD Filter Gallery: Halftone Pattern dot/circle/line settings survive PSD/PSB saves (#8940); Save for Web writes WebP, lossy or lossless, #6391; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency, #8877; layered TIFF writes the TySh text version and descriptor in PSD big-endian order while the header, matrix, warp and bounds keep container order, #9098; layered TIFF writes Photoshop's 14-byte global layer mask, so Photoshop opens the layers instead of only the composite, #9095; legacy PSD point text placed from the layout ink box after font resolution, #6094; `.atn`: 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; `.asl` layer style libraries read and written, #1051; JPEG Options on File › Save, Save As and Save a Copy, FILE-215-19 partial (no Matte, progressive Scans or size preview); 32-bit Hue/Saturation unclipped and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; JPEG Options on Save As and Save a Copy, FILE-215-19; TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; Cineon write: 10-bit gray/RGB; animated WebP frames load into video layers, #1198; Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 53 filters (39 more: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag, Invert, Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding; Amiga IFF write: 24-plane ILBM, Save As `.iff`; PSD stores spot channels as Photoshop does, ink 0 = solid, and numbers them in 1053, #7415 split; TIFF writes and reads alpha and spot channels as extra samples with Photoshop's channel resources, PSD output unchanged, FILE-215-7, FILE-215-17's TIFF part, #7415; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency) · **Target:** Adobe Photoshop 2026 **PSD Filter Gallery (#8940, 2026-10-23):** Halftone Pattern maps its size, contrast and dot/circle/line pattern as editable settings on import and export, with the reference application's descriptor keys (`HlSz`, `Cntr`, `ScrT`: `ScrD`/`ScrC`/`ScrL`). PSD/PSB round trips pass at 8/16/32 bits. This does not claim pixel equivalence for the existing renderer. Unknown or malformed settings remain verbatim on import. @@ -133,11 +133,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | LUTs `.cube` / `.3dl` / `.look` | R/W (Export Color Lookup) | R/W / R / R | `cms/src/lutfile.rs` | | Keyboard shortcuts `.kys` | R/W | R/W | `engine/src/kys.rs` (`write`: Edit › Keyboard Shortcuts › Export…, rows carry our id; Photoshop reading our files is unverified) | | ICC profiles | R | R | Own CMS | -| Actions `.atn` ([reference](atn.md)) | R/W | R/W | `psd/src/atn.rs` (version 16 sets, exact round trip), `engine/src/atn_cmds.rs` (Photoshop events ↔ commands, including Apply Layer Comp by name, #7101; other steps kept verbatim; Save As maps to `file.saveAs`/`file.saveACopy` when its destination is a file, #7102). Panel menu, CLI `batch --actions set.atn`, `actions.load`/`actions.save`. Photoshop 5-era version 12 files unsupported (AUTO-219-2); 120 supported event IDs, 97 measured reference entries unmapped, one recognized-but-disabled event | -| Actions `.atn` | R/W | R/W | `psd/src/atn.rs` reads version 12 and 16 sets; writes version 16. Legacy bare identifiers, reference classes and object-array values retain their meaning; trailing bytes remain a warning (#10699). `engine/src/atn_cmds.rs` maps Photoshop events to commands, including Apply Layer Comp by name (#7101), and preserves unsupported steps. Save As maps to `file.saveAs`/`file.saveACopy` only for a file destination (#7102). Panel menu, CLI `batch --actions set.atn`, `actions.load`/`actions.save`. Version 7 and MacBinary wrappers remain unsupported. Legacy coverage uses synthetic fixtures, not licensed real version 12 sets. | -| Actions `.atn` ([reference](atn.md)) | R/W | R/W | `psd/src/atn.rs` (version 16 sets, exact round trip), `engine/src/atn_cmds.rs` (Photoshop events ↔ commands, including Apply Layer Comp by name, #7101; recorded layer indexes/ordinals and indexed moves, opening-order document indexes, and comp index/ID/ordinal references, #10701; other steps kept verbatim; Save As maps to `file.saveAs`/`file.saveACopy` when its destination is a file, #7102). Panel menu, CLI `batch --actions set.atn`, `actions.load`/`actions.save`. Photoshop 5-era version 12 files unsupported (AUTO-219-2) | -| Actions `.atn` ([reference](atn.md)) | R/W | R/W | `psd/src/atn.rs` (version 16 sets, exact round trip), `engine/src/cmd/atn_cmds/` (Photoshop events ↔ commands, including Apply Layer Comp by name, #7101; other steps kept verbatim; Save As maps to `file.saveAs`/`file.saveACopy` when its destination is a file, #7102). Panel menu, CLI `batch --actions set.atn`, `actions.load`/`actions.save`. Photoshop 5-era version 12 files unsupported (AUTO-219-2) | -| Actions `.atn` ([reference](atn.md)) | R/W | R/W | `psd/src/atn.rs` (version 16 sets, exact round trip), `engine/src/atn_cmds.rs` (Photoshop events ↔ commands, including Apply Layer Comp by name, #7101; other steps kept verbatim; Save As maps to `file.saveAs`/`file.saveACopy` when its destination is a file, #7102). Panel menu, CLI `batch --actions set.atn`, `actions.load`/`actions.save`. Photoshop 5-era version 12 files unsupported (AUTO-219-2); 127 supported event IDs, 89 measured reference entries unmapped, one recognized-but-disabled event | +| Actions `.atn` ([reference](atn.md)) | R/W | R/W | `psd/src/atn.rs` reads version 12 and 16 sets and writes version 16 (exact round trip for version 16; version 12 legacy bare identifiers, reference classes and object-array values keep their meaning; trailing bytes remain a warning, #10699). `engine/src/cmd/atn_cmds/` maps the reference application's events to commands (144 supported event IDs, the table in [atn.md](atn.md); one recognized-but-disabled event), including Apply Layer Comp by name (#7101), recorded layer indexes/ordinals and indexed moves, opening-order document indexes and comp index/ID/ordinal references (#10701); other steps kept verbatim. Save As maps to `file.saveAs`/`file.saveACopy` for a file destination (#7102). Panel menu, CLI `batch --actions set.atn`, `actions.load`/`actions.save`. Version 7 and MacBinary wrappers remain unsupported; version 12 coverage uses synthetic fixtures. | | Styles `.asl` | R/W | R/W | `psd/src/asl.rs`, `io/src/asl_map.rs`; Styles panel Import/Export Styles…, `style.presets.importAsl`/`exportAsl` (#1051) | | Contours `.shc` | R/W | R/W | `psd/src/shc.rs`; Contour Editor Load…/Save… (FILE-215-15). No user contour library; not yet checked against a Photoshop-written file | | Custom shapes `.csh`, tool presets `.tpl` | R/W | no | | From c01d82198ac72eb493f7cd92e6a0ed770474596a Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 2 Nov 2026 16:47:11 +0100 Subject: [PATCH 173/216] Integration: a Background selection keeps its modifier, and Move needs a layer destination (#10702, #10753) A Cmd-click adding the Background to the selection loaded as a plain select that replaced the selection; a Move to an ordinal of a channel loaded as a layer arrange. Found by CodeRabbit on #10776. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/atn_cmds/steps_layer.rs | 31 +++++++++++-------- crates/engine/src/cmd/atn_cmds/tests.rs | 28 +++++++++++++++++ 2 files changed, 46 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/cmd/atn_cmds/steps_layer.rs b/crates/engine/src/cmd/atn_cmds/steps_layer.rs index 0656654454..ed1d9caabb 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_layer.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_layer.rs @@ -15,23 +15,27 @@ use super::{D, Descriptor, Id, Map, ReferenceItem, UnicodeString, Value, json}; /// Translate supported selection targets, retaining unknown references as Photoshop steps. pub(super) fn select_from(d: &Descriptor) -> Mapped { let Some(r) = get_ref(d, "null") else { return Mapped::Unsupported { harmless: false } }; + // The layer selection's modifier, whichever way the step names the layer. + let with_modifier = |params| { + let mut p = Value::Object(params); + match get_enum(d, "selectionModifier").as_deref() { + Some("addToSelection") => p["mode"] = json!("add"), + Some("removeFromSelection") => p["mode"] = json!("remove"), + // Shift-click: select everything between the active layer and this one. + Some("addToSelectionContinuous") => p["mode"] = json!("range"), + _ => {} + } + step("layer.select", p) + }; match (ref_class(r).as_deref(), target(r)) { (Some("Lyr"), Target::Name(_) | Target::Index(_) | Target::Ordinal(_)) => { let Some(params) = recorded_layer_target(r) else { return Mapped::Unsupported { harmless: false } }; - let mut p = Value::Object(params); - match get_enum(d, "selectionModifier").as_deref() { - Some("addToSelection") => p["mode"] = json!("add"), - Some("removeFromSelection") => p["mode"] = json!("remove"), - // Shift-click: select everything between the active layer and this one. - Some("addToSelectionContinuous") => p["mode"] = json!("range"), - _ => {} - } - step("layer.select", p) + with_modifier(params) } // A Cmd-click recorded on a layer property (e.g. the Background, #10702). (Some("Lyr"), Target::Property(_)) => { let Some(p) = layer_target(r) else { return Mapped::Unsupported { harmless: false } }; - step("layer.select", Value::Object(p)) + with_modifier(p) } // The Channels panel: the active layer's mask, or back to the composite (`RGB`, `CMYK`…). (Some("Chnl"), Target::Ordinal(o)) => match o.as_str() { @@ -293,10 +297,11 @@ pub(super) fn move_from(d: &Descriptor) -> Mapped { step("layer.translate", Value::Object(p)) } Some(D::Reference(t)) => { + // Only a layer destination: an index or ordinal of anything else isn't a stack move. + if t.len() != 1 || ref_class(t).as_deref() != Some("Lyr") { + return Mapped::Unsupported { harmless: false }; + } if let Target::Index(index) = target(t) { - if t.len() != 1 || ref_class(t).as_deref() != Some("Lyr") { - return Mapped::Unsupported { harmless: false }; - } p.insert("target".into(), json!({"index": index})); return step("layer.moveTo", Value::Object(p)); } diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index b8217a5451..f153ee83af 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -5854,3 +5854,31 @@ fn gallery_effect_steps_keep_a_legacy_panel_name_byte_for_byte() { let back = &file.actions[0].items[0]; assert_eq!((back.event.clone(), back.dictionary.clone()), (AtnEvent::Code(*b"AccE"), gbk)); } + +/// A Cmd-click that adds the Background (a property reference) to the selection keeps its +/// modifier, as a click on a named layer does, and saves back the same (CodeRabbit on #10776). +#[test] +fn a_background_selection_keeps_its_modifier() { + let bckg = || D::Reference(vec![ReferenceItem::Property { class: class("Lyr "), key: code("Bckg") }]); + for (modifier, mode) in [("addToSelection", "add"), ("removeFromSelection", "remove"), ("addToSelectionContinuous", "range")] { + let d = desc("null").with("null", bckg()).with("selectionModifier", enm("selectionModifierType", modifier)).with("MkVs", D::Boolean(false)); + let loaded = mapped_recording("slct", d.clone()); + assert_eq!(loaded.actions[0].steps, vec![("layer.select".into(), json!({"layer":{"background":true},"mode":mode}))], "{modifier}"); + let (bytes, _) = export("S", &[&loaded.actions[0]], &[], false).unwrap(); + assert_eq!(atn::parse(&bytes).unwrap().actions[0].items[0].descriptor, Some(d)); + } +} + +/// A Move whose destination is an ordinal of something other than a layer is no stack move: +/// it stays a Photoshop step (CodeRabbit on #10776). +#[test] +fn a_move_to_an_ordinal_that_is_not_a_layer_is_kept() { + let ord = |cls: &str, o: &str| D::Reference(vec![ReferenceItem::Enumerated { class: class(cls), type_id: code("Ordn"), value: code(o) }]); + let target = || D::Reference(vec![ReferenceItem::Enumerated { class: class("Lyr "), type_id: code("Ordn"), value: code("Trgt") }]); + for o in ["Frnt", "Back", "Nxt ", "Prvs"] { + let layer = mapped_recording("move", desc("null").with("null", target()).with("T ", ord("Lyr ", o))); + assert!(layer.unsupported.is_empty(), "{o} on a layer"); + let channel = mapped_recording("move", desc("null").with("null", target()).with("T ", ord("Chnl", o))); + assert_eq!(channel.unsupported.len(), 1, "{o} on a channel was mapped: {:?}", channel.actions[0].steps); + } +} From 46b3302cdd3262f8c6f4b0f5b5310f3b58a7461d Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 2 Nov 2026 16:49:54 +0100 Subject: [PATCH 174/216] Integration: test that the multi-file Load Actions summary counts a surviving set's unsupported steps (#10601) Found by CodeRabbit on #10776. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/actions/tests.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/crates/ui-egui/src/panels/actions/tests.rs b/crates/ui-egui/src/panels/actions/tests.rs index 2ceea330eb..441aa9260b 100644 --- a/crates/ui-egui/src/panels/actions/tests.rs +++ b/crates/ui-egui/src/panels/actions/tests.rs @@ -1956,3 +1956,16 @@ fn save_hook_closing_the_target_stops_an_imported_close_with_save() { assert!(app.session.actions.playback_stack.is_empty()); } } + +#[test] +/// An unsupported step in a set that stays loaded is counted in the multi-file summary, unlike +/// one in a set a later file replaced (CodeRabbit on #10776). +fn bulk_load_summary_counts_unsupported_steps_of_surviving_sets() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let mut a = photocraft_psd::atn::parse(&action_set_bytes("Set A", "A")).unwrap(); + a.actions[0].items[0].event = photocraft_psd::atn::AtnEvent::Code(*b"Xxxx"); + let files = vec![("a.atn".into(), photocraft_psd::atn::write(&a).unwrap()), ("b.atn".into(), action_set_bytes("Set B", "B"))]; + let status = bulk_load_status(&mut app, files); + assert!(status.contains("1 step has no PhotoCraft equivalent yet"), "{status}"); + assert!(!app.ui.status_error, "{status}"); +} From 379fcd81ac869233909e5419400ed83d5ad80e9c Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 2 Nov 2026 17:11:58 +0100 Subject: [PATCH 175/216] Integration: collapse the Paste As Pixels fallback condition (clippy collapsible_if) (#10704) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/app/commands.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index 0c148a58d9..1a43ffe77f 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -103,10 +103,12 @@ impl PhotocraftApp { Err(error) if mode == "pixels" => (false, Some(error)), Err(error) => return Err(error), }; - if mode == "pixels" && !imported && !clip_read { - if let Err(error) = self.import_os_clipboard() { - return Err(svg_error.unwrap_or(error)); - } + if mode == "pixels" + && !imported + && !clip_read + && let Err(error) = self.import_os_clipboard() + { + return Err(svg_error.unwrap_or(error)); } if let Some(error) = svg_error { if self.session.clipboard.is_none() { From 289dd0a874763d6528bab2184a87a1b53771c02a Mon Sep 17 00:00:00 2001 From: ".CooCoo" Date: Mon, 2 Nov 2026 08:43:46 -0800 Subject: [PATCH 176/216] Format: take the .pcraft envelope from craft-pcraft-format (#10724) * Format: take the .pcraft envelope from craft-pcraft-format The manifest envelope, TileRef, Hash, FORMAT_VERSION, EXTENSION, the entry names, tile_path and blob_path, is_valid_hash, the manifest nesting and item checks and the migration runner now come from craft-libs' craft-pcraft-format. photocraft-format re-exports them, so callers do not change. Manifest is craft_pcraft_format::Manifest. Saved bundles keep the same bytes. A save still checks only the nesting and the item count of its manifest, not its size, and the manifest nesting limit is still computed from photocraft_doc::MAX_GROUP_DEPTH. Tests: a save, load and save again gives the same bytes in every color mode and depth, with and without previews; MAX_GROUP_DEPTH equals craft_pcraft_format::MAX_GROUP_DEPTH. * Format: pin craft-pcraft-format from storytold/craft-libs * Format: read manifests with craft-pcraft-format's read_manifest_with Pins craft-pcraft-format to dca870a0, the head of craft-libs #407. Loading reads the manifest with read_manifest_with, which runs the limit check, the version check, the migration steps and the envelope read in one path; the layers are still decoded one at a time after the rest of the document. A save builds the manifest with Manifest::new and tile references with TileRef::new, and the bundle's directory names and the .zst suffix come from the crate's constants. Saved bytes and error messages are unchanged. * Format: pin craft-pcraft-format to craft-libs #407's head 04dd1bd 04dd1bd changes only the crate's docs and a test, so photocraft-format builds the same code. * Format: pin craft-pcraft-format to craft-libs main at b8f7d93 craft-libs #407 merged as b8f7d93. Its read_manifest also refuses a thumbnail other than thumb.png and a composite other than composite/preview.png, the only names photocraft-format writes. --- Cargo.lock | 11 +++ Cargo.toml | 1 + crates/format/Cargo.toml | 1 + crates/format/src/autosave.rs | 2 +- crates/format/src/convert.rs | 6 +- crates/format/src/lib.rs | 23 ++++- crates/format/src/manifest.rs | 28 +----- crates/format/src/migrate.rs | 44 ++++----- crates/format/src/store.rs | 134 +++++++++------------------- crates/format/tests/it/roundtrip.rs | 23 +++++ 10 files changed, 119 insertions(+), 154 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b22b872d1d..eb50bad6fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1388,6 +1388,16 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "craft-pcraft-format" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=b8f7d93b00dfb8f172938f3d2d796ae62dcfe123#b8f7d93b00dfb8f172938f3d2d796ae62dcfe123" +dependencies = [ + "serde", + "serde_json", + "thiserror 2.0.21", +] + [[package]] name = "craft-print" version = "0.1.0" @@ -5199,6 +5209,7 @@ name = "photocraft-format" version = "0.19.0" dependencies = [ "blake3", + "craft-pcraft-format", "crc32fast", "exacl", "flate2", diff --git a/Cargo.toml b/Cargo.toml index ef1f6d5fdb..4674a55e6f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,7 @@ photocraft-ipp = { path = "crates/ipp" } craft-print = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" } craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" } craft-jpeg = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" } +craft-pcraft-format = { git = "https://github.com/storytold/craft-libs", rev = "b8f7d93b00dfb8f172938f3d2d796ae62dcfe123" } # craft-update: app-specific pretend versions apply to startup and manual checks. craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] } thiserror = "2" diff --git a/crates/format/Cargo.toml b/crates/format/Cargo.toml index 4267746633..a04875e443 100644 --- a/crates/format/Cargo.toml +++ b/crates/format/Cargo.toml @@ -15,6 +15,7 @@ photocraft-color = { workspace = true } photocraft-raster = { workspace = true } photocraft-doc = { workspace = true } photocraft-codecs = { workspace = true } +craft-pcraft-format = { workspace = true } serde = { workspace = true } serde_json = { workspace = true, features = ["unbounded_depth", "float_roundtrip"] } thiserror = { workspace = true } diff --git a/crates/format/src/autosave.rs b/crates/format/src/autosave.rs index 7b379451dd..2ab8676c66 100644 --- a/crates/format/src/autosave.rs +++ b/crates/format/src/autosave.rs @@ -451,7 +451,7 @@ fn sweep_orphans(recovery_dir: &Path, min_age: std::time::Duration) { } // Anything at the sidecar's path (even a broken one) keeps the bundle. let has_sidecar = std::fs::symlink_metadata(recovery_dir.join(format!("{key}.json"))).is_ok(); - let parts = [path.clone(), path.join("tiles"), path.join("blobs")]; + let parts = [path.clone(), path.join(craft_pcraft_format::TILES_DIR), path.join(craft_pcraft_format::BLOBS_DIR)]; // A committed bundle is recoverable work: keep exactly what `list_recovery` lists. if !has_sidecar && sidecarless_entry(recovery_dir, &path).is_none() && parts.iter().all(|p| untouched_for(p, min_age, true)) { let _ = std::fs::remove_dir_all(&path); diff --git a/crates/format/src/convert.rs b/crates/format/src/convert.rs index b5cfda9ad5..fbe121c153 100644 --- a/crates/format/src/convert.rs +++ b/crates/format/src/convert.rs @@ -81,9 +81,7 @@ fn key4(s: &str) -> Result<[u8; 4]> { unhex(s)?.try_into().map_err(|_| FormatError::corrupt(format!("bad 4-byte key `{s}`"))) } -pub(crate) fn is_valid_hash(h: &str) -> bool { - h.len() == 64 && h.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) -} +pub(crate) use craft_pcraft_format::is_valid_hash; /// Convert native-endian sample bytes to little-endian in place (and back: /// the swap is an involution). @@ -110,7 +108,7 @@ fn surface_m(s: &Surface, sink: &mut dyn Sink) -> SurfaceM { let mut dp = vec![0u8; f.bytes_per_pixel()]; encode_pixel(&f, &s.default_pixel(), &mut dp); swap_to_le(&mut dp, f.sample); - SurfaceM { format: f, default: hex(&dp), tiles: s.tiles().map(|(c, t)| TileRef { tx: c.tx, ty: c.ty, hash: sink.tile(f, t) }).collect() } + SurfaceM { format: f, default: hex(&dp), tiles: s.tiles().map(|(c, t)| TileRef::new(c.tx, c.ty, sink.tile(f, t))).collect() } } fn opt_blob(b: &Option>>, sink: &mut dyn Sink) -> Option { diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 992e925ae9..cb1c5eb41d 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -50,7 +50,7 @@ pub use read::read_file; pub use store::{ByteSource, DEFAULT_REVERIFY_BUDGET, PcraftWriter, SaveStats, read_entry}; /// File extension of the native format. -pub const EXTENSION: &str = "pcraft"; +pub use craft_pcraft_format::EXTENSION; #[derive(Debug, thiserror::Error)] pub enum FormatError { @@ -76,6 +76,20 @@ impl FormatError { } } +impl From for FormatError { + fn from(e: craft_pcraft_format::Error) -> Self { + use craft_pcraft_format::Error; + match e { + Error::Corrupt(m) => FormatError::Corrupt(m), + Error::Unsupported(m) => FormatError::Unsupported(m), + Error::TooNew { found, supported, .. } => FormatError::TooNew { found, supported }, + Error::LimitExceeded(m) => FormatError::LimitExceeded(m), + Error::Json(e) => FormatError::Json(e), + other => FormatError::Corrupt(other.to_string()), + } + } +} + pub type Result = std::result::Result; /// Previews to embed. The format crate cannot render (it sits beside the @@ -119,10 +133,11 @@ pub const MAX_CANVAS_PIXELS: u64 = MAX_CANVAS_SIDE as u64 * MAX_CANVAS_SIDE as u impl Default for LoadOptions { fn default() -> Self { + let limits = craft_pcraft_format::Limits::default(); LoadOptions { - max_manifest_bytes: 256 << 20, - max_blob_bytes: 1 << 30, - max_total_bytes: 16 << 30, + max_manifest_bytes: limits.max_manifest_bytes, + max_blob_bytes: limits.max_blob_bytes, + max_total_bytes: limits.max_total_bytes, preserve_ids: true, max_canvas_side: MAX_CANVAS_SIDE, max_canvas_pixels: MAX_CANVAS_PIXELS, diff --git a/crates/format/src/manifest.rs b/crates/format/src/manifest.rs index 1164bc0a68..dd3a012ad2 100644 --- a/crates/format/src/manifest.rs +++ b/crates/format/src/manifest.rs @@ -14,24 +14,11 @@ use photocraft_doc::{ use photocraft_geom::{Affine, Size}; use serde::{Deserialize, Serialize}; -/// Current manifest version written by this build. -pub const FORMAT_VERSION: u32 = 1; +pub use craft_pcraft_format::{FORMAT_VERSION, Hash, TileRef}; -/// Hex-encoded BLAKE3 hash (64 chars). -pub type Hash = String; - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct Manifest { - pub format_version: u32, - /// Free-form writer identification, e.g. `photocraft-format 0.1.0`. - pub generator: String, - pub document: DocM, - /// Optional previews present in the bundle. - #[serde(default)] - pub thumbnail: Option, - #[serde(default)] - pub composite: Option, -} +/// The manifest: craft-pcraft-format's envelope (`format_version`, `generator`, the optional +/// `thumbnail` and `composite` previews) around the document [`DocM`]. +pub type Manifest = craft_pcraft_format::Manifest; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct DocM { @@ -154,13 +141,6 @@ pub struct NamedPathM { pub psd_raw: Option, } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct TileRef { - pub tx: i32, - pub ty: i32, - pub hash: Hash, -} - #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct SurfaceM { pub format: PixelFormat, diff --git a/crates/format/src/migrate.rs b/crates/format/src/migrate.rs index d24581275e..429b5bc281 100644 --- a/crates/format/src/migrate.rs +++ b/crates/format/src/migrate.rs @@ -1,43 +1,29 @@ //! Manifest migrations. Each step upgrades the raw JSON from version `n` to //! `n + 1`, so old bundles keep loading as the schema evolves. -use serde_json::Value; +use crate::FormatError; -use crate::manifest::FORMAT_VERSION; -use crate::{FormatError, Result}; - -pub(crate) type Step = fn(&mut Value) -> Result<()>; +pub(crate) type Step = craft_pcraft_format::Step; /// `STEPS[i]` upgrades version `i + 1` to `i + 2`. Empty while version 1 is -/// the only version. +/// the only version. `craft_pcraft_format::read_manifest_with` runs them on load. pub(crate) const STEPS: &[Step] = &[]; -/// Upgrade `v` in place to `target` using `steps`. -pub(crate) fn migrate_with(v: &mut Value, steps: &[Step], target: u32) -> Result { - let found = v.get("format_version").and_then(Value::as_u64).ok_or_else(|| FormatError::corrupt("manifest has no format_version"))?; - let found = u32::try_from(found).map_err(|_| FormatError::corrupt("format_version out of range"))?; - if found == 0 { - return Err(FormatError::corrupt("format_version 0 is invalid")); - } - if found > target { - return Err(FormatError::TooNew { found, supported: target }); - } - for ver in found..target { - let step = steps.get(ver as usize - 1).ok_or_else(|| FormatError::Unsupported(format!("no migration from format {ver}")))?; - step(v)?; - v["format_version"] = Value::from(ver + 1); - } - Ok(found) -} - -pub(crate) fn migrate(v: &mut Value) -> Result { - migrate_with(v, STEPS, FORMAT_VERSION) -} - #[cfg(test)] mod tests { use super::*; - use serde_json::json; + use crate::Result; + use crate::manifest::FORMAT_VERSION; + use serde_json::{Value, json}; + + /// Upgrade `v` in place to `target` using `steps`, with craft-pcraft-format's runner. + fn migrate_with(v: &mut Value, steps: &[Step], target: u32) -> Result { + craft_pcraft_format::migrate(v, steps, target) + } + + fn migrate(v: &mut Value) -> Result { + migrate_with(v, STEPS, FORMAT_VERSION) + } fn rename_title(v: &mut Value) -> Result<()> { let t = v["document"]["title"].take(); diff --git a/crates/format/src/store.rs b/crates/format/src/store.rs index 67bb8df27b..5fa9b55425 100644 --- a/crates/format/src/store.rs +++ b/crates/format/src/store.rs @@ -15,21 +15,12 @@ use crate::manifest::{ContentM, FORMAT_VERSION, Hash, LayerM, Manifest}; use crate::zip::{ZipReader, ZipWriter}; use crate::{FormatError, LoadOptions, Result, SaveOptions}; -pub(crate) const MANIFEST: &str = "manifest.json"; -pub(crate) const THUMB: &str = "thumb.png"; -pub(crate) const COMPOSITE: &str = "composite/preview.png"; -/// The file a directory save holds an exclusive lock on for its whole transaction (#4957). -pub(crate) const SAVE_LOCK: &str = "save.lock"; +use craft_pcraft_format::{BLOBS_DIR, COMPOSITE_DIR, TILES_DIR, blob_path, tile_path}; +// `SAVE_LOCK` is the file a directory save holds an exclusive lock on for its whole transaction (#4957). +pub(crate) use craft_pcraft_format::{COMPOSITE, MANIFEST, SAVE_LOCK, THUMB}; /// How long a directory save waits for another save of the same bundle to finish. const SAVE_LOCK_WAIT: std::time::Duration = std::time::Duration::from_secs(10); -fn tile_path(h: &str) -> String { - format!("tiles/{h}.zst") -} -fn blob_path(h: &str) -> String { - format!("blobs/{h}.zst") -} - /// The bytes an [`Object`] stores, owned by (or shared with) the compressor's source so one /// [`Compressor`] can be reused for objects of any lifetime. enum Source { @@ -228,64 +219,27 @@ pub fn read_entry(file: &mut impl std::io::Read, len: u64, max: usize, path: &st // Loading // --------------------------------------------------------------------------- -/// Deepest JSON nesting a manifest may have: each group level adds three (the layer object, its -/// content object and the `children` array), with headroom for the document and leaf layer fields. -/// The saver checks the same bound, so every bundle it writes loads again. -const MAX_MANIFEST_DEPTH: usize = 3 * (convert::MAX_GROUP_DEPTH + 1) + 64; - -/// Most JSON items (arrays and objects opened, plus elements and members after a comma) a -/// manifest may hold. Parsing builds a `serde_json::Value` tree of tens of bytes per item, so -/// `[0,0,…]` costs about 16× its size in memory; a typical manifest spends 20 bytes or more per -/// item, so this still admits manifests of about 80 MiB. -const MAX_MANIFEST_ITEMS: usize = 4 << 20; - -/// Rejects JSON nested deeper than [`MAX_MANIFEST_DEPTH`] or holding more than -/// [`MAX_MANIFEST_ITEMS`] items with a linear scan (no recursion), so parsing with serde_json's -/// recursion limit disabled can't exhaust the stack, nor tiny values the memory, on hostile input. -fn check_manifest_depth(json: &[u8]) -> Result<()> { - let (mut depth, mut items, mut in_str, mut escaped) = (0usize, 0usize, false, false); - for &b in json { - if in_str { - match b { - _ if escaped => escaped = false, - b'\\' => escaped = true, - b'"' => in_str = false, - _ => {} - } - continue; - } - match b { - b'"' => in_str = true, - b'[' | b'{' => { - depth += 1; - if depth > MAX_MANIFEST_DEPTH { - return Err(FormatError::LimitExceeded(format!("manifest JSON nested deeper than {MAX_MANIFEST_DEPTH} levels"))); - } - } - b']' | b'}' => depth = depth.saturating_sub(1), - _ => {} - } - if matches!(b, b'[' | b'{' | b',') { - items += 1; - if items > MAX_MANIFEST_ITEMS { - return Err(FormatError::LimitExceeded(format!("manifest JSON holds more than {MAX_MANIFEST_ITEMS} values"))); - } - } - } - Ok(()) +/// The limits a manifest is checked against: craft-pcraft-format's item limit, the JSON nesting +/// of [`convert::MAX_GROUP_DEPTH`] nested groups ([`craft_pcraft_format::manifest_depth`]) and at +/// most `max_manifest_bytes` bytes. The saver checks the same nesting and items, so every bundle +/// it writes loads again. +fn manifest_limits(max_manifest_bytes: usize) -> craft_pcraft_format::Limits { + let mut limits = craft_pcraft_format::Limits::default(); + limits.max_manifest_bytes = max_manifest_bytes; + limits.max_depth = craft_pcraft_format::manifest_depth(convert::MAX_GROUP_DEPTH); + limits } pub(crate) fn read_manifest(src: &dyn ByteSource, opts: &LoadOptions) -> Result { let raw = src.get(MANIFEST, opts.max_manifest_bytes)?; - // serde_json stops at 128 levels (about 40 nested groups); the bounded check replaces that limit. - check_manifest_depth(&raw)?; - let mut de = serde_json::Deserializer::from_slice(&raw); - de.disable_recursion_limit(); - let mut v = ::deserialize(&mut de)?; - de.end()?; - crate::migrate::migrate(&mut v)?; - let layers = take_array(&mut v, "/document/layers"); - let mut m: Manifest = serde_json::from_value(v)?; + // serde_json stops at 128 levels (about 40 nested groups); `read_manifest_with` checks the + // nesting and the items in one pass, then parses without that limit. + let limits = manifest_limits(opts.max_manifest_bytes); + let mut layers = Vec::new(); + let mut m: Manifest = craft_pcraft_format::read_manifest_with(&raw, &limits, crate::migrate::STEPS, FORMAT_VERSION, |mut document| { + layers = take_array(&mut document, "/layers"); + Ok::<_, FormatError>(serde_json::from_value(document)?) + })?; m.document.layers = layers_from_json(layers)?; // A number past f32's range decodes as infinity, which no save could write back (#6862). if let Err(crate::finite::Problem::NonFinite { path }) = crate::finite::check(&m) { @@ -342,11 +296,10 @@ impl LoadFetch<'_> { impl Fetch for LoadFetch<'_> { fn tile(&mut self, hash: &str, len: usize) -> Result> { - if !is_valid_hash(hash) { + let Some(path) = tile_path(hash) else { return Err(FormatError::corrupt(format!("invalid tile hash `{hash}`"))); - } + }; self.account(len)?; - let path = tile_path(hash); let z = self.src.get(&path, len + len / 8 + 4096)?; let data = decompress(&z, len, &path)?; if hash_bytes(&data) != hash { @@ -367,10 +320,9 @@ impl Fetch for LoadFetch<'_> { if let Some(b) = self.blobs.get(hash) { return if b.len() <= limit { Ok(b.clone()) } else { Err(FormatError::LimitExceeded("optional preview blob exceeds its limit".into())) }; } - if !is_valid_hash(hash) { + let Some(path) = blob_path(hash) else { return Err(FormatError::corrupt(format!("invalid blob hash `{hash}`"))); - } - let path = blob_path(hash); + }; // The stored zstd frame of incompressible data is a little larger than the data, so a // blob right at `limit` must still be read; `decompress_bounded` enforces `limit`. let z = self.src.get(&path, limit.saturating_add(limit / 8).saturating_add(4096))?; @@ -686,7 +638,7 @@ fn bundle_root(dir: &Path) -> Result { std::fs::create_dir_all(&dir)?; let root = std::fs::canonicalize(&dir)?; plain_directory_or_missing(&root)?; - for sub in ["tiles", "blobs", "composite"] { + for sub in [TILES_DIR, BLOBS_DIR, COMPOSITE_DIR] { let path = root.join(sub); plain_directory_or_missing(&path)?; if !path.exists() { @@ -706,7 +658,7 @@ fn bundle_root(dir: &Path) -> Result { fn validate_bundle_file(root: &Path, path: &Path) -> Result<()> { let parent = path.parent().ok_or_else(|| unsafe_bundle_entry(path))?; - if parent != root && parent != root.join("tiles") && parent != root.join("blobs") && parent != root.join("composite") { + if parent != root && parent != root.join(TILES_DIR) && parent != root.join(BLOBS_DIR) && parent != root.join(COMPOSITE_DIR) { return Err(unsafe_bundle_entry(path)); } plain_directory_or_missing(parent)?; @@ -863,9 +815,9 @@ impl BundleGc { let flags = gc_directory_flags(); let root = open(root, flags, Mode::empty()).map_err(std::io::Error::from)?; - let tiles = openat(&root, "tiles", flags, Mode::empty()).map_err(std::io::Error::from)?; - let blobs = openat(&root, "blobs", flags, Mode::empty()).map_err(std::io::Error::from)?; - let composite = openat(&root, "composite", flags, Mode::empty()).map_err(std::io::Error::from)?; + let tiles = openat(&root, TILES_DIR, flags, Mode::empty()).map_err(std::io::Error::from)?; + let blobs = openat(&root, BLOBS_DIR, flags, Mode::empty()).map_err(std::io::Error::from)?; + let composite = openat(&root, COMPOSITE_DIR, flags, Mode::empty()).map_err(std::io::Error::from)?; Ok(Self { root, tiles, blobs, composite }) } @@ -875,8 +827,8 @@ impl BundleGc { THUMB => (&self.root, THUMB), COMPOSITE => (&self.composite, "preview.png"), _ => match relative.split_once('/') { - Some(("tiles", name)) if name.strip_suffix(".zst").is_some_and(is_valid_hash) => (&self.tiles, name), - Some(("blobs", name)) if name.strip_suffix(".zst").is_some_and(is_valid_hash) => (&self.blobs, name), + Some((TILES_DIR, name)) if name.strip_suffix(craft_pcraft_format::OBJECT_SUFFIX).is_some_and(is_valid_hash) => (&self.tiles, name), + Some((BLOBS_DIR, name)) if name.strip_suffix(craft_pcraft_format::OBJECT_SUFFIX).is_some_and(is_valid_hash) => (&self.blobs, name), _ => return Err(FormatError::Unsupported(format!("invalid bundle cleanup target: {relative}"))), }, }; @@ -1000,13 +952,9 @@ impl PcraftWriter { convert::check_nesting(&doc.layers)?; let mut c = Collect { hash_cache: Some(&mut self.hash_cache), blob_hash_cache: Some(&mut self.blob_hash_cache), ..Default::default() }; let document = convert::doc_m(doc, &mut c); - let manifest = Manifest { - format_version: FORMAT_VERSION, - generator: format!("photocraft-format {}", env!("CARGO_PKG_VERSION")), - document, - thumbnail: opts.thumbnail.as_ref().map(|_| THUMB.to_owned()), - composite: opts.composite.as_ref().map(|_| COMPOSITE.to_owned()), - }; + let mut manifest = Manifest::new(format!("photocraft-format {}", env!("CARGO_PKG_VERSION")), document); + manifest.thumbnail = opts.thumbnail.as_ref().map(|_| THUMB.to_owned()); + manifest.composite = opts.composite.as_ref().map(|_| COMPOSITE.to_owned()); // serde_json writes NaN and the infinities as `null`, which the manifest's float fields // refuse on load (issue #1101): refuse the save instead, naming the value. crate::finite::check(&manifest).map_err(|e| match e { @@ -1014,14 +962,16 @@ impl PcraftWriter { crate::finite::Problem::Other(m) => FormatError::Unsupported(format!("manifest: {m}")), })?; let manifest = serde_json::to_vec_pretty(&manifest)?; - check_manifest_depth(&manifest)?; + // The nesting and the items only: a save checks no manifest size. + craft_pcraft_format::check_manifest(&manifest, &manifest_limits(usize::MAX))?; let stats = SaveStats { tiles_total: c.tiles.len(), blobs_total: c.blobs.len(), manifest_bytes: manifest.len(), ..Default::default() }; + let invalid = |h: &str| FormatError::corrupt(format!("invalid object hash `{h}`")); let mut objects = BTreeMap::new(); for (h, (t, s)) in c.tiles { - objects.insert(tile_path(&h), Object::Tile(t, s)); + objects.insert(tile_path(&h).ok_or_else(|| invalid(&h))?, Object::Tile(t, s)); } for (h, b) in c.blobs { - objects.insert(blob_path(&h), Object::Blob(b)); + objects.insert(blob_path(&h).ok_or_else(|| invalid(&h))?, Object::Blob(b)); } Ok(Prepared { manifest, objects, stats }) } @@ -1178,12 +1128,12 @@ impl PcraftWriter { // flush. The retry can reuse those valid bytes (including cached ones), so flush // both object directories again before publishing a manifest that references them. // This also persists the fixed subdirectories of a newly created bundle. - for sub in ["tiles", "blobs"] { + for sub in [TILES_DIR, BLOBS_DIR] { fs.sync_dir(&dir.join(sub)).map_err(|e| crate::atomic::publication_error(e, &dir.join(sub)))?; } } #[cfg(not(windows))] - for sub in ["tiles", "blobs"] { + for sub in [TILES_DIR, BLOBS_DIR] { let folder = dir.join(sub); if folder.is_dir() { confirm_flushed(&folder, &folder)?; @@ -1560,7 +1510,7 @@ fn an_incompressible_blob_at_its_limit_still_loads() { let z = Compressor::new().object(&Object::Blob(Arc::new(data.clone()))); assert!(z.len() > data.len(), "the fixture must not compress"); let hash = hash_bytes(&data); - let src = One(blob_path(&hash), z); + let src = One(blob_path(&hash).unwrap(), z); let mut fetch = LoadFetch { src: &src, opts: crate::LoadOptions::default(), total: 0, blobs: HashMap::new() }; assert_eq!(fetch.blob_bounded(&hash, data.len()).unwrap().as_slice(), data.as_slice()); assert!(LoadFetch { src: &src, opts: crate::LoadOptions::default(), total: 0, blobs: HashMap::new() }.blob_bounded(&hash, data.len() - 1).is_err()); diff --git a/crates/format/tests/it/roundtrip.rs b/crates/format/tests/it/roundtrip.rs index 25af8b22a6..be016f0aaf 100644 --- a/crates/format/tests/it/roundtrip.rs +++ b/crates/format/tests/it/roundtrip.rs @@ -216,6 +216,13 @@ fn nesting_beyond_the_load_limit_is_refused_at_save() { assert!(matches!(e, FormatError::LimitExceeded(_)), "{e}"); } +/// craft-pcraft-format's default manifest nesting limit is computed from its own copy of the +/// group depth, so the two must stay equal. +#[test] +fn group_depth_matches_craft_pcraft_format() { + assert_eq!(MAX_GROUP_DEPTH, craft_pcraft_format::MAX_GROUP_DEPTH); +} + #[test] fn empty_document_roundtrips() { let doc = Document::new("empty", photocraft_doc::Size::new(1, 1), ColorMode::Rgb, SampleType::U8); @@ -524,6 +531,22 @@ fn save_is_deterministic() { assert_eq!(a, b); } +/// A bundle that is read and saved again gives the same bytes, previews included. +#[test] +fn save_load_save_gives_the_same_bytes() { + let mut preview = Rgba8Image::new(4, 3); + preview.pixels.iter_mut().enumerate().for_each(|(i, p)| *p = i as u8); + for opts in [SaveOptions::default(), SaveOptions { thumbnail: Some(preview.clone()), composite: Some(preview.clone()) }] { + for mode in MODES { + for depth in SampleType::ALL { + let first = save_to_bytes(&rich_doc(mode, depth), &opts).unwrap(); + let again = save_to_bytes(&load_from_bytes(&first).unwrap(), &opts).unwrap(); + assert!(first == again, "{mode:?} {depth:?}: the second save differs"); + } + } + } +} + #[test] fn not_pcraft() { assert!(!is_pcraft(b"")); From 43fab54950b3c1c5f8c4f3973d2438dbcca1fa06 Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 2 Nov 2026 17:56:07 +0100 Subject: [PATCH 177/216] Integration: the SVG clipboard service doc says ordinary paste reads it too; the bulk-load test checks the sets that stay loaded (#10704, #10601) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/actions/tests.rs | 4 ++++ crates/ui-egui/src/services.rs | 3 ++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/actions/tests.rs b/crates/ui-egui/src/panels/actions/tests.rs index 441aa9260b..e35999dedc 100644 --- a/crates/ui-egui/src/panels/actions/tests.rs +++ b/crates/ui-egui/src/panels/actions/tests.rs @@ -1968,4 +1968,8 @@ fn bulk_load_summary_counts_unsupported_steps_of_surviving_sets() { let status = bulk_load_status(&mut app, files); assert!(status.contains("1 step has no PhotoCraft equivalent yet"), "{status}"); assert!(!app.ui.status_error, "{status}"); + // Both sets stay loaded: the unsupported step is counted for one that is in the panel. + let names: Vec<&str> = app.session.actions.list.iter().map(|a| a.name.as_str()).collect(); + assert_eq!(names, ["A", "B"]); + assert!(app.session.actions.list[0].steps.iter().any(|(id, _)| id == "actions.photoshopStep")); } diff --git a/crates/ui-egui/src/services.rs b/crates/ui-egui/src/services.rs index 11fc27119b..d770d4a601 100644 --- a/crates/ui-egui/src/services.rs +++ b/crates/ui-egui/src/services.rs @@ -436,7 +436,8 @@ pub struct Services { /// OS clipboard images: copies go out, screenshots and images from other apps come in. pub clipboard_set_image: Option, pub clipboard_get_image: Option, - /// SVG text copied by another app, read only for an explicit pixels/path/shape paste. + /// SVG text copied by another app: imported only by an explicit pixels/path/shape paste, + /// and read after an ordinary paste too, only to remember which SVG came with that image. pub clipboard_get_svg: Option, /// True when the last `clipboard_get_image` read had no clipboard to read, as in a Flatpak on /// Wayland (#5031), rather than a clipboard that holds no image. `None` where that can't happen. From 9034223c4b6d80fe15a12b9e0d173786ac5f7a4c Mon Sep 17 00:00:00 2001 From: Yuki-init Date: Mon, 2 Nov 2026 18:31:22 +0000 Subject: [PATCH 178/216] TIFF: open YCbCr images as RGB (#10316) (#10319) * TIFF: open YCbCr images as RGB (#10316) Scanners often write JPEG-compressed YCbCr TIFFs with 2x2 chroma subsampling. The tiff crate decodes and upsamples them but returns raw YCbCr samples, which the decoder rejected with "colour type YCbCr(8)". The new tiff/ycbcr.rs converts them to RGB after decoding: JPEG data uses the JFIF full-range BT.601 conversion (as libtiff does), other compressions honour ReferenceBlackWhite and YCbCrCoefficients with the TIFF defaults as fallback. 8 and 16-bit. YCbCr with extra samples, or in other sample formats, fails with a clear unsupported error. Co-Authored-By: Claude Opus 5.5 * Contributors: add Yuki-init's display name Co-Authored-By: Claude Opus 5.5 * TIFF YCbCr: test that YCbCrCoefficients set the luma weights Co-Authored-By: Claude Opus 5.5 * TIFF: YCbCr tags with the wrong value count use the defaults tag_f64s now returns None unless the tag holds exactly n values, so a YCbCrCoefficients or ReferenceBlackWhite tag with extra values falls back to the TIFF defaults instead of being truncated. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Lrgj5pnShYtsSnrsZq5iRn * TIFF YCbCr: tests for tiled JPEG with ICC, big-endian 16-bit and planar Covers the layouts the review asked for: a tiled 4:2:0 JPEG file with an embedded profile (checked against an independent JPEG decoder), a big-endian 16-bit file, and planar data in both byte orders. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Lrgj5pnShYtsSnrsZq5iRn * TIFF YCbCr: cite the formula sources and pin the green-channel choice Documents where the conversion comes from: TIFF 6.0 Section 21 (YCbCrCoefficients, pp. 90-91) and libtiff's tif_color.c (TIFFYCbCrToRGBInit, TIFFYCbCrtoRGB). Red and blue follow the spec; green follows libtiff and adds Y unscaled, which matches the spec only when the luma weights sum to 1. The module doc records this as a deliberate compatibility choice, and a new test pins it: with weights 0.25/0.25/0.25, mid gray stays (128, 128, 128). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Lrgj5pnShYtsSnrsZq5iRn * Codecs README: YCbCr TIFFs with three 8- or 16-bit samples decode to RGB Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Lrgj5pnShYtsSnrsZq5iRn * Format tiff_ycbcr tests with rustfmt Co-Authored-By: Claude Opus 5.5 * test(tiff): compare grouped YCbCr decoding and update support notes * style(tiff): align grouped decode call with test formatting * style(tiff): apply repository rustfmt to grouped YCbCr regression --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com> Co-authored-by: FredApps --- contributors/people.toml | 4 + crates/codecs/README.md | 6 +- crates/codecs/src/codecs/tiff.rs | 1 + crates/codecs/src/codecs/tiff/decode.rs | 38 +++- crates/codecs/src/codecs/tiff/ycbcr.rs | 222 ++++++++++++++++++++ crates/codecs/src/codecs/tiff_ifd.rs | 25 ++- crates/codecs/tests/it/main.rs | 1 + crates/codecs/tests/it/tiff_ycbcr.rs | 259 ++++++++++++++++++++++++ docs/file-format-parity.md | 5 +- 9 files changed, 547 insertions(+), 14 deletions(-) create mode 100644 crates/codecs/src/codecs/tiff/ycbcr.rs create mode 100644 crates/codecs/tests/it/tiff_ycbcr.rs diff --git a/contributors/people.toml b/contributors/people.toml index e1c91b3049..f8dece1fdc 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -291,6 +291,10 @@ sync_github_name = true [people.dylanmtaylor] sync_github_name = true +[people.Yuki-init] +display_name = "DC@yuki.himejima" +sync_github_name = false + [people.sn-o-w] real_name = "Cristian Silaghi" sync_github_name = false diff --git a/crates/codecs/README.md b/crates/codecs/README.md index 43bf4ea361..ffd7b655b1 100644 --- a/crates/codecs/README.md +++ b/crates/codecs/README.md @@ -254,8 +254,10 @@ embedded table (and a PNG's transparent entry) so `photocraft-io` can open them ms in release). Image data cut off by the end of the file decodes with `DecodeWarning::Truncated`; a file far too small for its declared size (more than any compression could expand) is refused before allocating. - * YCbCr, CIE Lab, signed integers and compressions that the `tiff` crate lacks (CCITT, - JPEG-in-TIFF) return `Unsupported`. + * YCbCr with three 8- or 16-bit samples decodes to RGB. YCbCr with other sample counts or + bit depths returns `Unsupported`. JPEG-compressed YCbCr uses the full-range JFIF conversion. + * 8/16-bit CIELab and ICCLab decode to the Lab channel layout. + * Signed integer samples and unsupported compression methods return `Unsupported`. * **TIFF encode** writes classic TIFF, or BigTIFF when `EncodeOptions::tiff_bigtiff` is set or the file could pass 4 GiB. `Metadata::extra_samples` (up to 16) are written after the colour and alpha samples, with their `kind` in the ExtraSamples tag (a reserved `kind` above 2 is diff --git a/crates/codecs/src/codecs/tiff.rs b/crates/codecs/src/codecs/tiff.rs index a04460ea1a..8b2d5d4e8f 100644 --- a/crates/codecs/src/codecs/tiff.rs +++ b/crates/codecs/src/codecs/tiff.rs @@ -168,6 +168,7 @@ mod bands; mod decode; mod encode; mod tags; +mod ycbcr; pub use decode::TiffBands; pub(crate) use decode::{decode, decode_page, orientation}; diff --git a/crates/codecs/src/codecs/tiff/decode.rs b/crates/codecs/src/codecs/tiff/decode.rs index f698f04039..9747b7c2f0 100644 --- a/crates/codecs/src/codecs/tiff/decode.rs +++ b/crates/codecs/src/codecs/tiff/decode.rs @@ -2,6 +2,7 @@ use std::borrow::Cow; use super::bands::{Tally, convert_ab, decode_bands, extra_sample_indices, refill_zeroed, split_extra_samples, zeroed}; use super::tags::{F, TAG_IMAGE_SOURCE_DATA, TAG_PHOTOSHOP, TAG_XMP, TEXT_TAGS, photoshop_tags}; +use super::ycbcr::YCbCr; use super::{ ChannelLayout, ChunkType, CodecError, DecodeWarning, Decoder, ExifDirs, File, Ifd, Image, Limits, Metadata, Read, SampleType, Seek, SeekFrom, Tag, camera_raw, err, ifd, io, map_err, tiff_exif, @@ -185,6 +186,9 @@ impl<'a> TiffBands<'a> { // Missing chunks leave their rows as they are: they must start empty in every group. buf = refill_zeroed(buf, len)?; tally = tally + decode_bands(p, self.b, &mut buf, band, first, &mut [], 0)?; + if let Some(c) = p.ycbcr { + c.convert(&mut buf, p.lay.out_channels(), p.lay.out); + } if let Some(signed) = p.lab { convert_ab(&mut buf, p.lay.out_channels(), p.lay.out, signed, false); } @@ -520,6 +524,20 @@ fn header_patches( Ok(patches) } +/// Lab and YCbCr are converted after decoding, from 8 or 16-bit samples only: Lab needs at least +/// its three colour samples, YCbCr exactly three. +fn check_converted_model(photometric: u64, spp: u64, bits: u64, src: Src) -> Result<(), CodecError> { + let (name, samples_ok) = match photometric { + 8 | 9 => ("Lab", spp >= 3), + 6 => ("YCbCr", spp == 3), + _ => return Ok(()), + }; + if samples_ok && matches!(src, Src::U8 | Src::U16) { + return Ok(()); + } + Err(CodecError::unsupported(F, format!("{name} image with {spp} samples of {bits} bits"))) +} + /// A directory checked and laid out for decoding, before any pixel is read. pub(super) struct Prepared { w: u32, @@ -531,6 +549,8 @@ pub(super) struct Prepared { tl: tiff::decoder::Limits, /// Lab samples: `Some(true)` for CIELab (a* and b* stored signed), `Some(false)` for ICCLab. lab: Option, + /// YCbCr samples: the conversion to RGB (#10316). + ycbcr: Option, /// The colour samples of a pixel in the file's colour model. color: usize, /// The ExtraSamples tag (0 unspecified, 1 associated alpha, 2 unassociated alpha). @@ -567,6 +587,9 @@ fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result 0 { return Err(err("the image data is missing (cut off or past the end of the file)")); } + if let Some(c) = p.ycbcr { + c.convert(&mut data, p.lay.out_channels(), p.lay.out); + } if let Some(signed) = p.lab { convert_ab(&mut data, p.lay.out_channels(), p.lay.out, signed, false); } @@ -642,15 +665,17 @@ fn prepare(f: &File<'_>, dir: &Ifd, limits: &Limits, whole: bool) -> Result 3, + 2 | 6 | 8 | 9 => 3, 5 => 4, _ => 1, }; @@ -686,6 +711,7 @@ fn prepare(f: &File<'_>, dir: &Ifd, limits: &Limits, whole: bool) -> Result (ChannelLayout::Rgb, 3), + tiff::ColorType::YCbCr(_) if ycbcr => (ChannelLayout::Rgb, 3), tiff::ColorType::RGBA(_) => (ChannelLayout::Rgba, 4), tiff::ColorType::CMYK(_) => (ChannelLayout::Cmyk, 4), tiff::ColorType::CMYKA(_) => (ChannelLayout::CmykA, 5), @@ -729,8 +755,9 @@ fn prepare(f: &File<'_>, dir: &Ifd, limits: &Limits, whole: bool) -> Result 4, 32773 => 130, 4 => cw.clamp(4096, 65_535), @@ -814,6 +841,7 @@ fn prepare(f: &File<'_>, dir: &Ifd, limits: &Limits, whole: bool) -> Result +//! - libtiff, `libtiff/tif_color.c`: `TIFFYCbCrToRGBInit` (tables) and `TIFFYCbCrtoRGB` (per pixel). +//! +//! +//! # Green: a compatibility choice +//! +//! Red and blue follow the spec. Green follows libtiff, which rewrites the spec's equation as +//! `G = Y - (LumaRed·(2 - 2·LumaRed) / LumaGreen)·Cr - (LumaBlue·(2 - 2·LumaBlue) / LumaGreen)·Cb`, +//! adding Y unscaled. The two agree only when the weights sum to 1. Every standard set does +//! (the spec's own examples, BT.601 and BT.709), but the spec only calls the weights "the +//! proportions of red, green, and blue respectively in luminance" and does not require the sum. +//! For other weights the spec's equation scales Y in green by `(1 - LumaRed - LumaBlue) / LumaGreen`, +//! which tints neutral grays. +//! +//! PhotoCraft deliberately follows libtiff here (decided in PR #10319, for issue #10316), so such +//! files open as they do in libtiff-based tools (ImageMagick, GIMP) and grays stay neutral. This is +//! a contributor choice, not a requirement of the spec; the test +//! `weights_that_do_not_sum_to_one_keep_grays_neutral` in `tests/it/tiff_ycbcr.rs` pins it. + +use super::{File, Ifd, SampleType}; + +const TAG_YCBCR_COEFFICIENTS: u16 = 529; +const TAG_REFERENCE_BLACK_WHITE: u16 = 532; + +/// BT.601 luma weights: the TIFF default for YCbCrCoefficients. +const BT601: [f64; 3] = [0.299, 0.587, 0.114]; + +/// How one file's YCbCr codes map to RGB. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(super) struct YCbCr { + /// Luma weights of red, green and blue. + luma: [f64; 3], + /// Black and white codes of Y, Cb and Cr. + ref_bw: [f64; 6], +} + +impl YCbCr { + /// The conversion for directory `dir`, whose samples have `max` as their largest code + /// (255 or 65535). Missing or unusable tags fall back to the TIFF defaults. + pub(super) fn of(f: &File<'_>, dir: &Ifd, compression: u64, max: f64) -> Self { + let defaults = Self::defaults(max); + if compression == 7 { + return defaults; + } + let luma = f + .tag_f64s(dir, TAG_YCBCR_COEFFICIENTS, 3) + .and_then(|v| <[f64; 3]>::try_from(v).ok()) + .filter(|l| l.iter().all(|c| c.is_finite() && *c > 0.0 && *c < 1.0)) + .unwrap_or(defaults.luma); + let ref_bw = f + .tag_f64s(dir, TAG_REFERENCE_BLACK_WHITE, 6) + .and_then(|v| <[f64; 6]>::try_from(v).ok()) + .filter(|r| r.iter().all(|v| v.is_finite()) && r.as_chunks::<2>().0.iter().all(|[black, white]| (white - black).abs() > f64::EPSILON)) + .unwrap_or(defaults.ref_bw); + YCbCr { luma, ref_bw } + } + + /// TIFF's defaults: BT.601 weights, full-range luma, chroma centred on the middle code. + fn defaults(max: f64) -> Self { + let mid = (max + 1.0) / 2.0; + YCbCr { luma: BT601, ref_bw: [0.0, max, mid, max, mid, max] } + } + + /// Converts interleaved Y, Cb, Cr pixels of `channels` samples (the first three are the + /// colour) to R, G, B in place. Other sample types are left as they are. + pub(super) fn convert(self, data: &mut [u8], channels: usize, sample: SampleType) { + match sample { + SampleType::U8 => { + let t = Tables::new(&self, 255); + for_each_pixel(data, channels, |px| { + if let [y, cb, cr, ..] = px { + let [r, g, b] = t.rgb(usize::from(*y), usize::from(*cb), usize::from(*cr)); + (*y, *cb, *cr) = (r as u8, g as u8, b as u8); + } + }); + } + SampleType::U16 => { + let t = Tables::new(&self, 65535); + for_each_pixel(data, channels * 2, |px| { + if let [y0, y1, b0, b1, r0, r1, ..] = px { + let get = |a: u8, b: u8| usize::from(u16::from_ne_bytes([a, b])); + let [r, g, b] = t.rgb(get(*y0, *y1), get(*b0, *b1), get(*r0, *r1)); + [*y0, *y1] = (r as u16).to_ne_bytes(); + [*b0, *b1] = (g as u16).to_ne_bytes(); + [*r0, *r1] = (b as u16).to_ne_bytes(); + } + }); + } + _ => {} + } + } +} + +/// Runs `f` on every `stride`-byte pixel, in parallel on native targets. +fn for_each_pixel(data: &mut [u8], stride: usize, f: impl Fn(&mut [u8]) + Sync + Send) { + if stride == 0 { + return; + } + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + // Bands of whole pixels, so each task has a useful amount of work. + let band = stride.saturating_mul(1 << 14); + data.par_chunks_mut(band).for_each(|b| b.chunks_exact_mut(stride).for_each(&f)); + } + #[cfg(target_arch = "wasm32")] + data.chunks_exact_mut(stride).for_each(f); +} + +/// Per-code contributions, so a pixel costs a few additions. +struct Tables { + max: f64, + /// Scaled luma of each Y code. + y: Vec, + /// Red and green contributions of each Cr code. + cr_r: Vec, + cr_g: Vec, + /// Green and blue contributions of each Cb code. + cb_g: Vec, + cb_b: Vec, +} + +impl Tables { + fn new(c: &YCbCr, max: u32) -> Self { + let m = f64::from(max); + let [lr, lg, lb] = c.luma; + let [y_black, y_white, cb_black, cb_white, cr_black, cr_white] = c.ref_bw; + // TIFF 6.0: chroma codes span ±(max + 1) / 2 - 1 around the black code; with the + // default ReferenceBlackWhite that is the code minus the middle code. + let half = (m + 1.0) / 2.0 - 1.0; + let codes = || (0..=max).map(f64::from); + let y = codes().map(|v| (v - y_black) * m / (y_white - y_black)).collect(); + let cb: Vec = codes().map(|v| (v - cb_black) * half / (cb_white - cb_black)).collect(); + let cr: Vec = codes().map(|v| (v - cr_black) * half / (cr_white - cr_black)).collect(); + // R = Y + (2 - 2·Lr)·Cr and B = Y + (2 - 2·Lb)·Cb (TIFF 6.0 p. 91); green as libtiff: + // G = Y - (Lr·(2 - 2·Lr) / Lg)·Cr - (Lb·(2 - 2·Lb) / Lg)·Cb (see the module notes). + let (kr, kb) = (2.0 - 2.0 * lr, 2.0 - 2.0 * lb); + Tables { + max: m, + y, + cr_r: cr.iter().map(|v| kr * v).collect(), + cr_g: cr.iter().map(|v| -lr * kr * v / lg).collect(), + cb_g: cb.iter().map(|v| -lb * kb * v / lg).collect(), + cb_b: cb.iter().map(|v| kb * v).collect(), + } + } + + /// R, G and B of one pixel, rounded and clamped to the code range. + fn rgb(&self, y: usize, cb: usize, cr: usize) -> [u32; 3] { + let at = |t: &[f64], i: usize| t.get(i).copied().unwrap_or(0.0); + let yv = at(&self.y, y); + // Green adds Y unscaled, as libtiff does (see "Green: a compatibility choice" above). + let r = yv + at(&self.cr_r, cr); + let g = yv + at(&self.cr_g, cr) + at(&self.cb_g, cb); + let b = yv + at(&self.cb_b, cb); + [r, g, b].map(|v| v.round().clamp(0.0, self.max) as u32) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn convert(c: &YCbCr, px: [u8; 3]) -> [u8; 3] { + let mut d = px.to_vec(); + c.convert(&mut d, 3, SampleType::U8); + [d[0], d[1], d[2]] + } + + #[test] + fn jfif_conversion_matches_known_colours() { + let c = YCbCr::defaults(255.0); + assert_eq!(convert(&c, [0, 128, 128]), [0, 0, 0]); + assert_eq!(convert(&c, [255, 128, 128]), [255, 255, 255]); + assert_eq!(convert(&c, [128, 128, 128]), [128, 128, 128]); + // Pure red, green and blue as JFIF encodes them. + assert_eq!(convert(&c, [76, 85, 255]), [254, 0, 0]); + assert_eq!(convert(&c, [150, 44, 21]), [0, 255, 1]); + assert_eq!(convert(&c, [29, 255, 107]), [0, 0, 254]); + } + + #[test] + fn sixteen_bit_and_extra_samples() { + let c = YCbCr::defaults(65535.0); + let mut d: Vec = [65535u16, 32768, 32768, 1234].iter().flat_map(|v| v.to_ne_bytes()).collect(); + c.convert(&mut d, 4, SampleType::U16); + let v: Vec = d.as_chunks::<2>().0.iter().map(|b| u16::from_ne_bytes(*b)).collect(); + assert_eq!(v, [65535, 65535, 65535, 1234]); + } + + #[test] + fn studio_range_reference_black_white() { + // Y 16–235, chroma 128 ± 112 (ITU-R BT.601 studio range). + let c = YCbCr { luma: BT601, ref_bw: [16.0, 235.0, 128.0, 240.0, 128.0, 240.0] }; + assert_eq!(convert(&c, [16, 128, 128]), [0, 0, 0]); + assert_eq!(convert(&c, [235, 128, 128]), [255, 255, 255]); + } + + #[test] + fn odd_buffers_do_not_panic() { + let c = YCbCr::defaults(255.0); + let mut d = vec![1u8, 2]; + c.convert(&mut d, 3, SampleType::U8); + c.convert(&mut d, 0, SampleType::U8); + c.convert(&mut d, 0, SampleType::U16); + assert_eq!(d, [1, 2]); + } +} diff --git a/crates/codecs/src/codecs/tiff_ifd.rs b/crates/codecs/src/codecs/tiff_ifd.rs index 63b39258cf..cdaa890ec4 100644 --- a/crates/codecs/src/codecs/tiff_ifd.rs +++ b/crates/codecs/src/codecs/tiff_ifd.rs @@ -236,18 +236,33 @@ impl<'a> File<'a> { /// The first value of a numeric tag as a float: RATIONAL, FLOAT, DOUBLE or an integer. pub fn tag_f64(&self, ifd: &Ifd, tag: u16) -> Option { + self.float(ifd.get(tag)?, 0) + } + + /// The values of a numeric tag as floats, when it holds exactly `n` (`None` for any other + /// count or an unreadable value). + pub fn tag_f64s(&self, ifd: &Ifd, tag: u16, n: u64) -> Option> { let e = ifd.get(tag)?; - if e.count == 0 { + if e.count != n { return None; } + (0..n).map(|i| self.float(e, i)).collect() + } + + /// Value `i` of a numeric entry as a float: RATIONAL, FLOAT, DOUBLE or an integer. + fn float(&self, e: &Entry, i: u64) -> Option { + if i >= e.count { + return None; + } + let at = e.value_at.checked_add(i.checked_mul(type_size(e.ty)?)?)?; match e.ty { 5 => { - let (n, d) = (self.u32_at(e.value_at)?, self.u32_at(e.value_at.checked_add(4)?)?); + let (n, d) = (self.u32_at(at)?, self.u32_at(at.checked_add(4)?)?); (d != 0).then(|| f64::from(n) / f64::from(d)) } - 11 => Some(f64::from(f32::from_bits(self.u32_at(e.value_at)?))), - 12 => Some(f64::from_bits(self.u64_at(e.value_at)?)), - _ => self.uint(e, 0).map(|v| v as f64), + 11 => Some(f64::from(f32::from_bits(self.u32_at(at)?))), + 12 => Some(f64::from_bits(self.u64_at(at)?)), + _ => self.uint(e, i).map(|v| v as f64), } } diff --git a/crates/codecs/tests/it/main.rs b/crates/codecs/tests/it/main.rs index a2ed94616a..4293fb4a48 100644 --- a/crates/codecs/tests/it/main.rs +++ b/crates/codecs/tests/it/main.rs @@ -31,6 +31,7 @@ mod tiff_lab; mod tiff_pages; mod tiff_photoshop; mod tiff_regressions; +mod tiff_ycbcr; mod webp_frames; mod webp_lossy; mod xpm; diff --git a/crates/codecs/tests/it/tiff_ycbcr.rs b/crates/codecs/tests/it/tiff_ycbcr.rs new file mode 100644 index 0000000000..94ff9f87e2 --- /dev/null +++ b/crates/codecs/tests/it/tiff_ycbcr.rs @@ -0,0 +1,259 @@ +//! YCbCr TIFFs (PhotometricInterpretation 6), as scanners write them: JPEG-compressed with 2×2 +//! chroma subsampling, and uncompressed with ReferenceBlackWhite and YCbCrCoefficients (#10316). +//! Every file here is hand-built by `common::tiffgen`. + +use crate::common::sample_icc; +use crate::common::tiffgen::*; +use photocraft_codecs::*; + +const COMPRESSION: u16 = 259; +const SUBSAMPLING: u16 = 530; +const YCBCR_COEFFICIENTS: u16 = 529; +const REFERENCE_BLACK_WHITE: u16 = 532; +const ICC_PROFILE: u16 = 34675; + +/// A directory whose Compression is `c`. +fn compressed(mut d: Dir, c: u64) -> Dir { + d.entries.retain(|e| e.0 != COMPRESSION); + d.tag(COMPRESSION, SHORT, &[c]) +} + +/// A little-endian RATIONAL value for `tiffgen`. +fn rational(num: u32, den: u32) -> u64 { + u64::from(num) | (u64::from(den) << 32) +} + +/// The image's pixels as RGB bytes. +fn rgb(img: &Image) -> Vec { + img.to_rgba8().as_chunks::<4>().0.iter().flat_map(|[r, g, b, _]| [*r, *g, *b]).collect() +} + +/// A smooth colour test card, `w`×`h` RGB. +fn card(w: u32, h: u32) -> Vec { + let mut v = Vec::new(); + for y in 0..h { + for x in 0..w { + v.extend([(x * 255 / (w - 1)) as u8, (y * 255 / (h - 1)) as u8, (255 - x * 255 / (w - 1)) as u8]); + } + } + v +} + +/// A 4:2:0 baseline JPEG stream of `rgb`. +fn jpeg(rgb: &[u8], w: u32, h: u32) -> Vec { + let img = Image::from_raw(w, h, ChannelLayout::Rgb, SampleType::U8, rgb.to_vec()).unwrap(); + encode(&img, Format::Jpeg, &EncodeOptions { jpeg_chroma_subsampling: true, ..Default::default() }).unwrap() +} + +#[test] +fn jpeg_compressed_ycbcr_with_subsampled_chroma_reads_as_rgb() { + let (w, h) = (32, 24); + let stream = jpeg(&card(w, h), w, h); + // The stream really is subsampled: the luma component samples 2×2 in the frame header. + let sof = stream.windows(2).position(|m| m == [0xFF, 0xC0]).expect("baseline frame header"); + assert_eq!(stream[sof + 11], 0x22, "luma sampling factors"); + let dir = compressed(strips(w, h, 8, 3, 6, h, &stream), 7).tag(SUBSAMPLING, SHORT, &[2, 2]); + let file = build(true, false, &[dir], &[0]).bytes; + + let img = decode(&file).unwrap(); + assert_eq!((img.width(), img.height(), img.layout(), img.sample_type()), (w, h, ChannelLayout::Rgb, SampleType::U8)); + // The oracle: the same JPEG stream decoded to RGB by an independent decoder. + let want = image::load_from_memory_with_format(&stream, image::ImageFormat::Jpeg).unwrap().to_rgb8().into_raw(); + let worst = rgb(&img).iter().zip(&want).map(|(g, o)| g.abs_diff(*o)).max().unwrap(); + assert!(worst <= 2, "largest difference from the oracle: {worst}"); +} + +#[test] +fn uncompressed_ycbcr_uses_the_default_conversion() { + // Black, white, mid gray and JFIF's pure red, without subsampling. + let px = [0, 128, 128, 255, 128, 128, 128, 128, 128, 76, 85, 255]; + let dir = strips(4, 1, 8, 3, 6, 1, &px).tag(SUBSAMPLING, SHORT, &[1, 1]); + let img = decode(&build(true, false, &[dir], &[0]).bytes).unwrap(); + assert_eq!(img.layout(), ChannelLayout::Rgb); + assert_eq!(rgb(&img), [0, 0, 0, 255, 255, 255, 128, 128, 128, 254, 0, 0]); +} + +#[test] +fn reference_black_white_sets_the_code_range() { + // Studio range: Y 16–235, chroma 128 ± 112. + let rbw = [rational(16, 1), rational(235, 1), rational(128, 1), rational(240, 1), rational(128, 1), rational(240, 1)]; + let dir = strips(2, 1, 8, 3, 6, 1, &[16, 128, 128, 235, 128, 128]).tag(SUBSAMPLING, SHORT, &[1, 1]).tag(REFERENCE_BLACK_WHITE, RATIONAL, &rbw); + let img = decode(&build(true, false, &[dir], &[0]).bytes).unwrap(); + assert_eq!(rgb(&img), [0, 0, 0, 255, 255, 255]); +} + +#[test] +fn weights_that_do_not_sum_to_one_keep_grays_neutral() { + // 0.25 each (sum 0.75): TIFF 6.0's full inverse would scale green's Y by (1 − Lr − Lb) / Lg + // = 2 and give mid gray G 255 (clamped). Like libtiff, green keeps Y, so gray stays gray, and + // the weights still set the chroma terms: R = Y + 1.5·Cr, B = Y + 1.5·Cb. + let weights = [rational(1, 4), rational(1, 4), rational(1, 4)]; + let dir = strips(2, 1, 8, 3, 6, 1, &[128, 128, 128, 128, 64, 192]).tag(SUBSAMPLING, SHORT, &[1, 1]).tag(YCBCR_COEFFICIENTS, RATIONAL, &weights); + let img = decode(&build(true, false, &[dir], &[0]).bytes).unwrap(); + // Green for the second pixel: 128 − (Lb·1.5/Lg)·Cb − (Lr·1.5/Lg)·Cr = 128 + 96 − 96. + assert_eq!(rgb(&img), [128, 128, 128, 224, 128, 32]); +} + +#[test] +fn ycbcr_coefficients_set_the_luma_weights() { + // Weights 0.25, 0.5, 0.25 with Cb −64 and Cr +64: R = Y + 1.5·Cr, B = Y + 1.5·Cb, and G keeps Y + // (BT.601 would give R 218 and B 15). + let weights = [rational(1, 4), rational(1, 2), rational(1, 4)]; + let dir = strips(1, 1, 8, 3, 6, 1, &[128, 64, 192]).tag(SUBSAMPLING, SHORT, &[1, 1]).tag(YCBCR_COEFFICIENTS, RATIONAL, &weights); + let img = decode(&build(true, false, &[dir], &[0]).bytes).unwrap(); + assert_eq!(rgb(&img), [224, 128, 32]); +} + +#[test] +fn ycbcr_tags_with_the_wrong_count_use_the_defaults() { + // Four weights and seven black/white codes: both tags are malformed, so BT.601 and full range + // apply (the weights alone would give 224, 128, 32). + let weights = [rational(1, 4), rational(1, 2), rational(1, 4), rational(1, 4)]; + let rbw = [rational(16, 1), rational(235, 1), rational(128, 1), rational(240, 1), rational(128, 1), rational(240, 1), rational(0, 1)]; + let dir = strips(1, 1, 8, 3, 6, 1, &[128, 64, 192]).tag(SUBSAMPLING, SHORT, &[1, 1]).tag(YCBCR_COEFFICIENTS, RATIONAL, &weights).tag( + REFERENCE_BLACK_WHITE, + RATIONAL, + &rbw, + ); + let img = decode(&build(true, false, &[dir], &[0]).bytes).unwrap(); + assert_eq!(rgb(&img), [218, 104, 15]); +} + +#[test] +fn sixteen_bit_ycbcr_reads_as_rgb() { + let px: Vec = [65535u16, 32768, 32768, 0, 32768, 32768].iter().flat_map(|v| v.to_le_bytes()).collect(); + let dir = strips(2, 1, 16, 3, 6, 1, &px).tag(SUBSAMPLING, SHORT, &[1, 1]); + let img = decode(&build(true, false, &[dir], &[0]).bytes).unwrap(); + assert_eq!((img.layout(), img.sample_type()), (ChannelLayout::Rgb, SampleType::U16)); + let n = img.to_normalized(); + assert!(n[..3].iter().all(|&v| v == 1.0) && n[3..].iter().all(|&v| v == 0.0), "{n:?}"); +} + +#[test] +#[cfg(target_pointer_width = "64")] +fn sixteen_bit_ycbcr_bands_match_whole_decode_with_a_missing_last_strip() { + // On 32-bit targets max_alloc also bounds the whole image, so a small allocation cap + // cannot force these tiny strips into separate output groups there. + let (w, h, rows) = (4u32, 5u32, 2u32); + let pixels = [[0u16, 32768, 32768], [65535, 32768, 32768], [19595, 21710, 65535], [32768, 40960, 24576]]; + let samples: Vec = (0..w * h).flat_map(|i| pixels[i as usize % pixels.len()]).collect(); + let weights = [rational(1, 4), rational(1, 2), rational(1, 4)]; + let icc = sample_icc(512); + for little in [true, false] { + for missing in [false, true] { + let dir = strips(w, h, 16, 3, 6, rows, &u16s(&samples, little)).tag(SUBSAMPLING, SHORT, &[1, 1]).tag(YCBCR_COEFFICIENTS, RATIONAL, &weights).bytes( + ICC_PROFILE, + UNDEFINED, + &icc, + ); + let mut file = build(little, false, &[dir], &[0]); + if missing { + let past = file.bytes.len() as u64 + 4096; + patch_value(&mut file, little, false, 0, 273, 2, past); + } + let want = decode_with(&file.bytes, &DecodeOptions { keep_orientation: true, ..Default::default() }).unwrap(); + let limits = Limits { max_alloc: u64::from(w * rows * 3 * 2), ..Limits::banded() }; + let mut bands = TiffBands::open(&file.bytes, None, &limits).unwrap(); + assert_eq!((bands.dimensions(), bands.layout(), bands.sample_type()), ((w, h), ChannelLayout::Rgb, SampleType::U16)); + let mut got = Image::new(w, h, bands.layout(), bands.sample_type()).unwrap(); + let mut positions = Vec::new(); + bands + .decode(|x, y, group: &Image| -> Result<(), CodecError> { + positions.push((x, y, group.height())); + assert_eq!((x, group.width(), group.layout(), group.sample_type()), (0, w, ChannelLayout::Rgb, SampleType::U16)); + let at = y as usize * w as usize * 3 * 2; + got.data_mut()[at..at + group.data().len()].copy_from_slice(group.data()); + Ok(()) + }) + .unwrap(); + assert_eq!(positions, [(0, 0, 2), (0, 2, 2), (0, 4, 1)], "one output group per strip, including the short last strip"); + assert_eq!(got.data(), want.data(), "little endian: {little}, missing last strip: {missing}"); + assert_eq!(bands.icc, want.icc); + assert_eq!(bands.meta, want.meta); + assert_eq!(bands.warnings, want.warnings); + assert_eq!(bands.warnings.iter().any(|w| matches!(w, DecodeWarning::Truncated { .. })), missing); + } + } +} + +#[test] +fn unsupported_ycbcr_files_fail_cleanly() { + // Four samples (an extra sample next to YCbCr). + let four = strips(1, 1, 8, 4, 6, 1, &[128, 128, 128, 255]).tag(SUBSAMPLING, SHORT, &[1, 1]); + assert!(decode(&build(true, false, &[four], &[0]).bytes).is_err()); + // Subsampled chroma without JPEG compression (the default subsampling is 2×2). + let sub = strips(2, 2, 8, 3, 6, 2, &[128; 12]); + assert!(decode(&build(true, false, &[sub], &[0]).bytes).is_err()); + // A JPEG strip that is not JPEG data. + let junk = compressed(strips(16, 16, 8, 3, 6, 16, &[0x5A; 64]), 7).tag(SUBSAMPLING, SHORT, &[2, 2]); + assert!(decode(&build(true, false, &[junk], &[0]).bytes).is_err()); + // Coefficients and ReferenceBlackWhite that would divide by zero fall back to the defaults. + let bad = strips(1, 1, 8, 3, 6, 1, &[255, 128, 128]) + .tag(SUBSAMPLING, SHORT, &[1, 1]) + .tag(YCBCR_COEFFICIENTS, RATIONAL, &[rational(0, 1), rational(0, 0), rational(5, 1)]) + .tag(REFERENCE_BLACK_WHITE, RATIONAL, &[rational(7, 1); 6]); + let img = decode(&build(true, false, &[bad], &[0]).bytes).unwrap(); + assert_eq!(&img.to_rgba8()[..3], &[255, 255, 255]); +} + +#[test] +fn tiled_jpeg_ycbcr_keeps_its_icc_profile() { + // Two 16×16 tiles, each its own 4:2:0 JPEG stream, and an embedded profile, like a scanner's + // tiled output. + let (w, h, t) = (32u32, 16u32, 16u32); + let px = card(w, h); + let tile = |tx: u32| -> Vec { + (0..t) + .flat_map(|y| { + let at = ((y * w + tx * t) * 3) as usize; + px[at..at + (t * 3) as usize].to_vec() + }) + .collect() + }; + let streams = [jpeg(&tile(0), t, t), jpeg(&tile(1), t, t)]; + let icc = sample_icc(512); + let dir = Dir { entries: base(w, h, 8, 3, 6), chunks: streams.to_vec(), tiled: true }; + let dir = compressed(dir, 7).tag(322, SHORT, &[u64::from(t)]).tag(323, SHORT, &[u64::from(t)]).tag(SUBSAMPLING, SHORT, &[2, 2]).bytes( + ICC_PROFILE, + UNDEFINED, + &icc, + ); + let img = decode(&build(true, false, &[dir], &[0]).bytes).unwrap(); + assert_eq!((img.width(), img.height(), img.layout()), (w, h, ChannelLayout::Rgb)); + assert_eq!(img.icc.as_deref(), Some(icc.as_slice()), "the embedded profile is kept"); + // The oracle: each tile's stream decoded by an independent decoder, placed side by side. + let tiles: Vec> = streams.iter().map(|s| image::load_from_memory_with_format(s, image::ImageFormat::Jpeg).unwrap().to_rgb8().into_raw()).collect(); + let row = (t * 3) as usize; + let want: Vec = (0..t as usize).flat_map(|y| [&tiles[0][y * row..(y + 1) * row], &tiles[1][y * row..(y + 1) * row]].concat()).collect(); + let worst = rgb(&img).iter().zip(&want).map(|(g, o)| g.abs_diff(*o)).max().unwrap(); + assert!(worst <= 2, "largest difference from the oracle: {worst}"); +} + +#[test] +fn big_endian_sixteen_bit_ycbcr_reads_as_rgb() { + // White, black, and red as full-range 16-bit YCbCr (Y 0.299·65535, Cb 32768 − 0.1687·65535, Cr max). + let px = [65535u16, 32768, 32768, 0, 32768, 32768, 19595, 21710, 65535]; + let dir = strips(3, 1, 16, 3, 6, 1, &u16s(&px, false)).tag(SUBSAMPLING, SHORT, &[1, 1]); + let file = build(false, false, &[dir], &[0]).bytes; + assert_eq!(&file[..2], b"MM"); + let img = decode(&file).unwrap(); + assert_eq!((img.layout(), img.sample_type()), (ChannelLayout::Rgb, SampleType::U16)); + let n = img.to_normalized(); + let want = [1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0]; + assert!(n.iter().zip(want).all(|(g, w)| (g - w).abs() < 2.0 / 255.0), "{n:?}"); +} + +#[test] +fn planar_ycbcr_reads_as_rgb_in_both_byte_orders() { + // The pixels of the default-conversion test, one plane per component. + let px = [0u8, 128, 128, 255, 128, 128, 128, 128, 128, 76, 85, 255]; + for little in [true, false] { + let dir = Dir { entries: base(4, 1, 8, 3, 6), chunks: planar_strips(&px, 4, 1, 3, 1, 1), tiled: false }.tag(278, LONG, &[1]).tag(284, SHORT, &[2]).tag( + SUBSAMPLING, + SHORT, + &[1, 1], + ); + let img = decode(&build(little, false, &[dir], &[0]).bytes).unwrap(); + assert_eq!(rgb(&img), [0, 0, 0, 255, 255, 255, 128, 128, 128, 254, 0, 0], "little endian: {little}"); + } +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 5ef09a2e7e..68cd49bbe9 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-26 · **Change:** minor (TIFFs up to 300,000 px a side open, decoded a group of rows at a time into the layer, #8169; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; PSD Filter Gallery: Halftone Pattern dot/circle/line settings survive PSD/PSB saves (#8940); Save for Web writes WebP, lossy or lossless, #6391; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency, #8877; layered TIFF writes the TySh text version and descriptor in PSD big-endian order while the header, matrix, warp and bounds keep container order, #9098; layered TIFF writes Photoshop's 14-byte global layer mask, so Photoshop opens the layers instead of only the composite, #9095; legacy PSD point text placed from the layout ink box after font resolution, #6094; `.atn`: 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; `.asl` layer style libraries read and written, #1051; JPEG Options on File › Save, Save As and Save a Copy, FILE-215-19 partial (no Matte, progressive Scans or size preview); 32-bit Hue/Saturation unclipped and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; JPEG Options on Save As and Save a Copy, FILE-215-19; TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; Cineon write: 10-bit gray/RGB; animated WebP frames load into video layers, #1198; Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 53 filters (39 more: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag, Invert, Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding; Amiga IFF write: 24-plane ILBM, Save As `.iff`; PSD stores spot channels as Photoshop does, ink 0 = solid, and numbers them in 1053, #7415 split; TIFF writes and reads alpha and spot channels as extra samples with Photoshop's channel resources, PSD output unchanged, FILE-215-7, FILE-215-17's TIFF part, #7415; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-30 · **Change:** patch (TIFF: YCbCr images open as RGB, including JPEG-compressed scans with subsampled chroma, #10316; TIFFs up to 300,000 px a side open, decoded a group of rows at a time into the layer, #8169; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; PSD Filter Gallery: Halftone Pattern dot/circle/line settings survive PSD/PSB saves (#8940); Save for Web writes WebP, lossy or lossless, #6391; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency, #8877; layered TIFF writes the TySh text version and descriptor in PSD big-endian order while the header, matrix, warp and bounds keep container order, #9098; layered TIFF writes Photoshop's 14-byte global layer mask, so Photoshop opens the layers instead of only the composite, #9095; legacy PSD point text placed from the layout ink box after font resolution, #6094; `.atn`: 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; `.asl` layer style libraries read and written, #1051; JPEG Options on File › Save, Save As and Save a Copy, FILE-215-19 partial (no Matte, progressive Scans or size preview); 32-bit Hue/Saturation unclipped and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; JPEG Options on Save As and Save a Copy, FILE-215-19; TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; Cineon write: 10-bit gray/RGB; animated WebP frames load into video layers, #1198; Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 53 filters (39 more: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag, Invert, Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding; Amiga IFF write: 24-plane ILBM, Save As `.iff`; PSD stores spot channels as Photoshop does, ink 0 = solid, and numbers them in 1053, #7415 split; TIFF writes and reads alpha and spot channels as extra samples with Photoshop's channel resources, PSD output unchanged, FILE-215-7, FILE-215-17's TIFF part, #7415; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency) · **Target:** Adobe Photoshop 2026 **PSD Filter Gallery (#8940, 2026-10-23):** Halftone Pattern maps its size, contrast and dot/circle/line pattern as editable settings on import and export, with the reference application's descriptor keys (`HlSz`, `Cntr`, `ScrT`: `ScrD`/`ScrC`/`ScrL`). PSD/PSB round trips pass at 8/16/32 bits. This does not claim pixel equivalence for the existing renderer. Unknown or malformed settings remain verbatim on import. @@ -26,7 +26,7 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity. | PSD | R/W | yes | yes | 1/8/16/32-bit; Bitmap, Gray, Indexed, RGB, CMYK, Multichannel, Lab, Duotone; layers, groups, masks, vector masks, adjustment and fill layers, effects, text, shapes, smart objects (SoLd/lnk2 with their warp: the bend presets, a custom mesh and, since #4112, Cylinder; CS2 PlLd/lnkD read with their warp, kept as is or re-saved as SoLd once edited), smart filters (53 mapped, among them Invert, the Distort filters but Shear, Clouds, Difference Clouds, Fibers and Lens Flare, plus Hue/Saturation, Perspective Warp, Filter Gallery with Chrome and a Camera Raw subset, rest verbatim), slices, comps, Advanced Blending, each layer's XMP packet (`layer.xmpMetadata`); alpha and spot channels as Photoshop stores them (spot ink 0 = solid, channel identifiers in 1053), and PSDs with spot channels from PhotoCraft 0.1.0–0.16.0, which stored the ink the other way round, keep their ink. Oracle floors: Photoshop-authored 190/258, io 148/170, psd-tools 249/309; round trip ≥ 169/170, 308/309, 258/258. Known failures: #1281, #2469 (#2374, text layers, closed 2026-10-10; #2465, CS2 `PlLd`-only smart objects, fixed 2026-10-12) | | PSB | R/W | yes | yes | Auto-switches above 2 GB (REL-212-2); generated 20 MP, 150-layer, 2 GB PSD and 4.4 GB PSB fixtures opened, saved and re-opened by io tests/it/large_files.rs (FILE-216-2, 2026-10-13; the 4.4 GB PSB opens on Windows); > 4 GB from the app's File › Open on Windows: #375 | | Cloud PSD (.psdc) | R/W | no | no | Out of scope (Adobe cloud) | -| TIFF | R/W | yes | yes | 8/16/32f, Gray/RGB/CMYK, BigTIFF, every IFD, banded parallel decode; flat pages up to 300,000 px a side open a group of rows at a time into the layer, with no full-size copy, and are refused with an error when the machine lacks the memory (Linux: free RAM and swap, cgroup limits; #8169); layered TIFF both byte orders, including Photoshop mixed-order type blocks on read (#4758/#4771) and TySh text descriptors written in PSD order (#9098) and Photoshop's 14-byte `LMsk` block (#9095); LZW/ZIP for scripted saves by `tiffCompression`; undecodable type keeps saved pixels (FILE-215-7); alpha and spot channels as extra samples (at most 16) with Photoshop's names, ink colours and solidity, spot ink stored 0 = solid (#7415); no Save Transparency choice yet (channels are written as Photoshop does with it off) | +| TIFF | R/W | yes | yes | 8/16/32f, Gray/RGB/CMYK, YCbCr read as RGB (JPEG-compressed with subsampled chroma, or uncompressed without subsampling; #10316), BigTIFF, every IFD, banded parallel decode; flat pages up to 300,000 px a side open a group of rows at a time into the layer, with no full-size copy, and are refused with an error when the machine lacks the memory (Linux: free RAM and swap, cgroup limits; #8169); layered TIFF both byte orders, including Photoshop mixed-order type blocks on read (#4758/#4771) and TySh text descriptors written in PSD order (#9098) and Photoshop's 14-byte `LMsk` block (#9095); LZW/ZIP for scripted saves by `tiffCompression`; undecodable type keeps saved pixels (FILE-215-7); alpha and spot channels as extra samples (at most 16) with Photoshop's names, ink colours and solidity, spot ink stored 0 = solid (#7415); no Save Transparency choice yet (channels are written as Photoshop does with it off) | | JPEG | R/W | yes | yes | 8-bit Gray/RGB/CMYK, ICC/EXIF/XMP; Save keeps the opened file's quality (estimated from its quantisation tables, #2399); File › Save, Save As and Save a Copy ask JPEG Options: Quality 0–12, starting at the opened file's quality or else the Export preference, and Baseline ("Standard") / Baseline Optimized / Progressive; scripts pass `quality`/`jpegFormat`, the CLI `--jpeg-format` (FILE-215-19). Baseline Optimized fits the Huffman tables only at 4:4:4 (Gray, CMYK) or progressive: RGB is written 4:2:0, which zune-jpeg 0.5.15 decodes wrongly with fitted tables, as in Save for Web. No Matte, progressive Scans (3–5) or size preview | | PNG | R/W | yes | yes | 8/16-bit, ICC/text; APNG first frame only; alpha channels on open (#2225); palette PNGs open in Indexed Color mode with their transparent entry (#4804); Save keeps a stereo PNG's `sTER` pairing (#6063) | | GIF | R/W | yes | yes | File › Open keeps the first frame; New Video Layer from File and Video Frames to Layers take every frame, with the timeline rate from the delays (#572). Per-frame delays and keeping the frames in `.pcraft` are not done | @@ -148,6 +148,7 @@ Embedded SVG and PDF-compatible AI/PDF/EPS payloads can use an explicit VectorCr | Date | Change | Summary | |---|---|---| +| 2026-10-30 | patch | TIFF: YCbCr (PhotometricInterpretation 6) opens as RGB instead of failing with "colour type YCbCr(8)": JPEG-compressed with subsampled chroma (as scanners write it) through the JPEG conversion, uncompressed with ReferenceBlackWhite and YCbCrCoefficients; 8 and 16-bit. Uncompressed subsampled YCbCr and YCbCr with extra samples still fail with a clear error (#10316) | | 2026-10-26 | minor | Embedded AI/PDF/EPS save-back retains native format and a separate source-bound display preview; original sources remain undoable. Native Adobe interoperability and standalone EPS/DCS remain unverified; no corpus floor or percentage changed. | | 2026-10-22 | minor | Embedded PSD vectors gain explicit bounded SVG editor/save-back; standalone EPS/DCS and native-format fidelity remain open. | | 2026-10-22 | minor | TIFF: flat pages up to 300,000 px a side open (was 2^30 px, about 32,768²): `codecs::TiffBands` decodes a group of rows at a time into the layer's tiles. The 117,504 × 22,464 px banner from #8456 opens in 3.3 s at 11.45 GB peak for a 10.56 GB layer; 30,000² 6.8 s / 6.43 GB → 1.1 s / 3.86 GB; 36 MP 220 → 52 ms. Layered TIFFs still decode whole (#8169) | From 5992650b36c2980f9288feee7b916fac5cb466ac Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Tue, 3 Nov 2026 01:26:13 +0530 Subject: [PATCH 179/216] fix: address PSD admission review and enable platform memory consent --- Cargo.lock | 34 + crates/automation/Cargo.toml | 4 + crates/automation/src/headless.rs | 3 +- crates/automation/src/rpc.rs | 3 +- crates/automation/src/rpc/tests.rs | 55 + crates/automation/src/server.rs | 143 +- crates/codecs/src/codecs/tiff/decode.rs | 40 + crates/engine/src/cmd/file_cmds.rs | 23 +- crates/engine/src/cmd/file_cmds/open.rs | 67 +- crates/engine/src/cmd/file_cmds/tests.rs | 169 ++- crates/engine/src/cmd/video_cmds.rs | 14 +- crates/engine/src/cmd/video_cmds/tests.rs | 9 +- crates/engine/src/error.rs | 37 +- crates/engine/src/jobs.rs | 251 +++- crates/engine/src/jobs/tests.rs | 204 +++ crates/io/Cargo.toml | 9 +- crates/io/src/flat.rs | 291 +++- crates/io/src/lib.rs | 262 +++- crates/io/src/memory.rs | 1597 +++++++++++++++++++-- crates/io/src/psd_budget.rs | 57 + crates/io/src/psd_budget/tests.rs | 135 +- crates/io/src/psd_gate.rs | 34 +- crates/io/src/psd_import.rs | 96 +- crates/io/src/psd_import/tests.rs | 27 + crates/io/src/tiff_layers.rs | 547 ++++++- crates/ui-egui/src/app/files.rs | 32 +- crates/ui-egui/src/dialogs/open_prompt.rs | 61 +- crates/ui-egui/src/file_open.rs | 84 +- crates/ui-egui/src/file_open/tests.rs | 2 +- crates/ui-egui/src/jobs_ui.rs | 81 +- crates/ui-egui/src/jobs_ui/tests.rs | 16 +- crates/ui-egui/src/psd_open_tests.rs | 527 +++++-- docs/control-protocol.md | 4 +- docs/development.md | 111 +- 34 files changed, 4354 insertions(+), 675 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e3ba377e6c..87541c6e37 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4141,6 +4141,15 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + [[package]] name = "num-bigint" version = "0.4.8" @@ -4553,6 +4562,16 @@ dependencies = [ "objc2-core-graphics", ] +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + [[package]] name = "objc2-io-surface" version = "0.3.2" @@ -5281,6 +5300,7 @@ dependencies = [ "resvg", "roxmltree 0.21.1", "serde_json", + "sysinfo", "thiserror 2.0.21", "tiff", "winsafe", @@ -7338,6 +7358,20 @@ dependencies = [ "web-sys", ] +[[package]] +name = "sysinfo" +version = "0.40.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e8a3edbe0394ce115f3917a00349dd242be09eb5a091ed509eaf67b55cfb8a8" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows", +] + [[package]] name = "system-configuration" version = "0.7.0" diff --git a/crates/automation/Cargo.toml b/crates/automation/Cargo.toml index ba073af2ff..629a34a783 100644 --- a/crates/automation/Cargo.toml +++ b/crates/automation/Cargo.toml @@ -31,6 +31,10 @@ clap = { workspace = true } [dev-dependencies] # Synthetic camera raws (#7789). photocraft-raw = { workspace = true, features = ["testgen"] } +# Tiny generated PSDs and the scoped estimate charge for the high-memory approval tests +# (no multi-gigabyte fixture or global override). +photocraft-io = { workspace = true, features = ["testgen"] } +photocraft-psd = { workspace = true, features = ["testgen"] } rmcp = { version = "3.5", default-features = false, features = ["client", "server", "macros", "transport-io"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-std", "sync", "net", "io-util", "time"] } diff --git a/crates/automation/src/headless.rs b/crates/automation/src/headless.rs index 2ec4f23b9e..e623e69f2c 100644 --- a/crates/automation/src/headless.rs +++ b/crates/automation/src/headless.rs @@ -140,7 +140,8 @@ impl Headless { /// Place a file into a document as a Smart Object layer: the capability-scoped counterpart /// of `file.placeEmbedded`, which reads an ambient path and is therefore refused in /// automation (#2810). `path` resolves beneath the read root the way `open` resolves one, - /// and `params` carries the command's own options (`scale`, `fit`, `center`). + /// and `params` carries the command's own options (`scale`, `fit`, `center`, + /// `approveHighMemory`). pub fn place(&mut self, index: Option, path: &str, params: &Value) -> Result { self.on_doc(index, |this| { let mut opts = params.as_object().cloned().unwrap_or_default(); diff --git a/crates/automation/src/rpc.rs b/crates/automation/src/rpc.rs index a41737bbaf..e8c61e1b40 100644 --- a/crates/automation/src/rpc.rs +++ b/crates/automation/src/rpc.rs @@ -77,7 +77,8 @@ fn attach_memory_error_details(obj: &mut Value, err: &AutomationError) { } } } - AutomationError::Import(photocraft_io::IoError::PsdImportBudget { required, limit }) => { + AutomationError::Import(photocraft_io::IoError::PsdImportBudget { required, limit }) + | AutomationError::Engine(photocraft_engine::EngineError::PsdHardBudget { required, limit, .. }) => { if let Some(m) = obj.as_object_mut() { m.insert("required".into(), json!(required)); m.insert("hard".into(), json!(limit)); diff --git a/crates/automation/src/rpc/tests.rs b/crates/automation/src/rpc/tests.rs index b7ee7feb36..0a20075267 100644 --- a/crates/automation/src/rpc/tests.rs +++ b/crates/automation/src/rpc/tests.rs @@ -1057,4 +1057,59 @@ fn approve_high_memory_rejects_invalid_types_and_formats_structured_error() { assert_eq!(err_obj["hardLimit"], 8000); assert_eq!(err_obj["available"], 10000); assert_eq!(err_obj["availableRam"], 10000); + + // An engine-command hard refusal (Open As / Place go through `EngineError`) keeps its typed + // fields, and only the fields an approval request has: no soft limit and no capacity, since + // no approval can raise the ceiling. + let mut hard_obj = json!({"id": 2, "ok": false, "error": "test"}); + let hard = crate::AutomationError::Engine(photocraft_engine::EngineError::PsdHardBudget { name: "big.psd".into(), required: 9000, limit: 8000 }); + super::attach_memory_error_details(&mut hard_obj, &hard); + assert_eq!(hard_obj["required"], 9000); + assert_eq!(hard_obj["hard"], 8000); + assert_eq!(hard_obj["hardLimit"], 8000); + for absent in ["soft", "softLimit", "available", "availableRam"] { + assert!(hard_obj.get(absent).is_none(), "a hard refusal has no {absent}: {hard_obj}"); + } +} + +/// #10897: `doc.place` forwards the strict one-shot high-memory approval on both routes: the +/// read-root route (the bytes are imported in-process) and trusted local (the ambient +/// `file.placeEmbedded` command reads the path). A tiny PSD plus the scoped estimate charge +/// reaches the real approval window without allocating anything close to a gigabyte. +#[test] +fn doc_place_forwards_high_memory_approval() { + if cfg!(target_pointer_width = "32") { + return; // the 3 GiB charge would exceed a 32-bit process cap + } + let gb = 1u64 << 30; + let root = std::env::temp_dir().join(format!("pc-doc-place-approval-{}", std::process::id())); + std::fs::create_dir_all(&root).unwrap(); + let psd = photocraft_psd::testgen::small(photocraft_psd::Version::Psd, photocraft_psd::Compression::Zip).to_bytes().unwrap(); + std::fs::write(root.join("high.psd"), &psd).unwrap(); + // Known 5 GiB RAM: S = 2.5 GiB, H = 3.75 GiB. A 3 GiB estimate charge lands in (S, H]. + let snap = photocraft_io::memory::MemorySnapshot { available_ram: Some(5 * gb), swap_free: None, commit_headroom: None }; + let trial = |h: &mut Headless, path: &str| { + photocraft_io::psd_budget::with_test_estimate_charge(3 * gb, || { + photocraft_io::memory::with_mock_memory(snap, || { + let refused = h.handle("doc.place", json!({"path": path})).unwrap_err(); + assert!( + matches!(refused, crate::AutomationError::Engine(photocraft_engine::EngineError::PsdApprovalRequired { .. })), + "an unapproved high-memory place must ask for approval: {refused}" + ); + let placed = h.handle("doc.place", json!({"path": path, "approveHighMemory": true})).unwrap(); + assert!(placed["layer"].as_u64().is_some(), "the approval lets the same place proceed: {placed}"); + }); + }); + }; + let mut h = Headless::with_workspace(crate::AuthorizedWorkspace::new(Some(&root), Some(&root)).unwrap()); + h.handle("doc.new", json!({"width": 40, "height": 30})).unwrap(); + trial(&mut h, "high.psd"); + // Release directory capabilities before cleanup on Windows. + drop(h); + let mut t = Headless::trusted_local(); + t.handle("doc.new", json!({"width": 40, "height": 30})).unwrap(); + let absolute = root.join("high.psd").to_string_lossy().into_owned(); + trial(&mut t, &absolute); + drop(t); + std::fs::remove_dir_all(root).unwrap(); } diff --git a/crates/automation/src/server.rs b/crates/automation/src/server.rs index a0fb6d36fb..ecf3c406d1 100644 --- a/crates/automation/src/server.rs +++ b/crates/automation/src/server.rs @@ -72,6 +72,9 @@ pub struct PlaceParams { /// Centre of the placed layer, in document pixels. #[serde(default)] pub center: Option<[f64; 2]>, + /// Explicitly approve high-memory PSD/PSB imports that fit within the hard memory budget. + #[serde(default)] + pub approve_high_memory: bool, } #[derive(Debug, Default, Deserialize, JsonSchema)] @@ -483,37 +486,64 @@ fn png_result(png: &[u8], note: String) -> CallToolResult { bounded_tool_result(CallToolResult::success(vec![Content::image(b64, "image/png"), Content::text(note)])) } +/// Adds the typed byte counts of a memory refusal to an error object: an approval request keeps +/// its soft limit, hard limit and (when known) capacity; a hard refusal keeps only its required +/// and hard amounts, because no approval can raise that ceiling. +fn attach_memory_error_details(obj: &mut Value, err: &AutomationError) { + let Some(obj) = obj.as_object_mut() else { return }; + match err { + AutomationError::Import(photocraft_io::IoError::PsdApprovalRequired { required, soft_limit, hard_limit, available_ram }) + | AutomationError::Engine(photocraft_engine::EngineError::PsdApprovalRequired { required, soft_limit, hard_limit, available_ram, .. }) => { + obj.insert("required".into(), json!(required)); + obj.insert("soft".into(), json!(soft_limit)); + obj.insert("softLimit".into(), json!(soft_limit)); + obj.insert("hard".into(), json!(hard_limit)); + obj.insert("hardLimit".into(), json!(hard_limit)); + if let Some(avail) = available_ram { + obj.insert("available".into(), json!(avail)); + obj.insert("availableRam".into(), json!(avail)); + } + } + AutomationError::Import(photocraft_io::IoError::PsdImportBudget { required, limit }) + | AutomationError::Engine(photocraft_engine::EngineError::PsdHardBudget { required, limit, .. }) => { + obj.insert("required".into(), json!(required)); + obj.insert("hard".into(), json!(limit)); + obj.insert("hardLimit".into(), json!(limit)); + } + _ => {} + } +} + fn to_result(r: Result) -> Result { Ok(match r { Ok(v) => ok_json(&v), Err(e) => { let mut obj = json!({"error": e.to_string()}); - match &e { - AutomationError::Import(photocraft_io::IoError::PsdApprovalRequired { required, soft_limit, hard_limit, available_ram }) - | AutomationError::Engine(photocraft_engine::EngineError::PsdApprovalRequired { required, soft_limit, hard_limit, available_ram, .. }) => { - obj["required"] = json!(required); - obj["soft"] = json!(soft_limit); - obj["softLimit"] = json!(soft_limit); - obj["hard"] = json!(hard_limit); - obj["hardLimit"] = json!(hard_limit); - if let Some(avail) = available_ram { - obj["available"] = json!(avail); - obj["availableRam"] = json!(avail); - } - } - AutomationError::Import(photocraft_io::IoError::PsdImportBudget { required, limit }) => { - obj["required"] = json!(required); - obj["hard"] = json!(limit); - obj["hardLimit"] = json!(limit); - } - _ => {} - } + attach_memory_error_details(&mut obj, &e); let text = serde_json::to_string(&obj).unwrap_or_else(|_| e.to_string()); CallToolResult::error(vec![Content::text(text)]) } }) } +/// A `doc_place` call's command options, including the explicit high-memory approval. +fn place_options(p: &PlaceParams) -> Value { + let mut opts = serde_json::Map::new(); + if let Some(v) = p.scale { + opts.insert("scale".into(), json!(v)); + } + if let Some(v) = p.fit { + opts.insert("fit".into(), json!(v)); + } + if let Some(v) = p.center { + opts.insert("center".into(), json!(v)); + } + if p.approve_high_memory { + opts.insert("approveHighMemory".into(), json!(true)); + } + Value::Object(opts) +} + fn bridge_only(name: &str) -> Result { Ok(fail(format!( "`{name}` drives the live GUI and needs bridge mode: start the app with \ @@ -673,20 +703,10 @@ impl PhotocraftMcp { } #[tool( - description = "Place a file into the document as a Smart Object layer (Place Embedded), reading `path` beneath the read root. `scale` (percent), `fit` and `center` are the command's own." + description = "Place a file into the document as a Smart Object layer (Place Embedded), reading `path` beneath the read root. `scale` (percent), `fit`, `center` and `approve_high_memory` (one-shot PSD/PSB consent, default false) are the command's own." )] async fn doc_place(&self, Parameters(p): Parameters) -> Result { - let mut opts = serde_json::Map::new(); - if let Some(v) = p.scale { - opts.insert("scale".into(), json!(v)); - } - if let Some(v) = p.fit { - opts.insert("fit".into(), json!(v)); - } - if let Some(v) = p.center { - opts.insert("center".into(), json!(v)); - } - let opts = Value::Object(opts); + let opts = place_options(&p); // Bridge mode runs the app on this machine, which reads the path itself like any command. let mut bridge_params = opts.clone(); if let Some(o) = bridge_params.as_object_mut() { @@ -973,32 +993,7 @@ impl PhotocraftMcp { Ok(value) => json!({"ok": true, "result": value}), Err(error) => { let mut obj = json!({"ok": false, "error": error.to_string()}); - match &error { - AutomationError::Import(photocraft_io::IoError::PsdApprovalRequired { required, soft_limit, hard_limit, available_ram }) - | AutomationError::Engine(photocraft_engine::EngineError::PsdApprovalRequired { - required, - soft_limit, - hard_limit, - available_ram, - .. - }) => { - obj["required"] = json!(required); - obj["soft"] = json!(soft_limit); - obj["softLimit"] = json!(soft_limit); - obj["hard"] = json!(hard_limit); - obj["hardLimit"] = json!(hard_limit); - if let Some(avail) = available_ram { - obj["available"] = json!(avail); - obj["availableRam"] = json!(avail); - } - } - AutomationError::Import(photocraft_io::IoError::PsdImportBudget { required, limit }) => { - obj["required"] = json!(required); - obj["hard"] = json!(limit); - obj["hardLimit"] = json!(limit); - } - _ => {} - } + attach_memory_error_details(&mut obj, &error); obj } }; @@ -1344,6 +1339,38 @@ mod tests { assert_eq!(v["available"], 12000); } + /// #10897: the MCP `doc_place` tool takes the same strict, defaulted approval as `doc_open` + /// and forwards it as the command's `approveHighMemory` option. A hard refusal keeps its + /// typed `required`/`hardLimit` fields and claims nothing about a soft limit or capacity. + #[tokio::test(flavor = "multi_thread")] + async fn mcp_doc_place_schema_approval_and_structured_hard_error() { + let schema = &PhotocraftMcp::tool_router().map["doc_place"].attr.input_schema; + assert_eq!(schema["properties"]["approve_high_memory"]["type"], "boolean"); + let omitted: PlaceParams = serde_json::from_value(json!({"path": "file.psd"})).unwrap(); + assert!(!omitted.approve_high_memory); + assert!(place_options(&omitted).get("approveHighMemory").is_none(), "false is not forwarded"); + let approved: PlaceParams = serde_json::from_value(json!({"path": "file.psd", "approve_high_memory": true})).unwrap(); + assert!(approved.approve_high_memory); + assert_eq!(place_options(&approved)["approveHighMemory"], true); + let explicit_false: PlaceParams = serde_json::from_value(json!({"path": "file.psd", "approve_high_memory": false})).unwrap(); + assert!(!explicit_false.approve_high_memory); + for invalid in [Value::Null, json!("true"), json!(1)] { + assert!(serde_json::from_value::(json!({"path": "file.psd", "approve_high_memory": invalid})).is_err(), "{invalid}"); + } + + let err = AutomationError::Engine(photocraft_engine::EngineError::PsdHardBudget { name: "huge.psd".into(), required: 9_000, limit: 8_000 }); + let res = to_result(Err(err)).unwrap(); + assert_eq!(res.is_error, Some(true)); + let text = &res.content.first().and_then(|c| c.as_text()).unwrap().text; + let v: Value = serde_json::from_str(text).unwrap(); + assert_eq!(v["required"], 9_000); + assert_eq!(v["hard"], 8_000); + assert_eq!(v["hardLimit"], 8_000); + for absent in ["soft", "softLimit", "available", "availableRam"] { + assert!(v.get(absent).is_none(), "a hard refusal has no {absent}: {v}"); + } + } + /// The MCP server applies finished jobs before every tool call too (#503). #[tokio::test(flavor = "multi_thread")] async fn mcp_tools_see_a_finished_background_job() { diff --git a/crates/codecs/src/codecs/tiff/decode.rs b/crates/codecs/src/codecs/tiff/decode.rs index f698f04039..3962a46a5d 100644 --- a/crates/codecs/src/codecs/tiff/decode.rs +++ b/crates/codecs/src/codecs/tiff/decode.rs @@ -164,6 +164,18 @@ impl<'a> TiffBands<'a> { self.p.lay.out_row().checked_mul(rows).ok_or_else(|| CodecError::LimitExceeded("band size".into())) } + /// A conservative bound on the decoder's own scratch memory in a whole-image decode of this + /// page (as `decode_page` does it): one strip or tile per worker that decodes a band at + /// once. Zero when strips are decoded in place. Add the decoded image itself and any + /// extra-sample planes. + pub fn decode_scratch_bytes(&self) -> u64 { + #[cfg(not(target_arch = "wasm32"))] + let workers = rayon::current_num_threads() as u64; + #[cfg(target_arch = "wasm32")] + let workers = 1; + scratch_bound(self.p.lay.direct(), self.p.scratch_bytes as u64, self.p.band_rows, self.p.h, workers) + } + /// Decodes the image a group of bands at a time, in order, handing each group to `sink` /// with the position of its top-left pixel in the image [`TiffBands::dimensions`] gives. A /// group is about 64 MiB (at least one strip or row of tiles per worker, at most `max_alloc`; @@ -215,6 +227,16 @@ impl<'a> TiffBands<'a> { } } +/// [`TiffBands::decode_scratch_bytes`]'s bound: none when chunks are decoded in place, +/// otherwise one chunk per worker that decodes a band, and never more chunks than the image has. +fn scratch_bound(direct: bool, chunk_bytes: u64, band_rows: usize, h: u32, workers: u64) -> u64 { + if direct { + return 0; + } + let bands = u64::from(h).div_ceil(band_rows.max(1) as u64).max(1); + chunk_bytes.saturating_mul(workers.min(bands)) +} + /// The Orientation (1–8) of page `page` (`None`: the default page); 1 when absent, malformed /// or out of range. Classic TIFF and BigTIFF. pub(crate) fn orientation(bytes: &[u8], page: Option) -> u16 { @@ -539,6 +561,9 @@ pub(super) struct Prepared { extras_over_limit: usize, /// Extra samples left out because they did not fit the allocation budget beside the image. extras_dropped: u32, + /// Bytes one strip or tile decodes to, before clipping to the image: a worker's scratch + /// buffer. A strip is the full width; a tile keeps its declared size even at an edge. + scratch_bytes: usize, } impl Prepared { @@ -776,6 +801,9 @@ fn prepare(f: &File<'_>, dir: &Ifd, limits: &Limits, whole: bool) -> Result, dir: &Ifd, limits: &Limits, whole: bool) -> Result Vec { native, open_as ), - spec!( - "file.approveMemory", - "Approve High Memory PSD Import", - &[], - None, - r##"{"id":u64,"approve":bool=true,"background":bool=true}"##, - always, - approve_memory - ), + // One-shot permission for one file, not something to replay later: never journaled or + // recorded in an action, since replaying it in another session/on another file would + // grant memory use nobody approved (#10897). It also releases the queued source on both + // answers, so it must not be replayed as a step that expects a missing preflight. + CommandSpec { + id: "file.approveMemory", + label: "Approve High Memory PSD Import", + menu: &[], + shortcut: None, + params: r##"{"id":u64,"approve":bool=true,"background":bool=true}"##, + enabled: always, + run: approve_memory, + journal: false, + }, spec!( "file.placeEmbedded", "Place Embedded…", diff --git a/crates/engine/src/cmd/file_cmds/open.rs b/crates/engine/src/cmd/file_cmds/open.rs index 43daf778bb..13af404bea 100644 --- a/crates/engine/src/cmd/file_cmds/open.rs +++ b/crates/engine/src/cmd/file_cmds/open.rs @@ -24,7 +24,7 @@ pub(super) fn import_at_raw_ppi(raw_ppi: f64, name: &str, bytes: &[u8]) -> Resul fn import_result(name: &str, bytes: &[u8]) -> Result { crate::allocation::checkpoint("importing document")?; - let r = photocraft_io::import(name, bytes).map_err(|e| EngineError::Other(format!("{name}: {e}")))?; + let r = photocraft_io::import(name, bytes).map_err(|e| crate::error::io_open_error(name, e))?; // Auxiliary imports return only a document and cannot surface the preview's fidelity warning. // Open has its own warning-preserving path; never silently place or process a thumbnail. if r.preview_only { @@ -128,12 +128,7 @@ pub fn open_bytes_as_with_options( let mut opts = crate::jobs::import_options(s.prefs()); opts.psd_high_memory_approved = approve_high_memory; opts.psd_approved_estimate = None; - let r = photocraft_io::import_with_options(&decode_name, bytes, &opts).map_err(|e| match e { - photocraft_io::IoError::PsdApprovalRequired { required, soft_limit, hard_limit, available_ram } => { - EngineError::PsdApprovalRequired { name: decode_name.clone(), required, soft_limit, hard_limit, available_ram } - } - e => EngineError::Other(format!("{decode_name}: {e}")), - })?; + let r = photocraft_io::import_with_options(&decode_name, bytes, &opts).map_err(|e| crate::error::io_open_error(&decode_name, e))?; let mut doc = r.document; tag_exr_colorspace(s, &decode_name, &mut doc); raw_defaults(s, &r.warnings, &mut doc); @@ -191,35 +186,30 @@ pub(super) fn approve_memory(s: &mut Session, p: &Value) -> Result { .ok_or_else(|| EngineError::BadParams { cmd: "file.approveMemory".into(), msg: format!("no pending high-memory preflight for id {id}") })?; return Ok(json!({"id": id, "approved": false, "cancelled": true})); } - #[cfg(target_arch = "wasm32")] - { - return Err(EngineError::Other("high-memory approval is not available on the web (WebAssembly limit is 1 GiB)".into())); + // Taking the preflight releases the source bytes it held: approval, refusal and a later + // failure all end the wait. The one-shot authorization is bound to this source and estimate; + // the import re-checks both against the handle it actually decodes. + let preflight = s + .take_pending_preflight(id) + .ok_or_else(|| EngineError::BadParams { cmd: "file.approveMemory".into(), msg: format!("no pending high-memory preflight for id {id}") })?; + // The resolved policy is the platform's: desktop uses its measured adaptive budget, the web + // its 1 GiB automatic / 2 GiB hard product cap. Explicit consent is available on both; the + // hard ceiling is never raised by an approval. + let budget = photocraft_io::memory::resolve_budget(photocraft_io::memory::MemorySnapshot::current(), None); + if preflight.required > budget.hard_limit { + return Err(EngineError::PsdHardBudget { name: preflight.name, required: preflight.required, limit: budget.hard_limit }); } - #[cfg(not(target_arch = "wasm32"))] - { - let preflight = s - .take_pending_preflight(id) - .ok_or_else(|| EngineError::BadParams { cmd: "file.approveMemory".into(), msg: format!("no pending high-memory preflight for id {id}") })?; - let snapshot = photocraft_io::memory::MemorySnapshot::current(); - let budget = photocraft_io::memory::resolve_budget(snapshot, None); - if preflight.required > budget.hard_limit { - return Err(EngineError::Other(format!( - "available memory has decreased; opening this file needs {} bytes, exceeding current hard limit {} bytes", - preflight.required, budget.hard_limit - ))); - } - let started = s.start_open_approved_with_background( - &preflight.name, - preflight.source, - preflight.confirm_damage, - Some(preflight.required), - Some(preflight.fingerprint), - _background, - )?; - match started { - crate::jobs::Started::Done(v) => Ok(json!({"id": id, "approved": true, "result": v})), - crate::jobs::Started::Job(job_id) => Ok(json!({"id": id, "approved": true, "job": job_id.0})), - } + let started = s.start_open_approved_with_background( + &preflight.name, + preflight.source, + preflight.confirm_damage, + Some(preflight.required), + Some(preflight.fingerprint), + _background, + )?; + match started { + crate::jobs::Started::Done(v) => Ok(json!({"id": id, "approved": true, "result": v})), + crate::jobs::Started::Job(job_id) => Ok(json!({"id": id, "approved": true, "job": job_id.0})), } } @@ -248,12 +238,7 @@ pub fn place_bytes(s: &mut Session, name: &str, bytes: Vec, linked: Option { - EngineError::PsdApprovalRequired { name: name.to_string(), required, soft_limit, hard_limit, available_ram } - } - e => EngineError::Other(format!("{name}: {e}")), - })?; + let imported = photocraft_io::import_with_options(name, &bytes, &opts).map_err(|e| crate::error::io_open_error(name, e))?; if imported.preview_only { return Err(EngineError::Other(format!( "{name}: only this Affinity file's embedded preview could be read; open it with File › Open to see the warning, or export PSD or PNG from Affinity before using it here" diff --git a/crates/engine/src/cmd/file_cmds/tests.rs b/crates/engine/src/cmd/file_cmds/tests.rs index cbdcc82815..675f55b49c 100644 --- a/crates/engine/src/cmd/file_cmds/tests.rs +++ b/crates/engine/src/cmd/file_cmds/tests.rs @@ -2075,7 +2075,7 @@ fn approve_memory_refuses_when_source_changes_before_confirmation() { std::fs::write(&path, b"changed content longer").unwrap(); let res = s.execute("file.approveMemory", json!({"id": id, "approve": true, "background": false})); - assert!(res.is_err(), "changed file must be refused"); + assert!(matches!(res, Err(EngineError::SourceChanged { .. })), "changed file must fail identity verification: {res:?}"); } #[test] @@ -2142,3 +2142,170 @@ fn pending_preflights_cap_fails_closed() { .is_err() ); } + +/// A queued high-memory preflight for `source`, with fields a test can read. +fn memory_preflight(name: &str, source: crate::jobs::OpenSource) -> crate::jobs::PendingPsdPreflight { + let fingerprint = crate::jobs::SourceFingerprint::of(&source).unwrap(); + crate::jobs::PendingPsdPreflight { + id: 0, + name: name.into(), + source, + fingerprint, + required: 1000, + soft_limit: 500, + hard_limit: 2000, + available_ram: Some(4000), + confirm_damage: true, + } +} + +#[test] +fn pending_preflights_bound_retained_bytes_and_charge_a_shared_buffer_once() { + let mut s = Session::new(); + let cap = 100; + let a_src = crate::jobs::OpenSource::Bytes(std::sync::Arc::new(vec![0u8; 40])); + let b_src = crate::jobs::OpenSource::Bytes(std::sync::Arc::new(vec![0u8; 40])); + let a_id = s.store_pending_preflight_with_limit(memory_preflight("a.psd", a_src.clone()), cap).unwrap(); + let b_id = s.store_pending_preflight_with_limit(memory_preflight("b.psd", b_src), cap).unwrap(); + assert_eq!(s.pending_preflight_bytes().unwrap(), 80); + // A second reference to the same allocation is already held: queued for free. + let a_again = s.store_pending_preflight_with_limit(memory_preflight("a-again.psd", a_src), cap).unwrap(); + assert_eq!(s.pending_preflight_bytes().unwrap(), 80, "one shared buffer is charged once, not three times"); + // A different buffer would carry the total over the cap: refused, and nothing is charged. + let c_src = crate::jobs::OpenSource::Bytes(std::sync::Arc::new(vec![0u8; 30])); + let err = s.store_pending_preflight_with_limit(memory_preflight("c.psd", c_src.clone()), cap).expect_err("a further 30 bytes must not fit beside 80"); + assert!(err.to_string().contains("waiting for a memory approval"), "{err}"); + assert_eq!(s.pending_preflight_bytes().unwrap(), 80, "a refused preflight adds no charge"); + // Cancelling one released its buffer; the refused one now fits; clearing releases the rest. + assert!(s.cancel_pending_preflight(a_again).is_some()); + assert_eq!(s.pending_preflight_bytes().unwrap(), 80, "the other live reference still holds the buffer"); + assert!(s.cancel_pending_preflight(a_id).is_some()); + assert_eq!(s.pending_preflight_bytes().unwrap(), 40); + let c_id = s.store_pending_preflight_with_limit(memory_preflight("c.psd", c_src), cap).unwrap(); + assert_eq!(s.pending_preflight_bytes().unwrap(), 70); + s.clear_pending_preflights(); + assert_eq!(s.pending_preflight_bytes().unwrap(), 0, "clear releases every held buffer"); + assert!(s.cancel_pending_preflight(b_id).is_none()); + assert!(s.cancel_pending_preflight(c_id).is_none()); +} + +#[test] +fn pending_preflights_charge_the_retained_allocation_not_the_visible_length() { + let mut s = Session::new(); + let cap = 100; + // Room for 128 bytes but one byte of contents: what stays resident is the allocation, so a + // cap that fits the contents must still refuse it. + let mut grown = Vec::with_capacity(128); + grown.push(7u8); + let held = grown.capacity() as u64; + assert!(held >= 128, "the buffer really has the larger allocation: {held}"); + let big = std::sync::Arc::new(grown); + let err = s + .store_pending_preflight_with_limit(memory_preflight("big.psd", crate::jobs::OpenSource::Bytes(std::sync::Arc::clone(&big))), cap) + .expect_err("the held capacity, not the one byte of contents, must be charged"); + assert!(err.to_string().contains("waiting for a memory approval"), "{err}"); + assert_eq!(s.pending_preflight_bytes().unwrap(), 0, "a refused preflight adds no charge"); + + // A cap that admits the whole allocation accepts it, and shared clones count once. + let id = s.store_pending_preflight_with_limit(memory_preflight("big.psd", crate::jobs::OpenSource::Bytes(std::sync::Arc::clone(&big))), held).unwrap(); + let shared = + s.store_pending_preflight_with_limit(memory_preflight("shared.psd", crate::jobs::OpenSource::Bytes(std::sync::Arc::clone(&big))), held).unwrap(); + assert_eq!(s.pending_preflight_bytes().unwrap(), held, "one shared allocation is charged once, whatever its length"); + assert!(s.cancel_pending_preflight(shared).is_some()); + assert_eq!(s.pending_preflight_bytes().unwrap(), held, "the other shared reference still holds the allocation"); + assert!(s.cancel_pending_preflight(id).is_some()); + assert_eq!(s.pending_preflight_bytes().unwrap(), 0, "cancelling the last reference releases the allocation"); + s.clear_pending_preflights(); + + // A buffer whose length was shrunk to zero still holds its allocation: the held capacity is + // measured, so it is charged even though `len()` says nothing is there. + let mut emptied = Vec::with_capacity(128); + emptied.push(1u8); + emptied.clear(); + assert!(emptied.is_empty(), "the length is zero while the allocation stays"); + let emptied = std::sync::Arc::new(emptied); + let emptied_held = emptied.capacity() as u64; + assert!(emptied_held >= 128, "clearing does not release the allocation: {emptied_held}"); + let emptied_id = s + .store_pending_preflight_with_limit(memory_preflight("emptied.psd", crate::jobs::OpenSource::Bytes(emptied)), emptied_held) + .expect("the allocation fits the cap that admits it"); + assert_eq!(s.pending_preflight_bytes().unwrap(), emptied_held, "an empty length is not zero bytes held"); + s.clear_pending_preflights(); + assert_eq!(s.pending_preflight_bytes().unwrap(), 0, "clear releases every held allocation"); + assert!(s.cancel_pending_preflight(emptied_id).is_none()); +} + +#[test] +fn approve_memory_is_neither_journaled_nor_recorded_in_an_action() { + let mut s = Session::new(); + let id = s.store_pending_preflight(memory_preflight("cancel.psd", crate::jobs::OpenSource::Bytes(std::sync::Arc::new(vec![0u8; 4])))).unwrap(); + s.execute("actions.record", json!({"name": "Memory approvals"})).unwrap(); + let r = s.execute("file.approveMemory", json!({"id": id, "approve": false})).unwrap(); + assert_eq!((r["approved"].clone(), r["cancelled"].clone()), (json!(false), json!(true))); + s.execute("actions.stop", json!({})).unwrap(); + assert!(s.journal.iter().all(|(command, _)| command != "file.approveMemory"), "one-shot approval must not become a journal entry: {:?}", s.journal); + let action = s.actions.list.first().expect("the recorded action"); + assert!(action.steps.iter().all(|(command, _)| command != "file.approveMemory"), "one-shot approval must not replay from an action: {:?}", action.steps); +} + +#[test] +fn approve_memory_releases_the_preflight_when_the_hard_limit_refuses_it() { + let gb = 1u64 << 30; + let mut s = Session::new(); + let preflight = crate::jobs::PendingPsdPreflight { + required: 8 * gb, + soft_limit: 6 * gb, + hard_limit: 9 * gb, + available_ram: Some(12 * gb), + ..memory_preflight("ram_drop.psd", crate::jobs::OpenSource::Bytes(std::sync::Arc::new(vec![0u8; 16]))) + }; + let id = s.store_pending_preflight(preflight).unwrap(); + let snap_low = photocraft_io::memory::MemorySnapshot { available_ram: Some(4 * gb), swap_free: None, commit_headroom: None }; + let expected_limit = photocraft_io::memory::resolve_budget(snap_low, None).hard_limit; + photocraft_io::memory::with_mock_memory(snap_low, || { + let err = s.execute("file.approveMemory", json!({"id": id, "approve": true, "background": false})).unwrap_err(); + match &err { + EngineError::PsdHardBudget { name, required, limit } => { + assert_eq!(name, "ram_drop.psd"); + assert_eq!((*required, *limit), (8 * gb, expected_limit)); + } + other => panic!("the hard refusal must keep its fields, got {other}"), + } + }); + assert!(s.take_pending_preflight(id).is_none(), "a hard refusal releases the held source"); + assert_eq!(s.pending_preflight_bytes().unwrap(), 0); +} + +#[test] +fn open_as_and_place_keep_the_typed_hard_budget_fields() { + let gb = 1u64 << 30; + let dir = tmp("hard_budget"); + let path = join(&dir, "big.psd"); + let mut src = Session::new(); + src.execute("file.new", json!({"width": 4, "height": 4, "depth": 8})).unwrap(); + std::fs::write(&path, photocraft_io::export(&src.active().unwrap().doc, "x.psd", &Default::default()).unwrap().bytes).unwrap(); + // An estimate over the resolved hard ceiling is refused, and no approval raises the ceiling. + // The estimate charge is a test seam so the fixture stays tiny. + let snap = photocraft_io::memory::MemorySnapshot { available_ram: Some(4 * gb), swap_free: None, commit_headroom: None }; + let hard = photocraft_io::memory::resolve_budget(snap, None).hard_limit; + assert!(hard < u64::MAX); + photocraft_io::memory::with_mock_memory(snap, || { + photocraft_io::psd_budget::with_test_estimate_charge(hard + 1, || { + let mut s = Session::new(); + let err = s.execute("file.openAs", json!({"path": path.clone(), "approveHighMemory": true})).unwrap_err(); + match &err { + EngineError::PsdHardBudget { name, required, limit } => { + assert_eq!(name, &path); + assert!(*required > hard && *limit == hard); + } + other => panic!("openAs must keep name/required/limit, got {other}"), + } + let mut s = session(8, 8, 8); + let err = s.execute("file.placeEmbedded", json!({"path": path.clone(), "approveHighMemory": true})).unwrap_err(); + match &err { + EngineError::PsdHardBudget { required, limit, .. } => assert!(*required > hard && *limit == hard), + other => panic!("place must keep required/limit, got {other}"), + } + }); + }); +} diff --git a/crates/engine/src/cmd/video_cmds.rs b/crates/engine/src/cmd/video_cmds.rs index edc6c7e59d..03d4f32d7c 100644 --- a/crates/engine/src/cmd/video_cmds.rs +++ b/crates/engine/src/cmd/video_cmds.rs @@ -266,7 +266,7 @@ const MAX_FOOTAGE_BYTES: u64 = 2 << 30; /// Load frames from `path`: a single image (1 frame, or every frame of an animated GIF or WebP), or a /// directory of images (sorted = an image sequence). Each becomes a full raster frame in the /// document's format. -fn load_frames(path: &str, fmt: photocraft_color::PixelFormat) -> Result { +fn load_frames(path: &str, fmt: photocraft_color::PixelFormat, opts: &photocraft_io::ImportOptions) -> Result { let p = std::path::Path::new(path); let is_img = |f: &std::path::Path| { matches!( @@ -345,7 +345,9 @@ fn load_frames(path: &str, fmt: photocraft_color::PixelFormat) -> Result Result { .ok_or_else(|| EngineError::BadParams { cmd: "layer.videoLayers.newVideoLayerFromFile".into(), msg: "need `path`".into() })? .to_string(); let fmt = s.active().ok_or(EngineError::NoDocument)?.doc.pixel_format(); - let Footage { frames, fps: footage_fps } = load_frames(&path, fmt)?; + let Footage { frames, fps: footage_fps } = load_frames(&path, fmt, &crate::jobs::import_options(s.prefs()))?; // An explicit `fps` wins, then the rate the footage implies, then 30. let fps = crate::timeline_cmds::fps_param("layer.videoLayers.newVideoLayerFromFile", p)?.or(footage_fps).unwrap_or(30.0); let n = frames.len(); @@ -410,7 +412,7 @@ fn replace_footage(s: &mut Session, p: &Value) -> Result { .ok_or_else(|| EngineError::BadParams { cmd: "layer.videoLayers.replaceFootage".into(), msg: "need `path`".into() })? .to_string(); let fmt = s.active().ok_or(EngineError::NoDocument)?.doc.pixel_format(); - let frames = load_frames(&path, fmt)?.frames; + let frames = load_frames(&path, fmt, &crate::jobs::import_options(s.prefs()))?.frames; edit_video(s, "Replace Footage", move |v, _| { v.original_frames = Some(frames.clone()); v.frames = frames; @@ -446,7 +448,7 @@ fn reload_frame(s: &mut Session, _p: &Value) -> Result { }; // The footage path is stored in the document. let file = crate::doc_path::resolve(&path).map_err(|why| crate::doc_path::refusal("the footage", &path, why))?; - let frames = load_frames(&file.to_string_lossy(), fmt)?.frames; + let frames = load_frames(&file.to_string_lossy(), fmt, &crate::jobs::import_options(s.prefs()))?.frames; edit_video(s, "Reload Frame", move |v, _| { v.original_frames = Some(frames.clone()); v.frames = frames; @@ -471,7 +473,7 @@ fn frames_to_layers(s: &mut Session, p: &Value) -> Result { .ok_or_else(|| EngineError::BadParams { cmd: "file.import.videoFramesToLayers".into(), msg: "need `path`".into() })? .to_string(); let fmt = s.active().ok_or(EngineError::NoDocument)?.doc.pixel_format(); - let frames = load_frames(&path, fmt)?.frames; + let frames = load_frames(&path, fmt, &crate::jobs::import_options(s.prefs()))?.frames; let n = frames.len(); s.edit("Video Frames to Layers", |doc, active| { let mut above = *active; diff --git a/crates/engine/src/cmd/video_cmds/tests.rs b/crates/engine/src/cmd/video_cmds/tests.rs index d08135f60e..0e4552c234 100644 --- a/crates/engine/src/cmd/video_cmds/tests.rs +++ b/crates/engine/src/cmd/video_cmds/tests.rs @@ -382,8 +382,13 @@ fn tga_sequence_frames_keep_their_transparency() { let dir = tmpdir("tga"); write_tga(&format!("{dir}/f01.tga")); write_tga(&format!("{dir}/f02.tga")); - let frames = - load_frames(&dir, photocraft_color::PixelFormat::new(photocraft_color::ColorMode::Rgb, photocraft_color::SampleType::U8, true)).unwrap().frames; + let frames = load_frames( + &dir, + photocraft_color::PixelFormat::new(photocraft_color::ColorMode::Rgb, photocraft_color::SampleType::U8, true), + &photocraft_io::ImportOptions::default(), + ) + .unwrap() + .frames; assert_eq!(frames.len(), 2); for f in &frames { assert_eq!(f.pixel(2, 6), vec![1.0, 0.0, 0.0, 1.0]); diff --git a/crates/engine/src/error.rs b/crates/engine/src/error.rs index 1a8342099d..0abdb6302a 100644 --- a/crates/engine/src/error.rs +++ b/crates/engine/src/error.rs @@ -1,7 +1,7 @@ use super::LayerId; use crate::command_hints; -#[derive(Debug, thiserror::Error)] +#[derive(Clone, Debug, PartialEq, thiserror::Error)] pub enum EngineError { /// Displays with the nearest registered ids, if any ([`command_hints`], #3674). #[error("unknown command `{id}`{hint}", id = .0, hint = command_hints::unknown_command_hint(.0))] @@ -21,6 +21,14 @@ pub enum EngineError { /// Opening a PSD/PSB file requires explicit high-memory confirmation. #[error("opening “{name}” requires explicit memory approval: {required} bytes required (soft limit {soft_limit}, hard limit {hard_limit})")] PsdApprovalRequired { name: String, required: u64, soft_limit: u64, hard_limit: u64, available_ram: Option }, + /// The file's estimated memory cannot be admitted within the hard ceiling, which no approval + /// can raise. The requirement may equal the ceiling when the estimate saturated. + #[error("cannot open “{name}”: its estimated memory requirement of {required} bytes cannot be admitted within the {limit} byte hard limit on this machine")] + PsdHardBudget { name: String, required: u64, limit: u64 }, + /// The file behind an opened handle is not the one whose memory estimate was approved. + /// The document is discarded and is not opened. + #[error("“{name}” changed since it was checked; open it again to approve the new file")] + SourceChanged { name: String }, #[error("{0}")] Other(String), /// A background job was cancelled (see [`jobs`]); nothing changed. @@ -30,6 +38,33 @@ pub enum EngineError { pub type Result = std::result::Result; +impl EngineError { + /// Whether an approval-required error also carries the unknown-capacity warning: capacity was + /// unknown when the estimate was made and the estimate is above the warning threshold. A + /// fresh measurement or a known capacity never sets it, and no number is invented for a + /// capacity that could not be measured. + pub fn uncertain_capacity_warning(&self) -> bool { + match self { + Self::PsdApprovalRequired { required, available_ram, .. } => photocraft_io::memory::uncertain_capacity_warning(*required, *available_ram), + _ => false, + } + } +} + +/// Maps an io open/import failure to the engine's typed errors, preserving the memory fields the +/// UI, control channel and agents need to answer or explain it. +pub(crate) fn io_open_error(name: &str, error: photocraft_io::IoError) -> EngineError { + use photocraft_io::IoError; + match error { + IoError::PsdApprovalRequired { required, soft_limit, hard_limit, available_ram } => { + EngineError::PsdApprovalRequired { name: name.to_string(), required, soft_limit, hard_limit, available_ram } + } + IoError::PsdImportBudget { required, limit } => EngineError::PsdHardBudget { name: name.to_string(), required, limit }, + IoError::SourceChanged => EngineError::SourceChanged { name: name.to_string() }, + error => EngineError::Other(format!("{name}: {error}")), + } +} + impl From for EngineError { fn from(error: photocraft_raster::AllocationError) -> Self { Self::Other(format!("not enough memory for this operation ({error}); the document was not changed")) diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index da2851a3d0..91b0e11ca4 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -170,6 +170,11 @@ pub struct JobEvent { pub label: String, pub document: Option, pub outcome: JobOutcome, + /// The typed failure behind a failed `outcome`: a hard-budget refusal keeps its + /// name/required/limit, an approval request keeps its limits, a changed source keeps its + /// kind. `None` for done, cancelled and panics without a typed error. Not serialized; + /// `JobInfo::error` remains the display string on the wire. + pub error: Option, } /// A job's state for the UI, the control channel and MCP (`jobs.list`). @@ -192,6 +197,10 @@ pub struct JobInfo { pub result: Option, #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, + /// The typed failure behind `error`, for callers in this process (not serialized: the wire + /// form is the message, as before). + #[serde(skip)] + pub error_kind: Option, } type Payload = Box; @@ -273,6 +282,7 @@ impl Running { elapsed_ms: now_ms() - self.started_ms, result: None, error: None, + error_kind: None, } } } @@ -340,16 +350,29 @@ fn independent_of_document(id: &str) -> bool { PREFIXES.iter().any(|p| id.starts_with(p)) || IDS.contains(&id) } +/// Imports the file at `path`, taking its identity from the very handle it decodes. +/// +/// `expected` is the identity approved for this file, if any. It is checked against the opened +/// handle before decoding and again when the memory preflight pauses for approval, so the +/// document opened is the approved file even if the path is replaced meanwhile. `opened` receives +/// the handle's identity as soon as it is known, including when the import stops for approval. #[cfg(not(target_arch = "wasm32"))] fn import_path( name: &str, path: &str, opts: &photocraft_io::ImportOptions, + expected: Option<&SourceFingerprint>, ctl: &photocraft_raster::Interrupt, + opened: &mut Option, ) -> std::result::Result<(photocraft_io::ImportResult, bool), photocraft_io::IoError> { + let expected = match expected { + None => None, + Some(SourceFingerprint::Path { identity, .. }) => Some(identity), + Some(SourceFingerprint::Bytes { .. }) => return Err(photocraft_io::IoError::SourceChanged), + }; // Bounded reads, and a clear error for a file larger than memory (#375); a PSD/PSB is - // parsed from the open file instead of being read whole. - photocraft_io::import_file_for_open_with(name, std::path::Path::new(path), opts, ctl) + // streamed from the open file instead of being read whole. + photocraft_io::import_file_for_open_verified(name, std::path::Path::new(path), expected, opts, ctl, opened) } #[cfg(target_arch = "wasm32")] @@ -357,7 +380,9 @@ fn import_path( _: &str, path: &str, _: &photocraft_io::ImportOptions, + _: Option<&SourceFingerprint>, _: &photocraft_raster::Interrupt, + _: &mut Option, ) -> std::result::Result<(photocraft_io::ImportResult, bool), photocraft_io::IoError> { Err(photocraft_io::IoError::Read(format!("cannot open paths on the web: {path}"))) } @@ -529,46 +554,47 @@ pub fn import_options(prefs: &crate::prefs::Preferences) -> photocraft_io::Impor } /// Fingerprint verifying that a source has not changed between preflight and approval. +/// +/// A path's fingerprint is the [`photocraft_io::FileIdentity`] of an opened handle, never a path +/// stat made before a separate open: the importer takes it from the very handle it decodes +/// (`photocraft_io::import_file_for_open_verified`), so a path replaced or rewritten between +/// the estimate and the approval is refused with [`EngineError::SourceChanged`]. The Unix +/// identity includes the inode change time and nanoseconds, so restoring a modification time +/// cannot hide a same-size rewrite. #[derive(Clone, Debug, PartialEq, Eq)] pub enum SourceFingerprint { - Bytes { - len: usize, - identity: usize, - }, - Path { - path: String, - len: u64, - modified: std::time::SystemTime, - #[cfg(unix)] - dev: u64, - #[cfg(unix)] - ino: u64, - }, + Bytes { len: usize, identity: usize }, + Path { path: String, identity: photocraft_io::FileIdentity }, } impl SourceFingerprint { + /// Fingerprint of `source`. + /// + /// An in-memory source's identity is its shared buffer. A path is inspected here for + /// discovery only; the decode path re-reads its identity from the handle it opens. Fails + /// closed when the identity data cannot be read. pub fn of(source: &OpenSource) -> Result { match source { OpenSource::Bytes(b) => Ok(SourceFingerprint::Bytes { len: b.len(), identity: Arc::as_ptr(b) as usize }), OpenSource::Path(p) => { - let meta = std::fs::metadata(p).map_err(|e| EngineError::Other(format!("{p}: {e}")))?; - let len = meta.len(); - let modified = meta.modified().map_err(|e| EngineError::Other(format!("{p}: failed to read modification time: {e}")))?; - #[cfg(unix)] - { - use std::os::unix::fs::MetadataExt; - let dev = meta.dev(); - let ino = meta.ino(); - Ok(SourceFingerprint::Path { path: p.clone(), len, modified, dev, ino }) - } - #[cfg(not(unix))] - { - Ok(SourceFingerprint::Path { path: p.clone(), len, modified }) - } + Self::path(p.clone(), photocraft_io::FileIdentity::of_path(std::path::Path::new(p)).map_err(|e| EngineError::Other(format!("{p}: {e}")))?) } } } + /// Fingerprint of the file identity read from an opened handle for `path`. + pub fn path(path: String, identity: photocraft_io::FileIdentity) -> Self { + SourceFingerprint::Path { path, identity } + } + + /// The handle identity a path fingerprint compares against, if this is one. + pub fn file_identity(&self) -> Option<&photocraft_io::FileIdentity> { + match self { + SourceFingerprint::Path { identity, .. } => Some(identity), + SourceFingerprint::Bytes { .. } => None, + } + } + pub fn matches(&self, other: &Self) -> bool { self == other } @@ -612,10 +638,39 @@ impl Session { pub const MAX_PENDING_PREFLIGHTS: usize = 32; - pub fn store_pending_preflight(&mut self, mut preflight: PendingPsdPreflight) -> Result { + /// Most bytes of file data pending approvals may keep alive together. + /// + /// This is a retention bound for the *sources* of files waiting for an answer, not a pixel + /// budget and not an admission limit: only an [`OpenSource::Bytes`] source holds data (a path + /// streams from its file once opened), the allocation's capacity is charged rather than its + /// contents, and one shared buffer counts once however many preflights refer to it. 1 GiB is + /// chosen conservatively: it is below the 2 GiB web hard + /// product cap, so even a web session that queues several large dropped files cannot spend + /// the whole hard budget on data it has not decoded, and a native session keeps a comparable + /// amount out of the adaptive allowance. A file that would exceed the cap cannot become + /// pending: it is refused with an error instead of being decoded, and the caller can answer + /// or cancel queued prompts and try again, rather than holding an unbounded amount of memory + /// for unanswered prompts. The record count + /// ([`Self::MAX_PENDING_PREFLIGHTS`]) stays as a separate bound, so many small files are + /// bounded too. + pub const MAX_PENDING_PREFLIGHT_BYTES: u64 = 1 << 30; + + pub fn store_pending_preflight(&mut self, preflight: PendingPsdPreflight) -> Result { + self.store_pending_preflight_with_limit(preflight, Self::MAX_PENDING_PREFLIGHT_BYTES) + } + + /// [`Self::store_pending_preflight`] with an explicit retention cap (tests use a tiny one so + /// the boundary is exercised without holding a gigabyte). + pub(crate) fn store_pending_preflight_with_limit(&mut self, mut preflight: PendingPsdPreflight, byte_limit: u64) -> Result { if self.pending_preflights.len() >= Self::MAX_PENDING_PREFLIGHTS { return Err(EngineError::Other("too many pending high-memory preflights waiting for confirmation".into())); } + let held = self.pending_preflight_bytes_with(&preflight.source)?; + if held > byte_limit { + return Err(EngineError::Other(format!( + "too much memory is already held by files waiting for a memory approval ({held} bytes, limit {byte_limit}); answer or cancel one of them and try again" + ))); + } loop { self.next_pending_preflight = self.next_pending_preflight.checked_add(1).unwrap_or(1); if self.next_pending_preflight != 0 && !self.pending_preflights.iter().any(|p| p.id == self.next_pending_preflight) { @@ -627,6 +682,42 @@ impl Session { Ok(self.next_pending_preflight) } + /// Bytes held alive by the queued preflights, counting an `Arc>` shared by several of + /// them once. Computed from the queue, so approving, cancelling, refusing or clearing always + /// releases the charge; a rejected preflight never adds one. + pub fn pending_preflight_bytes(&self) -> Result { + Self::retained_source_bytes(self.pending_preflights.iter().map(|p| &p.source)) + } + + /// [`Self::pending_preflight_bytes`] as if `incoming` were queued too, so accepting it can be + /// checked before the list is changed. + fn pending_preflight_bytes_with(&self, incoming: &OpenSource) -> Result { + let mut sources: Vec<&OpenSource> = self.pending_preflights.iter().map(|p| &p.source).collect(); + sources.push(incoming); + Self::retained_source_bytes(sources) + } + + fn retained_source_bytes<'a>(sources: impl IntoIterator) -> Result { + let mut seen: Vec = Vec::new(); + let mut total: u64 = 0; + for source in sources { + let OpenSource::Bytes(bytes) = source else { continue }; + let identity = Arc::as_ptr(bytes) as usize; + // Distinct live allocations cannot share an address, so identity dedupes clones of + // one buffer without under-counting two different files with equal contents. + if seen.contains(&identity) { + continue; + } + seen.push(identity); + // The allocation is what stays resident, not its contents: a `Vec` that once held a + // large file keeps that capacity after being truncated, so charging `len()` would + // under-count the memory a pending prompt holds. Overflow is an error, never a wrap. + let held = u64::try_from(bytes.capacity()).unwrap_or(u64::MAX); + total = total.checked_add(held).ok_or_else(|| EngineError::Other("held file size overflow while counting pending memory approvals".into()))?; + } + Ok(total) + } + pub fn take_pending_preflight(&mut self, id: u64) -> Option { let i = self.pending_preflights.iter().position(|p| p.id == id)?; Some(self.pending_preflights.remove(i)) @@ -722,18 +813,19 @@ impl Session { let _test_memory = test_memory_scope.enter(); ctx.progress(0.0, "Reading"); - // Identity lookup is best-effort for ordinary opens, but mandatory if a - // file is held for confirmation. Valid non-PSD files do not require timestamps. - let initial_fingerprint = (confirm_damage || expected_fingerprint.is_some()).then(|| SourceFingerprint::of(&source)).transpose(); - if let Some(expected) = &expected_fingerprint { - let cur = initial_fingerprint - .as_ref() - .map_err(|e| EngineError::Other(e.to_string()))? - .as_ref() - .ok_or_else(|| EngineError::Other("missing source identity".into()))?; - if !expected.matches(cur) { - return Err(EngineError::Other("the file has changed since it was checked; please try opening it again".into())); - } + // An in-memory source's identity is its shared buffer, known before decoding. A + // path's identity can only come from the handle the verified import opens, so + // that import compares it against `expected_fingerprint` itself, before reading + // any pixel and again after its memory preflight. + let initial_fingerprint = match &source { + OpenSource::Bytes(_) => Some(SourceFingerprint::of(&source)?), + OpenSource::Path(_) => None, + }; + if let Some(expected) = &expected_fingerprint + && let Some(current) = &initial_fingerprint + && !expected.matches(current) + { + return Err(EngineError::SourceChanged { name: name_w.clone() }); } let outcome = match source { @@ -744,47 +836,36 @@ impl Session { match res { Ok(imported) => WorkResult::Decoded(Box::new(imported)), Err(photocraft_io::IoError::PsdApprovalRequired { required, soft_limit, hard_limit, available_ram }) if confirm_damage => { - let src = OpenSource::Bytes(bytes); - let fingerprint = SourceFingerprint::of(&src)?; - let initial = initial_fingerprint - .as_ref() - .map_err(|e| EngineError::Other(e.to_string()))? - .as_ref() - .ok_or_else(|| EngineError::Other("missing source identity".into()))?; - if !initial.matches(&fingerprint) { - return Err(EngineError::Other("the file changed while checking its memory requirements; please open it again".into())); - } - WorkResult::PreflightNeeded { required, soft_limit, hard_limit, available_ram, source: src, fingerprint } + // The buffer cannot change while it is decoded, so the identity + // captured before reading is still exact. + let fingerprint = initial_fingerprint.clone().ok_or_else(|| EngineError::Other("missing source identity".into()))?; + WorkResult::PreflightNeeded { required, soft_limit, hard_limit, available_ram, source: OpenSource::Bytes(bytes), fingerprint } } Err(photocraft_io::IoError::PsdApprovalRequired { required, soft_limit, hard_limit, available_ram }) => { return Err(EngineError::PsdApprovalRequired { name: name_w.clone(), required, soft_limit, hard_limit, available_ram }); } Err(photocraft_io::IoError::Cancelled) => return Err(EngineError::Cancelled), - Err(e) => return Err(EngineError::Other(e.to_string())), + Err(e) => return Err(crate::error::io_open_error(&name_w, e)), } } OpenSource::Path(p) => { - let res = ctx.stage(0.0, 0.95, "Decoding", |ctl| import_path(&name_w, &p, &opts, ctl)); + let mut opened = None; + let res = ctx.stage(0.0, 0.95, "Decoding", |ctl| import_path(&name_w, &p, &opts, expected_fingerprint.as_ref(), ctl, &mut opened)); match res { Ok(imported) => WorkResult::Decoded(Box::new(imported)), Err(photocraft_io::IoError::PsdApprovalRequired { required, soft_limit, hard_limit, available_ram }) if confirm_damage => { - let src = OpenSource::Path(p); - let fingerprint = SourceFingerprint::of(&src)?; - let initial = initial_fingerprint - .as_ref() - .map_err(|e| EngineError::Other(e.to_string()))? - .as_ref() - .ok_or_else(|| EngineError::Other("missing source identity".into()))?; - if !initial.matches(&fingerprint) { - return Err(EngineError::Other("the file changed while checking its memory requirements; please open it again".into())); - } - WorkResult::PreflightNeeded { required, soft_limit, hard_limit, available_ram, source: src, fingerprint } + // This identity is the one read from the handle that was + // estimated, and the verified import refused a handle that is + // not the approved file or changed while preflighting. + let identity = opened.ok_or_else(|| EngineError::Other(format!("{p}: the file's identity could not be read")))?; + let fingerprint = SourceFingerprint::path(p.clone(), identity); + WorkResult::PreflightNeeded { required, soft_limit, hard_limit, available_ram, source: OpenSource::Path(p), fingerprint } } Err(photocraft_io::IoError::PsdApprovalRequired { required, soft_limit, hard_limit, available_ram }) => { return Err(EngineError::PsdApprovalRequired { name: name_w.clone(), required, soft_limit, hard_limit, available_ram }); } Err(photocraft_io::IoError::Cancelled) => return Err(EngineError::Cancelled), - Err(e) => return Err(EngineError::Other(e.to_string())), + Err(e) => return Err(crate::error::io_open_error(&name_w, e)), } } }; @@ -805,6 +886,11 @@ impl Session { }, move |s, outcome| match outcome { WorkResult::PreflightNeeded { required, soft_limit, hard_limit, available_ram, source, fingerprint } => { + // The warning follows the capacity measured for this estimate: an unknown + // capacity carries no fabricated number, and above the threshold it also + // says the process or tab may run out of memory. The UI shows both. + let capacity_unknown = available_ram.is_none(); + let uncertain_capacity = photocraft_io::memory::uncertain_capacity_warning(required, available_ram); let id = s.store_pending_preflight(PendingPsdPreflight { id: 0, name: name_a.clone(), @@ -823,6 +909,8 @@ impl Session { "softLimit": soft_limit, "hardLimit": hard_limit, "availableRam": available_ram, + "capacityUnknown": capacity_unknown, + "uncertainCapacityWarning": uncertain_capacity, })) } WorkResult::Decoded(imported) => { @@ -944,7 +1032,9 @@ impl Session { match self.jobs.recent.iter().rev().find(|j| j.id == id) { Some(j) if j.state == "done" => Ok(j.result.clone().unwrap_or(Value::Null)), Some(j) if j.state == "cancelled" => Err(EngineError::Cancelled), - Some(j) => Err(EngineError::Other(j.error.clone().unwrap_or_else(|| "the job failed".into()))), + // A typed failure keeps its fields for a caller that answers or explains it; the + // display string stays available through `jobs.list`. + Some(j) => Err(j.error_kind.clone().unwrap_or_else(|| EngineError::Other(j.error.clone().unwrap_or_else(|| "the job failed".into())))), None => Err(EngineError::Other(format!("no job {}", id.0))), } } @@ -1251,17 +1341,19 @@ impl Session { /// Apply (or report the failure of) a job whose worker has delivered `r`. fn finish_job(&mut self, mut job: Running, r: Result) { let apply = job.apply.take(); - let outcome = match r { - Err(EngineError::Cancelled) => JobOutcome::Cancelled, - Err(e) => JobOutcome::Failed(e.to_string()), - Ok(_) if job.ctx.cancelled() => JobOutcome::Cancelled, + // Keep the typed error beside its display text, so `wait_job` and `JobEvent` can still + // report a hard budget's required/limit or an approval request's limits. + let (outcome, error) = match r { + Err(EngineError::Cancelled) => (JobOutcome::Cancelled, None), + Err(e) => (JobOutcome::Failed(e.to_string()), Some(e)), + Ok(_) if job.ctx.cancelled() => (JobOutcome::Cancelled, None), Ok(payload) => match self.apply_job(&job, apply, payload) { - Ok(v) => JobOutcome::Done(v), - Err(EngineError::Cancelled) => JobOutcome::Cancelled, - Err(e) => JobOutcome::Failed(e.to_string()), + Ok(v) => (JobOutcome::Done(v), None), + Err(EngineError::Cancelled) => (JobOutcome::Cancelled, None), + Err(e) => (JobOutcome::Failed(e.to_string()), Some(e)), }, }; - self.end_job(&job, outcome); + self.end_job_with_error(&job, outcome, error); } fn apply_job(&mut self, job: &Running, apply: Option, payload: Payload) -> Result { @@ -1347,6 +1439,10 @@ impl Session { } fn end_job(&mut self, job: &Running, outcome: JobOutcome) { + self.end_job_with_error(job, outcome, None); + } + + fn end_job_with_error(&mut self, job: &Running, outcome: JobOutcome, error_kind: Option) { // Every terminal path invalidates a preview, including worker errors, cancellation // and discarded results whose document was closed or changed. self.live_commit = None; @@ -1359,6 +1455,7 @@ impl Session { info.state = state; info.result = result; info.error = error; + info.error_kind = error_kind.clone(); if state == "done" { info.progress = 1.0; } @@ -1367,7 +1464,7 @@ impl Session { let extra = self.jobs.recent.len() - RECENT; self.jobs.recent.drain(..extra); } - self.jobs.events.push(JobEvent { id: job.id, command: job.command.clone(), label: job.label.clone(), document: job.doc, outcome }); + self.jobs.events.push(JobEvent { id: job.id, command: job.command.clone(), label: job.label.clone(), document: job.doc, outcome, error: error_kind }); } } diff --git a/crates/engine/src/jobs/tests.rs b/crates/engine/src/jobs/tests.rs index 0974a5c17a..45fb1cbf9b 100644 --- a/crates/engine/src/jobs/tests.rs +++ b/crates/engine/src/jobs/tests.rs @@ -782,3 +782,207 @@ fn every_job_terminal_path_drops_a_live_commit_stroke() { assert!(s.live_commit.is_none(), "terminal path {outcome} retained the preview"); } } + +#[cfg(unix)] +fn tmp_dir(name: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("photocraft-engine-jobs-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +/// A tiny real PSD fixture, so an admission test never allocates anything near a threshold. +fn tiny_psd() -> Vec { + super::psd_open_fixture::make_psd(false) +} + +#[cfg(target_pointer_width = "64")] +#[test] +fn approved_open_forwards_the_flag_and_the_bound_estimate() { + let gb = 1u64 << 30; + let bytes = Arc::new(tiny_psd()); + // Known 8 GiB: S = 4 GiB, H = 6 GiB. The test charge puts the estimate just over S. + let snapshot = photocraft_io::memory::MemorySnapshot { available_ram: Some(8 * gb), swap_free: None, commit_headroom: None }; + photocraft_io::memory::with_mock_memory(snapshot, || { + photocraft_io::psd_budget::with_test_estimate_charge(4 * gb + 1, || { + let mut s = Session::new(); + let started = s.start_open_for_user_with_background("tiny.psd", OpenSource::Bytes(Arc::clone(&bytes)), false).unwrap(); + let v = match started { + Started::Done(v) => v, + Started::Job(id) => s.wait_job(id).unwrap(), + }; + let id = v["pendingPreflight"].as_u64().unwrap_or_else(|| panic!("expected a memory preflight: {v}")); + assert!(v["estimated"].as_u64().unwrap() > 4 * gb, "the seam's charge is part of the estimate: {v}"); + assert!(s.documents().is_empty()); + // Approving forwards the one-shot flag and estimate through the real import path. + let r = s.execute("file.approveMemory", json!({"id": id, "approve": true, "background": false})).unwrap(); + assert_eq!(r["approved"], json!(true), "{r}"); + assert!(r["result"].get("document").is_some(), "the approved import opened the document: {r}"); + assert_eq!(s.documents().len(), 1); + assert_eq!(s.pending_preflight_bytes().unwrap(), 0, "approval releases the held source bytes"); + }); + }); +} + +#[cfg(target_pointer_width = "64")] +#[test] +fn approval_is_bound_to_the_approved_estimate() { + let gb = 1u64 << 30; + let bytes = Arc::new(tiny_psd()); + let snapshot = photocraft_io::memory::MemorySnapshot { available_ram: Some(8 * gb), swap_free: None, commit_headroom: None }; + photocraft_io::memory::with_mock_memory(snapshot, || { + photocraft_io::psd_budget::with_test_estimate_charge(4 * gb + 1, || { + let mut s = Session::new(); + let source = OpenSource::Bytes(Arc::clone(&bytes)); + let fingerprint = SourceFingerprint::of(&source).unwrap(); + // Consent given for a much smaller estimate does not cover this file. + let err = s + .start_open_approved_with_background("tiny.psd", source, false, Some(1 << 20), Some(fingerprint), false) + .expect_err("a larger estimate needs fresh consent"); + assert!(matches!(&err, EngineError::PsdApprovalRequired { .. }), "got {err:?}"); + assert!(s.documents().is_empty()); + }); + }); +} + +#[cfg(target_pointer_width = "64")] +#[test] +fn unknown_capacity_metadata_never_invents_available_ram() { + let gb = 1u64 << 30; + let bytes = Arc::new(tiny_psd()); + let snapshot = photocraft_io::memory::MemorySnapshot { available_ram: None, swap_free: None, commit_headroom: None }; + photocraft_io::memory::with_mock_memory(snapshot, || { + // Above both the 2 GiB unknown-capacity allowance and the 8 GiB warning threshold. + photocraft_io::psd_budget::with_test_estimate_charge(8 * gb + 1, || { + let mut s = Session::new(); + let started = s.start_open_for_user_with_background("unknown.psd", OpenSource::Bytes(Arc::clone(&bytes)), false).unwrap(); + let v = match started { + Started::Done(v) => v, + Started::Job(id) => s.wait_job(id).unwrap(), + }; + assert!(v["availableRam"].is_null(), "no number is invented for an unmeasured capacity: {v}"); + assert_eq!(v["capacityUnknown"], json!(true), "{v}"); + assert_eq!(v["uncertainCapacityWarning"], json!(true), "{v}"); + let id = v["pendingPreflight"].as_u64().unwrap(); + assert!(s.take_pending_preflight(id).is_some()); + }); + }); +} + +#[test] +fn an_open_reports_a_typed_hard_budget_refusal() { + let gb = 1u64 << 30; + let bytes = Arc::new(tiny_psd()); + // A measured 4 GiB gives a finite hard limit; a charge over it is refused. + let snapshot = photocraft_io::memory::MemorySnapshot { available_ram: Some(4 * gb), swap_free: None, commit_headroom: None }; + let hard = photocraft_io::memory::resolve_budget(snapshot, None).hard_limit; + assert!(hard < u64::MAX, "the test needs a finite hard limit"); + photocraft_io::memory::with_mock_memory(snapshot, || { + photocraft_io::psd_budget::with_test_estimate_charge(hard + 1, || { + let mut s = Session::new(); + let err = s.start_open_for_user_with_background("over.psd", OpenSource::Bytes(Arc::clone(&bytes)), false).unwrap_err(); + match &err { + EngineError::PsdHardBudget { required, limit, .. } => assert!(*required > hard && *limit == hard), + other => panic!("the job must keep the hard-budget fields, got {other:?}"), + } + assert!(s.documents().is_empty()); + }); + }); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn a_failed_job_keeps_its_typed_error_for_wait_and_events() { + let mut s = Session::new(); + let started = s + .start_job( + "test.hardBudget", + json!({}), + "Test hard budget", + false, + |_ctx| -> Result<()> { Err(EngineError::PsdHardBudget { name: "too-big.psd".into(), required: 9000, limit: 4000 }) }, + |_, _| Ok(Value::Null), + ) + .unwrap(); + let id = job(started); + let err = s.wait_job(id).expect_err("the job fails"); + assert!( + matches!(&err, EngineError::PsdHardBudget { name, required, limit } if name == "too-big.psd" && *required == 9000 && *limit == 4000), + "wait_job must not flatten the typed refusal: {err:?}" + ); + let event = s.poll_jobs().into_iter().find(|e| e.id == id).expect("the job event"); + assert!( + matches!(&event.error, Some(EngineError::PsdHardBudget { required, limit, .. }) if *required == 9000 && *limit == 4000), + "the event carries the typed refusal: {event:?}" + ); +} + +#[cfg(unix)] +#[test] +fn a_replaced_path_is_refused_with_the_source_identity_reason() { + let dir = tmp_dir("replaced"); + let path = dir.join("source.psd"); + let bytes = tiny_psd(); + std::fs::write(&path, &bytes).unwrap(); + let source = OpenSource::Path(path.to_string_lossy().into_owned()); + let fingerprint = SourceFingerprint::of(&source).unwrap(); + // A different inode with identical content and length: only the identity differs. + let replacement = dir.join("replacement.psd"); + std::fs::write(&replacement, &bytes).unwrap(); + std::fs::rename(&replacement, &path).unwrap(); + let mut s = Session::new(); + let err = s + .start_open_approved_with_background("source.psd", source.clone(), false, Some(1024), Some(fingerprint.clone()), false) + .expect_err("the handle is not the approved file"); + assert!(matches!(&err, EngineError::SourceChanged { .. }), "the inline refusal must name the identity, not a parser failure: {err:?}"); + assert!(s.documents().is_empty()); + // The same check on the worker path: the job fails with the identity reason, not a decode error. + let started = s.start_open_approved_with_background("source.psd", source, false, Some(1024), Some(fingerprint), true).unwrap(); + let Started::Job(id) = started else { panic!("expected a background job") }; + let err = s.wait_job(id).expect_err("the worker's handle is not the approved file"); + assert!(matches!(&err, EngineError::SourceChanged { .. }), "the background refusal must name the identity: {err:?}"); + assert!(s.documents().is_empty()); + let _ = std::fs::remove_dir_all(&dir); +} + +#[cfg(unix)] +#[test] +fn a_same_size_rewrite_with_a_restored_mtime_is_refused_with_the_identity_reason() { + let dir = tmp_dir("rewrite"); + let path = dir.join("source.psd"); + let bytes = tiny_psd(); + std::fs::write(&path, &bytes).unwrap(); + let source = OpenSource::Path(path.to_string_lossy().into_owned()); + let fingerprint = SourceFingerprint::of(&source).unwrap(); + let modified = std::fs::metadata(&path).unwrap().modified().unwrap(); + std::thread::sleep(Duration::from_millis(10)); + // Same inode, same length, same bytes, restored modification time: only ctime differs. + std::fs::write(&path, &bytes).unwrap(); + std::fs::OpenOptions::new().write(true).open(&path).unwrap().set_times(std::fs::FileTimes::new().set_modified(modified)).unwrap(); + let mut s = Session::new(); + let err = s + .start_open_approved_with_background("source.psd", source, false, Some(1024), Some(fingerprint), false) + .expect_err("the rewrite is not the file whose estimate was approved"); + assert!(matches!(&err, EngineError::SourceChanged { .. }), "a restored mtime must not hide the rewrite: {err:?}"); + assert!(s.documents().is_empty()); + let _ = std::fs::remove_dir_all(&dir); +} + +#[cfg(unix)] +#[test] +fn unix_fingerprint_detects_a_same_size_rewrite_with_a_restored_mtime() { + let dir = tmp_dir("ctime"); + let path = dir.join("source.psd"); + std::fs::write(&path, b"AAAAAAAAAA").unwrap(); + let before = SourceFingerprint::of(&OpenSource::Path(path.to_string_lossy().into_owned())).unwrap(); + let modified = std::fs::metadata(&path).unwrap().modified().unwrap(); + std::thread::sleep(Duration::from_millis(10)); + std::fs::write(&path, b"BBBBBBBBBB").unwrap(); + std::fs::OpenOptions::new().write(true).open(&path).unwrap().set_times(std::fs::FileTimes::new().set_modified(modified)).unwrap(); + let after = SourceFingerprint::of(&OpenSource::Path(path.to_string_lossy().into_owned())).unwrap(); + let (b, a) = (before.file_identity().unwrap(), after.file_identity().unwrap()); + assert_eq!((b.len, b.modified, b.ino), (a.len, a.modified, a.ino), "same length, modification time and inode"); + assert_ne!((b.ctime_secs, b.ctime_nanos), (a.ctime_secs, a.ctime_nanos), "the Unix identity carries ctime, so a restored mtime cannot hide a rewrite"); + assert_ne!(before, after, "a same-size rewrite with a restored mtime is a different source"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/io/Cargo.toml b/crates/io/Cargo.toml index ef6ddffc68..3c80dc3511 100644 --- a/crates/io/Cargo.toml +++ b/crates/io/Cargo.toml @@ -14,7 +14,7 @@ workspace = true corpus = [] # HEIC/HEIF decode (forwarded to photocraft-codecs; see its `heif` feature). heif = ["photocraft-codecs/heif"] -# Test support for synthetic memory injection and verification across crates. +# Test support for synthetic memory injection, estimate charges and verification across crates. testgen = [] [dependencies] @@ -55,6 +55,13 @@ resvg = { version = "0.45", default-features = false, features = ["text", "raste [target.'cfg(windows)'.dependencies] winsafe = { version = "=0.0.29", default-features = false, features = ["kernel"] } +# Available physical memory for the adaptive import budget (memory.rs). `sysinfo` is a safe +# Rust API over `host_statistics64`'s `vm_statistics64` on macOS; memory.rs derives the +# reclaimable capacity from its total/free/available/used counters (see its doc comment). +# Only the `system` feature is needed, and its MSRV 1.95 matches the workspace. +[target.'cfg(target_os = "macos")'.dependencies] +sysinfo = { version = "=0.40.1", default-features = false, features = ["system"] } + [dev-dependencies] miniz_oxide = "0.9" crc32fast = "1.5" diff --git a/crates/io/src/flat.rs b/crates/io/src/flat.rs index 803a09e580..c8303c7b84 100644 --- a/crates/io/src/flat.rs +++ b/crates/io/src/flat.rs @@ -13,19 +13,60 @@ use crate::{ExportOptions, ExportResult, ImportResult, IoError, TgaBits, XmpEmbe /// Bytes per band when converting or exporting a band of rows at a time. const BAND_BYTES: usize = 32 << 20; +#[cfg(test)] +thread_local! { + /// Called on this thread just before a layered TIFF's composite is decoded. A test proves + /// that admission refused a file without ever reaching that decode. + static TEST_BEFORE_COMPOSITE_DECODE: std::cell::RefCell>> = const { std::cell::RefCell::new(None) }; +} + +/// Runs `f` with `hook` called on this thread just before a layered TIFF's composite is decoded. +#[cfg(test)] +pub(crate) fn with_before_composite_decode(hook: std::rc::Rc, f: impl FnOnce() -> R) -> R { + struct Reset(Option>); + impl Drop for Reset { + fn drop(&mut self) { + TEST_BEFORE_COMPOSITE_DECODE.with(|h| *h.borrow_mut() = self.0.take()); + } + } + let prev = TEST_BEFORE_COMPOSITE_DECODE.with(|h| h.borrow_mut().replace(hook)); + let _reset = Reset(prev); + f() +} + +#[cfg(test)] +fn before_composite_decode() { + if let Some(hook) = TEST_BEFORE_COMPOSITE_DECODE.with(|h| h.borrow().clone()) { + hook(); + } +} + +#[cfg(not(test))] +fn before_composite_decode() {} + /// Decodes a flat image into a single-layer document; a TIFF with Photoshop layer data opens /// layered (see [`crate::tiff_layers`]). pub fn import_flat(name: &str, bytes: &[u8]) -> Result { - import_flat_with_limit(name, bytes, crate::DEFAULT_PSD_IMPORT_PIXEL_BYTES) + import_flat_with_options(name, bytes, &crate::ImportOptions::default(), &photocraft_raster::Interrupt::NONE) } -/// [`import_flat`] with the caller's PSD pixel allowance, which a layered TIFF is held to. -pub(crate) fn import_flat_with_limit(name: &str, bytes: &[u8], pixel_limit: u64) -> Result { +/// [`import_flat`] with the user's import settings and the job's cancellation. +/// +/// A layered TIFF's composite, layer and retained-image estimate is admitted through +/// [`crate::memory::evaluate_psd_admission`] with these settings; a zero +/// `psd_max_pixel_bytes` therefore means the adaptive budget, and +/// `psd_high_memory_approved`/`psd_approved_estimate` apply to it like to a PSD. The other flat +/// decoders keep their own limits. +pub(crate) fn import_flat_with_options( + name: &str, + bytes: &[u8], + opts: &crate::ImportOptions, + ctl: &photocraft_raster::Interrupt, +) -> Result { // Detected once; every step below branches on it. let format = codecs::detect(bytes); if format == Some(Format::Tiff) { - let limit = if pixel_limit > 0 { pixel_limit } else { crate::DEFAULT_PSD_IMPORT_PIXEL_BYTES }; - return import_tiff_page_with_limit(name, bytes, None, limit); + return import_tiff_page_with_options(name, bytes, None, opts, ctl); } let img = codecs::decode_as(format.ok_or(codecs::CodecError::UnknownFormat)?, bytes)?; // A palette BMP, PNG or GIF opens in Indexed Color mode with its colour table, as in @@ -235,47 +276,139 @@ pub fn tga_alpha_channel_to_transparency(doc: &mut Document) { /// rows at a time straight into the layer's tiles, so it opens up to 300,000 px a side without /// a second full-size copy (#8169), when the machine has the memory for it (see /// [`crate::memory`]). +/// +/// A layered page is admitted by the adaptive budget like a PSD, with +/// [`crate::ImportOptions`]' defaults: it can be refused with [`IoError::PsdApprovalRequired`] +/// or [`IoError::PsdImportBudget`]. Callers that can ask the user or carry cancellation use +/// [`import_tiff_page_with_options`]. pub fn import_tiff_page(name: &str, bytes: &[u8], page: Option) -> Result { - import_tiff_page_with_limit(name, bytes, page, crate::DEFAULT_PSD_IMPORT_PIXEL_BYTES) + import_tiff_page_with_options(name, bytes, page, &crate::ImportOptions::default(), &photocraft_raster::Interrupt::NONE) } -fn import_tiff_page_with_limit(name: &str, bytes: &[u8], page: Option, pixel_limit: u64) -> Result { +/// [`import_tiff_page`] with the user's import settings and the job's cancellation. +/// +/// A layered page takes the same caller limit and high-memory approval as a PSD +/// (`ImportOptions::psd_*`): its composite, PSD layer model and retained decoded image are +/// estimated from the page's tags and admitted *before* the composite is decoded. A cancelled +/// job stops waiting for the PSD decode gate and between bands. +pub fn import_tiff_page_with_options( + name: &str, + bytes: &[u8], + page: Option, + opts: &crate::ImportOptions, + ctl: &photocraft_raster::Interrupt, +) -> Result { + ctl.check().map_err(|_| IoError::Cancelled)?; let bands = codecs::TiffBands::open(bytes, page, &codecs::Limits::banded())?; - // A page with layers or extra samples (alpha and spot channels) is decoded whole: the layers - // and channels are built from the full image. Only one copy of the layer data is kept through - // that decode. - if bands.meta.photoshop_layers.as_deref().is_some_and(|l| !l.is_empty()) || bands.extra_samples() > 0 { - drop(bands); - return import_tiff_whole(name, bytes, page, pixel_limit); + // Photoshop layer data is decoded whole, so the layers can be built from the complete + // composite; the tags are read (not decoded) first, to admit the import before that decode. + if bands.meta.photoshop_layers.as_deref().is_some_and(|l| !l.is_empty()) { + return import_tiff_layered(name, bytes, page, bands, opts, ctl); } - tiff_to_document(name, bands.upright(), crate::memory::available, BAND_BYTES) + // Extra samples (alpha and spot channels) are only available from a whole-image decode. + if bands.extra_samples() > 0 { + drop(bands); + return import_tiff_with_extras(name, bytes, page, ctl); + } + tiff_to_document(name, bands.upright(), crate::memory::available, BAND_BYTES, ctl) } -/// A layered TIFF, or a flat one with extra samples, decoded whole: the layers are built from the -/// composite, the channels from the extra samples. -fn import_tiff_whole(name: &str, bytes: &[u8], page: Option, pixel_limit: u64) -> Result { +/// A layered TIFF page: its layer tag and resources are read into a plan, the plan's estimate is +/// admitted, and only then is the composite decoded. +/// +/// The admission guard is held across the composite decode and the PSD conversion that follows, +/// so nested decodes spend the same attempt's allowance and nothing here is charged twice. +fn import_tiff_layered( + name: &str, + bytes: &[u8], + page: Option, + bands: codecs::TiffBands<'_>, + opts: &crate::ImportOptions, + ctl: &photocraft_raster::Interrupt, +) -> Result { + let plan = crate::tiff_layers::preflight(&bands)?; + // A file this machine cannot open is refused here, before any composite pixel is decoded, + // and an eligible one rechecks a fresh measurement after the gate. + let _admission = tiff_admission(plan.required, opts, ctl)?; + drop(bands); + before_composite_decode(); // The orientation is applied after the layer check: rotating the composite but not the // layers would misalign them, so a layered TIFF keeps its stored orientation. let keep = codecs::DecodeOptions { keep_orientation: true, ..Default::default() }; + let (mut img, orientation) = match page { + None => (codecs::decode_with(bytes, &keep)?, codecs::tiff_orientation(bytes)), + Some(p) => (codecs::decode_tiff_page(bytes, p, &keep)?, codecs::tiff_page_orientation(bytes, p)), + }; + // The plan already holds both tags, parsed: a layered page drops the decoder's copies + // instead of keeping two of each through the conversion. A flattened fallback still needs + // the resources for its channels. + if plan.is_layered() { + img.meta.photoshop_layers = None; + img.meta.photoshop_resources = None; + } + ctl.check().map_err(|_| IoError::Cancelled)?; + let mut r = crate::tiff_layers::import_layered(name, &img, plan, ctl)?; + if orientation != 1 { + r.warnings.push("the TIFF's orientation tag was ignored so the layers stay aligned with the image".to_string()); + } + Ok(r) +} + +/// Admits a layered TIFF import for `required` bytes and holds decode admission for it. +/// +/// A nested import of a different file spends the enclosing attempt's remaining allowance: it +/// never resolves a fresh budget, resets the parent or charges the parent's image again. A +/// top-level import is refused before waiting for the gate and rechecked with a fresh +/// measurement once admitted, so a memory drop during the wait is seen before pixels are +/// decoded. Cancellation is checked before an allowance is consumed, so a cancelled nested +/// import never spends its parent's budget. +fn tiff_admission(required: u64, opts: &crate::ImportOptions, ctl: &photocraft_raster::Interrupt) -> Result { + let caller_limit = opts.psd_max_pixel_bytes; + let admit = |snapshot: crate::memory::MemorySnapshot| { + crate::memory::evaluate_psd_admission(required, caller_limit, opts.psd_high_memory_approved, opts.psd_approved_estimate, snapshot) + }; + if crate::psd_gate::is_holding_admission() { + // A nested, independent TIFF: the same-file continuation never gets here, because + // `import_tiff_layered` admits once and `import_layered` does not admit again. It spends + // the enclosing attempt's remaining allowance, and the caller's explicit bounds still + // apply before that charge (a refused nested import leaves the parent untouched). + ctl.check().map_err(|_| IoError::Cancelled)?; + crate::memory::nested_import_local_bound(required, caller_limit, opts.psd_approved_estimate)?; + let guard = crate::psd_gate::acquire_decode_admission(ctl)?; + crate::psd_gate::consume_attempt_allowance(required)?; + return Ok(guard); + } + admit(crate::memory::MemorySnapshot::current())?; + let guard = crate::psd_gate::acquire_decode_admission(ctl)?; + let outcome = admit(crate::memory::MemorySnapshot::current())?; + crate::psd_gate::set_active_attempt_budget(outcome.authorized_total.saturating_sub(required)); + Ok(guard) +} + +/// A flat TIFF page with extra samples, decoded whole: the composite becomes the layer, the +/// extra samples its channels. +fn import_tiff_with_extras(name: &str, bytes: &[u8], page: Option, ctl: &photocraft_raster::Interrupt) -> Result { + ctl.check().map_err(|_| IoError::Cancelled)?; + let keep = codecs::DecodeOptions { keep_orientation: true, ..Default::default() }; let (img, orientation) = match page { None => (codecs::decode_with(bytes, &keep)?, codecs::tiff_orientation(bytes)), Some(p) => (codecs::decode_tiff_page(bytes, p, &keep)?, codecs::tiff_page_orientation(bytes, p)), }; - if let Some(layers) = img.meta.photoshop_layers.as_deref().filter(|l| !l.is_empty()) { - let mut r = crate::tiff_layers::import_layered(name, &img, layers, pixel_limit)?; - if orientation != 1 { - r.warnings.push("the TIFF's orientation tag was ignored so the layers stay aligned with the image".to_string()); - } - return Ok(r); - } + ctl.check().map_err(|_| IoError::Cancelled)?; image_to_document(name, &img.oriented(orientation)?) } /// A flat TIFF page as a single-layer document, decoded a group of rows at a time into the /// layer's tiles: only one group exists beside the document. `available`: the memory this /// process can still use (see [`crate::memory::available`]); `budget`: the source bytes per core -/// converted at a time ([`BAND_BYTES`]). -fn tiff_to_document(name: &str, mut bands: codecs::TiffBands<'_>, available: impl FnOnce() -> Option, budget: usize) -> Result { +/// converted at a time ([`BAND_BYTES`]); `ctl`: the job's cancellation, checked per group. +fn tiff_to_document( + name: &str, + mut bands: codecs::TiffBands<'_>, + available: impl FnOnce() -> Option, + budget: usize, + ctl: &photocraft_raster::Interrupt, +) -> Result { let (w, h) = bands.dimensions(); let (layout, sample) = (bands.layout(), bands.sample_type()); let f = document_format(layout, sample); @@ -285,11 +418,9 @@ fn tiff_to_document(name: &str, mut bands: codecs::TiffBands<'_>, available: imp // The layer's tiles, plus a group as decoded, turned upright, converted and read into a // worker's scratch buffer. Linux overcommits, so the tiles below are allocated even when // they can't be backed, and writing them would have the process killed: refuse first. - let tiles = |n: u32| u64::from(n).div_ceil(TILE_SIZE as u64).saturating_mul(TILE_SIZE as u64); - let layer = tiles(w).saturating_mul(tiles(h)).saturating_mul(bpp); let group = bands.group_bytes()? as u64; let group_px = group / (layout.channels() as u64 * sample.bytes() as u64).max(1); - let needed = layer.saturating_add(group.saturating_mul(3)).saturating_add(group_px.saturating_mul(bpp)); + let needed = surface_bytes(w, h, fmt).saturating_add(group.saturating_mul(3)).saturating_add(group_px.saturating_mul(bpp)); crate::memory::check_fits(w, h, needed, available())?; } let mut s = Surface::new(fmt); @@ -298,6 +429,7 @@ fn tiff_to_document(name: &str, mut bands: codecs::TiffBands<'_>, available: imp IoError::Unsupported(format!("not enough memory to open this {w} × {h} px image: it needs about {gb:.1} GB ({e})")) }; bands.decode(|x, y, group: &Image| -> Result<(), IoError> { + ctl.check().map_err(|_| IoError::Cancelled)?; let (gw, gh) = group.dimensions(); let (x, y) = (x as i32, y as i32); let rect = |rows: &std::ops::Range| Rect::new(x, y + rows.start as i32, x.saturating_add(gw as i32), y + rows.end as i32); @@ -327,6 +459,40 @@ fn tiff_to_document(name: &str, mut bands: codecs::TiffBands<'_>, available: imp Ok(single_layer_document(name, (w, h), f, layout.has_alpha(), s, bands.icc.take(), meta, warnings)) } +/// Bytes a `w` × `h` px document surface of `fmt` allocates: whole tiles, including the padding +/// of a partly covered edge tile, as [`Surface`] holds them. +fn surface_bytes(w: u32, h: u32, fmt: PixelFormat) -> u64 { + let tiles = |n: u32| u64::from(n).div_ceil(TILE_SIZE as u64).saturating_mul(TILE_SIZE as u64); + tiles(w).saturating_mul(tiles(h)).saturating_mul(fmt.bytes_per_pixel() as u64) +} + +/// Bytes a flat page that is decoded whole needs: the decoded image and its extra-sample planes, +/// the band buffers [`image_surface`] converts through when the document format differs from the +/// page's, the layer's tiles and, per extra sample, its channel's tiles and its copied plane. +/// +/// A TIFF whose Photoshop layer tag cannot be read still opens its composite flattened; this +/// bounds that whole-image decode and its conversion before anything is allocated. +/// +/// Every term saturates instead of wrapping: an estimate that overflows `u64` is the +/// `u64::MAX` sentinel, which admission always refuses (see +/// [`crate::memory::MemoryBudget::decide`]) rather than admitting a wrapped-around value. +pub(crate) fn flattened_import_bytes(w: u32, h: u32, layout: ChannelLayout, sample: CSample, extras: usize, scratch: u64) -> u64 { + let f = document_format(layout, sample); + let fmt = PixelFormat::new(f.mode, f.depth, true); + let pixels = u64::from(w).saturating_mul(u64::from(h)); + let in_bps = sample.bytes() as u64; + let out_bps = f.sample.bytes() as u64; + let e = extras as u64; + let decoded = pixels.saturating_mul((layout.channels() as u64).saturating_add(e.saturating_mul(2))).saturating_mul(in_bps); + // A band of rows is copied, normalized to 32-bit floats and converted; a few bands' worth + // are live at once and the scratch pool reuses the rest within its own budget. + let convert = if layout == f.layout && sample == f.sample { 0 } else { (BAND_BYTES as u64).saturating_mul(16) }; + let layer = surface_bytes(w, h, fmt); + let channel = surface_bytes(w, h, PixelFormat::new(ColorMode::Grayscale, f.depth, false)); + let channels = e.saturating_mul(channel.saturating_add(pixels.saturating_mul(out_bps))); + layer.saturating_add(decoded).saturating_add(convert).saturating_add(channels).saturating_add(scratch) +} + /// Whether a `w` × `h` flat TIFF is checked against free memory before it opens: only the sizes /// the whole-image decode refused, which only the banded decode opens. Every image that opened /// before opens as it did, also on a small machine or in a container whose page cache fills. @@ -1658,4 +1824,71 @@ mod streamed_png_tests { assert!(streams_png(&doc(8000, 6000, SampleType::U16))); assert!(streams_png(&doc(117_504, 22_464, SampleType::U8))); } + + /// A cancelled nested TIFF import never spends its parent's remaining allowance: the + /// cancellation check comes before the charge, and the reentrant guard is taken first. + #[test] + fn a_cancelled_nested_tiff_admission_leaves_the_parent_allowance_untouched() { + let _parent = crate::psd_gate::acquire_decode_admission(&Interrupt::NONE).expect("parent admission"); + crate::psd_gate::set_active_attempt_budget(1000); + let yes = || true; + let cancelled = Interrupt::cancel_only(&yes); + match tiff_admission(100, &crate::ImportOptions::default(), &cancelled) { + Err(IoError::Cancelled) => {} + other => panic!("expected a cancelled nested admission, got {:?}", other.map(|_| ())), + } + assert_eq!(crate::psd_gate::active_attempt_remaining(), Some(1000), "a cancelled nested import must not consume the parent's budget"); + } + + /// A nested TIFF import still enforces the caller's explicit bounds, before the parent's + /// allowance is charged; one within those bounds is charged exactly once. + #[test] + fn nested_tiff_admission_enforces_the_callers_local_bounds_before_charging_the_parent() { + let ctl = Interrupt::NONE; + let _parent = crate::psd_gate::acquire_decode_admission(&ctl).expect("parent admission"); + let ample = 1000u64; + crate::psd_gate::set_active_attempt_budget(ample); + + let lowered = crate::ImportOptions { psd_max_pixel_bytes: 99, ..Default::default() }; + match tiff_admission(100, &lowered, &ctl) { + Err(IoError::PsdImportBudget { required, limit }) => assert_eq!((required, limit), (100, 99)), + other => panic!("expected the caller's limit to refuse, got {:?}", other.map(|_| ())), + } + assert_eq!(crate::psd_gate::active_attempt_remaining(), Some(ample), "a refused nested import leaves the parent's allowance untouched"); + + let stale = crate::ImportOptions { psd_high_memory_approved: true, psd_approved_estimate: Some(99), ..Default::default() }; + match tiff_admission(100, &stale, &ctl) { + Err(IoError::PsdImportBudget { required, limit }) => assert_eq!((required, limit), (100, 99)), + other => panic!("expected the approved estimate bound to refuse, got {:?}", other.map(|_| ())), + } + assert_eq!(crate::psd_gate::active_attempt_remaining(), Some(ample), "a refused nested import leaves the parent's allowance untouched"); + + let allowed = crate::ImportOptions { psd_max_pixel_bytes: 100, psd_high_memory_approved: true, psd_approved_estimate: Some(100), ..Default::default() }; + let guard = tiff_admission(100, &allowed, &ctl).expect("within the caller's bounds"); + assert_eq!(crate::psd_gate::active_attempt_remaining(), Some(ample - 100), "the nested import is charged once"); + drop(guard); + assert_eq!(crate::psd_gate::active_attempt_remaining(), Some(ample - 100), "releasing the nested guard neither refunds nor resets the parent"); + } + + /// A layered TIFF estimate that overflowed `u64` saturates at `u64::MAX`. Unknown native + /// memory has `u64::MAX` as its hard limit, so approval used to admit the sentinel; it is + /// refused before the gate and before any composite is decoded. + #[test] + fn a_saturated_tiff_estimate_is_refused_before_any_decode() { + if cfg!(target_pointer_width = "32") { + return; + } + crate::memory::with_mock_memory(crate::memory::MemorySnapshot::UNKNOWN, || { + for opts in [ + crate::ImportOptions::default(), + crate::ImportOptions { psd_high_memory_approved: true, ..Default::default() }, + crate::ImportOptions { psd_high_memory_approved: true, psd_approved_estimate: Some(u64::MAX), ..Default::default() }, + ] { + match tiff_admission(u64::MAX, &opts, &Interrupt::NONE) { + Err(IoError::PsdImportBudget { required, limit }) => assert_eq!((required, limit), (u64::MAX, u64::MAX)), + other => panic!("expected a hard refusal for {opts:?}, got {:?}", other.map(|_| ())), + } + } + }); + } } diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 5c30961673..e52a808b47 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -89,8 +89,8 @@ use photocraft_psd::{PsdError, PsdFile}; pub use adjust_map::ADJUSTMENT_KEYS; pub use flat::{ - DEFAULT_LOSSY_WEBP_QUALITY, apply_file_encoding, document_to_image, file_encoding, import_gif_frames, import_tiff_page, import_webp_frames, - tga_alpha_channel_to_transparency, tga_default_bits, + DEFAULT_LOSSY_WEBP_QUALITY, apply_file_encoding, document_to_image, file_encoding, import_gif_frames, import_tiff_page, import_tiff_page_with_options, + import_webp_frames, tga_alpha_channel_to_transparency, tga_default_bits, }; pub use psd_export::{PsdExportOptions, document_to_psd, document_to_psd_with}; pub use psd_import::{psd_to_document, psd_to_document_with}; @@ -122,6 +122,10 @@ pub enum IoError { "PSD import needs an estimated {required} bytes of pixel memory (soft limit {soft_limit} bytes, hard limit {hard_limit} bytes); explicit approval required" )] PsdApprovalRequired { required: u64, soft_limit: u64, hard_limit: u64, available_ram: Option }, + /// The file behind the opened handle is not the one whose memory estimate was approved: it + /// was replaced, rewritten or resized since it was checked. Nothing was decoded. + #[error("the file changed since it was checked; approve it again to open the new file")] + SourceChanged, /// A pixel buffer could not be allocated while reading a file: the file may be fine, but /// this machine has no memory for it. #[error("not enough memory: {0}")] @@ -166,6 +170,68 @@ pub struct ImportResult { pub preview_only: bool, } +/// Identity of the file behind an opened handle, read from that handle. +/// +/// A one-shot high-memory approval is bound to a file, not to a path: the importer opens the +/// path once, takes this identity from the handle and refuses to decode unless it is the file the +/// estimate was approved for (`import_file_for_open_verified`). The Unix form includes the +/// device, inode, modification time and change time with nanoseconds, so a same-size rewrite +/// cannot hide behind a restored modification timestamp. The identity fails closed when any of +/// these cannot be read (see [`Self::of_handle`]). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct FileIdentity { + /// File length in bytes. + pub len: u64, + /// Last modification time. + pub modified: std::time::SystemTime, + /// Device holding the file (Unix). + #[cfg(unix)] + pub dev: u64, + /// Inode of the file (Unix). + #[cfg(unix)] + pub ino: u64, + /// Inode change time, seconds since the Unix epoch (Unix). + #[cfg(unix)] + pub ctime_secs: i64, + /// Nanosecond part of [`Self::ctime_secs`] (Unix). + #[cfg(unix)] + pub ctime_nanos: i64, +} + +impl FileIdentity { + /// Identity of an open file handle. + /// + /// Reads the handle's own metadata, never the path, so replacing the path cannot change what + /// this describes. A metadata field that cannot be read is an error: callers must fail closed + /// rather than decode under an approval they cannot verify. + pub fn of_handle(file: &std::fs::File) -> std::io::Result { + Self::of_metadata(&file.metadata()?) + } + + /// Identity of the file currently at `path`. + /// + /// For discovery only (a fingerprint kept while an estimate is made). The decode path must + /// open the file and compare the identity of that handle, since the path can change between + /// this call and the open. + pub fn of_path(path: &std::path::Path) -> std::io::Result { + Self::of_metadata(&std::fs::metadata(path)?) + } + + fn of_metadata(metadata: &std::fs::Metadata) -> std::io::Result { + let len = metadata.len(); + let modified = metadata.modified()?; + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + Ok(Self { len, modified, dev: metadata.dev(), ino: metadata.ino(), ctime_secs: metadata.ctime(), ctime_nanos: metadata.ctime_nsec() }) + } + #[cfg(not(unix))] + { + Ok(Self { len, modified }) + } + } +} + /// The import note for a file whose horizontal and vertical resolutions (`x`, `y`, in pixels per /// inch) differ: a document has one resolution, so it keeps the horizontal one and saves it on /// both axes (#1018). Differences under 0.01 ppi are below what the warning can show. @@ -355,8 +421,10 @@ pub struct ImportOptions { pub max_group_depth: usize, /// The demosaic method camera raws are developed with. pub demosaic: raw::Demosaic, - /// PSD layer/mask/merged pixel allowance, including conversion workspace. May lower the - /// default cap; 0 uses the adaptive budget. Does not cover metadata or smart-filter caches. + /// PSD layer/mask/merged pixel allowance, including conversion workspace, and the + /// equivalent allowance for a layered TIFF's composite and retained decoded image. May + /// lower the default cap; 0 uses the adaptive budget. Does not cover metadata or + /// smart-filter caches. pub psd_max_pixel_bytes: u64, /// Explicit one-shot authorization for imports that exceed the soft budget but fit within the hard budget. pub psd_high_memory_approved: bool, @@ -433,9 +501,16 @@ fn import_psd( let est = psd_budget::estimate(file)?; if psd_gate::is_holding_admission() { - // Nested import: count against shared remaining allowance, never reset parent on nested import - psd_gate::consume_attempt_allowance(est.total_bytes)?; + // Nested import: it spends the enclosing attempt's remaining allowance and + // never resets or re-charges the parent. Cancellation is checked before the + // charge, and admission is reentrant here, so a cancelled or refused nested + // import leaves the parent's allowance untouched. The caller's explicit + // bounds (a lower allowance and a one-shot estimate) still apply before the + // parent is charged. + ctl.check().map_err(|_| IoError::Cancelled)?; + memory::nested_import_local_bound(est.total_bytes, opts.psd_max_pixel_bytes, opts.psd_approved_estimate)?; let guard = psd_gate::acquire_decode_admission(ctl)?; + psd_gate::consume_attempt_allowance(est.total_bytes)?; *admission_guard.borrow_mut() = Some(guard); return Ok(psd_import::merged_needed(file)); } @@ -518,18 +593,72 @@ pub fn import_file_for_open_with( path: &std::path::Path, opts: &ImportOptions, ctl: &photocraft_raster::Interrupt, +) -> Result<(ImportResult, bool), IoError> { + let mut identity = None; + import_file_for_open_verified(name, path, None, opts, ctl, &mut identity) +} + +/// [`import_file_for_open_with`] bound to the identity of the file handle it decodes. +/// +/// The path is opened exactly once. `identity_out` is set from that handle before any pixel is +/// read; a PSD/PSB is then streamed from the same handle (not read whole), anything else is read +/// from it within `photocraft_format::MAX_OPEN_BYTES`. When `expected` is given, the import +/// refuses with [`IoError::SourceChanged`] unless the opened file is that one. When the parse +/// stops for a high-memory approval, the identity is read again from a duplicate of the same +/// handle: a file rewritten in place while its estimate was checked is refused, so the approval +/// always refers to the bytes that will be decoded. +/// +/// A missing or unreadable metadata field fails the open instead of decoding under an identity +/// that cannot be verified. +#[cfg(any(unix, windows))] +pub fn import_file_for_open_verified( + name: &str, + path: &std::path::Path, + expected: Option<&FileIdentity>, + opts: &ImportOptions, + ctl: &photocraft_raster::Interrupt, + identity_out: &mut Option, ) -> Result<(ImportResult, bool), IoError> { let read_failed = |e: std::io::Error| IoError::Read(format!("{}: {e}", path.display())); ctl.check().map_err(|_| IoError::Cancelled)?; let source = photocraft_psd::FileSource::open(path).map_err(read_failed)?; + let metadata = source.file().metadata().map_err(read_failed)?; + if !metadata.is_file() { + return Err(IoError::Read(format!("{}: not a regular file", path.display()))); + } + let identity = FileIdentity::of_metadata(&metadata).map_err(read_failed)?; + *identity_out = Some(identity.clone()); + if expected.is_some_and(|expected| expected != &identity) { + return Err(IoError::SourceChanged); + } + // A duplicate of the same open file (not a fresh path lookup): the approval pause re-reads + // the identity from it after parsing, and never from a path that may point elsewhere now. + let watch = source.file().try_clone().map_err(read_failed)?; let mut magic = [0u8; 4]; let psd = !has_extension(name, photocraft_format::EXTENSION) && photocraft_psd::ReadAt::read_at(&source, 0, &mut magic).is_ok() && is_psd(&magic); - if !psd { - drop(source); - let bytes = photocraft_format::read_file(path).map_err(read_failed)?; - return import_for_open_with(name, &bytes, opts, ctl); + let result = if psd { + import_source_with_options(name, photocraft_psd::Source::new(source), ctl, opts) + } else { + // Read from the handle that was just identified, not by opening the path again. + let bytes = { + let mut file = source.file(); + // Windows seek_read advances this handle during the format probe. + // Rewind the same verified handle before reading a non-PSD file. + std::io::Seek::rewind(&mut file).map_err(read_failed)?; + photocraft_format::read::read_all(&mut file, photocraft_psd::ReadAt::len(&source)).map_err(read_failed)? + }; + import_for_open_with(name, &bytes, opts, ctl) + }; + // An approval pause must describe a file that still has the identity it was estimated with; + // an approved resume must have decoded that file, not a rewrite that raced the decode. + if expected.is_some() || matches!(&result, Err(IoError::PsdApprovalRequired { .. })) { + let after = FileIdentity::of_handle(&watch).map_err(read_failed)?; + if after != identity { + return Err(IoError::SourceChanged); + } + *identity_out = Some(after); } - import_source_with_options(name, photocraft_psd::Source::new(source), ctl, opts) + result } /// [`import_file_for_open_with`] for a PSD/PSB in any [`photocraft_psd::ReadAt`] source. @@ -615,7 +744,7 @@ fn import_stages(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt, o if is_psd_input(name, bytes) { return import_psd(name, ctl, damaged, opts, |want, recover| PsdFile::from_bytes_and_merged_preflight(bytes, want, recover)); } - flat::import_flat_with_limit(name, bytes, opts.psd_max_pixel_bytes) + flat::import_flat_with_options(name, bytes, opts, ctl) } /// Extension names can be wrong (for example, an image downloaded as WebP and renamed `.svg`). @@ -884,6 +1013,53 @@ mod tests { assert!(!result.source_read_only); } + /// A nested PSD import still enforces the caller's explicit bounds before it spends the + /// parent's allowance, and one within those bounds is charged exactly once. + #[cfg(not(any(target_arch = "wasm32", target_pointer_width = "32")))] + #[test] + fn nested_psd_import_enforces_the_callers_local_bounds_before_charging_the_parent() { + let gb = 1u64 << 30; + let bytes = testgen::small(Version::Psd, Compression::Raw).to_bytes().unwrap(); + let ctl = photocraft_raster::Interrupt::NONE; + let ample = 16 * gb; + let _parent = super::psd_gate::acquire_decode_admission(&ctl).expect("parent admission"); + + // The tiny fixture plus a thread-local charge lands in the approval band; nothing near a + // gigabyte is allocated. + super::psd_budget::with_test_estimate_charge(5 * gb, || { + // What the real nested import is charged, learned from one admitted run. + super::psd_gate::set_active_attempt_budget(ample); + super::import_with_options_and_interrupt("nested.psd", &bytes, &ImportOptions::default(), &ctl).expect("an admitted nested import"); + let required = ample - super::psd_gate::active_attempt_remaining().expect("the parent attempt keeps an allowance"); + assert!(required > 5 * gb, "the test charge is part of the estimate: {required}"); + + let cases = [ + (ImportOptions { psd_max_pixel_bytes: required - 1, ..Default::default() }, "the caller's lower allowance"), + ( + ImportOptions { psd_high_memory_approved: true, psd_approved_estimate: Some(required - 1), ..Default::default() }, + "the one-shot approved estimate", + ), + ]; + for (opts, what) in cases { + super::psd_gate::set_active_attempt_budget(ample); + match super::import_with_options_and_interrupt("nested.psd", &bytes, &opts, &ctl) { + Err(super::IoError::PsdImportBudget { required: r, limit }) => { + assert_eq!((r, limit), (required, required - 1), "{what} must refuse with its exact fields") + } + other => panic!("expected {what} to refuse, got {:?}", other.map(|_| ())), + } + assert_eq!(super::psd_gate::active_attempt_remaining(), Some(ample), "{what} must not spend the parent's allowance"); + } + + super::psd_gate::set_active_attempt_budget(ample); + let bounded = + ImportOptions { psd_max_pixel_bytes: required, psd_high_memory_approved: true, psd_approved_estimate: Some(required), ..Default::default() }; + let opened = super::import_with_options_and_interrupt("nested.psd", &bytes, &bounded, &ctl).expect("within the caller's bounds"); + assert!(opened.document.size.area() > 0, "the bounded nested import really opened a document"); + assert_eq!(super::psd_gate::active_attempt_remaining(), Some(ample - required), "the nested import is charged once"); + }); + } + #[test] fn svg_extension_does_not_override_another_format_signature() { let webp_header = b"RIFF\x04\0\0\0WEBP"; @@ -909,6 +1085,68 @@ mod tests { assert!(!super::is_psd(b"\0\xffKA")); } + #[cfg(any(unix, windows))] + #[test] + fn verified_path_import_reads_a_flat_file_from_its_start() { + let dir = std::env::temp_dir().join(format!("photocraft-verified-png-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("verified.png"); + let image = photocraft_codecs::Image::from_u8(2, 3, photocraft_codecs::ChannelLayout::Rgb, vec![64; 18]).unwrap(); + let bytes = photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).unwrap(); + std::fs::write(&path, bytes).unwrap(); + let mut identity = None; + let (opened, damaged) = + super::import_file_for_open_verified("verified.png", &path, None, &ImportOptions::default(), &photocraft_raster::Interrupt::NONE, &mut identity) + .unwrap(); + assert_eq!((opened.document.size.width, opened.document.size.height), (2, 3)); + assert!(!damaged); + assert_eq!(identity, Some(super::FileIdentity::of_path(&path).unwrap())); + std::fs::remove_dir_all(dir).unwrap(); + } + + /// The identity a verified open returns is the opened handle's, and a different inode with + /// identical content is refused with the dedicated reason before any pixel is decoded. + #[cfg(unix)] + #[test] + fn verified_import_binds_the_decoded_handle_identity() { + let dir = std::env::temp_dir().join(format!("photocraft-io-verified-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("source.psd"); + let bytes = testgen::small(Version::Psd, Compression::Raw).to_bytes().unwrap(); + std::fs::write(&path, &bytes).unwrap(); + let expected = super::FileIdentity::of_path(&path).unwrap(); + let mut opened = None; + let (result, _) = super::import_file_for_open_verified( + "source.psd", + &path, + Some(&expected), + &ImportOptions::default(), + &photocraft_raster::Interrupt::NONE, + &mut opened, + ) + .unwrap(); + assert!(!result.document.layers.is_empty()); + assert_eq!(opened.as_ref(), Some(&expected), "the identity comes from the decoded handle"); + // Replacement with identical bytes but a different inode: not the approved file. + let replacement = dir.join("replacement.psd"); + std::fs::write(&replacement, &bytes).unwrap(); + std::fs::rename(&replacement, &path).unwrap(); + let mut opened = None; + let err = super::import_file_for_open_verified( + "source.psd", + &path, + Some(&expected), + &ImportOptions::default(), + &photocraft_raster::Interrupt::NONE, + &mut opened, + ) + .expect_err("the path now names another file"); + assert!(matches!(&err, super::IoError::SourceChanged), "got {err:?}"); + assert!(opened.is_some(), "the refused handle's identity is still reported"); + let _ = std::fs::remove_dir_all(&dir); + } + /// A common mislabeled download: PNG/JPEG bytes named `.psd` open from the signature, /// as on main and in Photoshop. #[test] diff --git a/crates/io/src/memory.rs b/crates/io/src/memory.rs index 55edaa0865..8f1f191424 100644 --- a/crates/io/src/memory.rs +++ b/crates/io/src/memory.rs @@ -8,18 +8,69 @@ use crate::IoError; /// Memory left free beside what an import needs: for the app, its display and the system. pub const RESERVE: u64 = 512 << 20; -/// Default allowance for native platforms when available memory is unknown (no override allowed). +/// Automatic allowance for native platforms when available memory is unknown. +/// +/// Estimates above it need explicit per-invocation approval, up to the platform's aggregate +/// admission limit (see [`native_platform_cap`]). This is a conservative automatic admission +/// threshold, not a hard ceiling and not a per-buffer allocation bound. pub const NATIVE_FALLBACK_BUDGET: u64 = 2 << 30; -/// Maximum allowance for WebAssembly execution. +/// Automatic allowance for WebAssembly execution. +/// +/// Estimates above it need explicit per-invocation approval, up to +/// [`WASM_MAX_PEAK_BUDGET`]. This is a conservative product threshold, not an assertion that +/// the browser can allocate that much. pub const WASM_BUDGET: u64 = 1 << 30; -/// Upper ceiling on the automatic soft import budget on desktop platforms. +/// Upper ceiling on the automatic soft import budget when available memory is known. pub const MAX_SOFT_BUDGET: u64 = 8 << 30; +/// Estimate above which an import made with unknown available memory carries an extra warning. +/// +/// It is a warning threshold, not a hard ceiling: explicit approval remains possible up to the +/// platform's actual allocation limits, but the process may run out of memory. +pub const UNKNOWN_MEMORY_WARNING_THRESHOLD: u64 = 8 << 30; + /// Reserve subtracted from commit headroom when known. pub const COMMIT_RESERVE: u64 = 512 << 20; +/// Bytes Rust's layout rules allow for one contiguous allocation on this target +/// (`isize::MAX`), not a bound on an import's aggregate peak. +/// +/// A layer plane, a decode buffer or a tile is one contiguous allocation, so each must fit this +/// limit at the point it is allocated. An import's peak is the *sum* of many such buffers and +/// may legitimately exceed this; decode paths enforce their own per-buffer limits +/// (`photocraft-psd::MAX_DECODED_BYTES`, `photocraft-codecs`' `Limits::max_alloc`) there. +pub const MAX_SINGLE_ALLOCATION: u64 = isize::MAX as u64; + +/// Architectural maximum linear memory of wasm32: 65,536 pages of 64 KiB (4 GiB). +/// +/// This is the whole address space of one wasm32 instance, shared with the resident document, +/// history, decoder buffers, the allocator, the stack and the runtime. It is an address-space +/// bound, not a promise that 4 GiB can be allocated; browsers may cap linear memory lower. +pub const WASM_LINEAR_MEMORY_LIMIT: u64 = 4u64 << 30; + +/// Largest single Rust allocation on wasm32 (`isize::MAX`): 2 GiB - 1. +/// +/// A per-buffer bound only. Aggregate admission on web uses [`WASM_MAX_PEAK_BUDGET`]; the layout +/// limit itself rejects a larger single buffer, and decoders may apply a tighter cap. +pub const WASM_MAX_SINGLE_ALLOCATION: u64 = (1u64 << 31) - 1; + +/// Conservative product cap for the aggregate peak of one web import: 2 GiB. +/// +/// Chosen by this project, not dictated by the architecture: it sits below both the 4 GiB +/// wasm32 linear-memory limit (leaving room for the runtime, stack, allocator metadata and the +/// resident document) and is reachable only through explicit approval above [`WASM_BUDGET`]. +/// Browsers commonly allow less, and allocation may still fail. Do not read it as a guarantee. +pub const WASM_MAX_PEAK_BUDGET: u64 = 2 << 30; + +/// Conservative product cap for the aggregate peak of a 32-bit native process: 2 GiB. +/// +/// A 32-bit process has a 4 GiB address space, but the kernel/reserved range, runtime, stack, +/// allocator metadata, document and history share it. This is a product choice well below the +/// architectural limit, not an architectural bound. +pub const NATIVE_32BIT_PEAK_CAP: u64 = 2 << 30; + /// A snapshot of system memory availability, distinguishing physical RAM from swap and commit headroom. #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] pub struct MemorySnapshot { @@ -35,6 +86,10 @@ pub struct MemorySnapshot { #[cfg(any(test, feature = "testgen"))] thread_local! { static MOCK_SNAPSHOT: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; + /// A per-attempt provider consulted on every query, unlike the fixed [`MOCK_SNAPSHOT`]. + /// Tests use it to change the measured capacity between admission checks from another + /// thread; it is thread-local, so it never affects unrelated parallel tests. + static MOCK_PROVIDER: std::cell::RefCell MemorySnapshot>>> = const { std::cell::RefCell::new(None) }; } impl MemorySnapshot { @@ -45,6 +100,9 @@ impl MemorySnapshot { pub fn current() -> Self { #[cfg(any(test, feature = "testgen"))] { + if let Some(provider) = MOCK_PROVIDER.with(|p| p.borrow().clone()) { + return provider(); + } if let Some(mock) = MOCK_SNAPSHOT.with(|m| *m.borrow()) { return mock; } @@ -58,7 +116,11 @@ impl MemorySnapshot { { windows_mem::snapshot() } - #[cfg(not(any(target_os = "linux", windows)))] + #[cfg(target_os = "macos")] + { + macos_mem::snapshot() + } + #[cfg(not(any(target_os = "linux", windows, target_os = "macos")))] { fallback_mem::snapshot() } @@ -74,6 +136,9 @@ pub struct MemoryBudget { pub hard_limit: u64, /// Currently available physical RAM from the snapshot, if known. pub available_ram: Option, + /// Set when available memory is unknown: estimates above this threshold still need explicit + /// approval but also carry the uncertain-capacity warning. `None` when capacity is known. + pub warning_threshold: Option, } /// Admission decision for a given required memory estimate. @@ -83,13 +148,22 @@ pub enum BudgetDecision { Automatic, /// Soft limit < estimated memory <= hard limit: requires explicit user/caller confirmation. RequiresApproval, - /// Estimated memory > hard limit: refused. + /// Estimated memory > hard limit, or the saturated `u64::MAX` overflow sentinel: refused. Refused, } impl MemoryBudget { /// Determines the admission decision for an estimated requirement in bytes. + /// + /// `u64::MAX` is the sentinel a format estimate produces when a size overflowed `u64` + /// (`photocraft-io`'s TIFF estimates saturate there rather than wrap). It is not a real + /// requirement, so it is always refused, even when the hard limit is itself `u64::MAX` (the + /// native unknown-memory cap) and even with explicit approval or a matching one-shot + /// estimate. The refusal keeps the estimate and limit as they are; no value is invented. pub fn decide(&self, required: u64) -> BudgetDecision { + if required == u64::MAX { + return BudgetDecision::Refused; + } if required <= self.soft_limit { BudgetDecision::Automatic } else if required <= self.hard_limit { @@ -98,30 +172,80 @@ impl MemoryBudget { BudgetDecision::Refused } } + + /// Whether an approvable estimate also needs the uncertain-capacity warning. + /// + /// This is only meaningful together with [`BudgetDecision::RequiresApproval`]; it says the + /// budget came from the unknown-memory fallback and the estimate is above + /// [`UNKNOWN_MEMORY_WARNING_THRESHOLD`]. + pub fn requires_uncertainty_warning(&self, required: u64) -> bool { + self.warning_threshold.is_some_and(|threshold| required > threshold) + } +} + +/// Whether an approval-required estimate carries the unknown-capacity warning, given the +/// capacity measured when the estimate was made. +/// +/// A known capacity never carries it (a measured number is reported instead of a guess); when +/// capacity is unknown, an estimate above [`UNKNOWN_MEMORY_WARNING_THRESHOLD`] is still +/// approvable but says the process or tab may run out of memory. Kept separate from +/// [`MemoryBudget`] so a pending approval can carry the flag across a later, fresh measurement. +pub fn uncertain_capacity_warning(required: u64, available_ram: Option) -> bool { + available_ram.is_none() && required > UNKNOWN_MEMORY_WARNING_THRESHOLD +} + +/// Which platform policy to resolve. Tests use this to exercise the web policy on any host. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Platform { + Native, + Web, +} + +fn active_platform() -> Platform { + if cfg!(target_arch = "wasm32") { Platform::Web } else { Platform::Native } +} + +/// Aggregate address-space bound for native admission (not a per-buffer allocation bound). +/// +/// A 64-bit process has no practical aggregate address-space limit measurable through safe +/// standard APIs, so this is `u64::MAX`; per-buffer and format limits are enforced where buffers +/// are actually allocated. A 32-bit process uses [`NATIVE_32BIT_PEAK_CAP`]. +pub fn native_platform_cap() -> u64 { + if cfg!(target_pointer_width = "32") { NATIVE_32BIT_PEAK_CAP } else { u64::MAX } } /// Resolves the adaptive hard and soft import budgets from a snapshot and an optional caller limit. /// +/// The limits are for an import's aggregate peak estimate, not for one allocation: a peak may sum +/// many buffers, each of which is separately bounded where the decoder allocates it. +/// /// Policy: -/// - Hard H = min(floor(3*A/4), saturating A - 1 GiB, C - reserve if known, platform/address-space limits) -/// - Soft S = min(50% A, 8 GiB, H) +/// - Known RAM: H = min(floor(3*A/4), A - 1 GiB, C - reserve if known, [`native_platform_cap`]); +/// S = min(A/2, 8 GiB, H). +/// - Unknown native RAM: S = min(2 GiB, H) where H is [`native_platform_cap`], reduced by known +/// commit headroom; approval above S is possible up to H. 8 GiB is a warning threshold, not H. +/// - Web (wasm32): S = 1 GiB; H = min([`WASM_MAX_PEAK_BUDGET`], caller limit). /// - <= S: automatic; S < E <= H: explicit approval; > H: refuse, no unlimited override. -/// - Unknown native fallback: 2 GiB bounded by C - reserve if known (S = H). -/// - Browser/wasm: fixed 1 GiB (S = H = 1 GiB). +/// - The saturated `u64::MAX` overflow sentinel is always refused, even when H is `u64::MAX` +/// (see [`MemoryBudget::decide`]). +/// - Known zero available RAM stays zero (it never becomes unknown/free). +/// - swap is never part of the adaptive budget. /// - Explicit caller limits may lower admission, never bypass the hard boundary. pub fn resolve_budget(snapshot: MemorySnapshot, caller_limit: Option) -> MemoryBudget { - if cfg!(target_arch = "wasm32") { - let mut hard = WASM_BUDGET; + resolve_budget_for(active_platform(), snapshot, caller_limit) +} + +fn resolve_budget_for(platform: Platform, snapshot: MemorySnapshot, caller_limit: Option) -> MemoryBudget { + if platform == Platform::Web { + let mut hard = WASM_MAX_PEAK_BUDGET; if let Some(caller) = caller_limit.filter(|&c| c > 0) { hard = hard.min(caller); } - let soft = hard; - return MemoryBudget { soft_limit: soft, hard_limit: hard, available_ram: None }; + return MemoryBudget { soft_limit: WASM_BUDGET.min(hard), hard_limit: hard, available_ram: None, warning_threshold: None }; } - let address_space_cap = if cfg!(target_pointer_width = "32") { 2 << 30 } else { u64::MAX }; - - let (mut hard, mut soft) = match snapshot.available_ram { + let platform_cap = native_platform_cap(); + let (mut hard, mut soft, warning_threshold) = match snapshot.available_ram { Some(a) => { let h_pct = ((a as u128 * 3) / 4) as u64; let h_reserve = a.saturating_sub(1 << 30); @@ -132,19 +256,20 @@ pub fn resolve_budget(snapshot: MemorySnapshot, caller_limit: Option) -> Me h = h.min(c_room); } - h = h.min(address_space_cap); + h = h.min(platform_cap); let s_pct = a / 2; let s = s_pct.min(MAX_SOFT_BUDGET).min(h); - (h, s) + (h, s, None) } None => { - let mut limit = NATIVE_FALLBACK_BUDGET.min(address_space_cap); + let mut h = platform_cap; if let Some(c) = snapshot.commit_headroom { let c_room = c.saturating_sub(COMMIT_RESERVE); - limit = limit.min(c_room); + h = h.min(c_room); } - (limit, limit) + let s = NATIVE_FALLBACK_BUDGET.min(h); + (h, s, Some(UNKNOWN_MEMORY_WARNING_THRESHOLD)) } }; @@ -153,17 +278,9 @@ pub fn resolve_budget(snapshot: MemorySnapshot, caller_limit: Option) -> Me soft = soft.min(hard); } - MemoryBudget { soft_limit: soft, hard_limit: hard, available_ram: snapshot.available_ram } + MemoryBudget { soft_limit: soft, hard_limit: hard, available_ram: snapshot.available_ram, warning_threshold } } -/// Central evaluation of PSD admission against adaptive budgets and options. -/// -/// Refuses before decoding if: -/// - `required > hard_limit` (regardless of approvals: hard ceiling cannot be overridden); -/// - `required > soft_limit` and high memory is not approved; -/// - high memory is approved, but `approved_estimate` is specified and `required > approved_estimate` -/// (preventing unbounded approval for altered/enlarged files). -/// /// Result of evaluating an import admission request. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct AdmissionOutcome { @@ -173,15 +290,34 @@ pub struct AdmissionOutcome { pub authorized_total: u64, } +/// Central evaluation of PSD admission against adaptive budgets and options. +/// +/// Refuses before decoding if: +/// - `required` is the saturated `u64::MAX` overflow sentinel (see [`MemoryBudget::decide`]); +/// - `required > hard_limit` (regardless of approvals: hard ceiling cannot be overridden); +/// - `required > soft_limit` and high memory is not approved; +/// - high memory is approved, but `approved_estimate` is specified and `required > approved_estimate` +/// (preventing unbounded approval for altered/enlarged files). pub(crate) fn evaluate_psd_admission( required: u64, caller_limit: u64, high_memory_approved: bool, approved_estimate: Option, snapshot: MemorySnapshot, +) -> Result { + evaluate_psd_admission_for(active_platform(), required, caller_limit, high_memory_approved, approved_estimate, snapshot) +} + +fn evaluate_psd_admission_for( + platform: Platform, + required: u64, + caller_limit: u64, + high_memory_approved: bool, + approved_estimate: Option, + snapshot: MemorySnapshot, ) -> Result { let limit_opt = (caller_limit > 0).then_some(caller_limit); - let budget = resolve_budget(snapshot, limit_opt); + let budget = resolve_budget_for(platform, snapshot, limit_opt); let authorized_total = match budget.decide(required) { BudgetDecision::Automatic => { @@ -226,11 +362,41 @@ pub(crate) fn evaluate_psd_admission( Ok(AdmissionOutcome { budget, authorized_total }) } +/// Applies a caller's explicit local bounds to a nested decode. +/// +/// A nested import spends the enclosing attempt's remaining allowance: it never resolves an +/// independent adaptive budget and never resets the parent's. The caller's explicit bounds still +/// apply, because they limit this attempt rather than describe a second budget: +/// `caller_limit > 0` is the lower caller allowance, and `approved_estimate` (when present) is the +/// estimate one-shot consent was given for. Both are checked before the parent is charged, so a +/// refusal leaves its remaining allowance untouched, and the tighter bound is the one reported. +/// +/// A nested attempt cannot ask for fresh consent, so exceeding either bound is a hard refusal +/// ([`IoError::PsdImportBudget`], the form the outer caller limit already uses) rather than a new +/// approval request. +/// +/// The saturated `u64::MAX` overflow sentinel is not covered here (there is no meaningful limit to +/// report when no local bound is set); [`crate::psd_gate::consume_attempt_allowance`] refuses it +/// before the parent is charged. +pub(crate) fn nested_import_local_bound(required: u64, caller_limit: u64, approved_estimate: Option) -> Result<(), IoError> { + let caller = (caller_limit > 0).then_some(caller_limit); + let limit = match (caller, approved_estimate) { + (Some(caller), Some(approved)) => Some(caller.min(approved)), + (caller, approved) => caller.or(approved), + }; + match limit { + Some(limit) if required > limit => Err(IoError::PsdImportBudget { required, limit }), + _ => Ok(()), + } +} + /// Bytes this process can still use without running out, or `None` where that isn't known. On -/// Linux: the free RAM the kernel can hand out (`MemAvailable`, capped by the process's cgroup -/// v2 `memory.max` and those above it) plus free swap (capped by the cgroup's `memory.swap.max` and those above it). -/// Elsewhere `None`: Windows refuses an allocation past its commit limit, which the importers -/// already report as an error, and macOS pages to disk as it needs. +/// Linux: the free RAM the kernel can hand out (`MemAvailable`, capped by the process's memory +/// cgroup — v2 `memory.max` or v1 `memory.limit_in_bytes`/`memsw` — and those above it) plus free +/// swap (capped by the cgroup's swap limits). Elsewhere `None`: Windows refuses an allocation past +/// its commit limit, which the importers already report as an error, and macOS pages to disk as it +/// needs. macOS's physical-memory provider feeds the adaptive budget, not this legacy +/// RAM-plus-swap helper; flat and layered TIFF conversion keep their existing behaviour. pub fn available() -> Option { #[cfg(target_os = "linux")] { @@ -287,31 +453,73 @@ pub fn with_mock_memory(snapshot: MemorySnapshot, f: impl FnOnce() -> R) -> R f() } -/// Explicit test-support capture/scope object to propagate a thread-local mock -/// snapshot across worker threads without modifying global process state. +/// Runs a closure with a per-attempt memory provider on the current thread. +/// +/// Unlike [`with_mock_memory`], the provider is consulted for every +/// [`MemorySnapshot::current`], so a test can change the measured capacity while an import is +/// waiting on the decode gate and observe the post-gate recheck. The provider is thread-local +/// and restored on return or panic, so it never overrides another test's measurement. +#[cfg(any(test, feature = "testgen"))] +pub fn with_mock_memory_provider(provider: std::rc::Rc MemorySnapshot>, f: impl FnOnce() -> R) -> R { + let prev = MOCK_PROVIDER.with(|p| p.borrow_mut().replace(provider)); + + struct Reset { + prev: Option MemorySnapshot>>, + } + impl Drop for Reset { + fn drop(&mut self) { + MOCK_PROVIDER.with(|p| *p.borrow_mut() = self.prev.take()); + } + } + + let _reset = Reset { prev }; + f() +} + +/// Explicit test-support capture/scope object to propagate the thread-local fixed mock snapshot +/// and the scoped PSD estimate charge across worker threads without modifying global process +/// state. +/// +/// It captures `set_mock_memory`/`with_mock_memory`'s fixed snapshot and +/// [`crate::psd_budget::with_test_estimate_charge`]'s extra charge, so an engine worker that +/// enters the scope makes the same admission estimates as the test thread. The per-attempt +/// provider set by `with_mock_memory_provider` is not captured: that provider is a non-`Send` +/// closure re-consulted on every [`MemorySnapshot::current`], and propagating it to a worker +/// needs an explicitly shared, `Send + Sync` scope designed for that purpose (follow-up work). +/// A worker an import starts therefore sees the fixed snapshot, not a live provider. +/// +/// Capturing is explicit: nothing here changes a thread that never enters the scope, and no +/// global state is written. /// /// In production, this is an inert zero-overhead type. #[derive(Clone, Copy, Debug, Default)] pub struct TestMemoryScope { #[cfg(any(test, feature = "testgen"))] mock: Option, + /// The estimate charge captured with the scope (zero when the test set none). + #[cfg(any(test, feature = "testgen"))] + estimate_charge: u64, } -/// RAII guard that restores the previous thread-local mock memory upon drop. +/// RAII guard that restores the previous thread-local mock memory and estimate charge upon drop. pub struct TestMemoryGuard { #[cfg(any(test, feature = "testgen"))] prev: Option, + #[cfg(any(test, feature = "testgen"))] + prev_charge: u64, } #[cfg(any(test, feature = "testgen"))] impl Drop for TestMemoryGuard { fn drop(&mut self) { MOCK_SNAPSHOT.with(|m| *m.borrow_mut() = self.prev); + crate::psd_budget::enter_test_estimate_charge(self.prev_charge); } } impl TestMemoryScope { - /// Captures the current thread's mock memory snapshot, if any. + /// Captures the current thread's fixed mock memory snapshot and estimate charge, if any (see + /// [`TestMemoryScope`] for what is not captured). /// /// Never captures actual system RAM as a permanent snapshot so that /// production memory rechecks remain fresh. @@ -319,7 +527,7 @@ impl TestMemoryScope { #[cfg(any(test, feature = "testgen"))] { let mock = MOCK_SNAPSHOT.with(|m| *m.borrow()); - Self { mock } + Self { mock, estimate_charge: crate::psd_budget::test_estimate_charge() } } #[cfg(not(any(test, feature = "testgen")))] { @@ -328,7 +536,7 @@ impl TestMemoryScope { } /// Enters this captured mock memory scope, returning a RAII guard that - /// restores the thread's previous mock state on drop. + /// restores the thread's previous mock state and estimate charge on drop. pub fn enter(self) -> TestMemoryGuard { #[cfg(any(test, feature = "testgen"))] { @@ -337,7 +545,8 @@ impl TestMemoryScope { *m.borrow_mut() = self.mock; old }); - TestMemoryGuard { prev } + let prev_charge = crate::psd_budget::enter_test_estimate_charge(self.estimate_charge); + TestMemoryGuard { prev, prev_charge } } #[cfg(not(any(test, feature = "testgen")))] { @@ -357,20 +566,110 @@ pub(super) mod linux { use super::MemorySnapshot; use std::path::{Path, PathBuf}; + /// cgroup v1 reports `PAGE_COUNTER_MAX * page_size` for "no limit", which is always close to + /// `i64::MAX`. Any limit at or above 4 EiB is treated as unlimited. + const V1_UNLIMITED: u64 = 1 << 62; + + /// Bound on ancestor walks; cgroup hierarchies are far shallower than this. + const MAX_CGROUP_DEPTH: usize = 128; + + #[derive(Clone, Debug, PartialEq, Eq)] + enum Limit { + Missing, + Unlimited, + Value(u64), + Invalid, + } + + /// What the process's memory cgroup says, independent of host `/proc/meminfo`. + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + pub(super) enum CgroupLimits { + /// No memory controller in this process's hierarchy: host memory applies. + NotConstrained, + /// Headroom under the memory controller; `None` means that dimension is unlimited. + Limited { ram: Option, swap: Option }, + /// A memory controller exists but its mount, path or limit could not be read. Callers + /// must fail closed instead of falling back to unrestricted host memory. + Unreadable, + } + + #[derive(Clone, Debug, PartialEq, Eq)] + struct CgroupDir { + leaf: PathBuf, + /// The visible root of the walk (the mount point). + root: PathBuf, + /// Whether `root` is the hierarchy's own root rather than a subtree exposed by a mount. + /// Only the hierarchy root is exempt from exposing its controller interface files. + hierarchy_root: bool, + } + + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + struct Membership<'a> { + /// Unified (v2) hierarchy path (`0::`). + v2: Option<&'a str>, + /// v1 hierarchy path for the `memory` controller. + v1_memory: Option<&'a str>, + } + + /// Result of reading `/proc/self/cgroup`. + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + enum MembershipParse<'a> { + Parsed(Membership<'a>), + /// A non-empty line could not be understood, so the absence of a memory controller cannot + /// be established. + Malformed, + } + + #[derive(Clone, Debug, PartialEq, Eq)] + struct Mount { + root: String, + mount_point: String, + fstype: String, + options: String, + super_options: String, + } + pub(super) fn snapshot() -> MemorySnapshot { - let Some(info) = std::fs::read_to_string("/proc/meminfo").ok() else { - return MemorySnapshot::UNKNOWN; + snapshot_at(Path::new("/proc"), Path::new("/sys/fs/cgroup")) + } + + /// Provider body with explicit roots, so tests exercise the real filesystem parsing without + /// global mocks. + pub(super) fn snapshot_at(proc_root: &Path, sys_root: &Path) -> MemorySnapshot { + let info = std::fs::read_to_string(proc_root.join("meminfo")).ok(); + let host_ram = info.as_deref().and_then(|i| meminfo(i, "MemAvailable:")); + let host_swap = info.as_deref().and_then(|i| meminfo(i, "SwapFree:")); + + let (cgroup_ram, cgroup_swap) = match cgroup_limits(proc_root, sys_root) { + CgroupLimits::NotConstrained => (None, None), + CgroupLimits::Limited { ram, swap } => (ram, swap), + CgroupLimits::Unreadable => return MemorySnapshot { available_ram: Some(0), swap_free: Some(0), commit_headroom: None }, + }; + + let available_ram = match (host_ram, cgroup_ram) { + (Some(host), Some(cgroup)) => Some(host.min(cgroup)), + (Some(host), None) => Some(host), + (None, Some(cgroup)) => Some(cgroup), + (None, None) => None, + }; + let swap_free = match (host_swap, cgroup_swap) { + (Some(host), Some(cgroup)) => Some(host.min(cgroup)), + (Some(host), None) => Some(host), + (None, Some(cgroup)) => Some(cgroup), + (None, None) => None, }; - let ram = meminfo(&info, "MemAvailable:"); - let swap = meminfo(&info, "SwapFree:"); - let (cgroup_ram, cgroup_swap) = cgroup_dir().as_deref().map_or((None, None), cgroup_room); - let available_ram = ram.map(|r| r.min(cgroup_ram.unwrap_or(u64::MAX))); - let swap_free = swap.map(|s| s.min(cgroup_swap.unwrap_or(u64::MAX))); MemorySnapshot { available_ram, swap_free, commit_headroom: None } } pub(super) fn available() -> Option { - let snap = snapshot(); + available_at(Path::new("/proc"), Path::new("/sys/fs/cgroup")) + } + + /// Legacy RAM-plus-swap helper with explicit roots. Its meaning is deliberately unchanged: + /// the adaptive budget excludes swap, but flat and layered TIFF conversion still count it + /// (now bounded by the cgroup figures derived above, including v1 `memsw`). + pub(super) fn available_at(proc_root: &Path, sys_root: &Path) -> Option { + let snap = snapshot_at(proc_root, sys_root); snap.available_ram.map(|ram| ram.saturating_add(snap.swap_free.unwrap_or(0))) } @@ -380,27 +679,422 @@ pub(super) mod linux { kb.checked_mul(1024) } - /// The process's cgroup v2 directory. - fn cgroup_dir() -> Option { - let groups = std::fs::read_to_string("/proc/self/cgroup").ok()?; - let path = groups.lines().find_map(|l| l.strip_prefix("0::"))?; - Some(Path::new("/sys/fs/cgroup").join(path.trim().trim_start_matches('/'))) + /// Resolves the memory cgroup from `/proc/self/cgroup` plus `/proc/self/mountinfo`. + /// + /// v1 is preferred when the `memory` controller appears there; otherwise the unified v2 + /// hierarchy is used. A memory controller that cannot be resolved or read, or membership that + /// cannot be parsed, is [`CgroupLimits::Unreadable`], never host memory. Ancestors above a + /// cgroup namespace's root are not visible to an unprivileged reader (the kernel still + /// enforces them), so only visible ancestors can be accounted for. The widest visible view of + /// the hierarchy is used, not merely the deepest matching mount point. + pub(super) fn cgroup_limits(proc_root: &Path, sys_root: &Path) -> CgroupLimits { + let Ok(groups) = std::fs::read_to_string(proc_root.join("self/cgroup")) else { + // Without membership we cannot prove that no memory cgroup applies. + return CgroupLimits::Unreadable; + }; + let membership = match parse_membership(&groups) { + MembershipParse::Parsed(membership) => membership, + MembershipParse::Malformed => return CgroupLimits::Unreadable, + }; + let mountinfo = std::fs::read_to_string(proc_root.join("self/mountinfo")).ok(); + + if let Some(path) = membership.v1_memory { + let Some(dir) = resolve_dir(sys_root, mountinfo.as_deref(), path, "cgroup", Some("memory")) else { + return CgroupLimits::Unreadable; + }; + return v1_limits(&dir); + } + if let Some(path) = membership.v2 { + let Some(dir) = resolve_dir(sys_root, mountinfo.as_deref(), path, "cgroup2", None) else { + return CgroupLimits::Unreadable; + }; + return v2_limits(&dir); + } + CgroupLimits::NotConstrained } - /// Memory and swap left under the limits of `dir` and every cgroup above it (`None`: no - /// limit). Both limits apply hierarchically. `memory.current` includes page cache, which the - /// kernel reclaims before a cgroup hits `memory.max`, so its inactive part counts as free. - pub(super) fn cgroup_room(dir: &Path) -> (Option, Option) { - let read = |d: &Path, f: &str| std::fs::read_to_string(d.join(f)).ok(); - let room = |d: &Path, max: &str, current: &str, reclaimable: u64| -> Option { - let max = read(d, max)?.trim().parse::().ok()?; - let current = read(d, current).and_then(|c| c.trim().parse::().ok()).unwrap_or(0); - Some(max.saturating_sub(current.saturating_sub(reclaimable))) + /// Parses `/proc/self/cgroup`. + /// + /// Kernel format is `::`; the + /// unified hierarchy uses `0::`. A line that does not have that shape makes the whole + /// file untrustworthy: returning "no memory controller" from a partial parse would silently + /// fall back to unrestricted host memory, so an empty or malformed file is reported as + /// malformed instead. + fn parse_membership(text: &str) -> MembershipParse<'_> { + let mut membership = Membership { v2: None, v1_memory: None }; + let mut saw_line = false; + for line in text.lines() { + let line = line.trim_end_matches('\r'); + if line.is_empty() { + continue; + } + saw_line = true; + let mut fields = line.splitn(3, ':'); + let (Some(hierarchy), Some(controllers), Some(path)) = (fields.next(), fields.next(), fields.next()) else { + return MembershipParse::Malformed; + }; + let Ok(hierarchy) = hierarchy.parse::() else { return MembershipParse::Malformed }; + if !path.starts_with('/') { + return MembershipParse::Malformed; + } + if hierarchy == 0 { + if !controllers.is_empty() { + return MembershipParse::Malformed; + } + membership.v2.get_or_insert(path); + } else if controllers.is_empty() { + return MembershipParse::Malformed; + } else if controllers.split(',').any(|c| c.trim() == "memory") { + membership.v1_memory.get_or_insert(path); + } + } + if !saw_line { + return MembershipParse::Malformed; + } + MembershipParse::Parsed(membership) + } + + fn parse_mountinfo(text: &str) -> Vec { + let mut mounts = Vec::new(); + for line in text.lines() { + let fields: Vec<&str> = line.split_whitespace().collect(); + let Some(separator) = fields.iter().position(|f| *f == "-") else { continue }; + let (Some(root), Some(point), Some(fstype), Some(super_options)) = + (fields.get(3).copied(), fields.get(4).copied(), fields.get(separator + 1).copied(), fields.get(separator + 3).copied()) + else { + continue; + }; + let options = fields.get(5).copied().unwrap_or(""); + mounts.push(Mount { + root: unescape(root), + mount_point: unescape(point), + fstype: fstype.to_string(), + options: options.to_string(), + super_options: super_options.to_string(), + }); + } + mounts + } + + /// Finds the mount for `fstype`/`controller` and joins the process's cgroup path to it. + /// + /// Reads `/proc/self/mountinfo` (dedicated controller mounts, bind mounts and non-root mount + /// roots). If that file cannot be read, the conventional `/sys/fs/cgroup[/]` + /// layout is the only guess available. When mountinfo *is* readable, a missing matching mount + /// fails closed instead of guessing another mount's hierarchy: a wrong directory must never + /// become unrestricted host memory. + /// + /// A bind mount of a subtree has a deeper `root` and hides the cgroup ancestors above that + /// subtree, so the *widest* matching view is preferred: the mount whose cgroup root is + /// highest in the hierarchy (fewest path components), not the longest mount point. A deep + /// bind mount must not be allowed to hide a limit on an ancestor that is visible through the + /// hierarchy's real mount. + fn resolve_dir(sys_root: &Path, mountinfo: Option<&str>, membership: &str, fstype: &str, controller: Option<&str>) -> Option { + let membership = unescape(membership.trim()); + let Some(info) = mountinfo else { + let root = match controller { + Some(c) => sys_root.join(c), + None => sys_root.to_path_buf(), + }; + let leaf = join_membership(&root, &membership)?; + // The conventional layout mounts each hierarchy at its own root. + return leaf.is_dir().then_some(CgroupDir { leaf, root, hierarchy_root: true }); }; - let inactive_file = |d: &Path| read(d, "memory.stat").and_then(|s| stat(&s, "inactive_file")).unwrap_or(0); - let swap = dir.ancestors().take(64).filter_map(|d| room(d, "memory.swap.max", "memory.swap.current", 0)).min(); - let ram = dir.ancestors().take(64).filter_map(|d| room(d, "memory.max", "memory.current", inactive_file(d))).min(); - (ram, swap) + + let mut best: Option<(usize, usize, CgroupDir)> = None; + for mount in parse_mountinfo(info) { + if mount.fstype != fstype { + continue; + } + if controller.is_some_and(|c| !has_option(&mount.options, c) && !has_option(&mount.super_options, c)) { + continue; + } + let Some(relative) = strip_mount_root(&mount.root, &membership) else { continue }; + let root = normalize_root(&mount.mount_point); + let Some(leaf) = join_membership(&root, relative) else { continue }; + if !leaf.is_dir() { + continue; + } + let key = (cgroup_path_depth(&mount.root), mount.mount_point.len()); + if best.as_ref().is_none_or(|(depth, point, _)| key < (*depth, *point)) { + let hierarchy_root = mount.root.trim_end_matches('/').is_empty(); + best = Some((key.0, key.1, CgroupDir { leaf, root, hierarchy_root })); + } + } + best.map(|(_, _, dir)| dir) + } + + /// Trims trailing slashes so `Path` equality holds while walking ancestors, keeping `/`. + fn normalize_root(point: &str) -> PathBuf { + let trimmed = point.trim_end_matches('/'); + PathBuf::from(if trimmed.is_empty() { "/" } else { trimmed }) + } + + /// Joins a cgroup path below a visible hierarchy root, normalizing `.` components and + /// rejecting parent traversal so a malformed path cannot escape the mount. + fn join_membership(root: &Path, path: &str) -> Option { + let mut leaf = root.to_path_buf(); + for component in Path::new(path.trim_start_matches('/')).components() { + match component { + std::path::Component::Normal(part) => leaf.push(part), + std::path::Component::CurDir => {} + std::path::Component::ParentDir | std::path::Component::RootDir | std::path::Component::Prefix(_) => return None, + } + } + Some(leaf) + } + + /// Number of path components in a mount's cgroup root; `/` or an empty root is zero. + fn cgroup_path_depth(root: &str) -> usize { + Path::new(root).components().filter(|c| matches!(c, std::path::Component::Normal(_))).count() + } + + /// Whether a v2 mount's `cgroup.controllers` exposes the memory controller. + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + enum V2Controller { + /// `cgroup.controllers` lists `memory`. + Available, + /// `cgroup.controllers` is readable and omits `memory`, so no cgroup memory limit exists. + Absent, + /// `cgroup.controllers` could not be read, so absence cannot be established. + Unknown, + } + + fn v2_controller(root: &Path) -> V2Controller { + match std::fs::read_to_string(root.join("cgroup.controllers")) { + Ok(text) => { + if text.split_whitespace().any(|controller| controller == "memory") { + V2Controller::Available + } else { + V2Controller::Absent + } + } + Err(_) => V2Controller::Unknown, + } + } + + /// Whether a cgroup's `cgroup.subtree_control` enables memory for its children. + /// + /// Interface files are created by the parent, so this decides whether a child *must* expose + /// `memory.max`: a readable control file without memory means the child legitimately has no + /// limit, while an unreadable one means that cannot be established. + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + enum Delegation { + Enabled, + Disabled, + Unknown, + } + + fn v2_delegation(parent: &Path) -> Delegation { + match std::fs::read_to_string(parent.join("cgroup.subtree_control")) { + Ok(text) => { + if text.split_whitespace().any(|controller| controller.trim_start_matches('+') == "memory") { + Delegation::Enabled + } else { + Delegation::Disabled + } + } + Err(_) => Delegation::Unknown, + } + } + + /// cgroup v2: `memory.max`/`memory.current`, reclaiming `inactive_file`, plus + /// `memory.swap.max`/`memory.swap.current`. Every visible ancestor limit applies. + /// + /// A missing `memory.max` is not always an error: the hierarchy root is exempt from resource + /// control, and a child has the file only when its parent delegates the controller. A missing + /// file where the parent *does* delegate memory, or where delegation cannot be read, means the + /// visible view is incomplete and is reported as unreadable instead of unrestricted. + fn v2_limits(dir: &CgroupDir) -> CgroupLimits { + if !dir.leaf.starts_with(&dir.root) || !dir.leaf.is_dir() { + return CgroupLimits::Unreadable; + } + let controller = v2_controller(&dir.root); + + let mut nodes = Vec::new(); + let mut node = Some(dir.leaf.as_path()); + while let Some(path) = node { + nodes.push(path); + if path == dir.root.as_path() { + break; + } + if nodes.len() > MAX_CGROUP_DEPTH { + return CgroupLimits::Unreadable; + } + node = path.parent(); + } + if nodes.last().is_none_or(|path| *path != dir.root.as_path()) { + return CgroupLimits::Unreadable; + } + nodes.reverse(); + + let mut ram: Option = None; + let mut swap: Option = None; + let mut enabled = false; + for (index, path) in nodes.iter().enumerate() { + let expected = if index == 0 { + if dir.hierarchy_root { + // The hierarchy root is exempt from resource control and may have no + // `memory.max` at all. + None + } else if controller == V2Controller::Absent { + Some(Delegation::Disabled) + } else { + // A mounted subtree root must expose the controller when it is enabled for + // that cgroup; without the parent's control file its absence is unprovable. + Some(Delegation::Unknown) + } + } else { + Some(v2_delegation(nodes[index - 1])) + }; + match read_limit(&path.join("memory.max")) { + Limit::Value(max) => { + enabled = true; + let Some(current) = read_u64(&path.join("memory.current")) else { return CgroupLimits::Unreadable }; + let inactive = read_stat(&path.join("memory.stat"), "inactive_file"); + let room = max.saturating_sub(current.saturating_sub(inactive)); + ram = Some(ram.map_or(room, |r| r.min(room))); + } + Limit::Unlimited => enabled = true, + Limit::Invalid => return CgroupLimits::Unreadable, + Limit::Missing => match expected { + None | Some(Delegation::Disabled) => {} + Some(Delegation::Enabled | Delegation::Unknown) => return CgroupLimits::Unreadable, + }, + } + match read_limit(&path.join("memory.swap.max")) { + Limit::Value(max) => { + enabled = true; + let Some(current) = read_u64(&path.join("memory.swap.current")) else { return CgroupLimits::Unreadable }; + let room = max.saturating_sub(current); + swap = Some(swap.map_or(room, |r| r.min(room))); + } + Limit::Unlimited => enabled = true, + Limit::Invalid => return CgroupLimits::Unreadable, + // Kernels before swap accounting expose `memory.max` without `memory.swap.*`; + // absence there means no swap limit at this cgroup, not an unreadable constraint. + Limit::Missing => {} + } + } + + if enabled { + return CgroupLimits::Limited { ram, swap }; + } + match controller { + // The controller is genuinely absent, or available but not enabled anywhere on the + // visible path (the root may normally have no interface files): no cgroup limit. + V2Controller::Absent | V2Controller::Available => CgroupLimits::NotConstrained, + // No readable limit file and no metadata proving the controller absent. + V2Controller::Unknown => CgroupLimits::Unreadable, + } + } + + /// cgroup v1: `memory.limit_in_bytes`/`memory.usage_in_bytes` (inactive cache reclaimable) + /// and the separate `memory.memsw` memory-plus-swap bound. + /// + /// A memsw limit also caps RAM because memory usage counts toward it, but its remaining room + /// is not swap that can be added on top of a different ancestor's RAM headroom. Each + /// ancestor's swap allowance is `memsw limit - memory limit - swap already used`, and those + /// allowances are combined separately. Subtracting one global combined headroom from one + /// global RAM headroom would invent swap room from different ancestors. + fn v1_limits(dir: &CgroupDir) -> CgroupLimits { + if !dir.leaf.starts_with(&dir.root) || !dir.leaf.is_dir() { + return CgroupLimits::Unreadable; + } + let mut ram: Option = None; + let mut swap: Option = None; + let mut enabled = false; + let mut node = Some(dir.leaf.as_path()); + let mut depth = 0; + while let Some(path) = node { + if depth >= MAX_CGROUP_DEPTH { + return CgroupLimits::Unreadable; + } + + // `None` means unlimited at this ancestor; the raw usage feeds the swap bound. + let mut memory_usage = None; + let memory_limit = match read_limit(&path.join("memory.limit_in_bytes")) { + Limit::Missing | Limit::Invalid => return CgroupLimits::Unreadable, + Limit::Unlimited => { + enabled = true; + None + } + Limit::Value(bytes) if bytes >= V1_UNLIMITED => { + enabled = true; + None + } + Limit::Value(bytes) => { + enabled = true; + let Some(usage) = read_u64(&path.join("memory.usage_in_bytes")) else { return CgroupLimits::Unreadable }; + memory_usage = Some(usage); + let inactive = std::fs::read_to_string(path.join("memory.stat")) + .ok() + .and_then(|s| stat(&s, "total_inactive_file").or_else(|| stat(&s, "inactive_file"))) + .unwrap_or(0); + let room = bytes.saturating_sub(usage.saturating_sub(inactive)); + ram = Some(ram.map_or(room, |r| r.min(room))); + Some(bytes) + } + }; + + match read_limit(&path.join("memory.memsw.limit_in_bytes")) { + Limit::Missing | Limit::Unlimited => {} + Limit::Invalid => return CgroupLimits::Unreadable, + Limit::Value(bytes) if bytes >= V1_UNLIMITED => {} + Limit::Value(bytes) => { + let Some(memsw_usage) = read_u64(&path.join("memory.memsw.usage_in_bytes")) else { + return CgroupLimits::Unreadable; + }; + // Memory plus swap is bounded by memsw, so its remaining room also bounds RAM. + let combined_room = bytes.saturating_sub(memsw_usage); + ram = Some(ram.map_or(combined_room, |r| r.min(combined_room))); + if let Some(memory_limit) = memory_limit { + // Swap can only use the part of memsw above the memory limit after the + // swap already in use; filling RAM to its own limit does not consume it. + let used_swap = memsw_usage.saturating_sub(memory_usage.unwrap_or(0)); + let room = bytes.saturating_sub(memory_limit).saturating_sub(used_swap); + swap = Some(swap.map_or(room, |s| s.min(room))); + } else { + // With no RAM limit the remaining memsw room can all be consumed by RAM, + // so no swap room is guaranteed on top of the RAM allowance. + swap = Some(0); + } + } + } + + if path == dir.root.as_path() { + break; + } + node = path.parent(); + depth += 1; + } + if !enabled { + // The v1 hierarchy claims a memory controller but never exposed a limit file. + return CgroupLimits::Unreadable; + } + CgroupLimits::Limited { ram, swap } + } + + fn read_limit(path: &Path) -> Limit { + match std::fs::read_to_string(path) { + Ok(text) => match text.trim() { + "max" => Limit::Unlimited, + value => match value.parse::() { + Ok(bytes) => Limit::Value(bytes), + Err(_) => Limit::Invalid, + }, + }, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Limit::Missing, + Err(_) => Limit::Invalid, + } + } + + fn read_u64(path: &Path) -> Option { + std::fs::read_to_string(path).ok()?.trim().parse().ok() + } + + fn read_stat(path: &Path, field: &str) -> u64 { + std::fs::read_to_string(path).ok().and_then(|s| stat(&s, field)).unwrap_or(0) } fn stat(s: &str, field: &str) -> Option { @@ -410,6 +1104,57 @@ pub(super) mod linux { if key == field { parts.next()?.parse().ok() } else { None } }) } + + fn has_option(options: &str, controller: &str) -> bool { + options.split(',').any(|option| option.trim() == controller) + } + + /// Strips a mount's root prefix from a cgroup path, returning the path relative to the mount. + fn strip_mount_root<'a>(root: &str, path: &'a str) -> Option<&'a str> { + let root = root.trim_end_matches('/'); + if root.is_empty() { + return Some(path); + } + let rest = path.strip_prefix(root)?; + if rest.is_empty() { + return Some(""); + } + rest.strip_prefix('/') + } + + /// Decodes mountinfo/mount-path octal escapes (`\040` space, `\011` tab, `\134` backslash). + fn unescape(value: &str) -> String { + let bytes = value.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut index = 0; + while let Some(&byte) = bytes.get(index) { + if byte == b'\\' { + if let Some(digits) = bytes.get(index + 1..index + 4) { + let code = digits.iter().try_fold(0u32, |acc, digit| { + let value = digit.checked_sub(b'0')?; + (value < 8).then_some(acc * 8 + u32::from(value)) + }); + if let Some(byte) = code.and_then(|code| u8::try_from(code).ok()) { + out.push(byte); + index += 4; + continue; + } + } + } + out.push(byte); + index += 1; + } + String::from_utf8_lossy(&out).into_owned() + } + + /// Exposed for the Linux provider tests; the policy itself never calls this. + #[cfg(test)] + pub(super) fn membership_for_test(text: &str) -> Option<(Option, Option)> { + match parse_membership(text) { + MembershipParse::Parsed(membership) => Some((membership.v2.map(str::to_string), membership.v1_memory.map(str::to_string))), + MembershipParse::Malformed => None, + } + } } #[cfg(windows)] @@ -424,15 +1169,76 @@ mod windows_mem { } } -#[cfg(not(any(target_os = "linux", windows)))] +/// Conservative reclaimable physical RAM from macOS VM counters, or `None` when the query failed. +/// +/// `total` is installed physical memory (`sysctl` `HW_MEMSIZE`). The other three are the same +/// `host_statistics64` `vm_statistics64` fields that `sysinfo` derives: +/// +/// - `free` = `free_memory` = `free_count - speculative_count` pages; +/// - `available` = `available_memory` = `active + inactive + free_count` pages; +/// - `used` = `used_memory` = `internal_page_count - purgeable_count + wire_count + +/// compressor_page_count` pages. +/// +/// XNU documents `AVAILABLE_NON_COMPRESSED_MEMORY = active + inactive + free + speculative` as +/// "everything that is not either wired, compressed, or stolen" +/// (). +/// That includes anonymous working sets, which can only be released by compressing or swapping +/// them, so on its own it is too optimistic for a budget that excludes swap. +/// +/// `internal_page_count` counts anonymous pages and `external_page_count` counts file-backed +/// pages (), +/// so `total - used` is the pool that can be handed out without evicting a working set: +/// `free + file-backed + purgeable`. That is close to the fully-reclaimable pool jetsam monitors +/// (`file-backed + free + purgeable`, same document). The smaller of the two derivations is +/// taken so a counter disagreement stays conservative, and the result is never below `free`: +/// pages that are already free are allocatable by definition, so a derived value below them +/// means the counters disagree and cannot be used to claim less. +/// +/// Only a demonstrably failed query is unknown. A live host always has a non-zero installed size +/// and non-zero used memory (wired kernel pages alone), so either reading zero proves the +/// provider query failed. A genuine "no reclaimable pages" reading stays a known zero rather +/// than becoming the 2 GiB unknown-memory allowance. +#[cfg(any(target_os = "macos", test))] +fn macos_available_ram(total: u64, free: u64, available: u64, used: u64) -> Option { + if total == 0 || used == 0 { + return None; + } + let free = free.min(total); + let reclaimable = total.saturating_sub(used).min(available).max(free); + Some(reclaimable.min(total)) +} + +#[cfg(target_os = "macos")] +mod macos_mem { + use super::MemorySnapshot; + use sysinfo::{MemoryRefreshKind, RefreshKind, System}; + + /// Available physical memory through `sysinfo`'s safe API. + /// + /// On macOS that is `host_statistics64`'s `vm_statistics64` (see + /// [`super::macos_available_ram`]). The estimate counts free pages, file-backed pages and + /// purgeable pages, not anonymous working sets, wired memory, compressed memory or swap. A + /// failed query reports unknown; a successful zero reading stays zero. + pub(super) fn snapshot() -> MemorySnapshot { + let system = match System::new_with_specifics(RefreshKind::nothing().with_memory(MemoryRefreshKind::nothing().with_ram())) { + Ok(system) => system, + Err(_) => return MemorySnapshot::UNKNOWN, + }; + match super::macos_available_ram(system.total_memory(), system.free_memory(), system.available_memory(), system.used_memory()) { + Some(ram) => MemorySnapshot { available_ram: Some(ram), swap_free: None, commit_headroom: None }, + None => MemorySnapshot::UNKNOWN, + } + } +} + +#[cfg(not(any(target_os = "linux", windows, target_os = "macos")))] mod fallback_mem { use super::MemorySnapshot; - /// Fallback provider for platforms without a validated memory-availability provider (e.g. macOS). + /// Fallback provider for platforms without a validated memory-availability provider. /// - /// Available memory is reported as unknown (`None`), which applies the safe native fallback - /// budget (2 GiB) without allowing an unverified memory override. macOS dynamic paging makes - /// physical RAM a poor indicator of allocation capacity without platform-specific Mach VM APIs. + /// Available memory is reported as unknown (`None`), which applies the conservative native + /// fallback budget (2 GiB, approval above it) without inventing a measurement. pub(super) fn snapshot() -> MemorySnapshot { MemorySnapshot::UNKNOWN } @@ -456,6 +1262,13 @@ mod tests { #[test] fn pure_policy_adheres_to_approved_thresholds() { + if cfg!(target_arch = "wasm32") { + return; // the web policy has its own tests + } + if native_platform_cap() < 24 << 30 { + // This table uses 6-24 GiB limits, above a 32-bit aggregate cap. + return; + } let gb = 1u64 << 30; // 12 GiB RAM: H = min(9 GiB, 11 GiB) = 9 GiB; S = min(6 GiB, 8 GiB, 9 GiB) = 6 GiB. @@ -463,6 +1276,7 @@ mod tests { let b12 = resolve_budget(snap12, None); assert_eq!(b12.soft_limit, 6 * gb); assert_eq!(b12.hard_limit, 9 * gb); + assert_eq!(b12.warning_threshold, None); // 5.80 GiB estimate (5.8 * 1 GiB = 6227702579) let est_5_8 = (5.80 * (gb as f64)).round() as u64; @@ -491,6 +1305,8 @@ mod tests { let b32 = resolve_budget(snap32, None); assert_eq!(b32.soft_limit, 8 * gb); assert_eq!(b32.hard_limit, 24 * gb); + assert_eq!(b32.warning_threshold, None); + assert!(!b32.requires_uncertainty_warning(10 * gb), "known memory has no unknown-capacity warning"); // Commit headroom constraint: C = 5 GiB -> C - 512 MiB = 4.5 GiB. let snap_commit = MemorySnapshot { available_ram: Some(12 * gb), swap_free: None, commit_headroom: Some(5 * gb) }; @@ -510,42 +1326,199 @@ mod tests { #[test] fn zero_available_ram_is_not_unknown() { + if cfg!(target_arch = "wasm32") { + return; + } let snap_zero = MemorySnapshot { available_ram: Some(0), swap_free: None, commit_headroom: None }; let b_zero = resolve_budget(snap_zero, None); assert_eq!(b_zero.soft_limit, 0); assert_eq!(b_zero.hard_limit, 0); + assert_eq!(b_zero.warning_threshold, None); assert_eq!(b_zero.decide(1), BudgetDecision::Refused); let snap_unknown = MemorySnapshot::UNKNOWN; let b_unknown = resolve_budget(snap_unknown, None); - assert_eq!(b_unknown.soft_limit, NATIVE_FALLBACK_BUDGET); - assert_eq!(b_unknown.hard_limit, NATIVE_FALLBACK_BUDGET); + assert_eq!(b_unknown.soft_limit, NATIVE_FALLBACK_BUDGET.min(native_platform_cap())); + assert_eq!(b_unknown.hard_limit, native_platform_cap()); + assert_eq!(b_unknown.available_ram, None); + assert_eq!(b_unknown.warning_threshold, Some(UNKNOWN_MEMORY_WARNING_THRESHOLD)); assert_eq!(b_unknown.decide(1), BudgetDecision::Automatic); - assert_eq!(b_unknown.decide(NATIVE_FALLBACK_BUDGET + 1), BudgetDecision::Refused); + let expected = if b_unknown.hard_limit > b_unknown.soft_limit { BudgetDecision::RequiresApproval } else { BudgetDecision::Automatic }; + assert_eq!(b_unknown.decide(b_unknown.hard_limit.min(u64::MAX - 1)), expected); + assert_eq!(b_unknown.decide(u64::MAX), BudgetDecision::Refused, "the saturated overflow sentinel is never approved"); + if b_unknown.hard_limit < u64::MAX { + assert_eq!(b_unknown.decide(b_unknown.hard_limit + 1), BudgetDecision::Refused); + } + } + + /// A format estimate that overflows `u64` saturates at `u64::MAX`. Unknown native memory has + /// `u64::MAX` as its hard limit, so without a sentinel rule explicit approval would admit it. + #[test] + fn a_saturated_estimate_is_refused_even_at_the_unknown_native_hard_limit() { + if cfg!(target_pointer_width = "32") { + // A 32-bit aggregate cap already refuses it; the case this test guards is the + // 64-bit unknown-memory cap, where the limit itself is the sentinel. + return; + } + let budget = resolve_budget_for(Platform::Native, MemorySnapshot::UNKNOWN, None); + assert_eq!(budget.hard_limit, u64::MAX, "the case that would otherwise admit the sentinel"); + assert_eq!(budget.decide(u64::MAX), BudgetDecision::Refused, "the decision agrees with evaluation"); + assert_eq!(budget.decide(u64::MAX - 1), BudgetDecision::RequiresApproval); + + for (approved, estimate) in [(false, None), (true, None), (true, Some(u64::MAX))] { + match evaluate_psd_admission_for(Platform::Native, u64::MAX, 0, approved, estimate, MemorySnapshot::UNKNOWN) { + Err(IoError::PsdImportBudget { required, limit }) => { + assert_eq!((required, limit), (u64::MAX, u64::MAX), "the typed refusal keeps the estimate and limit unchanged") + } + other => panic!("approved={approved}, estimate={estimate:?}: expected a hard refusal, got {other:?}"), + } + } + + // A caller limit equal to the sentinel, or an approved estimate of it, is no exception. + assert!(matches!( + evaluate_psd_admission_for(Platform::Native, u64::MAX, u64::MAX, true, Some(u64::MAX), MemorySnapshot::UNKNOWN), + Err(IoError::PsdImportBudget { required: u64::MAX, limit: u64::MAX }) + )); } #[test] - fn unknown_ram_with_known_zero_commit_bounds_admission() { - let snap = MemorySnapshot { available_ram: None, swap_free: None, commit_headroom: Some(0) }; - let b = resolve_budget(snap, None); - assert_eq!(b.hard_limit, 0); - assert_eq!(b.soft_limit, 0); - assert_eq!(b.decide(1), BudgetDecision::Refused); + fn unknown_memory_is_conservative_but_not_capped_at_eight_gib() { + if cfg!(target_pointer_width = "32") { + return; + } + let gb = 1u64 << 30; + let b = resolve_budget_for(Platform::Native, MemorySnapshot::UNKNOWN, None); + assert_eq!(b.soft_limit, NATIVE_FALLBACK_BUDGET); + assert_eq!(b.hard_limit, native_platform_cap()); - let snap_room = MemorySnapshot { available_ram: None, swap_free: None, commit_headroom: Some(1 << 30) }; + assert_eq!(b.decide(NATIVE_FALLBACK_BUDGET), BudgetDecision::Automatic); + assert_eq!(b.decide(NATIVE_FALLBACK_BUDGET + 1), BudgetDecision::RequiresApproval); + assert_eq!(b.decide(UNKNOWN_MEMORY_WARNING_THRESHOLD), BudgetDecision::RequiresApproval); + assert!(!b.requires_uncertainty_warning(UNKNOWN_MEMORY_WARNING_THRESHOLD)); + assert!(b.requires_uncertainty_warning(UNKNOWN_MEMORY_WARNING_THRESHOLD + 1)); + assert_eq!(b.decide(UNKNOWN_MEMORY_WARNING_THRESHOLD + 1), BudgetDecision::RequiresApproval, "8 GiB is a warning threshold, not a hard ceiling"); + assert_eq!(b.decide(64 * gb), BudgetDecision::RequiresApproval); + assert!(b.requires_uncertainty_warning(64 * gb)); + } + + #[test] + fn unknown_memory_with_known_commit_headroom_stays_conservative() { + if cfg!(target_arch = "wasm32") { + return; + } + let gb = 1u64 << 30; + + let snap_zero = MemorySnapshot { available_ram: None, swap_free: None, commit_headroom: Some(0) }; + let b_zero = resolve_budget(snap_zero, None); + assert_eq!(b_zero.hard_limit, 0); + assert_eq!(b_zero.soft_limit, 0); + assert_eq!(b_zero.decide(1), BudgetDecision::Refused); + + let snap_tight = MemorySnapshot { available_ram: None, swap_free: None, commit_headroom: Some(gb) }; + let b_tight = resolve_budget(snap_tight, None); + assert_eq!(b_tight.hard_limit, gb - COMMIT_RESERVE); + assert_eq!(b_tight.soft_limit, gb - COMMIT_RESERVE); + + let snap_room = MemorySnapshot { available_ram: None, swap_free: None, commit_headroom: Some(4 * gb) }; let b_room = resolve_budget(snap_room, None); - assert_eq!(b_room.hard_limit, (1 << 30) - COMMIT_RESERVE); - assert_eq!(b_room.soft_limit, (1 << 30) - COMMIT_RESERVE); + assert_eq!(b_room.hard_limit, 4 * gb - COMMIT_RESERVE); + assert_eq!(b_room.soft_limit, NATIVE_FALLBACK_BUDGET); + } + + #[test] + fn unknown_memory_admits_approved_imports_above_eight_gib() { + if cfg!(target_pointer_width = "32") { + return; // the 32-bit platform cap is below 8 GiB + } + let gb = 1u64 << 30; + let snap = MemorySnapshot::UNKNOWN; + let required = 9 * gb; + + let err = evaluate_psd_admission_for(Platform::Native, required, 0, false, None, snap).unwrap_err(); + assert!(matches!(err, IoError::PsdApprovalRequired { available_ram: None, .. })); + + let out = evaluate_psd_admission_for(Platform::Native, required, 0, true, Some(required), snap).unwrap(); + assert_eq!(out.authorized_total, required); + assert!(out.budget.requires_uncertainty_warning(required), "the consent warning must carry through admission"); + + let err = evaluate_psd_admission_for(Platform::Native, required, 0, true, Some(required - 1), snap).unwrap_err(); + assert!(matches!(err, IoError::PsdApprovalRequired { .. }), "a larger estimate needs fresh consent"); + } + + #[test] + fn web_policy_allows_approval_above_one_gib_within_its_own_cap() { + let b = resolve_budget_for(Platform::Web, MemorySnapshot::UNKNOWN, None); + assert_eq!(b.soft_limit, WASM_BUDGET); + assert_eq!(b.hard_limit, WASM_MAX_PEAK_BUDGET); + assert!(b.hard_limit < WASM_LINEAR_MEMORY_LIMIT, "the aggregate cap stays below the wasm32 address space"); + assert!(WASM_MAX_SINGLE_ALLOCATION < WASM_LINEAR_MEMORY_LIMIT, "a single Rust allocation cannot use all 4 GiB"); + assert_eq!(b.available_ram, None); + assert_eq!(b.warning_threshold, None); + + assert_eq!(b.decide(WASM_BUDGET), BudgetDecision::Automatic); + assert_eq!(b.decide(WASM_BUDGET + 1), BudgetDecision::RequiresApproval); + assert_eq!(b.decide(WASM_MAX_PEAK_BUDGET), BudgetDecision::RequiresApproval); + assert_eq!(b.decide(WASM_MAX_PEAK_BUDGET + 1), BudgetDecision::Refused); + assert!(WASM_MAX_SINGLE_ALLOCATION < WASM_MAX_PEAK_BUDGET, "the aggregate cap must not be the single-allocation layout bound"); + let out = evaluate_psd_admission_for(Platform::Web, WASM_MAX_PEAK_BUDGET, 0, true, Some(WASM_MAX_PEAK_BUDGET), MemorySnapshot::UNKNOWN).unwrap(); + assert_eq!(out.authorized_total, WASM_MAX_PEAK_BUDGET, "approval may authorize an aggregate above one buffer's layout limit"); + + let capped = resolve_budget_for(Platform::Web, MemorySnapshot::UNKNOWN, Some(512 << 20)); + assert_eq!(capped.hard_limit, 512 << 20); + assert_eq!(capped.soft_limit, 512 << 20); + + let no_raise = resolve_budget_for(Platform::Web, MemorySnapshot::UNKNOWN, Some(64 << 30)); + assert_eq!(no_raise.hard_limit, WASM_MAX_PEAK_BUDGET); + } + + #[test] + fn web_admission_requires_approval_above_its_automatic_allowance() { + let snap = MemorySnapshot::UNKNOWN; + let approval = 3 * (WASM_BUDGET / 2); + + let out = evaluate_psd_admission_for(Platform::Web, WASM_BUDGET, 0, false, None, snap).unwrap(); + assert_eq!(out.authorized_total, WASM_BUDGET); + + let err = evaluate_psd_admission_for(Platform::Web, WASM_BUDGET + 1, 0, false, None, snap).unwrap_err(); + assert!(matches!( + err, + IoError::PsdApprovalRequired { required, soft_limit, hard_limit, available_ram } + if required == WASM_BUDGET + 1 && soft_limit == WASM_BUDGET && hard_limit == WASM_MAX_PEAK_BUDGET && available_ram.is_none() + )); + + let out = evaluate_psd_admission_for(Platform::Web, approval, 0, true, Some(approval), snap).unwrap(); + assert_eq!(out.authorized_total, approval); + + let err = evaluate_psd_admission_for(Platform::Web, approval, 0, true, Some(approval - 1), snap).unwrap_err(); + assert!(matches!(err, IoError::PsdApprovalRequired { .. })); + + let err = evaluate_psd_admission_for(Platform::Web, WASM_MAX_PEAK_BUDGET + 1, 0, true, None, snap).unwrap_err(); + assert!(matches!(err, IoError::PsdImportBudget { .. })); + } + + #[test] + fn caller_caps_lower_unknown_memory_admission() { + let b = resolve_budget_for(Platform::Native, MemorySnapshot::UNKNOWN, Some(1 << 30)); + assert_eq!(b.hard_limit, 1 << 30); + assert_eq!(b.soft_limit, 1 << 30); + assert_eq!(b.warning_threshold, Some(UNKNOWN_MEMORY_WARNING_THRESHOLD)); + + let no_raise = resolve_budget_for(Platform::Native, MemorySnapshot::UNKNOWN, Some(64 << 30)); + assert_eq!(no_raise.hard_limit, native_platform_cap().min(64 << 30)); + assert_eq!(no_raise.soft_limit, NATIVE_FALLBACK_BUDGET.min(native_platform_cap())); } #[test] fn arithmetic_overflow_safety() { + if cfg!(target_arch = "wasm32") { + return; + } let snap_huge = MemorySnapshot { available_ram: Some(u64::MAX), swap_free: Some(u64::MAX), commit_headroom: Some(u64::MAX) }; let b = resolve_budget(snap_huge, None); - let expected_hard = ((u64::MAX as u128 * 3) / 4) as u64; - let expected_hard = if cfg!(target_pointer_width = "32") { 2 << 30 } else { expected_hard }; + let expected_hard = (((u64::MAX as u128) * 3) / 4) as u64; + let expected_hard = expected_hard.min(native_platform_cap()); assert_eq!(b.hard_limit, expected_hard); - assert_eq!(b.soft_limit, MAX_SOFT_BUDGET); + assert_eq!(b.soft_limit, MAX_SOFT_BUDGET.min(expected_hard)); } #[test] @@ -599,46 +1572,430 @@ mod tests { assert!(matches!(err, IoError::PsdImportBudget { .. })); } + #[test] + fn known_snapshot_uses_known_memory_policy_on_every_target() { + let gb = 1u64 << 30; + let snap = MemorySnapshot { available_ram: Some(16 * gb), swap_free: None, commit_headroom: None }; + let b = resolve_budget_for(Platform::Native, snap, None); + let expected_hard = (12 * gb).min(native_platform_cap()); + assert_eq!(b.hard_limit, expected_hard); + assert_eq!(b.soft_limit, (8 * gb).min(expected_hard)); + assert_eq!(b.available_ram, Some(16 * gb)); + assert_eq!(b.warning_threshold, None); + } + + #[test] + fn aggregate_cap_is_not_a_single_allocation_bound() { + let cap = native_platform_cap(); + if cfg!(target_pointer_width = "32") { + assert_eq!(cap, NATIVE_32BIT_PEAK_CAP, "32-bit native uses the documented product cap"); + } else { + assert_eq!(cap, u64::MAX, "64-bit native has no measurable aggregate address-space cap"); + assert!(MAX_SINGLE_ALLOCATION < cap, "a per-buffer limit must not become the aggregate limit"); + } + } + + #[test] + fn macos_available_ram_counts_reclaimable_pages_not_free_only() { + let gb = 1u64 << 30; + // A failed provider query: no installed size, or no used memory on a live host. + assert_eq!(macos_available_ram(0, 0, 0, 0), None, "an empty query is not a measurement"); + assert_eq!(macos_available_ram(0, 1 << 20, 4 * gb, 4 * gb), None, "no installed size: the sysctl query failed"); + assert_eq!(macos_available_ram(16 * gb, 1 << 20, 8 * gb, 0), None, "no used memory on a live host: the VM query failed"); + + // A genuine zero-capacity reading stays zero, not unknown. + assert_eq!(macos_available_ram(16 * gb, 0, 0, 16 * gb), Some(0), "everything is wired or compressed"); + + // Low free memory beside a large file-backed cache: free alone would report ~0 and the + // hard budget would collapse, while 12 GiB is reclaimable without touching a working set. + assert_eq!(macos_available_ram(16 * gb, 100 << 20, 12 * gb, 4 * gb), Some(12 * gb)); + + // A loaded machine: most memory is anonymous working set, which is not reclaimable, so + // `available` (which counts it) does not raise the estimate above total - used. + assert_eq!(macos_available_ram(16 * gb, 200 << 20, 8 * gb, 14 * gb), Some(2 * gb)); + + // Counters that disagree: free pages are allocatable by definition, so never claim less. + assert_eq!(macos_available_ram(16 * gb, 3 * gb, gb, 15 * gb), Some(3 * gb)); + + // Never above installed memory. + assert_eq!(macos_available_ram(gb, 2 * gb, 4 * gb, 1), Some(gb)); + } + + #[cfg(target_os = "macos")] + #[test] + fn macos_provider_excludes_swap_and_commit_headroom() { + let snap = MemorySnapshot::current(); + assert_eq!(snap.swap_free, None, "the adaptive budget must not count swap"); + assert_eq!(snap.commit_headroom, None); + // The numeric floor and zero-versus-unknown semantics are covered deterministically by + // `macos_available_ram_counts_reclaimable_pages_not_free_only`; asserting a live `> 0` + // here would be vacuous because a genuine zero is a valid reading. + } + + #[cfg(target_os = "linux")] + use std::path::{Path, PathBuf}; + + #[cfg(target_os = "linux")] + fn fixture(name: &str) -> (PathBuf, PathBuf) { + let root = std::env::temp_dir().join(format!("photocraft-cgroup-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(root.join("proc/self")).unwrap(); + std::fs::create_dir_all(root.join("sys/fs/cgroup")).unwrap(); + (root.join("proc"), root.join("sys/fs/cgroup")) + } + + #[cfg(target_os = "linux")] + fn write(path: &Path, text: &str) { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(path, text).unwrap(); + } + #[cfg(target_os = "linux")] #[test] - fn linux_memory_is_read_from_meminfo_and_the_cgroup() { + fn linux_memory_is_read_from_meminfo() { let info = "MemTotal: 24314352 kB\nMemAvailable: 12626632 kB\nSwapFree: 1024 kB\n"; assert_eq!(linux::meminfo(info, "MemAvailable:"), Some(12_626_632 * 1024)); assert_eq!(linux::meminfo(info, "SwapFree:"), Some(1024 * 1024)); assert_eq!(linux::meminfo(info, "Missing:"), None); assert_eq!(linux::meminfo("MemAvailable: lots kB", "MemAvailable:"), None); - - let root = std::env::temp_dir().join(format!("photocraft-cgroup-{}", std::process::id())); - let leaf = root.join("app.slice/app.scope"); - std::fs::create_dir_all(&leaf).unwrap(); - let write = |d: &Path, f: &str, v: &str| std::fs::write(d.join(f), v).unwrap(); - write(&root, "memory.max", "8589934592\n"); - write(&root, "memory.current", "2147483648\n"); - write(&leaf, "memory.max", "max\n"); - write(&leaf, "memory.current", "1073741824\n"); - write(&leaf, "memory.swap.max", "1073741824\n"); - write(&leaf, "memory.swap.current", "0\n"); - assert_eq!(linux::cgroup_room(&leaf), (Some(6 << 30), Some(1 << 30)), "the parent's limit applies"); - write(&root, "memory.swap.max", "1073741824\n"); - write(&root, "memory.swap.current", "805306368\n"); - write(&leaf, "memory.swap.max", "max\n"); - assert_eq!(linux::cgroup_room(&leaf).1, Some(256 << 20), "the parent's remaining swap applies to an unlimited leaf"); - write(&root, "memory.swap.max", "0\n"); - assert_eq!(linux::cgroup_room(&leaf).1, Some(0), "a parent can forbid swap"); - write(&root, "memory.swap.max", "1073741824\n"); - write(&leaf, "memory.swap.max", "134217728\n"); - assert_eq!(linux::cgroup_room(&leaf).1, Some(128 << 20), "the tighter leaf swap limit wins"); - write(&leaf, "memory.max", "1610612736\n"); - assert_eq!(linux::cgroup_room(&leaf).0, Some(512 << 20), "the tighter limit wins"); - write(&leaf, "memory.max", "2147483648\n"); - write(&leaf, "memory.current", "2147483648\n"); - write(&leaf, "memory.stat", "anon 268435456\nfile 1879048192\ninactive_file_x 1\ninactive_file 1610612736\n"); - assert_eq!(linux::cgroup_room(&leaf).0, Some(1610612736), "inactive page cache is reclaimed before the limit"); - write(&leaf, "memory.stat", "anon 268435456\n"); - assert_eq!(linux::cgroup_room(&leaf).0, Some(0), "no inactive_file: memory.current is all in use"); - std::fs::remove_dir_all(&root).unwrap(); } #[cfg(target_os = "linux")] - use std::path::Path; + #[test] + fn linux_cgroup_membership_distinguishes_v1_and_v2() { + assert_eq!(linux::membership_for_test("0::/user.slice/session.scope\n"), Some((Some("/user.slice/session.scope".into()), None))); + assert_eq!(linux::membership_for_test("4:memory:/docker/abc\n"), Some((None, Some("/docker/abc".into())))); + assert_eq!(linux::membership_for_test("4:cpu,cpuacct:/user.slice\n5:memory:/a/b\n"), Some((None, Some("/a/b".into())))); + assert_eq!(linux::membership_for_test("12:pids:/\n"), Some((None, None)), "no memory controller is demonstrably absent"); + assert_eq!(linux::membership_for_test("garbage\n"), None); + assert_eq!(linux::membership_for_test(""), None, "an empty file cannot prove absence"); + assert_eq!(linux::membership_for_test("0:memory:/app\n"), None, "the unified hierarchy cannot name controllers"); + assert_eq!(linux::membership_for_test("4::/app\n"), None, "a v1 line must name a controller"); + assert_eq!(linux::membership_for_test("4:memory:relative\n"), None, "cgroup paths are absolute"); + } + + #[test] + fn swap_is_never_part_of_the_adaptive_budget() { + let gb = 1u64 << 30; + let with_swap = MemorySnapshot { available_ram: Some(4 * gb), swap_free: Some(64 * gb), commit_headroom: None }; + let without_swap = MemorySnapshot { available_ram: Some(4 * gb), swap_free: None, commit_headroom: None }; + assert_eq!(resolve_budget_for(Platform::Native, with_swap, None), resolve_budget_for(Platform::Native, without_swap, None)); + } + + #[cfg(target_os = "linux")] + #[test] + fn linux_mountinfo_octal_escapes_are_decoded() { + let (proc, sys) = fixture("escaped"); + write(&proc.join("self/cgroup"), "0::/app\n"); + let mount = sys.join("cg root"); + let escaped = mount.display().to_string().replace(' ', "\\040"); + write(&proc.join("self/mountinfo"), &format!("30 20 0:26 / {} rw,relatime - cgroup2 cgroup2 rw\n", escaped)); + write(&mount.join("app/memory.max"), "1073741824\n"); + write(&mount.join("app/memory.current"), "0\n"); + assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::Limited { ram: Some(1 << 30), swap: None }); + std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); + } + + #[cfg(target_os = "linux")] + #[test] + fn linux_v2_cgroup_applies_mount_path_and_ancestor_limits() { + let (proc, sys) = fixture("v2"); + write(&proc.join("self/cgroup"), "0::/app.scope\n"); + write(&proc.join("self/mountinfo"), &format!("30 20 0:26 / {} rw,nosuid,nodev,noexec,relatime - cgroup2 cgroup2 rw,nsdelegate\n", sys.display())); + let leaf = sys.join("app.scope"); + write(&sys.join("memory.max"), "8589934592\n"); + write(&sys.join("memory.current"), "2147483648\n"); + write(&sys.join("memory.stat"), "inactive_file 1073741824\n"); + write(&leaf.join("memory.max"), "max\n"); + assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::Limited { ram: Some(7 << 30), swap: None }, "the parent's limit applies"); + + write(&leaf.join("memory.max"), "4294967296\n"); + write(&leaf.join("memory.current"), "1073741824\n"); + write(&leaf.join("memory.stat"), "inactive_file 0\n"); + assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::Limited { ram: Some(3 << 30), swap: None }, "the tighter leaf limit wins"); + + write(&leaf.join("memory.swap.max"), "1073741824\n"); + write(&leaf.join("memory.swap.current"), "268435456\n"); + assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::Limited { ram: Some(3 << 30), swap: Some(768 << 20) }); + + std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); + } + + #[cfg(target_os = "linux")] + #[test] + fn linux_v2_distinguishes_disabled_controller_from_missing_expected_limit() { + // The hierarchy root may legitimately have no `memory.max`; a child has one exactly when + // its parent delegates the controller. + let (proc, sys) = fixture("v2-metadata"); + write(&proc.join("self/cgroup"), "0::/app\n"); + write(&proc.join("self/mountinfo"), &format!("30 20 0:26 / {} rw - cgroup2 cgroup2 rw\n", sys.display())); + write(&sys.join("cgroup.controllers"), "cpu io memory\n"); + write(&sys.join("cgroup.subtree_control"), "+cpu +memory\n"); + write(&sys.join("app/memory.max"), "1073741824\n"); + write(&sys.join("app/memory.current"), "0\n"); + assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::Limited { ram: Some(1 << 30), swap: None }, "a missing root file is normal"); + + write(&sys.join("cgroup.controllers"), "cpu io\n"); + write(&sys.join("cgroup.subtree_control"), "+cpu\n"); + std::fs::remove_file(sys.join("app/memory.max")).unwrap(); + assert_eq!( + linux::cgroup_limits(&proc, &sys), + linux::CgroupLimits::NotConstrained, + "readable controllers without memory prove the controller is absent" + ); + + write(&sys.join("cgroup.controllers"), "cpu io memory\n"); + write(&sys.join("cgroup.subtree_control"), "+memory\n"); + assert_eq!( + linux::cgroup_limits(&proc, &sys), + linux::CgroupLimits::Unreadable, + "memory is delegated to the child, so the missing child file means the view is incomplete" + ); + + std::fs::remove_file(sys.join("cgroup.controllers")).unwrap(); + std::fs::remove_file(sys.join("cgroup.subtree_control")).unwrap(); + assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::Unreadable, "without metadata the absence of a constraint cannot be established"); + std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); + + // Available but not delegated anywhere on the path: no cgroup limit applies. + let (proc, sys) = fixture("v2-not-enabled"); + write(&proc.join("self/cgroup"), "0::/app\n"); + write(&proc.join("self/mountinfo"), &format!("30 20 0:26 / {} rw - cgroup2 cgroup2 rw\n", sys.display())); + write(&sys.join("cgroup.controllers"), "cpu io memory\n"); + write(&sys.join("cgroup.subtree_control"), "+cpu\n"); + write(&sys.join("app/cgroup.controllers"), "\n"); + assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::NotConstrained); + std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); + } + + #[cfg(target_os = "linux")] + #[test] + fn linux_prefers_the_widest_hierarchy_view_over_a_deep_bind_mount() { + let (proc, sys) = fixture("bind-widest"); + write(&proc.join("self/cgroup"), "0::/user.slice/app.scope\n"); + let narrow = sys.join("bind"); + write( + &proc.join("self/mountinfo"), + &format!("30 20 0:26 / {} rw - cgroup2 cgroup2 rw\n31 30 0:26 /user.slice/app.scope {} rw - cgroup2 cgroup2 rw\n", sys.display(), narrow.display()), + ); + write(&sys.join("user.slice/memory.max"), "2147483648\n"); + write(&sys.join("user.slice/memory.current"), "0\n"); + write(&sys.join("user.slice/cgroup.subtree_control"), "+memory\n"); + write(&sys.join("user.slice/app.scope/memory.max"), "max\n"); + write(&narrow.join("memory.max"), "max\n"); + assert_eq!( + linux::cgroup_limits(&proc, &sys), + linux::CgroupLimits::Limited { ram: Some(2 << 30), swap: None }, + "the hierarchy's own mount exposes user.slice's limit; the deep bind mount would hide it" + ); + + write(&proc.join("self/mountinfo"), &format!("31 30 0:26 /user.slice/app.scope {} rw - cgroup2 cgroup2 rw\n", narrow.display())); + assert_eq!( + linux::cgroup_limits(&proc, &sys), + linux::CgroupLimits::Limited { ram: None, swap: None }, + "the bind mount alone cannot see the ancestor limit, which is why the widest view is preferred" + ); + + std::fs::remove_file(narrow.join("memory.max")).unwrap(); + assert_eq!( + linux::cgroup_limits(&proc, &sys), + linux::CgroupLimits::Unreadable, + "a mounted subtree root cannot silently lose the controller file it should expose" + ); + std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); + } + + #[cfg(target_os = "linux")] + #[test] + fn linux_v1_cgroup_honours_controller_mounts_and_ancestors() { + let (proc, sys) = fixture("v1"); + write(&proc.join("self/cgroup"), "4:memory:/docker/abc\n"); + let mount = sys.join("memory"); + write(&proc.join("self/mountinfo"), &format!("31 24 0:26 / {} rw,nosuid,nodev,noexec,relatime shared:12 - cgroup cgroup rw,memory\n", mount.display())); + let leaf = mount.join("docker/abc"); + write(&mount.join("docker/memory.limit_in_bytes"), "9223372036854771712\n"); + write(&mount.join("memory.limit_in_bytes"), "8589934592\n"); + write(&mount.join("memory.usage_in_bytes"), "2147483648\n"); + write(&mount.join("memory.stat"), "total_inactive_file 1073741824\n"); + write(&leaf.join("memory.limit_in_bytes"), "9223372036854771712\n"); + assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::Limited { ram: Some(7 << 30), swap: None }, "the v1 sentinel is unlimited"); + + write(&leaf.join("memory.limit_in_bytes"), "3221225472\n"); + write(&leaf.join("memory.usage_in_bytes"), "1073741824\n"); + write(&leaf.join("memory.stat"), "total_inactive_file 0\n"); + assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::Limited { ram: Some(2 << 30), swap: None }, "the tighter leaf limit wins"); + + write(&mount.join("memory.memsw.limit_in_bytes"), "9663676416\n"); + write(&mount.join("memory.memsw.usage_in_bytes"), "2147483648\n"); + assert_eq!( + linux::cgroup_limits(&proc, &sys), + linux::CgroupLimits::Limited { ram: Some(2 << 30), swap: Some(1 << 30) }, + "swap room is memsw limit - memory limit - swap used at the same ancestor, not combined headroom minus RAM headroom" + ); + + write(&mount.join("memory.memsw.usage_in_bytes"), "3221225472\n"); + assert_eq!( + linux::cgroup_limits(&proc, &sys), + linux::CgroupLimits::Limited { ram: Some(2 << 30), swap: Some(0) }, + "a gigabyte already swapped uses the ancestor's whole swap allowance" + ); + + write(&mount.join("memory.memsw.limit_in_bytes"), "10737418240\n"); + assert_eq!( + linux::cgroup_limits(&proc, &sys), + linux::CgroupLimits::Limited { ram: Some(2 << 30), swap: Some(1 << 30) }, + "only the memsw allowance above the memory limit counts as added swap room" + ); + + write(&mount.join("memory.limit_in_bytes"), "9223372036854771712\n"); + write(&leaf.join("memory.limit_in_bytes"), "9223372036854771712\n"); + write(&mount.join("memory.memsw.limit_in_bytes"), "2147483648\n"); + write(&mount.join("memory.memsw.usage_in_bytes"), "0\n"); + assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::Limited { ram: Some(2 << 30), swap: Some(0) }, "memsw alone still caps RAM"); + + std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); + } + + #[cfg(target_os = "linux")] + #[test] + fn linux_v1_swap_bounds_combine_per_ancestor() { + let (proc, sys) = fixture("v1-swap-ancestors"); + write(&proc.join("self/cgroup"), "4:memory:/a/b\n"); + let mount = sys.join("memory"); + write(&proc.join("self/mountinfo"), &format!("31 24 0:26 / {} rw - cgroup cgroup rw,memory\n", mount.display())); + write(&mount.join("memory.limit_in_bytes"), "8589934592\n"); + write(&mount.join("memory.usage_in_bytes"), "2147483648\n"); + write(&mount.join("memory.memsw.limit_in_bytes"), "9663676416\n"); + write(&mount.join("memory.memsw.usage_in_bytes"), "2147483648\n"); + write(&mount.join("a/memory.limit_in_bytes"), "10737418240\n"); + write(&mount.join("a/memory.usage_in_bytes"), "1073741824\n"); + write(&mount.join("a/memory.memsw.limit_in_bytes"), "21474836480\n"); + write(&mount.join("a/memory.memsw.usage_in_bytes"), "2147483648\n"); + write(&mount.join("a/b/memory.limit_in_bytes"), "3221225472\n"); + write(&mount.join("a/b/memory.usage_in_bytes"), "1073741824\n"); + + assert_eq!( + linux::cgroup_limits(&proc, &sys), + linux::CgroupLimits::Limited { ram: Some(2 << 30), swap: Some(1 << 30) }, + "root swap allowance is 9 - 8 - 0 = 1 GiB; /a contributes 9 GiB and the leaf adds no swap limit" + ); + std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); + } + + #[cfg(target_os = "linux")] + #[test] + fn linux_v1_mount_root_prefix_is_stripped() { + let (proc, sys) = fixture("v1-root"); + write(&proc.join("self/cgroup"), "4:memory:/docker/abc/child\n"); + let mount = sys.join("memory"); + write(&proc.join("self/mountinfo"), &format!("31 24 0:26 /docker/abc {} rw,relatime - cgroup cgroup rw,memory\n", mount.display())); + write(&mount.join("memory.limit_in_bytes"), "1073741824\n"); + write(&mount.join("memory.usage_in_bytes"), "0\n"); + write(&mount.join("memory.stat"), "total_inactive_file 0\n"); + write(&mount.join("child/memory.limit_in_bytes"), "1073741824\n"); + write(&mount.join("child/memory.usage_in_bytes"), "0\n"); + assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::Limited { ram: Some(1 << 30), swap: None }); + std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); + } + + #[cfg(target_os = "linux")] + #[test] + fn linux_cgroup_falls_back_to_conventional_layouts() { + let (proc, sys) = fixture("fallback-v2"); + write(&proc.join("self/cgroup"), "0::/app.scope\n"); + write(&sys.join("app.scope/memory.max"), "1073741824\n"); + write(&sys.join("app.scope/memory.current"), "0\n"); + assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::Limited { ram: Some(1 << 30), swap: None }); + std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); + + let (proc, sys) = fixture("fallback-v1"); + write(&proc.join("self/cgroup"), "4:memory:/app\n"); + write(&sys.join("memory/memory.limit_in_bytes"), "1073741824\n"); + write(&sys.join("memory/memory.usage_in_bytes"), "0\n"); + write(&sys.join("memory/memory.stat"), "total_inactive_file 0\n"); + write(&sys.join("memory/app/memory.limit_in_bytes"), "1073741824\n"); + write(&sys.join("memory/app/memory.usage_in_bytes"), "0\n"); + write(&sys.join("memory/app/memory.stat"), "total_inactive_file 0\n"); + assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::Limited { ram: Some(1 << 30), swap: None }); + std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); + } + + #[cfg(target_os = "linux")] + #[test] + fn linux_unreadable_constraints_fail_closed() { + let (proc, sys) = fixture("unreadable-missing-dir"); + write(&proc.join("self/cgroup"), "0::/missing\n"); + write(&proc.join("meminfo"), "MemAvailable: 12582912 kB\nSwapFree: 2097152 kB\n"); + assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::Unreadable); + let snap = linux::snapshot_at(&proc, &sys); + assert_eq!(snap.available_ram, Some(0), "host memory must not be assumed"); + assert_eq!(snap.swap_free, Some(0)); + std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); + + let (proc, sys) = fixture("unreadable-v1-limit"); + write(&proc.join("self/cgroup"), "4:memory:/app\n"); + let mount = sys.join("memory"); + write(&proc.join("self/mountinfo"), &format!("31 24 0:26 / {} rw,relatime - cgroup cgroup rw,memory\n", mount.display())); + std::fs::create_dir_all(mount.join("app")).unwrap(); + assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::Unreadable); + std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); + + let (proc, sys) = fixture("unreadable-v2-limit"); + write(&proc.join("self/cgroup"), "0::/app\n"); + write(&sys.join("app/memory.max"), "lots\n"); + write(&sys.join("app/memory.current"), "0\n"); + assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::Unreadable); + std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); + + let (proc, sys) = fixture("no-memory-controller"); + write(&proc.join("self/cgroup"), "4:cpu,cpuacct:/user.slice\n"); + assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::NotConstrained); + std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); + + let (proc, sys) = fixture("malformed-membership"); + write(&proc.join("self/cgroup"), "0:memory:/app\n"); + write(&proc.join("meminfo"), "MemAvailable: 12582912 kB\nSwapFree: 2097152 kB\n"); + assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::Unreadable, "a malformed membership line cannot prove the controller absent"); + assert_eq!(linux::snapshot_at(&proc, &sys).available_ram, Some(0), "host memory must not be assumed"); + std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); + + let (proc, sys) = fixture("empty-membership"); + write(&proc.join("self/cgroup"), ""); + assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::Unreadable, "an empty membership file cannot prove absence"); + std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); + + let (proc, sys) = fixture("mountinfo-without-cgroup"); + write(&proc.join("self/cgroup"), "0::/app\n"); + write(&proc.join("self/mountinfo"), "30 20 8:1 / / rw - ext4 /dev/root rw\n"); + write(&sys.join("app/memory.max"), "1073741824\n"); + write(&sys.join("app/memory.current"), "0\n"); + assert_eq!( + linux::cgroup_limits(&proc, &sys), + linux::CgroupLimits::Unreadable, + "a readable mountinfo without a cgroup mount must not be guessed away by the conventional path" + ); + std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); + } + + #[cfg(target_os = "linux")] + #[test] + fn linux_snapshot_combines_host_meminfo_and_cgroup_room() { + let (proc, sys) = fixture("snapshot"); + write(&proc.join("self/cgroup"), "0::/app.scope\n"); + write(&proc.join("meminfo"), "MemTotal: 16777216 kB\nMemAvailable: 12582912 kB\nSwapFree: 2097152 kB\n"); + write(&proc.join("self/mountinfo"), &format!("30 20 0:26 / {} rw,relatime - cgroup2 cgroup2 rw\n", sys.display())); + write(&sys.join("app.scope/memory.max"), "4294967296\n"); + write(&sys.join("app.scope/memory.current"), "0\n"); + write(&sys.join("app.scope/memory.swap.max"), "536870912\n"); + write(&sys.join("app.scope/memory.swap.current"), "0\n"); + + let snap = linux::snapshot_at(&proc, &sys); + assert_eq!(snap.available_ram, Some(4 << 30), "the cgroup limit is smaller than host MemAvailable"); + assert_eq!(snap.swap_free, Some(512 << 20)); + assert_eq!(snap.commit_headroom, None); + assert_eq!(linux::available_at(&proc, &sys), Some((4 << 30) + (512 << 20)), "legacy helper keeps RAM + swap"); + std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); + } } diff --git a/crates/io/src/psd_budget.rs b/crates/io/src/psd_budget.rs index e6e4ba6eab..4a4463abd4 100644 --- a/crates/io/src/psd_budget.rs +++ b/crates/io/src/psd_budget.rs @@ -33,6 +33,62 @@ struct ImportBudget { workspace: u64, } +#[cfg(any(test, feature = "testgen"))] +thread_local! { + /// Extra bytes added to every estimate on this thread by [`with_test_estimate_charge`]. + static TEST_ESTIMATE_CHARGE: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Adds `bytes` to every PSD memory estimate made on this thread while `f` runs. +/// +/// A test seam: an admission-threshold test can use a tiny fixture, so nothing near a gigabyte +/// is ever allocated, while still exercising the real import path with its approval flags and +/// gate ordering. Scoped to the thread and restored on return or panic. +/// +/// The charge lives on the calling thread. A real import that runs on another thread (or a +/// worker the engine starts for it) does not see it unless the caller propagates an explicitly +/// captured scope: [`crate::memory::TestMemoryScope`] captures this charge together with the +/// fixed mock snapshot, so an engine worker that enters that scope makes the same estimates as +/// the test thread. This is the seam engine and UI dev-dependency tests use through the +/// `testgen` feature; it is never process-global. +#[cfg(any(test, feature = "testgen"))] +pub fn with_test_estimate_charge(bytes: u64, f: impl FnOnce() -> R) -> R { + struct Reset(u64); + impl Drop for Reset { + fn drop(&mut self) { + TEST_ESTIMATE_CHARGE.with(|c| c.set(self.0)); + } + } + let prev = TEST_ESTIMATE_CHARGE.with(|c| c.replace(bytes)); + let _reset = Reset(prev); + f() +} + +/// The calling thread's current estimate charge, for an explicit scope to capture. +#[cfg(any(test, feature = "testgen"))] +pub(crate) fn test_estimate_charge() -> u64 { + TEST_ESTIMATE_CHARGE.with(|c| c.get()) +} + +/// Enters a captured estimate charge (an explicit [`crate::memory::TestMemoryScope`] worker) and +/// returns the charge it replaced, to restore after the scope ends. +#[cfg(any(test, feature = "testgen"))] +pub(crate) fn enter_test_estimate_charge(bytes: u64) -> u64 { + TEST_ESTIMATE_CHARGE.with(|c| c.replace(bytes)) +} + +/// The test seam's extra charge; zero in every other build. +fn test_estimate_charge_value() -> u64 { + #[cfg(any(test, feature = "testgen"))] + { + test_estimate_charge() + } + #[cfg(not(any(test, feature = "testgen")))] + { + 0 + } +} + fn overflow() -> IoError { IoError::Unsupported("PSD import pixel memory estimate overflow".into()) } @@ -149,6 +205,7 @@ pub fn estimate(file: &PsdFile) -> Result { budget.scratch(converted.checked_add(p.decoded_bytes).ok_or_else(overflow)?); } } + budget.retain(test_estimate_charge_value())?; let total_bytes = budget.required()?; Ok(PsdMemoryEstimate { retained_bytes: budget.retained, workspace_bytes: budget.workspace, total_bytes }) } diff --git a/crates/io/src/psd_budget/tests.rs b/crates/io/src/psd_budget/tests.rs index dab8c331e5..f6ace6a4c3 100644 --- a/crates/io/src/psd_budget/tests.rs +++ b/crates/io/src/psd_budget/tests.rs @@ -55,9 +55,14 @@ fn large_off_canvas_layer_is_rejected_without_decoding_its_tiny_data() { let mut bytes = file.to_bytes().unwrap(); bytes.truncate(bytes.len() - file.image_data.data.len() - 2); crate::memory::with_mock_memory(crate::memory::MemorySnapshot::UNKNOWN, || { - assert!(matches!(crate::import("huge.psd", &bytes), Err(IoError::PsdImportBudget { .. }))); + // Unknown memory keeps a conservative automatic allowance, so this file is refused + // pending approval (or by the hard platform limit on 32-bit) before any pixel decode. + assert!(matches!(crate::import("huge.psd", &bytes), Err(IoError::PsdApprovalRequired { .. } | IoError::PsdImportBudget { .. }))); let source = photocraft_psd::Source::new(bytes); - assert!(matches!(crate::import_source_for_open_with("huge.psd", source, &photocraft_raster::Interrupt::NONE), Err(IoError::PsdImportBudget { .. }))); + assert!(matches!( + crate::import_source_for_open_with("huge.psd", source, &photocraft_raster::Interrupt::NONE), + Err(IoError::PsdApprovalRequired { .. } | IoError::PsdImportBudget { .. }) + )); }); } @@ -238,6 +243,9 @@ fn adaptive_budget_requires_explicit_approval_in_confirmation_range() { #[test] fn explicit_approval_allows_confirmation_range_but_never_bypasses_hard_boundary() { + if cfg!(any(target_arch = "wasm32", target_pointer_width = "32")) { + return; // the 6 GiB hard limit is above the 32-bit aggregate cap + } let gb = 1u64 << 30; // Scale RAM so that est falls between S and H: @@ -262,6 +270,9 @@ fn explicit_approval_allows_confirmation_range_but_never_bypasses_hard_boundary( #[test] fn zero_available_ram_refuses_admission() { + if cfg!(target_arch = "wasm32") { + return; // the web policy ignores a host snapshot; it has its own tests + } let file = testgen::small(Version::Psd, Compression::Zip); let bytes = file.to_bytes().unwrap(); @@ -369,6 +380,26 @@ fn test_memory_scope_propagates_to_worker_thread() { }); } +#[test] +fn test_memory_scope_propagates_the_estimate_charge_to_a_worker() { + let gb = 1u64 << 30; + let file = std::sync::Arc::new(testgen::small(Version::Psd, Compression::Zip)); + let bare = estimate(&file); + with_test_estimate_charge(2 * gb, || { + assert_eq!(estimate(&file), bare + 2 * gb, "the charge is part of the estimate on this thread"); + let scope = crate::memory::TestMemoryScope::capture(); + let worker_file = std::sync::Arc::clone(&file); + std::thread::spawn(move || { + scope.run(|| assert_eq!(estimate(&worker_file), bare + 2 * gb, "a captured scope carries the charge to its worker")); + }) + .join() + .unwrap(); + }); + assert_eq!(estimate(&file), bare, "the charge is restored on this thread"); + let detached = std::sync::Arc::clone(&file); + assert_eq!(std::thread::spawn(move || estimate(&detached)).join().unwrap(), bare, "a worker outside the scope never sees it"); +} + #[test] fn nested_imports_consume_from_shared_allowance_and_do_not_reset_parent() { let gb = 1u64 << 30; @@ -401,3 +432,103 @@ fn nested_tiff_layer_import_consumes_composite_and_estimate_without_resetting_pa assert!(crate::psd_gate::consume_attempt_allowance(2 * gb).is_ok()); assert!(crate::psd_gate::consume_attempt_allowance(1).is_err()); } + +/// A real import carries the approval flag and its estimate bound into admission: the fixture is +/// tiny, and a thread-local test charge puts it in the confirmation band without allocating +/// gigabytes. +#[test] +fn real_import_propagates_high_memory_approval_and_its_estimate_bound() { + if cfg!(any(target_arch = "wasm32", target_pointer_width = "32")) { + return; + } + let gb = 1u64 << 30; + let file = testgen::small(Version::Psd, Compression::Zip); + let bytes = file.to_bytes().unwrap(); + let snapshot = crate::memory::MemorySnapshot { available_ram: Some(12 * gb), swap_free: None, commit_headroom: None }; + crate::memory::with_mock_memory(snapshot, || { + let automatic_layers = + crate::import_with_options("approval.psd", &bytes, &crate::ImportOptions::default()).expect("small file is automatic").document.layers.len(); + with_test_estimate_charge(7 * gb, || { + let required = match crate::import_with_options("approval.psd", &bytes, &crate::ImportOptions::default()) { + Err(IoError::PsdApprovalRequired { required, soft_limit, hard_limit, available_ram }) => { + assert_eq!((soft_limit, hard_limit), (6 * gb, 9 * gb)); + assert_eq!(available_ram, Some(12 * gb)); + required + } + other => panic!("expected an approval request, got {other:?}"), + }; + assert!((7 * gb..=9 * gb).contains(&required), "the charge lands in the approval band: {required}"); + + let approved = crate::ImportOptions { psd_high_memory_approved: true, ..Default::default() }; + assert_eq!( + crate::import_with_options("approval.psd", &bytes, &approved).expect("approved").document.layers.len(), + automatic_layers, + "an approved import decodes the same document as an automatic one" + ); + let bounded = crate::ImportOptions { psd_high_memory_approved: true, psd_approved_estimate: Some(required), ..Default::default() }; + assert!(crate::import_with_options("approval.psd", &bytes, &bounded).is_ok()); + let stale = crate::ImportOptions { psd_high_memory_approved: true, psd_approved_estimate: Some(required - 1), ..Default::default() }; + assert!(matches!(crate::import_with_options("approval.psd", &bytes, &stale), Err(IoError::PsdApprovalRequired { .. }))); + let lowered = crate::ImportOptions { psd_high_memory_approved: true, psd_max_pixel_bytes: required - 1, ..Default::default() }; + assert!(matches!( + crate::import_with_options("approval.psd", &bytes, &lowered), + Err(IoError::PsdImportBudget { required: r, limit: l }) if r == required && l == required - 1 + )); + }); + }); +} + +/// Available memory can drop while an import waits for the decode gate. The post-gate recheck +/// must then refuse before anything is decoded. A per-attempt provider, shared with this test +/// only, changes the measurement while the worker is blocked; nothing near a gigabyte is +/// allocated, and the real document is never opened. +#[test] +fn memory_drop_while_waiting_for_the_gate_refuses_before_decoding() { + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; + use std::time::{Duration, Instant}; + + if cfg!(any(target_arch = "wasm32", target_pointer_width = "32")) { + return; + } + let gb = 1u64 << 30; + let file = testgen::small(Version::Psd, Compression::Zip); + let bytes = file.to_bytes().unwrap(); + + // Hold the gate so the worker stops after its first admission check. + let _guard = crate::psd_gate::acquire_decode_admission(&photocraft_raster::Interrupt::NONE).expect("active decode"); + + let available = Arc::new(AtomicU64::new(32 * gb)); + let prechecked = Arc::new(AtomicBool::new(false)); + let (tx, rx) = std::sync::mpsc::channel(); + let handle = { + let available = Arc::clone(&available); + let prechecked = Arc::clone(&prechecked); + std::thread::spawn(move || { + let provider = std::rc::Rc::new(move || { + prechecked.store(true, Ordering::SeqCst); + crate::memory::MemorySnapshot { available_ram: Some(available.load(Ordering::SeqCst)), swap_free: None, commit_headroom: None } + }); + let result = + crate::memory::with_mock_memory_provider(provider, || crate::import_with_options("drop.psd", &bytes, &crate::ImportOptions::default())); + tx.send(result.map(|r| r.document.layers.len())).ok(); + }) + }; + + // The provider's first call is the pre-gate admission check. + let deadline = Instant::now() + Duration::from_secs(10); + while !prechecked.load(Ordering::SeqCst) { + assert!(Instant::now() < deadline, "the import never reached its admission check"); + std::thread::yield_now(); + } + // The precheck admitted it; now the machine has nothing left. Release the gate afterwards. + available.store(0, Ordering::SeqCst); + drop(_guard); + + let result = rx.recv_timeout(Duration::from_secs(10)).expect("the post-gate recheck must refuse instead of decoding"); + match result { + Err(IoError::PsdImportBudget { required, limit: 0 }) => assert!(required > 0), + other => panic!("expected the post-gate recheck to refuse with a zero budget, got {other:?}"), + } + handle.join().expect("worker thread"); +} diff --git a/crates/io/src/psd_gate.rs b/crates/io/src/psd_gate.rs index 8e00cc56c1..dc98a1346e 100644 --- a/crates/io/src/psd_gate.rs +++ b/crates/io/src/psd_gate.rs @@ -4,6 +4,10 @@ //! using cancellation-aware waiting outside the UI thread. Reentrant/nested decodes on //! the same thread are admitted without deadlocking and share the enclosing attempt's //! budget. Approval prompts never hold this lock. +//! +//! Nested callers check cancellation and take the reentrant guard before calling +//! `consume_attempt_allowance`, so a cancelled or refused nested import never spends its +//! parent's remaining allowance; a failed charge deducts nothing. use std::cell::Cell; use std::marker::PhantomData; @@ -66,10 +70,14 @@ pub(crate) fn set_active_attempt_budget(allowance: u64) { /// Consumes `bytes` from the enclosing attempt's remaining allowance, or returns /// [`IoError::PsdImportBudget`] if the required amount exceeds what remains. +/// +/// `u64::MAX` is a format estimate's saturated overflow sentinel, not a requirement: it is +/// refused even when the remaining allowance is also `u64::MAX` (the native unknown-memory +/// attempt cap), and a refusal never spends the parent's allowance. pub(crate) fn consume_attempt_allowance(bytes: u64) -> Result<(), IoError> { ATTEMPT_ALLOWANCE.with(|a| match a.get() { Some(remaining) => { - if bytes > remaining { + if bytes == u64::MAX || bytes > remaining { return Err(IoError::PsdImportBudget { required: bytes, limit: remaining }); } a.set(Some(remaining - bytes)); @@ -82,6 +90,13 @@ pub(crate) fn consume_attempt_allowance(bytes: u64) -> Result<(), IoError> { }) } +/// The enclosing attempt's remaining allowance, for tests that assert a nested import was +/// charged instead of resetting the parent attempt's budget. +#[cfg(test)] +pub(crate) fn active_attempt_remaining() -> Option { + ATTEMPT_ALLOWANCE.with(|a| a.get()) +} + /// Acquires process-wide admission to decode PSD pixels, or awaits completion of an active decode. /// /// If the current thread already holds admission (a nested/reentrant decode), it is admitted @@ -168,6 +183,23 @@ mod tests { assert_eq!(ATTEMPT_ALLOWANCE.with(|a| a.get()), Some(2000)); } + /// A saturated estimate is not a charge, even when the parent's allowance is the same + /// saturated value: it is refused and spends nothing. + #[test] + fn a_saturated_nested_charge_is_refused_and_spends_nothing() { + let ctl = photocraft_raster::Interrupt::NONE; + let _guard = acquire_decode_admission(&ctl).unwrap(); + set_active_attempt_budget(u64::MAX); + + let err = consume_attempt_allowance(u64::MAX).expect_err("the overflow sentinel is never a charge"); + assert!(matches!(err, IoError::PsdImportBudget { required, limit } if required == u64::MAX && limit == u64::MAX)); + assert_eq!(active_attempt_remaining(), Some(u64::MAX), "a refused sentinel charge leaves the parent's allowance untouched"); + + // One byte below the sentinel is an ordinary, enormous charge. + assert!(consume_attempt_allowance(u64::MAX - 1).is_ok()); + assert_eq!(active_attempt_remaining(), Some(1)); + } + #[test] fn gate_serializes_concurrent_threads() { let ctl = photocraft_raster::Interrupt::NONE; diff --git a/crates/io/src/psd_import.rs b/crates/io/src/psd_import.rs index 59981b5db3..154f0fb27d 100644 --- a/crates/io/src/psd_import.rs +++ b/crates/io/src/psd_import.rs @@ -1228,6 +1228,30 @@ pub(crate) fn psd_to_document_decoded_integrity( psd_to_document_global(file, ctl, decoded, global, false).map(|(doc, warnings, damaged, _)| (doc, warnings, damaged)) } +/// Admission for a legacy conversion. +/// +/// A nested conversion spends the enclosing attempt's remaining allowance and never resets it. +/// A standalone conversion refuses an ineligible file before waiting for the decode gate and +/// rechecks the fresh measurement once admitted, so a refused file never waits behind an active +/// decode and a memory drop during the wait is seen before any pixel is decoded. +fn legacy_admission(est: &crate::psd_budget::PsdMemoryEstimate, ctl: &photocraft_raster::Interrupt) -> Result { + if crate::psd_gate::is_holding_admission() { + // Cancellation is checked before the charge and admission is reentrant here, so a + // cancelled or refused nested conversion never spends its parent's allowance. + ctl.check().map_err(|_| crate::IoError::Cancelled)?; + let guard = crate::psd_gate::acquire_decode_admission(ctl)?; + crate::psd_gate::consume_attempt_allowance(est.total_bytes)?; + return Ok(guard); + } + let snapshot = || crate::memory::MemorySnapshot::current(); + // Legacy standalone is unconfirmed: it cannot silently auto allow an estimate above S. + crate::memory::evaluate_psd_admission(est.total_bytes, 0, false, None, snapshot())?; + let guard = crate::psd_gate::acquire_decode_admission(ctl)?; + let outcome = crate::memory::evaluate_psd_admission(est.total_bytes, 0, false, None, snapshot())?; + crate::psd_gate::set_active_attempt_budget(outcome.authorized_total.saturating_sub(est.total_bytes)); + Ok(guard) +} + fn psd_to_document_global( file: &PsdFile, ctl: &photocraft_raster::Interrupt, @@ -1238,8 +1262,7 @@ fn psd_to_document_global( // Legacy conversion APIs return warnings rather than Result. Reject before metadata or // pixels are decoded, and retain the fatal error for the ordinary import path. An import // that checked the file against its own limit first (`preflighted`) does not estimate twice. - let _guard; - if !preflighted { + let _guard = if !preflighted || !crate::psd_gate::is_holding_admission() { let est = match crate::psd_budget::estimate(file) { Ok(e) => e, Err(error) => { @@ -1248,76 +1271,17 @@ fn psd_to_document_global( return Some((doc, vec![message], true, Some(error))); } }; - - if crate::psd_gate::is_holding_admission() { - // Nested conversion: consume from enclosing attempt allowance without fresh budget or deadlocks - if let Err(error) = crate::psd_gate::consume_attempt_allowance(est.total_bytes) { - let message = error.to_string(); - let doc = Document::new("Untitled", Size::new(1, 1), ColorMode::Rgb, SampleType::U8); - return Some((doc, vec![message], true, Some(error))); - } - match crate::psd_gate::acquire_decode_admission(ctl) { - Ok(g) => _guard = Some(g), - Err(error) => { - let message = error.to_string(); - let doc = Document::new("Untitled", Size::new(1, 1), ColorMode::Rgb, SampleType::U8); - return Some((doc, vec![message], true, Some(error))); - } - } - } else { - // Standalone legacy conversion: acquire admission gate and check adaptive budget - match crate::psd_gate::acquire_decode_admission(ctl) { - Ok(g) => _guard = Some(g), - Err(error) => { - let message = error.to_string(); - let doc = Document::new("Untitled", Size::new(1, 1), ColorMode::Rgb, SampleType::U8); - return Some((doc, vec![message], true, Some(error))); - } - } - let snapshot = crate::memory::MemorySnapshot::current(); - // Legacy standalone is unconfirmed: cannot silently auto allow > S. - match crate::memory::evaluate_psd_admission(est.total_bytes, 0, false, None, snapshot) { - Ok(outcome) => { - crate::psd_gate::set_active_attempt_budget(outcome.authorized_total.saturating_sub(est.total_bytes)); - } - Err(error) => { - let message = error.to_string(); - let doc = Document::new("Untitled", Size::new(1, 1), ColorMode::Rgb, SampleType::U8); - return Some((doc, vec![message], true, Some(error))); - } - } - } - } else if !crate::psd_gate::is_holding_admission() { - // Fallback for callers passing preflighted=true without holding admission: - // acquire admission gate and initialize allowance safely. - match crate::psd_gate::acquire_decode_admission(ctl) { - Ok(g) => _guard = Some(g), + match legacy_admission(&est, ctl) { + Ok(guard) => Some(guard), Err(error) => { let message = error.to_string(); let doc = Document::new("Untitled", Size::new(1, 1), ColorMode::Rgb, SampleType::U8); return Some((doc, vec![message], true, Some(error))); } } - let est = match crate::psd_budget::estimate(file) { - Ok(e) => e, - Err(error) => { - let message = error.to_string(); - let doc = Document::new("Untitled", Size::new(1, 1), ColorMode::Rgb, SampleType::U8); - return Some((doc, vec![message], true, Some(error))); - } - }; - let snapshot = crate::memory::MemorySnapshot::current(); - match crate::memory::evaluate_psd_admission(est.total_bytes, 0, false, None, snapshot) { - Ok(outcome) => { - crate::psd_gate::set_active_attempt_budget(outcome.authorized_total.saturating_sub(est.total_bytes)); - } - Err(error) => { - let message = error.to_string(); - let doc = Document::new("Untitled", Size::new(1, 1), ColorMode::Rgb, SampleType::U8); - return Some((doc, vec![message], true, Some(error))); - } - } - } + } else { + None + }; let (mode, layered, multichannel, depth) = import_format(file); let h = &file.header; let mut warnings = Vec::new(); diff --git a/crates/io/src/psd_import/tests.rs b/crates/io/src/psd_import/tests.rs index 3fc4e3222e..a2b42161e4 100644 --- a/crates/io/src/psd_import/tests.rs +++ b/crates/io/src/psd_import/tests.rs @@ -600,3 +600,30 @@ fn only_allocation_failures_are_fatal_to_a_layer_channel() { other => panic!("{other:?}"), } } + +/// A standalone legacy conversion that is over budget is refused *before* it waits for the +/// decode gate, so an ineligible file never blocks behind an active decode. The legacy API keeps +/// its warning (the ordinary import path still receives the fatal error). +#[test] +fn legacy_conversion_refuses_before_waiting_for_the_decode_gate() { + let mut file = testgen::small(Version::Psd, Compression::Zip); + // 30,000² tiles are several GiB: far past the hard budget of the 4 GiB machine below. + file.layers_mut()[0].rect = photocraft_psd::Rect::from_xywh(0, 0, 30_000, 30_000); + let snapshot = crate::memory::MemorySnapshot { available_ram: Some(4 << 30), swap_free: None, commit_headroom: None }; + let ctl = photocraft_raster::Interrupt::NONE; + let _guard = crate::psd_gate::acquire_decode_admission(&ctl).expect("active decode"); + + let (tx, rx) = std::sync::mpsc::channel(); + let handle = std::thread::spawn(move || { + let (doc, warnings) = crate::memory::with_mock_memory(snapshot, || crate::psd_to_document(&file)); + tx.send((doc.layers.len(), warnings)).ok(); + }); + + // The refusal arrives while this thread still holds the gate: the legacy call never waited. + let (layers, warnings) = rx.recv_timeout(std::time::Duration::from_secs(10)).expect("a refused legacy conversion must not wait for the decode gate"); + assert_eq!(layers, 0, "a refused conversion returns an empty document"); + assert!(warnings.iter().any(|w| w.contains("pixel memory")), "{warnings:?}"); + + drop(_guard); + handle.join().expect("worker thread"); +} diff --git a/crates/io/src/tiff_layers.rs b/crates/io/src/tiff_layers.rs index 2ca338f07e..4bdd0b224e 100644 --- a/crates/io/src/tiff_layers.rs +++ b/crates/io/src/tiff_layers.rs @@ -48,19 +48,60 @@ fn set_resource(resources: &mut Vec, id: u16, data: Option Result { - let mut warnings = Vec::new(); - let (isd, w) = match ImageSourceData::from_bytes(layers) { +/// Everything a layered TIFF page's Photoshop tags say, read before any pixel is decoded. +/// +/// It is built from a [`codecs::TiffBands`] page's dimensions, layout, sample type, +/// extra-sample count, metadata and decoder scratch bound, never from its pixels, so the whole +/// import is admitted through the adaptive budget before the composite is decoded. +pub(crate) struct LayeredPlan { + /// The PSD model the layer tag rebuilds, or `None` when the tag could not be read and the + /// page opens flattened. + model: Option>, + /// Why the page opens flattened, when the layer tag did not parse. + flatten_warning: Option, + /// Warnings the layer tag and the image resources produced. + warnings: Vec, + /// Bytes this import needs beside what it already holds, for admission; see + /// [`layered_import_bytes`]. + pub(crate) required: u64, +} + +struct LayeredModel { + /// The PSD model with an empty merged image: the TIFF's pixels replace it after decoding. + file: photocraft_psd::PsdFile, +} + +impl LayeredPlan { + /// Whether the page's layer tag parsed into a PSD model, as opposed to opening flattened. + /// + /// A flattened fallback still reads the page's image resources for channel names and spot + /// flags, so the decoder's copies stay available; a layered page's tags are already in the + /// plan and the decoder's copies can be dropped. + pub(crate) fn is_layered(&self) -> bool { + self.model.is_some() + } +} + +/// Reads a layered TIFF page's layer tag and image resources into a [`LayeredPlan`] without +/// decoding any pixels, and checks the group nesting depth and the PSD memory estimate. +pub(crate) fn preflight(bands: &codecs::TiffBands<'_>) -> Result { + let (w, h) = bands.dimensions(); + let (layout, sample) = (bands.layout(), bands.sample_type()); + let extras = bands.extra_samples(); + let scratch = bands.decode_scratch_bytes(); + let layers = bands.meta.photoshop_layers.as_deref().unwrap_or_default(); + let (isd, isd_warnings) = match ImageSourceData::from_bytes(layers) { Ok(v) => v, Err(e) => { - let mut r = image_to_document(name, img)?; - r.warnings.push(format!("the PSD layer data could not be read ({e}); opened flattened")); - return Ok(r); + // Malformed layer data opens flat, but the composite it opens still has to be + // bounded: its whole-image decode and the conversion are admitted too. + let warning = format!("the PSD layer data could not be read ({e}); opened flattened"); + let required = crate::flat::flattened_import_bytes(w, h, layout, sample, extras, scratch); + return Ok(LayeredPlan { model: None, flatten_warning: Some(warning), warnings: Vec::new(), required }); } }; - warnings.extend(w.into_iter().map(|w| format!("layer data: {w}"))); - let (mut resources, w) = match img.meta.photoshop_resources.as_deref().filter(|r| !r.is_empty()).map(resources_from_bytes) { + let mut warnings: Vec = isd_warnings.into_iter().map(|w| format!("layer data: {w}")).collect(); + let (mut resources, resource_warnings) = match bands.meta.photoshop_resources.as_deref().filter(|r| !r.is_empty()).map(resources_from_bytes) { Some(Ok(v)) => v, Some(Err(e)) => { warnings.push(format!("the PSD image resources could not be read: {e}")); @@ -68,44 +109,38 @@ pub(crate) fn import_layered(name: &str, img: &Image, layers: &[u8], pixel_limit } None => (Vec::new(), Vec::new()), }; - warnings.extend(w.into_iter().map(|w| format!("image resources: {w}"))); - // The profile, resolution, XMP and EXIF are the TIFF's own tags; they win over any copy - // in the resources. - set_resource(&mut resources, ids::ICC_PROFILE, img.icc.clone()); - if let Some((x, y)) = img.meta.dpi.filter(|d| d.0 > 0.0) { + warnings.extend(resource_warnings.into_iter().map(|w| format!("image resources: {w}"))); + // The profile, resolution, XMP and EXIF are the TIFF's own tags; they win over any copy in + // the resources. They are set here, from the page's metadata, because the model is complete + // before the composite exists. + set_resource(&mut resources, ids::ICC_PROFILE, bands.icc.clone()); + if let Some((x, y)) = bands.meta.dpi.filter(|d| d.0 > 0.0) { // Both axes, so the PSD import notes a vertical resolution the document can't keep. let ri = ResolutionInfo { v_res_fixed: ResolutionInfo::from_dpi(f64::from(y)).v_res_fixed, ..ResolutionInfo::from_dpi(f64::from(x)) }; set_resource(&mut resources, ids::RESOLUTION_INFO, Some(ri.to_bytes())); } - if let Some(xmp) = &img.meta.xmp { + if let Some(xmp) = &bands.meta.xmp { set_resource(&mut resources, ids::XMP, Some(xmp.as_bytes().to_vec())); } - if let Some(exif) = &img.meta.exif { + if let Some(exif) = &bands.meta.exif { set_resource(&mut resources, ids::EXIF, Some(exif.clone())); } // The TIFF's pixels become the PSD's merged image: planar, big-endian, CMYK inverted. - let (mode, color_channels) = match img.layout() { - ChannelLayout::Gray | ChannelLayout::GrayA => (PsdMode::Grayscale, 1), - ChannelLayout::Rgb | ChannelLayout::Rgba => (PsdMode::Rgb, 3), - ChannelLayout::Cmyk | ChannelLayout::CmykA => (PsdMode::Cmyk, 4), - ChannelLayout::Lab | ChannelLayout::LabA => (PsdMode::Lab, 3), + let mode = match layout { + ChannelLayout::Gray | ChannelLayout::GrayA => PsdMode::Grayscale, + ChannelLayout::Rgb | ChannelLayout::Rgba => PsdMode::Rgb, + ChannelLayout::Cmyk | ChannelLayout::CmykA => PsdMode::Cmyk, + ChannelLayout::Lab | ChannelLayout::LabA => PsdMode::Lab, }; - let depth = match img.sample_type() { + let depth = match sample { CSample::U8 => 8, CSample::U16 => 16, CSample::F16 | CSample::F32 => 32, }; - let (w, h) = img.dimensions(); - // A half-float image is stored as 32-bit float; it is converted after the budget check, as - // the conversion allocates several times the decoded image. - let bps = if img.sample_type() == CSample::F16 { CSample::F32.bytes() } else { img.sample_type().bytes() }; - let plane_len = (w as usize).saturating_mul(h as usize).saturating_mul(bps); - // The budget is checked on the model before the planar copy of the whole image is allocated, - // so an over-budget file is refused without it. The extra samples that fit the image count - // towards the channels the check sees. - let extras = img.meta.extra_samples.iter().take(codecs::MAX_EXTRA_SAMPLES).filter(|e| e.data.len() == plane_len).count(); - let channels = u16::try_from(img.layout().channels().saturating_add(extras)).map_err(|_| IoError::Unsupported("too many channels".into()))?; + // The extra samples that fit the image count towards the channels the estimate sees; the + // codec already capped them at [`codecs::MAX_EXTRA_SAMPLES`]. + let channels = u16::try_from(layout.channels().saturating_add(extras)).map_err(|_| IoError::Unsupported("too many channels".into()))?; let header = Header::new(isd.version, w, h, channels, depth, mode); let mut file = isd.into_psd(header, resources, ImageData::new(Compression::Raw, Vec::new())); // Empty `Patt` and the default filter mask are written so Photoshop's block list matches @@ -118,24 +153,73 @@ pub(crate) fn import_layered(name: &str, img: &Image, layers: &[u8], pixel_limit }); // The same nesting limit as opening a PSD (#2940). crate::psd_import::check_group_depth(&file)?; - // The planar composite is copied whether or not the budget counts a merged image, so it is - // held against the allowance as well. - let composite = u64::try_from(plane_len.saturating_mul(usize::from(channels))).unwrap_or(u64::MAX); - if composite > pixel_limit { - return Err(photocraft_psd::PsdError::LimitExceeded("the TIFF composite exceeds the import allowance").into()); - } - let remaining_limit = pixel_limit - composite; - let est = crate::psd_budget::check(&file, remaining_limit)?; - let nested = crate::psd_gate::is_holding_admission(); - if nested { - crate::psd_gate::consume_attempt_allowance( - est.total_bytes.checked_add(composite).ok_or_else(|| IoError::Unsupported("TIFF import estimate overflow".into()))?, - )?; - } - let _guard = crate::psd_gate::acquire_decode_admission(&photocraft_raster::Interrupt::NONE)?; - if !nested { - crate::psd_gate::set_active_attempt_budget(remaining_limit.saturating_sub(est.total_bytes)); - } + // Layer/mask/merged pixel planes and their conversion workspace, from the tag alone. + let est = crate::psd_budget::estimate(&file)?; + // The decoder makes its own copy of the two Photoshop tags while decoding. The plan's + // copies are already made and are bounded by the file, not by the image size. + let tags = layers.len().saturating_add(bands.meta.photoshop_resources.as_ref().map_or(0, |r| r.len())); + let required = layered_import_bytes(est.total_bytes, w, h, layout.channels(), sample, extras, scratch, tags); + Ok(LayeredPlan { model: Some(Box::new(LayeredModel { file })), flatten_warning: None, warnings, required }) +} + +/// Bytes a layered TIFF import needs beyond [`crate::psd_budget::estimate`]'s layer, mask, +/// merged and workspace charge: +/// +/// - the decoded composite and its extra-sample planes: the decoder holds the interleaved extra +/// buffer and one split plane per sample at once; +/// - for a 16-bit float page, the normalized and 32-bit copies the conversion makes while the +/// 16-bit image and its planes are still alive; +/// - the planar big-endian merged-image copy, with one extra plane copied while it grows; +/// - the decoder's own scratch buffers (see [`codecs::TiffBands::decode_scratch_bytes`]); +/// - the decoder's copy of the Photoshop tags. +/// +/// Every term saturates instead of wrapping: an estimate too large for `u64` is the `u64::MAX` +/// sentinel, which admission always refuses (see [`crate::memory::MemoryBudget::decide`]), so it +/// can never become an admitted, wrapped-around value. +#[allow(clippy::too_many_arguments)] +pub(crate) fn layered_import_bytes(est: u64, w: u32, h: u32, channels: usize, sample: CSample, extras: usize, scratch: u64, tags: usize) -> u64 { + let pixels = u64::from(w).saturating_mul(u64::from(h)); + let ch = channels as u64; + let e = extras as u64; + let in_bps = sample.bytes() as u64; + let out_bps = if sample == CSample::F16 { CSample::F32.bytes() as u64 } else { in_bps }; + let decoded = pixels.saturating_mul(ch.saturating_add(e.saturating_mul(2))).saturating_mul(in_bps); + // A normalized 32-bit buffer and the 32-bit result exist beside the 16-bit source. + let float_convert = if sample == CSample::F16 { pixels.saturating_mul(ch.saturating_add(e)).saturating_mul(8) } else { 0 }; + let planar = pixels.saturating_mul(ch.saturating_add(e)).saturating_mul(out_bps); + let plane = if e > 0 { pixels.saturating_mul(out_bps) } else { 0 }; + est.saturating_add(decoded).saturating_add(float_convert).saturating_add(planar).saturating_add(plane).saturating_add(scratch).saturating_add(tags as u64) +} + +/// Opens a layered TIFF page from its decoded `img`, using the model and estimate [`preflight`] +/// read from the same page's tags. +/// +/// The caller has admitted `plan.required` through the adaptive budget and holds decode +/// admission before any pixel is decoded, so nothing here resolves a second budget or charges +/// the enclosing attempt again: a top-level TIFF is charged once, and a nested independent one +/// spends only what its parent still has. `ctl` stops the PSD conversion between layers. +pub(crate) fn import_layered(name: &str, img: &Image, plan: LayeredPlan, ctl: &photocraft_raster::Interrupt) -> Result { + let LayeredPlan { model, flatten_warning, mut warnings, required: _ } = plan; + let Some(model) = model else { + let mut r = image_to_document(name, img)?; + if let Some(warning) = flatten_warning { + r.warnings.push(warning); + } + return Ok(r); + }; + let LayeredModel { mut file } = *model; + let mode = match img.layout() { + ChannelLayout::Gray | ChannelLayout::GrayA => PsdMode::Grayscale, + ChannelLayout::Rgb | ChannelLayout::Rgba => PsdMode::Rgb, + ChannelLayout::Cmyk | ChannelLayout::CmykA => PsdMode::Cmyk, + ChannelLayout::Lab | ChannelLayout::LabA => PsdMode::Lab, + }; + let color_channels = img.layout().color_channels(); + // A half-float image is stored as 32-bit float; it is converted here, after admission, as + // the conversion allocates several times the decoded image (charged by the estimate). + let bps = if img.sample_type() == CSample::F16 { CSample::F32.bytes() } else { img.sample_type().bytes() }; + let (w, h) = img.dimensions(); + let plane_len = (w as usize).saturating_mul(h as usize).saturating_mul(bps); let img = if img.sample_type() == CSample::F16 { warnings.push("16-bit float samples are stored as 32-bit float".to_string()); std::borrow::Cow::Owned(img.convert(img.layout(), CSample::F32)) @@ -160,11 +244,11 @@ pub(crate) fn import_layered(name: &str, img: &Image, layers: &[u8], pixel_limit // Spot planes are read in Photoshop's polarity (0 = solid ink), as from a PSD. The importer's // rule for PSDs from earlier PhotoCraft versions never applies: those versions stored no // extra samples in a TIFF, and a TIFF with them carries 1053. - let (mut doc, w, _, fatal_error) = psd_to_document_owned(file, &photocraft_raster::Interrupt::NONE, None).ok_or(IoError::Cancelled)?; + let (mut doc, psd_warnings, _, fatal_error) = psd_to_document_owned(file, ctl, None).ok_or(IoError::Cancelled)?; if let Some(error) = fatal_error { return Err(error); } - warnings.extend(w); + warnings.extend(psd_warnings); doc.name = name.to_string(); if doc.icc_profile.is_none() { doc.icc_profile = img.icc.clone().map(Arc::new); @@ -1001,7 +1085,7 @@ mod tests { use photocraft_doc::{Color, Document, Fill, FillCache, Layer, LayerContent}; use photocraft_raster::Surface; - use crate::{ExportOptions, export}; + use crate::{ExportOptions, ImportOptions, IoError, export}; /// Hidden solid fills on a 2048² canvas: counted as canvas-sized planes (16 MiB each at 8 /// bits), 140 of them pass 2 GB, while their real pixels (the cached rendering, pruned to @@ -1020,6 +1104,30 @@ mod tests { doc } + /// A small two-layer RGB document exported as a TIFF with Photoshop layer data: the real + /// file the admission tests below import. No fixture is decoded at a size near a threshold. + fn layered_tiff(w: u32, h: u32) -> Vec { + let mut doc = Document::new("charge.tif", photocraft_geom::Size::new(w, h), photocraft_color::ColorMode::Rgb, photocraft_color::SampleType::U8); + let fmt = doc.pixel_format(); + let bytes = (w as usize * h as usize * fmt.bytes_per_pixel()).max(1); + for name in ["bottom", "top"] { + let mut s = Surface::new(fmt); + s.write_interleaved(photocraft_geom::Rect::new(0, 0, w as i32, h as i32), &vec![96u8; bytes]); + doc.layers.push(Layer::new(name, LayerContent::Raster(s))); + } + export(&doc, "charge.tif", &ExportOptions { tiff_layers: true, ..Default::default() }).expect("export layered TIFF").bytes + } + + /// The bytes of the layered TIFF's decoded composite: the size of both the planar copy the + /// importer builds and the interleaved image it keeps beside it. + fn composite_bytes(bytes: &[u8]) -> u64 { + let img = photocraft_codecs::decode_with(bytes, &photocraft_codecs::DecodeOptions { keep_orientation: true, ..Default::default() }) + .expect("decode the composite"); + let (w, h) = img.dimensions(); + let sample = u64::try_from(img.sample_type().bytes()).unwrap_or(1); + u64::from(w).saturating_mul(u64::from(h)).saturating_mul(sample).saturating_mul(img.layout().channels() as u64) + } + #[test] fn layered_tiff_ignores_the_uncompressed_size_estimate() { let doc = many_cheap_fills(); @@ -1044,4 +1152,337 @@ mod tests { let (_, layers) = photocraft_codecs::tiff_photoshop_tags(&r.bytes); assert!(layers.unwrap().starts_with(photocraft_psd::tiff::SIGNATURE_PSB)); } + + /// The adaptive budget, not only the caller's pixel limit, decides a layered TIFF: a known + /// zero reading refuses the import before the planar composite is allocated, while a normal + /// machine opens the same file. Before this route the fixed 2 GiB default admitted it. + #[test] + fn layered_tiff_import_honours_a_known_zero_adaptive_budget() { + let bytes = layered_tiff(8, 6); + let zero = crate::memory::MemorySnapshot { available_ram: Some(0), swap_free: None, commit_headroom: None }; + crate::memory::with_mock_memory(zero, || match crate::import_with_options("zero.tif", &bytes, &ImportOptions::default()) { + Err(IoError::PsdImportBudget { limit: 0, .. }) => {} + other => panic!("expected an import-budget refusal, got {other:?}"), + }); + + let plenty = crate::memory::MemorySnapshot { available_ram: Some(16 << 30), swap_free: None, commit_headroom: None }; + crate::memory::with_mock_memory(plenty, || { + let opened = crate::import_with_options("plenty.tif", &bytes, &ImportOptions::default()).expect("a normal machine opens it"); + assert_eq!(opened.document.layers.len(), 2); + }); + } + + /// Approval and its estimate bound reach the TIFF route: the same options that a PSD takes + /// decide the layered TIFF's composite, layer and retained-image charge. The fixture is tiny; + /// the threshold charge is a thread-local test seam, so nothing near a gigabyte is allocated. + #[test] + fn layered_tiff_import_uses_the_adaptive_budget_approval_options() { + if cfg!(any(target_arch = "wasm32", target_pointer_width = "32")) { + return; + } + let gb = 1u64 << 30; + let bytes = layered_tiff(8, 6); + // 12 GiB: S = 6 GiB, H = 9 GiB. + let snapshot = crate::memory::MemorySnapshot { available_ram: Some(12 * gb), swap_free: None, commit_headroom: None }; + crate::memory::with_mock_memory(snapshot, || { + let plain = crate::import_with_options("l.tif", &bytes, &ImportOptions::default()).expect("automatic").document.layers.len(); + crate::psd_budget::with_test_estimate_charge(7 * gb, || { + let required = match crate::import_with_options("l.tif", &bytes, &ImportOptions::default()) { + Err(IoError::PsdApprovalRequired { required, soft_limit, hard_limit, available_ram }) => { + assert_eq!((soft_limit, hard_limit), (6 * gb, 9 * gb)); + assert_eq!(available_ram, Some(12 * gb)); + required + } + other => panic!("expected an approval request, got {other:?}"), + }; + assert!((7 * gb..=9 * gb).contains(&required), "the charge lands in the approval band: {required}"); + + // Approval opens the same file. + let approved = ImportOptions { psd_high_memory_approved: true, ..Default::default() }; + assert_eq!(crate::import_with_options("l.tif", &bytes, &approved).expect("approved").document.layers.len(), plain); + // A matching one-shot estimate bound is enough; a smaller one needs fresh consent. + let bounded = ImportOptions { psd_high_memory_approved: true, psd_approved_estimate: Some(required), ..Default::default() }; + assert!(crate::import_with_options("l.tif", &bytes, &bounded).is_ok()); + let stale = ImportOptions { psd_high_memory_approved: true, psd_approved_estimate: Some(required - 1), ..Default::default() }; + assert!(matches!(crate::import_with_options("l.tif", &bytes, &stale), Err(IoError::PsdApprovalRequired { .. }))); + // Approval never bypasses a lower caller limit. + let lowered = ImportOptions { psd_high_memory_approved: true, psd_max_pixel_bytes: required - 1, ..Default::default() }; + assert!(matches!( + crate::import_with_options("l.tif", &bytes, &lowered), + Err(IoError::PsdImportBudget { required: r, limit: l }) if r == required && l == required - 1 + )); + }); + }); + } + + /// A real nested layered TIFF spends the enclosing attempt's remaining allowance: the charge + /// covers the planar composite and the decoded image kept beside it, and a refused nested + /// import neither waits for a fresh gate nor resets the parent's budget. + #[test] + fn real_nested_layered_tiff_import_is_charged_without_resetting_the_parent() { + let bytes = layered_tiff(8, 6); + let composite = composite_bytes(&bytes); + assert!(composite > 0); + let ctl = photocraft_raster::Interrupt::NONE; + + let _guard = crate::psd_gate::acquire_decode_admission(&ctl).expect("parent admission"); + let start = 1u64 << 40; + crate::psd_gate::set_active_attempt_budget(start); + crate::import_with_options_and_interrupt("charge.tif", &bytes, &ImportOptions::default(), &ctl).expect("nested import"); + let remaining = crate::psd_gate::active_attempt_remaining().expect("the parent attempt keeps an allowance"); + let charged = start - remaining; + assert!( + charged >= composite.saturating_mul(2), + "both the planar composite ({composite}) and the decoded image kept beside it are charged; charged {charged}" + ); + assert!(crate::psd_gate::is_holding_admission(), "the nested import must not release the parent's admission"); + + // One byte short: the real import is refused, the reason is the charge, and the parent's + // remaining allowance is untouched. + crate::psd_gate::set_active_attempt_budget(charged - 1); + match crate::import_with_options_and_interrupt("charge.tif", &bytes, &ImportOptions::default(), &ctl) { + Err(IoError::PsdImportBudget { required, limit }) => assert_eq!((required, limit), (charged, charged - 1)), + other => panic!("expected an import-budget refusal, got {other:?}"), + } + assert_eq!(crate::psd_gate::active_attempt_remaining(), Some(charged - 1), "a failed nested import leaves the allowance as it was"); + + // Exactly enough succeeds and leaves nothing, so the charge is not reset per attempt. + crate::psd_gate::set_active_attempt_budget(charged); + crate::import_with_options_and_interrupt("charge.tif", &bytes, &ImportOptions::default(), &ctl).expect("exact allowance"); + assert_eq!(crate::psd_gate::active_attempt_remaining(), Some(0)); + } + + /// A cancelled job stops waiting for the PSD decode gate: the interrupt reaches the layered + /// TIFF's gate wait, so it does not block behind another decode until that one finishes. + #[test] + fn cancelled_layered_tiff_import_stops_waiting_for_the_gate() { + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::time::{Duration, Instant}; + + let bytes = layered_tiff(8, 6); + let snapshot = crate::memory::MemorySnapshot { available_ram: Some(32 << 30), swap_free: None, commit_headroom: None }; + let _guard = crate::psd_gate::acquire_decode_admission(&photocraft_raster::Interrupt::NONE).expect("active decode"); + + let cancel = Arc::new(AtomicBool::new(false)); + let prechecked = Arc::new(AtomicBool::new(false)); + let (tx, rx) = std::sync::mpsc::channel(); + + let handle = { + let cancel = Arc::clone(&cancel); + let prechecked = Arc::clone(&prechecked); + std::thread::spawn(move || { + let provider = std::rc::Rc::new(move || { + prechecked.store(true, Ordering::SeqCst); + snapshot + }); + let cancelled = move || cancel.load(Ordering::SeqCst); + let progress = |_: f32| {}; + let ctl = photocraft_raster::Interrupt::new(&cancelled, &progress); + let result = crate::memory::with_mock_memory_provider(provider, || { + crate::import_with_options_and_interrupt("cancel.tif", &bytes, &ImportOptions::default(), &ctl) + }); + tx.send(result.map(|r| r.document.layers.len())).ok(); + }) + }; + + // The provider's first call is the precheck, which happens before the gate wait. + let deadline = Instant::now() + Duration::from_secs(10); + while !prechecked.load(Ordering::SeqCst) { + assert!(Instant::now() < deadline, "the import never reached its admission precheck"); + std::thread::yield_now(); + } + cancel.store(true, Ordering::SeqCst); + + // This thread still holds the gate, so the only way to answer is to abort the wait. + let result = rx.recv_timeout(Duration::from_secs(10)).expect("a cancelled import must stop waiting for the gate"); + match result { + Err(IoError::Cancelled) => {} + other => panic!("expected the cancelled import to fail, got {other:?}"), + } + + drop(_guard); + handle.join().expect("worker thread"); + } + + /// The estimate covers what the layered import holds besides the PSD layer model: the + /// decoded composite, the planar merged copy, the decoder's extra-sample planes and scratch, + /// the tags it copies, and a half-float conversion's extra buffers. + #[test] + fn layered_estimate_counts_decode_conversion_extras_and_scratch() { + use photocraft_codecs::SampleType as S; + // 100 × 50 px, RGB, 8-bit, no extras: the decoded image plus the planar copy. + let pixels = 100u64 * 50; + assert_eq!(super::layered_import_bytes(0, 100, 50, 3, S::U8, 0, 0, 0), 2 * pixels * 3); + // Two extra samples add their interleaved buffer, their split planes and one big-endian + // plane copied into the merged image. + assert_eq!(super::layered_import_bytes(0, 100, 50, 3, S::U8, 2, 0, 0), pixels * 13); + // A half-float page is converted: the 16-bit image and planes stay alive beside the + // normalized 32-bit buffer and the 32-bit result. + assert_eq!(super::layered_import_bytes(0, 100, 50, 3, S::F16, 0, 0, 0), pixels * (3 * 2 + 3 * 8 + 3 * 4)); + let base = super::layered_import_bytes(0, 100, 50, 3, S::U8, 0, 0, 0); + assert_eq!(super::layered_import_bytes(0, 100, 50, 3, S::U8, 0, 1234, 99), base + 1234 + 99); + assert_eq!(super::layered_import_bytes(u64::MAX, 100, 50, 3, S::U8, 0, 0, 0), u64::MAX, "an estimate never wraps into an admitted one"); + } + + /// A TIFF whose layer tag cannot be read still opens flattened, and its whole-image decode + /// and conversion are admitted like a layered page: a refused file never reaches the decode, + /// and the flatten warning is unchanged. + #[test] + fn malformed_layer_tag_bounds_the_composite_before_it_is_decoded() { + use std::cell::Cell; + use std::rc::Rc; + + use photocraft_codecs::{ChannelLayout, EncodeOptions, Format, Image, SampleType}; + + if cfg!(any(target_arch = "wasm32", target_pointer_width = "32")) { + return; + } + let mut img = Image::from_raw(4, 3, ChannelLayout::Rgb, SampleType::U8, vec![40; 4 * 3 * 3]).unwrap(); + img.meta.photoshop_layers = Some(b"not Photoshop image source data".to_vec()); + let bytes = photocraft_codecs::encode(&img, Format::Tiff, &EncodeOptions::default()).unwrap(); + + let decoded = Rc::new(Cell::new(0u32)); + let hook = |decoded: &Rc>| -> Rc { + let decoded = Rc::clone(decoded); + Rc::new(move || decoded.set(decoded.get() + 1)) + }; + + let zero = crate::memory::MemorySnapshot { available_ram: Some(0), swap_free: None, commit_headroom: None }; + crate::memory::with_mock_memory(zero, || { + crate::flat::with_before_composite_decode(hook(&decoded), || { + match crate::import_with_options("bogus.tif", &bytes, &ImportOptions::default()) { + Err(IoError::PsdImportBudget { limit: 0, required }) => assert!(required > 0), + other => panic!("expected an import-budget refusal, got {other:?}"), + } + assert_eq!(decoded.get(), 0, "a refused file must not decode its composite"); + }); + }); + + let plenty = crate::memory::MemorySnapshot { available_ram: Some(16 << 30), swap_free: None, commit_headroom: None }; + crate::memory::with_mock_memory(plenty, || { + crate::flat::with_before_composite_decode(hook(&decoded), || { + let opened = crate::import_with_options("bogus.tif", &bytes, &ImportOptions::default()).expect("a normal machine opens the composite"); + assert_eq!(opened.document.layers.len(), 1); + assert!(opened.warnings.iter().any(|w| w.contains("opened flattened")), "{:?}", opened.warnings); + assert_eq!(decoded.get(), 1, "the admitted import decodes the composite exactly once"); + }); + }); + } + + /// Admission happens before a layered TIFF's composite is decoded: a known-zero budget and + /// an approval-needed estimate both refuse the file without reaching the decode, and an + /// approved import does reach it. The fixture is the real tiny layered TIFF; the estimate + /// threshold is the scoped test seam, so nothing near a gigabyte is allocated. + #[test] + fn layered_tiff_is_refused_before_its_composite_is_decoded() { + use std::cell::Cell; + use std::rc::Rc; + + if cfg!(any(target_arch = "wasm32", target_pointer_width = "32")) { + return; + } + let gb = 1u64 << 30; + let bytes = layered_tiff(8, 6); + let decoded = Rc::new(Cell::new(0u32)); + let hook = |decoded: &Rc>| -> Rc { + let decoded = Rc::clone(decoded); + Rc::new(move || decoded.set(decoded.get() + 1)) + }; + + let zero = crate::memory::MemorySnapshot { available_ram: Some(0), swap_free: None, commit_headroom: None }; + crate::memory::with_mock_memory(zero, || { + crate::flat::with_before_composite_decode(hook(&decoded), || { + match crate::import_with_options("zero.tif", &bytes, &ImportOptions::default()) { + Err(IoError::PsdImportBudget { limit: 0, required }) => assert!(required > 0), + other => panic!("expected an import-budget refusal, got {other:?}"), + } + assert_eq!(decoded.get(), 0, "a refused file must not decode its composite"); + }); + }); + + // 12 GiB: S = 6 GiB, H = 9 GiB. The seam puts the estimate in the approval band. + let snapshot = crate::memory::MemorySnapshot { available_ram: Some(12 * gb), swap_free: None, commit_headroom: None }; + crate::memory::with_mock_memory(snapshot, || { + crate::flat::with_before_composite_decode(hook(&decoded), || { + crate::psd_budget::with_test_estimate_charge(7 * gb, || { + match crate::import_with_options("approval.tif", &bytes, &ImportOptions::default()) { + Err(IoError::PsdApprovalRequired { required, soft_limit, hard_limit, .. }) => { + assert_eq!((soft_limit, hard_limit), (6 * gb, 9 * gb)); + assert!(required > 6 * gb, "the charge reaches the approval band: {required}"); + } + other => panic!("expected an approval request, got {other:?}"), + } + assert_eq!(decoded.get(), 0, "approval is needed before the composite is decoded, not after it"); + + let approved = ImportOptions { psd_high_memory_approved: true, ..Default::default() }; + let opened = crate::import_with_options("approval.tif", &bytes, &approved).expect("approved"); + assert_eq!(opened.document.layers.len(), 2); + assert_eq!(decoded.get(), 1, "the approved import decodes the composite exactly once"); + }); + }); + }); + } + + /// Available memory can drop while a layered TIFF waits for the decode gate: the post-gate + /// recheck must refuse it before the composite is decoded. A per-attempt provider shared with + /// this test only changes the measurement; nothing near a gigabyte is allocated. + #[test] + fn layered_tiff_memory_drop_while_waiting_for_the_gate_refuses_before_decoding() { + use std::rc::Rc; + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; + use std::time::{Duration, Instant}; + + if cfg!(any(target_arch = "wasm32", target_pointer_width = "32")) { + return; + } + let gb = 1u64 << 30; + let bytes = layered_tiff(8, 6); + + // Hold the gate so the import stops between its pre-gate check and the post-gate one. + let _guard = crate::psd_gate::acquire_decode_admission(&photocraft_raster::Interrupt::NONE).expect("active decode"); + let available = Arc::new(AtomicU64::new(32 * gb)); + let prechecked = Arc::new(AtomicBool::new(false)); + let decoded = Arc::new(AtomicBool::new(false)); + let (tx, rx) = std::sync::mpsc::channel(); + + let handle = { + let available = Arc::clone(&available); + let prechecked = Arc::clone(&prechecked); + let decoded = Arc::clone(&decoded); + std::thread::spawn(move || { + let provider = Rc::new(move || { + // Read before signalling, so the pre-gate check that the main thread waits + // for has already measured the larger figure. + let ram = available.load(Ordering::SeqCst); + prechecked.store(true, Ordering::SeqCst); + crate::memory::MemorySnapshot { available_ram: Some(ram), swap_free: None, commit_headroom: None } + }); + let hook: Rc = Rc::new(move || decoded.store(true, Ordering::SeqCst)); + let result = crate::memory::with_mock_memory_provider(provider, || { + crate::flat::with_before_composite_decode(hook, || crate::import_with_options("drop.tif", &bytes, &ImportOptions::default())) + }); + tx.send(result.map(|r| r.document.layers.len())).ok(); + }) + }; + + // The provider's first call is the pre-gate admission check. + let deadline = Instant::now() + Duration::from_secs(10); + while !prechecked.load(Ordering::SeqCst) { + assert!(Instant::now() < deadline, "the import never reached its admission precheck"); + std::thread::yield_now(); + } + // The precheck admitted it; now the machine has nothing left. Release the gate afterwards. + available.store(0, Ordering::SeqCst); + drop(_guard); + + let result = rx.recv_timeout(Duration::from_secs(10)).expect("the post-gate recheck must refuse instead of decoding"); + match result { + Err(IoError::PsdImportBudget { limit: 0, required }) => assert!(required > 0), + other => panic!("expected the post-gate recheck to refuse with a zero budget, got {other:?}"), + } + assert!(!decoded.load(Ordering::SeqCst), "the composite must not be decoded after the post-gate refusal"); + handle.join().expect("worker thread"); + } } diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index 789cf42aa1..1ec892886d 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -52,12 +52,25 @@ impl PhotocraftApp { /// shown to the user). Files from disk go through [`open_file`](Self::open_file), which also /// remembers the path. Brush and gradient files go to the preset libraries instead. pub fn open_bytes(&mut self, name: &str, bytes: &[u8]) -> Result, String> { - self.open_bytes_from(name, bytes, None) + self.open_bytes_placed(name, bytes, None, None, None) } /// [`Self::open_bytes`] for a file read from `path` (File › Save writes back there; it goes /// to Open Recent), when known. pub(crate) fn open_bytes_from(&mut self, name: &str, bytes: &[u8], path: Option<&str>) -> Result, String> { + self.open_bytes_placed(name, bytes, path, None, None) + } + + /// [`Self::open_bytes_from`] for a file dropped on a tab slot (`slot`) or opened as one of a + /// set (`order`): both survive the inline decode, a memory prompt and the final placement. + pub(crate) fn open_bytes_placed( + &mut self, + name: &str, + bytes: &[u8], + path: Option<&str>, + slot: Option, + order: Option, + ) -> Result, String> { if let Some(r) = preset_files_ui::open(self, name, bytes) { return r.map(|()| Vec::new()); } @@ -65,18 +78,24 @@ impl PhotocraftApp { // Decoded on a worker: a tab with progress appears now, the document when it's ready // (warnings are shown then). if self.background_jobs { - jobs_ui::start_open(self, name, path.map(str::to_string), jobs_ui::bytes(bytes))?; + jobs_ui::start_open(self, name, path.map(str::to_string), jobs_ui::bytes(bytes), slot, order)?; return Ok(Vec::new()); } let import = self.services.import.as_ref().ok_or("no importer configured")?; let opts = photocraft_engine::jobs::import_options(self.session.prefs()); - let (mut doc, warnings, damaged) = if photocraft_io::is_psd(bytes) { + // A PSD and a magic-detected TIFF both go through the typed IO path: only it carries + // `PsdApprovalRequired` (and, for a PSD, the structured damaged flag), so a layered + // TIFF's high-memory decision can raise its prompt instead of becoming a terminal + // string. Every other format keeps the shell's import service. + let typed_open = photocraft_io::is_psd(bytes) || photocraft_codecs::detect(bytes) == Some(photocraft_codecs::Format::Tiff); + let (mut doc, warnings, damaged) = if typed_open { // Read PSD damage as structured information in the same decode, not by - // retrying an error or matching the text of a compatibility warning. + // retrying an error or matching the text of a compatibility warning. A TIFF never + // sets it: its layered import has no damaged-content confirmation. let (r, damaged) = match photocraft_io::import_for_open(name, bytes, &opts) { Ok(result) => result, Err(photocraft_io::IoError::PsdApprovalRequired { .. }) => { - jobs_ui::start_open(self, name, path.map(str::to_string), jobs_ui::bytes(bytes))?; + jobs_ui::start_open(self, name, path.map(str::to_string), jobs_ui::bytes(bytes), slot, order)?; return Ok(Vec::new()); } Err(error) => return Err(error.to_string()), @@ -106,7 +125,8 @@ impl PhotocraftApp { path: path.map(str::to_string), warnings: warnings.clone(), bytes: (wants_raw && path.is_none()).then(|| std::sync::Arc::new(bytes.to_vec())), - slot: None, + slot, + order, question, policy_applied: false, }, diff --git a/crates/ui-egui/src/dialogs/open_prompt.rs b/crates/ui-egui/src/dialogs/open_prompt.rs index a9db6507ec..484ea21a25 100644 --- a/crates/ui-egui/src/dialogs/open_prompt.rs +++ b/crates/ui-egui/src/dialogs/open_prompt.rs @@ -33,6 +33,10 @@ pub struct Held { pub bytes: Option>>, /// The tab position a reopened file keeps. pub slot: Option, + /// The file's place in a set opened together, when it is one of several + /// ([`crate::jobs_ui::OpenOrder`]): kept while this file waits, so its tab still lands in the + /// set's order. + pub order: Option, /// The profile question ([`photocraft_engine::color_cmds::ColorState::open_question`]), or /// `None` to open without asking. pub question: Option, @@ -60,7 +64,13 @@ pub struct MemoryPrompt { pub slot: Option, pub order: Option, pub estimated: u64, + /// The capacity measured when the estimate was made; `None` when it could not be measured. pub available: Option, + /// The engine could not measure available memory for this estimate. No number is shown. + pub capacity_unknown: bool, + /// The estimate is above the unknown-capacity warning threshold (8 GiB), so the prompt also + /// says the process or tab may run out of memory. Approval stays possible. + pub uncertain_capacity_warning: bool, } /// Whether a file that just finished reading has to wait: one earlier in the queue does. @@ -206,6 +216,7 @@ pub fn answer(app: &mut PhotocraftApp, action: Option<&str>) -> Result Result Result) -> Result) -> Result app .run("document.move", json!({"document": index, "to": slot})) @@ -349,8 +363,10 @@ pub fn arrive_memory( order: Option, estimated: u64, available: Option, + capacity_unknown: bool, + uncertain_capacity_warning: bool, ) { - app.held_memory_prompts.push_back(MemoryPrompt { id, name, path, slot, order, estimated, available }); + app.held_memory_prompts.push_back(MemoryPrompt { id, name, path, slot, order, estimated, available, capacity_unknown, uncertain_capacity_warning }); app.prompt_order.push_back(PromptKind::Memory); advance_memory(app); } @@ -369,18 +385,27 @@ pub fn advance_memory(app: &mut PhotocraftApp) { fn show_memory(app: &mut PhotocraftApp, prompt: &MemoryPrompt) { let est_gb = prompt.estimated as f64 / (1u64 << 30) as f64; - let avail_str = match prompt.available { - Some(a) => format!("{:.1} GiB", a as f64 / (1u64 << 30) as f64), - None => "unknown".to_string(), - }; - let message = format!( - "{}\n\n{}", - crate::i18n::fmt( - tl!("Opening “{name}” requires an estimated {est} GiB of memory ({avail} available)."), - &[("name", &prompt.name), ("est", &format!("{est_gb:.1}")), ("avail", &avail_str)], + // An unmeasured capacity is never printed as a number, even if a stale measurement is + // attached: the engine's `capacityUnknown` wins. + let available = prompt.available.filter(|_| !prompt.capacity_unknown); + let mut message = match available { + Some(a) => crate::i18n::fmt( + tl!("Opening “{name}” requires an estimated {est} GiB of memory ({avail} GiB available)."), + &[("name", &prompt.name), ("est", &format!("{est_gb:.1}")), ("avail", &format!("{:.1}", a as f64 / (1u64 << 30) as f64))], ), - tl!("Opening this document uses more memory and may slow down your computer or other running applications."), - ); + None => crate::i18n::fmt( + tl!("Opening “{name}” requires an estimated {est} GiB of memory. The available memory could not be measured."), + &[("name", &prompt.name), ("est", &format!("{est_gb:.1}"))], + ), + }; + message.push_str("\n\n"); + message.push_str(tl!("Opening this document uses more memory and may slow down your computer or other running applications.")); + if prompt.uncertain_capacity_warning { + message.push_str("\n\n"); + message.push_str(tl!( + "The estimate is above 8 GiB and the available memory is unknown, so this process or browser tab may run out of memory. You can still try to open it." + )); + } let fields = [ (MEMORY_FIELD.into(), json!(true)), @@ -389,7 +414,9 @@ fn show_memory(app: &mut PhotocraftApp, prompt: &MemoryPrompt) { ("id".into(), json!(prompt.id)), ("message".into(), json!(message)), ("estimated".into(), json!(prompt.estimated)), - ("available".into(), json!(prompt.available)), + ("available".into(), json!(available)), + ("capacityUnknown".into(), json!(prompt.capacity_unknown)), + ("uncertainCapacityWarning".into(), json!(prompt.uncertain_capacity_warning)), ] .into_iter() .collect(); @@ -432,16 +459,18 @@ pub(crate) fn answer_memory(app: &mut PhotocraftApp, accept: bool) -> Result { app.ui.status = err.clone(); app.ui.status_error = true; - crate::jobs_ui::end_batch(&mut app.jobs, &app.held_memory_prompts); + crate::jobs_ui::end_batch(app); advance(app); + crate::jobs_ui::end_batch(app); Err(err) } } diff --git a/crates/ui-egui/src/file_open.rs b/crates/ui-egui/src/file_open.rs index 39e55aa3e0..814fc4e114 100644 --- a/crates/ui-egui/src/file_open.rs +++ b/crates/ui-egui/src/file_open.rs @@ -93,16 +93,27 @@ impl PhotocraftApp { /// `path` for File › Save unless it is a derivative preview, and `path` goes to the top of Open Recent. Returns the import /// warnings (also shown to the user). pub fn open_file(&mut self, path: &str, bytes: &[u8]) -> Result, String> { + self.open_file_placed(path, bytes, None, None) + } + + /// [`Self::open_file`] for a file dropped on a tab slot or opened as one of a set. + pub(crate) fn open_file_placed( + &mut self, + path: &str, + bytes: &[u8], + slot: Option, + order: Option, + ) -> Result, String> { // Brushes/gradients go to the preset libraries (no document, no Open Recent entry). if let Some(r) = crate::preset_files_ui::open(self, path, bytes) { return r.map(|()| Vec::new()); } if self.background_jobs { // The path and Open Recent are recorded when the background open finishes. - crate::jobs_ui::start_open(self, &display_name(path), Some(path.to_string()), crate::jobs_ui::bytes(bytes))?; + crate::jobs_ui::start_open(self, &display_name(path), Some(path.to_string()), crate::jobs_ui::bytes(bytes), slot, order)?; return Ok(Vec::new()); } - self.open_bytes_from(&display_name(path), bytes, Some(path)) + self.open_bytes_placed(&display_name(path), bytes, Some(path), slot, order) } /// Record that the active document was just opened from `path`: File › Save writes back to it @@ -131,6 +142,13 @@ impl PhotocraftApp { /// Read and open the file at `path` (see [`open_file`](Self::open_file)). #[cfg(not(target_arch = "wasm32"))] pub fn open_path(&mut self, path: &str) -> Result, String> { + self.open_path_placed(path, None, None) + } + + /// [`Self::open_path`] for a file dropped on a tab slot or opened as one of a set: the slot + /// and order survive an inline memory/profile prompt and the document's final placement. + #[cfg(not(target_arch = "wasm32"))] + fn open_path_placed(&mut self, path: &str, slot: Option, order: Option) -> Result, String> { if self.show_if_open(path) { return Ok(Vec::new()); } @@ -138,11 +156,18 @@ impl PhotocraftApp { // would block the window). Preset files (brushes, gradients) go the usual way. let ext = std::path::Path::new(path).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default(); if self.background_jobs && !crate::preset_files_ui::PRESET_EXTS.contains(&ext.as_str()) { - crate::jobs_ui::start_open(self, &display_name(path), Some(path.to_string()), photocraft_engine::jobs::OpenSource::Path(path.to_string()))?; + crate::jobs_ui::start_open( + self, + &display_name(path), + Some(path.to_string()), + photocraft_engine::jobs::OpenSource::Path(path.to_string()), + slot, + order, + )?; return Ok(Vec::new()); } let bytes = photocraft_format::read_file(std::path::Path::new(path)).map_err(|e| format!("{path}: {e}"))?; - self.open_file(path, &bytes) + self.open_file_placed(path, &bytes, slot, order) } #[cfg(target_arch = "wasm32")] @@ -150,6 +175,12 @@ impl PhotocraftApp { Err(format!("cannot open paths on the web: {path}")) } + /// The web has no filesystem paths: a drop read by the shell arrives as bytes. + #[cfg(target_arch = "wasm32")] + fn open_path_placed(&mut self, path: &str, _slot: Option, _order: Option) -> Result, String> { + Err(format!("cannot open paths on the web: {path}")) + } + /// Open several files, reporting each failure to the user instead of stopping. Relative paths /// are resolved against the working directory so the remembered path stays valid. The files /// open in name order ([`natural_cmp`]: `File_2` before `File_10`), and their tabs keep that @@ -168,21 +199,14 @@ impl PhotocraftApp { continue; } let order = crate::jobs_ui::OpenOrder { batch, seq }; - let pending = self.jobs.opens.len(); - match self.open_path(abs) { - Ok(_) => { - opened += 1; - // A background open's tab takes its place when it finishes; a document opened - // at once is in place now. - match self.jobs.opens.get_mut(pending) { - Some(tab) => tab.order = Some(order), - None => crate::jobs_ui::opened_in_order(&mut self.jobs, order), - } - } + // The order travels with the open: a background tab, an inline document, or a memory + // prompt that is answered later all place the file at its place in the set. + match self.open_path_placed(abs, None, Some(order)) { + Ok(_) => opened += 1, Err(e) => self.open_failed(&display_name(abs), &e), } } - crate::jobs_ui::end_batch(&mut self.jobs, &self.held_memory_prompts); + crate::jobs_ui::end_batch(self); opened } @@ -198,6 +222,13 @@ impl PhotocraftApp { } else if let Some(o) = self.jobs.opens.iter().find(|o| o.path.as_deref().is_some_and(|p| !is_template(p) && same(p))) { // A template still loading opens as another Untitled, as it does once loaded. self.jobs.focus = Some(o.job); + } else if self.held_memory_prompts.iter().any(|m| m.path.as_deref().is_some_and(|p| !is_template(p) && same(p))) { + // The file's high-memory prompt is already waiting: show it, don't queue a second + // prompt and a second open for the same file. A template still opens again as + // another Untitled, like one that is still loading. + crate::open_prompt::advance_memory(self); + self.ui.chrome.home = None; + return true; } else { return false; } @@ -282,22 +313,23 @@ impl PhotocraftApp { DropTarget::Elsewhere } - /// Open one dropped file; with `slot`, its tab goes there (when it finishes opening, for - /// background opens). True when it took the slot. + /// Open one dropped file; with `slot`, its tab goes there (at once, or when a held prompt is + /// answered). True when it took the slot. fn open_dropped_file(&mut self, f: &egui::DroppedFileHandle, name: &str, slot: Option) -> Result { - // Opens append: a background open's tab, or a document. let (docs, opens) = (self.session.documents().len(), self.jobs.opens.len()); + let held = self.held_opens.len() + self.held_memory_prompts.len(); + // The slot travels with the open through the inline document, a background tab and a + // memory/profile prompt, so no separate move here can land on another document. if f.path().is_absolute() { - self.open_path(&path_text(f.path(), false)?)?; + self.open_path_placed(&path_text(f.path(), false)?, slot, None)?; } else { - self.open_bytes(name, &crate::read_dropped(&**f)?)?; + self.open_bytes_placed(name, &crate::read_dropped(&**f)?, None, slot, None)?; } - let Some(slot) = slot else { return Ok(false) }; - if let Some(tab) = self.jobs.opens.get_mut(opens) { - tab.slot = Some(slot); - return Ok(true); + if slot.is_none() { + return Ok(false); } - Ok(self.run("document.move", serde_json::json!({"document": docs, "to": slot})).is_ok()) + let took = self.jobs.opens.len() > opens || self.session.documents().len() > docs || self.held_opens.len() + self.held_memory_prompts.len() > held; + Ok(took) } /// Place the next file dropped on the canvas and start Free Transform on it, once no other diff --git a/crates/ui-egui/src/file_open/tests.rs b/crates/ui-egui/src/file_open/tests.rs index 5fdece7f71..bd74eceb9e 100644 --- a/crates/ui-egui/src/file_open/tests.rs +++ b/crates/ui-egui/src/file_open/tests.rs @@ -885,7 +885,7 @@ fn a_set_of_background_opens_keeps_its_order_however_they_finish() { let index = app.session.add_document(Document::new("f1.psd", Size::new(4, 4), ColorMode::Rgb, SampleType::U8), None); assert_eq!(index, 1); jobs_ui::opened_in_order(&mut app.jobs, OpenOrder { batch, seq: 1 }); - jobs_ui::end_batch(&mut app.jobs, &app.held_memory_prompts); + jobs_ui::end_batch(&mut app); assert_eq!(app.jobs.batches.len(), 1, "kept while members are opening"); // The rest finish out of order: last, first, third. for seq in [3usize, 0, 2] { diff --git a/crates/ui-egui/src/jobs_ui.rs b/crates/ui-egui/src/jobs_ui.rs index 9dc391169f..c7d78d3933 100644 --- a/crates/ui-egui/src/jobs_ui.rs +++ b/crates/ui-egui/src/jobs_ui.rs @@ -197,14 +197,24 @@ pub fn run(app: &mut PhotocraftApp, id: &str, params: Value) -> Result, source: OpenSource) -> Result<(), String> { +/// replaces it when decoded. `path` is remembered for File › Save and Open Recent. `slot` (a file +/// dropped on the tabs) and `order` (its place in a set opened together) survive an inline +/// completion, a memory prompt and the open itself, so the tab lands where it belongs either way. +pub fn start_open( + app: &mut PhotocraftApp, + name: &str, + path: Option, + source: OpenSource, + slot: Option, + order: Option, +) -> Result<(), String> { let name = &app.open_name(name); match app.session.start_open_for_user_with_background(name, source, app.background_jobs).map_err(|e| e.to_string())? { - // Inline (wasm): finish now, like a background open that ended at once. - Started::Done(v) => finish_open(app, name, path.as_deref(), &v, None), + // Inline (wasm, or background jobs off): finish now, like a background open that ended at + // once. A preflight keeps the slot and set order in its memory prompt. + Started::Done(v) => finish_open(app, name, path.as_deref(), &v, slot, order), Started::Job(job) => { - app.jobs.opens.push(OpenTab { job, name: name.to_string(), path, slot: None, order: None }); + app.jobs.opens.push(OpenTab { job, name: name.to_string(), path, slot, order }); app.jobs.focus = Some(job); app.ui.chrome.home = None; app.ui.status = crate::i18n::fmt(tl!("Opening {name}…"), &[("name", name)]); @@ -226,18 +236,20 @@ pub(crate) fn begin_batch(app: &mut PhotocraftApp) -> u64 { /// A member of a set opened at once (not in the background): its document is in place at the /// end, so later members go after it. +#[cfg(test)] pub(crate) fn opened_in_order(jobs: &mut JobsUi, order: OpenOrder) { if let Some(batch) = jobs.batches.iter_mut().find(|b| b.id == order.batch) { batch.opened.push(order.seq); } } -/// Every member of the set has been started: forget it unless some are still opening. -pub(crate) fn end_batch(jobs: &mut JobsUi, held_memory_prompts: &std::collections::VecDeque) { - let opens = &jobs.opens; - jobs.batches.retain(|b| { - opens.iter().any(|o| o.order.is_some_and(|x| x.batch == b.id)) || held_memory_prompts.iter().any(|m| m.order.is_some_and(|x| x.batch == b.id)) - }); +/// Every member of the set has been started: forget it unless some are still opening, waiting on +/// their prompt (memory, profile or damage), or waiting for a prompt to clear. +pub(crate) fn end_batch(app: &mut PhotocraftApp) { + let waiting: Vec = + app.held_memory_prompts.iter().filter_map(|m| m.order.map(|o| o.batch)).chain(app.held_opens.iter().filter_map(|h| h.order.map(|o| o.batch))).collect(); + let JobsUi { opens, batches, .. } = &mut app.jobs; + batches.retain(|b| opens.iter().any(|o| o.order.is_some_and(|x| x.batch == b.id)) || waiting.contains(&b.id)); } /// The tab position for a member of a set that just opened: after the members before it that @@ -249,18 +261,19 @@ fn batch_slot(jobs: &mut JobsUi, order: OpenOrder) -> Option { Some(slot) } +/// Where a finished open puts its tab: an explicit slot (a file dropped on the tabs) wins; +/// otherwise its place in a set opened together, if that set is still tracked. +pub(crate) fn tab_slot(app: &mut PhotocraftApp, slot: Option, order: Option) -> Option { + let in_set = order.and_then(|o| batch_slot(&mut app.jobs, o)); + slot.or(in_set) +} + /// A background open's result (`{document, warnings, color}`) for `tab`, already taken out of -/// [`JobsUi::opens`]: the document goes to the tab's slot, or to its place in its set. +/// [`JobsUi::opens`]: the document goes to the tab's slot, or to its place in its set. A memory +/// preflight keeps both in its prompt. pub(crate) fn finish_open_tab(app: &mut PhotocraftApp, tab: &OpenTab, v: &Value) -> Result<(), String> { - if let Some(preflight_id) = v.get("pendingPreflight").and_then(Value::as_u64) { - let estimated = v.get("estimated").and_then(Value::as_u64).unwrap_or(0); - let available = v.get("availableRam").and_then(Value::as_u64); - crate::open_prompt::arrive_memory(app, preflight_id, tab.name.clone(), tab.path.clone(), tab.slot, tab.order, estimated, available); - return Ok(()); - } - let slot = tab.slot.or_else(|| tab.order.and_then(|o| batch_slot(&mut app.jobs, o))); - let r = finish_open(app, &tab.name, tab.path.as_deref(), v, slot); - end_batch(&mut app.jobs, &app.held_memory_prompts); + let r = finish_open(app, &tab.name, tab.path.as_deref(), v, tab.slot, tab.order); + end_batch(app); r } @@ -515,7 +528,7 @@ fn on_event(app: &mut PhotocraftApp, ctx: &egui::Context, e: JobEvent) { app.ui.status_error = false; } } - end_batch(&mut app.jobs, &app.held_memory_prompts); + end_batch(app); return; } match e.outcome { @@ -554,12 +567,27 @@ fn on_event(app: &mut PhotocraftApp, ctx: &egui::Context, e: JobEvent) { } /// What [`crate::PhotocraftApp::open_bytes`] does after decoding, for a background open's result -/// (`{document, docId, warnings, color}` or `{held, …}`). -fn finish_open(app: &mut PhotocraftApp, name: &str, path: Option<&str>, v: &Value, slot: Option) -> Result<(), String> { +/// (`{document, docId, warnings, color}` or `{held, …}`). `slot` and `order` are the tab position +/// and set place the file keeps: through a memory prompt, a profile/damage prompt and the final +/// document, so an inline completion lands where a background tab would. +fn finish_open(app: &mut PhotocraftApp, name: &str, path: Option<&str>, v: &Value, slot: Option, order: Option) -> Result<(), String> { if let Some(preflight_id) = v.get("pendingPreflight").and_then(Value::as_u64) { let estimated = v.get("estimated").and_then(Value::as_u64).unwrap_or(0); let available = v.get("availableRam").and_then(Value::as_u64); - crate::open_prompt::arrive_memory(app, preflight_id, name.to_string(), path.map(str::to_string), slot, None, estimated, available); + let capacity_unknown = v.get("capacityUnknown").and_then(Value::as_bool).unwrap_or(available.is_none()); + let uncertain_capacity_warning = v.get("uncertainCapacityWarning").and_then(Value::as_bool).unwrap_or(false); + crate::open_prompt::arrive_memory( + app, + preflight_id, + name.to_string(), + path.map(str::to_string), + slot, + order, + estimated, + available, + capacity_unknown, + uncertain_capacity_warning, + ); return Ok(()); } let warnings: Vec = @@ -573,6 +601,7 @@ fn finish_open(app: &mut PhotocraftApp, name: &str, path: Option<&str>, v: &Valu warnings: warnings.clone(), bytes: None, slot, + order, question, policy_applied, }; @@ -595,7 +624,7 @@ fn finish_open(app: &mut PhotocraftApp, name: &str, path: Option<&str>, v: &Valu crate::open_prompt::arrive(app, held(doc, read_only, None, true)); return Ok(()); } - if let Some(slot) = slot { + if let Some(slot) = tab_slot(app, slot, order) { let _ = app.run("document.move", json!({"document": index, "to": slot})); } after_open(app, document, name, path, &warnings, None) diff --git a/crates/ui-egui/src/jobs_ui/tests.rs b/crates/ui-egui/src/jobs_ui/tests.rs index 4377074ed5..ffb6207fd4 100644 --- a/crates/ui-egui/src/jobs_ui/tests.rs +++ b/crates/ui-egui/src/jobs_ui/tests.rs @@ -940,7 +940,7 @@ fn background_open_warns_about_missing_fonts() { // What the open job leaves behind: the decoded document in the session, then its result. let (index, _) = app.session.open_document(doc, None); let id = app.session.documents()[index].doc.id; - super::finish_open(&mut app, "poster.psd", None, &json!({"document": index, "docId": id.0, "warnings": []}), None).unwrap(); + super::finish_open(&mut app, "poster.psd", None, &json!({"document": index, "docId": id.0, "warnings": []}), None, None).unwrap(); let want = vec!["font 'NoSuchFamily4559' is not installed; text may render using a fallback face".to_string()]; assert!(app.ui.notices.last().is_some_and(|n| n.title.contains("poster.psd") && n.lines == want), "{:?}", app.ui.notices); } @@ -1022,7 +1022,7 @@ fn open_identity_survives_tab_reordering() { let old_index = result["document"].as_u64().unwrap() as usize; app.session.move_document(old_index, 0).unwrap(); assert_eq!(app.session.documents()[old_index].doc.id, control.doc.id); - super::finish_open(&mut app, "imported.png", Some("imported.png"), &result, None).unwrap(); + super::finish_open(&mut app, "imported.png", Some("imported.png"), &result, None, None).unwrap(); control.assert_untouched(&app); assert_eq!(app.session.active().unwrap().doc.id, imported); assert_eq!(app.session.active().unwrap().path.as_deref(), Some("imported.png")); @@ -1037,7 +1037,7 @@ fn open_identity_survives_an_earlier_tab_closing() { let (result, imported) = identified_result(&app, result); app.session.close(1).unwrap(); app.session.set_active(0); - super::finish_open(&mut app, "imported.png", Some("imported.png"), &result, Some(0)).unwrap(); + super::finish_open(&mut app, "imported.png", Some("imported.png"), &result, Some(0), None).unwrap(); control.assert_untouched(&app); assert_eq!(app.session.active().unwrap().doc.id, imported); assert_eq!(app.session.documents()[0].doc.id, imported, "the requested destination slot applies to the correct document"); @@ -1059,13 +1059,13 @@ fn open_identity_rejects_closed_missing_and_invalid_identities() { } Some(value) => result["docId"] = value, } - assert!(super::finish_open(&mut app, "closed.png", Some("closed.png"), &result, Some(0)).is_err(), "invalid identity was accepted: {result}"); + assert!(super::finish_open(&mut app, "closed.png", Some("closed.png"), &result, Some(0), None).is_err(), "invalid identity was accepted: {result}"); control.assert_untouched(&app); assert_eq!(app.session.active().unwrap().doc.id, control.doc.id); assert_eq!(app.session.documents().len(), 1); result["docId"] = json!(imported.0); assert!( - super::finish_open(&mut app, "closed.png", Some("closed.png"), &result, None).is_err(), + super::finish_open(&mut app, "closed.png", Some("closed.png"), &result, None, None).is_err(), "a closed identity must not fall back to the old tab index" ); control.assert_untouched(&app); @@ -1086,9 +1086,9 @@ fn open_identity_keeps_same_poll_results_in_the_profile_queue() { let (second, second_id) = identified_result(&app, second); // All engine applies have finished. Handling the first ordinary result removes its tab, // shifting the second result before its UI callback runs. - super::finish_open(&mut app, "profile.png", Some("profile.png"), &prompt, None).unwrap(); - super::finish_open(&mut app, "first.png", Some("first.png"), &first, None).unwrap(); - super::finish_open(&mut app, "second.png", Some("second.png"), &second, None).unwrap(); + super::finish_open(&mut app, "profile.png", Some("profile.png"), &prompt, None, None).unwrap(); + super::finish_open(&mut app, "first.png", Some("first.png"), &first, None, None).unwrap(); + super::finish_open(&mut app, "second.png", Some("second.png"), &second, None, None).unwrap(); assert_eq!(app.held_opens.iter().map(|held| held.name.as_str()).collect::>(), ["profile.png", "first.png", "second.png"]); assert_eq!(app.held_opens.iter().skip(1).map(|held| held.doc.id).collect::>(), [first_id, second_id]); assert_eq!(app.session.documents().len(), 1, "both completed imports wait behind the profile prompt"); diff --git a/crates/ui-egui/src/psd_open_tests.rs b/crates/ui-egui/src/psd_open_tests.rs index e01a7a50b9..1c5e2ab0f4 100644 --- a/crates/ui-egui/src/psd_open_tests.rs +++ b/crates/ui-egui/src/psd_open_tests.rs @@ -218,7 +218,7 @@ fn memory_confirmation_dialog_and_cancel_lifecycle() { }) .unwrap(); - crate::open_prompt::arrive_memory(&mut app, preflight_id, "high_mem.psd".into(), None, None, None, 7 * gb, Some(10 * gb)); + crate::open_prompt::arrive_memory(&mut app, preflight_id, "high_mem.psd".into(), None, None, None, 7 * gb, Some(10 * gb), false, false); assert_eq!(app.ui.dialogs.len(), 1); assert!(crate::open_prompt::owns_memory(&app.ui.dialogs[0].fields)); let dlg_id = app.ui.dialogs[0].id; @@ -230,118 +230,105 @@ fn memory_confirmation_dialog_and_cancel_lifecycle() { assert!(app.prompt_order.is_empty()); } +/// #10897: the inline path (the browser shell and any desktop open with background jobs off) +/// routes the memory prompt's answer through `file.approveMemory`, and the retry only opens the +/// file when it carries the one-shot approval. A tiny fixture under a scoped estimate charge +/// reaches the real approval window, so dropping the approval flag or the bound estimate in the +/// retry leaves the prompt (and no document) instead of passing silently. +#[cfg(not(target_arch = "wasm32"))] #[test] fn memory_confirmation_resume_inline_success() { + if cfg!(target_pointer_width = "32") { + return; // the 3 GiB charge would exceed a 32-bit process cap + } + let gb = 1u64 << 30; let mut app = app(); app.background_jobs = false; let psd = recovery_psd::make_psd(false); - let (file, _) = photocraft_psd::PsdFile::from_bytes_and_merged(&psd, |_| false).unwrap(); - let est = photocraft_io::psd_budget::estimate(&file).unwrap().total_bytes; - let bytes = std::sync::Arc::new(psd); - let fp = photocraft_engine::jobs::SourceFingerprint::of(&photocraft_engine::jobs::OpenSource::Bytes(std::sync::Arc::clone(&bytes))).unwrap(); + // Known 5 GiB: S = 2.5 GiB, H = 3.75 GiB. The 3 GiB charge lands in (S, H] on this thread. + let snap = photocraft_io::memory::MemorySnapshot { available_ram: Some(5 * gb), swap_free: None, commit_headroom: None }; + photocraft_io::psd_budget::with_test_estimate_charge(3 * gb, || { + photocraft_io::memory::with_mock_memory(snap, || { + // The real inline import raises the preflight: no fabricated prompt or preflight. + app.open_bytes_from("/test/inline_resume.psd", &psd, Some("/test/inline_resume.psd")).unwrap(); + assert!(app.session.documents().is_empty(), "the file waits for approval"); + assert_eq!(app.held_memory_prompts.len(), 1); + assert_eq!(app.ui.dialogs.len(), 1); + let fields = &app.ui.dialogs[0].fields; + assert!(crate::open_prompt::owns_memory(fields)); + assert!(fields["estimated"].as_u64().unwrap() > 3 * gb, "the charged estimate is shown: {fields:?}"); + assert_eq!(fields["available"], json!(5 * gb)); + assert_eq!(fields["capacityUnknown"], json!(false)); + assert_eq!(fields["uncertainCapacityWarning"], json!(false)); + let dlg_id = app.ui.dialogs[0].id; - let snap = photocraft_io::memory::MemorySnapshot { available_ram: Some((4 * est).max(4 << 30)), swap_free: None, commit_headroom: None }; - photocraft_io::memory::with_mock_memory(snap, || { - let budget = photocraft_io::memory::resolve_budget(snap, None); - let preflight_id = app - .session - .store_pending_preflight(photocraft_engine::jobs::PendingPsdPreflight { - id: 0, - name: "inline_resume.psd".into(), - source: photocraft_engine::jobs::OpenSource::Bytes(bytes), - fingerprint: fp, - required: est, - soft_limit: budget.soft_limit, - hard_limit: budget.hard_limit, - available_ram: budget.available_ram, - confirm_damage: true, - }) - .unwrap(); - - crate::open_prompt::arrive_memory( - &mut app, - preflight_id, - "inline_resume.psd".into(), - Some("/test/inline_resume.psd".into()), - None, - None, - est, - budget.available_ram, - ); - assert_eq!(app.ui.dialogs.len(), 1); - let dlg_id = app.ui.dialogs[0].id; - - let res = call(&mut app, "ui.dialog.confirm", json!({"dialog": dlg_id})); - assert_eq!(res["ok"], true); - assert!(app.ui.dialogs.is_empty(), "dialog dismissed after inline resume"); - assert_eq!(app.session.documents().len(), 1, "document opened via inline resume"); - assert_eq!(app.session.documents()[0].doc.name, "inline_resume.psd"); - assert_eq!(app.session.documents()[0].path.as_deref(), Some("/test/inline_resume.psd")); - assert!(app.held_memory_prompts.is_empty()); - assert!(app.held_opens.is_empty()); - assert!(app.prompt_order.is_empty()); - assert!(app.jobs.opens.is_empty()); + let res = call(&mut app, "ui.dialog.confirm", json!({"dialog": dlg_id})); + assert_eq!(res["ok"], true, "{res}"); + assert!(app.ui.dialogs.is_empty(), "dialog dismissed after inline resume"); + assert_eq!(app.session.documents().len(), 1, "document opened via inline resume"); + assert_eq!(app.session.documents()[0].doc.name, "inline_resume.psd"); + assert_eq!(app.session.documents()[0].path.as_deref(), Some("/test/inline_resume.psd")); + assert!(app.held_memory_prompts.is_empty()); + assert!(app.held_opens.is_empty()); + assert!(app.prompt_order.is_empty()); + assert!(app.jobs.opens.is_empty()); + assert_eq!(app.session.pending_preflight_bytes().unwrap(), 0, "the approval consumed and released the preflight"); + }); }); } +/// #10897: a background open carries the same scoped estimate into its worker through the +/// explicitly captured test scope (never a global mock), so the real job raises the approval +/// prompt; the retry then opens the file only with the one-shot approval. +#[cfg(not(target_arch = "wasm32"))] #[test] fn memory_confirmation_resume_background_success() { + if cfg!(target_pointer_width = "32") { + return; // the 3 GiB charge would exceed a 32-bit process cap + } + let gb = 1u64 << 30; let mut app = app(); app.background_jobs = true; let psd = recovery_psd::make_psd(false); - let (file, _) = photocraft_psd::PsdFile::from_bytes_and_merged(&psd, |_| false).unwrap(); - let est = photocraft_io::psd_budget::estimate(&file).unwrap().total_bytes; - let bytes = std::sync::Arc::new(psd); - let fp = photocraft_engine::jobs::SourceFingerprint::of(&photocraft_engine::jobs::OpenSource::Bytes(std::sync::Arc::clone(&bytes))).unwrap(); + let snap = photocraft_io::memory::MemorySnapshot { available_ram: Some(5 * gb), swap_free: None, commit_headroom: None }; + photocraft_io::psd_budget::with_test_estimate_charge(3 * gb, || { + photocraft_io::memory::with_mock_memory(snap, || { + app.open_bytes_from("/test/bg_resume.psd", &psd, Some("/test/bg_resume.psd")).unwrap(); + assert_eq!(app.jobs.opens.len(), 1, "a background open tab appears at once"); + let jobs: Vec<_> = app.session.jobs().iter().map(|j| j.id).collect(); + for id in jobs { + app.session.wait_job(id).unwrap(); + } + crate::jobs_ui::tick(&mut app, &egui::Context::default()); + assert!(app.session.documents().is_empty(), "the file waits for approval"); + assert_eq!(app.held_memory_prompts.len(), 1); + assert_eq!(app.ui.dialogs.len(), 1); + let fields = &app.ui.dialogs[0].fields; + assert!(crate::open_prompt::owns_memory(fields)); + assert!(fields["estimated"].as_u64().unwrap() > 3 * gb, "the worker made the charged estimate: {fields:?}"); + assert_eq!(fields["available"], json!(5 * gb)); + assert_eq!(fields["capacityUnknown"], json!(false)); + let dlg_id = app.ui.dialogs[0].id; - let snap = photocraft_io::memory::MemorySnapshot { available_ram: Some((4 * est).max(4 << 30)), swap_free: None, commit_headroom: None }; - photocraft_io::memory::with_mock_memory(snap, || { - let budget = photocraft_io::memory::resolve_budget(snap, None); - let preflight_id = app - .session - .store_pending_preflight(photocraft_engine::jobs::PendingPsdPreflight { - id: 0, - name: "bg_resume.psd".into(), - source: photocraft_engine::jobs::OpenSource::Bytes(bytes), - fingerprint: fp, - required: est, - soft_limit: budget.soft_limit, - hard_limit: budget.hard_limit, - available_ram: budget.available_ram, - confirm_damage: true, - }) - .unwrap(); + let res = call(&mut app, "ui.dialog.confirm", json!({"dialog": dlg_id})); + assert_eq!(res["ok"], true, "{res}"); + assert_eq!(app.jobs.opens.len(), 1, "background retry job registered in jobs.opens"); - crate::open_prompt::arrive_memory( - &mut app, - preflight_id, - "bg_resume.psd".into(), - Some("/test/bg_resume.psd".into()), - None, - None, - est, - budget.available_ram, - ); - assert_eq!(app.ui.dialogs.len(), 1); - let dlg_id = app.ui.dialogs[0].id; - - let res = call(&mut app, "ui.dialog.confirm", json!({"dialog": dlg_id})); - assert_eq!(res["ok"], true); - assert_eq!(app.jobs.opens.len(), 1, "background open job registered in jobs.opens"); - - let jobs: Vec<_> = app.session.jobs().iter().map(|j| j.id).collect(); - for id in jobs { - app.session.wait_job(id).unwrap(); - } - crate::jobs_ui::tick(&mut app, &egui::Context::default()); - assert_eq!(app.session.documents().len(), 1, "document opened via background resume"); - assert_eq!(app.session.documents()[0].doc.name, "bg_resume.psd"); - assert_eq!(app.session.documents()[0].path.as_deref(), Some("/test/bg_resume.psd")); - assert!(app.ui.dialogs.is_empty()); - assert!(app.held_memory_prompts.is_empty()); - assert!(app.held_opens.is_empty()); - assert!(app.prompt_order.is_empty()); - assert!(app.jobs.opens.is_empty()); + let jobs: Vec<_> = app.session.jobs().iter().map(|j| j.id).collect(); + for id in jobs { + app.session.wait_job(id).unwrap(); + } + crate::jobs_ui::tick(&mut app, &egui::Context::default()); + assert_eq!(app.session.documents().len(), 1, "document opened via background resume"); + assert_eq!(app.session.documents()[0].doc.name, "bg_resume.psd"); + assert_eq!(app.session.documents()[0].path.as_deref(), Some("/test/bg_resume.psd")); + assert!(app.ui.dialogs.is_empty()); + assert!(app.held_memory_prompts.is_empty()); + assert!(app.held_opens.is_empty()); + assert!(app.prompt_order.is_empty()); + assert!(app.jobs.opens.is_empty()); + assert_eq!(app.session.pending_preflight_bytes().unwrap(), 0); + }); }); } @@ -371,7 +358,18 @@ fn memory_confirmation_refuses_when_source_changed_inline() { }) .unwrap(); - crate::open_prompt::arrive_memory(&mut app, preflight_id, "changed.psd".into(), Some(path.to_string_lossy().to_string()), None, None, 1000, Some(100_000)); + crate::open_prompt::arrive_memory( + &mut app, + preflight_id, + "changed.psd".into(), + Some(path.to_string_lossy().to_string()), + None, + None, + 1000, + Some(100_000), + false, + false, + ); assert_eq!(app.ui.dialogs.len(), 1); let dlg_id = app.ui.dialogs[0].id; @@ -380,6 +378,8 @@ fn memory_confirmation_refuses_when_source_changed_inline() { let res = call(&mut app, "ui.dialog.confirm", json!({"dialog": dlg_id})); assert_eq!(res["ok"], false, "confirming changed file inline must fail"); + let error = res["error"].as_str().unwrap_or_default(); + assert!(error.contains("changed since it was checked"), "the refusal names the changed identity, not a parser failure: {res}"); assert!(app.session.documents().is_empty()); assert!(app.held_memory_prompts.is_empty()); } @@ -419,6 +419,8 @@ fn memory_confirmation_refuses_when_source_changed_async() { None, 1000, Some(100_000), + false, + false, ); assert_eq!(app.ui.dialogs.len(), 1); let dlg_id = app.ui.dialogs[0].id; @@ -440,6 +442,7 @@ fn memory_confirmation_refuses_when_source_changed_async() { assert!(app.session.documents().is_empty(), "no document opened on changed file failure"); assert!(app.jobs.opens.is_empty(), "opens cleared on job failure"); assert!(app.ui.status_error, "error recorded in UI status"); + assert!(app.ui.status.contains("changed since it was checked"), "the background refusal names the changed identity: {}", app.ui.status); } #[test] @@ -463,7 +466,7 @@ fn memory_decline_advances_queue_and_cleans_batch() { }) .unwrap(); - crate::open_prompt::arrive_memory(&mut app, preflight_id, "declined.psd".into(), None, None, Some(order), 1000, Some(4000)); + crate::open_prompt::arrive_memory(&mut app, preflight_id, "declined.psd".into(), None, None, Some(order), 1000, Some(4000), false, false); assert_eq!(app.ui.dialogs.len(), 1); let dlg_id = app.ui.dialogs[0].id; @@ -512,8 +515,8 @@ fn batch_single_file_cancel_preserves_other_held_memory_orders() { }) .unwrap(); - crate::open_prompt::arrive_memory(&mut app, p0, "first.psd".into(), None, None, Some(order0), 1000, Some(4000)); - crate::open_prompt::arrive_memory(&mut app, p1, "second.psd".into(), None, None, Some(order1), 1000, Some(4000)); + crate::open_prompt::arrive_memory(&mut app, p0, "first.psd".into(), None, None, Some(order0), 1000, Some(4000), false, false); + crate::open_prompt::arrive_memory(&mut app, p1, "second.psd".into(), None, None, Some(order1), 1000, Some(4000), false, false); // Cancel first file: let dlg0 = app.ui.dialogs[0].id; @@ -550,7 +553,7 @@ fn profile_cancel_cancels_whole_queue_including_memory_prompts() { }) .unwrap(); - crate::open_prompt::arrive_memory(&mut app, preflight_id, "high_mem.psd".into(), None, None, None, 7 * gb, Some(10 * gb)); + crate::open_prompt::arrive_memory(&mut app, preflight_id, "high_mem.psd".into(), None, None, None, 7 * gb, Some(10 * gb), false, false); crate::open_prompt::arrive( &mut app, @@ -563,6 +566,7 @@ fn profile_cancel_cancels_whole_queue_including_memory_prompts() { warnings: vec![], bytes: None, slot: None, + order: None, question: Some(json!({"missing": false, "working": "sRGB", "embedded": "Adobe RGB"})), policy_applied: false, }, @@ -593,6 +597,7 @@ fn mixed_prompts_follow_arrival_order_fifo() { warnings: vec!["damaged data".into()], bytes: None, slot: None, + order: None, question: None, policy_applied: false, }, @@ -613,7 +618,7 @@ fn mixed_prompts_follow_arrival_order_fifo() { confirm_damage: true, }) .unwrap(); - crate::open_prompt::arrive_memory(&mut app, preflight_id, "second_mem.psd".into(), None, None, None, 7 * gb, Some(10 * gb)); + crate::open_prompt::arrive_memory(&mut app, preflight_id, "second_mem.psd".into(), None, None, None, 7 * gb, Some(10 * gb), false, false); // First prompt shown must be damage (since it arrived first)! assert_eq!(app.ui.dialogs.len(), 1); @@ -630,3 +635,319 @@ fn mixed_prompts_follow_arrival_order_fifo() { assert_eq!(call(&mut app, "ui.dialog.cancel", json!({"dialog": dlg_mem}))["ok"], true); assert!(app.ui.dialogs.is_empty()); } + +/// A stored preflight for the prompt tests, with a tiny in-memory source so nothing large is +/// ever held. +fn store_preflight(app: &mut PhotocraftApp, name: &str, required: u64, available: Option) -> u64 { + let bytes = std::sync::Arc::new(vec![0u8; 16]); + let source = photocraft_engine::jobs::OpenSource::Bytes(std::sync::Arc::clone(&bytes)); + let fingerprint = photocraft_engine::jobs::SourceFingerprint::of(&source).unwrap(); + app.session + .store_pending_preflight(photocraft_engine::jobs::PendingPsdPreflight { + id: 0, + name: name.into(), + source, + fingerprint, + required, + soft_limit: required / 2, + hard_limit: required.saturating_mul(2), + available_ram: available, + confirm_damage: true, + }) + .unwrap() +} + +/// #10897: the browser and desktop share one consent route. The web shell runs every open inline +/// (`background_jobs == false`), answers the same `MemoryPrompt`, and dispatches the same +/// `file.approveMemory` command: it is not menu-only, journaled or gated off wasm. The web's own +/// allowance and 2 GiB product cap stay in `photocraft-io`'s policy, so approval never raises +/// them. This checks the routing half on the host; the web policy half is covered by +/// `photocraft_io::memory`'s web-policy tests. +#[test] +fn memory_approval_routes_through_the_file_approve_memory_command() { + let app = app(); + assert!(!app.background_jobs, "the browser shell is always inline"); + assert!(app.session.is_enabled("file.approveMemory"), "the one-shot approval command is available on every target"); + let spec = photocraft_engine::commands::find("file.approveMemory").expect("registered"); + assert!(spec.menu.is_empty(), "it is not a menu item"); + assert!(!spec.journal, "a one-shot permission is never journaled or replayed"); +} + +/// #10897: on the web the same prompt offers consent, and `file.approveMemory` may authorize an +/// estimate above the 1 GiB automatic allowance only within the 2 GiB product cap: a 1.5 GiB +/// estimate is approved and opens, a 3 GiB estimate is refused before decoding. The fixed mock +/// snapshot is ignored on wasm (the web policy does not read one), so the charge alone sets the +/// window. This target is compile-checked, not executed, by the current CI (`cargo xtask wasm` +/// checks the crates without test targets); the same web policy is exercised on native by +/// `photocraft_io::memory`'s web-policy tests. +#[cfg(target_arch = "wasm32")] +#[test] +fn web_memory_prompt_offers_consent_within_the_two_gib_product_cap() { + let gb = 1u64 << 30; + let psd = recovery_psd::make_psd(false); + let mut approved = app(); + photocraft_io::psd_budget::with_test_estimate_charge(3 * gb / 2, || { + approved.open_bytes_from("web.psd", &psd, Some("web.psd")).unwrap(); + assert_eq!(approved.held_memory_prompts.len(), 1, "1.5 GiB is above the web's 1 GiB automatic allowance"); + let fields = &approved.ui.dialogs[0].fields; + assert_eq!(fields["available"], json!(null)); + assert_eq!(fields["capacityUnknown"], json!(true), "the browser cannot measure the tab's capacity"); + let dlg = approved.ui.dialogs[0].id; + assert_eq!(call(&mut approved, "ui.dialog.confirm", json!({"dialog": dlg}))["ok"], true); + assert_eq!(approved.session.documents().len(), 1, "consent opens an estimate within the product cap"); + }); + let mut refused = app(); + photocraft_io::psd_budget::with_test_estimate_charge(3 * gb, || { + assert!(refused.open_bytes_from("too-big.psd", &psd, Some("too-big.psd")).is_err(), "consent cannot raise the 2 GiB product cap"); + assert!(refused.ui.dialogs.is_empty()); + assert!(refused.session.documents().is_empty()); + }); +} + +/// #10897: the prompt shows an unmeasured capacity honestly (no invented number) and adds the +/// above-8-GiB warning, both from the engine's typed flags, so the browser and desktop consent +/// text matches what was actually measured. +#[test] +fn memory_prompt_states_unknown_capacity_and_the_eight_gib_warning() { + let gb = 1u64 << 30; + let mut app = app(); + let id = store_preflight(&mut app, "unknown.psd", 9 * gb, None); + let tab = crate::jobs_ui::OpenTab { + job: photocraft_engine::jobs::JobId(1), + name: "unknown.psd".into(), + path: Some("/pics/unknown.psd".into()), + slot: None, + order: None, + }; + crate::jobs_ui::finish_open_tab( + &mut app, + &tab, + &json!({"pendingPreflight": id, "estimated": 9 * gb, "availableRam": null, "capacityUnknown": true, "uncertainCapacityWarning": true}), + ) + .unwrap(); + assert_eq!(app.ui.dialogs.len(), 1); + let fields = &app.ui.dialogs[0].fields; + assert!(crate::open_prompt::owns_memory(fields)); + assert_eq!(fields["available"], json!(null), "no number is invented for an unmeasured capacity"); + assert_eq!(fields["capacityUnknown"], json!(true)); + assert_eq!(fields["uncertainCapacityWarning"], json!(true)); + let message = fields["message"].as_str().unwrap(); + assert!(message.contains("could not be measured"), "{message}"); + assert!(message.contains("8 GiB") && message.contains("may run out of memory"), "{message}"); + assert!(!message.contains("available)"), "the unknown-capacity message claims no available figure: {message}"); + assert_eq!(call(&mut app, "ui.dialog.cancel", json!({"dialog": app.ui.dialogs[0].id}))["ok"], true); + + // A measured capacity shows the number and drops the uncertainty warning. + let id = store_preflight(&mut app, "known.psd", 3 * gb, Some(5 * gb)); + let tab = crate::jobs_ui::OpenTab { job: photocraft_engine::jobs::JobId(2), name: "known.psd".into(), path: None, slot: None, order: None }; + crate::jobs_ui::finish_open_tab( + &mut app, + &tab, + &json!({"pendingPreflight": id, "estimated": 3 * gb, "availableRam": 5 * gb, "capacityUnknown": false, "uncertainCapacityWarning": false}), + ) + .unwrap(); + let fields = &app.ui.dialogs[0].fields; + assert_eq!(fields["available"], json!(5 * gb)); + assert_eq!(fields["capacityUnknown"], json!(false)); + let message = fields["message"].as_str().unwrap(); + assert!(message.contains("5.0 GiB available"), "{message}"); + assert!(!message.contains("could not be measured") && !message.contains("may run out of memory"), "{message}"); + assert_eq!(call(&mut app, "ui.dialog.cancel", json!({"dialog": app.ui.dialogs[0].id}))["ok"], true); + assert!(app.ui.dialogs.is_empty()); +} + +/// #10897: reopening an ordinary file whose high-memory prompt is already waiting shows that +/// prompt instead of queueing a second one and a second open; a template still opens again as +/// another Untitled, like one that is still loading. +#[test] +fn reopening_a_file_whose_memory_prompt_waits_does_not_queue_a_duplicate() { + let gb = 1u64 << 30; + let mut app = app(); + let id = store_preflight(&mut app, "slow.psd", 3 * gb, Some(5 * gb)); + crate::open_prompt::arrive_memory(&mut app, id, "slow.psd".into(), Some("/pics/slow.psd".into()), None, None, 3 * gb, Some(5 * gb), false, false); + assert_eq!(app.held_memory_prompts.len(), 1); + assert_eq!(app.ui.dialogs.len(), 1); + assert!(app.show_if_open("/pics/slow.psd"), "the waiting prompt is shown"); + assert_eq!(app.held_memory_prompts.len(), 1, "no second prompt for the same file"); + assert_eq!(app.ui.dialogs.len(), 1); + + // The template exception: a repeat open still creates another Untitled. + let id = store_preflight(&mut app, "card.psdt", 3 * gb, Some(5 * gb)); + crate::open_prompt::arrive_memory(&mut app, id, "card.psdt".into(), Some("/pics/card.psdt".into()), None, None, 3 * gb, Some(5 * gb), false, false); + assert_eq!(app.held_memory_prompts.len(), 2); + assert!(!app.show_if_open("/pics/card.psdt"), "a template reopens instead of reusing the waiting prompt"); + assert_eq!(app.held_memory_prompts.len(), 2); +} + +/// A tiny PNG so a set can hold one PSD that needs approval and one flat file that opens at once. +#[cfg(not(target_arch = "wasm32"))] +fn png_file() -> Vec { + let img = photocraft_codecs::Image::from_u8(2, 2, photocraft_codecs::ChannelLayout::Rgb, vec![10; 2 * 2 * 3]).unwrap(); + photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap() +} + +/// #10897: a set opened inline keeps the charged PSD's place in the set through its memory +/// prompt: the prompt carries the batch order and the answered file lands before the later member. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn inline_set_keeps_the_memory_prompted_file_in_batch_order() { + if cfg!(target_pointer_width = "32") { + return; // the 3 GiB charge would exceed a 32-bit process cap + } + let gb = 1u64 << 30; + let dir = tempfile::tempdir().unwrap(); + let psd = dir.path().join("a_high.psd"); + let png = dir.path().join("b_flat.png"); + std::fs::write(&psd, recovery_psd::make_psd(false)).unwrap(); + std::fs::write(&png, png_file()).unwrap(); + let paths = vec![psd.to_string_lossy().into_owned(), png.to_string_lossy().into_owned()]; + let mut app = app(); + let snap = photocraft_io::memory::MemorySnapshot { available_ram: Some(5 * gb), swap_free: None, commit_headroom: None }; + photocraft_io::psd_budget::with_test_estimate_charge(3 * gb, || { + photocraft_io::memory::with_mock_memory(snap, || { + assert_eq!(app.open_paths(&paths), 2); + assert_eq!(app.held_memory_prompts.len(), 1, "the charged PSD waits for approval"); + assert_eq!(app.held_memory_prompts.front().unwrap().order.map(|o| o.seq), Some(0), "the set order travels with the prompt"); + // The flat file waits behind the prompt, keeping its own place in the set. + assert_eq!(app.held_opens.len(), 1); + assert_eq!(app.held_opens.front().unwrap().order.map(|o| o.seq), Some(1)); + let dlg = app.ui.dialogs[0].id; + assert_eq!(call(&mut app, "ui.dialog.confirm", json!({"dialog": dlg}))["ok"], true); + let names: Vec<_> = app.session.documents().iter().map(|d| d.doc.name.clone()).collect(); + assert_eq!(names, ["a_high.psd", "b_flat.png"]); + assert!(app.held_memory_prompts.is_empty() && app.held_opens.is_empty()); + assert!(app.jobs.batches.is_empty(), "the set is forgotten once every member opened"); + assert!(app.prompt_order.is_empty()); + }); + }); +} + +/// A real tiny layered TIFF: a small two-layer RGB document exported with Photoshop layer data. +/// The fixture is the actual file the UI opens, not a fabricated preflight. +#[cfg(not(target_arch = "wasm32"))] +fn exported_layered_tiff(w: u32, h: u32) -> Vec { + use photocraft_doc::{Document, Layer, LayerContent}; + use photocraft_raster::Surface; + + let mut doc = Document::new("layers.tif", photocraft_geom::Size::new(w, h), photocraft_color::ColorMode::Rgb, photocraft_color::SampleType::U8); + let fmt = doc.pixel_format(); + let bytes = (w as usize * h as usize * fmt.bytes_per_pixel()).max(1); + for name in ["bottom", "top"] { + let mut s = Surface::new(fmt); + s.write_interleaved(photocraft_geom::Rect::new(0, 0, w as i32, h as i32), &vec![96u8; bytes]); + doc.layers.push(Layer::new(name, LayerContent::Raster(s))); + } + photocraft_io::export(&doc, "layers.tif", &photocraft_io::ExportOptions { tiff_layers: true, ..Default::default() }).expect("export a layered TIFF").bytes +} + +/// #10897: a layered TIFF opened inline reaches the same high-memory prompt a PSD does. Only the +/// typed IO path carries `PsdApprovalRequired`; the shell's plain import service turns it into a +/// terminal string, so without that routing this open fails instead of prompting. The real +/// two-layer fixture plus the scoped estimate charge land in the confirmation band under a fixed +/// 5 GiB, and the prompt arrives before any composite pixel is decoded. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn inline_layered_tiff_prompts_before_decoding_and_opens_or_declines() { + if cfg!(target_pointer_width = "32") { + return; // the 3 GiB charge would exceed a 32-bit process cap + } + let gb = 1u64 << 30; + let bytes = exported_layered_tiff(8, 6); + let path = "/pics/layered.tif"; + let mut app = app(); + assert!(!app.background_jobs, "the inline path the web shell uses"); + let snap = photocraft_io::memory::MemorySnapshot { available_ram: Some(5 * gb), swap_free: None, commit_headroom: None }; + photocraft_io::psd_budget::with_test_estimate_charge(3 * gb, || { + photocraft_io::memory::with_mock_memory(snap, || { + let order = crate::jobs_ui::OpenOrder { batch: crate::jobs_ui::begin_batch(&mut app), seq: 0 }; + app.open_bytes_placed("layered.tif", &bytes, Some(path), None, Some(order)).unwrap(); + // The prompt comes first: nothing is decoded, no tab is opened, and the engine holds + // the source for the approval. The set order travels with the prompt. + assert!(app.session.documents().is_empty(), "the layered TIFF waits for approval instead of opening"); + assert!(app.jobs.opens.is_empty(), "inline: no background tab"); + assert_eq!(app.held_memory_prompts.len(), 1); + assert_eq!(app.held_memory_prompts.front().unwrap().order, Some(order)); + assert_eq!(app.ui.dialogs.len(), 1); + let fields = &app.ui.dialogs[0].fields; + assert!(crate::open_prompt::owns_memory(fields)); + assert!(fields["estimated"].as_u64().unwrap() > 3 * gb, "the charged estimate is shown: {fields:?}"); + assert_eq!(fields["available"], json!(5 * gb)); + assert!(app.session.pending_preflight_bytes().unwrap() > 0, "the engine preflight holds the source"); + + // Accepting runs the typed TIFF import with the one-shot approval: both layers, in + // their order, keep the document's path, and the preflight is released. + let dlg = app.ui.dialogs[0].id; + let res = call(&mut app, "ui.dialog.confirm", json!({"dialog": dlg})); + assert_eq!(res["ok"], true, "{res}"); + assert_eq!(app.session.documents().len(), 1, "the approved TIFF opens"); + let st = app.session.active().unwrap(); + assert_eq!(st.doc.name, "layered.tif"); + assert_eq!(st.path.as_deref(), Some(path), "the approved open keeps its path"); + let names: Vec<_> = st.doc.layers.iter().map(|l| l.name.clone()).collect(); + assert_eq!(names, ["bottom", "top"], "both layers open in their order"); + assert!(app.ui.dialogs.is_empty(), "a TIFF has no damage prompt"); + assert!(app.held_memory_prompts.is_empty()); + assert!(app.jobs.batches.is_empty(), "the set is forgotten once its prompted file opened"); + assert_eq!(app.session.pending_preflight_bytes().unwrap(), 0, "accepting clears the engine preflight"); + + // Declining opens nothing and releases the engine preflight too. + app.open_bytes_from("declined.tif", &bytes, Some("/pics/declined.tif")).unwrap(); + assert_eq!(app.held_memory_prompts.len(), 1); + assert!(app.session.pending_preflight_bytes().unwrap() > 0); + let dlg = app.ui.dialogs[0].id; + assert_eq!(call(&mut app, "ui.dialog.cancel", json!({"dialog": dlg}))["ok"], true); + assert!(app.held_memory_prompts.is_empty() && app.ui.dialogs.is_empty()); + assert_eq!(app.session.documents().len(), 1, "declining opens nothing"); + assert_eq!(app.session.pending_preflight_bytes().unwrap(), 0, "declining clears the engine preflight"); + }); + }); +} + +/// A TIFF whose Photoshop layer tag cannot be read still opens flattened through the typed path, +/// with its warning: routing TIFF magic to typed IO must not turn a malformed tag into a refusal. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn inline_tiff_with_a_malformed_layer_tag_opens_flattened() { + use photocraft_codecs::{ChannelLayout, EncodeOptions, Format, Image, SampleType}; + + let mut img = Image::from_raw(4, 3, ChannelLayout::Rgb, SampleType::U8, vec![40; 4 * 3 * 3]).unwrap(); + img.meta.photoshop_layers = Some(b"not Photoshop image source data".to_vec()); + let bytes = photocraft_codecs::encode(&img, Format::Tiff, &EncodeOptions::default()).unwrap(); + let mut app = app(); + let snap = photocraft_io::memory::MemorySnapshot { available_ram: Some(16 << 30), swap_free: None, commit_headroom: None }; + photocraft_io::memory::with_mock_memory(snap, || { + let warnings = app.open_bytes("bogus.tif", &bytes).unwrap(); + assert_eq!(app.session.documents().len(), 1, "the composite still opens"); + assert_eq!(app.session.active().unwrap().doc.layers.len(), 1, "a malformed tag opens one flat layer"); + assert!(warnings.iter().any(|w| w.contains("opened flattened")), "{warnings:?}"); + assert!(app.ui.dialogs.is_empty()); + }); +} + +/// #10897: a file dropped on a tab slot keeps that slot through an inline memory prompt, even +/// though the open completes later (before, it appended to the end of the tab strip). +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn inline_drop_slot_survives_the_memory_prompt() { + if cfg!(target_pointer_width = "32") { + return; // the 3 GiB charge would exceed a 32-bit process cap + } + let gb = 1u64 << 30; + let mut app = app(); + for name in ["first.psd", "second.psd"] { + app.open_bytes(name, &recovery_psd::make_psd(false)).unwrap(); + } + assert_eq!(app.session.documents().len(), 2); + let snap = photocraft_io::memory::MemorySnapshot { available_ram: Some(5 * gb), swap_free: None, commit_headroom: None }; + photocraft_io::psd_budget::with_test_estimate_charge(3 * gb, || { + photocraft_io::memory::with_mock_memory(snap, || { + app.open_bytes_placed("dropped.psd", &recovery_psd::make_psd(false), None, Some(0), None).unwrap(); + assert_eq!(app.held_memory_prompts.len(), 1); + assert_eq!(app.held_memory_prompts.front().unwrap().slot, Some(0), "the drop slot travels with the prompt"); + let dlg = app.ui.dialogs[0].id; + assert_eq!(call(&mut app, "ui.dialog.confirm", json!({"dialog": dlg}))["ok"], true); + let names: Vec<_> = app.session.documents().iter().map(|d| d.doc.name.clone()).collect(); + assert_eq!(names, ["dropped.psd", "first.psd", "second.psd"], "the approved drop keeps its tab slot"); + }); + }); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 7c5f4f2c24..9a2c27d001 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -443,8 +443,8 @@ stdio, and a value that is not a port is a usage error naming the variable. Impl | `jobs.list` / `jobs.cancel` | `{}` / `{job?}`: background jobs; cancel one or all. Every request (and MCP tool call) first applies the jobs that finished, so `doc.save`, `doc.inspect`, `doc.render` and `session.list` include a finished job's result without polling `jobs.list` first | | `engine.commands` | `{filter?}`: registry with params docs and enablement (`enabled`, `disabledReason`, `needsDocument`); `result` is a bare array of command objects ([Command list replies](#command-list-replies)) | | `session.list` | open documents and the active index | -| `doc.open` / `doc.new` | `{path}` / `file.new` params | -| `doc.place` | `{path, scale? (percent), fit? (true), center? ([x, y]), index?}`: places a file as a Smart Object layer above the active one and returns `{layer, scale, bounds}`. `path` resolves beneath the read root, so this is the capability-scoped `file.placeEmbedded` — that command itself still refuses an ambient path | +| `doc.open` / `doc.new` | `{path, approveHighMemory? (false)}` / `file.new` params (`approveHighMemory` is the same one-shot PSD/PSB consent as the `file.openAs` command; see [development.md](development.md) for the structured refusal fields) | +| `doc.place` | `{path, scale? (percent), fit? (true), center? ([x, y]), approveHighMemory? (false), index?}`: places a file as a Smart Object layer above the active one and returns `{layer, scale, bounds}`. `path` resolves beneath the read root, so this is the capability-scoped `file.placeEmbedded` — that command itself still refuses an ambient path. `approveHighMemory` is refused unless it is a boolean | | `doc.replaceSmartObject` | `{path, layer? (id or name; default the active layer), index?}` → the command's `{layer, instances}`: replaces a Smart Object's contents (one history step, transform and filters kept). `path` resolves beneath the read root, so this is the capability-scoped `layer.smartObjects.replaceContents`, which itself still refuses an ambient path (#8061) | | `doc.exportLayers` | `{dir, format? (png), prefix?, visibleOnly? (true), quality?, index?}` → `{files, skipped}` (plus `count` for workspace exports): one file per exported top-level layer, named like `file.export.layersToFiles` names them. `count` is present only for workspace exports and counts written files only. `skipped` is always an array (empty when nothing is omitted), in top-to-bottom order, of `{layer, name, reason}`: the session layer id, original unsanitized name, and `"hidden by visibleOnly"` or `"adjustment layer has no pixels of its own"`. Visibility takes precedence for hidden adjustments; `visibleOnly:false` exports hidden non-adjustment layers. Groups export merged; their children are not separate exports or skips. Existing filenames/numbering are unchanged. `dir` resolves beneath the write root, so the ambient command stays refused. The bytes come from the command's own core, so both paths write the same files; the write capability refuses to overwrite an existing file. Encode/write failures are errors, not skips | | `doc.exportDataSets` | `{dir, format? (png), dataSets? (all), naming? ("{name}\|{index}\|{document}"), index?}` → `{files, count}`: the capability-scoped `file.export.dataSetsAsFiles`. The batch is planned first and colliding names are refused before any file is written, as in the command | diff --git a/docs/development.md b/docs/development.md index 286b235eca..e445ab913b 100644 --- a/docs/development.md +++ b/docs/development.md @@ -666,22 +666,115 @@ are refused. Valid off-canvas pixels are preserved; oversized layers are rejecte clipped. Native desktop platforms use an adaptive memory policy evaluated against currently available -physical RAM (excluding swap) and system commit headroom: -- **Hard budget (H)**: $\min(75\% ext{ RAM}, ext{RAM} - 1 ext{ GiB}, ext{commit headroom} - 512 ext{ MiB}, ext{address-space limits})$. -- **Soft budget (S)**: $\min(50\% ext{ RAM}, 8 ext{ GiB}, H)$. +physical RAM (excluding swap) and, where measurable, system commit headroom: +- **Hard budget (H)** for known RAM: $\min(75\% \text{ RAM}, \text{RAM} - 1 \text{ GiB}, \text{commit headroom} - 512 \text{ MiB}, \text{aggregate address-space cap})$. +- **Soft budget (S)**: $\min(50\% \text{ RAM}, 8 \text{ GiB}, H)$. - Files requiring $\le S$ are admitted automatically. - Files requiring $S < E \le H$ prompt for explicit one-shot user confirmation ("Open anyway") in the UI. Headless callers default to no interactive dialog and require explicit per-invocation opt-in: - Engine commands: `file.openAs` and `file.placeEmbedded` take `approveHighMemory: bool` (default `false`); `file.approveMemory` takes `{"id": u64, "approve": bool = true}`. - Headless RPC / control protocol: `doc.open` and `doc.place` take `approveHighMemory: bool`. - - MCP: `doc_open` takes `approve_high_memory: bool`. + - MCP: `doc_open` and `doc_place` take `approve_high_memory: bool` (default `false`; a wrong type is refused, not coerced). - CLI: `convert`, `info`, `run`, and `batch` take `--approve-high-memory`. - Explicit approval authorizes imports within $(S, H]$ but never bypasses the hard memory budget $H$. - Files requiring $> H$ are refused and cannot be overridden. -- Fallback allowance when usable memory is unknown is 2 GiB with no confirmation override; this currently applies on macOS. Adaptive memory detection is available on Linux and Windows. WebAssembly uses a fixed 1 GiB limit. +- When available RAM is unknown (an unsupported platform or a failed provider), native + automatic admission is 2 GiB, not 8 GiB: + $S = \min(2 \text{ GiB}, H)$, where $H$ is the aggregate address-space cap + (`native_platform_cap`), reduced by known commit headroom. Estimates above $S$ need the same + explicit, per-invocation approval as known-memory estimates above $S$, and approval is possible + up to $H$. Estimates above 8 GiB remain approvable; `MemoryBudget::warning_threshold` and + `MemoryBudget::requires_uncertainty_warning` let callers show the uncertain-capacity warning. + 8 GiB is a warning threshold, not a hard ceiling, because the process may still run out of + memory. +- These budgets apply to an import's **aggregate peak estimate**, not to one allocation: a peak + may sum many layer, mask, merged and workspace buffers. Each individual buffer must still fit + the target's layout limit (`isize::MAX`) and the decoder's own per-buffer allowance + (`photocraft-psd`'s `MAX_DECODED_BYTES`, `photocraft-codecs`' `Limits::max_alloc`), which is + enforced by fallible allocation or an explicit cap where that buffer is created. A 64-bit + native process has no measurable aggregate address-space cap, so admission relies on explicit + approval plus those per-buffer checks; 32-bit native uses a 2 GiB project cap + (`NATIVE_32BIT_PEAK_CAP`), a product choice below its 4 GiB address space. +- WebAssembly (wasm32): automatic admission is 1 GiB; estimates above it use the same explicit + approval. The architectural linear-memory limit is 4 GiB (65,536 × 64 KiB pages), shared with + the runtime, stack, allocator, resident document and history, and one Rust allocation cannot + exceed `isize::MAX` (2 GiB − 1). Admission uses a **project cap of 2 GiB** + (`WASM_MAX_PEAK_BUDGET`) for the aggregate peak, chosen below the address space to leave room + for the rest; it is not an architectural maximum, browsers often allow less, and allocation can + still fail. +- macOS reads a conservative reclaimable-memory measurement through `sysinfo`'s safe API: + $\min(\text{total} - \text{used}, \text{available})$, never below `free`. `total` is + `HW_MEMSIZE`; `used` is `internal_page_count - purgeable_count + wire_count + + compressor_page_count`; `available` is sysinfo's `available_memory` (`active + inactive + + free_count`, where XNU counts speculative pages inside `free_count`); `free` is its + `free_memory` (`free_count - speculative`). XNU documents *available memory* (active + + inactive + free + speculative) as everything not wired, compressed or stolen, which counts + anonymous working sets, while $\text{total} - \text{used}$ is the pool that can be handed out + without evicting one: free + file-backed + purgeable pages, close to the fully-reclaimable + pool jetsam monitors. Taking the smaller of the two keeps a counter disagreement + conservative, and `free` is a measured floor because those pages are allocatable by + definition. Swap is excluded. A genuine zero reading stays zero; unknown memory is reported + only when the provider query demonstrably failed (zero installed size or zero used memory, + which a live host cannot report). Linux reads `MemAvailable` from `/proc/meminfo` and + constrains it by the memory cgroup: v2 from the unified hierarchy's + `memory.max`/`memory.current`/`memory.swap.max`, using `cgroup.controllers` and + `cgroup.subtree_control` to distinguish a genuinely disabled controller (and the interface-file + exemption of the hierarchy root) from a view that is missing files it should expose; v1 from the + `memory` controller mount found in `/proc/self/mountinfo` (including dedicated mounts, bind + mounts and non-root process paths), applying every visible ancestor limit. When several mounts + match, the widest view (the shortest cgroup mount root) is used so a deep bind mount cannot hide + a constrained ancestor. Inactive page cache counts as reclaimable (`inactive_file` on v2, + `total_inactive_file` on v1). A v1 `memsw` limit also caps RAM; per ancestor its swap allowance + is `memsw limit - memory limit - swap already used`, and those allowances are combined + separately from RAM headroom. A memory cgroup whose mount, path, membership, limit or usage + cannot be read, or whose membership file is malformed or empty, fails closed (available memory + is treated as zero) rather than falling back to unrestricted host RAM. - Applications can lower the admission threshold with `ImportOptions::psd_max_pixel_bytes`; explicit caller limits may lower admission, never bypass the hard boundary. - Concurrent PSD decodes are serialized using a process-wide, cancellation-aware admission gate, with nested decodes sharing the enclosing allowance. +- A pending high-memory approval is bound to one source and one estimate. The engine keeps at + most 32 pending preflights and, separately, at most 1 GiB of source bytes + (`Session::MAX_PENDING_PREFLIGHT_BYTES`): only an in-memory source holds data (a path streams + from its file once opened), and an `Arc` shared by several preflights is counted once. The + retention cap is not a pixel budget or an admission limit; it keeps an unanswered prompt from + pinning unbounded memory, and is deliberately below the 2 GiB web hard cap. The charge is + computed from the queue, so answering, cancelling, refusing or clearing releases it. +- The approval verifies the file it decodes, not the path it was told about: the importer opens + the path once, reads `photocraft_io::FileIdentity` from that handle (Unix: device, inode, + length, mtime and ctime with nanoseconds), refuses with `SourceChanged` when it is not the + approved file, and re-reads the identity from the same handle when the memory preflight pauses + for approval. Windows has no stable file-index API in `std`, so its identity is the length and + modification time (a different file with both preserved is not distinguished there). The + `file.approveMemory` permission is one-shot and is not journaled or recorded in an action. A + refusal above the hard ceiling is the typed `PsdHardBudget` error (name, required, limit), not + a string, and it releases the queued source. +- A layered TIFF (Photoshop layer data in tags 37724/34377) is admitted by the same policy + before its composite is decoded. The page's tags are read into the PSD model first, without + pixels; then that model's layer/workspace estimate, the decoded composite and its + extra-sample planes, the planar big-endian merged copy, a 16-bit-float conversion's extra + copies, the decoder's scratch buffers (one strip or tile per worker, see + `codecs::TiffBands::decode_scratch_bytes`) and the decoder's copy of the tags are charged + together, with the caller limit and approval flags from `ImportOptions`. A zero caller limit + means the adaptive budget, as for a PSD. A TIFF whose layer tag cannot be read still opens + flattened with the same warning as before, and that whole-image decode and conversion are + bounded the same way. A layered TIFF opened inside another import spends the enclosing + attempt's remaining allowance instead of resolving a fresh budget, and its admission guard is + held across the composite decode and the PSD conversion, so a top-level import is charged + once and a same-file continuation is never charged again. +- Before waiting for the decode gate, an import checks admission; it checks again with a fresh + measurement once admitted, and only then decodes. This holds for the ordinary PSD and layered + TIFF paths and for the standalone legacy `psd_to_document` conversion, so an ineligible file + never waits behind an active decode. A nested import checks cancellation before consuming its + parent's remaining allowance and takes the reentrant admission guard first, so a cancelled or + refused nested import never spends the parent's budget. Hiding a large estimate behind a + destroyed composite or a malformed layer tag does not bypass the obvious bound: the + codecs' per-buffer limit (`Limits::max_alloc`) and the flat band check still apply to a page + opened without Photoshop layers. -Headless RPC and MCP report structured memory errors with byte counts (`required`, `softLimit`, `hardLimit`, and `availableRam` when known). GUI automation imports currently cross a service boundary that returns string errors. +Headless RPC and MCP report approval-required memory errors with byte counts (`required`, +`softLimit`, `hardLimit`, and `availableRam` when known). A hard-budget refusal reports only +`required` and `hardLimit`: no approval can raise that ceiling, so there is no soft limit or +capacity to act on. This holds for `doc.open` and `doc.place` and for the MCP `doc_open` and +`doc_place` tools, including a refusal from `file.openAs`/`file.placeEmbedded` reached through a +command. GUI automation imports currently cross a service boundary that returns string errors. An import exceeding the hard budget returns `IoError::PsdImportBudget` with the estimated and allowed byte counts; imports requiring confirmation return `IoError::PsdApprovalRequired` @@ -707,6 +800,12 @@ contents, generated vector caches, and text preparation are outside it and retai existing limits. Joining these paths to a shared budget and streaming ZIP inflation are follow-up work. An accepted estimate does not guarantee allocation success on a memory-constrained host. +The legacy `memory::available()` helper used by flat TIFF conversion keeps its established +meaning: on Linux it reports `MemAvailable + SwapFree`, now bounded by cgroup v2 swap limits or +v1 `memsw`, and it is `None` on Windows and macOS. The adaptive budget is the only consumer of +the macOS physical-memory provider. Banded flat TIFF pages keep that helper; a layered TIFF's +whole-image work is admitted by the adaptive policy above. + ## Test corpora Real files are our best oracles, but they are large binaries, so they never go into this From e97480ac764b17035e31d581d2fee147f56654cf Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Tue, 3 Nov 2026 01:31:34 +0530 Subject: [PATCH 180/216] fix: synchronize test dependencies and repair CI compile diagnostics --- Cargo.lock | 1 + crates/engine/src/jobs.rs | 2 +- crates/io/src/flat.rs | 4 ++-- crates/io/src/memory.rs | 22 +++++++++++----------- 4 files changed, 15 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 87541c6e37..1b9d9b37df 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5074,6 +5074,7 @@ dependencies = [ "photocraft-engine", "photocraft-format", "photocraft-io", + "photocraft-psd", "photocraft-raster", "photocraft-raw", "rmcp", diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index 91b0e11ca4..529873aa90 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -577,7 +577,7 @@ impl SourceFingerprint { match source { OpenSource::Bytes(b) => Ok(SourceFingerprint::Bytes { len: b.len(), identity: Arc::as_ptr(b) as usize }), OpenSource::Path(p) => { - Self::path(p.clone(), photocraft_io::FileIdentity::of_path(std::path::Path::new(p)).map_err(|e| EngineError::Other(format!("{p}: {e}")))?) + Ok(Self::path(p.clone(), photocraft_io::FileIdentity::of_path(std::path::Path::new(p)).map_err(|e| EngineError::Other(format!("{p}: {e}")))?)) } } } diff --git a/crates/io/src/flat.rs b/crates/io/src/flat.rs index c8303c7b84..7c7afe2c1f 100644 --- a/crates/io/src/flat.rs +++ b/crates/io/src/flat.rs @@ -1562,7 +1562,7 @@ mod tiff_banded_tests { let img = Image::from_raw(64, 64, ChannelLayout::Rgb, CSample::U8, vec![77; 64 * 64 * 3]).unwrap(); let bytes = codecs::encode(&img, Format::Tiff, &codecs::EncodeOptions::default()).unwrap(); let bands = codecs::TiffBands::open(&bytes, None, &codecs::Limits::banded()).unwrap(); - let r = tiff_to_document("small.tif", bands.upright(), || Some(0), BAND_BYTES).unwrap(); + let r = tiff_to_document("small.tif", bands.upright(), || Some(0), BAND_BYTES, &photocraft_raster::Interrupt::NONE).unwrap(); assert_eq!((r.document.size.width, r.document.size.height), (64, 64)); } @@ -1578,7 +1578,7 @@ mod tiff_banded_tests { let bytes = codecs::encode(&img, Format::Tiff, &codecs::EncodeOptions::default()).unwrap(); let bands = codecs::TiffBands::open(&bytes, None, &codecs::Limits::banded()).unwrap(); let row = w as usize * layout.channels() * sample.bytes(); - let r = tiff_to_document("rounds.tif", bands, || None, 3 * row).unwrap(); + let r = tiff_to_document("rounds.tif", bands, || None, 3 * row, &photocraft_raster::Interrupt::NONE).unwrap(); let f = document_format(layout, sample); let want = codecs::decode(&bytes).unwrap().convert(f.layout, f.sample); let LayerContent::Raster(s) = &r.document.layers[0].content else { panic!("not a raster layer") }; diff --git a/crates/io/src/memory.rs b/crates/io/src/memory.rs index 8f1f191424..0079e91724 100644 --- a/crates/io/src/memory.rs +++ b/crates/io/src/memory.rs @@ -1128,17 +1128,17 @@ pub(super) mod linux { let mut out = Vec::with_capacity(bytes.len()); let mut index = 0; while let Some(&byte) = bytes.get(index) { - if byte == b'\\' { - if let Some(digits) = bytes.get(index + 1..index + 4) { - let code = digits.iter().try_fold(0u32, |acc, digit| { - let value = digit.checked_sub(b'0')?; - (value < 8).then_some(acc * 8 + u32::from(value)) - }); - if let Some(byte) = code.and_then(|code| u8::try_from(code).ok()) { - out.push(byte); - index += 4; - continue; - } + if byte == b'\\' + && let Some(digits) = bytes.get(index + 1..index + 4) + { + let code = digits.iter().try_fold(0u32, |acc, digit| { + let value = digit.checked_sub(b'0')?; + (value < 8).then_some(acc * 8 + u32::from(value)) + }); + if let Some(byte) = code.and_then(|code| u8::try_from(code).ok()) { + out.push(byte); + index += 4; + continue; } } out.push(byte); From 03cbe19f087ff7457f1b3e6ff975d251cc3fad40 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Tue, 3 Nov 2026 01:49:57 +0530 Subject: [PATCH 181/216] fix: clear assertion lints and UI test borrow errors --- crates/io/src/memory.rs | 4 ++-- crates/ui-egui/src/app/files.rs | 2 +- crates/ui-egui/src/psd_open_tests.rs | 6 ++++-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/crates/io/src/memory.rs b/crates/io/src/memory.rs index 0079e91724..ccddd9cca1 100644 --- a/crates/io/src/memory.rs +++ b/crates/io/src/memory.rs @@ -1451,7 +1451,7 @@ mod tests { assert_eq!(b.soft_limit, WASM_BUDGET); assert_eq!(b.hard_limit, WASM_MAX_PEAK_BUDGET); assert!(b.hard_limit < WASM_LINEAR_MEMORY_LIMIT, "the aggregate cap stays below the wasm32 address space"); - assert!(WASM_MAX_SINGLE_ALLOCATION < WASM_LINEAR_MEMORY_LIMIT, "a single Rust allocation cannot use all 4 GiB"); + const { assert!(WASM_MAX_SINGLE_ALLOCATION < WASM_LINEAR_MEMORY_LIMIT, "a single Rust allocation cannot use all 4 GiB") }; assert_eq!(b.available_ram, None); assert_eq!(b.warning_threshold, None); @@ -1459,7 +1459,7 @@ mod tests { assert_eq!(b.decide(WASM_BUDGET + 1), BudgetDecision::RequiresApproval); assert_eq!(b.decide(WASM_MAX_PEAK_BUDGET), BudgetDecision::RequiresApproval); assert_eq!(b.decide(WASM_MAX_PEAK_BUDGET + 1), BudgetDecision::Refused); - assert!(WASM_MAX_SINGLE_ALLOCATION < WASM_MAX_PEAK_BUDGET, "the aggregate cap must not be the single-allocation layout bound"); + const { assert!(WASM_MAX_SINGLE_ALLOCATION < WASM_MAX_PEAK_BUDGET, "the aggregate cap must not be the single-allocation layout bound") }; let out = evaluate_psd_admission_for(Platform::Web, WASM_MAX_PEAK_BUDGET, 0, true, Some(WASM_MAX_PEAK_BUDGET), MemorySnapshot::UNKNOWN).unwrap(); assert_eq!(out.authorized_total, WASM_MAX_PEAK_BUDGET, "approval may authorize an aggregate above one buffer's layout limit"); diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index 1ec892886d..b4df20f4c5 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -52,7 +52,7 @@ impl PhotocraftApp { /// shown to the user). Files from disk go through [`open_file`](Self::open_file), which also /// remembers the path. Brush and gradient files go to the preset libraries instead. pub fn open_bytes(&mut self, name: &str, bytes: &[u8]) -> Result, String> { - self.open_bytes_placed(name, bytes, None, None, None) + self.open_bytes_from(name, bytes, None) } /// [`Self::open_bytes`] for a file read from `path` (File › Save writes back there; it goes diff --git a/crates/ui-egui/src/psd_open_tests.rs b/crates/ui-egui/src/psd_open_tests.rs index 1c5e2ab0f4..7d9c947a94 100644 --- a/crates/ui-egui/src/psd_open_tests.rs +++ b/crates/ui-egui/src/psd_open_tests.rs @@ -735,7 +735,8 @@ fn memory_prompt_states_unknown_capacity_and_the_eight_gib_warning() { assert!(message.contains("could not be measured"), "{message}"); assert!(message.contains("8 GiB") && message.contains("may run out of memory"), "{message}"); assert!(!message.contains("available)"), "the unknown-capacity message claims no available figure: {message}"); - assert_eq!(call(&mut app, "ui.dialog.cancel", json!({"dialog": app.ui.dialogs[0].id}))["ok"], true); + let dialog = app.ui.dialogs[0].id; + assert_eq!(call(&mut app, "ui.dialog.cancel", json!({"dialog": dialog}))["ok"], true); // A measured capacity shows the number and drops the uncertainty warning. let id = store_preflight(&mut app, "known.psd", 3 * gb, Some(5 * gb)); @@ -752,7 +753,8 @@ fn memory_prompt_states_unknown_capacity_and_the_eight_gib_warning() { let message = fields["message"].as_str().unwrap(); assert!(message.contains("5.0 GiB available"), "{message}"); assert!(!message.contains("could not be measured") && !message.contains("may run out of memory"), "{message}"); - assert_eq!(call(&mut app, "ui.dialog.cancel", json!({"dialog": app.ui.dialogs[0].id}))["ok"], true); + let dialog = app.ui.dialogs[0].id; + assert_eq!(call(&mut app, "ui.dialog.cancel", json!({"dialog": dialog}))["ok"], true); assert!(app.ui.dialogs.is_empty()); } From ac14963dd2a927bdce99b8ddc477ef7ff0eb3664 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Tue, 3 Nov 2026 02:17:39 +0530 Subject: [PATCH 182/216] test: pass display names separately from source paths on memory retry --- crates/ui-egui/src/psd_open_tests.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/psd_open_tests.rs b/crates/ui-egui/src/psd_open_tests.rs index 7d9c947a94..719c760f35 100644 --- a/crates/ui-egui/src/psd_open_tests.rs +++ b/crates/ui-egui/src/psd_open_tests.rs @@ -250,7 +250,7 @@ fn memory_confirmation_resume_inline_success() { photocraft_io::psd_budget::with_test_estimate_charge(3 * gb, || { photocraft_io::memory::with_mock_memory(snap, || { // The real inline import raises the preflight: no fabricated prompt or preflight. - app.open_bytes_from("/test/inline_resume.psd", &psd, Some("/test/inline_resume.psd")).unwrap(); + app.open_bytes_from("inline_resume.psd", &psd, Some("/test/inline_resume.psd")).unwrap(); assert!(app.session.documents().is_empty(), "the file waits for approval"); assert_eq!(app.held_memory_prompts.len(), 1); assert_eq!(app.ui.dialogs.len(), 1); @@ -293,7 +293,7 @@ fn memory_confirmation_resume_background_success() { let snap = photocraft_io::memory::MemorySnapshot { available_ram: Some(5 * gb), swap_free: None, commit_headroom: None }; photocraft_io::psd_budget::with_test_estimate_charge(3 * gb, || { photocraft_io::memory::with_mock_memory(snap, || { - app.open_bytes_from("/test/bg_resume.psd", &psd, Some("/test/bg_resume.psd")).unwrap(); + app.open_bytes_from("bg_resume.psd", &psd, Some("/test/bg_resume.psd")).unwrap(); assert_eq!(app.jobs.opens.len(), 1, "a background open tab appears at once"); let jobs: Vec<_> = app.session.jobs().iter().map(|j| j.id).collect(); for id in jobs { From 13271f58fa9dd13c2c88b56bed91fa59dc5def7b Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Mon, 2 Nov 2026 22:16:32 +0100 Subject: [PATCH 183/216] Size filter working sets by area and serialize overlapping operations working_bytes reserved three 256 MiB result groups for every filter, so two concurrent filters on a 24x18 image could exhaust the transient allowance and actions.play reported 'not enough working memory'. Bound the result groups by the area, charge only untiled RSS when resident tiles are evictable, always admit the transient allowance, and let an operation that fits on its own wait (bounded, 30 s, native only) for concurrent reservations instead of failing. Replace the deprecated AtomicU64::fetch_update with try_update (stable since 1.95, the MSRV). --- crates/algo/src/lib.rs | 3 +- crates/algo/src/tests.rs | 3 ++ crates/raster/src/memory.rs | 60 +++++++++++++++++++++++++++++++++---- 3 files changed, 59 insertions(+), 7 deletions(-) diff --git a/crates/algo/src/lib.rs b/crates/algo/src/lib.rs index def8d6b42e..60a3b488e4 100644 --- a/crates/algo/src/lib.rs +++ b/crates/algo/src/lib.rs @@ -1035,7 +1035,8 @@ pub fn working_bytes(params: &FilterParams, area: Rect, bounds: Rect, channels: let threads = 1u64; let tile = auto_tile(params, bounds).max(1) as u64; let floats = (channels as u64).saturating_mul(4); - let base = (RESULT_BUDGET as u64).saturating_mul(3); + // Result groups hold at most RESULT_BUDGET bytes of floats, and never more than the area. + let base = area.size().area().saturating_mul(floats).min(RESULT_BUDGET as u64).saturating_mul(3); match params.halo_for(bounds) { Halo::Bounds => base.saturating_add(bounds.union(&area).size().area().saturating_mul(floats)), Halo::Radius(r) => { diff --git a/crates/algo/src/tests.rs b/crates/algo/src/tests.rs index 1ed8c11403..8a2e6a272d 100644 --- a/crates/algo/src/tests.rs +++ b/crates/algo/src/tests.rs @@ -11,6 +11,9 @@ fn relight_admission_tracks_the_document_dependent_working_set() { let large = Rect::new(0, 0, 86400, 43200); assert!(working_bytes(¶ms, small, small, 4) < working_bytes(¶ms, small, large, 4)); assert_eq!(working_bytes(¶ms, Rect::EMPTY, large, 4), 0); + // A tiny image must not reserve the 256 MiB group budget three times over. + let tiny = Rect::new(0, 0, 24, 18); + assert!(working_bytes(¶ms, tiny, tiny, 4) < 64 << 20, "{}", working_bytes(¶ms, tiny, tiny, 4)); } fn fmt(s: SampleType) -> PixelFormat { diff --git a/crates/raster/src/memory.rs b/crates/raster/src/memory.rs index 069f8c0af0..5ad3df50d0 100644 --- a/crates/raster/src/memory.rs +++ b/crates/raster/src/memory.rs @@ -122,7 +122,7 @@ pub struct Reservation(u64); pub fn try_reserve(bytes: u64) -> Result { let (_, b, _) = stats(); RESERVED - .fetch_update(Ordering::AcqRel, Ordering::Relaxed, |n| n.checked_add(bytes).filter(|next| *next <= b.transient_bytes)) + .try_update(Ordering::AcqRel, Ordering::Relaxed, |n| n.checked_add(bytes).filter(|next| *next <= b.transient_bytes)) .map_err(|_| "not enough working memory; reduce the operation or wait for other jobs".to_string())?; Ok(Reservation(bytes)) } @@ -133,16 +133,51 @@ pub fn try_reserve(bytes: u64) -> Result { pub fn try_reserve_operation(bytes: u64) -> Result { let b = refresh(); let (sample, _, _) = stats(); - let available = if sample.total_bytes == 0 { b.transient_bytes } else { b.effective_process_bytes.saturating_sub(sample.process_resident_bytes) }; - RESERVED - .fetch_update(Ordering::AcqRel, Ordering::Relaxed, |n| n.checked_add(bytes).filter(|next| *next <= available)) - .map_err(|_| "not enough working memory for this operation; reduce its region or use a streaming format".to_string())?; - Ok(Reservation(bytes)) + // Resident tiles are evictable when spilling is on, so only the untiled part of RSS is + // charged. The transient allowance is always admissible: it is what the budget reserves + // for working sets, so normal-sized operations are never refused by a high RSS sample. + let evictable = if crate::spill::enabled() { crate::spill::stats().resident_bytes as u64 } else { 0 }; + let untiled = sample.process_resident_bytes.saturating_sub(evictable); + let available = if sample.total_bytes == 0 { b.transient_bytes } else { b.effective_process_bytes.saturating_sub(untiled).max(b.transient_bytes) }; + let refused = || "not enough working memory for this operation; reduce its region or use a streaming format".to_string(); + if bytes > available { + return Err(refused()); + } + // It fits on its own: wait (bounded) for concurrent operations to return their working + // sets instead of failing an operation that only overlapped another one. + #[cfg(not(target_arch = "wasm32"))] + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + loop { + if RESERVED.try_update(Ordering::AcqRel, Ordering::Relaxed, |n| n.checked_add(bytes).filter(|next| *next <= available)).is_ok() { + return Ok(Reservation(bytes)); + } + #[cfg(target_arch = "wasm32")] + return Err(refused()); + #[cfg(not(target_arch = "wasm32"))] + { + let now = std::time::Instant::now(); + if now >= deadline { + return Err(refused()); + } + let guard = RELEASED.0.lock().unwrap_or_else(PoisonError::into_inner); + // Re-checked after the wake-up; a missed notification costs at most this slice. + let slice = deadline.saturating_duration_since(now).min(std::time::Duration::from_millis(50)); + drop(RELEASED.1.wait_timeout(guard, slice).unwrap_or_else(PoisonError::into_inner)); + } + } } +#[cfg(not(target_arch = "wasm32"))] +static RELEASED: (Mutex<()>, std::sync::Condvar) = (Mutex::new(()), std::sync::Condvar::new()); + impl Drop for Reservation { fn drop(&mut self) { RESERVED.fetch_sub(self.0, Ordering::AcqRel); + #[cfg(not(target_arch = "wasm32"))] + { + let _guard = RELEASED.0.lock().unwrap_or_else(PoisonError::into_inner); + RELEASED.1.notify_all(); + } } } @@ -170,6 +205,19 @@ mod tests { assert_eq!(b.tile_bytes, MIB); } #[test] + fn an_operation_that_fits_alone_waits_for_a_concurrent_one() { + let available = refresh().transient_bytes; + let first = try_reserve_operation(available / 2 + 1).unwrap(); + let releaser = std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(100)); + drop(first); + }); + let second = try_reserve_operation(available / 2 + 1); + releaser.join().unwrap(); + assert!(second.is_ok(), "overlapping work is serialized, not refused"); + assert!(try_reserve_operation(u64::MAX).is_err(), "work that can never fit is refused at once"); + } + #[test] fn reservations_are_bounded_and_returned() { let before = stats().2; let r = try_reserve(1024).unwrap(); From 947038d0db52280ad003eb3d55f6f19aa5070f66 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Mon, 2 Nov 2026 22:17:50 +0100 Subject: [PATCH 184/216] Unlink Unix scratch files right after opening The spill manager is process-global and never dropped, so the Unix scratch file was only removed by the next launch's reap. Unlink it immediately after opening and read/write through the one handle with pread/pwrite (readers can no longer reopen it by path). Windows keeps delete-on-close and per-thread readers. The abrupt-exit test now asserts that no file remains on every platform; docs/scratch-disks.md updated. --- crates/compose/src/lib.rs | 4 +- crates/engine/src/color_channels.rs | 3 +- crates/engine/src/filters/tests.rs | 7 +-- crates/raster/src/lib.rs | 14 ++--- crates/raster/src/scratch_file.rs | 79 ++++++++++++++++++++++++----- docs/scratch-disks.md | 12 +++-- 6 files changed, 88 insertions(+), 31 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 37f7974564..9039a2c621 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -68,7 +68,9 @@ pub use extents::{ }; #[cfg(test)] use extents::{mask_k, shapeless_stroke_bounds, trim_to_painted}; -pub use fx_cache::{DEFAULT_FX_CACHE_BUDGET, discard_unreadable_pixel_caches, effect_cache_budget, effect_cache_bytes, purge_effect_cache, set_effect_cache_budget}; +pub use fx_cache::{ + DEFAULT_FX_CACHE_BUDGET, discard_unreadable_pixel_caches, effect_cache_budget, effect_cache_bytes, purge_effect_cache, set_effect_cache_budget, +}; #[cfg(test)] use fx_cache::{effect_maps, effect_region, fx_region_bytes}; pub use reduced::{PROXY_THUMBNAIL_PIXELS, render_reduced, render_reduced_damage, render_reduced_rect, thumbnail, thumbnail_buffer, thumbnail_size}; diff --git a/crates/engine/src/color_channels.rs b/crates/engine/src/color_channels.rs index d538267b98..c19c7e389c 100644 --- a/crates/engine/src/color_channels.rs +++ b/crates/engine/src/color_channels.rs @@ -175,7 +175,8 @@ fn restrict_surface_impl(before: &Surface, after: &mut Surface, colors: ColorCha continue; } let offset = i.saturating_mul(warped.bytes_per_pixel()); - let input = source_bytes.as_deref().and_then(|t| t.get(offset..offset.saturating_add(warped.bytes_per_pixel()))).unwrap_or(after.default_bytes()); + let input = + source_bytes.as_deref().and_then(|t| t.get(offset..offset.saturating_add(warped.bytes_per_pixel()))).unwrap_or(after.default_bytes()); for channel in colors.indices() { let offset = channel.saturating_mul(bytes); let range = offset..offset.saturating_add(bytes); diff --git a/crates/engine/src/filters/tests.rs b/crates/engine/src/filters/tests.rs index 077b7636a8..4d21d2dfdf 100644 --- a/crates/engine/src/filters/tests.rs +++ b/crates/engine/src/filters/tests.rs @@ -84,12 +84,7 @@ fn enormous_global_filter_is_refused_before_allocating_and_preserves_document() let mut s = Session::new(); // A sparse document exercises admission without allocating a giant background first. s.add_document( - photocraft_doc::Document::new( - "large", - photocraft_doc::Size::new(300000, 300000), - photocraft_color::ColorMode::Rgb, - photocraft_color::SampleType::U8, - ), + photocraft_doc::Document::new("large", photocraft_doc::Size::new(300000, 300000), photocraft_color::ColorMode::Rgb, photocraft_color::SampleType::U8), None, ); s.execute("layer.new.layer", json!({})).unwrap(); diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs index 2a99549683..4ba7d45ee7 100644 --- a/crates/raster/src/lib.rs +++ b/crates/raster/src/lib.rs @@ -223,8 +223,6 @@ impl DerefMut for TileBytesMut<'_> { fn deref_mut(&mut self) -> &mut [u8] { self.0.as_deref_mut().unwrap_or(&mut self.1) } - - } /// Bytes compared at once while looking for a difference in a tile row. @@ -291,11 +289,13 @@ impl UniformTiles { } let bytes = t.try_bytes().ok()?; let mut px = bytes.chunks_exact(bpp); - let k = px.next().filter(|p0| px.all(|p| p == *p0)).map(|p0| match self.canonical.iter().position(|c| c.try_bytes().is_ok_and(|bytes| bytes.get(..bpp) == Some(p0))) { - Some(k) => k, - None => { - self.canonical.push(t.clone()); - self.canonical.len() - 1 + let k = px.next().filter(|p0| px.all(|p| p == *p0)).map(|p0| { + match self.canonical.iter().position(|c| c.try_bytes().is_ok_and(|bytes| bytes.get(..bpp) == Some(p0))) { + Some(k) => k, + None => { + self.canonical.push(t.clone()); + self.canonical.len() - 1 + } } }); self.of.insert(key, k); diff --git a/crates/raster/src/scratch_file.rs b/crates/raster/src/scratch_file.rs index f597fd4bba..2df4d82f33 100644 --- a/crates/raster/src/scratch_file.rs +++ b/crates/raster/src/scratch_file.rs @@ -1,21 +1,28 @@ //! The scratch file: where cold tiles go when pixel memory is over budget. //! -//! - One file per configured directory. Windows deletes it even after a crash; Unix uses private -//! permissions and reaps files of dead processes when the directory is next configured. +//! - One file per configured directory, never visible after the process ends. Unix creates it +//! with private permissions and unlinks it right after opening, so the space is returned when +//! the last handle closes, even on a crash or `exit` without destructors. Windows opens it +//! delete-on-close. Files of dead processes (an older build, or a crash between creating and +//! unlinking) are reaped when the directory is next configured. //! - Space is handed out in 4 KiB-aligned extents from a free list (best fit, coalescing), else //! appended at the end. //! - All I/O is positioned: no shared cursor, no lock held during I/O. The allocator lock only //! guards the free list. -//! - Reads go through one handle per thread (the file opened again by path): Windows serialises -//! I/O on one synchronous handle, so parallel readers sharing it would run one at a time. +//! - Unix reads and writes with `pread`/`pwrite` on the one handle (no reopen by path, which +//! is impossible once unlinked). Windows reads through one handle per thread (the file opened +//! again by path): it serialises I/O on one synchronous handle, so parallel readers sharing it +//! would run one at a time. //! //! Ported from Fotox's `fx-tiles` scratch file (same author), minus its `unsafe` `ReOpenFile`. use std::collections::{BTreeMap, BTreeSet}; use std::fs::File; use std::path::{Path, PathBuf}; +#[cfg(windows)] +use std::sync::OnceLock; use std::sync::atomic::{AtomicUsize, Ordering}; -use std::sync::{Mutex, OnceLock, PoisonError}; +use std::sync::{Mutex, PoisonError}; const ALIGN: u64 = 4096; @@ -94,8 +101,12 @@ impl Allocator { pub(crate) struct ScratchFile { main: File, + #[cfg(windows)] readers: OnceLock>, path: PathBuf, + /// The directory entry still exists (Unix unlink failed): remove it on drop. + #[cfg(not(windows))] + linked: bool, allocator: Mutex, } @@ -132,7 +143,19 @@ impl ScratchFile { let main = options.open(&path)?; #[cfg(unix)] reap_abandoned(dir, &main); - Ok(Self { main, readers: OnceLock::new(), path, allocator: Mutex::new(Allocator::default()) }) + // Unix: the open handle keeps the inode; nothing is left behind at any exit. A + // filesystem that refuses the unlink keeps the name until drop or the next reap. + #[cfg(not(windows))] + let linked = std::fs::remove_file(&path).is_err(); + Ok(Self { + main, + #[cfg(windows)] + readers: OnceLock::new(), + path, + #[cfg(not(windows))] + linked, + allocator: Mutex::new(Allocator::default()), + }) } pub fn path(&self) -> &Path { @@ -180,7 +203,14 @@ impl ScratchFile { self.allocator.lock().unwrap_or_else(PoisonError::into_inner) } + /// Positioned reads on one handle run in parallel on Unix. + #[cfg(not(windows))] + fn reader(&self) -> &File { + &self.main + } + /// A handle for reading on the calling thread. + #[cfg(windows)] fn reader(&self) -> &File { let extra = self.readers.get_or_init(|| { // The same file opened again by path: std shares read, write and delete, so this @@ -242,8 +272,9 @@ fn reap_with(dir: &Path, uid: u32, live: impl Fn(u32) -> bool) { impl Drop for ScratchFile { fn drop(&mut self) { - // On Windows the file is already gone (delete-on-close); elsewhere remove it. - if !cfg!(windows) { + // Windows deletes on close and Unix unlinked at creation; only a refused unlink remains. + #[cfg(not(windows))] + if self.linked { let _ = std::fs::remove_file(&self.path); } } @@ -251,6 +282,7 @@ impl Drop for ScratchFile { /// A stable small number per thread, handed out in turn, so the worker threads of a pool land /// on different handles. +#[cfg(windows)] fn thread_slot() -> usize { static NEXT: AtomicUsize = AtomicUsize::new(0); thread_local! { @@ -344,10 +376,11 @@ mod tests { .status() .unwrap(); assert!(result.success()); - #[cfg(windows)] - assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0, "delete-on-close survives exit without Drop"); + // Windows: delete-on-close. Unix: unlinked right after opening. Neither needs Drop. + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0, "no scratch file outlives an exit without destructors"); let next = ScratchFile::create(&dir).unwrap(); - assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1, "a new launch reaps the dead process's files"); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0, "a live scratch file has no directory entry either"); + next.write(b"still usable").unwrap(); drop(next); std::fs::remove_dir(dir).unwrap(); } @@ -373,11 +406,33 @@ mod tests { assert!(stale.exists(), "another owner's files remain"); reap_with(&dir, meta.uid(), |pid| pid == 124); assert!(!stale.exists()); - assert!(live.exists() && unrelated.exists() && link.exists() && own.path.exists()); + assert!(live.exists() && unrelated.exists() && link.exists()); + assert!(!own.path.exists() && !own.linked, "the open scratch file is already unlinked"); drop(own); std::fs::remove_dir_all(dir).unwrap(); } + #[test] + fn parallel_positioned_reads_share_one_unlinked_file() { + let dir = std::env::temp_dir().join(format!("pc-scratch-parallel-{}", std::process::id())); + let scratch = ScratchFile::create(&dir).unwrap(); + let extents: Vec<_> = (0..32u8).map(|i| scratch.write(&vec![i; 5000 + usize::from(i)]).unwrap()).collect(); + std::thread::scope(|s| { + for t in 0..8 { + let (scratch, extents) = (&scratch, &extents); + s.spawn(move || { + let mut buf = Vec::new(); + for (i, e) in extents.iter().enumerate().skip(t) { + scratch.read_into(*e, &mut buf).unwrap(); + assert_eq!(buf, vec![i as u8; 5000 + i]); + } + }); + } + }); + drop(scratch); + std::fs::remove_dir(dir).unwrap(); + } + #[test] fn alloc_reuses_and_coalesces() { let mut a = Allocator::default(); diff --git a/docs/scratch-disks.md b/docs/scratch-disks.md index a99cc7b30d..22936897e3 100644 --- a/docs/scratch-disks.md +++ b/docs/scratch-disks.md @@ -18,12 +18,16 @@ directories creates a new scratch file; old disk-backed tiles keep their old fil The development override `PHOTOCRAFT_TILE_BUDGET_MB` fixes the tile allowance for stress tests. Scratch extents are LZ4 compressed, CRC-checked and reused after a tile is released. Closing -the last tile truncates its file. Windows opens files with delete-on-close so process exit, -including exit without Rust destructors, removes them. Unix creates files with mode `0600` -and sweeps `photocraft--.scratch` when configuring the directory. It preserves +the last tile truncates its file. No scratch file outlives the process, including a crash or +an exit without Rust destructors: Windows opens it with delete-on-close, and Unix creates it +with mode `0600` and unlinks it immediately after opening, reading and writing through the +one open handle with positioned I/O (`pread`/`pwrite`). The space is returned when the last +handle closes. Configuring a directory also sweeps `photocraft--.scratch` files of +dead processes (older builds, or a crash between creating and unlinking). It preserves live-process files, other users' files, symlinks and unrelated names; unavailable process enumeration leaves files untouched. PID reuse conservatively retains a stale file, and a -unique launch nonce avoids a collision. Normal Unix drops remove the file immediately. +unique launch nonce avoids a collision. A filesystem that refuses the unlink keeps the name +until the file is dropped or the next sweep. The engine owns a shared, stoppable residency supervisor and joins it when its last session is dropped. `photocraft-raster` only exposes synchronous measurement and bounded eviction From 44f13e5498af63a42a3b0f4cba67774bb5b15eab Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Mon, 2 Nov 2026 22:23:42 +0100 Subject: [PATCH 185/216] Scope scratch read failures to the document that holds the tile One unreadable tile used to block editing, saving and exporting every open document (spill::check_integrity was process-global). Add Tile/Surface/Document::scratch_health (free while no tile has failed) and Document::for_each_surface (ops' history walk now delegates to it). edit_with checks the document before and after, jobs the document they captured, import the result, save/export the document written; check_since is now generation-only so a failure or recovery during an operation refuses it once and a retry works. Caches check their own sources and purge once per new failure instead of every frame. Fault-injection tests use a duplicated handle (the Unix file has no name). --- crates/compose/src/bounds.rs | 2 +- crates/compose/src/fx_cache.rs | 18 ++++++--- crates/compose/src/masks.rs | 2 +- crates/compose/src/shape_split.rs | 2 +- crates/compose/tests/scratch_retry.rs | 4 +- crates/doc/src/lib.rs | 52 ++++++++++++++++++++++++++ crates/engine/src/cmd/timeline_cmds.rs | 3 ++ crates/engine/src/jobs.rs | 6 ++- crates/engine/src/session/execute.rs | 7 +++- crates/engine/tests/scratch_disk.rs | 35 +++++++++++++++-- crates/format/src/store.rs | 20 +++++++--- crates/format/tests/scratch_retry.rs | 4 +- crates/io/src/lib.rs | 6 ++- crates/ops/src/tiles.rs | 38 ++----------------- crates/raster/src/lib.rs | 26 +++++++++++++ crates/raster/src/scratch_file.rs | 5 +++ crates/raster/src/spill.rs | 44 +++++++++++++++++----- docs/scratch-disks.md | 12 ++++-- 18 files changed, 214 insertions(+), 72 deletions(-) diff --git a/crates/compose/src/bounds.rs b/crates/compose/src/bounds.rs index dec2d868d9..d6a3a88ef1 100644 --- a/crates/compose/src/bounds.rs +++ b/crates/compose/src/bounds.rs @@ -62,7 +62,7 @@ pub fn content_bounds(s: &Surface) -> Rect { None => { let generation = photocraft_raster::spill::read_error_generation(); let b = t.content_bounds(&dp); - if photocraft_raster::spill::check_since(generation).is_ok() { + if photocraft_raster::spill::check_since(generation).is_ok() && t.scratch_health().is_ok() { c.entries.insert(key, Entry { tile: Arc::downgrade(t), default: dp.clone().into_boxed_slice(), bounds: b }); } b diff --git a/crates/compose/src/fx_cache.rs b/crates/compose/src/fx_cache.rs index 2559e44854..b24aabc29b 100644 --- a/crates/compose/src/fx_cache.rs +++ b/crates/compose/src/fx_cache.rs @@ -122,10 +122,14 @@ pub fn purge_effect_cache() -> usize { freed } -/// Discard derived pixels and their source pins after a scratch read failure. This also lets -/// closing the affected document release its failed tiles instead of keeping them in a cache. +/// Discard derived pixels and their source pins once after each new scratch read failure. +/// This lets closing the affected document release its failed tiles instead of keeping them +/// in a cache. Caches never admit results built from unreadable tiles, so healthy documents' +/// caches are not purged again on every call while one document stays unreadable. pub fn discard_unreadable_pixel_caches() { - if photocraft_raster::spill::check_integrity().is_err() { + static SEEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let generation = photocraft_raster::spill::read_error_generation(); + if SEEN.swap(generation, std::sync::atomic::Ordering::AcqRel) != generation && photocraft_raster::spill::any_failed() { purge_effect_cache(); super::bounds::purge(); super::masks::purge(); @@ -233,11 +237,15 @@ fn build_fx_slot_maps(layer: &Layer, region: Rect, key: u64, cx: &Ctx, slot: &Fx let shape: Vec = if region.is_empty() { Vec::new() } else { effect_shape(layer, region, cx) }; let maps = effects::build_maps_prepared(layer, shape, region, &cx.light, &texture_ctx(layer, region, cx), cx.patterns); let mut pin = Vec::new(); - layer_identity(layer, &mut std::collections::hash_map::DefaultHasher::new(), &mut |t| pin.push(std::sync::Arc::downgrade(t))); + let mut readable = true; + layer_identity(layer, &mut std::collections::hash_map::DefaultHasher::new(), &mut |t| { + readable &= t.scratch_health().is_ok(); + pin.push(std::sync::Arc::downgrade(t)); + }); // The maps, plus what each reserved tile address keeps allocated. let bytes = maps.bytes().saturating_add(pin.len().saturating_mul(std::mem::size_of::() + 2 * std::mem::size_of::())); let maps = std::sync::Arc::new(maps); - if photocraft_raster::spill::check_since(generation).is_err() { + if !readable || photocraft_raster::spill::check_since(generation).is_err() { // Built from a scratch placeholder: use it for this frame, never cache it. return maps; } diff --git a/crates/compose/src/masks.rs b/crates/compose/src/masks.rs index e60e151f50..faf45a19d0 100644 --- a/crates/compose/src/masks.rs +++ b/crates/compose/src/masks.rs @@ -124,7 +124,7 @@ fn finish_mask(c: &Mutex, k: u64, slot: &Arc>, generati // Never hold the global cache mutex while rasterizing or feathering. let read_generation = photocraft_raster::spill::read_error_generation(); let (surface, pixel_input) = build(); - if photocraft_raster::spill::check_since(read_generation).is_err() { + if photocraft_raster::spill::check_since(read_generation).is_err() || pixel_input.as_ref().is_some_and(|p| p.scratch_health().is_err()) { return surface; } let bytes = entry_bytes(&surface, pixel_input.as_ref()); diff --git a/crates/compose/src/shape_split.rs b/crates/compose/src/shape_split.rs index 176d43e3bf..53875d5922 100644 --- a/crates/compose/src/shape_split.rs +++ b/crates/compose/src/shape_split.rs @@ -83,7 +83,7 @@ pub fn split(sh: &ShapeLayer, canvas: Rect, depth: SampleType) -> Option<(Surfac if let Some(cache) = &sh.cache { fit_stroke(&parts.0, &mut parts.1, cache, canvas); } - if photocraft_raster::spill::check_since(generation).is_err() { + if photocraft_raster::spill::check_since(generation).is_err() || sh.cache.as_ref().is_some_and(|c| c.scratch_health().is_err()) { return Some(parts); } let mut c = cache().lock().unwrap_or_else(|e| e.into_inner()); diff --git a/crates/compose/tests/scratch_retry.rs b/crates/compose/tests/scratch_retry.rs index 051113857c..70fdc41225 100644 --- a/crates/compose/tests/scratch_retry.rs +++ b/crates/compose/tests/scratch_retry.rs @@ -22,8 +22,8 @@ fn failed_effect_build_is_not_reused_after_original_pixels_recover() { photocraft_compose::purge_effect_cache(); spill::settle(); assert_eq!(spill::stats().resident_bytes, 0); - let path = std::fs::read_dir(&dir).unwrap().filter_map(Result::ok).map(|e| e.path()).find(|p| p.extension().is_some_and(|e| e == "scratch")).unwrap(); - let mut file = std::fs::OpenOptions::new().read(true).write(true).open(path).unwrap(); + let mut file = spill::scratch_handle_for_tests().unwrap(); + file.seek(SeekFrom::Start(0)).unwrap(); let mut good = Vec::new(); file.read_to_end(&mut good).unwrap(); file.seek(SeekFrom::Start(0)).unwrap(); diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index ab13697e73..915ddca7da 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -1064,6 +1064,58 @@ impl Document { max } + /// Every pixel surface the document holds: layer pixels and caches, masks, smart filter + /// masks, video frames (including the originals), channels, the quick mask, patterns and + /// the selection. History snapshots and saves cover exactly these tiles. + pub fn for_each_surface(&self, mut visit: impl FnMut(&Surface)) { + for (_, _, layer) in self.walk() { + if let Some(surface) = layer.surface() { + visit(surface); + } + if let Some(mask) = &layer.mask { + visit(&mask.surface); + } + if let Some(cache) = &layer.fill_cache { + visit(&cache.surface); + } + if let LayerContent::Smart(smart) = &layer.content + && let Some(mask) = &smart.filter_mask + { + visit(&mask.surface); + } + if let Some(video) = &layer.video { + for frame in video.frames.iter().chain(video.original_frames.iter().flatten()) { + visit(frame); + } + } + } + for channel in self.channels.iter().chain(&self.quick_mask) { + visit(&channel.surface); + } + for pattern in &self.patterns { + visit(&pattern.surface); + } + if let Some(selection) = &self.selection { + visit(selection); + } + } + + /// `Err` if any of this document's tiles is unreadable on the scratch disk. Edits, saves + /// and exports of this document check it; other documents are unaffected. Free when no + /// tile in the process has failed. + pub fn scratch_health(&self) -> Result<(), String> { + if !photocraft_raster::spill::any_failed() { + return Ok(()); + } + let mut health = Ok(()); + self.for_each_surface(|surface| { + if health.is_ok() { + health = surface.scratch_health(); + } + }); + health + } + /// Depth-first walk yielding `(path, depth, layer)` bottom-to-top. pub fn walk(&self) -> Vec<(LayerPath, usize, &Layer)> { fn rec<'a>(layers: &'a [Layer], prefix: &mut LayerPath, out: &mut Vec<(LayerPath, usize, &'a Layer)>) { diff --git a/crates/engine/src/cmd/timeline_cmds.rs b/crates/engine/src/cmd/timeline_cmds.rs index 7893ac8d8f..9b6e0623cc 100644 --- a/crates/engine/src/cmd/timeline_cmds.rs +++ b/crates/engine/src/cmd/timeline_cmds.rs @@ -31,6 +31,7 @@ fn has_timeline(s: &Session) -> std::result::Result<(), String> { /// Mutate the document timeline without a history step (navigation / metadata). fn with_timeline(s: &mut Session, f: impl FnOnce(&mut Option)) -> Result { let st = s.active_mut().ok_or(EngineError::NoDocument)?; + let generation = photocraft_raster::spill::read_error_generation(); let mut doc = (*st.doc).clone(); crate::video_cmds::store(&mut doc); f(&mut doc.timeline); @@ -38,6 +39,8 @@ fn with_timeline(s: &mut Session, f: impl FnOnce(&mut Option)) -> Resu t.clamp(); } crate::video_cmds::sync(&mut doc); + // Storing and syncing video frames bypasses edit_with: apply the same scratch guard. + photocraft_raster::spill::check_since(generation).and_then(|()| doc.scratch_health()).map_err(EngineError::Other)?; st.doc = Arc::new(doc); st.revision += 1; info(s) diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index 78fcea4956..dfc7fd39f8 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -377,12 +377,16 @@ pub fn run( work: impl FnOnce(&JobCtx) -> Result + Send + 'static, apply: impl FnOnce(&mut Session, T) -> Result + Send + 'static, ) -> Result { + // The job reads the active document it captured; only that document's pixels gate it. + let source = s.active().map(|d| d.doc.clone()); let work = move |ctx: &JobCtx| -> Result { photocraft_compose::discard_unreadable_pixel_caches(); - photocraft_raster::spill::check_integrity().map_err(EngineError::Other)?; + let health = || source.as_deref().map_or(Ok(()), photocraft_doc::Document::scratch_health).map_err(EngineError::Other); + health()?; let generation = photocraft_raster::spill::read_error_generation(); let value = work(ctx)?; photocraft_raster::spill::check_since(generation).map_err(EngineError::Other)?; + health()?; Ok(value) }; let authorization = s.authorize.or_else(|| RENDER_AUTHORIZATION.with(std::cell::Cell::get)); diff --git a/crates/engine/src/session/execute.rs b/crates/engine/src/session/execute.rs index 76d33ecfd0..1a16f6d192 100644 --- a/crates/engine/src/session/execute.rs +++ b/crates/engine/src/session/execute.rs @@ -208,7 +208,6 @@ impl Session { let _authorization = crate::jobs::render_authorization_scope(self.authorize); let _document = crate::doc_path::scope(self.active().and_then(|d| d.path.as_deref()).map(std::path::Path::new)); photocraft_compose::discard_unreadable_pixel_caches(); - photocraft_raster::spill::check_integrity().map_err(EngineError::Other)?; let scratch_generation = photocraft_raster::spill::read_error_generation(); let restrict = self.color_restrict; let filter_mask_layer = self.filter_mask_layer; @@ -216,6 +215,9 @@ impl Session { let symmetry_map = self.symmetry_map.take(); let st = self.active_mut().ok_or(EngineError::NoDocument)?; let before = st.doc.clone(); + // Only this document's pixels gate its edits: an unreadable tile elsewhere (another + // open document) does not block it. + before.scratch_health().map_err(EngineError::Other)?; // Selecting layers is not a step, so the state this edit leaves behind targets what was // selected just before it (#1356): undoing a stroke keeps the painted layer active. let prior = st.layer_target(); @@ -229,7 +231,8 @@ impl Session { if let Some(id) = filter_mask_layer { smart_cmds::refresh_after_filter_mask_edit(&mut doc, id)?; } - if let Err(error) = photocraft_raster::spill::check_since(scratch_generation) { + if let Err(error) = photocraft_raster::spill::check_since(scratch_generation).and_then(|()| before.scratch_health()).and_then(|()| doc.scratch_health()) + { st.active_layer = previous_active_layer; return Err(EngineError::Other(error)); } diff --git a/crates/engine/tests/scratch_disk.rs b/crates/engine/tests/scratch_disk.rs index 6a323c7389..55a361133e 100644 --- a/crates/engine/tests/scratch_disk.rs +++ b/crates/engine/tests/scratch_disk.rs @@ -48,8 +48,8 @@ fn scratch_pixels_history_and_failed_save_are_transactional() { spill::settle(); assert_eq!(spill::stats().resident_bytes, 0); // Damage only this test's scratch file; retain its exact contents to restore the backing. - let path = std::fs::read_dir(&dir).unwrap().filter_map(Result::ok).map(|e| e.path()).find(|p| p.extension().is_some_and(|e| e == "scratch")).unwrap(); - let mut file = std::fs::OpenOptions::new().read(true).write(true).open(path).unwrap(); + let mut file = spill::scratch_handle_for_tests().unwrap(); + file.seek(SeekFrom::Start(0)).unwrap(); let mut good = Vec::new(); file.read_to_end(&mut good).unwrap(); file.seek(SeekFrom::Start(0)).unwrap(); @@ -71,6 +71,35 @@ fn scratch_pixels_history_and_failed_save_are_transactional() { let mut writer = photocraft_format::PcraftWriter::new(); assert!(writer.save_zip(&retained, &Default::default()).is_err()); assert!(photocraft_io::export(&retained, "png", &Default::default()).is_err()); + // No failure or recovery happens during a retry, so only the document's own health + // refuses it. + let generation = spill::read_error_generation(); + assert!(session.edit("Retry paint", |_, _| Ok(())).is_err()); + assert_eq!(spill::read_error_generation(), generation, "an already-failed tile does not change the generation"); + assert!(Arc::ptr_eq(&session.active().unwrap().doc, &retained)); + + // Another document is unaffected: it edits, saves and exports while this one is unreadable. + assert!(spill::check_integrity().is_err(), "the process-wide status still reports the failure"); + let mut other = Document::new("Healthy", Size::new(512, 256), ColorMode::Rgb, SampleType::U8); + let mut pixels = Layer::raster("Other", PixelFormat::RGBA8); + let other_id = pixels.id; + pixels.surface_mut().unwrap().fill_rect(Rect::from_xywh(0, 0, 512, 256), &[0.0, 0.0, 1.0, 1.0]); + other.layers.push(pixels); + session.add_document(other, None); + session.select_layer(other_id).unwrap(); + session + .edit("Paint other", |d, _| { + d.layer_mut(other_id).unwrap().surface_mut().unwrap().fill_rect(Rect::from_xywh(0, 0, 8, 8), &[1.0, 1.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + spill::settle(); + let healthy = session.active().unwrap().doc.clone(); + assert!(healthy.scratch_health().is_ok()); + let (saved, _) = photocraft_format::PcraftWriter::new().save_zip(&healthy, &Default::default()).unwrap(); + assert_eq!(photocraft_format::load_from_bytes(&saved).unwrap().layers, healthy.layers); + assert!(photocraft_io::export(&healthy, "png", &Default::default()).is_ok()); + assert!(retained.scratch_health().is_err()); file.seek(SeekFrom::Start(0)).unwrap(); file.write_all(&good).unwrap(); @@ -84,7 +113,7 @@ fn scratch_pixels_history_and_failed_save_are_transactional() { let (bytes, _) = writer.save_zip(&retained, &Default::default()).unwrap(); let loaded = photocraft_format::load_from_bytes(&bytes).unwrap(); assert_eq!(loaded.layers, retained.layers); - drop((session, before, retained, loaded, writer, file)); + drop((session, before, retained, healthy, loaded, writer, file)); spill::configure(None, 0).unwrap(); let _ = std::fs::remove_dir_all(dir); } diff --git a/crates/format/src/store.rs b/crates/format/src/store.rs index 8b08ef7cfa..f221af9f57 100644 --- a/crates/format/src/store.rs +++ b/crates/format/src/store.rs @@ -579,6 +579,14 @@ struct Prepared { stats: SaveStats, } +/// Refuse to write pixels of `doc` that are unreadable now, or that may have been read as a +/// placeholder because some tile failed or recovered since `generation` (the save's start). +/// Other documents' scratch failures do not block this save. +fn scratch_ok(doc: &Document, generation: u64) -> Result<()> { + doc.scratch_health().map_err(FormatError::corrupt)?; + photocraft_raster::spill::check_since(generation).map_err(FormatError::corrupt) +} + enum Object { Tile(Arc, SampleType), Blob(Arc>), @@ -973,7 +981,7 @@ impl PcraftWriter { } fn prepare(&mut self, doc: &Document, opts: &SaveOptions) -> Result { - photocraft_raster::spill::check_integrity().map_err(FormatError::corrupt)?; + doc.scratch_health().map_err(FormatError::corrupt)?; let generation = photocraft_raster::spill::read_error_generation(); let prepared = self.prepare_inner(doc, opts); if let Err(error) = photocraft_raster::spill::check_since(generation) { @@ -1012,12 +1020,13 @@ impl PcraftWriter { for (h, b) in c.blobs { objects.insert(blob_path(&h).ok_or_else(|| invalid(&h))?, Object::Blob(b)); } - photocraft_raster::spill::check_integrity().map_err(FormatError::corrupt)?; + doc.scratch_health().map_err(FormatError::corrupt)?; Ok(Prepared { manifest, objects, stats }) } /// Save as a ZIP bundle in memory. pub fn save_zip(&mut self, doc: &Document, opts: &SaveOptions) -> Result<(Vec, SaveStats)> { + let generation = photocraft_raster::spill::read_error_generation(); // Another thread encodes the previews while the document is collected and its new // objects are compressed on all cores (zstd dominates a full save). let (previews, prepared) = beside( @@ -1070,7 +1079,7 @@ impl PcraftWriter { next.insert(key.unwrap_or_else(|| path.clone()), data); } } - photocraft_raster::spill::check_integrity().map_err(FormatError::corrupt)?; + scratch_ok(doc, generation)?; self.compressed = next; Ok((z.finish()?, stats)) } @@ -1085,6 +1094,7 @@ impl PcraftWriter { /// The same save path with an injectable publication barrier, for failure/GC ordering tests. fn save_dir_with(&mut self, doc: &Document, dir: &Path, opts: &SaveOptions, fs: &dyn crate::atomic::AtomicFs) -> Result { + let scratch_generation = photocraft_raster::spill::read_error_generation(); let canonical_dir = bundle_root(dir)?; let dir = canonical_dir.as_path(); // Held until the end of the save: listing, writing, publishing and cleanup (#4957). @@ -1145,7 +1155,7 @@ impl PcraftWriter { Object::Blob(_) => stats.blobs_written += 1, } let compressed = obj.compressed(compressor.get_or_insert_with(Compressor::new)); - photocraft_raster::spill::check_integrity().map_err(FormatError::corrupt)?; + scratch_ok(doc, scratch_generation)?; write_bundle_file(dir, &object_path, &compressed, fs)?; let written = path_signature(&object_path)?; if let Some(cache) = &mut self.verified_directory { @@ -1182,7 +1192,7 @@ impl PcraftWriter { confirm_flushed(&folder, &folder)?; } } - photocraft_raster::spill::check_integrity().map_err(FormatError::corrupt)?; + scratch_ok(doc, scratch_generation)?; write_bundle_file(dir, &dir.join(MANIFEST), &p.manifest, fs)?; // Omitted previews belong to the previous manifest until the new one commits. // After commit, a locked leftover is only housekeeping, just like unused objects. diff --git a/crates/format/tests/scratch_retry.rs b/crates/format/tests/scratch_retry.rs index 1bb06110ec..3d8a32f540 100644 --- a/crates/format/tests/scratch_retry.rs +++ b/crates/format/tests/scratch_retry.rs @@ -21,8 +21,8 @@ fn retry_after_failed_hashing_never_reuses_placeholder_hashes() { doc.layers.push(layer); spill::settle(); assert_eq!(spill::stats().resident_bytes, 0); - let path = std::fs::read_dir(&dir).unwrap().filter_map(Result::ok).map(|e| e.path()).find(|p| p.extension().is_some_and(|e| e == "scratch")).unwrap(); - let mut file = std::fs::OpenOptions::new().read(true).write(true).open(path).unwrap(); + let mut file = spill::scratch_handle_for_tests().unwrap(); + file.seek(SeekFrom::Start(0)).unwrap(); let mut good = Vec::new(); file.read_to_end(&mut good).unwrap(); file.seek(SeekFrom::Start(0)).unwrap(); diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 33b0ec7502..6303ebf0b7 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -388,14 +388,15 @@ pub fn import_for_open(name: &str, bytes: &[u8], opts: &ImportOptions) -> Result /// [`import_for_open`] with cancellation and progress, shared with [`import_with_options_and_interrupt`]. pub fn import_for_open_with(name: &str, bytes: &[u8], opts: &ImportOptions, ctl: &photocraft_raster::Interrupt) -> Result<(ImportResult, bool), IoError> { photocraft_compose::discard_unreadable_pixel_caches(); - photocraft_raster::spill::check_integrity().map_err(IoError::Unsupported)?; let scratch_generation = photocraft_raster::spill::read_error_generation(); ctl.check().map_err(|_| IoError::Cancelled)?; let mut damaged = false; let result = import_stages(name, bytes, ctl, opts, &mut damaged)?; ctl.check().map_err(|_| IoError::Cancelled)?; ctl.progress(1.0); + // The imported document's own tiles, and anything that failed or recovered meanwhile. photocraft_raster::spill::check_since(scratch_generation).map_err(IoError::Unsupported)?; + result.document.scratch_health().map_err(IoError::Unsupported)?; Ok((result, damaged)) } @@ -603,10 +604,11 @@ pub fn export(doc: &Document, name_or_ext: &str, opts: &ExportOptions) -> Result pub fn export_with(doc: &Document, name_or_ext: &str, opts: &ExportOptions, ctl: &photocraft_raster::Interrupt) -> Result { ctl.check().map_err(|_| IoError::Cancelled)?; photocraft_compose::discard_unreadable_pixel_caches(); - photocraft_raster::spill::check_integrity().map_err(IoError::Unsupported)?; + doc.scratch_health().map_err(IoError::Unsupported)?; let scratch_generation = photocraft_raster::spill::read_error_generation(); let r = export_stages(doc, name_or_ext, opts, ctl)?; photocraft_raster::spill::check_since(scratch_generation).map_err(IoError::Unsupported)?; + doc.scratch_health().map_err(IoError::Unsupported)?; ctl.check().map_err(|_| IoError::Cancelled)?; ctl.progress(1.0); Ok(r) diff --git a/crates/ops/src/tiles.rs b/crates/ops/src/tiles.rs index dd813a897d..64da14929e 100644 --- a/crates/ops/src/tiles.rs +++ b/crates/ops/src/tiles.rs @@ -11,40 +11,10 @@ pub(crate) struct Tiles { bytes: u128, } -pub(crate) fn surfaces(doc: &Document, mut visit: impl FnMut(&Surface)) { - for (_, _, layer) in doc.walk() { - if let Some(surface) = layer.surface() { - visit(surface); - } - if let Some(mask) = &layer.mask { - visit(&mask.surface); - } - if let Some(cache) = &layer.fill_cache { - visit(&cache.surface); - } - if let LayerContent::Smart(smart) = &layer.content - && let Some(mask) = &smart.filter_mask - { - visit(&mask.surface); - } - if let Some(video) = &layer.video { - // Undo retains the whole clip, not only the displayed frame. Visit aliases too: - // Tiles tracks every owner and counts their shared allocations only once. - for frame in video.frames.iter().chain(video.original_frames.iter().flatten()) { - visit(frame); - } - } - } - for channel in doc.channels.iter().chain(&doc.quick_mask) { - visit(&channel.surface); - } - // A pattern fill or layer style copies its pattern into the document. - for pattern in &doc.patterns { - visit(&pattern.surface); - } - if let Some(selection) = &doc.selection { - visit(selection); - } +pub(crate) fn surfaces(doc: &Document, visit: impl FnMut(&Surface)) { + // Undo retains the whole clip, not only the displayed frame. Video aliases are visited + // too: Tiles tracks every owner and counts their shared allocations only once. + doc.for_each_surface(visit); } /// Every shared allocation `doc` holds that history can retain, as `visit(address, length)`: diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs index 4ba7d45ee7..994c02c57c 100644 --- a/crates/raster/src/lib.rs +++ b/crates/raster/src/lib.rs @@ -129,6 +129,14 @@ impl Tile { let placeholder = if guard.is_none() { vec![0; self.len()] } else { Vec::new() }; TileBytesMut(guard, placeholder) } + /// `Err` while this tile's scratch copy is unreadable (its bytes read as a placeholder). + /// Free when no tile in the process has failed. + pub fn scratch_health(&self) -> Result<(), String> { + if !spill::any_failed() { + return Ok(()); + } + self.cell.read_error().map_or(Ok(()), Err) + } /// Fallible pixel access for callers that can propagate a scratch read failure. pub fn try_bytes(&self) -> Result, String> { let bytes = self.bytes(); @@ -451,9 +459,27 @@ impl Surface { let generation = spill::read_error_generation(); self.read_region_into(r, &mut out); spill::check_since(generation).map_err(AllocationError::ScratchRead)?; + self.region_health(r).map_err(AllocationError::ScratchRead)?; Ok(out) } + /// `Err` if any stored tile of this surface is unreadable. Free when no tile in the + /// process has failed. + pub fn scratch_health(&self) -> Result<(), String> { + if !spill::any_failed() { + return Ok(()); + } + self.tiles.values().try_for_each(|t| t.scratch_health()) + } + + /// [`Surface::scratch_health`] for the tiles overlapping `r` only. + pub fn region_health(&self, r: Rect) -> Result<(), String> { + if !spill::any_failed() { + return Ok(()); + } + r.tiles().filter_map(|c| self.tiles.get(&c)).try_for_each(|t| t.scratch_health()) + } + /// Reads a region as encoded interleaved bytes with fallible allocation. pub fn try_to_interleaved(&self, r: Rect) -> Result, AllocationError> { let bpp = self.format.bytes_per_pixel(); diff --git a/crates/raster/src/scratch_file.rs b/crates/raster/src/scratch_file.rs index 2df4d82f33..2a4d4d1302 100644 --- a/crates/raster/src/scratch_file.rs +++ b/crates/raster/src/scratch_file.rs @@ -158,6 +158,11 @@ impl ScratchFile { }) } + /// Another handle to the open file (the Unix one has no name), for fault-injection tests. + pub(crate) fn duplicate_handle(&self) -> std::io::Result { + self.main.try_clone() + } + pub fn path(&self) -> &Path { &self.path } diff --git a/crates/raster/src/spill.rs b/crates/raster/src/spill.rs index 7bd09c7f58..efdccb5c1c 100644 --- a/crates/raster/src/spill.rs +++ b/crates/raster/src/spill.rs @@ -17,7 +17,8 @@ //! //! Failures never panic: a scratch write that fails keeps the tile in memory and turns spilling //! off (reported in [`SpillStats::last_error`]). A failed read retains its disk extent and -//! poisons the tile: edits and saves must check [`check_integrity`] before committing. The +//! poisons the tile: edits and saves check the health of the document they publish, plus +//! [`check_since`] for failures that came and went during the operation. The //! infallible display API uses a temporary placeholder, never replacement pixel storage. use std::path::{Path, PathBuf}; @@ -487,25 +488,35 @@ pub fn enabled() -> bool { M.budget.load(Ordering::Relaxed) > 0 } -/// Refuse to publish edited or serialized pixels after a scratch read failure. A failed -/// tile can retry its original extent; changing preferences cannot clear this protection. +/// Whether any live tile in the process is currently unreadable (cheap; the fast path of +/// every per-document health check). +pub fn any_failed() -> bool { + M.failed.load(Ordering::Acquire) > 0 +} + +/// Process-wide status for the UI: some live tile, in any document, is unreadable. Commit +/// guards check only the pixels they publish ([`Tile::scratch_health`](crate::Tile::scratch_health) +/// and the document's health) so one document's failure does not block the others. pub fn check_integrity() -> Result<(), String> { - if M.failed.load(Ordering::Acquire) == 0 { - Ok(()) - } else { + if any_failed() { Err(M.config().last_error.clone().unwrap_or_else(|| "scratch pixels are unreadable; editing and saving are blocked".into())) + } else { + Ok(()) } } -/// Capture before an edit/import/save or derived cache computation. A failed tile might be dropped or repaired -/// before the operation ends; its placeholder must still never be committed as real pixels. -/// Both failure and recovery change this generation, including computations begun during a failure. +/// Capture before an edit/import/save or derived cache computation. A tile that fails or +/// recovers while the operation runs might have fed it placeholder pixels and then been +/// dropped or repaired; its result must still never be committed as real pixels. Both failure +/// and recovery change this generation. Tiles that were already unreadable when the operation +/// began do not change it: callers also check the health of the pixels they read. pub fn read_error_generation() -> u64 { M.read_errors.load(Ordering::Acquire) } +/// Refuse a result if any tile failed or recovered since `generation` was captured. A retry +/// (capturing a new generation) succeeds once the transition is over. pub fn check_since(generation: u64) -> Result<(), String> { - check_integrity()?; if generation == read_error_generation() { Ok(()) } else { @@ -513,6 +524,14 @@ pub fn check_since(generation: u64) -> Result<(), String> { } } +/// A second handle to the current scratch file, for tests that damage or restore its bytes. +/// The Unix file is unlinked, so it cannot be reopened by path. All scratch I/O is positioned, +/// so the shared file offset of a duplicated handle does not affect it. +#[doc(hidden)] +pub fn scratch_handle_for_tests() -> Option { + M.config().file.as_ref().and_then(|f| f.duplicate_handle().ok()) +} + /// Evict down to the budget now, on this thread (tests, and before a measurement). pub fn settle() { let budget = M.budget.load(Ordering::Relaxed); @@ -549,6 +568,10 @@ mod tests { let failed_generation = read_error_generation(); assert!(tile.cell.data.read().unwrap().is_none(), "placeholder must never become stored pixels"); assert!(check_integrity().is_err()); + assert!(tile.scratch_health().is_err(), "the tile reports its own failure"); + assert!(crate::Tile::from_vec(vec![1; 16]).scratch_health().is_ok(), "other tiles stay healthy"); + assert!(tile.try_bytes().is_err()); + assert!(check_since(failed_generation).is_ok(), "re-reading a tile that already failed is not a new transition"); tile.bytes_mut()[0] = 9; assert!(tile.cell.data.read().unwrap().is_none()); assert!(tile.cell.slot.lock().unwrap().is_some(), "a failed mutation must preserve the original extent"); @@ -560,6 +583,7 @@ mod tests { tile.cell.slot.lock().unwrap().as_mut().unwrap().1 = good; assert_eq!(&*tile.try_bytes().unwrap(), &[73; 1024]); assert!(check_integrity().is_ok()); + assert!(tile.scratch_health().is_ok()); assert!(check_since(generation).is_err(), "repairing or dropping a failed tile must not validate an earlier result"); assert!(check_since(failed_generation).is_err(), "recovery must reject a computation begun during the failure"); assert_eq!(tile.cell.evict(&file).unwrap(), 1024); diff --git a/docs/scratch-disks.md b/docs/scratch-disks.md index 22936897e3..6f888ddf4a 100644 --- a/docs/scratch-disks.md +++ b/docs/scratch-disks.md @@ -35,9 +35,15 @@ operations; standalone callers work without a background thread. `sysinfo` is na with its default features disabled and only the `system` feature enabled. An unavailable or full scratch disk retains resident pixels and reports the failure. -A failed read retains its backing extent and blocks editing, cache admission and saving -until the tile is readable again. Recovery can retry the original bytes after the storage -becomes available; zeros from a failed display read must never become saved pixels. +A failed read retains its backing extent and blocks editing, saving and exporting of the +document that holds the tile until it is readable again; other open documents keep working. +Each commit checks the health of its own document (before and after an edit, the document a +job captured, the imported result, the document being saved or exported) and refuses a result +if any tile failed or recovered while it ran; the retry succeeds. Derived-pixel caches never +admit results built from unreadable tiles and are purged once per new failure. Recovery can +retry the original bytes after the storage becomes available; zeros from a failed display +read must never become saved pixels. Limitation: a tile that was already unreadable and is +read from outside the edited document (for example a clipboard copy) is not detected. Scratch storage alone does not make every format, filter or renderer streamable. The native PSD/EXR import and viewport streaming changes build on these guards and memory reservations. From 9eddf1d8cace75e5359c186f4632c99e3746cf6f Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Mon, 2 Nov 2026 22:31:56 +0100 Subject: [PATCH 186/216] Pause and retry a failed or full scratch disk instead of turning spilling off A scratch write or create failure, or a volume about to drop below max(1 GiB, 5%) free space, now pauses spilling with exponential backoff (1 s doubling to 60 s) instead of disabling it for the session. Tiles stay resident, set_budget keeps the latest budget for the resume, preference changes retry at once, and spill::epoch lets the engine re-apply History's byte limit while paused. spill::set_size_cap adds an explicit size limit; free space comes from sysinfo's disk feature. --- crates/engine/src/jobs.rs | 1 + crates/engine/src/lib.rs | 3 + crates/engine/src/prefs.rs | 20 ++ crates/engine/src/scratch.rs | 3 +- crates/engine/tests/scratch_pause.rs | 54 ++++ crates/raster/Cargo.toml | 4 +- crates/raster/src/spill.rs | 312 ++++++++++++++++++++---- crates/raster/tests/scratch_recovery.rs | 117 +++++++++ docs/scratch-disks.md | 12 +- 9 files changed, 468 insertions(+), 58 deletions(-) create mode 100644 crates/engine/tests/scratch_pause.rs create mode 100644 crates/raster/tests/scratch_recovery.rs diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index dfc7fd39f8..7c64b74fa5 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -825,6 +825,7 @@ impl Session { // The offer belongs to this one dispatch. Early errors drop it locally, including // authorization, validation, floating-selection and admission failures. let live_commit = self.live_commit.take(); + self.sync_scratch_state(); let spec = crate::commands::find(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?; // Params are named fields: anything but an object (or null, meaning none) is a caller // mistake, not "use every default". diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a228864591..1b370ace46 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -285,6 +285,9 @@ pub struct Session { next_held_open: u64, /// Shared native memory supervision, owned and stopped by the last session using it. scratch_runtime: Option, + /// [`photocraft_raster::spill::epoch`] when History's byte limits were last applied: a + /// scratch disk that pauses or resumes changes the limit ([`Session::sync_scratch_state`]). + scratch_epoch: u64, /// The document before the latest [`Session::edit`] of the running command, which /// [`Session::hint_damage`] checks a hint against. Set by `edit`, taken by `hint_damage`, /// dropped when the command ends. Debug and test builds only. diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 9317c31c30..891f9585c9 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -1351,6 +1351,7 @@ impl Session { pub fn apply_prefs(&mut self) { self.scratch_runtime.get_or_insert_with(crate::scratch::Runtime::default); crate::scratch::configure(self.prefs()); + self.scratch_epoch = photocraft_raster::spill::epoch(); let n = self.prefs().performance.history_states.max(1) as usize; let bytes = self.prefs().performance.history_cap_bytes(); let budget = self.prefs().performance.effect_cache_mb as usize; @@ -1368,6 +1369,25 @@ impl Session { crate::plugin_cmds::sync_prefs(self); } + /// Re-apply History's byte limit after the scratch disk paused (a write, create or + /// free-space failure) or resumed since it was last applied: while spilling is paused, + /// History again bounds the pixel memory it holds, as with no scratch disk. Runs before + /// every command; a single atomic load when nothing changed. + pub(crate) fn sync_scratch_state(&mut self) { + let epoch = photocraft_raster::spill::epoch(); + if self.scratch_epoch == epoch { + return; + } + self.scratch_epoch = epoch; + let bytes = self.prefs().performance.history_cap_bytes(); + for st in &mut self.docs { + if st.history.max_bytes != bytes { + st.history.max_bytes = bytes; + st.trim_history(); + } + } + } + /// Everything persisted as one JSON document: the preferences plus `colorSettings`. pub fn prefs_to_json(&self) -> String { serde_json::to_string_pretty(&self.prefs_value()).unwrap_or_default() diff --git a/crates/engine/src/scratch.rs b/crates/engine/src/scratch.rs index 79d67ab0db..486efbe9b3 100644 --- a/crates/engine/src/scratch.rs +++ b/crates/engine/src/scratch.rs @@ -128,7 +128,8 @@ pub(crate) fn configure(prefs: &Preferences) { .iter() .find(|d| d.enabled && !d.path.trim().is_empty()) .map(|d| if d.path.trim() == "(system temp)" { std::env::temp_dir() } else { std::path::PathBuf::from(d.path.trim()) }); - // An unusable directory disables eviction without dropping resident pixels. + // An unusable or full directory pauses eviction without dropping resident pixels and + // is retried with backoff; History's byte limit applies meanwhile (sync_scratch_state). // The reason is exposed in ui.inspect and the application's status bar. let tiles = if std::env::var_os("PHOTOCRAFT_TILE_BUDGET_MB").is_some() { prefs.performance.tile_budget_bytes() diff --git a/crates/engine/tests/scratch_pause.rs b/crates/engine/tests/scratch_pause.rs new file mode 100644 index 0000000000..d8990d504c --- /dev/null +++ b/crates/engine/tests/scratch_pause.rs @@ -0,0 +1,54 @@ +//! A scratch disk that fills up pauses spilling; History's byte limit applies again until it +//! resumes, so undo states cannot pile up in memory unbounded meanwhile. +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_doc::{Document, Layer}; +use photocraft_engine::Session; +use photocraft_geom::{Size, TileCoord}; +use photocraft_raster::spill; +use serde_json::json; + +#[test] +fn paused_scratch_disk_restores_the_history_limit() { + let dir = std::env::temp_dir().join(format!("photocraft-engine-scratch-pause-{}", std::process::id())); + let mut session = Session::new(); + session.edit_prefs(|p| { + p.scratch_disks.disks = vec![photocraft_engine::prefs::ScratchDisk { path: dir.to_string_lossy().into(), enabled: true }]; + p.performance.memory_usage_mb = 64; + }); + assert!(spill::enabled()); + let mut doc = Document::new("Pause fixture", Size::new(1024, 256), ColorMode::Rgb, SampleType::U8); + let mut layer = Layer::raster("Pixels", PixelFormat::RGBA8); + let id = layer.id; + for i in 0..4 { + for (j, b) in layer.surface_mut().unwrap().tile_mut(TileCoord::new(i, 0)).bytes_mut().iter_mut().enumerate() { + *b = (j as u32).wrapping_mul(31).wrapping_add(i as u32 * 7) as u8; + } + } + doc.layers.push(layer); + session.add_document(doc, None); + session.select_layer(id).unwrap(); + assert_eq!(session.active().unwrap().history.max_bytes, 0, "spilling: only History States bound History"); + + // The disk "fills up": a size limit below what eviction needs pauses spilling. + spill::set_size_cap(Some(1)); + spill::configure(Some(&dir), 1).unwrap_or_default(); + spill::settle(); + assert!(spill::paused() && !spill::enabled(), "{:?}", spill::stats()); + let limit = session.prefs().performance.history_budget_bytes(); + assert!(limit > 0); + session.execute("edit.fill", json!({"color": "#204060"})).unwrap(); + assert_eq!(session.active().unwrap().history.max_bytes, limit, "a paused disk must restore History's byte limit"); + assert!(session.active().unwrap().memory_bytes() <= limit); + + // Room again: spilling resumes and the next command lifts the limit again. + spill::set_size_cap(None); + assert!(spill::enabled() && !spill::paused(), "{:?}", spill::stats()); + session.execute("edit.fill", json!({"color": "#406080"})).unwrap(); + assert_eq!(session.active().unwrap().history.max_bytes, 0); + assert!(session.undo()); + assert!(session.undo()); + + drop(session); + spill::configure(None, 0).unwrap(); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/raster/Cargo.toml b/crates/raster/Cargo.toml index e84d963978..e7f5af6420 100644 --- a/crates/raster/Cargo.toml +++ b/crates/raster/Cargo.toml @@ -15,10 +15,10 @@ crc32fast = "1" lz4_flex = { version = "0.11", default-features = false, features = ["std", "safe-encode", "safe-decode"] } # Native only: converting a region's tiles is independent per tile, so it runs on all cores; -# sysinfo reads the physical RAM size for the memory limit. +# sysinfo reads the physical RAM size for the memory limit and the scratch volume's free space. [target.'cfg(not(target_arch = "wasm32"))'.dependencies] rayon = { workspace = true } -sysinfo = { version = "0.37", default-features = false, features = ["system"] } +sysinfo = { version = "0.37", default-features = false, features = ["system", "disk"] } [dev-dependencies] proptest = { workspace = true } diff --git a/crates/raster/src/spill.rs b/crates/raster/src/spill.rs index efdccb5c1c..ae0b56f3a0 100644 --- a/crates/raster/src/spill.rs +++ b/crates/raster/src/spill.rs @@ -15,8 +15,10 @@ //! - **Off by default.** Until [`configure`] sets a directory and a budget nothing is evicted //! (tests, the web build). //! -//! Failures never panic: a scratch write that fails keeps the tile in memory and turns spilling -//! off (reported in [`SpillStats::last_error`]). A failed read retains its disk extent and +//! Failures never panic: a scratch write or file creation that fails, or a volume running low +//! on free space, keeps the tile in memory and pauses spilling (reported in +//! [`SpillStats::last_error`]); it is retried with exponential backoff and resumes on its own. +//! [`epoch`] tells owners of memory caps (history) to re-apply them meanwhile. A failed read retains its disk extent and //! poisons the tile: edits and saves check the health of the document they publish, plus //! [`check_since`] for failures that came and went during the operation. The //! infallible display API uses a temporary placeholder, never replacement pixel storage. @@ -24,6 +26,7 @@ use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex, PoisonError, RwLock, RwLockReadGuard, RwLockWriteGuard, Weak}; +use std::time::{Duration, Instant}; use crate::scratch_file::{Extent, ScratchFile}; @@ -220,8 +223,18 @@ struct Manager { read_errors: AtomicU64, resident: AtomicUsize, live: AtomicUsize, - /// Pixel bytes to keep resident; 0 = spilling off. + /// Pixel bytes to keep resident; 0 = spilling off or paused. budget: AtomicUsize, + /// Spilling is paused after a write, create or free-space failure ([`Config::paused`]). + paused: AtomicBool, + /// Changes whenever spilling turns on or off, so owners of memory caps re-apply them. + epoch: AtomicU64, + /// Scratch bytes allowed before the volume would run low or the size cap is reached. + space_limit: AtomicU64, + /// Re-read the volume's free space once the scratch file grows past this. + next_space_check: AtomicU64, + /// A sweep wrote successfully since the last resume: reset the retry backoff. + recovering: AtomicBool, registry: Mutex, config: Mutex, evictions: AtomicU64, @@ -239,16 +252,40 @@ struct Config { /// Opened at configuration time; startup also reaps abandoned Unix files. file: Option>, last_error: Option, + /// The budget [`configure`] / [`set_budget`] asked for; restored when a pause ends. + budget: usize, + paused: Option, + /// Delay before the next retry; doubles per failed retry up to [`RETRY_MAX`]. + backoff: Duration, + /// Explicit scratch size limit ([`set_size_cap`]); the free-space reserve always applies. + size_cap: Option, } +struct Pause { + message: String, + retry_at: Instant, +} + +const RETRY_FIRST: Duration = Duration::from_secs(1); +const RETRY_MAX: Duration = Duration::from_secs(60); +/// Re-check free space after this much scratch growth. +const SPACE_CHECK_EVERY: u64 = 64 << 20; +/// Leave at least max(1 GiB, 5 % of the volume) free for the system and other programs. +const RESERVE_MIN: u64 = 1 << 30; + static M: Manager = Manager { failed: AtomicUsize::new(0), read_errors: AtomicU64::new(0), resident: AtomicUsize::new(0), live: AtomicUsize::new(0), budget: AtomicUsize::new(0), + paused: AtomicBool::new(false), + epoch: AtomicU64::new(0), + space_limit: AtomicU64::new(u64::MAX), + next_space_check: AtomicU64::new(0), + recovering: AtomicBool::new(false), registry: Mutex::new(Registry { cells: Vec::new(), hand: 0 }), - config: Mutex::new(Config { dir: None, file: None, last_error: None }), + config: Mutex::new(Config { dir: None, file: None, last_error: None, budget: 0, paused: None, backoff: RETRY_FIRST, size_cap: None }), evictions: AtomicU64::new(0), reloads: AtomicU64::new(0), written: AtomicU64::new(0), @@ -274,28 +311,118 @@ impl Manager { self.config().last_error = Some(msg); } - /// The scratch file, created on first use; `None` (and spilling off) if it can't be. + /// Set the active budget, bumping [`epoch`] when spilling turns on or off. + fn set_active(&self, budget: usize) { + let old = self.budget.swap(budget, Ordering::AcqRel); + if (old > 0) != (budget > 0) { + self.epoch.fetch_add(1, Ordering::AcqRel); + } + } + + /// Pause spilling after a failure: every tile stays in memory (owners re-apply their + /// memory caps through [`epoch`]) and a retry is scheduled with exponential backoff. + fn pause(&self, c: &mut Config, reason: &str) { + let wait = c.backoff; + c.backoff = c.backoff.saturating_mul(2).min(RETRY_MAX); + let message = format!("{reason}; spilling is paused (tiles stay in memory), retrying in {} s", wait.as_secs()); + log_error(&message); + c.last_error = Some(message.clone()); + c.paused = Some(Pause { message, retry_at: Instant::now().checked_add(wait).unwrap_or_else(Instant::now) }); + self.recovering.store(false, Ordering::Relaxed); + self.paused.store(true, Ordering::Release); + self.set_active(0); + } + + /// End a pause: the directory is usable and has room again. + fn resume(&self, c: &mut Config) { + if let Some(p) = c.paused.take() + && c.last_error.as_deref() == Some(p.message.as_str()) + { + c.last_error = None; + } + self.paused.store(false, Ordering::Release); + self.recovering.store(true, Ordering::Relaxed); + self.set_active(c.budget); + } + + /// Open the scratch file and check its volume's room; pause (and say why) if either fails. + /// The caller holds the config lock. Returns whether spilling can go ahead. + fn prepare(&self, c: &mut Config) -> bool { + let Some(dir) = c.dir.clone() else { return false }; + if c.file.is_none() { + match std::fs::create_dir_all(&dir).and_then(|()| ScratchFile::create(&dir)) { + Ok(f) => c.file = Some(Arc::new(f)), + Err(e) => { + self.pause(c, &format!("could not create a scratch file in {}: {e}", dir.display())); + return false; + } + } + } + let used = c.file.as_ref().map_or(0, |f| f.used()); + refresh_space(c, &dir, used); + if used >= self.space_limit.load(Ordering::Relaxed) { + let msg = no_room(&dir, c.size_cap); + self.pause(c, &msg); + return false; + } + true + } + + /// Retry a paused scratch disk once its backoff has elapsed. Cheap when not paused. + fn retry(&self) { + if !self.paused.load(Ordering::Acquire) { + return; + } + let mut c = match self.config.try_lock() { + Ok(c) => c, + Err(std::sync::TryLockError::Poisoned(p)) => p.into_inner(), + // Someone else is configuring or retrying. + Err(std::sync::TryLockError::WouldBlock) => return, + }; + let due = c.paused.as_ref().is_some_and(|p| Instant::now() >= p.retry_at); + if !due { + return; + } + if c.dir.is_none() || c.budget == 0 { + c.paused = None; + self.paused.store(false, Ordering::Release); + return; + } + if self.prepare(&mut c) { + self.resume(&mut c); + } + } + + /// The scratch file, if spilling is on and the volume still has room. fn file(&self) -> Option> { let mut c = self.config(); - if let Some(f) = &c.file { - return Some(f.clone()); + if self.budget.load(Ordering::Relaxed) == 0 { + return None; } - let dir = c.dir.clone()?; - match ScratchFile::create(&dir) { - Ok(f) => { - let f = Arc::new(f); - c.file = Some(f.clone()); - Some(f) - } - Err(e) => { - let msg = format!("could not create a scratch file in {}: {e}; spilling is off", dir.display()); - log_error(&msg); - c.last_error = Some(msg); - c.dir = None; - self.budget.store(0, Ordering::Relaxed); - None - } + if c.file.is_none() && !self.prepare(&mut c) { + return None; } + c.file.clone() + } + + /// Whether the scratch file may grow further; re-reads free space every + /// [`SPACE_CHECK_EVERY`] bytes and pauses spilling when the volume runs low. + fn has_room(&self, file: &ScratchFile) -> bool { + let used = file.used(); + if used < self.next_space_check.load(Ordering::Relaxed) && used < self.space_limit.load(Ordering::Relaxed) { + return true; + } + let mut c = self.config(); + let Some(dir) = c.dir.clone() else { return false }; + refresh_space(&c, &dir, used); + if used < self.space_limit.load(Ordering::Relaxed) { + return true; + } + if c.paused.is_none() { + let msg = no_room(&dir, c.size_cap); + self.pause(&mut c, &msg); + } + false } /// Evict until resident memory is at most `target` or `max_scan` cells were looked at. @@ -338,25 +465,68 @@ impl Manager { } } for cell in victims { + if !self.has_room(&file) { + return freed; + } match cell.evict(&file) { Ok(n) => freed += n, Err(e) => { - // Disk full or gone: keep everything in memory from now on. - self.budget.store(0, Ordering::Relaxed); - self.error(format!("{e}; spilling is off")); + // Disk full or gone: keep tiles in memory until a retry succeeds. + let mut c = self.config(); + if c.paused.is_none() { + self.pause(&mut c, &e); + } return freed; } } } } + if freed > 0 && self.recovering.swap(false, Ordering::Relaxed) { + self.config().backoff = RETRY_FIRST; + } freed } } +/// Free space on the volume holding `dir`, minus the reserve, sets how far the file may grow. +fn refresh_space(c: &Config, dir: &Path, used: u64) { + let room = free_space(dir).map_or(u64::MAX, |(available, total)| used.saturating_add(available.saturating_sub(RESERVE_MIN.max(total / 20)))); + let limit = c.size_cap.map_or(room, |cap| cap.min(room)); + M.space_limit.store(limit, Ordering::Relaxed); + M.next_space_check.store(used.saturating_add(SPACE_CHECK_EVERY), Ordering::Relaxed); +} + +fn no_room(dir: &Path, cap: Option) -> String { + match cap { + Some(cap) => format!("scratch disk {} reached its size limit ({} MB) or is low on free space", dir.display(), cap >> 20), + None => format!("scratch disk {} is low on free space", dir.display()), + } +} + +/// (available, total) bytes of the volume holding `dir`: the mount point that is its longest +/// prefix. `None` if unknown, which disables only the free-space check, not spilling. +#[cfg(not(target_arch = "wasm32"))] +fn free_space(dir: &Path) -> Option<(u64, u64)> { + let dir = std::fs::canonicalize(dir).ok()?; + let disks = sysinfo::Disks::new_with_refreshed_list_specifics(sysinfo::DiskRefreshKind::nothing().with_storage()); + disks + .list() + .iter() + .filter(|d| dir.starts_with(d.mount_point())) + .max_by_key(|d| d.mount_point().as_os_str().len()) + .map(|d| (d.available_space(), d.total_space())) +} + +#[cfg(target_arch = "wasm32")] +fn free_space(_dir: &Path) -> Option<(u64, u64)> { + None +} + /// Called after resident memory grew. fn maybe_evict() { let budget = M.budget.load(Ordering::Relaxed); if budget == 0 { + M.retry(); return; } let resident = M.resident.load(Ordering::Relaxed); @@ -392,8 +562,10 @@ impl Drop for Maintenance { } } -/// One bounded clock pass. The caller owns scheduling and shutdown. +/// One bounded clock pass, plus a due retry of a paused scratch disk. The caller owns +/// scheduling and shutdown. pub fn maintain_budget() -> usize { + M.retry(); let budget = M.budget.load(Ordering::Relaxed); if budget == 0 || M.resident.load(Ordering::Relaxed) <= budget { return 0; @@ -401,49 +573,80 @@ pub fn maintain_budget() -> usize { M.sweep(low_water(budget), 4096) } -/// Turn the scratch disk on (`dir` and a non-zero `budget_bytes`) or off. Tiles already on disk -/// stay readable either way. Changing the directory starts a new scratch file there; the old one -/// is deleted once its last tile is freed or changed. +/// Change the memory budget of a configured scratch disk (the memory limit moved). A paused +/// disk keeps its pause and resumes with this budget. pub fn set_budget(budget_bytes: usize) { - if enabled() { - M.budget.store(budget_bytes.max(1), Ordering::Relaxed); - maybe_evict(); + let mut c = M.config(); + if c.dir.is_none() || c.budget == 0 { + return; } + c.budget = budget_bytes.max(1); + if c.paused.is_none() { + M.set_active(c.budget); + } + drop(c); + maybe_evict(); } +/// Limit how large the scratch file may grow (`None`: only the free-space reserve applies). +/// Reaching the limit pauses spilling like a full disk; changing it retries a paused disk now. +pub fn set_size_cap(cap: Option) { + let mut c = M.config(); + c.size_cap = cap; + M.next_space_check.store(0, Ordering::Relaxed); + if let Some(p) = &mut c.paused { + p.retry_at = Instant::now(); + c.backoff = RETRY_FIRST; + } + drop(c); + M.retry(); +} + +/// Turn the scratch disk on (`dir` and a non-zero `budget_bytes`) or off. Tiles already on disk +/// stay readable either way. Changing the directory starts a new scratch file there; the old one +/// is deleted once its last tile is freed or changed. A directory that can't be used is kept and +/// retried with backoff (spilling stays paused meanwhile); the error is returned and reported. pub fn configure(dir: Option<&Path>, budget_bytes: usize) -> Result<(), String> { let mut c = M.config(); let dir = dir.filter(|_| budget_bytes > 0).map(Path::to_path_buf); - if let Some(d) = &dir - && let Err(e) = std::fs::create_dir_all(d) - { - let msg = format!("scratch disk {}: {e}; spilling is off", d.display()); - c.dir = None; + if dir != c.dir { c.file = None; - c.last_error = Some(msg.clone()); - M.budget.store(0, Ordering::Relaxed); - return Err(msg); - } - if dir != c.dir || (dir.is_some() && c.file.is_none()) { - c.file = match dir.as_deref().map(ScratchFile::create).transpose() { - Ok(file) => file.map(Arc::new), - Err(e) => { - let msg = format!("scratch disk: {e}; spilling is off"); - c.dir = None; - c.last_error = Some(msg.clone()); - M.budget.store(0, Ordering::Relaxed); - return Err(msg); - } - }; } c.dir = dir.clone(); + c.budget = if dir.is_some() { budget_bytes } else { 0 }; + c.backoff = RETRY_FIRST; + c.paused = None; c.last_error = None; - M.budget.store(if dir.is_some() { budget_bytes } else { 0 }, Ordering::Relaxed); + M.paused.store(false, Ordering::Release); + M.next_space_check.store(0, Ordering::Relaxed); + if dir.is_some() && !M.prepare(&mut c) { + return Err(c.last_error.clone().unwrap_or_default()); + } + M.set_active(c.budget); drop(c); maybe_evict(); Ok(()) } +/// Changes whenever spilling turns on or off, including a pause after a disk error and the +/// resume after it. Owners of memory caps that depend on [`enabled`] re-apply them on a change. +pub fn epoch() -> u64 { + M.epoch.load(Ordering::Acquire) +} + +/// Whether spilling is paused after a scratch disk failure (it retries on its own). +pub fn paused() -> bool { + M.paused.load(Ordering::Acquire) +} + +/// Retry a paused scratch disk on the next maintenance pass instead of after its backoff. +#[doc(hidden)] +pub fn retry_now_for_tests() { + if let Some(p) = &mut M.config().paused { + p.retry_at = Instant::now(); + } +} + /// Where the tile memory stands. #[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize)] #[serde(rename_all = "camelCase")] @@ -461,6 +664,8 @@ pub struct SpillStats { /// Compressed bytes written to scratch so far. pub written_bytes: u64, pub last_error: Option, + /// Spilling is paused after a scratch disk failure and will be retried. + pub paused: bool, } pub fn stats() -> SpillStats { @@ -475,6 +680,7 @@ pub fn stats() -> SpillStats { reloads: M.reloads.load(Ordering::Relaxed), written_bytes: M.written.load(Ordering::Relaxed), last_error: c.last_error.clone(), + paused: c.paused.is_some(), } } diff --git a/crates/raster/tests/scratch_recovery.rs b/crates/raster/tests/scratch_recovery.rs new file mode 100644 index 0000000000..fc4e91bfc7 --- /dev/null +++ b/crates/raster/tests/scratch_recovery.rs @@ -0,0 +1,117 @@ +//! Scratch disk failures pause spilling instead of turning it off for the session: tiles stay +//! in memory, the pause is retried with backoff and spilling resumes once the disk has room. +//! +//! One test function: the spill manager is process-wide, so its steps must not interleave. + +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_geom::{Rect, TILE_SIZE, TileCoord}; +use photocraft_raster::{Surface, spill}; + +const TILE_BYTES: usize = (TILE_SIZE * TILE_SIZE) as usize * 4; + +fn rgba8() -> PixelFormat { + PixelFormat { mode: ColorMode::Rgb, sample: SampleType::U8, alpha: true } +} + +/// Incompressible bytes, different per seed. +fn noise(seed: u32, len: usize) -> Vec { + let mut x = seed.wrapping_mul(2_654_435_761).max(1); + (0..len) + .map(|_| { + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + x as u8 + }) + .collect() +} + +fn surface(seed: u32, n: i32) -> Surface { + let mut s = Surface::new(rgba8()); + for i in 0..n { + let r = Rect::from_xywh(i * TILE_SIZE, 0, TILE_SIZE as u32, TILE_SIZE as u32); + s.write_interleaved(r, &noise(seed + i as u32, TILE_BYTES)); + } + s +} + +fn check(s: &Surface, seed: u32, n: i32) { + for i in 0..n { + let t = s.tile(TileCoord { tx: i, ty: 0 }).unwrap(); + assert!(*t.bytes() == *noise(seed + i as u32, TILE_BYTES), "tile {i} of seed {seed} changed"); + } +} + +#[test] +fn full_or_missing_scratch_disk_pauses_and_recovers() { + let root = std::env::temp_dir().join(format!("photocraft-scratch-recovery-{}", std::process::id())); + let dir = root.join("scratch"); + let budget = 8 * TILE_BYTES; + spill::configure(Some(&dir), budget).unwrap(); + assert!(spill::enabled() && !spill::paused()); + + // 1. "Disk full": the scratch file reaches its size limit. Spilling pauses, every tile stays + // in memory and readable, and owners of memory caps see the change. + let cap = 16 * TILE_BYTES as u64; + spill::set_size_cap(Some(cap)); + let epoch = spill::epoch(); + let a = surface(1, 64); + spill::settle(); + let st = spill::stats(); + assert!(st.paused && !spill::enabled(), "{st:?}"); + assert_ne!(spill::epoch(), epoch, "pausing must tell owners to re-apply their caps"); + assert!(st.resident_bytes > budget, "tiles must stay in memory while paused: {st:?}"); + assert!(st.scratch_bytes < cap + 2 * TILE_BYTES as u64, "the file grew past its limit: {st:?}"); + assert!(st.last_error.as_deref().is_some_and(|e| e.contains("paused") && e.contains("retrying in 1 s")), "{st:?}"); + check(&a, 1, 64); + + // 2. A retry while the disk is still full pauses again, with a longer backoff; a budget + // change (the memory limit moved) does not end the pause. + spill::retry_now_for_tests(); + spill::maintain_budget(); + let st = spill::stats(); + assert!(st.paused && !spill::enabled(), "{st:?}"); + assert!(st.last_error.as_deref().is_some_and(|e| e.contains("retrying in 2 s")), "{st:?}"); + spill::set_budget(budget); + assert!(!spill::enabled(), "set_budget must not bypass the pause"); + + // 3. Room again: the next retry resumes spilling with the latest budget and clears the error. + let epoch = spill::epoch(); + spill::set_size_cap(None); + spill::maintain_budget(); + let st = spill::stats(); + assert!(!st.paused && spill::enabled() && st.budget_bytes == budget && st.last_error.is_none(), "{st:?}"); + assert_ne!(spill::epoch(), epoch); + spill::settle(); + assert!(spill::stats().resident_bytes <= budget, "{:?}", spill::stats()); + check(&a, 1, 64); + drop(a); + + // 4. "Disk gone": the directory can't be created (a file is in the way). Configuring reports + // it and pauses; the directory is kept and retried, and spilling resumes once it works. + let blocked = root.join("blocked"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write(&blocked, b"not a directory").unwrap(); + assert!(spill::configure(Some(&blocked), budget).is_err()); + let st = spill::stats(); + assert!(st.paused && !spill::enabled() && st.dir.as_deref() == Some(blocked.as_path()), "{st:?}"); + let b = surface(500, 32); + spill::settle(); + check(&b, 500, 32); + std::fs::remove_file(&blocked).unwrap(); + spill::retry_now_for_tests(); + spill::maintain_budget(); + assert!(spill::enabled() && !spill::paused(), "{:?}", spill::stats()); + spill::settle(); + assert!(spill::stats().resident_bytes <= budget); + check(&b, 500, 32); + + // 5. Cleanup: once spilling is off and the tiles are freed, no scratch file is left behind. + drop(b); + spill::configure(None, 0).unwrap(); + for d in [&dir, &blocked] { + let left: Vec<_> = std::fs::read_dir(d).map(|r| r.flatten().map(|e| e.path()).collect()).unwrap_or_default(); + assert!(left.is_empty(), "scratch files left in {}: {left:?}", d.display()); + } + let _ = std::fs::remove_dir_all(&root); +} diff --git a/docs/scratch-disks.md b/docs/scratch-disks.md index 6f888ddf4a..0ffbcf2a11 100644 --- a/docs/scratch-disks.md +++ b/docs/scratch-disks.md @@ -32,9 +32,17 @@ until the file is dropped or the next sweep. The engine owns a shared, stoppable residency supervisor and joins it when its last session is dropped. `photocraft-raster` only exposes synchronous measurement and bounded eviction operations; standalone callers work without a background thread. `sysinfo` is native-only -with its default features disabled and only the `system` feature enabled. +with its default features disabled and only the `system` (RAM) and `disk` (free space) +features enabled. -An unavailable or full scratch disk retains resident pixels and reports the failure. +An unavailable or full scratch disk never loses pixels or turns spilling off for the session. +A failed scratch write or file creation, or a volume about to run low (free space is checked +when configuring, every 64 MB of scratch growth and on each retry; spilling stops while less +than max(1 GiB, 5% of the volume) would stay free), pauses spilling: tiles stay in memory, +the status bar shows the reason, and History's Memory Usage byte limit applies again from the +next command, as without a scratch disk. The pause is retried after 1 s, doubling up to 60 s; +spilling resumes on its own once the directory is usable and has room, and changing +preferences retries at once. `spill::set_size_cap` adds an explicit scratch size limit. A failed read retains its backing extent and blocks editing, saving and exporting of the document that holds the tile until it is readable again; other open documents keep working. Each commit checks the health of its own document (before and after an edit, the document a From 51e9bed8b7d4c41d813ed2628ced381f9646ef99 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Tue, 3 Nov 2026 00:13:34 +0100 Subject: [PATCH 187/216] Borrow guarded tile bytes in the profile dither from main Main's new profile dither (8a0f959d2) matched on the tile bytes by value inside the per-pixel loop. On this branch Tile::bytes returns a TileBytes guard rather than a slice, so borrow it for the loop instead, and compare the test's tiles as owned bytes since the guard has no Debug or PartialEq. --- crates/engine/src/cmd/color_cmds/profile_dither.rs | 2 +- crates/engine/tests/convert_profile_dither.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/color_cmds/profile_dither.rs b/crates/engine/src/cmd/color_cmds/profile_dither.rs index 8f99f49dbe..4bc5854ac0 100644 --- a/crates/engine/src/cmd/color_cmds/profile_dither.rs +++ b/crates/engine/src/cmd/color_cmds/profile_dither.rs @@ -122,7 +122,7 @@ fn dither_tile(s: &Surface, out: &Surface, t: &Transform, bounds: Rect, c: TileC for (index, dp) in dest.bytes_mut().chunks_exact_mut(ds).enumerate() { let x = x0 + (index % TILE_SIZE as usize) as i32; let y = y0 + (index / TILE_SIZE as usize) as i32; - let sp = match source { + let sp = match &source { Some(bytes) => bytes.get(index * ss..index * ss + ss).ok_or_else(|| bad("a stored tile has invalid sample storage"))?, None => default, }; diff --git a/crates/engine/tests/convert_profile_dither.rs b/crates/engine/tests/convert_profile_dither.rs index 6a78b83956..a8c92e2e5b 100644 --- a/crates/engine/tests/convert_profile_dither.rs +++ b/crates/engine/tests/convert_profile_dither.rs @@ -104,7 +104,7 @@ fn shared_tiles_and_distant_nondefault_pixels_use_actual_coordinates() { let a = converted(document(size, source, SampleType::U8), params(json!(true))); let b = converted(document(size, independent, SampleType::U8), params(json!(true))); assert_eq!(pixels(&a).read_region(a.bounds()), pixels(&b).read_region(b.bounds())); - assert_ne!(pixels(&a).tile(TileCoord::new(0, 0)).unwrap().bytes(), pixels(&a).tile(TileCoord::new(1, 0)).unwrap().bytes()); + assert_ne!(pixels(&a).tile(TileCoord::new(0, 0)).unwrap().bytes().to_vec(), pixels(&a).tile(TileCoord::new(1, 0)).unwrap().bytes().to_vec()); for (x, y) in [(-512, 0), (256_000_003, 256_000_007)] { let px = pixels(&a).pixel(x, y); assert!([55.0, 56.0].contains(&(px[0] * 255.0).round())); From c9478d0501ba6097074e121d8659a376cfee194c Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Tue, 3 Nov 2026 00:54:09 +0100 Subject: [PATCH 188/216] Expect the Windows scratch entry until its delete-on-close handle closes Windows keeps delete-on-close (unlinking an open file is not portable there), so a live scratch file still has a directory entry. Only Unix unlinks it right after opening. Check that the directory is empty once the file is closed on both. --- crates/raster/src/scratch_file.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/raster/src/scratch_file.rs b/crates/raster/src/scratch_file.rs index 2a4d4d1302..bcb46c3d0f 100644 --- a/crates/raster/src/scratch_file.rs +++ b/crates/raster/src/scratch_file.rs @@ -384,9 +384,12 @@ mod tests { // Windows: delete-on-close. Unix: unlinked right after opening. Neither needs Drop. assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0, "no scratch file outlives an exit without destructors"); let next = ScratchFile::create(&dir).unwrap(); - assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0, "a live scratch file has no directory entry either"); + // Unix unlinks the open file; Windows keeps the entry until the delete-on-close handle closes. + let live_entries = if cfg!(unix) { 0 } else { 1 }; + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), live_entries, "a live scratch file is unlinked on Unix only"); next.write(b"still usable").unwrap(); drop(next); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0, "closing the last scratch file leaves the directory empty"); std::fs::remove_dir(dir).unwrap(); } From ed681787c3a5909d8e55dd7b1ce34b8958d3544f Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Tue, 3 Nov 2026 01:16:52 +0100 Subject: [PATCH 189/216] Add a Scratch Size Limit preference and honour the web memory cap in the memory policy --- crates/engine/src/prefs.rs | 6 +++++- crates/engine/src/scratch.rs | 5 ++++- crates/engine/tests/scratch_pause.rs | 6 ++++++ crates/raster/src/spill.rs | 6 ++++++ crates/ui-egui/src/i18n/ar.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/da.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nb.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sr.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/prefs/prefs_ui/fields.rs | 4 ++++ docs/scorecard.md | 4 ++-- docs/scratch-disks.md | 7 +++++-- 32 files changed, 57 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 891f9585c9..64cfe59c1e 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -698,11 +698,15 @@ pub struct ScratchDisk { pub struct ScratchDisks { /// Disks for spilling tiles once the memory budget is reached (in priority order). pub disks: Vec, + /// Largest the scratch file may grow, in MB; 0 = no limit. Reaching it pauses spilling + /// like a full disk (History's byte limit applies again). At least max(1 GB, 5 % of the + /// volume) is always left free, whatever this is set to. + pub size_limit_mb: u32, } impl Default for ScratchDisks { fn default() -> Self { - Self { disks: vec![ScratchDisk { path: "(system temp)".into(), enabled: true }] } + Self { disks: vec![ScratchDisk { path: "(system temp)".into(), enabled: true }], size_limit_mb: 0 } } } diff --git a/crates/engine/src/scratch.rs b/crates/engine/src/scratch.rs index 486efbe9b3..6e21d9c1d7 100644 --- a/crates/engine/src/scratch.rs +++ b/crates/engine/src/scratch.rs @@ -118,7 +118,8 @@ mod native { pub(crate) fn configure(prefs: &Preferences) { let budget = photocraft_raster::memory::configure(photocraft_raster::memory::Policy { percent: prefs.performance.memory_usage_percent, - absolute_bytes: u64::from(prefs.performance.memory_usage_mb).saturating_mul(1 << 20), + // The web build's cap applies here too (effective_memory_usage_mb). + absolute_bytes: u64::from(prefs.performance.effective_memory_usage_mb()).saturating_mul(1 << 20), }); #[cfg(not(target_arch = "wasm32"))] { @@ -136,6 +137,8 @@ pub(crate) fn configure(prefs: &Preferences) { } else { usize::try_from(budget.tile_bytes).unwrap_or(usize::MAX) }; + let limit = prefs.scratch_disks.size_limit_mb; + photocraft_raster::spill::set_size_cap((limit > 0).then(|| u64::from(limit).saturating_mul(1 << 20))); let _ = photocraft_raster::spill::configure(dir.as_deref(), tiles); } #[cfg(target_arch = "wasm32")] diff --git a/crates/engine/tests/scratch_pause.rs b/crates/engine/tests/scratch_pause.rs index d8990d504c..3b694f2da2 100644 --- a/crates/engine/tests/scratch_pause.rs +++ b/crates/engine/tests/scratch_pause.rs @@ -16,6 +16,12 @@ fn paused_scratch_disk_restores_the_history_limit() { p.performance.memory_usage_mb = 64; }); assert!(spill::enabled()); + assert_eq!(spill::stats().size_cap_bytes, None, "0 MB = no scratch size limit"); + // Preferences › Scratch Disks › Scratch Size Limit becomes the scratch file's cap. + session.edit_prefs(|p| p.scratch_disks.size_limit_mb = 4096); + assert_eq!(spill::stats().size_cap_bytes, Some(4096 << 20)); + session.edit_prefs(|p| p.scratch_disks.size_limit_mb = 0); + assert_eq!(spill::stats().size_cap_bytes, None); let mut doc = Document::new("Pause fixture", Size::new(1024, 256), ColorMode::Rgb, SampleType::U8); let mut layer = Layer::raster("Pixels", PixelFormat::RGBA8); let id = layer.id; diff --git a/crates/raster/src/spill.rs b/crates/raster/src/spill.rs index ae0b56f3a0..26e06785b7 100644 --- a/crates/raster/src/spill.rs +++ b/crates/raster/src/spill.rs @@ -592,6 +592,9 @@ pub fn set_budget(budget_bytes: usize) { /// Reaching the limit pauses spilling like a full disk; changing it retries a paused disk now. pub fn set_size_cap(cap: Option) { let mut c = M.config(); + if c.size_cap == cap { + return; + } c.size_cap = cap; M.next_space_check.store(0, Ordering::Relaxed); if let Some(p) = &mut c.paused { @@ -666,6 +669,8 @@ pub struct SpillStats { pub last_error: Option, /// Spilling is paused after a scratch disk failure and will be retried. pub paused: bool, + /// Scratch file size limit ([`set_size_cap`]); `None` = only the free-space reserve. + pub size_cap_bytes: Option, } pub fn stats() -> SpillStats { @@ -681,6 +686,7 @@ pub fn stats() -> SpillStats { written_bytes: M.written.load(Ordering::Relaxed), last_error: c.last_error.clone(), paused: c.paused.is_some(), + size_cap_bytes: c.size_cap, } } diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 0b09aae6e1..0c67a91ec6 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -3424,6 +3424,7 @@ curves Smooth تنعيم This document's layers: {needed} of {budget} for layers طبقات هذا المستند: {needed} من {budget} للطبقات This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU تحتاج طبقات هذا المستند إلى {needed} لكنها تستوعب {budget}: تستخدم إعادة رسم المستند بالكامل وحدة المعالجة المركزية GPU Memory (MB, 0 = Automatic) ذاكرة GPU (ميغابايت، 0 = تلقائي) + Scratch Size Limit (MB, 0 = No Limit) حد حجم ملف التسويد (ميغابايت، 0 = بلا حد) {path}: the path is not valid Unicode; save under another name. {path}: المسار ليس Unicode صالحًا؛ احفظ باسم آخر. {name} was changed by another app after it was opened or saved here. تم تغيير {name} بواسطة تطبيق آخر بعد فتحه أو حفظه هنا. Replace it with this document, or cancel and use File › Save As to keep both. استبدله بهذا المستند، أو ألغِ واستخدم ملف › حفظ باسم للاحتفاظ بكليهما. diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 460a30973c..530359b4eb 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -4023,6 +4023,7 @@ navigator Zoom Zvětšení This document's layers: {needed} of {budget} for layers Vrstvy tohoto dokumentu: {needed} z {budget} pro vrstvy This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Vrstvy tohoto dokumentu potřebují {needed}, ale mohou zabrat {budget}: překreslení celého dokumentu probíhá na CPU GPU Memory (MB, 0 = Automatic) Paměť GPU (MB, 0 = Automaticky) + Scratch Size Limit (MB, 0 = No Limit) Limit velikosti odkládacího disku (MB, 0 = bez limitu) {path}: the path is not valid Unicode; save under another name. {path}: cesta není platný Unicode; uložte soubor pod jiným názvem. {name} was changed by another app after it was opened or saved here. Soubor {name} změnila jiná aplikace poté, co byl zde otevřen nebo uložen. Replace it with this document, or cancel and use File › Save As to keep both. Nahraďte ho tímto dokumentem, nebo zrušte a použijte Soubor › Uložit jako, abyste zachovali obojí. diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 00cfb09d84..46e71ed2cf 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -3361,6 +3361,7 @@ curves Smooth Udjævn This document's layers: {needed} of {budget} for layers Dette dokuments lag: {needed} af {budget} til lag This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Dette dokuments lag kræver {needed}, men kan rumme {budget}: gentegninger af hele dokumentet bruger CPU'en GPU Memory (MB, 0 = Automatic) GPU-hukommelse (MB, 0 = automatisk) + Scratch Size Limit (MB, 0 = No Limit) Størrelsesgrænse for arbejdsdisk (MB, 0 = ingen grænse) {path}: the path is not valid Unicode; save under another name. {path}: stien er ikke gyldig Unicode; gem under et andet navn. Replace Erstat {name} was changed by another app after it was opened or saved here. {name} blev ændret af et andet program, efter at den blev åbnet eller gemt her. diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 2565608d7a..a56ef68064 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3424,6 +3424,7 @@ curves Smooth Glätten This document's layers: {needed} of {budget} for layers Ebenen dieses Dokuments: {needed} von {budget} für Ebenen This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Die Ebenen dieses Dokuments brauchen {needed}, dürfen aber {budget} belegen: Das ganze Dokument wird auf der CPU neu gezeichnet GPU Memory (MB, 0 = Automatic) GPU-Speicher (MB, 0 = Automatisch) + Scratch Size Limit (MB, 0 = No Limit) Größenlimit für Arbeitsvolume (MB, 0 = kein Limit) {path}: the path is not valid Unicode; save under another name. {path}: Der Dateipfad enthält ungültige Zeichen. Speichere unter einem anderen Namen. {name} was changed by another app after it was opened or saved here. {name} wurde von einer anderen App geändert, nachdem es hier geöffnet oder gespeichert wurde. Replace it with this document, or cancel and use File › Save As to keep both. Ersetze die Datei durch dieses Dokument oder brich ab und nutze Datei › Speichern unter, um beide zu behalten. diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index bb89361d76..e1deee2890 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -4106,6 +4106,7 @@ curves Smooth Εξομάλυνση This document's layers: {needed} of {budget} for layers Επίπεδα αυτού του εγγράφου: {needed} από {budget} για επίπεδα This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Τα επίπεδα αυτού του εγγράφου χρειάζονται {needed} αλλά μπορούν να καταλάβουν {budget}: η επανασχεδίαση όλου του εγγράφου γίνεται στη CPU GPU Memory (MB, 0 = Automatic) Μνήμη GPU (MB, 0 = Αυτόματο) + Scratch Size Limit (MB, 0 = No Limit) Όριο μεγέθους δίσκου προσωρινής αποθήκευσης (MB, 0 = χωρίς όριο) {path}: the path is not valid Unicode; save under another name. {path}: η διαδρομή δεν είναι έγκυρο Unicode· αποθηκεύστε με άλλο όνομα. {name} was changed by another app after it was opened or saved here. Το {name} άλλαξε από άλλη εφαρμογή αφού ανοίχτηκε ή αποθηκεύτηκε εδώ. Replace it with this document, or cancel and use File › Save As to keep both. Αντικαταστήστε το με αυτό το έγγραφο ή ακυρώστε και χρησιμοποιήστε Αρχείο › Αποθήκευση ως για να κρατήσετε και τα δύο. diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 9daaf9934f..ef0171fe33 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4318,6 +4318,7 @@ navigator Zoom Zoom This document's layers: {needed} of {budget} for layers Capas de este documento: {needed} de {budget} para capas This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Las capas de este documento necesitan {needed} pero pueden ocupar {budget}: los redibujados de todo el documento usan la CPU GPU Memory (MB, 0 = Automatic) Memoria de GPU (MB, 0 = Automático) + Scratch Size Limit (MB, 0 = No Limit) Límite de tamaño del disco de memoria virtual (MB, 0 = sin límite) {path}: the path is not valid Unicode; save under another name. {path}: la ruta no es Unicode válido; guarde con otro nombre. {name} was changed by another app after it was opened or saved here. Otra aplicación cambió {name} después de abrirlo o guardarlo aquí. Replace it with this document, or cancel and use File › Save As to keep both. Reemplázalo con este documento, o cancela y usa Archivo › Guardar como para conservar ambos. diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 1cacf9ffea..b08a224b27 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -3362,6 +3362,7 @@ curves Smooth Tasoita This document's layers: {needed} of {budget} for layers Tämän asiakirjan tasot: {needed} / {budget} tasoille This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Tämän asiakirjan tasot tarvitsevat {needed}, mutta saavat käyttää {budget}: koko asiakirjan uudelleenpiirrot käyttävät suoritinta GPU Memory (MB, 0 = Automatic) GPU-muisti (Mt, 0 = automaattinen) + Scratch Size Limit (MB, 0 = No Limit) Työlevyn kokoraja (Mt, 0 = ei rajaa) {path}: the path is not valid Unicode; save under another name. {path}: polku ei ole kelvollista Unicodea; tallenna toisella nimellä. Replace Korvaa {name} was changed by another app after it was opened or saved here. Toinen ohjelma on muuttanut tiedostoa {name} sen jälkeen, kun se avattiin tai tallennettiin täällä. diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 68cc2853a1..2e07d2fe6a 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3414,6 +3414,7 @@ curves Smooth Lisser This document's layers: {needed} of {budget} for layers Calques de ce document : {needed} sur {budget} pour les calques This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Les calques de ce document ont besoin de {needed} mais peuvent occuper {budget} : le document entier se redessine sur le CPU GPU Memory (MB, 0 = Automatic) Mémoire GPU (Mo, 0 = Automatique) + Scratch Size Limit (MB, 0 = No Limit) Limite de taille du disque de travail (Mo, 0 = aucune limite) {path}: the path is not valid Unicode; save under another name. {path} : le chemin n’est pas un Unicode valide ; enregistrez sous un autre nom. Replace Remplacer {name} was changed by another app after it was opened or saved here. {name} a été modifié par une autre application après avoir été ouvert ou enregistré ici. diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 420dc7b850..e9471dc61a 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4320,6 +4320,7 @@ cameraRaw Yellow Sárga This document's layers: {needed} of {budget} for layers A dokumentum rétegei: {needed} / {budget} a rétegeknek This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU A dokumentum rétegeinek {needed} kell, de {budget} foglalhatnak: a teljes dokumentum újrarajzolása a CPU-n fut GPU Memory (MB, 0 = Automatic) GPU-memória (MB, 0 = Automatikus) + Scratch Size Limit (MB, 0 = No Limit) Lapozólemez méretkorlátja (MB, 0 = nincs korlát) {path}: the path is not valid Unicode; save under another name. {path}: az elérési út nem érvényes Unicode-szöveg. Mentsd más néven. {name} was changed by another app after it was opened or saved here. A(z) {name} fájlt egy másik alkalmazás módosította, miután itt megnyitották vagy mentették. Replace it with this document, or cancel and use File › Save As to keep both. Cserélje le erre a dokumentumra, vagy válassza a Mégse gombot, és a Fájl › Mentés másként paranccsal tartsa meg mindkettőt. diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index ea9f0d02be..e47e213b74 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3419,6 +3419,7 @@ curves Smooth Haluskan This document's layers: {needed} of {budget} for layers Layer dokumen ini: {needed} dari {budget} untuk layer This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Layer dokumen ini membutuhkan {needed} tetapi boleh memakai {budget}: penggambaran ulang seluruh dokumen memakai CPU GPU Memory (MB, 0 = Automatic) Memori GPU (MB, 0 = Otomatis) + Scratch Size Limit (MB, 0 = No Limit) Batas ukuran disk scratch (MB, 0 = Tanpa batas) {path}: the path is not valid Unicode; save under another name. {path}: jalur bukan Unicode yang valid; simpan dengan nama lain. Replace Ganti {name} was changed by another app after it was opened or saved here. {name} diubah oleh aplikasi lain setelah dibuka atau disimpan di sini. diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 98071d223f..ea03b3c894 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4529,6 +4529,7 @@ shape Appearance Aspetto This document's layers: {needed} of {budget} for layers Livelli di questo documento: {needed} su {budget} per i livelli This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU I livelli di questo documento richiedono {needed} ma possono occupare {budget}: i ridisegni dell'intero documento usano la CPU GPU Memory (MB, 0 = Automatic) Memoria GPU (MB, 0 = Automatico) + Scratch Size Limit (MB, 0 = No Limit) Limite dimensioni disco di memoria virtuale (MB, 0 = nessun limite) {path}: the path is not valid Unicode; save under another name. {path}: il percorso non è Unicode valido; salva con un altro nome. {name} was changed by another app after it was opened or saved here. {name} è stato modificato da un'altra app dopo essere stato aperto o salvato qui. Replace it with this document, or cancel and use File › Save As to keep both. Sostituiscilo con questo documento, oppure annulla e usa File › Salva con nome per tenerli entrambi. diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 48f98c0c40..e21697ca86 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4080,6 +4080,7 @@ curves Smooth スムーズ This document's layers: {needed} of {budget} for layers このドキュメントのレイヤー: レイヤー用 {budget} のうち {needed} This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU このドキュメントのレイヤーには {needed} が必要ですが、使えるのは {budget} です: ドキュメント全体の再描画は CPU で行われます GPU Memory (MB, 0 = Automatic) GPU メモリ(MB、0 = 自動) + Scratch Size Limit (MB, 0 = No Limit) 仮想記憶ディスクのサイズ上限(MB、0 = 上限なし) {path}: the path is not valid Unicode; save under another name. {path}: パスが有効な Unicode ではありません。別の名前で保存してください。 Replace 置き換え {name} was changed by another app after it was opened or saved here. {name} は、ここで開くか保存した後に別のアプリで変更されました。 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 782eef9caa..80c2b28d7c 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3615,6 +3615,7 @@ curves Smooth 매끄럽게 This document's layers: {needed} of {budget} for layers 이 문서의 레이어: 레이어용 {budget} 중 {needed} This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU 이 문서의 레이어에 {needed}가 필요하지만 {budget}만 사용할 수 있습니다: 문서 전체 다시 그리기는 CPU를 사용합니다 GPU Memory (MB, 0 = Automatic) GPU 메모리(MB, 0 = 자동) + Scratch Size Limit (MB, 0 = No Limit) 임시 디스크 크기 제한(MB, 0 = 제한 없음) {path}: the path is not valid Unicode; save under another name. {path}: 경로가 올바른 유니코드가 아닙니다. 다른 이름으로 저장하세요. Replace 대치 {name} was changed by another app after it was opened or saved here. {name}이(가) 여기서 열거나 저장한 뒤 다른 앱에서 변경되었습니다. diff --git a/crates/ui-egui/src/i18n/nb.tsv b/crates/ui-egui/src/i18n/nb.tsv index 85f9a0441a..d7232004b6 100644 --- a/crates/ui-egui/src/i18n/nb.tsv +++ b/crates/ui-egui/src/i18n/nb.tsv @@ -3361,6 +3361,7 @@ curves Smooth Jevn ut This document's layers: {needed} of {budget} for layers Dette dokumentets lag: {needed} av {budget} for lag This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Dette dokumentets lag trenger {needed}, men kan holde {budget}: gjentegning av hele dokumentet bruker CPU-en GPU Memory (MB, 0 = Automatic) GPU-minne (MB, 0 = automatisk) + Scratch Size Limit (MB, 0 = No Limit) Størrelsesgrense for arbeidsdisk (MB, 0 = ingen grense) {path}: the path is not valid Unicode; save under another name. {path}: banen er ikke gyldig Unicode. Lagre under et annet navn. Replace Erstatt {name} was changed by another app after it was opened or saved here. {name} ble endret av et annet program etter at den ble åpnet eller lagret her. diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index b63cdcdc40..21ef79fdf4 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -4164,6 +4164,7 @@ curves Smooth Vloeiend maken This document's layers: {needed} of {budget} for layers Lagen van dit document: {needed} van {budget} voor lagen This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU De lagen van dit document hebben {needed} nodig maar mogen {budget} gebruiken: het hele document wordt op de CPU opnieuw getekend GPU Memory (MB, 0 = Automatic) GPU-geheugen (MB, 0 = Automatisch) + Scratch Size Limit (MB, 0 = No Limit) Groottelimiet voor werkschijf (MB, 0 = geen limiet) {path}: the path is not valid Unicode; save under another name. {path}: het pad is geen geldige Unicode; sla op onder een andere naam. {name} was changed by another app after it was opened or saved here. {name} is door een andere app gewijzigd nadat het hier is geopend of bewaard. Replace it with this document, or cancel and use File › Save As to keep both. Vervang het door dit document, of annuleer en gebruik Bestand › Opslaan als om beide te houden. diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 98e7dae10f..a470bf1087 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -4021,6 +4021,7 @@ curves Smooth Wygładź This document's layers: {needed} of {budget} for layers Warstwy tego dokumentu: {needed} z {budget} na warstwy This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Warstwy tego dokumentu potrzebują {needed}, ale mogą zająć {budget}: odświeżanie całego dokumentu używa CPU GPU Memory (MB, 0 = Automatic) Pamięć GPU (MB, 0 = Automatycznie) + Scratch Size Limit (MB, 0 = No Limit) Limit rozmiaru dysku roboczego (MB, 0 = bez limitu) {path}: the path is not valid Unicode; save under another name. {path}: ścieżka nie jest prawidłowym Unicode; zapisz pod inną nazwą. Replace Zastąp {name} was changed by another app after it was opened or saved here. Plik {name} został zmieniony przez inną aplikację po otwarciu lub zapisaniu go tutaj. diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 2ab4bbd172..5f16eea094 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -4204,6 +4204,7 @@ layerLabel No Color Sem cor This document's layers: {needed} of {budget} for layers Camadas deste documento: {needed} de {budget} para camadas This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU As camadas deste documento precisam de {needed} mas podem ocupar {budget}: o documento inteiro é redesenhado na CPU GPU Memory (MB, 0 = Automatic) Memória da GPU (MB, 0 = Automático) + Scratch Size Limit (MB, 0 = No Limit) Limite de tamanho do disco de rascunho (MB, 0 = sem limite) {path}: the path is not valid Unicode; save under another name. {path}: o caminho não é Unicode válido; salve com outro nome. {name} was changed by another app after it was opened or saved here. {name} foi alterado por outro app depois de ser aberto ou salvo aqui. Replace it with this document, or cancel and use File › Save As to keep both. Substitua-o por este documento, ou cancele e use Arquivo › Salvar como para manter os dois. diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 2c5ca8e6eb..6ff24a98a5 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3388,6 +3388,7 @@ curves Smooth Сгладить This document's layers: {needed} of {budget} for layers Слои этого документа: {needed} из {budget} для слоёв This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Слоям этого документа нужно {needed}, но занимать они могут {budget}: весь документ перерисовывается на CPU GPU Memory (MB, 0 = Automatic) Память GPU (МБ, 0 = автоматически) + Scratch Size Limit (MB, 0 = No Limit) Предел размера рабочего диска (МБ, 0 = без ограничения) {path}: the path is not valid Unicode; save under another name. {path}: путь не является допустимым Unicode; сохраните под другим именем. Replace Заменить {name} was changed by another app after it was opened or saved here. Файл {name} был изменён другим приложением после того, как его открыли или сохранили здесь. diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index cebead75fb..7354c5cf40 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -4473,6 +4473,7 @@ shape Appearance Izgled This document's layers: {needed} of {budget} for layers Slojevi ovog dokumenta: {needed} od {budget} za slojeve This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Slojevima ovog dokumenta treba {needed}, a smeju da zauzmu {budget}: ceo dokument se ponovo iscrtava na CPU-u GPU Memory (MB, 0 = Automatic) GPU memorija (MB, 0 = Automatski) + Scratch Size Limit (MB, 0 = No Limit) Ograničenje veličine radnog diska (MB, 0 = bez ograničenja) {path}: the path is not valid Unicode; save under another name. {path}: putanja nije ispravan Unicode; sačuvajte pod drugim imenom. {name} was changed by another app after it was opened or saved here. Datoteku {name} je izmenila druga aplikacija nakon što je ovde otvorena ili sačuvana. Replace it with this document, or cancel and use File › Save As to keep both. Zameni je ovim dokumentom ili otkaži i koristi Datoteka › Sačuvaj kao da zadržiš obe. diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 64191012c4..b9c3721765 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -3359,6 +3359,7 @@ curves Smooth Jämna ut This document's layers: {needed} of {budget} for layers Lagren i det här dokumentet: {needed} av {budget} för lager This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Lagren i det här dokumentet behöver {needed} men får använda {budget}: omritningar av hela dokumentet använder CPU:n GPU Memory (MB, 0 = Automatic) GPU-minne (MB, 0 = automatisk) + Scratch Size Limit (MB, 0 = No Limit) Storleksgräns för arbetsdisk (MB, 0 = ingen gräns) {path}: the path is not valid Unicode; save under another name. {path}: sökvägen är inte giltig Unicode; spara under ett annat namn. Replace Ersätt {name} was changed by another app after it was opened or saved here. {name} har ändrats av en annan app efter att den öppnades eller sparades här. diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 1a3eab8bf2..e03cd01c77 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -4127,6 +4127,7 @@ cameraData Lens Lens This document's layers: {needed} of {budget} for layers Bu belgenin katmanları: katmanlar için {budget} içinden {needed} This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Bu belgenin katmanlarının {needed} belleğe ihtiyacı var ama en fazla {budget} kullanabilirler: tüm belgenin yeniden çizimi CPU'yu kullanır GPU Memory (MB, 0 = Automatic) GPU Belleği (MB, 0 = Otomatik) + Scratch Size Limit (MB, 0 = No Limit) Geçici disk boyut sınırı (MB, 0 = Sınırsız) {path}: the path is not valid Unicode; save under another name. {path}: yol geçerli bir Unicode değil; başka bir adla kaydedin. {name} was changed by another app after it was opened or saved here. {name}, burada açıldıktan veya kaydedildikten sonra başka bir uygulama tarafından değiştirildi. Replace it with this document, or cancel and use File › Save As to keep both. Bu belgeyle değiştirin ya da iptal edip ikisini de korumak için Dosya › Farklı Kaydet'i kullanın. diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index eb8ad312ae..b0a6c75041 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4107,6 +4107,7 @@ curves Smooth Згладити This document's layers: {needed} of {budget} for layers Шари цього документа: {needed} з {budget} для шарів This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Шарам цього документа потрібно {needed}, але займати вони можуть {budget}: увесь документ перемальовується на CPU GPU Memory (MB, 0 = Automatic) Пам'ять GPU (МБ, 0 = автоматично) + Scratch Size Limit (MB, 0 = No Limit) Обмеження розміру робочого диска (МБ, 0 = без обмеження) {path}: the path is not valid Unicode; save under another name. {path}: шлях не є коректним Unicode; збережіть під іншою назвою. Replace Замінити {name} was changed by another app after it was opened or saved here. Файл {name} змінила інша програма після того, як його тут відкрили або зберегли. diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 8ddd03bf99..7d5f39e280 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3898,6 +3898,7 @@ curves Smooth Làm mượt This document's layers: {needed} of {budget} for layers Các lớp của tài liệu này: {needed} trên {budget} dành cho lớp This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Các lớp của tài liệu này cần {needed} nhưng chỉ được dùng {budget}: vẽ lại toàn bộ tài liệu dùng CPU GPU Memory (MB, 0 = Automatic) Bộ nhớ GPU (MB, 0 = Tự động) + Scratch Size Limit (MB, 0 = No Limit) Giới hạn dung lượng đĩa tạm (MB, 0 = Không giới hạn) {path}: the path is not valid Unicode; save under another name. {path}: đường dẫn không phải Unicode hợp lệ; hãy lưu với tên khác. Replace Thay thế {name} was changed by another app after it was opened or saved here. {name} đã bị một ứng dụng khác thay đổi sau khi được mở hoặc lưu ở đây. diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index c7b11fc593..953566b08a 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -3069,6 +3069,7 @@ curves Smooth 平滑 This document's layers: {needed} of {budget} for layers 此文档的图层:图层可用 {budget} 中的 {needed} This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU 此文档的图层需要 {needed},但只能占用 {budget}:整个文档的重绘使用 CPU GPU Memory (MB, 0 = Automatic) GPU 内存(MB,0 = 自动) + Scratch Size Limit (MB, 0 = No Limit) 暂存盘大小上限(MB,0 = 无限制) {path}: the path is not valid Unicode; save under another name. {path}:路径不是有效的 Unicode;请以其他名称存储。 Replace 替换 {name} was changed by another app after it was opened or saved here. {name} 在此处打开或存储后已被其他应用更改。 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index a8de12a6d1..06440368af 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3030,6 +3030,7 @@ curves Smooth 平滑 This document's layers: {needed} of {budget} for layers 此文件的圖層:圖層可用 {budget} 中的 {needed} This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU 此文件的圖層需要 {needed},但只能佔用 {budget}:整份文件的重繪使用 CPU GPU Memory (MB, 0 = Automatic) GPU 記憶體(MB,0 = 自動) + Scratch Size Limit (MB, 0 = No Limit) 暫存磁碟大小上限(MB,0 = 無限制) {path}: the path is not valid Unicode; save under another name. {path}:路徑不是有效的 Unicode;請以其他名稱儲存。 Replace 取代 {name} was changed by another app after it was opened or saved here. {name} 在此處開啟或儲存後已被其他 App 更改。 diff --git a/crates/ui-egui/src/prefs/prefs_ui/fields.rs b/crates/ui-egui/src/prefs/prefs_ui/fields.rs index b79867bf6b..0c15f630a6 100644 --- a/crates/ui-egui/src/prefs/prefs_ui/fields.rs +++ b/crates/ui-egui/src/prefs/prefs_ui/fields.rs @@ -71,6 +71,10 @@ pub(super) fn humanize(key: &str) -> String { return "Low-Latency Drawing (vsync off, applies at next launch)".into(); } // Performance: the canvas compositor's GPU memory, 0 for the automatic budget (L3). + // Scratch Disks: how large the scratch file may grow; the free-space reserve always applies. + if key == "sizeLimitMb" { + return "Scratch Size Limit (MB, 0 = No Limit)".into(); + } if key == "gpuMemoryMb" { return "GPU Memory (MB, 0 = Automatic)".into(); } diff --git a/docs/scorecard.md b/docs/scorecard.md index 34778e7bb3..4b3f1299c6 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -9,7 +9,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [Performance](#performance) | 3 | 22 | 45 | 79 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 41 not run, 4 not measurable yet (baseline 2026-10-05) | | [File compatibility](#file-compatibility) | 11 | 14 | 2 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 190/258, round trip ≥ 258/258 | | [Tools](#tools) | 36 | 9 | 0 | | -| [Workspace and UI](#workspace-and-ui) | 15 | 6 | 2 | settings that do nothing: 10 of 184 (target 0) | +| [Workspace and UI](#workspace-and-ui) | 15 | 6 | 2 | settings that do nothing: 10 of 185 (target 0) | | [Type](#type) | 5 | 5 | 0 | | | [Automation](#automation) | 5 | 2 | 4 | 631 of 631 menu items live (100.0%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 30 of 30 crates; panic_hunt present | @@ -203,7 +203,7 @@ Corpus floors (`crates/io/tests/it/corpus.rs`): the enforced minimums; the corpu ## Workspace and UI -**Settings that do nothing: 10 of 184** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. +**Settings that do nothing: 10 of 185** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. Unread: `general.color_picker`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `performance.legacy_compositing`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `type_.text_engine`, `enhanced_controls.scrubby_slider_acceleration`. diff --git a/docs/scratch-disks.md b/docs/scratch-disks.md index 0ffbcf2a11..93f314a426 100644 --- a/docs/scratch-disks.md +++ b/docs/scratch-disks.md @@ -3,7 +3,8 @@ Native PhotoCraft keeps pixel tiles in RAM and spills cold tiles to a configured scratch directory. A read or write guard keeps a tile resident while a consumer uses its bytes. Undo snapshots keep their copy-on-write tiles, including disk-backed tiles, until History States -retires them. Web builds retain their explicit memory limit and do not use a filesystem. +retires them. Web builds retain their explicit memory limit (capped like Memory Usage) and do not use a +filesystem. New preferences use **70% of physical RAM** as the process ceiling. This is configurable from 1–95%; older preferences without `performance.memoryUsagePercent` retain their absolute @@ -42,7 +43,9 @@ than max(1 GiB, 5% of the volume) would stay free), pauses spilling: tiles stay the status bar shows the reason, and History's Memory Usage byte limit applies again from the next command, as without a scratch disk. The pause is retried after 1 s, doubling up to 60 s; spilling resumes on its own once the directory is usable and has room, and changing -preferences retries at once. `spill::set_size_cap` adds an explicit scratch size limit. +preferences retries at once. Preferences → Scratch Disks → Scratch Size Limit +(`scratchDisks.sizeLimitMb`, 0 = no limit; `spill::set_size_cap`) caps the scratch file: +reaching it pauses spilling the same way, and the free-space reserve applies regardless. A failed read retains its backing extent and blocks editing, saving and exporting of the document that holds the tile until it is readable again; other open documents keep working. Each commit checks the health of its own document (before and after an edit, the document a From 24d4f4db9fea590ed7dde65db6592938359c2c2e Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 2 Nov 2026 19:27:12 -0500 Subject: [PATCH 190/216] Release 0.20.0 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 62 +++++++++++++++++++++++++++--------------------------- Cargo.toml | 2 +- 2 files changed, 32 insertions(+), 32 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 58c70d0228..3751dbac4b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4973,7 +4973,7 @@ dependencies = [ [[package]] name = "photocraft" -version = "0.19.0" +version = "0.20.0" dependencies = [ "arboard", "async-io", @@ -5028,7 +5028,7 @@ dependencies = [ [[package]] name = "photocraft-affinity" -version = "0.19.0" +version = "0.20.0" dependencies = [ "crc32fast", "flate2", @@ -5038,7 +5038,7 @@ dependencies = [ [[package]] name = "photocraft-algo" -version = "0.19.0" +version = "0.20.0" dependencies = [ "criterion", "photocraft-color", @@ -5053,7 +5053,7 @@ dependencies = [ [[package]] name = "photocraft-automation" -version = "0.19.0" +version = "0.20.0" dependencies = [ "base64 0.23.1", "cap-std", @@ -5078,7 +5078,7 @@ dependencies = [ [[package]] name = "photocraft-cli" -version = "0.19.0" +version = "0.20.0" dependencies = [ "clap", "clap_complete", @@ -5097,7 +5097,7 @@ dependencies = [ [[package]] name = "photocraft-cms" -version = "0.19.0" +version = "0.20.0" dependencies = [ "moxcms", "rayon", @@ -5105,7 +5105,7 @@ dependencies = [ [[package]] name = "photocraft-codecs" -version = "0.19.0" +version = "0.20.0" dependencies = [ "craft-jpeg", "exr", @@ -5129,7 +5129,7 @@ dependencies = [ [[package]] name = "photocraft-color" -version = "0.19.0" +version = "0.20.0" dependencies = [ "photocraft-cms", "serde", @@ -5137,7 +5137,7 @@ dependencies = [ [[package]] name = "photocraft-compose" -version = "0.19.0" +version = "0.20.0" dependencies = [ "criterion", "photocraft-cms", @@ -5153,7 +5153,7 @@ dependencies = [ [[package]] name = "photocraft-doc" -version = "0.19.0" +version = "0.20.0" dependencies = [ "photocraft-color", "photocraft-geom", @@ -5164,7 +5164,7 @@ dependencies = [ [[package]] name = "photocraft-engine" -version = "0.19.0" +version = "0.20.0" dependencies = [ "base64 0.22.1", "blake3", @@ -5206,7 +5206,7 @@ dependencies = [ [[package]] name = "photocraft-format" -version = "0.19.0" +version = "0.20.0" dependencies = [ "blake3", "craft-pcraft-format", @@ -5230,14 +5230,14 @@ dependencies = [ [[package]] name = "photocraft-geom" -version = "0.19.0" +version = "0.20.0" dependencies = [ "serde", ] [[package]] name = "photocraft-gpu" -version = "0.19.0" +version = "0.20.0" dependencies = [ "bytemuck", "half", @@ -5257,14 +5257,14 @@ dependencies = [ [[package]] name = "photocraft-heif" -version = "0.19.0" +version = "0.20.0" dependencies = [ "heic-rs", ] [[package]] name = "photocraft-io" -version = "0.19.0" +version = "0.20.0" dependencies = [ "blake3", "craft-docpath", @@ -5300,18 +5300,18 @@ dependencies = [ [[package]] name = "photocraft-ipp" -version = "0.19.0" +version = "0.20.0" [[package]] name = "photocraft-ops" -version = "0.19.0" +version = "0.20.0" dependencies = [ "photocraft-doc", ] [[package]] name = "photocraft-paint" -version = "0.19.0" +version = "0.20.0" dependencies = [ "photocraft-cms", "photocraft-color", @@ -5325,7 +5325,7 @@ dependencies = [ [[package]] name = "photocraft-plugins" -version = "0.19.0" +version = "0.20.0" dependencies = [ "photocraft-color", "photocraft-geom", @@ -5341,7 +5341,7 @@ dependencies = [ [[package]] name = "photocraft-psd" -version = "0.19.0" +version = "0.20.0" dependencies = [ "encoding_rs", "flate2", @@ -5353,7 +5353,7 @@ dependencies = [ [[package]] name = "photocraft-raster" -version = "0.19.0" +version = "0.20.0" dependencies = [ "photocraft-color", "photocraft-geom", @@ -5364,7 +5364,7 @@ dependencies = [ [[package]] name = "photocraft-raw" -version = "0.19.0" +version = "0.20.0" dependencies = [ "flate2", "image", @@ -5379,7 +5379,7 @@ dependencies = [ [[package]] name = "photocraft-tablet" -version = "0.19.0" +version = "0.20.0" dependencies = [ "block2 0.6.2", "objc2 0.6.5", @@ -5391,7 +5391,7 @@ dependencies = [ [[package]] name = "photocraft-testkit" -version = "0.19.0" +version = "0.20.0" dependencies = [ "memory-stats", "serde_json", @@ -5399,7 +5399,7 @@ dependencies = [ [[package]] name = "photocraft-text" -version = "0.19.0" +version = "0.20.0" dependencies = [ "flate2", "parley", @@ -5414,7 +5414,7 @@ dependencies = [ [[package]] name = "photocraft-ui-egui" -version = "0.19.0" +version = "0.20.0" dependencies = [ "base64 0.23.1", "craft-print", @@ -5459,7 +5459,7 @@ dependencies = [ [[package]] name = "photocraft-vector" -version = "0.19.0" +version = "0.20.0" dependencies = [ "photocraft-color", "photocraft-doc", @@ -5470,7 +5470,7 @@ dependencies = [ [[package]] name = "photocraft-web" -version = "0.19.0" +version = "0.20.0" dependencies = [ "eframe", "egui", @@ -5492,7 +5492,7 @@ dependencies = [ [[package]] name = "photocraft-web-boot" -version = "0.19.0" +version = "0.20.0" dependencies = [ "flate2", "js-sys", @@ -9220,7 +9220,7 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" [[package]] name = "xtask" -version = "0.19.0" +version = "0.20.0" dependencies = [ "chrono", "serde", diff --git a/Cargo.toml b/Cargo.toml index 4674a55e6f..669e930777 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/photocraft", "apps/photocraft-cli", "xtask"] [workspace.package] -version = "0.19.0" +version = "0.20.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" From eed8bd27d297b87ab33bde76f7196bfa6ecfc76e Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 2 Nov 2026 19:47:01 -0500 Subject: [PATCH 191/216] Packaging: run the cross-built CLI's --version check without the malloc re-exec Co-Authored-By: Claude Opus 5.5 --- packaging/linux/package.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index 8b6cf54abe..519bc850c6 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -208,7 +208,9 @@ fi if [[ -z "${CROSS_TARGET:-}" ]]; then "$STAGE/usr/bin/photocraft-cli" --version elif [[ -n "${EMULATOR:-}" ]]; then - "$EMULATOR" "$STAGE/usr/bin/photocraft-cli" --version + # Set, so malloc_tuning::apply doesn't re-execute the CLI (#8743): under qemu-user that exec goes + # to the host kernel, which can't run a $CROSS_ARCH binary, and glibc then hands it to /bin/sh. + MALLOC_TRIM_THRESHOLD_=131072 "$EMULATOR" "$STAGE/usr/bin/photocraft-cli" --version fi echo "==> done" ls -lh "$DIST" From 327c4cc82e737a3e0af7c147a477126d77525495 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 2 Nov 2026 19:47:05 -0500 Subject: [PATCH 192/216] Release 0.20.1 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 62 +++++++++++++++++++++++++++--------------------------- Cargo.toml | 2 +- 2 files changed, 32 insertions(+), 32 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3751dbac4b..27fbbbc09c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4973,7 +4973,7 @@ dependencies = [ [[package]] name = "photocraft" -version = "0.20.0" +version = "0.20.1" dependencies = [ "arboard", "async-io", @@ -5028,7 +5028,7 @@ dependencies = [ [[package]] name = "photocraft-affinity" -version = "0.20.0" +version = "0.20.1" dependencies = [ "crc32fast", "flate2", @@ -5038,7 +5038,7 @@ dependencies = [ [[package]] name = "photocraft-algo" -version = "0.20.0" +version = "0.20.1" dependencies = [ "criterion", "photocraft-color", @@ -5053,7 +5053,7 @@ dependencies = [ [[package]] name = "photocraft-automation" -version = "0.20.0" +version = "0.20.1" dependencies = [ "base64 0.23.1", "cap-std", @@ -5078,7 +5078,7 @@ dependencies = [ [[package]] name = "photocraft-cli" -version = "0.20.0" +version = "0.20.1" dependencies = [ "clap", "clap_complete", @@ -5097,7 +5097,7 @@ dependencies = [ [[package]] name = "photocraft-cms" -version = "0.20.0" +version = "0.20.1" dependencies = [ "moxcms", "rayon", @@ -5105,7 +5105,7 @@ dependencies = [ [[package]] name = "photocraft-codecs" -version = "0.20.0" +version = "0.20.1" dependencies = [ "craft-jpeg", "exr", @@ -5129,7 +5129,7 @@ dependencies = [ [[package]] name = "photocraft-color" -version = "0.20.0" +version = "0.20.1" dependencies = [ "photocraft-cms", "serde", @@ -5137,7 +5137,7 @@ dependencies = [ [[package]] name = "photocraft-compose" -version = "0.20.0" +version = "0.20.1" dependencies = [ "criterion", "photocraft-cms", @@ -5153,7 +5153,7 @@ dependencies = [ [[package]] name = "photocraft-doc" -version = "0.20.0" +version = "0.20.1" dependencies = [ "photocraft-color", "photocraft-geom", @@ -5164,7 +5164,7 @@ dependencies = [ [[package]] name = "photocraft-engine" -version = "0.20.0" +version = "0.20.1" dependencies = [ "base64 0.22.1", "blake3", @@ -5206,7 +5206,7 @@ dependencies = [ [[package]] name = "photocraft-format" -version = "0.20.0" +version = "0.20.1" dependencies = [ "blake3", "craft-pcraft-format", @@ -5230,14 +5230,14 @@ dependencies = [ [[package]] name = "photocraft-geom" -version = "0.20.0" +version = "0.20.1" dependencies = [ "serde", ] [[package]] name = "photocraft-gpu" -version = "0.20.0" +version = "0.20.1" dependencies = [ "bytemuck", "half", @@ -5257,14 +5257,14 @@ dependencies = [ [[package]] name = "photocraft-heif" -version = "0.20.0" +version = "0.20.1" dependencies = [ "heic-rs", ] [[package]] name = "photocraft-io" -version = "0.20.0" +version = "0.20.1" dependencies = [ "blake3", "craft-docpath", @@ -5300,18 +5300,18 @@ dependencies = [ [[package]] name = "photocraft-ipp" -version = "0.20.0" +version = "0.20.1" [[package]] name = "photocraft-ops" -version = "0.20.0" +version = "0.20.1" dependencies = [ "photocraft-doc", ] [[package]] name = "photocraft-paint" -version = "0.20.0" +version = "0.20.1" dependencies = [ "photocraft-cms", "photocraft-color", @@ -5325,7 +5325,7 @@ dependencies = [ [[package]] name = "photocraft-plugins" -version = "0.20.0" +version = "0.20.1" dependencies = [ "photocraft-color", "photocraft-geom", @@ -5341,7 +5341,7 @@ dependencies = [ [[package]] name = "photocraft-psd" -version = "0.20.0" +version = "0.20.1" dependencies = [ "encoding_rs", "flate2", @@ -5353,7 +5353,7 @@ dependencies = [ [[package]] name = "photocraft-raster" -version = "0.20.0" +version = "0.20.1" dependencies = [ "photocraft-color", "photocraft-geom", @@ -5364,7 +5364,7 @@ dependencies = [ [[package]] name = "photocraft-raw" -version = "0.20.0" +version = "0.20.1" dependencies = [ "flate2", "image", @@ -5379,7 +5379,7 @@ dependencies = [ [[package]] name = "photocraft-tablet" -version = "0.20.0" +version = "0.20.1" dependencies = [ "block2 0.6.2", "objc2 0.6.5", @@ -5391,7 +5391,7 @@ dependencies = [ [[package]] name = "photocraft-testkit" -version = "0.20.0" +version = "0.20.1" dependencies = [ "memory-stats", "serde_json", @@ -5399,7 +5399,7 @@ dependencies = [ [[package]] name = "photocraft-text" -version = "0.20.0" +version = "0.20.1" dependencies = [ "flate2", "parley", @@ -5414,7 +5414,7 @@ dependencies = [ [[package]] name = "photocraft-ui-egui" -version = "0.20.0" +version = "0.20.1" dependencies = [ "base64 0.23.1", "craft-print", @@ -5459,7 +5459,7 @@ dependencies = [ [[package]] name = "photocraft-vector" -version = "0.20.0" +version = "0.20.1" dependencies = [ "photocraft-color", "photocraft-doc", @@ -5470,7 +5470,7 @@ dependencies = [ [[package]] name = "photocraft-web" -version = "0.20.0" +version = "0.20.1" dependencies = [ "eframe", "egui", @@ -5492,7 +5492,7 @@ dependencies = [ [[package]] name = "photocraft-web-boot" -version = "0.20.0" +version = "0.20.1" dependencies = [ "flate2", "js-sys", @@ -9220,7 +9220,7 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" [[package]] name = "xtask" -version = "0.20.0" +version = "0.20.1" dependencies = [ "chrono", "serde", diff --git a/Cargo.toml b/Cargo.toml index 669e930777..e01bcf175a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/photocraft", "apps/photocraft-cli", "xtask"] [workspace.package] -version = "0.20.0" +version = "0.20.1" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" From dcd21f81b43d8f2d195a7f68c5c921c0583417c3 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Tue, 3 Nov 2026 08:21:00 +0530 Subject: [PATCH 193/216] fix: keep multipage TIFF page memory refusals typed The multipage TIFF import loop mapped every page-import error to EngineError::Other, so a PsdApprovalRequired or PsdImportBudget from a layered page reached the UI, control channel and agents as text only, without the required/limit fields. Route those two through io_open_error and keep the page-specific text for every other error. --- crates/engine/src/cmd/video_cmds.rs | 12 ++++++++- crates/engine/src/cmd/video_cmds/tests.rs | 33 +++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/video_cmds.rs b/crates/engine/src/cmd/video_cmds.rs index 03d4f32d7c..06baf8529f 100644 --- a/crates/engine/src/cmd/video_cmds.rs +++ b/crates/engine/src/cmd/video_cmds.rs @@ -263,6 +263,16 @@ type ImportFrames = fn( /// Frame storage one footage import may keep (whole tiles, all frames together). const MAX_FOOTAGE_BYTES: u64 = 2 << 30; +/// The engine error for a failed page of a multi-page TIFF. A memory refusal keeps its typed form, +/// so the UI, control channel and agents still get the required and limit fields they need to +/// offer approval or explain the hard limit; any other failure keeps the page's text. +fn tiff_page_error(display: &std::path::Path, name: &str, page: usize, error: photocraft_io::IoError) -> EngineError { + match error { + photocraft_io::IoError::PsdApprovalRequired { .. } | photocraft_io::IoError::PsdImportBudget { .. } => crate::error::io_open_error(name, error), + error => EngineError::Other(format!("`{}` page {}: {error}", display.display(), page + 1)), + } +} + /// Load frames from `path`: a single image (1 frame, or every frame of an animated GIF or WebP), or a /// directory of images (sorted = an image sequence). Each becomes a full raster frame in the /// document's format. @@ -348,7 +358,7 @@ fn load_frames(path: &str, fmt: photocraft_color::PixelFormat, opts: &photocraft // Same admission policy, caller limit and import settings as any other TIFF // page import: a multi-page TIFF is not a way around the memory budget. let doc = photocraft_io::import_tiff_page_with_options(&name, &bytes, Some(page), opts, &photocraft_raster::Interrupt::NONE) - .map_err(|e| EngineError::Other(format!("`{}` page {}: {e}", f.display(), page + 1)))? + .map_err(|e| tiff_page_error(f, &name, page, e))? .document; frames.push(crate::file_cmds::flattened(&doc, fmt)); } diff --git a/crates/engine/src/cmd/video_cmds/tests.rs b/crates/engine/src/cmd/video_cmds/tests.rs index 0e4552c234..30039e4457 100644 --- a/crates/engine/src/cmd/video_cmds/tests.rs +++ b/crates/engine/src/cmd/video_cmds/tests.rs @@ -145,6 +145,39 @@ fn video_frames_to_layers_refuses_a_tiff_with_an_incomplete_page_list() { assert_eq!(s.active().unwrap().doc.layers.len(), before, "nothing imported"); } +/// #10897 review: a page refused by the PSD memory admission must reach the caller typed, so the +/// UI, control channel and agents can offer approval or explain the hard limit; a multi-page TIFF +/// is not a way to get a text-only refusal. Every other page failure keeps its file and page text. +#[test] +fn tiff_page_errors_keep_memory_refusals_typed_and_other_context() { + let display = std::path::Path::new("/tmp/footage/pages.tif"); + let name = "/tmp/footage/pages.tif"; + + let refusal = photocraft_io::IoError::PsdApprovalRequired { required: 9_000, soft_limit: 8_000, hard_limit: 16_000, available_ram: Some(4_000) }; + match tiff_page_error(display, name, 1, refusal) { + EngineError::PsdApprovalRequired { name: named, required, soft_limit, hard_limit, available_ram } => { + assert_eq!(named, name); + assert_eq!((required, soft_limit, hard_limit, available_ram), (9_000, 8_000, 16_000, Some(4_000))); + } + other => panic!("page 2 approval refusal lost its fields: {other:?}"), + } + + let hard = photocraft_io::IoError::PsdImportBudget { required: 9_000, limit: 8_000 }; + match tiff_page_error(display, name, 2, hard) { + EngineError::PsdHardBudget { name: named, required, limit } => { + assert_eq!(named, name); + assert_eq!((required, limit), (9_000, 8_000)); + } + other => panic!("page 3 hard refusal lost its fields: {other:?}"), + } + + let broken = photocraft_io::IoError::Unsupported("corrupt page".into()); + let err = tiff_page_error(display, name, 0, broken); + assert!(matches!(err, EngineError::Other(_)), "{err:?}"); + let msg = err.to_string(); + assert!(msg.contains("pages.tif") && msg.contains("page 1") && msg.contains("corrupt page"), "{msg}"); +} + #[test] fn new_video_layer_from_image_sequence() { let dir = tmpdir("seq"); From 27e0735af6a2af3dff47180b75d5dc3fb6f91d28 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Tue, 3 Nov 2026 08:36:10 +0530 Subject: [PATCH 194/216] Revert "fix: apply special Fill semantics to CMYK adjustment layers" This reverts commit b8621fd1ca6e978316d30cdc2418fd1c7e4038e0. The CMYK adjustment-layer Fill fix is independent of the PSD/TIFF memory admission work and ships in its own PR. Reverting it here keeps this branch's compose tree identical to main. --- .../proptest-regressions/cmyk/tests.txt | 2 - crates/compose/src/cmyk.rs | 15 +-- crates/compose/src/cmyk/tests.rs | 96 +------------------ 3 files changed, 4 insertions(+), 109 deletions(-) delete mode 100644 crates/compose/proptest-regressions/cmyk/tests.txt diff --git a/crates/compose/proptest-regressions/cmyk/tests.txt b/crates/compose/proptest-regressions/cmyk/tests.txt deleted file mode 100644 index c921b0f002..0000000000 --- a/crates/compose/proptest-regressions/cmyk/tests.txt +++ /dev/null @@ -1,2 +0,0 @@ -# Replay the CI counterexample for adjustment-layer Difference Fill. -cc 4725294ab4d39cca4de4f892e5746664cba17b57c513a042cba08a0d592f03ea diff --git a/crates/compose/src/cmyk.rs b/crates/compose/src/cmyk.rs index 2f05b3be2b..2f8eaa8861 100644 --- a/crates/compose/src/cmyk.rs +++ b/crates/compose/src/cmyk.rs @@ -403,10 +403,7 @@ fn adjust_inks(adj: &Adjustment, buf: &mut CmykBuffer, depth: SampleType) { /// Mixes an adjustment's result back into `base` in the layer's blend mode, by its opacity and /// mask; coverage is unchanged (the compositor's adjustment mix). fn mix_adjusted(l: &Layer, base: &mut CmykBuffer, adjusted: &CmykBuffer, cx: &Ctx) { - // Fill below 100% in one of the special eight changes the blend itself and leaves - // coverage to Opacity, as on the RGB compositor (psblend::composite_fill, #9501). - let special = l.fill_opacity < 1.0 && super::psblend::fill_is_special(l.blend); - let opacity = if special { l.opacity } else { l.opacity * l.fill_opacity }; + let opacity = l.opacity * l.fill_opacity; let rect = base.rect; let w = (rect.width() as usize).max(1); let mv = mask_vals(l, rect, cx); @@ -425,15 +422,7 @@ fn mix_adjusted(l: &Layer, base: &mut CmykBuffer, adjusted: &CmykBuffer, cx: &Ct } continue; } - let bl = if special { - let b = |i: usize| 1.0 - p[i]; - let s = |i: usize| 1.0 - a[i]; - let cmy = super::psblend::blend_rgb_fill(l.blend, [b(0), b(1), b(2)], [s(0), s(1), s(2)], l.fill_opacity); - let k = super::psblend::blend_rgb_fill(l.blend, [b(3); 3], [s(3); 3], l.fill_opacity); - [1.0 - cmy[0], 1.0 - cmy[1], 1.0 - cmy[2], 1.0 - k[0]] - } else { - blend_ink(l.blend, [p[0], p[1], p[2], p[3]], [a[0], a[1], a[2], a[3]]) - }; + let bl = blend_ink(l.blend, [p[0], p[1], p[2], p[3]], [a[0], a[1], a[2], a[3]]); for (v, b) in p.iter_mut().zip(bl) { *v = if k >= 1.0 { b } else { *v + (b - *v) * k }; } diff --git a/crates/compose/src/cmyk/tests.rs b/crates/compose/src/cmyk/tests.rs index 821c57fa3e..44b265c8dd 100644 --- a/crates/compose/src/cmyk/tests.rs +++ b/crates/compose/src/cmyk/tests.rs @@ -220,33 +220,14 @@ fn ref_adjusted(l: &Layer, adj: &Adjustment, clipped: &[Layer], p: Cmyka, x: i32 if p[4] > 0.0 { a = ref_clip(clipped, a, x, y); } - let special = l.fill_opacity < 1.0 && crate::psblend::fill_is_special(l.blend); - let op = if special { l.opacity } else { l.opacity * l.fill_opacity }; - let k = op * ref_mask(l, x, y); + let k = l.opacity * l.fill_opacity * ref_mask(l, x, y); if l.blend == BlendMode::Dissolve { // Opacity and mask are the chance the pixel takes the adjustment fully. return if k > 0.0 && crate::dissolve_noise(x, y) < k { [a[0], a[1], a[2], a[3], p[4]] } else { p }; } let mut o = p; - let bl = std::array::from_fn::<_, 4, _>(|c| { - if !special { - return ref_blend(l.blend, p[c], a[c]); - } - // Independent scalar oracle: apply Fill toward the mode's neutral brightness. - let f = l.fill_opacity.clamp(0.0, 1.0); - let (back, source) = (1.0 - p[c], 1.0 - a[c]); - if l.blend == BlendMode::HardMix { - return 1.0 - ((back - f * (1.0 - source)) / (1.0 - f)).clamp(0.0, 1.0); - } - let neutral = match l.blend { - BlendMode::ColorBurn | BlendMode::LinearBurn => 1.0, - BlendMode::VividLight | BlendMode::LinearLight => 0.5, - _ => 0.0, - }; - ref_blend(l.blend, p[c], 1.0 - (neutral + (source - neutral) * f)) - }); for c in 0..4 { - o[c] = p[c] + (bl[c] - p[c]) * k; + o[c] = p[c] + (ref_blend(l.blend, p[c], a[c]) - p[c]) * k; } o } @@ -1877,76 +1858,3 @@ fn every_cmyk_route_rounds_after_an_adjustment_layer() { assert_eq!((display, export), (105.0 / 255.0, 105.0 / 255.0), "opacity {opacity}"); } } - -/// The exact group stack captured by proptest seed 4725294a, with source pixels -/// stored at the requested depth rather than only changing the document label. -fn captured_adjustment_doc(depth: SampleType, blend: BlendMode, opacity: f32, fill: f32, layer_mask: Option) -> (Document, f32) { - let mut source = Layer::raster("source", PixelFormat { mode: ColorMode::Cmyk, sample: depth, alpha: true }); - source.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 1, 1), &[0.0, 0.0, 0.583_913_15, 0.0, 1.0]); - let stored_yellow = source.surface().unwrap().sample_channel(0, 0, 2); - let mut invert = adjustment(Adjustment::Invert); - invert.blend = blend; - invert.opacity = opacity; - invert.fill_opacity = fill; - invert.mask = layer_mask; - let mut pass = Layer::group("pass", vec![invert]); - pass.blend = BlendMode::PassThrough; - let mut outer = Layer::group("isolated", vec![source, pass]); - outer.blend = BlendMode::Normal; - let mut d = Document::new("captured", Size::new(4, 5), ColorMode::Cmyk, depth); - d.layers.push(outer); - (d, stored_yellow) -} - -#[test] -fn adjustment_special_fill_recreates_captured_repro() { - let (d, _) = captured_adjustment_doc(SampleType::U8, BlendMode::Difference, 1.0, 0.170_322_34, None); - assert_routes_agree(&d); - assert_matches_reference(&d); - assert_eq!(render1(&d, 0, 0)[2], 174.0 / 255.0); -} - -#[test] -fn adjustment_special_fill_depths() { - for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { - let (d, stored) = captured_adjustment_doc(depth, BlendMode::Difference, 1.0, 0.170_322_34, None); - assert!(supports(&d)); - if depth == SampleType::F32 { - assert!(!split::supports(&d), "float documents use the native path"); - } else { - assert_routes_agree(&d); - } - // Difference on brightness: 1 - |(1 - ink) - ink * Fill|. - let unrounded = 1.0 - ((1.0 - stored) - stored * 0.170_322_34).abs(); - let expected = match depth { - SampleType::U8 => (255.0 - ((1.0 - unrounded) * 255.0 + 0.5).floor()) / 255.0, - SampleType::U16 => (32768.0 - ((1.0 - unrounded) * 32768.0 + 0.5).floor()) / 32768.0, - _ => unrounded, - }; - let got = render1(&d, 0, 0); - assert!((got[2] - expected).abs() <= 1e-5, "{depth:?}: {got:?} vs {expected}"); - assert_eq!(got[4], 1.0); - } -} - -#[test] -fn adjustment_fill_attenuation_and_boundaries() { - let cases = [ - (BlendMode::Difference, 0.5, 0.170_322_34, None), - (BlendMode::Difference, 1.0, 0.170_322_34, Some(mask(&[(Rect::new(0, 0, 1, 1), 0.5)], 1.0, 1.0, true))), - (BlendMode::Normal, 1.0, 0.170_322_34, None), - (BlendMode::Difference, 1.0, 1.0, None), - (BlendMode::Difference, 1.0, 0.0, None), - (BlendMode::Dissolve, 1.0, 0.5, None), - ]; - for (blend, opacity, fill, layer_mask) in cases { - let (d, stored) = captured_adjustment_doc(SampleType::U8, blend, opacity, fill, layer_mask); - assert_routes_agree(&d); - assert_matches_reference(&d); - let got = render1(&d, 0, 0); - assert_eq!(got[4], 1.0); - if fill == 0.0 { - assert_eq!(got[2], stored); - } - } -} From 06d00e06a9526b8026761b21c79e513e314b4077 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Tue, 3 Nov 2026 09:33:29 +0530 Subject: [PATCH 195/216] fix: keep multipage TIFF frames inside the footage budget and refusals typed The multi-page TIFF branch of `load_frames` pushed each flattened page straight into `frames`, bypassing the `keep` closure, so `MAX_FOOTAGE_BYTES` was never checked for those pages: many large pages could retain more than the cap without an error. Route every flattened page through `keep`, with the same file-context error mapping as the single-file and GIF/WebP paths, and keep typed page admission refusals typed. Add command-level regressions. A per-thread test scope lowers the footage and PSD limits, so neither test allocates gibibytes: one proves an over-budget multi-page TIFF is refused with nothing imported, and one proves a page with Photoshop layer data reaches `file.import.videoFramesToLayers` as a typed `EngineError::PsdHardBudget` with its name, required and limit fields intact and the document unchanged. --- crates/engine/src/cmd/video_cmds.rs | 88 +++++++++++++++++++++-- crates/engine/src/cmd/video_cmds/tests.rs | 82 +++++++++++++++++++++ 2 files changed, 163 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/video_cmds.rs b/crates/engine/src/cmd/video_cmds.rs index 06baf8529f..9baf8da748 100644 --- a/crates/engine/src/cmd/video_cmds.rs +++ b/crates/engine/src/cmd/video_cmds.rs @@ -263,6 +263,76 @@ type ImportFrames = fn( /// Frame storage one footage import may keep (whole tiles, all frames together). const MAX_FOOTAGE_BYTES: u64 = 2 << 30; +/// The footage frame limit this import applies: the production cap, or a test scope's lowered +/// cap so a regression can reach the refusal without keeping gibibytes of frames. +fn footage_byte_limit() -> u64 { + #[cfg(test)] + if let Some(limit) = limits::max_footage_bytes() { + return limit; + } + MAX_FOOTAGE_BYTES +} + +/// Import options for footage files. A test scope may lower the PSD/layered-TIFF memory limit, +/// which the user's preferences do not expose, to reach an admission refusal on a small file. +fn footage_import_options(s: &Session) -> photocraft_io::ImportOptions { + #[cfg(test)] + if let Some(limit) = limits::psd_max_pixel_bytes() { + return photocraft_io::ImportOptions { psd_max_pixel_bytes: limit, ..crate::jobs::import_options(s.prefs()) }; + } + crate::jobs::import_options(s.prefs()) +} + +/// Per-thread test overrides for the footage import limits. A scope restores the previous values +/// on drop, and the thread-local keeps parallel tests from lowering each other's limits. +#[cfg(test)] +mod limits { + use std::cell::Cell; + + #[derive(Clone, Copy, Default)] + struct Overrides { + psd_max_pixel_bytes: Option, + max_footage_bytes: Option, + } + + thread_local! { + static CURRENT: Cell = const { Cell::new(Overrides { psd_max_pixel_bytes: None, max_footage_bytes: None }) }; + } + + /// Restores the previous overrides when dropped. + pub(crate) struct Scoped(Overrides); + + impl Scoped { + /// Lowers the PSD/layered-TIFF memory admission limit for the current thread. + pub(crate) fn psd_max_pixel_bytes(limit: u64) -> Self { + Self::set(Overrides { psd_max_pixel_bytes: Some(limit), ..Default::default() }) + } + + /// Lowers the retained footage-frame limit for the current thread. + pub(crate) fn max_footage_bytes(limit: u64) -> Self { + Self::set(Overrides { max_footage_bytes: Some(limit), ..Default::default() }) + } + + fn set(next: Overrides) -> Self { + Self(CURRENT.with(|current| current.replace(next))) + } + } + + impl Drop for Scoped { + fn drop(&mut self) { + CURRENT.with(|current| current.set(self.0)); + } + } + + pub(super) fn psd_max_pixel_bytes() -> Option { + CURRENT.with(|current| current.get().psd_max_pixel_bytes) + } + + pub(super) fn max_footage_bytes() -> Option { + CURRENT.with(|current| current.get().max_footage_bytes) + } +} + /// The engine error for a failed page of a multi-page TIFF. A memory refusal keeps its typed form, /// so the UI, control channel and agents still get the required and limit fields they need to /// offer approval or explain the hard limit; any other failure keeps the page's text. @@ -302,6 +372,7 @@ fn load_frames(path: &str, fmt: photocraft_color::PixelFormat, opts: &photocraft let mut delays = Vec::new(); // Each frame stores whole tiles however little it draws, so count the tiles kept so far. let mut kept_bytes = 0u64; + let max_footage_bytes = footage_byte_limit(); let mut keep = |frames: &mut Vec, surface: Surface| -> std::result::Result<(), String> { // A frame of fully transparent pixels stores no tiles, so the count needs its own limit. if frames.len() >= crate::timeline_cmds::MAX_DURATION { @@ -309,8 +380,8 @@ fn load_frames(path: &str, fmt: photocraft_color::PixelFormat, opts: &photocraft } let tile = u64::from(photocraft_geom::TILE_SIZE.unsigned_abs()).pow(2) * surface.format().bytes_per_pixel() as u64; kept_bytes = kept_bytes.saturating_add(tile.saturating_mul(surface.tile_count() as u64)); - if kept_bytes > MAX_FOOTAGE_BYTES { - return Err(format!("the footage needs more than {} MiB of frames", MAX_FOOTAGE_BYTES >> 20)); + if kept_bytes > max_footage_bytes { + return Err(format!("the footage needs more than {} MiB of frames", max_footage_bytes >> 20)); } frames.push(surface); Ok(()) @@ -360,7 +431,10 @@ fn load_frames(path: &str, fmt: photocraft_color::PixelFormat, opts: &photocraft let doc = photocraft_io::import_tiff_page_with_options(&name, &bytes, Some(page), opts, &photocraft_raster::Interrupt::NONE) .map_err(|e| tiff_page_error(f, &name, page, e))? .document; - frames.push(crate::file_cmds::flattened(&doc, fmt)); + // The page is flattened before admission of the next one, so the retained + // frames of every page count against the footage limit, as GIF and WebP + // frames do. + keep(&mut frames, crate::file_cmds::flattened(&doc, fmt)).map_err(|msg| EngineError::Other(format!("`{}`: {msg}", f.display())))?; } continue; } @@ -385,7 +459,7 @@ fn new_from_file(s: &mut Session, p: &Value) -> Result { .ok_or_else(|| EngineError::BadParams { cmd: "layer.videoLayers.newVideoLayerFromFile".into(), msg: "need `path`".into() })? .to_string(); let fmt = s.active().ok_or(EngineError::NoDocument)?.doc.pixel_format(); - let Footage { frames, fps: footage_fps } = load_frames(&path, fmt, &crate::jobs::import_options(s.prefs()))?; + let Footage { frames, fps: footage_fps } = load_frames(&path, fmt, &footage_import_options(s))?; // An explicit `fps` wins, then the rate the footage implies, then 30. let fps = crate::timeline_cmds::fps_param("layer.videoLayers.newVideoLayerFromFile", p)?.or(footage_fps).unwrap_or(30.0); let n = frames.len(); @@ -422,7 +496,7 @@ fn replace_footage(s: &mut Session, p: &Value) -> Result { .ok_or_else(|| EngineError::BadParams { cmd: "layer.videoLayers.replaceFootage".into(), msg: "need `path`".into() })? .to_string(); let fmt = s.active().ok_or(EngineError::NoDocument)?.doc.pixel_format(); - let frames = load_frames(&path, fmt, &crate::jobs::import_options(s.prefs()))?.frames; + let frames = load_frames(&path, fmt, &footage_import_options(s))?.frames; edit_video(s, "Replace Footage", move |v, _| { v.original_frames = Some(frames.clone()); v.frames = frames; @@ -458,7 +532,7 @@ fn reload_frame(s: &mut Session, _p: &Value) -> Result { }; // The footage path is stored in the document. let file = crate::doc_path::resolve(&path).map_err(|why| crate::doc_path::refusal("the footage", &path, why))?; - let frames = load_frames(&file.to_string_lossy(), fmt, &crate::jobs::import_options(s.prefs()))?.frames; + let frames = load_frames(&file.to_string_lossy(), fmt, &footage_import_options(s))?.frames; edit_video(s, "Reload Frame", move |v, _| { v.original_frames = Some(frames.clone()); v.frames = frames; @@ -483,7 +557,7 @@ fn frames_to_layers(s: &mut Session, p: &Value) -> Result { .ok_or_else(|| EngineError::BadParams { cmd: "file.import.videoFramesToLayers".into(), msg: "need `path`".into() })? .to_string(); let fmt = s.active().ok_or(EngineError::NoDocument)?.doc.pixel_format(); - let frames = load_frames(&path, fmt, &crate::jobs::import_options(s.prefs()))?.frames; + let frames = load_frames(&path, fmt, &footage_import_options(s))?.frames; let n = frames.len(); s.edit("Video Frames to Layers", |doc, active| { let mut above = *active; diff --git a/crates/engine/src/cmd/video_cmds/tests.rs b/crates/engine/src/cmd/video_cmds/tests.rs index 30039e4457..858b56a546 100644 --- a/crates/engine/src/cmd/video_cmds/tests.rs +++ b/crates/engine/src/cmd/video_cmds/tests.rs @@ -99,6 +99,39 @@ fn write_tiff(path: &str, pages: &[(u32, u32, u8, u32)]) { std::fs::write(path, f).unwrap(); } +/// A real layered TIFF from the exporter (Photoshop layer data in tag 37724), extended to two +/// pages by appending a copy of its first directory as the next one. The copied directory keeps +/// the original tag and pixel offsets, so both pages carry genuine layer data without hand-written +/// Photoshop blocks; the file stays small, and the second page is never decoded. +fn write_layered_multipage_tiff(path: &str) { + let mut doc = Document::new("layered", photocraft_geom::Size::new(8, 6), photocraft_color::ColorMode::Rgb, photocraft_color::SampleType::U8); + let fmt = doc.pixel_format(); + for name in ["bottom", "top"] { + let mut surface = Surface::new(fmt); + surface.write_interleaved(photocraft_geom::Rect::new(0, 0, 8, 6), &vec![96u8; 8 * 6 * fmt.bytes_per_pixel()]); + doc.layers.push(Layer::new(name, LayerContent::Raster(surface))); + } + let bytes = + photocraft_io::export(&doc, "layered.tif", &photocraft_io::ExportOptions { tiff_layers: true, ..Default::default() }).expect("a layered TIFF").bytes; + assert_eq!(bytes.get(0..2), Some(&b"II"[..]), "the TIFF exporter writes little-endian files here"); + let u16_at = |o: usize| u16::from_le_bytes([bytes[o], bytes[o + 1]]); + assert_eq!(u16_at(2), 42, "a classic TIFF"); + let first_ifd = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]) as usize; + let count = u16_at(first_ifd) as usize; + let dir_len = 2 + count * 12 + 4; + let mut f = bytes.clone(); + if f.len() % 2 == 1 { + f.push(0); + } + let second_ifd = f.len(); + f.extend_from_slice(&bytes[first_ifd..first_ifd + dir_len]); + let second_next = second_ifd + 2 + count * 12; + f[second_next..second_next + 4].copy_from_slice(&0u32.to_le_bytes()); + let first_next = first_ifd + 2 + count * 12; + f[first_next..first_next + 4].copy_from_slice(&u32::try_from(second_ifd).expect("a small TIFF").to_le_bytes()); + std::fs::write(path, f).expect("write the two-page TIFF"); +} + /// #6379: Video Frames to Layers takes every page of a multi-page TIFF, one layer each, in /// order, as it takes a GIF's frames; a thumbnail is not a page. A single-page TIFF is one frame. #[test] @@ -178,6 +211,55 @@ fn tiff_page_errors_keep_memory_refusals_typed_and_other_context() { assert!(msg.contains("pages.tif") && msg.contains("page 1") && msg.contains("corrupt page"), "{msg}"); } +/// #10897 review: a multi-page TIFF page with Photoshop layer data is admitted like a PSD. A tiny +/// explicit caller limit must reach the caller as the typed `PsdHardBudget`, with the file name, +/// required bytes and limit intact, and the document must be left untouched. A helper-only test +/// would still pass if the page loop turned that refusal into text. +#[test] +fn video_frames_to_layers_keeps_a_layered_tiff_page_refusal_typed() { + let dir = tmpdir("tiff-typed"); + let path = format!("{dir}/layered.tif"); + write_layered_multipage_tiff(&path); + let mut s = session(); + let before_layers = s.active().unwrap().doc.layers.len(); + let before_active = s.active().unwrap().active_layer; + let _limit = super::limits::Scoped::psd_max_pixel_bytes(1); + let err = s.execute("file.import.videoFramesToLayers", json!({"path": path})).unwrap_err(); + match err { + EngineError::PsdHardBudget { name, required, limit } => { + assert_eq!(name, path); + assert_eq!(limit, 1); + assert!(required > limit, "the page estimate must be past the caller's limit: {required} > {limit}"); + } + other => panic!("the layered page's refusal lost its typed fields: {other:?}"), + } + assert_eq!(s.active().unwrap().doc.layers.len(), before_layers, "no partial frame layers"); + assert_eq!(s.active().unwrap().active_layer, before_active, "the active layer is unchanged"); +} + +/// #10897 review: every page of a multi-page TIFF goes through the footage `keep` budget, as a +/// GIF's frames do. Before, the pages were pushed straight into `frames`, so many large pages could +/// keep more than the cap without an error. A thread-local test scope lowers the cap, so the +/// regression needs a few tiles of memory rather than gibibytes. +#[test] +fn video_frames_to_layers_enforces_the_footage_limit_on_every_tiff_page() { + let dir = tmpdir("tiff-budget"); + let path = format!("{dir}/pages.tif"); + let mut s = session(); + let fmt = s.active().unwrap().doc.pixel_format(); + let tile = u64::from(photocraft_geom::TILE_SIZE.unsigned_abs()).pow(2) * fmt.bytes_per_pixel() as u64; + let budget = 1u64 << 20; + assert!(tile < budget, "one page must fit in the lowered budget"); + // One more page than fits in the budget: only the accumulation can refuse it. + let pages = usize::try_from(budget / tile + 1).expect("a small page count"); + write_tiff(&path, &vec![(16, 16, 40, 0); pages]); + let before = s.active().unwrap().doc.layers.len(); + let _limit = super::limits::Scoped::max_footage_bytes(budget); + let err = s.execute("file.import.videoFramesToLayers", json!({"path": path})).unwrap_err().to_string(); + assert!(err.contains("pages.tif") && err.contains("more than 1 MiB"), "{err}"); + assert_eq!(s.active().unwrap().doc.layers.len(), before, "nothing imported"); +} + #[test] fn new_video_layer_from_image_sequence() { let dir = tmpdir("seq"); From d8e0dde8b5ef0d11f5831f78336e5a58a524123f Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Tue, 3 Nov 2026 10:41:03 +0530 Subject: [PATCH 196/216] fix: pass footage import options to the single-file fallback `load_frames`'s single-file path called `file_cmds::import`, which uses default import options, so the footage command's lowered PSD/layered-TIFF memory limit never reached a single-page layered TIFF or PSD. Multi-page TIFF pages already passed their `opts`. Give the engine import one shared `import_result_with` path and an `import_with_options` entry that keep the allocation checkpoint, native `.pcraft` and format dispatch, typed memory refusals and the preview-only refusal; the existing `import` delegates with default options, so every other caller behaves exactly as before. The single-file fallback now passes `opts`. Add a command-level regression: a single-page layered TIFF under a thread-scoped `psd_max_pixel_bytes` of 1 must reach `file.import.videoFramesToLayers` as a typed `EngineError::PsdHardBudget` with its name, required and limit fields intact and the document unchanged, then import as one frame when the limit is not lowered. --- crates/engine/src/cmd/file_cmds.rs | 2 +- crates/engine/src/cmd/file_cmds/open.rs | 17 +++++++- crates/engine/src/cmd/video_cmds.rs | 2 +- crates/engine/src/cmd/video_cmds/tests.rs | 52 ++++++++++++++++++++--- 4 files changed, 63 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/file_cmds.rs b/crates/engine/src/cmd/file_cmds.rs index 16a0c88991..c1c1b4f539 100644 --- a/crates/engine/src/cmd/file_cmds.rs +++ b/crates/engine/src/cmd/file_cmds.rs @@ -41,7 +41,7 @@ pub use info::{CAMERA_FIELDS, camera_data, read_file_info, write_file_info}; use info::{exposure_text, find_element}; pub use open::{PLACE_EMBEDDED, open_bytes_as, open_bytes_as_with_options, place_bytes, raw_defaults}; use open::{approve_memory, close_all, close_others, open_as, place, revert}; -pub(crate) use open::{import, tag_exr_colorspace}; +pub(crate) use open::{import, import_with_options, tag_exr_colorspace}; use paths::is_file; #[cfg(test)] use paths::openable_name; diff --git a/crates/engine/src/cmd/file_cmds/open.rs b/crates/engine/src/cmd/file_cmds/open.rs index 13af404bea..bc7dfe2594 100644 --- a/crates/engine/src/cmd/file_cmds/open.rs +++ b/crates/engine/src/cmd/file_cmds/open.rs @@ -5,7 +5,13 @@ use super::{ }; pub(crate) fn import(name: &str, bytes: &[u8]) -> Result { - import_result(name, bytes).map(|r| r.document) + import_with_options(name, bytes, &photocraft_io::ImportOptions::default()) +} + +/// [`import`] with the caller's import settings. Footage passes its PSD/layered-TIFF memory limit, +/// so a frame import cannot bypass the admission the rest of the open path applies. +pub(crate) fn import_with_options(name: &str, bytes: &[u8], opts: &photocraft_io::ImportOptions) -> Result { + import_result_with(name, bytes, opts).map(|r| r.document) } /// [`import`] for a file that becomes a document of its own (Revert, Load Files into Stack; @@ -23,8 +29,15 @@ pub(super) fn import_at_raw_ppi(raw_ppi: f64, name: &str, bytes: &[u8]) -> Resul } fn import_result(name: &str, bytes: &[u8]) -> Result { + import_result_with(name, bytes, &photocraft_io::ImportOptions::default()) +} + +/// The one import path every caller shares: allocation checkpoint, native `.pcraft` and format +/// dispatch through `photocraft_io::import_with_options`, typed memory refusals, and the +/// preview-only refusal wherever the import came from. +fn import_result_with(name: &str, bytes: &[u8], opts: &photocraft_io::ImportOptions) -> Result { crate::allocation::checkpoint("importing document")?; - let r = photocraft_io::import(name, bytes).map_err(|e| crate::error::io_open_error(name, e))?; + let r = photocraft_io::import_with_options(name, bytes, opts).map_err(|e| crate::error::io_open_error(name, e))?; // Auxiliary imports return only a document and cannot surface the preview's fidelity warning. // Open has its own warning-preserving path; never silently place or process a thumbnail. if r.preview_only { diff --git a/crates/engine/src/cmd/video_cmds.rs b/crates/engine/src/cmd/video_cmds.rs index 9baf8da748..d5540c1f79 100644 --- a/crates/engine/src/cmd/video_cmds.rs +++ b/crates/engine/src/cmd/video_cmds.rs @@ -439,7 +439,7 @@ fn load_frames(path: &str, fmt: photocraft_color::PixelFormat, opts: &photocraft continue; } } - let mut doc = crate::file_cmds::import(&name, &bytes)?; + let mut doc = crate::file_cmds::import_with_options(&name, &bytes, opts)?; // A Targa's alpha opens as a channel (as in Photoshop); a frame's alpha is transparency. if f.extension().is_some_and(|e| e.eq_ignore_ascii_case("tga")) { photocraft_io::tga_alpha_channel_to_transparency(&mut doc); diff --git a/crates/engine/src/cmd/video_cmds/tests.rs b/crates/engine/src/cmd/video_cmds/tests.rs index 858b56a546..f2acb0bd0f 100644 --- a/crates/engine/src/cmd/video_cmds/tests.rs +++ b/crates/engine/src/cmd/video_cmds/tests.rs @@ -99,11 +99,9 @@ fn write_tiff(path: &str, pages: &[(u32, u32, u8, u32)]) { std::fs::write(path, f).unwrap(); } -/// A real layered TIFF from the exporter (Photoshop layer data in tag 37724), extended to two -/// pages by appending a copy of its first directory as the next one. The copied directory keeps -/// the original tag and pixel offsets, so both pages carry genuine layer data without hand-written -/// Photoshop blocks; the file stays small, and the second page is never decoded. -fn write_layered_multipage_tiff(path: &str) { +/// A real layered TIFF from the exporter: two 8×6 raster layers and Photoshop layer data in tag +/// 37724, in one full-resolution page. +fn layered_tiff_bytes() -> Vec { let mut doc = Document::new("layered", photocraft_geom::Size::new(8, 6), photocraft_color::ColorMode::Rgb, photocraft_color::SampleType::U8); let fmt = doc.pixel_format(); for name in ["bottom", "top"] { @@ -114,8 +112,17 @@ fn write_layered_multipage_tiff(path: &str) { let bytes = photocraft_io::export(&doc, "layered.tif", &photocraft_io::ExportOptions { tiff_layers: true, ..Default::default() }).expect("a layered TIFF").bytes; assert_eq!(bytes.get(0..2), Some(&b"II"[..]), "the TIFF exporter writes little-endian files here"); + assert_eq!(u16::from_le_bytes([bytes[2], bytes[3]]), 42, "a classic TIFF"); + bytes +} + +/// [`layered_tiff_bytes`] extended to two pages by appending a copy of its first directory as the +/// next one. The copied directory keeps the original tag and pixel offsets, so both pages carry +/// genuine layer data without hand-written Photoshop blocks; the file stays small, and the second +/// page is never decoded. +fn write_layered_multipage_tiff(path: &str) { + let bytes = layered_tiff_bytes(); let u16_at = |o: usize| u16::from_le_bytes([bytes[o], bytes[o + 1]]); - assert_eq!(u16_at(2), 42, "a classic TIFF"); let first_ifd = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]) as usize; let count = u16_at(first_ifd) as usize; let dir_len = 2 + count * 12 + 4; @@ -260,6 +267,39 @@ fn video_frames_to_layers_enforces_the_footage_limit_on_every_tiff_page() { assert_eq!(s.active().unwrap().doc.layers.len(), before, "nothing imported"); } +/// #10897 review: a single-page layered TIFF does not take the multi-page branch, so it falls +/// through to the ordinary single-file import. That import must carry the footage import settings, +/// including the PSD/layered-TIFF memory limit the caller lowered; before, it called the default +/// import and ignored the limit, so this refusal was never typed (and a caller limit could be +/// bypassed). The same file imports as one frame when the limit is not lowered. +#[test] +fn video_frames_to_layers_uses_the_footage_limit_for_a_single_page_layered_tiff() { + let dir = tmpdir("tiff-single-typed"); + let path = format!("{dir}/layered.tif"); + std::fs::write(&path, layered_tiff_bytes()).expect("write the layered TIFF"); + let mut s = session(); + let before_layers = s.active().unwrap().doc.layers.len(); + let before_active = s.active().unwrap().active_layer; + { + let _limit = super::limits::Scoped::psd_max_pixel_bytes(1); + let err = s.execute("file.import.videoFramesToLayers", json!({"path": path})).unwrap_err(); + match err { + EngineError::PsdHardBudget { name, required, limit } => { + assert_eq!(name, path); + assert_eq!(limit, 1); + assert!(required > limit, "the page estimate must be past the caller's limit: {required} > {limit}"); + } + other => panic!("the single-page layered TIFF's refusal lost its typed fields: {other:?}"), + } + } + assert_eq!(s.active().unwrap().doc.layers.len(), before_layers, "no partial frame layers"); + assert_eq!(s.active().unwrap().active_layer, before_active, "the active layer is unchanged"); + // The same page imports normally under the budget the footage command always applied. + let r = s.execute("file.import.videoFramesToLayers", json!({"path": path})).unwrap(); + assert_eq!(r["layers"], 1, "one page is one frame"); + assert_eq!(s.active().unwrap().doc.layers.len(), before_layers + 1); +} + #[test] fn new_video_layer_from_image_sequence() { let dir = tmpdir("seq"); From f4d8886f6ea5b44802c980fc5a5402815872e93e Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Tue, 3 Nov 2026 07:19:16 +0100 Subject: [PATCH 197/216] Refuse scripted .pcraft saves whose previews read unreadable scratch The automation .pcraft save renders the previews and main's optional full-size composite before the bundle writer takes its scratch snapshot, so a tile that failed and recovered in between could be saved as placeholder pixels. Check the document's scratch health and the read-error generation around that render, as io's export path already does. --- Cargo.lock | 1 + crates/automation/Cargo.toml | 1 + crates/automation/src/files.rs | 16 ++++--- crates/automation/tests/scratch_save.rs | 55 +++++++++++++++++++++++++ 4 files changed, 68 insertions(+), 5 deletions(-) create mode 100644 crates/automation/tests/scratch_save.rs diff --git a/Cargo.lock b/Cargo.lock index 8c27a0d101..ed5486be6a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5080,6 +5080,7 @@ dependencies = [ "photocraft-doc", "photocraft-engine", "photocraft-format", + "photocraft-geom", "photocraft-io", "photocraft-raster", "photocraft-raw", diff --git a/crates/automation/Cargo.toml b/crates/automation/Cargo.toml index ba073af2ff..e72b38f760 100644 --- a/crates/automation/Cargo.toml +++ b/crates/automation/Cargo.toml @@ -29,6 +29,7 @@ cap-std = "4" clap = { workspace = true } [dev-dependencies] +photocraft-geom = { workspace = true } # Synthetic camera raws (#7789). photocraft-raw = { workspace = true, features = ["testgen"] } rmcp = { version = "3.5", default-features = false, features = ["client", "server", "macros", "transport-io"] } diff --git a/crates/automation/src/files.rs b/crates/automation/src/files.rs index a74af99241..fddd15ca96 100644 --- a/crates/automation/src/files.rs +++ b/crates/automation/src/files.rs @@ -58,14 +58,20 @@ pub fn previews(doc: &Document) -> SaveOptions { } /// [`previews`], plus the full-size composite when `opts` asks for it and it can be rendered -/// ([`ExportOptions::pcraft_full_composite`]). -fn native_options(doc: &Document, opts: &ExportOptions) -> SaveOptions { +/// ([`ExportOptions::pcraft_full_composite`]). Fails when a scratch tile of `doc` could not be +/// read while they were rendered: the previews may then hold placeholder pixels, and the +/// bundle's own scratch checks only cover reads made after it starts. +fn native_options(doc: &Document, opts: &ExportOptions) -> Result { + doc.scratch_health().map_err(AutomationError::Io)?; + let generation = photocraft_raster::spill::read_error_generation(); let mut out = previews(doc); if opts.pcraft_full_composite { out.full_composite = photocraft_io::pcraft_full_composite(doc); out.full_composite_icc = out.full_composite.as_ref().map(|_| photocraft_io::pcraft_full_composite_icc(doc)); } - out + photocraft_raster::spill::check_since(generation).map_err(AutomationError::Io)?; + doc.scratch_health().map_err(AutomationError::Io)?; + Ok(out) } /// Encode the name the saved native bundle will report on reopening. Save As updates @@ -97,7 +103,7 @@ pub fn save( let named = named_for_output(doc, &path.to_string_lossy()); let mut local = PcraftWriter::new(); let w = writer.unwrap_or(&mut local); - w.save_path(&named, path, &native_options(&named, opts))?; + w.save_path(&named, path, &native_options(&named, opts)?)?; return Ok(Vec::new()); } let r = photocraft_io::export(doc, &ext, opts)?; @@ -115,7 +121,7 @@ pub fn save_bytes(doc: &Document, name: &str, format_override: Option<&str>, opt .ok_or_else(|| AutomationError::BadRequest(format!("cannot tell the format of `{name}`; pass a format")))?; if ext == photocraft_format::EXTENSION { let named = named_for_output(doc, name); - return Ok((photocraft_format::save_to_bytes(&named, &native_options(&named, opts))?, Vec::new())); + return Ok((photocraft_format::save_to_bytes(&named, &native_options(&named, opts)?)?, Vec::new())); } let result = photocraft_io::export(doc, &ext, opts)?; Ok((result.bytes, result.warnings)) diff --git a/crates/automation/tests/scratch_save.rs b/crates/automation/tests/scratch_save.rs new file mode 100644 index 0000000000..e61c607e8f --- /dev/null +++ b/crates/automation/tests/scratch_save.rs @@ -0,0 +1,55 @@ +//! A scripted `.pcraft` save refuses to write previews or a full-size composite rendered from +//! scratch tiles that could not be read. Its own test binary: the scratch store is per process. +#![cfg(not(target_arch = "wasm32"))] + +use photocraft_automation::{AutomationError, files}; +use photocraft_doc::{ColorMode, Document, Layer, PixelFormat, SampleType, Size}; +use photocraft_geom::TileCoord; +use photocraft_raster::spill; +use std::io::{Read, Seek, SeekFrom, Write}; + +#[test] +fn native_save_refuses_previews_rendered_from_unreadable_scratch() { + let dir = std::env::temp_dir().join(format!("photocraft-automation-scratch-save-{}", std::process::id())); + spill::configure(Some(&dir), 1).unwrap(); + let mut doc = Document::new("Scratch", Size::new(512, 256), ColorMode::Rgb, SampleType::U8); + let mut layer = Layer::raster("Pixels", PixelFormat::RGBA8); + for i in 0..2 { + let mut bytes = layer.surface_mut().unwrap().tile_mut(TileCoord::new(i, 0)).bytes_mut(); + for (j, byte) in bytes.iter_mut().enumerate() { + *byte = (j as u8).wrapping_mul(29).wrapping_add((i * 7) as u8 + 1); + } + } + doc.layers.push(layer); + spill::settle(); + assert_eq!(spill::stats().resident_bytes, 0); + let mut file = spill::scratch_handle_for_tests().unwrap(); + file.seek(SeekFrom::Start(0)).unwrap(); + let mut good = Vec::new(); + file.read_to_end(&mut good).unwrap(); + file.seek(SeekFrom::Start(0)).unwrap(); + file.write_all(&vec![0; good.len()]).unwrap(); + file.sync_all().unwrap(); + + let on = photocraft_io::ExportOptions { pcraft_full_composite: true, ..Default::default() }; + // Refused while rendering the previews, before the bundle is assembled. + let err = files::save_bytes(&doc, "Scratch.pcraft", None, &on).unwrap_err(); + assert!(matches!(err, AutomationError::Io(_)), "{err:?}"); + let path = dir.join("Scratch.pcraft"); + let err = files::save(&doc, &path, None, &on, None).unwrap_err(); + assert!(matches!(err, AutomationError::Io(_)), "{err:?}"); + assert!(!path.exists()); + + file.seek(SeekFrom::Start(0)).unwrap(); + file.write_all(&good).unwrap(); + file.sync_all().unwrap(); + for (_, tile) in doc.layers[0].surface().unwrap().tiles() { + tile.try_bytes().unwrap(); + } + assert!(spill::check_integrity().is_ok()); + let (bytes, _) = files::save_bytes(&doc, "Scratch.pcraft", None, &on).unwrap(); + assert_eq!(photocraft_format::load_from_bytes(&bytes).unwrap().layers, doc.layers); + drop((doc, file)); + spill::configure(None, 0).unwrap(); + let _ = std::fs::remove_dir_all(dir); +} From da41a658110d7f6f31e0560fcfa335dd2780843d Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Tue, 3 Nov 2026 12:07:33 +0530 Subject: [PATCH 198/216] docs: document PSD memory admission and open queue functions (cherry picked from commit 07c6a3f87cff39dde8ec2b334f4e9b388667bfc8) --- crates/automation/src/rpc.rs | 2 ++ crates/engine/src/cmd/file_cmds/open.rs | 7 +++++++ crates/engine/src/jobs.rs | 21 +++++++++++++++++++++ crates/io/src/flat.rs | 2 ++ crates/io/src/lib.rs | 6 ++++++ crates/io/src/memory.rs | 21 +++++++++++++++++++++ crates/io/src/psd_gate.rs | 1 + crates/ui-egui/src/app/files.rs | 1 + crates/ui-egui/src/control/dispatch.rs | 2 ++ crates/ui-egui/src/dialogs/open_prompt.rs | 9 +++++++++ crates/ui-egui/src/jobs_ui.rs | 2 ++ 11 files changed, 74 insertions(+) diff --git a/crates/automation/src/rpc.rs b/crates/automation/src/rpc.rs index e8c61e1b40..d8abf5b218 100644 --- a/crates/automation/src/rpc.rs +++ b/crates/automation/src/rpc.rs @@ -61,6 +61,8 @@ pub const METHODS: &[&str] = &[ "methods", ]; +/// Adds the structured memory fields (`required`, soft and hard limits and measured capacity) +/// from a typed admission error to a JSON error object, so clients can offer approval. fn attach_memory_error_details(obj: &mut Value, err: &AutomationError) { match err { AutomationError::Import(photocraft_io::IoError::PsdApprovalRequired { required, soft_limit, hard_limit, available_ram }) diff --git a/crates/engine/src/cmd/file_cmds/open.rs b/crates/engine/src/cmd/file_cmds/open.rs index bc7dfe2594..f4ad017787 100644 --- a/crates/engine/src/cmd/file_cmds/open.rs +++ b/crates/engine/src/cmd/file_cmds/open.rs @@ -125,6 +125,10 @@ pub fn open_bytes_as(s: &mut Session, name: &str, bytes: &[u8], as_ext: Option<& open_bytes_as_with_options(s, name, bytes, as_ext, path, false) } +/// [`open_bytes_as`] with the caller's high-memory approval for these bytes. +/// +/// A one-shot approved estimate is deliberately not forwarded: these bytes may be a fresh copy +/// whose estimate was never approved, so approval applies only through the boolean flag. pub fn open_bytes_as_with_options( s: &mut Session, name: &str, @@ -178,6 +182,9 @@ pub(super) fn open_as(s: &mut Session, p: &Value) -> Result { open_bytes_as_with_options(s, &path, &bytes, as_ext, Some(path.clone()), approve_high_memory) } +/// Answers a pending high-memory preflight: `approve` false cancels it; true rechecks the hard +/// limit and starts the approved open. The preflight is removed either way, so a refusal or a +/// later failure releases the source it held. pub(super) fn approve_memory(s: &mut Session, p: &Value) -> Result { let id = crate::file_cmds::u64_param(p, "id", "file.approveMemory")?; let approve = match p.get("approve") { diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index 529873aa90..2fb72aa903 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -595,10 +595,12 @@ impl SourceFingerprint { } } + /// Whether both fingerprints capture the same source identity (and, for a path, the same path). pub fn matches(&self, other: &Self) -> bool { self == other } + /// Re-reads `source`'s fingerprint and compares it with this one; an unreadable source does not match. pub fn matches_source(&self, source: &OpenSource) -> bool { match Self::of(source) { Ok(cur) => self.matches(&cur), @@ -655,6 +657,8 @@ impl Session { /// bounded too. pub const MAX_PENDING_PREFLIGHT_BYTES: u64 = 1 << 30; + /// Queues an approval prompt, assigning its id, and enforces both the pending-count and + /// retained-byte limits. pub fn store_pending_preflight(&mut self, preflight: PendingPsdPreflight) -> Result { self.store_pending_preflight_with_limit(preflight, Self::MAX_PENDING_PREFLIGHT_BYTES) } @@ -697,6 +701,10 @@ impl Session { Self::retained_source_bytes(sources) } + /// Total retained capacity of distinct in-memory sources, counting a shared buffer once. + /// + /// Capacity, not length, is charged because a truncated `Vec` keeps its allocation; overflow + /// is an error rather than a wrapped total. fn retained_source_bytes<'a>(sources: impl IntoIterator) -> Result { let mut seen: Vec = Vec::new(); let mut total: u64 = 0; @@ -718,19 +726,24 @@ impl Session { Ok(total) } + /// Removes and returns the pending preflight with `id`, releasing the source it held. pub fn take_pending_preflight(&mut self, id: u64) -> Option { let i = self.pending_preflights.iter().position(|p| p.id == id)?; Some(self.pending_preflights.remove(i)) } + /// Cancels the pending preflight with `id` by removing it; `None` means no such prompt. pub fn cancel_pending_preflight(&mut self, id: u64) -> Option { self.take_pending_preflight(id) } + /// Drops every pending preflight, releasing all retained source buffers. pub fn clear_pending_preflights(&mut self) { self.pending_preflights.clear(); } + /// Starts an open with high-memory approval already granted, on a background worker. + /// `expected_fingerprint`, when given, must still match the decoded source. pub fn start_open_approved( &mut self, name: &str, @@ -742,6 +755,8 @@ impl Session { self.start_open_approved_with_background(name, source, confirm_damage, approved_estimate, expected_fingerprint, true) } + /// [`Self::start_open_approved`] with an explicit choice between a background worker and + /// inline work. pub fn start_open_approved_with_background( &mut self, name: &str, @@ -774,6 +789,8 @@ impl Session { self.start_open_for_user_with_background(name, source, true) } + /// Interactive open with an explicit background/inline choice: damaged PSD content and + /// high-memory preflights wait for the user instead of opening non-interactively. pub fn start_open_for_user_with_background(&mut self, name: &str, source: OpenSource, background: bool) -> Result { self.start_open_inner(name, source, true, false, None, None, background) } @@ -953,6 +970,8 @@ impl Session { } // Keep the worker and apply closures together with their execution options. + /// [`Self::start_job`] with an explicit `spawn` flag: when false (or on wasm) the work runs + /// inline and its result is returned directly; otherwise it becomes a background job. #[allow(clippy::too_many_arguments)] pub fn start_job_with_spawn( &mut self, @@ -1442,6 +1461,8 @@ impl Session { self.end_job_with_error(job, outcome, None); } + /// Records a finished job's terminal state (done, failed or cancelled) and optional typed + /// error in the recent-job list, invalidating any live preview. fn end_job_with_error(&mut self, job: &Running, outcome: JobOutcome, error_kind: Option) { // Every terminal path invalidates a preview, including worker errors, cancellation // and discarded results whose document was closed or changed. diff --git a/crates/io/src/flat.rs b/crates/io/src/flat.rs index 7c7afe2c1f..27a35015d0 100644 --- a/crates/io/src/flat.rs +++ b/crates/io/src/flat.rs @@ -34,6 +34,7 @@ pub(crate) fn with_before_composite_decode(hook: std::rc::Rc, f: im f() } +/// Runs the test hook installed by [`with_before_composite_decode`], if any. #[cfg(test)] fn before_composite_decode() { if let Some(hook) = TEST_BEFORE_COMPOSITE_DECODE.with(|h| h.borrow().clone()) { @@ -41,6 +42,7 @@ fn before_composite_decode() { } } +/// No-op outside tests: production decodes have no pre-composite hook. #[cfg(not(test))] fn before_composite_decode() {} diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index e52a808b47..3efb2a333c 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -217,6 +217,10 @@ impl FileIdentity { Self::of_metadata(&std::fs::metadata(path)?) } + /// Builds an identity from metadata already read from an open handle or path. + /// + /// Unix adds the device, inode and inode-change time so a same-size rewrite with a restored + /// modification time is still detected; other platforms compare length and modification time. fn of_metadata(metadata: &std::fs::Metadata) -> std::io::Result { let len = metadata.len(); let modified = metadata.modified()?; @@ -681,6 +685,8 @@ pub fn import_source_with_options( Ok((r, damaged)) } +/// [`import_source_with_options`] with an explicit PSD pixel limit, for tests that exercise +/// admission without the default allowance. #[cfg(test)] pub(crate) fn import_source_with_limit( name: &str, diff --git a/crates/io/src/memory.rs b/crates/io/src/memory.rs index ccddd9cca1..8d53723425 100644 --- a/crates/io/src/memory.rs +++ b/crates/io/src/memory.rs @@ -201,6 +201,7 @@ enum Platform { Web, } +/// The platform policy selected for this build: Web on wasm32, Native everywhere else. fn active_platform() -> Platform { if cfg!(target_arch = "wasm32") { Platform::Web } else { Platform::Native } } @@ -235,6 +236,7 @@ pub fn resolve_budget(snapshot: MemorySnapshot, caller_limit: Option) -> Me resolve_budget_for(active_platform(), snapshot, caller_limit) } +/// [`resolve_budget`] for an explicit platform, so tests can exercise the web policy on a native host. fn resolve_budget_for(platform: Platform, snapshot: MemorySnapshot, caller_limit: Option) -> MemoryBudget { if platform == Platform::Web { let mut hard = WASM_MAX_PEAK_BUDGET; @@ -308,6 +310,7 @@ pub(crate) fn evaluate_psd_admission( evaluate_psd_admission_for(active_platform(), required, caller_limit, high_memory_approved, approved_estimate, snapshot) } +/// [`evaluate_psd_admission`] for an explicit platform, so tests can exercise the web policy on a native host. fn evaluate_psd_admission_for( platform: Platform, required: u64, @@ -629,6 +632,9 @@ pub(super) mod linux { super_options: String, } + /// Measures available RAM and free swap from `/proc/meminfo`, capped by the process's memory + /// cgroup. Unreadable cgroup constraints fail closed as zero available memory rather than + /// falling back to unrestricted host memory. pub(super) fn snapshot() -> MemorySnapshot { snapshot_at(Path::new("/proc"), Path::new("/sys/fs/cgroup")) } @@ -754,6 +760,9 @@ pub(super) mod linux { MembershipParse::Parsed(membership) } + /// Parses `/proc/self/mountinfo` into the mount fields used to locate a cgroup hierarchy. + /// + /// Malformed lines are skipped; callers fail closed when no matching mount is found. fn parse_mountinfo(text: &str) -> Vec { let mut mounts = Vec::new(); for line in text.lines() { @@ -860,6 +869,8 @@ pub(super) mod linux { Unknown, } + /// Whether the cgroup v2 hierarchy at `root` exposes the memory controller. An unreadable + /// `cgroup.controllers` is `Unknown`, so its absence cannot be proven. fn v2_controller(root: &Path) -> V2Controller { match std::fs::read_to_string(root.join("cgroup.controllers")) { Ok(text) => { @@ -885,6 +896,8 @@ pub(super) mod linux { Unknown, } + /// Whether `parent` enables the memory controller for its children in + /// `cgroup.subtree_control`. An unreadable file is `Unknown`, not "disabled". fn v2_delegation(parent: &Path) -> Delegation { match std::fs::read_to_string(parent.join("cgroup.subtree_control")) { Ok(text) => { @@ -1075,6 +1088,8 @@ pub(super) mod linux { CgroupLimits::Limited { ram, swap } } + /// Reads a cgroup limit file: `max` is unlimited, a number is a byte limit, and a missing file + /// is distinguished from one that cannot be read or parsed. fn read_limit(path: &Path) -> Limit { match std::fs::read_to_string(path) { Ok(text) => match text.trim() { @@ -1089,14 +1104,17 @@ pub(super) mod linux { } } + /// Reads a whitespace-terminated unsigned value from a cgroup interface file. fn read_u64(path: &Path) -> Option { std::fs::read_to_string(path).ok()?.trim().parse().ok() } + /// Reads `field` from a cgroup `*.stat` file, treating a missing file or field as zero. fn read_stat(path: &Path, field: &str) -> u64 { std::fs::read_to_string(path).ok().and_then(|s| stat(&s, field)).unwrap_or(0) } + /// Finds `field` in `key value` stat lines and parses its value. fn stat(s: &str, field: &str) -> Option { s.lines().find_map(|l| { let mut parts = l.split_whitespace(); @@ -1105,6 +1123,7 @@ pub(super) mod linux { }) } + /// Whether a comma-separated mount option list contains `controller`. fn has_option(options: &str, controller: &str) -> bool { options.split(',').any(|option| option.trim() == controller) } @@ -1161,6 +1180,8 @@ pub(super) mod linux { mod windows_mem { use super::MemorySnapshot; + /// Measures available physical and commit memory through `GlobalMemoryStatusEx`. A failed + /// query reports unknown memory rather than inventing a measurement. pub(super) fn snapshot() -> MemorySnapshot { match winsafe::GlobalMemoryStatusEx() { Ok(msx) => MemorySnapshot { available_ram: Some(msx.ullAvailPhys), swap_free: None, commit_headroom: Some(msx.ullAvailPageFile) }, diff --git a/crates/io/src/psd_gate.rs b/crates/io/src/psd_gate.rs index dc98a1346e..d661166018 100644 --- a/crates/io/src/psd_gate.rs +++ b/crates/io/src/psd_gate.rs @@ -25,6 +25,7 @@ struct GateState { static GATE: OnceLock<(Mutex, Condvar)> = OnceLock::new(); +/// Returns the process-wide decode gate, creating its mutex and condition variable on first use. fn get_gate() -> &'static (Mutex, Condvar) { GATE.get_or_init(|| (Mutex::new(GateState { active: false, owner: None }), Condvar::new())) } diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index b4df20f4c5..af4b2d1d20 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -142,6 +142,7 @@ impl PhotocraftApp { self.open_automation_bytes_with_approval(name, bytes, false) } + /// [`Self::open_automation_bytes`] with the caller's high-memory approval for the imported bytes. pub fn open_automation_bytes_with_approval(&mut self, name: &str, bytes: &[u8], approved: bool) -> Result, String> { let events_enabled = self.session.prefs().script_events.enabled; if events_enabled { diff --git a/crates/ui-egui/src/control/dispatch.rs b/crates/ui-egui/src/control/dispatch.rs index 31ad74a4c8..7d9c6dd5cd 100644 --- a/crates/ui-egui/src/control/dispatch.rs +++ b/crates/ui-egui/src/control/dispatch.rs @@ -1392,6 +1392,8 @@ fn app_methods(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlReques }) } +/// The `app.open` control method: opens a path through the configured automation read +/// capability, with optional high-memory approval. fn app_open(app: &mut PhotocraftApp, path: &str, approved: bool) -> Outcome { if let Some(outcome) = open_pcraft_directory(app, path) { return outcome; diff --git a/crates/ui-egui/src/dialogs/open_prompt.rs b/crates/ui-egui/src/dialogs/open_prompt.rs index 484ea21a25..2da15f9fb4 100644 --- a/crates/ui-egui/src/dialogs/open_prompt.rs +++ b/crates/ui-egui/src/dialogs/open_prompt.rs @@ -85,6 +85,7 @@ pub fn arrive(app: &mut PhotocraftApp, held: Held) { advance(app); } +/// Whether any open prompt (damage, profile or high-memory) is currently on screen. pub fn has_open_dialog(app: &PhotocraftApp) -> bool { app.ui.dialogs.iter().any(|d| owns(&d.fields) || owns_damage(&d.fields) || owns_memory(&d.fields)) } @@ -353,6 +354,7 @@ fn open(app: &mut PhotocraftApp, held: Held, action: Option<&str>) -> Result) -> bool { fields.contains_key(MEMORY_FIELD) } +/// Draws the high-memory prompt's message body from its dialog fields. pub(crate) fn memory_body(ui: &mut egui::Ui, fields: &serde_json::Map) { ui.label(fields.get("message").and_then(Value::as_str).unwrap_or_default()); } +/// Advances the shared open-prompt queue; memory prompts take their turn with damage and profile +/// prompts in arrival order. pub fn advance_memory(app: &mut PhotocraftApp) { advance(app); } +/// Builds and opens the confirmation dialog for a queued high-memory preflight. fn show_memory(app: &mut PhotocraftApp, prompt: &MemoryPrompt) { let est_gb = prompt.estimated as f64 / (1u64 << 30) as f64; // An unmeasured capacity is never printed as a number, even if a stale measurement is @@ -424,6 +431,8 @@ fn show_memory(app: &mut PhotocraftApp, prompt: &MemoryPrompt) { app.ui.open_dialog(crate::state::DialogKind::Command, fields); } +/// Answers the front high-memory prompt: accepts and starts the approved open, or declines and +/// advances the queue. The engine command revalidates the source identity and the hard limit. pub(crate) fn answer_memory(app: &mut PhotocraftApp, accept: bool) -> Result { let Some(front) = app.held_memory_prompts.pop_front() else { advance(app); diff --git a/crates/ui-egui/src/jobs_ui.rs b/crates/ui-egui/src/jobs_ui.rs index c7d78d3933..760a7b6b48 100644 --- a/crates/ui-egui/src/jobs_ui.rs +++ b/crates/ui-egui/src/jobs_ui.rs @@ -468,6 +468,8 @@ pub fn cancel(app: &mut PhotocraftApp, id: JobId) -> bool { cancelled } +/// Handles one finished job: optional completion beep, waiting control replies, and the +/// command-specific open, save, transform and recovery paths. fn on_event(app: &mut PhotocraftApp, ctx: &egui::Context, e: JobEvent) { // Preferences ▸ General ▸ Beep When Done (off by default): a finished operation beeps, like // Photoshop's cue. Cancelling is the user's own action and stays silent. From 25f21db8930d7acb65d9c8eda864596052ce4f50 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:03:38 +0100 Subject: [PATCH 199/216] Revert "Stream native PSD and EXR imports through bounded seekable bands" This reverts commit 89ed4cb28414a173126e97d1853e957343c5ba98 from the #5332 branch so the GPU viewport streaming lands standalone. The import work is rescoped separately in #5331 (EXR streaming only). --- Cargo.lock | 1 - apps/photocraft/src/services.rs | 6 - crates/automation/Cargo.toml | 1 - crates/automation/src/files.rs | 22 +- crates/automation/src/headless.rs | 40 +- crates/automation/src/workspace.rs | 105 +---- crates/codecs/examples/exr_region_probe.rs | 74 --- crates/codecs/src/exr_stream.rs | 292 ------------ crates/codecs/src/lib.rs | 6 +- crates/codecs/tests/exr_stream.rs | 161 ------- crates/engine/src/file_cmds.rs | 30 +- crates/engine/src/jobs.rs | 31 +- crates/format/src/atomic.rs | 60 --- crates/format/src/atomic/tests.rs | 84 ---- crates/io/examples/scratch_psb_probe.rs | 36 -- crates/io/src/exr_native.rs | 76 ---- crates/io/src/lib.rs | 165 ------- crates/io/src/psd_export.rs | 85 +--- crates/io/src/psd_import.rs | 19 +- crates/io/src/psd_stream.rs | 395 ---------------- crates/io/tests/streaming_exr.rs | 94 ---- crates/io/tests/streaming_psd.rs | 136 ------ crates/io/tests/svg.rs | 17 - crates/psd/src/layer.rs | 4 +- crates/psd/src/lib.rs | 1 - crates/psd/src/stream.rs | 499 --------------------- crates/ui-egui/src/control.rs | 50 --- crates/ui-egui/src/file_open.rs | 26 +- crates/ui-egui/src/jobs_ui.rs | 45 -- crates/ui-egui/src/lib.rs | 3 - crates/ui-egui/tests/exr_open.rs | 44 -- docs/roadmap.md | 2 - docs/scorecard.md | 2 +- docs/scratch-disks.md | 1 - docs/streaming-gpu.md | 1 - docs/streaming-io.md | 36 -- scorecard/reliability.toml | 2 +- 37 files changed, 66 insertions(+), 2586 deletions(-) delete mode 100644 crates/codecs/examples/exr_region_probe.rs delete mode 100644 crates/codecs/src/exr_stream.rs delete mode 100644 crates/codecs/tests/exr_stream.rs delete mode 100644 crates/io/examples/scratch_psb_probe.rs delete mode 100644 crates/io/src/exr_native.rs delete mode 100644 crates/io/src/psd_stream.rs delete mode 100644 crates/io/tests/streaming_exr.rs delete mode 100644 crates/io/tests/streaming_psd.rs delete mode 100644 crates/psd/src/stream.rs delete mode 100644 crates/ui-egui/tests/exr_open.rs delete mode 100644 docs/streaming-io.md diff --git a/Cargo.lock b/Cargo.lock index 9dfd9946f6..79b80aae04 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4295,7 +4295,6 @@ dependencies = [ "photocraft-format", "photocraft-geom", "photocraft-io", - "photocraft-raster", "rmcp", "schemars", "serde", diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index 3cae110ea3..d4a3869f31 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -406,11 +406,6 @@ fn shell_word(s: &str) -> String { pub fn native(automation: Option) -> Services { let clip: Rc>> = Rc::default(); - let automation_import_path = automation.clone().map(|workspace| { - std::sync::Arc::new(move |path: &str, ctl: &photocraft_raster::Interrupt<'_>| { - crate::crash_guard::guard("Open", || workspace.read_document(path, ctl).map_err(|e| e.to_string())) - }) as photocraft_ui_egui::AutomationImportPathFn - }); let automation_read = automation.clone().map(|workspace| { Box::new(move |path: &str| { let bytes = workspace.read(path).map_err(|error| error.to_string())?; @@ -454,7 +449,6 @@ pub fn native(automation: Option) -> file_dialog: Some(Box::new(show_file_dialog)), write: Some(Box::new(|path: &str, bytes: &[u8]| write_atomic(Path::new(path), bytes))), automation_read, - automation_import_path, automation_write, automation_open_pcraft, automation_save, diff --git a/crates/automation/Cargo.toml b/crates/automation/Cargo.toml index f15f500e05..0738140a41 100644 --- a/crates/automation/Cargo.toml +++ b/crates/automation/Cargo.toml @@ -15,7 +15,6 @@ photocraft-doc = { workspace = true } photocraft-compose = { workspace = true } photocraft-codecs = { workspace = true } photocraft-io = { workspace = true } -photocraft-raster = { workspace = true } photocraft-format = { path = "../format" } photocraft-engine = { workspace = true } serde = { workspace = true } diff --git a/crates/automation/src/files.rs b/crates/automation/src/files.rs index 61c6ba246a..cd34ab3410 100644 --- a/crates/automation/src/files.rs +++ b/crates/automation/src/files.rs @@ -31,17 +31,9 @@ pub fn open(path: &Path) -> Result { if path.is_dir() { return Ok(Opened { document: photocraft_format::load_path(path)?, warnings: Vec::new(), source_read_only: false }); } + let bytes = photocraft_format::read_file(path).map_err(|e| AutomationError::Io(format!("{}: {e}", path.display())))?; let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); - #[cfg(not(target_arch = "wasm32"))] - { - let r = photocraft_io::import_path_with(&name, path, &photocraft_raster::Interrupt::NONE)?; - Ok(Opened { document: r.document, warnings: r.warnings, source_read_only: r.source_read_only }) - } - #[cfg(target_arch = "wasm32")] - { - let bytes = photocraft_format::read_file(path).map_err(|e| AutomationError::Io(format!("{}: {e}", path.display())))?; - open_bytes(&name, &bytes) - } + open_bytes(&name, &bytes) } /// Previews for a `.pcraft` bundle. @@ -81,16 +73,6 @@ pub fn save( w.save_path(&named, path, &previews(&named))?; return Ok(Vec::new()); } - #[cfg(not(target_arch = "wasm32"))] - if ext == "psb" || ext == "psd" && (opts.force_psb || doc.size.width > 30_000 || doc.size.height > 30_000) { - let mut warnings = Vec::new(); - photocraft_format::atomic::atomic_write_stream(path, |file| { - warnings = photocraft_io::export_psb_into(doc, file, &photocraft_raster::Interrupt::NONE).map_err(|e| std::io::Error::other(e.to_string()))?; - Ok(()) - }) - .map_err(|e| AutomationError::Io(e.to_string()))?; - return Ok(warnings); - } let r = photocraft_io::export(doc, &ext, opts)?; // Crash-safe: a failed write never destroys the previous file. photocraft_format::atomic_write(path, &r.bytes).map_err(|e| AutomationError::Io(e.to_string()))?; diff --git a/crates/automation/src/headless.rs b/crates/automation/src/headless.rs index 3a61ffa4dc..d6b3c17102 100644 --- a/crates/automation/src/headless.rs +++ b/crates/automation/src/headless.rs @@ -79,17 +79,9 @@ impl Headless { Filesystem::Denied => return Err(AutomationError::BadRequest("automation filesystem access is not granted: read authority is absent".into())), Filesystem::TrustedLocal => files::open(path)?, Filesystem::Workspace(workspace) => { - #[cfg(not(target_arch = "wasm32"))] - { - let r = workspace.read_document(requested, &photocraft_raster::Interrupt::NONE)?; - files::Opened { document: r.document, warnings: r.warnings, source_read_only: r.source_read_only } - } - #[cfg(target_arch = "wasm32")] - { - let bytes = workspace.read(requested)?; - let name = path.file_name().and_then(|name| name.to_str()).unwrap_or(requested); - files::open_bytes(name, &bytes)? - } + let bytes = workspace.read(requested)?; + let name = path.file_name().and_then(|name| name.to_str()).unwrap_or(requested); + files::open_bytes(name, &bytes)? } }; let path = match file_cmds::template_name(&self.session, requested) { @@ -259,29 +251,9 @@ impl Headless { files::save(&doc, &target, format, opts, Some(writer))? } Filesystem::Workspace(workspace) => { - #[cfg(not(target_arch = "wasm32"))] - if format - .map(|f| f.trim_start_matches('.').eq_ignore_ascii_case("psb")) - .unwrap_or_else(|| target.extension().is_some_and(|ext| ext.eq_ignore_ascii_case("psb"))) - { - let mut warnings = Vec::new(); - workspace.write_stream(target_text, |file| { - warnings = photocraft_io::export_psb_into(&doc, file, &photocraft_raster::Interrupt::NONE) - .map_err(|e| std::io::Error::other(e.to_string()))?; - Ok(()) - })?; - warnings - } else { - let (bytes, warnings) = files::save_bytes(&doc, target_text, format, opts)?; - workspace.write(target_text, &bytes)?; - warnings - } - #[cfg(target_arch = "wasm32")] - { - let (bytes, warnings) = files::save_bytes(&doc, target_text, format, opts)?; - workspace.write(target_text, &bytes)?; - warnings - } + let (bytes, warnings) = files::save_bytes(&doc, target_text, format, opts)?; + workspace.write(target_text, &bytes)?; + warnings } }; warnings.extend(photocraft_engine::type_extra_cmds::missing_font_warnings(&doc)); diff --git a/crates/automation/src/workspace.rs b/crates/automation/src/workspace.rs index 6321bc241f..cde3a3a702 100644 --- a/crates/automation/src/workspace.rs +++ b/crates/automation/src/workspace.rs @@ -68,24 +68,6 @@ impl AuthorizedWorkspace { let source = DirHandle { dir }; Ok(Some(photocraft_format::load_bytes_source(&source, &photocraft_format::LoadOptions::default())?)) } - /// Open a large document by rows through the held read capability, without a file-sized - /// byte array. Independent channel cursors remain rooted beneath the authorized directory. - #[cfg(not(target_arch = "wasm32"))] - pub fn read_document(&self, path: &str, ctl: &photocraft_raster::Interrupt<'_>) -> Result { - let relative = request_path(path, self.read.as_ref())?; - let root = self.read.as_ref().ok_or_else(|| AutomationError::BadRequest(format!("{DENIED}: read authority is absent")))?; - let original = root.dir.metadata(&relative).map_err(|e| file_error("read", path, e))?; - if !original.is_file() { - return Err(AutomationError::BadRequest("automation read path is not a regular file".into())); - } - let name = relative.file_name().and_then(|n| n.to_str()).unwrap_or(path); - let result = photocraft_io::import_seekable_with(name, &|| root.dir.open(&relative), ctl)?; - let current = root.dir.metadata(&relative).map_err(|e| file_error("read", path, e))?; - if current.len() != original.len() || current.modified().ok() != original.modified().ok() { - return Err(AutomationError::Io("source changed during import; open it again".into())); - } - Ok(result) - } /// Create or replace one file below the configured write root, crash-safely: the bytes go to /// a temporary file beside the target, which is synced and renamed over it (the same steps @@ -96,30 +78,18 @@ impl AuthorizedWorkspace { /// resolution and file creation relative to the held directory handle, so /// a non-existent final target is supported without ambient path access. pub fn write(&self, path: &str, bytes: &[u8]) -> Result<(), AutomationError> { - self.write_stream(path, |file| file.write_all(bytes)) - } - - /// Atomically publish a seekable encoder through the held write capability. No ambient - /// paths are exposed to the encoder, including for temporary-file creation and rename. - pub fn write_stream(&self, path: &str, encode: impl FnOnce(&mut cap_std::fs::File) -> std::io::Result<()>) -> Result<(), AutomationError> { - struct Cleanup(Arc, PathBuf); - impl Drop for Cleanup { - fn drop(&mut self) { - let _ = self.0.remove_file(&self.1); - } - } let relative = request_path(path, self.write.as_ref())?; let root = self.write.as_ref().ok_or_else(|| AutomationError::BadRequest(format!("{DENIED}: write authority is absent")))?; let leaf = relative.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); let parent = relative.parent().map(Path::to_path_buf).unwrap_or_default(); let tmp = parent.join(photocraft_format::atomic::temp_name(&leaf)); let mut options = OpenOptions::new(); - options.read(true).write(true).create_new(true); - let file = root.dir.open_with(&tmp, &options).map_err(|e| file_error("write", path, e))?; - let _cleanup = Cleanup(root.dir.clone(), tmp.clone()); - // This binding drops before the cleanup guard if an encoder unwinds. - let mut file = file; - let written = encode(&mut file).and_then(|()| photocraft_format::atomic::sync_file(&file.into_std())); + options.write(true).create_new(true); + let written = root.dir.open_with(&tmp, &options).and_then(|mut file| { + file.write_all(bytes)?; + // Falls back to a plain fsync on shares that refuse a full flush (#1336). + photocraft_format::atomic::sync_file(&file.into_std()) + }); let renamed = written.and_then(|()| photocraft_format::atomic::retry_rename(RenameRetry::platform(), || root.dir.rename(&tmp, &root.dir, &relative))); if let Err(e) = renamed { let _ = root.dir.remove_file(&tmp); @@ -476,69 +446,6 @@ impl Drop for ExportSequence { mod tests { use super::*; - #[cfg(not(target_arch = "wasm32"))] - #[test] - fn seekable_document_import_stays_within_read_capability() { - let (_, _, workspace) = roots("stream-read"); - let mut session = photocraft_engine::Session::new(); - session.execute("file.new", serde_json::json!({"width": 32, "height": 16})).unwrap(); - session.execute("layer.new.layer", serde_json::json!({})).unwrap(); - let doc = &session.active().unwrap().doc; - workspace - .write_stream("small.psb", |file| { - photocraft_io::export_psb_into(doc, file, &photocraft_raster::Interrupt::NONE).map_err(|e| std::io::Error::other(e.to_string()))?; - Ok(()) - }) - .unwrap(); - let opened = workspace.read_document("small.psb", &photocraft_raster::Interrupt::NONE).unwrap(); - assert_eq!(opened.document.size, doc.size); - assert_eq!(opened.document.layers.len(), doc.layers.len()); - assert!(workspace.read_document("../small.psb", &photocraft_raster::Interrupt::NONE).is_err()); - assert!(workspace.read_document("C:/small.psb", &photocraft_raster::Interrupt::NONE).is_err()); - } - - #[test] - fn seekable_capability_writer_rejects_escape_and_preserves_failed_destination() { - use std::io::{Seek, SeekFrom}; - let (inside, outside, workspace) = roots("stream"); - std::fs::write(inside.join("kept.psb"), b"original").unwrap(); - assert!(workspace.write_stream("../escaped.psb", |_| Ok(())).is_err()); - assert!(!outside.join("escaped.psb").exists()); - assert!( - workspace - .write_stream("kept.psb", |file| { - file.write_all(b"partial")?; - Err(std::io::Error::new(std::io::ErrorKind::StorageFull, "injected full disk")) - }) - .is_err() - ); - assert_eq!(std::fs::read(inside.join("kept.psb")).unwrap(), b"original"); - assert!(temp_files(&inside).is_empty()); - workspace - .write_stream("kept.psb", |file| { - file.write_all(b"0000payload")?; - file.seek(SeekFrom::Start(0))?; - file.write_all(b"0007") - }) - .unwrap(); - assert_eq!(std::fs::read(inside.join("kept.psb")).unwrap(), b"0007payload"); - } - - #[test] - fn seekable_capability_unwind_removes_temporary_after_closing_handle() { - let (inside, _, workspace) = roots("stream-unwind"); - std::fs::write(inside.join("kept.psb"), b"original").unwrap(); - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - let _ = workspace.write_stream("kept.psb", |file| { - file.write_all(b"partial")?; - panic!("injected encoder unwind"); - }); - })); - assert!(result.is_err()); - assert_eq!(std::fs::read(inside.join("kept.psb")).unwrap(), b"original"); - assert!(temp_files(&inside).is_empty()); - } - fn roots(name: &str) -> (PathBuf, PathBuf, AuthorizedWorkspace) { let base = std::env::temp_dir().join(format!("photocraft-workspace-{}-{name}", std::process::id())); let inside = base.join("inside"); diff --git a/crates/codecs/examples/exr_region_probe.rs b/crates/codecs/examples/exr_region_probe.rs deleted file mode 100644 index 4b57e803aa..0000000000 --- a/crates/codecs/examples/exr_region_probe.rs +++ /dev/null @@ -1,74 +0,0 @@ -//! Read-only independent EXR sample oracle: `exr_region_probe file.exr x y [x y ...]`. -//! Uses the dependency's filtered block reader, independently of our band conversion. -use exr::{block::reader::ChunksReader, meta::attribute::SampleType}; -use std::io::BufReader; - -fn run() -> Result<(), Box> { - let args: Vec = std::env::args().skip(1).collect(); - let path = args.first().ok_or("usage: exr_region_probe file.exr x y [x y ...]")?; - let coordinates = args.get(1..).ok_or("missing coordinates")?; - if coordinates.is_empty() || !coordinates.len().is_multiple_of(2) || coordinates.len() > 512 { - return Err("provide 1 to 256 coordinate pairs".into()); - } - let points: Vec<(usize, usize)> = coordinates - .chunks_exact(2) - .map(|p| Ok((p.first().ok_or("x")?.parse()?, p.get(1).ok_or("y")?.parse()?))) - .collect::>>()?; - // Bound metadata, dimensions and block sizes before using the oracle reader. - let info = photocraft_codecs::exr_stream::Decoder::new(std::fs::File::open(path)?, &|| false)?.info(); - if points.iter().any(|&(x, y)| x >= info.width as usize || y >= info.height as usize) { - return Err("coordinate outside the data window".into()); - } - let reader = exr::block::read(BufReader::new(std::fs::File::open(path)?), false)?; - let mut blocks = reader - .filter_chunks(false, |_, _, block| points.iter().any(|&(_, y)| y >= block.pixel_position.1 && y < block.pixel_position.1 + block.pixel_size.1))? - .sequential_decompressor(false); - let channels = blocks.meta_data().headers.first().ok_or("missing header")?.channels.clone(); - let mut samples = vec![[0_f32, 0.0, 0.0, 1.0]; points.len()]; - let mut seen = vec![[false; 3]; points.len()]; - while let Some(block) = blocks.decompress_next_block() { - let block = block?; - for line in block.lines(&channels) { - let channel = channels.list.get(line.location.channel).ok_or("channel")?; - let name = channel.name.to_string(); - let component = match name.rsplit('.').next() { - Some("R") => 0, - Some("G") => 1, - Some("B") => 2, - _ => continue, - }; - for (index, &(x, y)) in points.iter().enumerate().filter(|(_, p)| p.1 == line.location.position.1) { - let value = match channel.sample_type { - SampleType::F16 => line.read_samples::().nth(x).ok_or("sample")??.to_f32(), - SampleType::F32 => line.read_samples::().nth(x).ok_or("sample")??, - SampleType::U32 => line.read_samples::().nth(x).ok_or("sample")?? as f32, - }; - if !value.is_finite() { - return Err("non-finite sample cannot be represented in JSON".into()); - } - *samples.get_mut(index).and_then(|s| s.get_mut(component)).ok_or("sample index")? = value; - *seen.get_mut(index).and_then(|s| s.get_mut(component)).ok_or("sample index")? = true; - let _ = y; - } - } - } - if seen.iter().flatten().any(|v| !v) { - return Err("missing RGB samples".into()); - } - println!("{{\"width\":{},\"height\":{},\"samples\":[", info.width, info.height); - for (index, (&(x, y), sample)) in points.iter().zip(samples).enumerate() { - println!("{}{{\"x\":{x},\"y\":{y},\"rgba\":{:?}}}", if index == 0 { "" } else { "," }, sample); - } - println!("]}}"); - Ok(()) -} - -fn main() -> std::process::ExitCode { - match run() { - Ok(()) => std::process::ExitCode::SUCCESS, - Err(error) => { - eprintln!("{error}"); - std::process::ExitCode::FAILURE - } - } -} diff --git a/crates/codecs/src/exr_stream.rs b/crates/codecs/src/exr_stream.rs deleted file mode 100644 index a1d5818a16..0000000000 --- a/crates/codecs/src/exr_stream.rs +++ /dev/null @@ -1,292 +0,0 @@ -//! Bounded, seekable single-part scanline OpenEXR decoding. Total pixel count does not -//! size a decoded image buffer: only one compression block is returned at a time. -use std::io::{Cursor, Read, Seek, SeekFrom}; - -use crate::{CodecError, Format}; -use exr::{ - block::{ - UncompressedBlock, - chunk::{Chunk, CompressedBlock, CompressedScanLineBlock}, - lines::LineIndex, - }, - meta::{BlockDescription, MetaData, attribute::SampleType}, -}; - -const META_BYTES: usize = 8 << 20; -const BLOCK_BYTES: usize = 64 << 20; -const MAX_CHUNKS: usize = 1 << 20; - -fn invalid(e: impl std::fmt::Display) -> CodecError { - CodecError::malformed(Format::OpenExr, e) -} -fn limit(s: &str) -> CodecError { - CodecError::LimitExceeded(s.into()) -} -fn unsupported(s: &str) -> CodecError { - CodecError::unsupported(Format::OpenExr, s) -} - -fn read(r: &mut impl Read) -> Result<[u8; N], CodecError> { - let mut bytes = [0; N]; - r.read_exact(&mut bytes).map_err(invalid)?; - Ok(bytes) -} - -fn string(r: &mut impl Read, prefix: &mut Vec) -> Result { - for i in 0..256 { - let b = read::<1>(r)?; - prefix.extend(b); - if b == [0] { - return Ok(i != 0); - } - } - Err(limit("EXR attribute name exceeds 255 bytes")) -} - -fn metadata(r: &mut impl Read, cancel: &dyn Fn() -> bool) -> Result, CodecError> { - let start = read::<8>(r)?; - if start.get(..4) != Some(&[0x76, 0x2f, 0x31, 0x01]) { - return Err(invalid("EXR signature")); - } - let version = u32::from_le_bytes(start.get(4..).ok_or_else(|| invalid("version"))?.try_into().map_err(invalid)?); - if version & (0x200 | 0x800 | 0x1000) != 0 { - return Err(unsupported("streaming requires a single flat scanline part")); - } - let mut prefix = start.to_vec(); - let mut attributes = 0; - loop { - if cancel() { - return Err(invalid("EXR import cancelled")); - } - if !string(r, &mut prefix)? { - break; - } - attributes += 1; - if attributes > 4096 { - return Err(limit("EXR metadata exceeds 4096 attributes")); - } - string(r, &mut prefix)?; - let length = read::<4>(r)?; - prefix.extend(length); - let n = usize::try_from(i32::from_le_bytes(length)).map_err(invalid)?; - if n > META_BYTES || prefix.len().checked_add(n).is_none_or(|v| v > META_BYTES) { - return Err(limit("EXR metadata exceeds 8 MiB")); - } - let begin = prefix.len(); - prefix.try_reserve_exact(n).map_err(|_| limit("EXR metadata allocation"))?; - prefix.resize(begin + n, 0); - for bytes in prefix.get_mut(begin..).ok_or_else(|| invalid("attribute size"))?.chunks_mut(64 << 10) { - if cancel() { - return Err(invalid("EXR import cancelled")); - } - r.read_exact(bytes).map_err(invalid)?; - } - } - Ok(prefix) -} - -/// Header facts used by the document owner to admit tile and working memory. -#[derive(Clone, Copy, Debug)] -pub struct Info { - pub width: u32, - pub height: u32, - pub gray: bool, - pub has_alpha: bool, - pub all_half: bool, - pub working_bytes: u64, - pub blocks: usize, -} - -/// Interleaved little-endian F32, GrayA or RGBA, at source resolution. -pub struct Band { - pub x: u32, - pub y: u32, - pub width: u32, - pub height: u32, - pub data: Vec, -} - -pub struct Decoder { - source: R, - meta: MetaData, - info: Info, - offsets: Vec, - seen: Vec, - channels: Vec>, - next: usize, - file_len: u64, - chunks_start: u64, - raw_limit: usize, - rows: usize, -} - -impl Decoder { - /// Read only bounded metadata and offsets; allocate no full-image or channel buffer. - pub fn new(mut source: R, cancel: &dyn Fn() -> bool) -> Result { - let file_len = source.seek(SeekFrom::End(0)).map_err(invalid)?; - source.seek(SeekFrom::Start(0)).map_err(invalid)?; - let prefix = metadata(&mut source, cancel)?; - let meta = std::panic::catch_unwind(|| MetaData::read_from_buffered(Cursor::new(&prefix), false)) - .map_err(|_| invalid("invalid EXR metadata"))? - .map_err(invalid)?; - let header = meta.headers.first().ok_or_else(|| invalid("missing header"))?; - if meta.headers.len() != 1 || header.deep || !matches!(header.blocks, BlockDescription::ScanLines) { - return Err(unsupported("streaming requires a single flat scanline part")); - } - let (w, h) = (header.layer_size.0, header.layer_size.1); - if w == 0 || h == 0 || w > 1 << 18 || h > 1 << 18 { - return Err(limit("EXR dimensions exceed the streaming limit of 262144")); - } - let list = &header.channels.list; - if list.is_empty() || list.len() > 64 || list.iter().any(|c| c.sampling.0 != 1 || c.sampling.1 != 1) { - return Err(unsupported("EXR channel count or subsampling")); - } - let names: Vec = list.iter().map(|c| c.name.to_string()).collect(); - let find = |name| names.iter().position(|n| n.rsplit('.').next() == Some(name)); - let (selected, gray) = if let (Some(r), Some(g), Some(b)) = (find("R"), find("G"), find("B")) { - (vec![Some(r), Some(g), Some(b), find("A")], false) - } else if let Some(y) = find("Y").or_else(|| (list.len() == 1).then_some(0)) { - (vec![Some(y), find("A")], true) - } else { - return Err(unsupported("no R/G/B or Y channels")); - }; - // The document path currently tags Rec. 709 linear samples. Keep custom - // primaries on the existing decoder until their profiles can be mapped. - if !gray && header.shared_attributes.chromaticities.is_some() { - return Err(unsupported("streaming custom EXR chromaticities requires a color profile")); - } - let all_half = selected.iter().flatten().all(|i| list.get(*i).is_some_and(|c| c.sample_type == SampleType::F16)); - let has_alpha = selected.last().copied().flatten().is_some(); - let channels = (0..list.len()).map(|i| selected.iter().position(|s| *s == Some(i))).collect(); - let rows = header.max_block_pixel_size().1; - let expected = h.div_ceil(rows.max(1)); - if rows == 0 || header.chunk_count != expected || expected > MAX_CHUNKS { - return Err(limit("EXR block count exceeds the streaming budget")); - } - let raw_limit = w - .checked_mul(rows) - .and_then(|n| n.checked_mul(header.channels.bytes_per_pixel)) - .filter(|n| *n <= BLOCK_BYTES) - .ok_or_else(|| limit("EXR compression block exceeds 64 MiB"))?; - let output = (w as u64).saturating_mul(rows as u64).saturating_mul(selected.len() as u64 * 4); - let info = Info { - width: w as u32, - height: h as u32, - gray, - has_alpha, - all_half, - blocks: expected, - working_bytes: (raw_limit as u64).saturating_mul(8).saturating_add(output.saturating_mul(2)).saturating_add(1 << 20), - }; - let chunks_start = source - .stream_position() - .map_err(invalid)? - .checked_add(expected as u64 * 8) - .filter(|v| *v <= file_len) - .ok_or_else(|| invalid("truncated offset table"))?; - let mut offsets = Vec::new(); - offsets.try_reserve_exact(expected).map_err(|_| limit("EXR offset allocation"))?; - for _ in 0..expected { - if cancel() { - return Err(invalid("EXR import cancelled")); - } - let offset = u64::from_le_bytes(read(&mut source)?); - if offset < chunks_start || offset.checked_add(8).is_none_or(|v| v > file_len) { - return Err(invalid("EXR chunk offset")); - } - offsets.push(offset); - } - Ok(Self { source, meta, info, offsets, seen: vec![false; expected], channels, next: 0, file_len, chunks_start, raw_limit, rows }) - } - - pub fn info(&self) -> Info { - self.info - } - - /// Decode one block. Check cancellation while reading and between channel rows. - pub fn next_band(&mut self, cancel: &dyn Fn() -> bool) -> Result, CodecError> { - let Some(offset) = self.offsets.get(self.next).copied() else { - return Ok(None); - }; - if cancel() { - return Err(invalid("EXR import cancelled")); - } - self.source.seek(SeekFrom::Start(offset)).map_err(invalid)?; - let y = i32::from_le_bytes(read(&mut self.source)?); - let n = usize::try_from(i32::from_le_bytes(read(&mut self.source)?)).map_err(invalid)?; - if n > self.raw_limit.saturating_mul(2).saturating_add(64 << 10) - || offset < self.chunks_start - || offset.checked_add(8).and_then(|v| v.checked_add(n as u64)).is_none_or(|v| v > self.file_len) - { - return Err(invalid("EXR compressed block length")); - } - let header = self.meta.headers.first().ok_or_else(|| invalid("missing header"))?; - let row = i64::from(y) - i64::from(header.own_attributes.layer_position.1); - if row < 0 || row as u64 >= u64::from(self.info.height) || !(row as usize).is_multiple_of(self.rows) { - return Err(invalid("EXR block coordinate")); - } - let seen = self.seen.get_mut(row as usize / self.rows).ok_or_else(|| invalid("EXR block coordinate"))?; - if *seen { - return Err(invalid("duplicate EXR block")); - } - *seen = true; - let mut packed = Vec::new(); - packed.try_reserve_exact(n).map_err(|_| limit("EXR block allocation"))?; - packed.resize(n, 0); - for bytes in packed.chunks_mut(64 << 10) { - if cancel() { - return Err(invalid("EXR import cancelled")); - } - self.source.read_exact(bytes).map_err(invalid)?; - } - let chunk = - Chunk { layer_index: 0, compressed_block: CompressedBlock::ScanLine(CompressedScanLineBlock { y_coordinate: y, compressed_pixels_le: packed }) }; - let block = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| UncompressedBlock::decompress_chunk(chunk, &self.meta, false))) - .map_err(|_| invalid("invalid EXR compression block"))? - .map_err(invalid)?; - let (w, h) = (block.index.pixel_size.0, block.index.pixel_size.1); - if w != self.info.width as usize - || block.index.pixel_position.0 != 0 - || block.index.pixel_position.1 != row as usize - || h != self.rows.min(self.info.height as usize - row as usize) - { - return Err(invalid("EXR block shape")); - } - let expected = w.checked_mul(h).and_then(|n| n.checked_mul(header.channels.bytes_per_pixel)).ok_or_else(|| invalid("EXR block size"))?; - if block.data.len() != expected { - return Err(invalid("EXR block sample count")); - } - let nc = if self.info.gray { 2 } else { 4 }; - let count = w.checked_mul(h).and_then(|n| n.checked_mul(nc * 4)).ok_or_else(|| limit("EXR output block size"))?; - let mut data = Vec::new(); - data.try_reserve_exact(count).map_err(|_| limit("EXR output allocation"))?; - data.resize(count, 0); - if !self.info.has_alpha { - for pixel in data.chunks_exact_mut(nc * 4) { - pixel.get_mut((nc - 1) * 4..).ok_or_else(|| invalid("alpha sample"))?.copy_from_slice(&1_f32.to_le_bytes()); - } - } - for (range, line) in LineIndex::lines_in_block(block.index, &header.channels) { - if cancel() { - return Err(invalid("EXR import cancelled")); - } - let Some(channel) = self.channels.get(line.channel).copied().flatten() else { - continue; - }; - let sample = header.channels.list.get(line.channel).ok_or_else(|| invalid("EXR channel"))?.sample_type; - let input = block.data.get(range).ok_or_else(|| invalid("EXR row size"))?; - let y = line.position.1.checked_sub(block.index.pixel_position.1).ok_or_else(|| invalid("EXR row coordinate"))?; - let output = data.get_mut(y * w * nc * 4..(y + 1) * w * nc * 4).ok_or_else(|| invalid("EXR row coordinate"))?; - for (bytes, pixel) in input.chunks_exact(sample.bytes_per_sample()).zip(output.chunks_exact_mut(nc * 4)) { - let value = match sample { - SampleType::F16 => half::f16::from_bits(u16::from_ne_bytes(bytes.try_into().map_err(invalid)?)).to_f32(), - SampleType::F32 => f32::from_ne_bytes(bytes.try_into().map_err(invalid)?), - SampleType::U32 => u32::from_ne_bytes(bytes.try_into().map_err(invalid)?) as f32, - }; - pixel.get_mut(channel * 4..channel * 4 + 4).ok_or_else(|| invalid("EXR channel"))?.copy_from_slice(&value.to_le_bytes()); - } - } - self.next += 1; - Ok(Some(Band { x: 0, y: row as u32, width: w as u32, height: h as u32, data })) - } -} diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index 2f5422e558..438e1857e4 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -7,9 +7,8 @@ //! metadata (EXIF, XMP, DPI, text) are preserved where the format allows. //! * [`caps`] declares per-format capabilities and [`fidelity_warnings`] //! tells the UI exactly what an export will lose. -//! * APIs use bytes or caller-provided readers, with no ambient filesystem access, -//! and build for `wasm32-unknown-unknown`. Byte decoders enforce [`Limits`]; -//! [`exr_stream`] instead bounds metadata and one decoded compression block. +//! * The API is I/O free (`&[u8]` in, `Vec` out) and builds for +//! `wasm32-unknown-unknown`. Decoders enforce [`Limits`] before allocating. #![forbid(unsafe_code)] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] @@ -17,7 +16,6 @@ mod codecs; mod error; pub mod exif_color; -pub mod exr_stream; mod fidelity; mod format; mod image; diff --git a/crates/codecs/tests/exr_stream.rs b/crates/codecs/tests/exr_stream.rs deleted file mode 100644 index acf03e8ae1..0000000000 --- a/crates/codecs/tests/exr_stream.rs +++ /dev/null @@ -1,161 +0,0 @@ -use photocraft_codecs::{exr_stream::Decoder, *}; -use std::io::Cursor; - -fn fixture(layout: ChannelLayout, sample: SampleType, compression: ExrCompression) -> Vec { - let n = 31 * 35 * layout.channels(); - let values: Vec = (0..n) - .map(|i| match i % 5 { - 0 => -0.25, - 1 => 3.5, - 2 => 0.0, - 3 => 0.0001, - _ => 0.5, - }) - .collect(); - let image = Image::from_f32(31, 35, layout, &values).unwrap().convert(layout, sample); - encode(&image, Format::OpenExr, &EncodeOptions { exr_compression: compression, ..Default::default() }).unwrap() -} - -#[test] -fn scanline_blocks_match_whole_decoder_at_each_layout_depth_and_compression() { - for layout in [ChannelLayout::Gray, ChannelLayout::GrayA, ChannelLayout::Rgb, ChannelLayout::Rgba] { - for sample in [SampleType::F16, SampleType::F32] { - for compression in [ExrCompression::None, ExrCompression::Rle, ExrCompression::Zip1, ExrCompression::Zip16, ExrCompression::Piz] { - let bytes = fixture(layout, sample, compression); - let reference = decode(&bytes).unwrap(); - let target = if layout.is_gray() { ChannelLayout::GrayA } else { ChannelLayout::Rgba }; - let reference = reference.convert(target, SampleType::F32); - let mut stream = Decoder::new(Cursor::new(&bytes), &|| false).unwrap(); - assert_eq!(stream.info().all_half, sample == SampleType::F16); - let row = 31 * target.channels() * 4; - let mut actual = vec![0; reference.data().len()]; - let mut blocks = 0; - while let Some(band) = stream.next_band(&|| false).unwrap() { - actual[band.y as usize * row..(band.y + band.height) as usize * row].copy_from_slice(&band.data); - blocks += 1; - } - assert_eq!(blocks, stream.info().blocks); - assert_eq!(actual, reference.data(), "{layout:?}/{sample:?}/{compression:?}"); - } - } - } -} - -fn change_window(bytes: &mut [u8], width: i32, height: i32) { - let needle = b"dataWindow\0box2i\0\x10\0\0\0"; - let at = bytes.windows(needle.len()).position(|v| v == needle).unwrap() + needle.len(); - bytes[at + 8..at + 12].copy_from_slice(&(width - 1).to_le_bytes()); - bytes[at + 12..at + 16].copy_from_slice(&(height - 1).to_le_bytes()); -} - -#[test] -fn enormous_header_is_not_a_whole_image_allocation() { - let mut bytes = fixture(ChannelLayout::Rgb, SampleType::F16, ExrCompression::Zip16); - change_window(&mut bytes, 65536, 32768); - assert!(matches!(decode(&bytes), Err(CodecError::LimitExceeded(ref message)) if message.contains("2147483648 pixels"))); - // Streaming gets as far as the now-truncated offset table, without a pixel allocation. - assert!(matches!(Decoder::new(Cursor::new(bytes), &|| false), Err(CodecError::Malformed { .. }))); -} - -#[test] -fn cancellation_truncation_duplicate_offsets_and_forged_lengths_fail() { - let bytes = fixture(ChannelLayout::Rgb, SampleType::F16, ExrCompression::Zip16); - assert!(Decoder::new(Cursor::new(&bytes), &|| true).is_err()); - let mut decoder = Decoder::new(Cursor::new(&bytes), &|| false).unwrap(); - assert!(decoder.next_band(&|| true).is_err()); - for end in [0, 8, bytes.len() / 2] { - assert!(Decoder::new(Cursor::new(&bytes[..end]), &|| false).and_then(|mut r| r.next_band(&|| false)).is_err()); - } - // Locate the offset table by finding the validated header end. - let mut input = Cursor::new(&bytes); - let _ = exr::meta::MetaData::read_from_buffered(&mut input, false).unwrap(); - let at = input.position() as usize; - let offset = u64::from_le_bytes(bytes[at..at + 8].try_into().unwrap()) as usize; - let mut corrupt = bytes.clone(); - corrupt[offset + 4..offset + 8].copy_from_slice(&i32::MAX.to_le_bytes()); - assert!(Decoder::new(Cursor::new(corrupt), &|| false).unwrap().next_band(&|| false).is_err()); - let mut duplicate = bytes.clone(); - duplicate[at + 8..at + 16].copy_from_slice(&bytes[at..at + 8]); - let mut decoder = Decoder::new(Cursor::new(duplicate), &|| false).unwrap(); - assert!(decoder.next_band(&|| false).unwrap().is_some()); - assert!(decoder.next_band(&|| false).is_err()); -} - -#[test] -fn hostile_metadata_is_bounded_and_mutated_inputs_never_panic() { - let bytes = fixture(ChannelLayout::Rgb, SampleType::F16, ExrCompression::Zip16); - let mut large = bytes.clone(); - let needle = b"channels\0chlist\0"; - let length = bytes.windows(needle.len()).position(|v| v == needle).unwrap() + needle.len(); - large[length..length + 4].copy_from_slice(&i32::MAX.to_le_bytes()); - assert!(matches!(Decoder::new(Cursor::new(large), &|| false), Err(CodecError::LimitExceeded(_)))); - let mut many = bytes[..8].to_vec(); - for _ in 0..4097 { - many.extend_from_slice(b"z\0string\0\0\0\0\0"); - } - many.push(0); - assert!(matches!(Decoder::new(Cursor::new(many), &|| false), Err(CodecError::LimitExceeded(_)))); - // Small deterministic mutations cover headers, offsets and compressed payloads. - for i in 0..256 { - let mut corrupt = bytes.clone(); - let at = i * (bytes.len() - 1) / 255; - corrupt[at] ^= 0xff; - assert!( - std::panic::catch_unwind(|| { - if let Ok(mut decoder) = Decoder::new(Cursor::new(corrupt), &|| false) { - while let Ok(Some(_)) = decoder.next_band(&|| false) {} - } - }) - .is_ok(), - "mutation at byte {at}" - ); - } -} - -#[test] -fn mixed_samples_and_offset_data_window_keep_coordinates_and_values() { - use exr::prelude::{AnyChannel, AnyChannels, Blocks, Compression, Encoding, FlatSamples, Layer, LayerAttributes, LineOrder, WritableImage}; - let channels = AnyChannels::sort( - vec![ - AnyChannel::new("R", FlatSamples::F16(vec![f16::from_f32(-0.25); 31 * 35])), - AnyChannel::new("G", FlatSamples::F32(vec![3.5; 31 * 35])), - AnyChannel::new("B", FlatSamples::U32(vec![42; 31 * 35])), - ] - .into_iter() - .collect(), - ); - let attributes = LayerAttributes { layer_position: exr::math::Vec2(-15, 40), ..Default::default() }; - let layer = - Layer::new((31, 35), attributes, Encoding { compression: Compression::ZIP16, blocks: Blocks::ScanLines, line_order: LineOrder::Decreasing }, channels); - let mut bytes = Cursor::new(Vec::new()); - exr::image::Image::from_layer(layer).write().to_buffered(&mut bytes).unwrap(); - let mut decoder = Decoder::new(bytes, &|| false).unwrap(); - assert!(!decoder.info().all_half); - let mut rows = 0; - while let Some(band) = decoder.next_band(&|| false).unwrap() { - assert_eq!(band.y, rows); - rows += band.height; - for p in band.data.chunks_exact(16) { - let v: Vec = p.chunks_exact(4).map(|s| f32::from_le_bytes(s.try_into().unwrap())).collect(); - assert_eq!(v, [-0.25, 3.5, 42.0, 1.0]); - } - } - assert_eq!(rows, 35); -} - -#[test] -fn unsupported_variants_stay_on_the_guarded_legacy_path() { - let bytes = fixture(ChannelLayout::Rgb, SampleType::F16, ExrCompression::Zip16); - for flag in [0x200_u32, 0x800, 0x1000] { - let mut changed = bytes.clone(); - changed[4..8].copy_from_slice(&(2 | flag).to_le_bytes()); - assert!(matches!(Decoder::new(Cursor::new(changed), &|| false), Err(CodecError::Unsupported { .. }))); - } - let mut custom = bytes[..8].to_vec(); - custom.extend_from_slice(b"chromaticities\0chromaticities\0\x20\0\0\0"); - for v in [0.64_f32, 0.33, 0.3, 0.6, 0.15, 0.06, 0.3127, 0.329] { - custom.extend_from_slice(&v.to_le_bytes()); - } - custom.extend_from_slice(&bytes[8..]); - assert!(matches!(Decoder::new(Cursor::new(custom), &|| false), Err(CodecError::Unsupported { .. }))); -} diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs index 58c4e6ca7f..7689f39679 100644 --- a/crates/engine/src/file_cmds.rs +++ b/crates/engine/src/file_cmds.rs @@ -357,25 +357,9 @@ pub(crate) fn encode(doc: &Document, path: &str, save: impl Into) -> R } pub(crate) fn save_doc(doc: &Document, path: &str, save: impl Into) -> Result> { - #[cfg(not(target_arch = "wasm32"))] - { - let save = save.into(); - let mut opts = photocraft_io::ExportOptions { tiff_layers: save.tiff_layers, ..Default::default() }; - if let Some(q) = save.quality { - let q = (q.clamp(0.0, 12.0) / 12.0 * 99.0 + 1.0).round() as u8; - opts.encode.jpeg_quality = q; - opts.encode.webp_quality = q; - opts.encode.webp_lossless = false; - } - photocraft_io::export_path_with(doc, std::path::Path::new(path), &opts, &photocraft_raster::Interrupt::NONE) - .map_err(|e| EngineError::Other(format!("{path}: {e}"))) - } - #[cfg(target_arch = "wasm32")] - { - let (bytes, warnings) = encode(doc, path, save)?; - write_file(path, &bytes)?; - Ok(warnings) - } + let (bytes, warnings) = encode(doc, path, save)?; + write_file(path, &bytes)?; + Ok(warnings) } pub(crate) fn str_param<'a>(p: &'a Value, key: &str, cmd: &str) -> Result<&'a str> { @@ -442,12 +426,8 @@ fn close_others(s: &mut Session, p: &Value) -> Result { /// "Revert" history state); the document is clean afterwards. fn revert(s: &mut Session) -> Result { let path = s.active().and_then(|d| d.path.clone()).ok_or(EngineError::Other("the document has never been saved".into()))?; - #[cfg(not(target_arch = "wasm32"))] - let fresh = photocraft_io::import_path_with(&file_name(&path), std::path::Path::new(&path), &photocraft_raster::Interrupt::NONE) - .map_err(|e| EngineError::Other(e.to_string()))? - .document; - #[cfg(target_arch = "wasm32")] - let fresh = import(&file_name(&path), &read_file(&path)?)?; + let bytes = read_file(&path)?; + let fresh = import(&file_name(&path), &bytes)?; s.edit("Revert", |doc, active| { let (id, name) = (doc.id, doc.name.clone()); *doc = fresh; diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index 374300ad06..1fc75ead29 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -258,6 +258,17 @@ fn independent_of_document(id: &str) -> bool { PREFIXES.iter().any(|p| id.starts_with(p)) || IDS.contains(&id) } +#[cfg(not(target_arch = "wasm32"))] +fn read_path(path: &str) -> Result> { + // Bounded reads, and a clear error for a file larger than memory (#375). + photocraft_format::read_file(std::path::Path::new(path)).map_err(|e| EngineError::Other(format!("{path}: {e}"))) +} + +#[cfg(target_arch = "wasm32")] +fn read_path(path: &str) -> Result> { + Err(EngineError::Other(format!("cannot open paths on the web: {path}"))) +} + /// Run a job-capable command's heavy part. `work` runs on a worker thread when the command was /// started with [`Session::start`] (inline otherwise, and always on wasm); `apply` then runs on /// the calling thread with its output and returns the command's result. With `lock_document`, @@ -425,19 +436,17 @@ impl Session { false, move |ctx| { ctx.progress(0.0, "Reading"); + let bytes = match source { + OpenSource::Bytes(b) => b, + OpenSource::Path(p) => Arc::new(read_path(&p)?), + }; ctx.check()?; ctx.progress(0.02, "Decoding"); - ctx.stage(0.02, 1.0, "Decoding", |ctl| match source { - OpenSource::Bytes(b) => photocraft_io::import_with_svg_group_depth_and_interrupt(&name_w, &b, max_svg_group_depth, ctl), - #[cfg(not(target_arch = "wasm32"))] - OpenSource::Path(p) => photocraft_io::import_path_with_svg_group_depth(&name_w, std::path::Path::new(&p), max_svg_group_depth, ctl), - #[cfg(target_arch = "wasm32")] - OpenSource::Path(_) => Err(photocraft_io::IoError::Unsupported("paths cannot be opened on the web".into())), - }) - .map_err(|e| match e { - photocraft_io::IoError::Cancelled => EngineError::Cancelled, - e => EngineError::Other(e.to_string()), - }) + ctx.stage(0.02, 1.0, "Decoding", |ctl| photocraft_io::import_with_svg_group_depth_and_interrupt(&name_w, &bytes, max_svg_group_depth, ctl)) + .map_err(|e| match e { + photocraft_io::IoError::Cancelled => EngineError::Cancelled, + e => EngineError::Other(e.to_string()), + }) }, move |s, r: photocraft_io::ImportResult| { // Ask When Opening: held back until the prompt is answered (`take_held_open`). A diff --git a/crates/format/src/atomic.rs b/crates/format/src/atomic.rs index fb86d9eb38..bf39fe2761 100644 --- a/crates/format/src/atomic.rs +++ b/crates/format/src/atomic.rs @@ -155,66 +155,6 @@ pub fn atomic_write_progress(path: &Path, bytes: &[u8], progress: &dyn Fn(f32)) atomic_write_with_progress(&RealFs, RenameRetry::platform(), path, bytes, progress) } -/// Stream into a new seekable temporary file and publish it only after successful encoding -/// and fsync. A failed encoder, cancellation or full disk leaves the destination intact. -#[cfg(not(target_arch = "wasm32"))] -pub fn atomic_write_stream(path: &Path, encode: impl FnOnce(&mut std::fs::File) -> io::Result<()>) -> io::Result<()> { - atomic_write_stream_with_commit(path, encode, || Ok(())) -} - -/// Like [`atomic_write_stream`], with a final approval immediately before replacement. -/// The encoder and flush remain cancellable; after `commit` succeeds publication runs to -/// completion. A rejected commit or an unwinding callback removes the temporary file. -#[cfg(not(target_arch = "wasm32"))] -pub fn atomic_write_stream_with_commit( - path: &Path, - encode: impl FnOnce(&mut std::fs::File) -> io::Result<()>, - commit: impl FnOnce() -> io::Result<()>, -) -> io::Result<()> { - struct Cleanup(PathBuf); - impl Drop for Cleanup { - fn drop(&mut self) { - let _ = std::fs::remove_file(&self.0); - } - } - let target = resolve_symlink(path); - let name = target.file_name().ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "not a file path"))?; - let dir = target.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new(".")); - std::fs::create_dir_all(dir).map_err(|e| write_error(e, path, dir))?; - let mut temp = None; - for _ in 0..8 { - let candidate = dir.join(temp_name(&name.to_string_lossy())); - match std::fs::OpenOptions::new().read(true).write(true).create_new(true).open(&candidate) { - Ok(file) => { - temp = Some((candidate, file)); - break; - } - Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue, - Err(e) => return Err(write_error(e, path, dir)), - } - } - let (tmp, file) = temp.ok_or_else(|| io::Error::new(io::ErrorKind::AlreadyExists, "cannot create a save temporary"))?; - let _cleanup = Cleanup(tmp.clone()); - // Close the handle before cleanup runs during unwinding, including on Windows. - let mut file = file; - let result = encode(&mut file).and_then(|()| sync_file(&file)); - drop(file); - if let Err(e) = result { - let _ = std::fs::remove_file(&tmp); - return Err(write_error(e, path, dir)); - } - if target.exists() { - let _ = RealFs.copy_permissions(&target, &tmp); - } - commit()?; - if let Err(e) = retry_rename(RenameRetry::platform(), || RealFs.rename(&tmp, &target)) { - let _ = RealFs.remove(&tmp); - return Err(rename_error(e, path)); - } - let _ = RealFs.sync_dir(dir); - Ok(()) -} - /// [`atomic_write`] through an explicit file system and retry policy (the failure-injection seam). pub fn atomic_write_with(fs: &dyn AtomicFs, retry: RenameRetry, path: &Path, bytes: &[u8]) -> io::Result<()> { atomic_write_with_progress(fs, retry, path, bytes, &|_| {}) diff --git a/crates/format/src/atomic/tests.rs b/crates/format/src/atomic/tests.rs index eef149d476..8e71bc7ce3 100644 --- a/crates/format/src/atomic/tests.rs +++ b/crates/format/src/atomic/tests.rs @@ -31,74 +31,6 @@ fn assert_no_temp(dir: &Path) { const ORIGINAL: &[u8] = b"the user's precious original document"; -#[test] -fn streaming_commit_runs_after_encoding_and_before_replacement() { - let dir = TempDir::new("stream-commit"); - let path = dir.0.join("document.psb"); - std::fs::write(&path, ORIGINAL).unwrap(); - let encoded = Cell::new(false); - let result = atomic_write_stream_with_commit( - &path, - |file| { - file.write_all(b"complete")?; - encoded.set(true); - Ok(()) - }, - || { - assert!(encoded.get()); - assert_eq!(std::fs::read(&path).unwrap(), ORIGINAL); - Err(io::Error::new(io::ErrorKind::Interrupted, "cancelled before commit")) - }, - ); - assert_eq!(result.unwrap_err().kind(), io::ErrorKind::Interrupted); - assert_eq!(std::fs::read(&path).unwrap(), ORIGINAL); - assert_no_temp(&dir.0); - - atomic_write_stream_with_commit(&path, |file| file.write_all(b"complete"), || Ok(())).unwrap(); - assert_eq!(std::fs::read(&path).unwrap(), b"complete"); - assert_no_temp(&dir.0); -} - -#[test] -fn streaming_commit_unwind_removes_temporary_and_keeps_original() { - let dir = TempDir::new("stream-commit-unwind"); - let path = dir.0.join("document.psb"); - std::fs::write(&path, ORIGINAL).unwrap(); - let result = std::panic::catch_unwind(|| atomic_write_stream_with_commit(&path, |file| file.write_all(b"complete"), || panic!("injected commit panic"))); - assert!(result.is_err()); - assert_eq!(std::fs::read(&path).unwrap(), ORIGINAL); - assert_no_temp(&dir.0); -} - -#[test] -fn seekable_encoder_failure_keeps_original_and_cleans_temp() { - use std::io::{Seek, SeekFrom}; - let dir = TempDir::new("stream"); - let path = dir.0.join("document.psb"); - std::fs::write(&path, ORIGINAL).unwrap(); - for kind in [io::ErrorKind::StorageFull, io::ErrorKind::Interrupted] { - assert!( - atomic_write_stream(&path, |file| { - file.write_all(b"partial")?; - file.seek(SeekFrom::Start(0))?; - file.write_all(b"PATCH")?; - Err(io::Error::new(kind, "injected encoder failure")) - }) - .is_err() - ); - assert_eq!(std::fs::read(&path).unwrap(), ORIGINAL); - assert_no_temp(&dir.0); - } - atomic_write_stream(&path, |file| { - file.write_all(b"0000payload")?; - file.seek(SeekFrom::Start(0))?; - file.write_all(b"0007") - }) - .unwrap(); - assert_eq!(std::fs::read(&path).unwrap(), b"0007payload"); - assert_no_temp(&dir.0); -} - /// Real file system with failures injected at chosen steps. #[derive(Default)] struct Faulty { @@ -333,19 +265,3 @@ fn sync_file_flushes_a_local_file() { let file = std::fs::OpenOptions::new().write(true).create_new(true).open(&path).unwrap(); sync_file(&file).unwrap(); } - -#[test] -fn streaming_encoder_unwind_closes_and_removes_temporary_file() { - let d = TempDir::new("stream-unwind"); - let p = d.0.join("doc.psb"); - std::fs::write(&p, ORIGINAL).unwrap(); - let result = std::panic::catch_unwind(|| { - let _ = atomic_write_stream(&p, |file| { - file.write_all(b"partial output")?; - panic!("injected encoder unwind"); - }); - }); - assert!(result.is_err()); - assert_eq!(std::fs::read(&p).unwrap(), ORIGINAL); - assert_no_temp(&d.0); -} diff --git a/crates/io/examples/scratch_psb_probe.rs b/crates/io/examples/scratch_psb_probe.rs deleted file mode 100644 index 99c210aae0..0000000000 --- a/crates/io/examples/scratch_psb_probe.rs +++ /dev/null @@ -1,36 +0,0 @@ -//! Read-only large-document import probe. Never exports or changes the input file. -use photocraft_raster::spill; -use std::time::Instant; - -fn main() -> Result<(), String> { - let mut args = std::env::args().skip(1); - let path = args.next().ok_or("usage: scratch_psb_probe ")?; - let dir = args.next().ok_or("missing scratch directory")?; - spill::configure(Some(std::path::Path::new(&dir)), 1 << 30)?; - let start = Instant::now(); - let result = photocraft_io::import_path_with(&path, std::path::Path::new(&path), &photocraft_raster::Interrupt::NONE).map_err(|e| e.to_string())?; - spill::settle(); - let d = &result.document; - println!( - "import_seconds={:.3} size={}x{} depth={:?} mode={:?} warnings={:?}", - start.elapsed().as_secs_f64(), - d.size.width, - d.size.height, - d.depth, - d.mode, - result.warnings - ); - for (_, _, layer) in d.walk() { - if let Some(surface) = layer.surface() { - // Access source pixels directly, without creating a GPU canvas or flattening. - let bounds = surface.tile_bounds(); - let x = bounds.x0.saturating_add(bounds.width() as i32 / 2); - let y = bounds.y0.saturating_add(bounds.height() as i32 / 2); - println!("layer={:?} tiles={} bounds={bounds:?} sample={:?}", layer.name, surface.tile_count(), surface.pixel(x, y)); - } - } - spill::check_integrity()?; - println!("scratch={}", serde_json::to_string(&spill::stats()).map_err(|e| e.to_string())?); - drop(result); - spill::configure(None, 0) -} diff --git a/crates/io/src/exr_native.rs b/crates/io/src/exr_native.rs deleted file mode 100644 index 0d7555bd7b..0000000000 --- a/crates/io/src/exr_native.rs +++ /dev/null @@ -1,76 +0,0 @@ -//! Native OpenEXR blocks go straight into original-depth tiled storage. -use crate::{ImportResult, IoError}; -use photocraft_color::{ColorMode, PixelFormat, SampleType}; -use photocraft_doc::{Document, Layer, LayerContent}; -use photocraft_geom::{Rect, Size, TILE_SIZE}; -use photocraft_raster::{Interrupt, Surface}; -use std::io::{Read, Seek}; - -pub(crate) fn import(source: R, ctl: &Interrupt<'_>) -> Result, IoError> { - let _metadata = photocraft_raster::memory::try_reserve(32 << 20).map_err(IoError::Unsupported)?; - let mut decoder = match photocraft_codecs::exr_stream::Decoder::new(source, &|| ctl.cancelled()) { - Ok(decoder) => decoder, - Err(_) if ctl.cancelled() => return Err(IoError::Cancelled), - Err(photocraft_codecs::CodecError::Unsupported { .. }) => return Ok(None), - Err(e) => return Err(e.into()), - }; - let info = decoder.info(); - let mode = if info.gray { ColorMode::Grayscale } else { ColorMode::Rgb }; - let fmt = PixelFormat::new(mode, SampleType::F32, true); - let scratch_expected = photocraft_raster::spill::enabled(); - if !scratch_expected { - let budget = photocraft_raster::memory::refresh(); - let side = TILE_SIZE as u64; - let tiles = u64::from(info.width).div_ceil(side).saturating_mul(u64::from(info.height).div_ceil(side)); - crate::psd_stream::ram_tile_capacity( - false, - photocraft_raster::spill::stats().resident_bytes as u64, - budget.tile_bytes, - tiles.saturating_mul(side * side).saturating_mul(fmt.bytes_per_pixel() as u64), - )?; - } - let _working = photocraft_raster::memory::try_reserve(info.working_bytes).map_err(IoError::Unsupported)?; - let mut surface = Surface::new(fmt); - let mut done = 0; - loop { - ctl.check().map_err(|_| IoError::Cancelled)?; - crate::psd_stream::require_scratch(scratch_expected)?; - let band = decoder.next_band(&|| ctl.cancelled()).map_err(|e| if ctl.cancelled() { IoError::Cancelled } else { e.into() })?; - let Some(band) = band else { - break; - }; - let rect = Rect::from_xywh(band.x as i32, band.y as i32, band.width, band.height); - if !photocraft_raster::spill::enabled() { - let missing = rect.tiles().filter(|c| surface.tile(*c).is_none()).count() as u64; - crate::psd_stream::ram_tile_capacity( - false, - photocraft_raster::spill::stats().resident_bytes as u64, - photocraft_raster::memory::stats().1.tile_bytes, - missing.saturating_mul((TILE_SIZE as u64).pow(2)).saturating_mul(fmt.bytes_per_pixel() as u64), - )?; - } - surface.write_interleaved(rect, &band.data); - crate::psd_stream::require_scratch(scratch_expected)?; - done += 1; - ctl.progress(0.05 + 0.9 * done as f32 / info.blocks.max(1) as f32); - } - // Missing alpha is filled with one in every allocated tile. Pruning these opaque - // tiles cannot remove any and would unnecessarily reload the entire scratch store. - if info.has_alpha { - surface.prune(); - } - crate::psd_stream::require_scratch(scratch_expected)?; - ctl.check().map_err(|_| IoError::Cancelled)?; - let mut document = Document::new("", Size::new(info.width, info.height), mode, SampleType::F32); - let mut layer = Layer::new("Background", LayerContent::Raster(surface)); - if !info.has_alpha { - layer.locks.transparency = true; - layer.locks.position = true; - } - document.layers.push(layer); - document.icc_profile = - Some(if mode == ColorMode::Rgb { photocraft_cms::Builtin::LinearSrgb.profile().to_bytes() } else { photocraft_cms::builtin::linear_gray().to_bytes() }); - let warnings = if info.all_half { vec!["16-bit float samples are stored as 32-bit float".into()] } else { Vec::new() }; - ctl.progress(1.0); - Ok(Some(ImportResult { document, warnings, source_read_only: false, preview_only: false })) -} diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 0cb2b481f9..936bab32b9 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -41,8 +41,6 @@ pub mod camera_raw_xmp; mod channel_map; pub mod comps_map; pub mod effects_map; -#[cfg(not(target_arch = "wasm32"))] -mod exr_native; pub mod exr_parts; mod flat; mod gradient_bake; @@ -57,8 +55,6 @@ mod pdn; pub mod pixels; mod psd_export; mod psd_import; -#[cfg(not(target_arch = "wasm32"))] -mod psd_stream; pub mod psd_thumbnail; pub mod raw; pub mod slices_map; @@ -245,120 +241,6 @@ pub fn import(name: &str, bytes: &[u8]) -> Result { import_with_svg_group_depth(name, bytes, photocraft_doc::MAX_GROUP_DEPTH) } -/// Native seekable import. Layered PSD/PSB and flat scanline EXR decode bounded bands directly into tiles; -/// the bytes API remains available for web and for formats without a seekable decoder. -#[cfg(not(target_arch = "wasm32"))] -pub fn import_path_with(name: &str, path: &std::path::Path, ctl: &photocraft_raster::Interrupt<'_>) -> Result { - import_path_with_svg_group_depth(name, path, photocraft_doc::MAX_GROUP_DEPTH, ctl) -} - -/// Seekable import preserving the caller's SVG rasterization preference on fallback. -#[cfg(not(target_arch = "wasm32"))] -pub fn import_path_with_svg_group_depth( - name: &str, - path: &std::path::Path, - max_svg_group_depth: usize, - ctl: &photocraft_raster::Interrupt<'_>, -) -> Result { - use std::io::Read; - ctl.check().map_err(|_| IoError::Cancelled)?; - photocraft_compose::discard_unreadable_pixel_caches(); - photocraft_raster::spill::check_integrity().map_err(IoError::Unsupported)?; - let before = photocraft_raster::spill::read_error_generation(); - let mut source = std::fs::File::open(path).map_err(|e| IoError::Unsupported(e.to_string()))?; - let original = source.metadata().map_err(|e| IoError::Unsupported(e.to_string()))?; - let size = original.len(); - let mut magic = [0; 4]; - let n = source.read(&mut magic).map_err(|e| IoError::Unsupported(e.to_string()))?; - if n == 4 && is_psd(&magic) { - let budget = (photocraft_raster::memory::stats().1.transient_bytes / 8).min(128 << 20); - let _metadata = photocraft_raster::memory::try_reserve(budget.saturating_mul(3)).map_err(IoError::Unsupported)?; - let index = photocraft_psd::stream::Index::read(&mut source, budget, &|| ctl.cancelled()) - .map_err(|e| if ctl.cancelled() { IoError::Cancelled } else { e.into() })?; - if psd_stream::eligible(&index) { - if psd_import::group_depth(&index.metadata) > photocraft_doc::MAX_GROUP_DEPTH { - return Err(IoError::Unsupported("PSD layer nesting exceeds the document limit".into())); - } - let mut result = psd_stream::import(&|| std::fs::File::open(path), &index, *ctl)?; - let current = std::fs::metadata(path).map_err(|e| IoError::Unsupported(e.to_string()))?; - if current.len() != original.len() || current.modified().ok() != original.modified().ok() { - return Err(IoError::Unsupported("the source file changed while it was being opened; open it again".into())); - } - result.document.name = name.into(); - photocraft_raster::spill::check_since(before).map_err(IoError::Unsupported)?; - ctl.check().map_err(|_| IoError::Cancelled)?; - return Ok(result); - } - } - if n == 4 - && photocraft_codecs::detect(&magic) == Some(photocraft_codecs::Format::OpenExr) - && let Some(mut result) = exr_native::import(&mut source, ctl)? - { - let current = std::fs::metadata(path).map_err(|e| IoError::Unsupported(e.to_string()))?; - if current.len() != original.len() || current.modified().ok() != original.modified().ok() { - return Err(IoError::Unsupported("the source file changed while it was being opened; open it again".into())); - } - result.document.name = name.into(); - photocraft_raster::spill::check_since(before).map_err(IoError::Unsupported)?; - ctl.check().map_err(|_| IoError::Cancelled)?; - return Ok(result); - } - drop(source); - let _bytes = photocraft_raster::memory::try_reserve(size) - .map_err(|_| IoError::Unsupported("this format requires a whole-file decoder and exceeds the working-memory budget".into()))?; - let bytes = photocraft_format::read_file(path).map_err(|e| IoError::Unsupported(e.to_string()))?; - import_with_svg_group_depth_and_interrupt(name, &bytes, max_svg_group_depth, ctl) -} - -/// Read from handles already authorized by the caller. Independent opens prevent channel -/// readers sharing a seek cursor, and no ambient filesystem access occurs in this function. -#[cfg(not(target_arch = "wasm32"))] -pub fn import_seekable_with( - name: &str, - open: &dyn Fn() -> std::io::Result, - ctl: &photocraft_raster::Interrupt<'_>, -) -> Result { - use std::io::SeekFrom; - ctl.check().map_err(|_| IoError::Cancelled)?; - photocraft_compose::discard_unreadable_pixel_caches(); - photocraft_raster::spill::check_integrity().map_err(IoError::Unsupported)?; - let before = photocraft_raster::spill::read_error_generation(); - let mut source = open().map_err(|e| IoError::Unsupported(e.to_string()))?; - let size = source.seek(SeekFrom::End(0)).map_err(|e| IoError::Unsupported(e.to_string()))?; - source.seek(SeekFrom::Start(0)).map_err(|e| IoError::Unsupported(e.to_string()))?; - let mut magic = [0; 4]; - let n = source.read(&mut magic).map_err(|e| IoError::Unsupported(e.to_string()))?; - if n == 4 && is_psd(&magic) { - let budget = (photocraft_raster::memory::stats().1.transient_bytes / 8).min(128 << 20); - let _metadata = photocraft_raster::memory::try_reserve(budget.saturating_mul(3)).map_err(IoError::Unsupported)?; - let index = photocraft_psd::stream::Index::read(&mut source, budget, &|| ctl.cancelled()) - .map_err(|e| if ctl.cancelled() { IoError::Cancelled } else { e.into() })?; - if psd_stream::eligible(&index) { - if psd_import::group_depth(&index.metadata) > photocraft_doc::MAX_GROUP_DEPTH { - return Err(IoError::Unsupported("PSD layer nesting exceeds the document limit".into())); - } - let mut result = psd_stream::import(open, &index, *ctl)?; - result.document.name = name.into(); - photocraft_raster::spill::check_since(before).map_err(IoError::Unsupported)?; - ctl.check().map_err(|_| IoError::Cancelled)?; - return Ok(result); - } - } - if n == 4 - && photocraft_codecs::detect(&magic) == Some(photocraft_codecs::Format::OpenExr) - && let Some(mut result) = exr_native::import(&mut source, ctl)? - { - result.document.name = name.into(); - photocraft_raster::spill::check_since(before).map_err(IoError::Unsupported)?; - ctl.check().map_err(|_| IoError::Cancelled)?; - return Ok(result); - } - source.seek(SeekFrom::Start(0)).map_err(|e| IoError::Unsupported(e.to_string()))?; - let _working = photocraft_raster::memory::try_reserve_operation(size).map_err(IoError::Unsupported)?; - let bytes = photocraft_format::read::read_all(&mut source, size).map_err(|e| IoError::Unsupported(e.to_string()))?; - import_with(name, &bytes, ctl) -} - /// [`import`] for a background open: checks `ctl` between stages (and per layer for PSD/PSB) and /// reports progress. A cancelled import fails with [`IoError::Cancelled`]. pub fn import_with(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt) -> Result { @@ -477,17 +359,6 @@ pub fn export(doc: &Document, name_or_ext: &str, opts: &ExportOptions) -> Result /// export fails with [`IoError::Cancelled`]; otherwise the bytes are exactly [`export`]'s. pub fn export_with(doc: &Document, name_or_ext: &str, opts: &ExportOptions, ctl: &photocraft_raster::Interrupt) -> Result { ctl.check().map_err(|_| IoError::Cancelled)?; - // The byte API has to retain its complete output. Native large saves use the seekable - // path below; reject an inadmissible legacy working set before building channel arrays. - let tiles: u64 = doc - .walk() - .iter() - .filter_map(|(_, _, l)| l.surface()) - .map(|s| (s.tile_count() as u64).saturating_mul(256 * 256).saturating_mul(s.format().bytes_per_pixel() as u64)) - .sum(); - let estimate = doc.size.area().saturating_mul(32).saturating_add(tiles.saturating_mul(3)); - let _working = photocraft_raster::memory::try_reserve_operation(estimate) - .map_err(|_| IoError::Unsupported("export exceeds the working-memory budget; use native PSB streaming or export a smaller region".into()))?; photocraft_compose::discard_unreadable_pixel_caches(); photocraft_raster::spill::check_integrity().map_err(IoError::Unsupported)?; let scratch_generation = photocraft_raster::spill::read_error_generation(); @@ -498,42 +369,6 @@ pub fn export_with(doc: &Document, name_or_ext: &str, opts: &ExportOptions, ctl: Ok(r) } -/// Native atomic save. PSB writes metadata and bounded original-pixel bands directly to disk; -/// formats with only a bytes encoder use the checked working-set admission in [`export`]. -#[cfg(not(target_arch = "wasm32"))] -pub fn export_path_with(doc: &Document, path: &std::path::Path, opts: &ExportOptions, ctl: &photocraft_raster::Interrupt<'_>) -> Result, IoError> { - ctl.check().map_err(|_| IoError::Cancelled)?; - let ext = path.extension().and_then(|s| s.to_str()).unwrap_or("").to_ascii_lowercase(); - if ext == "psb" || ext == "psd" && (opts.force_psb || doc.size.width > 30_000 || doc.size.height > 30_000) { - photocraft_raster::spill::check_integrity().map_err(IoError::Unsupported)?; - let mut warnings = Vec::new(); - let result = photocraft_format::atomic::atomic_write_stream_with_commit( - path, - |out| { - warnings = psd_stream::export_into(doc, out, *ctl).map_err(|e| std::io::Error::other(e.to_string()))?; - Ok(()) - }, - || ctl.check().map_err(|e| std::io::Error::new(std::io::ErrorKind::Interrupted, e)), - ); - if result.is_err() && ctl.cancelled() { - return Err(IoError::Cancelled); - } - result.map_err(|e| IoError::Unsupported(e.to_string()))?; - return Ok(warnings); - } - let result = export(doc, &path.to_string_lossy(), opts)?; - ctl.check().map_err(|_| IoError::Cancelled)?; - photocraft_format::atomic_write(path, &result.bytes).map_err(|e| IoError::Unsupported(e.to_string()))?; - Ok(result.warnings) -} - -/// Write a native PSB to an already authorized seekable output. The caller owns atomic -/// publication and cleanup; this never opens an ambient path. -#[cfg(not(target_arch = "wasm32"))] -pub fn export_psb_into(doc: &Document, out: &mut W, ctl: &photocraft_raster::Interrupt<'_>) -> Result, IoError> { - psd_stream::export_into(doc, out, *ctl) -} - /// Run `f` with an interrupt whose progress `0..=1` fills `lo..=hi` of `ctl`'s (same cancellation). pub(crate) fn with_span(ctl: &photocraft_raster::Interrupt, lo: f32, hi: f32, f: impl FnOnce(&photocraft_raster::Interrupt) -> R) -> R { let cancelled = || ctl.cancelled(); diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index e2117b237e..9db3d4799f 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -81,8 +81,6 @@ struct Ex { patterns: Vec, /// The layer written as Photoshop's Background (see [`psd_background`]). background: Option, - deferred: bool, - sources: StreamSources, } /// The layer Photoshop should open as its Background: the bottom top-level layer when it is a @@ -105,14 +103,10 @@ fn psd_background(doc: &Document) -> Option { return None; } let n = s.channels(); - let opaque = canvas - .tiles() - .all(|tc| s.try_read_region(tc.rect().intersect(&canvas)).is_ok_and(|pixels| pixels.chunks_exact(n).all(|p| p.last().is_some_and(|a| *a >= 1.0)))); + let opaque = s.read_region(canvas).chunks_exact(n).all(|p| p.last().is_some_and(|a| *a >= 1.0)); opaque.then_some(l.id) } -pub(crate) type StreamSources = std::collections::HashMap, Option)>; - /// How deep embedded documents are exported inside each other before a smart object is written /// as pixels instead. const MAX_NESTING: u32 = 8; @@ -342,13 +336,10 @@ impl Ex { } fn empty_channels(&self) -> Vec { - if self.deferred { - return (-1..self.cc as i16).map(|id| ChannelData { id, compression: Some(Compression::Raw), data: Vec::new() }).collect(); - } (-1..self.cc as i16).map(|id| self.encode(id, &[], 0, 0)).collect() } - fn pixel_channels(&mut self, s: &Surface, name: &str) -> (PsdRect, Vec, Vec, Option) { + fn pixel_channels(&self, s: &Surface, name: &str) -> (PsdRect, Vec, Vec) { let mut warnings = Vec::new(); let s = if s.format() != self.fmt { warnings.push(format!("layer \"{name}\": pixels converted from {:?} to {:?}", s.format(), self.fmt)); @@ -358,11 +349,7 @@ impl Ex { }; let r = s.content_bounds(); if r.is_empty() { - return (PsdRect::default(), self.empty_channels(), warnings, None); - } - if self.deferred { - let channels = (-1..self.cc as i16).map(|id| ChannelData { id, compression: Some(Compression::Raw), data: Vec::new() }).collect(); - return (to_psd_rect(r), channels, warnings, Some(s)); + return (PsdRect::default(), self.empty_channels(), warnings); } // The interleaved gather is the sequential pass for full-canvas layers, so it runs // in row bands on all cores and the band results concatenate in order. @@ -386,15 +373,15 @@ impl Ex { let order: Vec<(i16, &Vec)> = std::iter::once((-1, &planes[self.cc])).chain(planes.iter().take(self.cc).enumerate().map(|(c, p)| (c as i16, p))).collect(); let ch = crate::pixels::par_map(order, |(id, plane)| self.encode(id, plane, w, h)); - (to_psd_rect(r), ch, warnings, None) + (to_psd_rect(r), ch, warnings) } - fn mask(&self, m: &LayerMask, vector: Option<(f32, f32)>) -> (MaskData, Option, Option) { + fn mask(&self, m: &LayerMask, vector: Option<(f32, f32)>) -> (MaskData, Option) { let s = if m.surface.format() != self.mask_fmt { m.surface.convert(self.mask_fmt) } else { m.surface.clone() }; let default = s.default_pixel().first().copied().unwrap_or(0.0); let r = s.content_bounds(); let (w, h) = (r.width() as usize, r.height() as usize); - let plane = if self.deferred || r.is_empty() { Vec::new() } else { deinterleave(&s.to_interleaved(r), 1, self.mask_fmt.sample, &[false]).remove(0) }; + let plane = if r.is_empty() { Vec::new() } else { deinterleave(&s.to_interleaved(r), 1, self.mask_fmt.sample, &[false]).remove(0) }; let mut flags = 0u8; if !m.linked { flags |= PsdMask::FLAG_RELATIVE; @@ -415,11 +402,7 @@ impl Ex { real: None, real_first: true, }; - if self.deferred { - (MaskData::Mask(pm), Some(ChannelData { id: -2, compression: Some(Compression::Raw), data: Vec::new() }), Some(s)) - } else { - (MaskData::Mask(pm), Some(self.encode(-2, &plane, w, h)), None) - } + (MaskData::Mask(pm), Some(self.encode(-2, &plane, w, h))) } /// Prefer numbers above imported layer IDs, wrapping safely at u32::MAX. @@ -441,18 +424,16 @@ impl Ex { fn record(&mut self, l: &Layer, extra: Vec, pixels: Option<&Surface>) -> LayerRecord { let id = self.layer_id(l); - let (rect, mut channels, warnings, source) = match pixels { + let (rect, mut channels, warnings) = match pixels { Some(s) => self.pixel_channels(s, &l.name), - None => (PsdRect::default(), self.empty_channels(), Vec::new(), None), + None => (PsdRect::default(), self.empty_channels(), Vec::new()), }; self.warnings.extend(warnings); let mut mask = MaskData::None; - let mut mask_source = None; // Vector mask density/feather live in the mask parameters, with or without a user mask. let vector = l.vector_mask.as_ref().map(|vm| (vm.density, vm.feather)); if let Some(m) = &l.mask { - let (md, ch, s) = self.mask(m, vector); - mask_source = s; + let (md, ch) = self.mask(m, vector); mask = md; channels.extend(ch); } else if let Some(parameters) = mask_parameters(None, vector) { @@ -467,9 +448,6 @@ impl Ex { real_first: true, }); } - if self.deferred { - self.sources.insert(id, (source, mask_source)); - } // A Background carries no transparency channel: that is how Photoshop tells it apart. if self.background == Some(l.id) { channels.retain(|c| c.id != -1); @@ -1000,7 +978,7 @@ impl Ex { } let doc = photocraft_format::load_from_bytes(bytes).map_err(|e| format!("its contents can't be read: {e}"))?; let (file, warnings) = - document_to_psd_nested(&doc, &PsdExportOptions { force_psb: true, ..Default::default() }, self.smart.depth + 1, &Interrupt::NONE, None); + document_to_psd_nested(&doc, &PsdExportOptions { force_psb: true, ..Default::default() }, self.smart.depth + 1, &Interrupt::NONE); let data = file.to_bytes().map_err(|e| format!("its contents can't be written: {e}"))?; let stem = file_name.rsplit_once('.').map_or(file_name, |(a, _)| a); self.warnings.extend(warnings.into_iter().map(|w| format!("smart object {stem}: {w}"))); @@ -1365,21 +1343,14 @@ pub fn document_to_psd(doc: &Document) -> PsdFile { /// anything that could not be represented. The merged composite is rendered /// with `photocraft_compose::flatten`. pub fn document_to_psd_with(doc: &Document, opts: &PsdExportOptions) -> (PsdFile, Vec) { - document_to_psd_nested(doc, opts, 0, &Interrupt::NONE, None) -} - -#[cfg(not(target_arch = "wasm32"))] -pub(crate) fn stream_metadata(doc: &Document, opts: &PsdExportOptions) -> (PsdFile, Vec, StreamSources) { - let mut sources = StreamSources::new(); - let (file, warnings) = document_to_psd_nested(doc, opts, 0, &Interrupt::NONE, Some(&mut sources)); - (file, warnings, sources) + document_to_psd_nested(doc, opts, 0, &Interrupt::NONE) } /// [`document_to_psd_with`] for a background save (#3389): reports progress through `ctl` after /// each layer record and each band of the merged composite. Once `ctl` is cancelled it stops /// early and returns an **incomplete** model: callers check `ctl` and discard it. pub(crate) fn document_to_psd_interruptible(doc: &Document, opts: &PsdExportOptions, ctl: &Interrupt) -> (PsdFile, Vec) { - document_to_psd_nested(doc, opts, 0, ctl, None) + document_to_psd_nested(doc, opts, 0, ctl) } /// How much of a PSD conversion the merged composite weighs, in layer records: rendering it @@ -1509,13 +1480,7 @@ pub(crate) fn psd_size_exceeds_limit(doc: &Document) -> bool { } /// [`document_to_psd_with`] for a document embedded `depth` smart objects deep. -fn document_to_psd_nested( - doc: &Document, - opts: &PsdExportOptions, - depth: u32, - ctl: &Interrupt, - deferred: Option<&mut StreamSources>, -) -> (PsdFile, Vec) { +fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, ctl: &Interrupt) -> (PsdFile, Vec) { if doc.mode == ColorMode::Multichannel { return crate::multichannel_map::document_to_psd(doc, opts.force_psb); } @@ -1545,8 +1510,6 @@ fn document_to_psd_nested( smart: SmartOut::new(doc, depth), patterns: doc.patterns.clone(), background: psd_background(doc), - deferred: deferred.is_some(), - sources: Default::default(), }; if big && !opts.force_psb { ex.warnings.push("document exceeds 30000 px; written as PSB".into()); @@ -1621,9 +1584,7 @@ fn document_to_psd_nested( // isn't rendered at all. A document without layer records keeps the real image: its pixels // live nowhere else. let real_merged = opts.maximize_compatibility || ex.records.is_empty(); - let (mut planes, has_alpha) = if ex.deferred { - (Vec::new(), true) - } else if real_merged { + let (mut planes, has_alpha) = if real_merged { // Merged composite, rendered and encoded in bands (no full-size float composite). Matting // against white only changes pixels with alpha < 1; if some are slightly translucent but // all round to opaque (so no alpha channel is written), encode once more without the matte. @@ -1661,9 +1622,6 @@ fn document_to_psd_nested( None => None, }; for a in &extra { - if ex.deferred { - continue; - } let s = if a.surface.format() != ex.mask_fmt { a.surface.convert(ex.mask_fmt) } else { a.surface.clone() }; let bytes = s.to_interleaved(canvas); planes.extend(deinterleave(&bytes, 1, sample, &[false]).remove(0)); @@ -1671,13 +1629,9 @@ fn document_to_psd_nested( let channels = (cc + usize::from(has_alpha) + extra.len()) as u16; let header = Header::new(version, doc.size.width, doc.size.height, channels, psd_depth(sample), psd_mode(fmt.mode)); let mcomp = if sample == SampleType::F32 { Compression::Raw } else { opts.merged_compression }; - let image_data = if ex.deferred { - ImageData { compression: Compression::Raw, data: Vec::new() } - } else { - ImageData::encode(mcomp, &planes, &header) - .or_else(|_| ImageData::encode(Compression::Raw, &planes, &header)) - .unwrap_or(ImageData { compression: Compression::Raw, data: planes }) - }; + let image_data = ImageData::encode(mcomp, &planes, &header) + .or_else(|_| ImageData::encode(Compression::Raw, &planes, &header)) + .unwrap_or(ImageData { compression: Compression::Raw, data: planes }); ctl.progress(1.0); // Resources. @@ -1823,9 +1777,6 @@ fn document_to_psd_nested( layer_mask_trailing: Vec::new(), image_data, }; - if let Some(out) = deferred { - *out = ex.sources; - } (file, ex.warnings) } diff --git a/crates/io/src/psd_import.rs b/crates/io/src/psd_import.rs index 82814f3762..444c2d6e5b 100644 --- a/crates/io/src/psd_import.rs +++ b/crates/io/src/psd_import.rs @@ -66,7 +66,6 @@ pub(crate) struct Ctx<'a> { /// Layer records decoded so far, out of `total` (progress). pub done: usize, pub total: usize, - pub streamed: Option<&'a std::collections::HashMap<(usize, i16), Surface>>, } fn doc_mode(m: PsdMode) -> Option { @@ -172,9 +171,6 @@ impl Ctx<'_> { } fn record_surface(&mut self, rec: &LayerRecord, name: &str) -> Surface { - if let Some(streamed) = self.streamed { - return streamed.get(&(rec as *const LayerRecord as usize, i16::MIN)).cloned().unwrap_or_else(|| Surface::new(self.fmt)); - } let r = rec.rect; if r.is_empty() || r.size().is_err() { return Surface::new(self.fmt); @@ -235,11 +231,7 @@ impl Ctx<'_> { } }; let mut surface = Surface::with_default(self.mask_fmt, &[f32::from(default) / 255.0]); - if let Some(streamed) = self.streamed { - if let Some(decoded) = streamed.get(&(rec as *const LayerRecord as usize, id)) { - surface = decoded.clone(); - } - } else if !rect.is_empty() + if !rect.is_empty() && let Ok((w, h)) = rect.size() && let Some(plane) = self.channel_plane(rec, id, name) { @@ -665,14 +657,6 @@ pub fn psd_to_document(file: &PsdFile) -> (Document, Vec) { /// [`psd_to_document`] for a background open: checks `ctl` per layer record and reports /// progress. `None` when cancelled. pub fn psd_to_document_with(file: &PsdFile, ctl: &photocraft_raster::Interrupt) -> Option<(Document, Vec)> { - psd_to_document_decoded(file, ctl, None) -} - -pub(crate) fn psd_to_document_decoded<'a>( - file: &'a PsdFile, - ctl: &photocraft_raster::Interrupt<'a>, - streamed: Option<&'a std::collections::HashMap<(usize, i16), Surface>>, -) -> Option<(Document, Vec)> { let h = &file.header; let mut warnings = Vec::new(); let mode = doc_mode(h.color_mode).unwrap_or_else(|| { @@ -770,7 +754,6 @@ pub(crate) fn psd_to_document_decoded<'a>( ctl: *ctl, done: 0, total: file.layers().len(), - streamed, }; for b in file.global_blocks.iter().filter(|b| matches!(&b.key, b"FEid" | b"FXid")) { // Smart-filter caches can be large: a cancelled open stops between blocks. diff --git a/crates/io/src/psd_stream.rs b/crates/io/src/psd_stream.rs deleted file mode 100644 index c11a9e593b..0000000000 --- a/crates/io/src/psd_stream.rs +++ /dev/null @@ -1,395 +0,0 @@ -//! Native import without a whole PSB byte buffer or whole decoded channel buffers. -use crate::{ - ImportResult, IoError, - pixels::{interleave, max_sample, zero_sample}, -}; -use photocraft_color::{ColorMode, PixelFormat, SampleType}; -use photocraft_geom::Rect; -use photocraft_psd::{ - LayerRecord, - stream::{Index, Rows}, -}; -use photocraft_raster::{Interrupt, Surface}; -use std::{ - collections::HashMap, - io::{BufReader, Read, Seek, SeekFrom, Write}, -}; - -pub(crate) fn eligible(index: &Index) -> bool { - let file = &index.metadata; - matches!( - file.header.color_mode, - photocraft_psd::ColorMode::Rgb | photocraft_psd::ColorMode::Grayscale | photocraft_psd::ColorMode::Cmyk | photocraft_psd::ColorMode::Lab - ) && matches!(file.header.depth, 8 | 16 | 32) - && !file.layers().is_empty() - && usize::from(file.header.channels) <= usize::from(file.header.color_mode.color_channels().unwrap_or(0)) + usize::from(file.merged_has_alpha()) -} - -struct Decode<'a, R> { - open: &'a dyn Fn() -> std::io::Result, - index: &'a Index, - ctl: Interrupt<'a>, - done: u64, - total: u64, - scratch_expected: bool, -} - -fn tile_footprint(r: photocraft_psd::Rect, bytes_per_pixel: usize) -> u64 { - if r.is_empty() { - return 0; - } - let side = i64::from(photocraft_geom::TILE_SIZE); - let columns = ((i64::from(r.right) - 1).div_euclid(side) - i64::from(r.left).div_euclid(side) + 1) as u64; - let rows = ((i64::from(r.bottom) - 1).div_euclid(side) - i64::from(r.top).div_euclid(side) + 1) as u64; - columns.saturating_mul(rows).saturating_mul(side as u64 * side as u64).saturating_mul(bytes_per_pixel as u64) -} - -pub(super) fn ram_tile_capacity(scratch: bool, resident: u64, budget: u64, growth: u64) -> Result<(), IoError> { - if !scratch && resident.checked_add(growth).is_none_or(|required| required > budget) { - return Err(IoError::Unsupported("decoded image tiles exceed the available RAM budget; enable a scratch disk or close other documents".into())); - } - Ok(()) -} - -pub(super) fn require_scratch(expected: bool) -> Result<(), IoError> { - if expected && !photocraft_raster::spill::enabled() { - return Err(IoError::Unsupported( - photocraft_raster::spill::last_error().unwrap_or_else(|| "scratch storage was disabled while loading; retry the import".into()), - )); - } - Ok(()) -} - -impl Decode<'_, R> { - fn surface(&mut self, layer: usize, rec: &LayerRecord, ids: &[i16], fmt: PixelFormat, default: &[f32]) -> Result { - let r = if ids.len() == 1 { rec.channel_rect(*ids.first().ok_or_else(|| IoError::Unsupported("empty channel set".into()))?) } else { rec.rect }; - if r.is_empty() { - return Ok(Surface::with_default(fmt, default)); - } - let (width, height) = r.size()?; - let working = - (width as u64).saturating_mul(64).saturating_mul(fmt.bytes_per_pixel() as u64).saturating_mul(3).saturating_add(ids.len() as u64 * (3 << 20)); - let _working = photocraft_raster::memory::try_reserve(working).map_err(IoError::Unsupported)?; - let ranges = self.index.channels.get(layer).ok_or_else(|| IoError::Unsupported("missing indexed layer".into()))?; - let mut readers = Vec::new(); - for id in ids { - readers.push(if let Some(range) = ranges.iter().find(|c| c.id == *id) { - Some(Rows::new( - BufReader::with_capacity(1 << 20, (self.open)().map_err(|e| IoError::Unsupported(e.to_string()))?), - *range, - width, - height, - self.index.metadata.header.depth, - self.index.metadata.header.version, - )?) - } else { - None - }); - } - let mut out = Surface::with_default(fmt, default); - let fill: Vec> = ids.iter().map(|id| if *id == -1 { max_sample(fmt.sample) } else { zero_sample(fmt.sample) }).collect(); - let invert: Vec = ids.iter().map(|id| fmt.mode == ColorMode::Cmyk && *id >= 0).collect(); - let mut planes: Vec> = ids.iter().map(|_| Vec::new()).collect(); - let mut row = Vec::new(); - let mut y = 0usize; - while y < height { - self.ctl.check().map_err(|_| IoError::Cancelled)?; - let rows = (height - y).min(64); - for (reader, plane) in readers.iter_mut().zip(&mut planes) { - plane.clear(); - if let Some(reader) = reader { - for _ in 0..rows { - self.ctl.check().map_err(|_| IoError::Cancelled)?; - reader.read_row(&mut row)?; - plane.extend_from_slice(&row); - } - } - } - let refs: Vec> = readers.iter().zip(&planes).map(|(r, p)| r.as_ref().map(|_| p.as_slice())).collect(); - let mut bytes = interleave(&refs, &fill, width * rows, fmt.sample, &invert); - if fmt.mode == ColorMode::Lab && fmt.sample == SampleType::U16 { - crate::pixels::lab16_chroma(&mut bytes, ids.len(), true); - } - let top = i32::try_from(i64::from(r.top) + y as i64).map_err(|_| IoError::Unsupported("layer coordinates overflow".into()))?; - let bottom = top.checked_add(rows as i32).ok_or_else(|| IoError::Unsupported("layer coordinates overflow".into()))?; - let band = Rect::new(r.left, top, r.right, bottom); - let scratch = photocraft_raster::spill::enabled(); - require_scratch(self.scratch_expected)?; - if !scratch { - let missing = band.tiles().filter(|c| out.tile(*c).is_none()).count() as u64; - let growth = missing.saturating_mul((photocraft_geom::TILE_SIZE as u64).pow(2)).saturating_mul(fmt.bytes_per_pixel() as u64); - ram_tile_capacity(false, photocraft_raster::spill::stats().resident_bytes as u64, photocraft_raster::memory::stats().1.tile_bytes, growth)?; - } - out.write_interleaved(band, &bytes); - // A final band can trigger a scratch write failure too. - require_scratch(self.scratch_expected)?; - y += rows; - self.done = self.done.saturating_add(rows as u64 * ids.len() as u64); - self.ctl.progress(0.05 + 0.85 * self.done as f32 / self.total.max(1) as f32); - } - for reader in readers.into_iter().flatten() { - reader.finish()?; - } - out.prune(); - require_scratch(self.scratch_expected)?; - Ok(out) - } -} - -pub(crate) fn import(open: &dyn Fn() -> std::io::Result, index: &Index, ctl: Interrupt<'_>) -> Result { - let file = &index.metadata; - let mode = match file.header.color_mode { - photocraft_psd::ColorMode::Rgb => ColorMode::Rgb, - photocraft_psd::ColorMode::Grayscale => ColorMode::Grayscale, - photocraft_psd::ColorMode::Cmyk => ColorMode::Cmyk, - photocraft_psd::ColorMode::Lab => ColorMode::Lab, - _ => return Err(IoError::Unsupported("stream color mode".into())), - }; - let sample = crate::pixels::sample_for_depth(file.header.depth); - let fmt = PixelFormat::new(mode, sample, true); - let scratch_expected = photocraft_raster::spill::enabled(); - if !scratch_expected { - let budget = photocraft_raster::memory::refresh(); - let required = file.layers().iter().fold(0_u64, |sum, rec| { - let mut bytes = tile_footprint(rec.rect, fmt.bytes_per_pixel()); - for id in [-2, -3] { - if rec.channel(id).is_some() { - bytes = bytes.saturating_add(tile_footprint(rec.channel_rect(id), sample.bytes())); - } - } - sum.saturating_add(bytes) - }); - ram_tile_capacity(false, photocraft_raster::spill::stats().resident_bytes as u64, budget.tile_bytes, required)?; - } - let total = file.layers().iter().map(|r| r.channel_rect(0).size().map_or(0, |(_, h)| h as u64 * (mode.color_channels() as u64 + 1))).sum(); - let mut decoder = Decode { open, index, ctl, done: 0, total, scratch_expected }; - let mut surfaces = HashMap::new(); - for (i, rec) in file.layers().iter().enumerate() { - ctl.check().map_err(|_| IoError::Cancelled)?; - let key = rec as *const LayerRecord as usize; - let ids: Vec = (0..mode.color_channels() as i16).chain([-1]).collect(); - surfaces.insert((key, i16::MIN), decoder.surface(i, rec, &ids, fmt, &vec![0.0; fmt.channels()])?); - for id in [-2, -3] { - if rec.channel(id).is_some() { - let default = rec.layer_mask().map_or(0, |m| if id == -3 { m.real.map_or(m.default_color, |m| m.background) } else { m.default_color }); - let fmt = PixelFormat::new(ColorMode::Grayscale, sample, false); - surfaces.insert((key, id), decoder.surface(i, rec, &[id], fmt, &[f32::from(default) / 255.0])?); - } - } - } - let (document, warnings) = crate::psd_import::psd_to_document_decoded(file, &ctl, Some(&surfaces)).ok_or(IoError::Cancelled)?; - Ok(ImportResult { document, warnings, source_read_only: false, preview_only: false }) -} - -fn sink_error(e: std::io::Error) -> photocraft_psd::PsdError { - photocraft_psd::PsdError::Invalid(format!("stream output: {e}")) -} - -pub(crate) fn export_into(doc: &photocraft_doc::Document, out: &mut W, ctl: Interrupt<'_>) -> Result, IoError> { - if !matches!(doc.mode, ColorMode::Rgb | ColorMode::Grayscale | ColorMode::Cmyk | ColorMode::Lab) { - return Err(IoError::Unsupported("seekable PSB export requires RGB, Gray, CMYK or Lab".into())); - } - ctl.check().map_err(|_| IoError::Cancelled)?; - let before = photocraft_raster::spill::read_error_generation(); - let fmt = doc.pixel_format(); - let metadata: u64 = doc - .metadata - .psd_resources - .iter() - .map(|(_, _, bytes)| bytes.len() as u64) - .chain(doc.metadata.psd_global_blocks.iter().map(|(_, _, bytes)| bytes.len() as u64)) - .chain(doc.walk().iter().flat_map(|(_, _, l)| l.psd_blocks.iter().map(|(_, b)| b.len() as u64))) - .fold(0_u64, u64::saturating_add); - let embedded = doc - .walk() - .iter() - .filter_map(|(_, _, l)| match &l.content { - photocraft_doc::LayerContent::Smart(sm) => match &sm.source { - photocraft_doc::SmartSource::Embedded { bytes, .. } => Some(bytes.len() as u64), - _ => None, - }, - _ => None, - }) - .fold(0_u64, u64::saturating_add); - let working = u64::from(doc.size.width) - .saturating_mul(64) - .saturating_mul(128) - .saturating_add(64 << 20) - .saturating_add(metadata.saturating_mul(4)) - .saturating_add(embedded.saturating_mul(4)); - let _working = photocraft_raster::memory::try_reserve(working).map_err(IoError::Unsupported)?; - let opts = crate::psd_export::PsdExportOptions { force_psb: true, ..Default::default() }; - let (file, warnings, sources) = crate::psd_export::stream_metadata(doc, &opts); - let check = || -> photocraft_psd::Result<()> { - if ctl.cancelled() { - return Err(photocraft_psd::PsdError::Invalid("stream export cancelled".into())); - } - photocraft_raster::spill::check_since(before).map_err(photocraft_psd::PsdError::Invalid) - }; - let result = photocraft_psd::stream::write_layered( - &file, - out, - |i, id, out| { - check()?; - let rec = file.layers().get(i).ok_or_else(|| photocraft_psd::PsdError::Invalid("missing output record".into()))?; - let source = rec.layer_id().and_then(|id| sources.get(&id)).and_then(|(pixels, mask)| if id == -2 { mask.as_ref() } else { pixels.as_ref() }); - let r = rec.channel_rect(id); - if r.is_empty() { - return Ok(()); - } - let s = source.ok_or_else(|| photocraft_psd::PsdError::Invalid("missing output pixel source".into()))?; - let r = Rect::new(r.left, r.top, r.right, r.bottom); - let channels = s.format().channels(); - let channel = if id == -1 { channels.saturating_sub(1) } else { id.max(0) as usize }; - let mut invert = vec![s.format().mode == ColorMode::Cmyk; channels]; - if (id < 0 || s.format().alpha) - && let Some(a) = invert.last_mut() - { - *a = false; - } - let mut y = r.y0; - while y < r.y1 { - check()?; - let band = Rect::new(r.x0, y, r.x1, y.saturating_add(64).min(r.y1)); - let mut bytes = s.to_interleaved(band); - if s.format().mode == ColorMode::Lab && s.format().sample == SampleType::U16 { - crate::pixels::lab16_chroma(&mut bytes, channels, false); - } - let planes = crate::pixels::deinterleave(&bytes, channels, s.format().sample, &invert); - out.write_all(planes.get(channel).ok_or_else(|| photocraft_psd::PsdError::Invalid("invalid output channel".into()))?).map_err(sink_error)?; - y = band.y1; - } - Ok(()) - }, - |out| { - check()?; - let start = out.stream_position().map_err(sink_error)?; - let row_bytes = u64::from(doc.size.width) * fmt.sample.bytes() as u64; - let plane_bytes = - row_bytes.checked_mul(u64::from(doc.size.height)).ok_or_else(|| photocraft_psd::PsdError::Invalid("output size overflow".into()))?; - let cc = fmt.mode.color_channels(); - let white = photocraft_raster::from_rgba(&fmt, [1.0; 4]); - let space = photocraft_compose::cmyk_space(doc); - photocraft_compose::render_bands(doc, doc.bounds(), 64, |band| -> photocraft_psd::Result<()> { - check()?; - let mut planes: Vec> = (0..=cc).map(|_| Vec::with_capacity(band.px.len() * fmt.sample.bytes())).collect(); - photocraft_color::convert::with_cmyk_space(space.as_ref(), || { - let mut v = [0.0; 5]; - for p in &band.px { - photocraft_raster::from_rgba_into(&fmt, *p, &mut v); - for (c, plane) in planes.iter_mut().enumerate() { - let value = v.get(c).copied().unwrap_or(0.0); - let value = if c < cc { - crate::pixels::matte(value, v.get(cc).copied().unwrap_or(0.0), white.get(c).copied().unwrap_or(1.0)) - } else { - value - }; - let value = if fmt.mode == ColorMode::Cmyk && c < cc { 1.0 - value } else { value }; - let value = if fmt.mode == ColorMode::Lab && fmt.sample == SampleType::U16 && matches!(c, 1 | 2) { - value * crate::pixels::LAB16_CHROMA_MAX / 65535.0 - } else { - value - }; - crate::pixels::encode_be(value, fmt.sample, plane); - } - } - }); - for (c, bytes) in planes.iter().enumerate() { - let offset = start - .checked_add(c as u64 * plane_bytes) - .and_then(|v| v.checked_add(band.rect.y0 as u64 * row_bytes)) - .ok_or_else(|| photocraft_psd::PsdError::Invalid("output offset overflow".into()))?; - out.seek(SeekFrom::Start(offset)).map_err(sink_error)?; - out.write_all(bytes).map_err(sink_error)?; - } - ctl.progress(0.5 + 0.5 * band.rect.y1 as f32 / doc.size.height.max(1) as f32); - Ok(()) - })?; - for (i, channel) in doc.channels.iter().chain(doc.quick_mask.iter()).take(usize::from(file.header.channels).saturating_sub(cc + 1)).enumerate() { - let index = cc + 1 + i; - let mask_fmt = photocraft_color::PixelFormat::new(ColorMode::Grayscale, fmt.sample, false); - let converted = (channel.surface.format() != mask_fmt).then(|| channel.surface.convert(mask_fmt)); - let surface = converted.as_ref().unwrap_or(&channel.surface); - let mut y = 0; - while y < doc.size.height { - check()?; - let end = y.saturating_add(64).min(doc.size.height); - let r = Rect::from_xywh(0, y as i32, doc.size.width, end - y); - let bytes = surface.to_interleaved(r); - let plane = crate::pixels::deinterleave(&bytes, 1, surface.format().sample, &[false]); - out.seek(SeekFrom::Start(start + index as u64 * plane_bytes + u64::from(y) * row_bytes)).map_err(sink_error)?; - out.write_all(plane.first().ok_or_else(|| photocraft_psd::PsdError::Invalid("missing extra output plane".into()))?).map_err(sink_error)?; - y = end; - } - } - let end = start - .checked_add(u64::from(file.header.channels).saturating_mul(plane_bytes)) - .ok_or_else(|| photocraft_psd::PsdError::Invalid("output size overflow".into()))?; - out.seek(SeekFrom::Start(end)).map_err(sink_error)?; - check() - }, - ); - if ctl.cancelled() { - return Err(IoError::Cancelled); - } - result?; - photocraft_raster::spill::check_since(before).map_err(IoError::Unsupported)?; - Ok(warnings) -} - -#[cfg(test)] -mod memory_tests { - use super::*; - use photocraft_psd::{Compression, Version}; - use std::io::Cursor; - - #[test] - fn tile_footprint_counts_partial_negative_tiles_at_each_depth() { - let side = photocraft_geom::TILE_SIZE as u64; - for bpp in [1, 2, 4, 8, 16, 20] { - let rect = photocraft_psd::Rect::from_xywh(-1, -1, 2, 2); - assert_eq!(tile_footprint(rect, bpp), 4 * side * side * bpp as u64); - let aligned = photocraft_psd::Rect::from_xywh(-(side as i32), 0, side as u32, side as u32); - assert_eq!(tile_footprint(aligned, bpp), side * side * bpp as u64); - } - assert_eq!(tile_footprint(photocraft_psd::Rect::default(), 16), 0); - let hostile = photocraft_psd::Rect { top: i32::MIN, left: i32::MIN, bottom: i32::MAX, right: i32::MAX }; - assert_eq!(tile_footprint(hostile, 16), u64::MAX); - } - - #[test] - fn ram_only_admission_accounts_for_live_tiles_and_overflow() { - assert!(ram_tile_capacity(false, 60, 100, 40).is_ok()); - assert!(ram_tile_capacity(false, 60, 100, 41).is_err()); - assert!(ram_tile_capacity(false, u64::MAX, u64::MAX, 1).is_err()); - assert!(ram_tile_capacity(true, 60, 100, 1000).is_ok()); - } - - #[test] - fn oversized_ram_only_import_is_rejected_before_opening_channels() { - assert!(!photocraft_raster::spill::enabled()); - let file = photocraft_psd::testgen::layered(Version::Psb, photocraft_psd::ColorMode::Rgb, 32, Compression::Raw); - let bytes = file.to_bytes().unwrap(); - let mut index = Index::read(&mut Cursor::new(&bytes), 128 << 20, &|| false).unwrap(); - // Valid dimensions would need over a terabyte; the source remains a tiny fixture. - index.metadata.layers_mut()[0].rect = photocraft_psd::Rect::from_xywh(0, 0, 300_000, 300_000); - let opened = std::cell::Cell::new(0); - let result = import( - &|| { - opened.set(opened.get() + 1); - Ok(Cursor::new(&bytes)) - }, - &index, - Interrupt::NONE, - ); - assert!(matches!(result, Err(IoError::Unsupported(ref message)) if message.contains("enable a scratch disk"))); - assert_eq!(opened.get(), 0); - } - - #[test] - fn missing_scratch_is_not_silently_treated_as_ram_only() { - assert!(!photocraft_raster::spill::enabled()); - assert!(require_scratch(false).is_ok()); - assert!(require_scratch(true).is_err()); - } -} diff --git a/crates/io/tests/streaming_exr.rs b/crates/io/tests/streaming_exr.rs deleted file mode 100644 index c9a6dae4fe..0000000000 --- a/crates/io/tests/streaming_exr.rs +++ /dev/null @@ -1,94 +0,0 @@ -#![cfg(not(target_arch = "wasm32"))] -use photocraft_codecs::{ChannelLayout, EncodeOptions, ExrCompression, Format, Image, SampleType}; -use photocraft_doc::LayerContent; -use photocraft_raster::Interrupt; -use std::{ - io::Cursor, - sync::atomic::{AtomicBool, Ordering}, -}; - -fn fixture(layout: ChannelLayout, sample: SampleType, compression: ExrCompression) -> Vec { - let values: Vec = (0..31 * 35 * layout.channels()).map(|i| [-0.25, 4.0, 0.0, 0.0001, 0.5][i % 5]).collect(); - let image = Image::from_f32(31, 35, layout, &values).unwrap().convert(layout, sample); - photocraft_codecs::encode(&image, Format::OpenExr, &EncodeOptions { exr_compression: compression, ..Default::default() }).unwrap() -} - -// One test keeps these imports from competing for the global working allowance. -#[test] -fn native_scanline_import_preserves_hdr_document_and_cancels_without_publication() { - let path = std::env::temp_dir().join(format!("photocraft-exr-stream-{}.exr", std::process::id())); - for layout in [ChannelLayout::Gray, ChannelLayout::GrayA, ChannelLayout::Rgb, ChannelLayout::Rgba] { - for sample in [SampleType::F16, SampleType::F32] { - for compression in [ExrCompression::None, ExrCompression::Rle, ExrCompression::Zip1, ExrCompression::Zip16, ExrCompression::Piz] { - let bytes = fixture(layout, sample, compression); - std::fs::write(&path, &bytes).unwrap(); - let reference = photocraft_io::import("HDR", &bytes).unwrap(); - let native = photocraft_io::import_path_with("HDR", &path, &Interrupt::NONE).unwrap(); - let authorized = photocraft_io::import_seekable_with("HDR", &|| Ok(Cursor::new(&bytes)), &Interrupt::NONE).unwrap(); - for actual in [native, authorized] { - let a = &actual.document; - let r = &reference.document; - assert_eq!(actual.warnings, reference.warnings); - assert_eq!(a.name, r.name); - assert_eq!(a.size, r.size); - assert_eq!(a.mode, r.mode); - assert_eq!(a.depth, photocraft_color::SampleType::F32); - assert_eq!(a.icc_profile, r.icc_profile); - assert_eq!(a.layers[0].locks, r.layers[0].locks); - let (LayerContent::Raster(a), LayerContent::Raster(r)) = (&a.layers[0].content, &r.layers[0].content) else { - panic!("raster background required") - }; - assert_eq!( - a.to_interleaved(reference.document.bounds()), - r.to_interleaved(reference.document.bounds()), - "{layout:?}/{sample:?}/{compression:?}" - ); - } - } - } - } - let bytes = fixture(ChannelLayout::Rgb, SampleType::F16, ExrCompression::Zip16); - std::fs::write(&path, &bytes).unwrap(); - let cancelled = AtomicBool::new(false); - let cancel = || cancelled.load(Ordering::Relaxed); - let progress = |p| { - if p > 0.2 { - cancelled.store(true, Ordering::Relaxed); - } - }; - assert!(matches!(photocraft_io::import_path_with("HDR", &path, &Interrupt::new(&cancel, &progress)), Err(photocraft_io::IoError::Cancelled))); - std::fs::write(&path, &bytes[..bytes.len() / 2]).unwrap(); - assert!(photocraft_io::import_path_with("HDR", &path, &Interrupt::NONE).is_err()); - // A syntactically complete huge header/table must be refused before reading - // its pixel blocks when scratch is unavailable. No giant fixture is needed. - assert!(!photocraft_raster::spill::enabled()); - let mut header_end = 8; - loop { - if bytes[header_end] == 0 { - header_end += 1; - break; - } - for _ in 0..2 { - while bytes[header_end] != 0 { - header_end += 1; - } - header_end += 1; - } - let length = i32::from_le_bytes(bytes[header_end..header_end + 4].try_into().unwrap()) as usize; - header_end += 4 + length; - } - let mut huge = bytes[..header_end].to_vec(); - let needle = b"dataWindow\0box2i\0\x10\0\0\0"; - let at = huge.windows(needle.len()).position(|v| v == needle).unwrap() + needle.len(); - huge[at + 8..at + 12].copy_from_slice(&65535_i32.to_le_bytes()); - huge[at + 12..at + 16].copy_from_slice(&32767_i32.to_le_bytes()); - let offset = (header_end + 2048 * 8) as u64; - for _ in 0..2048 { - huge.extend_from_slice(&offset.to_le_bytes()); - } - huge.extend_from_slice(&[0; 8]); - assert!( - matches!(photocraft_io::import_seekable_with("huge", &|| Ok(Cursor::new(&huge)), &Interrupt::NONE), Err(photocraft_io::IoError::Unsupported(ref message)) if message.contains("decoded image tiles")) - ); - std::fs::remove_file(path).unwrap(); -} diff --git a/crates/io/tests/streaming_psd.rs b/crates/io/tests/streaming_psd.rs deleted file mode 100644 index 0a8038b3ac..0000000000 --- a/crates/io/tests/streaming_psd.rs +++ /dev/null @@ -1,136 +0,0 @@ -#![cfg(not(target_arch = "wasm32"))] -use photocraft_psd::{ColorMode, Compression, Version}; -use photocraft_raster::Interrupt; - -// Native imports and exports reserve the same process-wide working-memory allowance. -// Keep fidelity fixtures from rejecting each other solely because the test runner is parallel. -static WORKING_MEMORY_TESTS: std::sync::Mutex<()> = std::sync::Mutex::new(()); - -#[test] -fn seekable_import_matches_byte_import_pixels_masks_and_metadata() { - let _test = WORKING_MEMORY_TESTS.lock().unwrap(); - let path = std::env::temp_dir().join(format!("photocraft-stream-parity-{}.psb", std::process::id())); - for version in [Version::Psd, Version::Psb] { - for mode in [ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Cmyk, ColorMode::Lab] { - for depth in [8, 16, 32] { - for compression in Compression::ALL { - let mut file = photocraft_psd::testgen::layered(version, mode, depth, compression); - // The streaming layer path need not consume the stored merged image. - file.header.channels = mode.color_channels().unwrap() + u16::from(file.merged_has_alpha()); - let bytes = file.to_bytes().unwrap(); - std::fs::write(&path, &bytes).unwrap(); - let a = photocraft_io::import("d", &bytes).unwrap(); - let b = photocraft_io::import_path_with("d", &path, &Interrupt::NONE).unwrap(); - let authorized = photocraft_io::import_seekable_with("d", &|| std::fs::File::open(&path), &Interrupt::NONE).unwrap(); - assert_eq!( - photocraft_compose::render(&authorized.document, authorized.document.bounds()).px, - photocraft_compose::render(&b.document, b.document.bounds()).px - ); - assert_eq!(a.warnings, b.warnings, "{version:?}/{mode:?}/{depth}/{compression:?}"); - let aa = photocraft_compose::render(&a.document, a.document.bounds()); - let bb = photocraft_compose::render(&b.document, b.document.bounds()); - assert_eq!(aa.px, bb.px, "{version:?}/{mode:?}/{depth}/{compression:?}"); - let options = photocraft_io::PsdExportOptions::default(); - let (a, _) = photocraft_io::document_to_psd_with(&a.document, &options); - let (b, _) = photocraft_io::document_to_psd_with(&b.document, &options); - assert_eq!(a, b, "layer, mask and unknown-block round trip: {version:?}/{mode:?}/{depth}/{compression:?}"); - } - } - } - } - std::fs::remove_file(path).unwrap(); -} - -#[test] -fn cancelled_and_truncated_imports_publish_no_document() { - let _test = WORKING_MEMORY_TESTS.lock().unwrap(); - let path = std::env::temp_dir().join(format!("photocraft-stream-cancel-{}.psb", std::process::id())); - let mut file = photocraft_psd::testgen::layered(Version::Psb, ColorMode::Rgb, 8, Compression::Zip); - file.header.channels = 3 + u16::from(file.merged_has_alpha()); - let bytes = file.to_bytes().unwrap(); - std::fs::write(&path, &bytes).unwrap(); - assert!(matches!(photocraft_io::import_path_with("d", &path, &Interrupt::cancel_only(&|| true)), Err(photocraft_io::IoError::Cancelled))); - std::fs::write(&path, &bytes[..bytes.len() / 2]).unwrap(); - assert!(photocraft_io::import_path_with("d", &path, &Interrupt::NONE).is_err()); - std::fs::remove_file(path).unwrap(); -} - -#[test] -fn seekable_export_preserves_merged_layers_masks_and_unknown_metadata() { - let _test = WORKING_MEMORY_TESTS.lock().unwrap(); - let path = std::env::temp_dir().join(format!("photocraft-export-parity-{}.psb", std::process::id())); - for mode in [ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Cmyk, ColorMode::Lab] { - for depth in [8, 16, 32] { - let input = photocraft_psd::testgen::layered(Version::Psb, mode, depth, Compression::ZipPrediction); - let doc = photocraft_io::import("input", &input.to_bytes().unwrap()).unwrap().document; - let (reference, _) = photocraft_io::document_to_psd_with(&doc, &photocraft_io::PsdExportOptions { force_psb: true, ..Default::default() }); - photocraft_io::export_path_with(&doc, &path, &Default::default(), &Interrupt::NONE).unwrap(); - let output = photocraft_psd::PsdFile::from_bytes(&std::fs::read(&path).unwrap()).unwrap(); - assert_eq!(output.header, reference.header, "{mode:?}/{depth}"); - assert_eq!(output.resources, reference.resources); - let normalized_reference = photocraft_psd::PsdFile::from_bytes(&reference.to_bytes().unwrap()).unwrap(); - assert_eq!(output.global_blocks, normalized_reference.global_blocks); - assert_eq!(output.image_data.decode(&output.header).unwrap(), reference.image_data.decode(&reference.header).unwrap(), "merged {mode:?}/{depth}"); - let loaded = photocraft_io::import_path_with("output", &path, &Interrupt::NONE).unwrap(); - let (loaded, _) = photocraft_io::document_to_psd_with(&loaded.document, &photocraft_io::PsdExportOptions { force_psb: true, ..Default::default() }); - assert_eq!(loaded.to_bytes().unwrap(), reference.to_bytes().unwrap(), "layer/mask/metadata {mode:?}/{depth}"); - } - } - std::fs::remove_file(path).unwrap(); -} - -#[test] -fn cancelled_stream_save_keeps_destination_and_removes_temporary_output() { - let _test = WORKING_MEMORY_TESTS.lock().unwrap(); - let dir = std::env::temp_dir().join(format!("photocraft-export-cancel-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("kept.psb"); - std::fs::write(&path, b"precious original").unwrap(); - let input = photocraft_psd::testgen::layered(Version::Psb, ColorMode::Rgb, 8, Compression::Raw); - let doc = photocraft_io::import("input", &input.to_bytes().unwrap()).unwrap().document; - let calls = std::sync::atomic::AtomicUsize::new(0); - let cancel = || calls.fetch_add(1, std::sync::atomic::Ordering::Relaxed) > 5; - assert!(matches!( - photocraft_io::export_path_with(&doc, &path, &Default::default(), &Interrupt::cancel_only(&cancel)), - Err(photocraft_io::IoError::Cancelled) - )); - assert_eq!(std::fs::read(&path).unwrap(), b"precious original"); - assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1); - std::fs::remove_dir_all(dir).unwrap(); -} - -#[cfg(feature = "corpus")] -#[test] -fn native_path_import_matches_byte_import_across_real_psd_corpus() { - let _test = WORKING_MEMORY_TESTS.lock().unwrap(); - fn collect(p: &std::path::Path, out: &mut Vec) { - for e in std::fs::read_dir(p).unwrap().flatten() { - let p = e.path(); - if p.is_dir() { - collect(&p, out); - } else if p.extension().is_some_and(|x| x.eq_ignore_ascii_case("psd") || x.eq_ignore_ascii_case("psb")) { - out.push(p); - } - } - } - let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/psd"); - assert!(root.is_dir(), "run cargo xtask corpus --all"); - let mut files = Vec::new(); - collect(&root, &mut files); - let mut compared = 0; - for path in files { - let bytes = std::fs::read(&path).unwrap(); - let Ok(a) = photocraft_io::import("d", &bytes) else { continue }; - let b = photocraft_io::import_path_with("d", &path, &Interrupt::NONE).unwrap_or_else(|e| panic!("{}: {e}", path.display())); - assert_eq!(a.warnings, b.warnings, "{}", path.display()); - assert_eq!( - photocraft_compose::render(&a.document, a.document.bounds()).px, - photocraft_compose::render(&b.document, b.document.bounds()).px, - "{}", - path.display() - ); - compared += 1; - } - assert!(compared >= 20, "{compared} corpus files compared"); - eprintln!("seekable corpus parity: {compared} files"); -} diff --git a/crates/io/tests/svg.rs b/crates/io/tests/svg.rs index fd56d35b86..d1baf418fa 100644 --- a/crates/io/tests/svg.rs +++ b/crates/io/tests/svg.rs @@ -93,23 +93,6 @@ fn svg_group_rasterization_threshold_is_configurable_and_clamped() { let LayerContent::Group(outer) = &unlimited.document.layers[0].content else { panic!("outer group should remain editable") }; assert!(outer.children.iter().any(|child| matches!(child.content, LayerContent::Group(_))), "zero disables the preference threshold"); - #[cfg(not(target_arch = "wasm32"))] - { - let path = std::env::temp_dir().join(format!("photocraft-svg-depth-{}.svg", std::process::id())); - std::fs::write(&path, source.as_bytes()).unwrap(); - for (depth, reference) in [(1, &shallow), (0, &unlimited)] { - let seekable = photocraft_io::import_path_with_svg_group_depth("nested.svg", &path, depth, &photocraft_raster::Interrupt::NONE).unwrap(); - assert_eq!(seekable.warnings, reference.warnings); - let LayerContent::Group(outer) = &seekable.document.layers[0].content else { panic!("outer group should remain editable") }; - assert!(outer.children.iter().any(|child| if depth == 1 { - matches!(child.content, LayerContent::Raster(_)) - } else { - matches!(child.content, LayerContent::Group(_)) - })); - } - std::fs::remove_file(path).unwrap(); - } - let clamped = import_with_svg_group_depth("nested.svg", source.as_bytes(), usize::MAX).unwrap(); assert!(matches!(clamped.document.layers[0].content, LayerContent::Group(_)), "out-of-range settings clamp to the document's hard group limit"); } diff --git a/crates/psd/src/layer.rs b/crates/psd/src/layer.rs index 42036a994b..5450ed7e67 100644 --- a/crates/psd/src/layer.rs +++ b/crates/psd/src/layer.rs @@ -544,7 +544,7 @@ impl LayerRecord { ch.decode(w, h, depth, version) } - pub(crate) fn read(r: &mut Reader<'_>, version: Version) -> Result<(Self, Vec)> { + fn read(r: &mut Reader<'_>, version: Version) -> Result<(Self, Vec)> { let rect = Rect::read(r)?; let nch = r.u16()?; if nch > MAX_LAYER_CHANNELS { @@ -586,7 +586,7 @@ impl LayerRecord { Ok((LayerRecord { rect, channels, blend_mode, opacity, clipping, flags, filler, mask, blending_ranges, name, blocks, extra_trailing }, lens)) } - pub(crate) fn write(&self, out: &mut Vec, version: Version) -> Result<()> { + fn write(&self, out: &mut Vec, version: Version) -> Result<()> { self.rect.write(out); let nch = u16::try_from(self.channels.len()).map_err(|_| PsdError::LimitExceeded("too many channels"))?; out.put_u16(nch); diff --git a/crates/psd/src/lib.rs b/crates/psd/src/lib.rs index 32efb0267c..00d73c4003 100644 --- a/crates/psd/src/lib.rs +++ b/crates/psd/src/lib.rs @@ -64,7 +64,6 @@ pub mod pixels; pub mod resources; pub mod shc; pub mod slices; -pub mod stream; pub mod tagged; #[cfg(any(test, feature = "testgen"))] // Test-data generator (tests and fuzz seeds only): failing loudly is the point. diff --git a/crates/psd/src/stream.rs b/crates/psd/src/stream.rs deleted file mode 100644 index 3023b5750a..0000000000 --- a/crates/psd/src/stream.rs +++ /dev/null @@ -1,499 +0,0 @@ -//! Seekable PSD/PSB metadata and row decoding. Pixel ranges stay in the source; no whole-file -//! or whole-channel buffer is created. `metadata` deliberately contains no encoded pixels. -use std::io::{Read, Seek, SeekFrom, Take, Write}; - -use crate::image_data::ImageData; -use crate::io::Reader; -use crate::{Compression, GlobalLayerMask, Header, LayerInfo, LayerInfoPlacement, LayerRecord, PlaneLayout, PsdError, PsdFile, Result, TaggedBlock, Version}; - -fn io(e: std::io::Error) -> PsdError { - PsdError::invalid(format!("stream I/O: {e}")) -} -fn invalid() -> PsdError { - PsdError::invalid("invalid stream range") -} -fn array(r: &mut impl Read) -> Result<[u8; N]> { - let mut b = [0; N]; - r.read_exact(&mut b).map_err(io)?; - Ok(b) -} -fn length(r: &mut impl Read, long: bool) -> Result { - Ok(if long { u64::from_be_bytes(array(r)?) } else { u64::from(u32::from_be_bytes(array(r)?)) }) -} - -#[derive(Clone, Copy, Debug)] -/// One checked encoded layer-channel range, excluding its compression marker. -pub struct ChannelRange { - /// Photoshop channel id (negative for transparency and masks). - pub id: i16, - /// Absolute byte offset in the source. - pub offset: u64, - /// Encoded byte count. - pub length: u64, - /// Channel codec. - pub compression: Compression, -} - -/// Metadata and independent source ranges; encoded pixels are never retained in this model. -pub struct Index { - /// Parsed metadata only. Do not serialize this model: pixel channels are in `channels`. - pub metadata: PsdFile, - /// Ranges per layer record, in stored order. - pub channels: Vec>, -} - -struct Scan<'a, R> { - reader: &'a mut R, - allowance: u64, - cancelled: &'a dyn Fn() -> bool, -} -impl Scan<'_, R> { - fn position(&mut self) -> Result { - self.reader.stream_position().map_err(io) - } - fn end(&mut self, length: u64, boundary: u64) -> Result { - self.position()?.checked_add(length).filter(|end| *end <= boundary).ok_or_else(invalid) - } - fn bytes(&mut self, n: u64, boundary: u64) -> Result> { - self.end(n, boundary)?; - self.allowance = self.allowance.checked_sub(n).ok_or(PsdError::LimitExceeded("stream metadata exceeds working-memory budget"))?; - let n = usize::try_from(n).map_err(|_| invalid())?; - let mut out = Vec::new(); - out.try_reserve_exact(n).map_err(|_| PsdError::LimitExceeded("stream metadata allocation"))?; - out.resize(n, 0); - for chunk in out.chunks_mut(64 << 10) { - if (self.cancelled)() { - return Err(PsdError::invalid("stream cancelled")); - } - self.reader.read_exact(chunk).map_err(io)?; - } - Ok(out) - } - fn layers(&mut self, end: u64, version: Version) -> Result<(LayerInfo, Vec>)> { - let start = self.position()?; - let count = i16::from_be_bytes(array(self.reader)?); - let n = usize::from(count.unsigned_abs()); - if (n as u64).saturating_mul(34) > end.saturating_sub(self.position()?) { - return Err(invalid()); - } - let mut layers = Vec::with_capacity(n); - let mut lengths = Vec::with_capacity(n); - for _ in 0..n { - let mut record = self.bytes(18, end)?; - let nch = u16::from_be_bytes(record.get(16..18).ok_or_else(invalid)?.try_into().map_err(|_| invalid())?); - if nch > crate::layer::MAX_LAYER_CHANNELS { - return Err(PsdError::LimitExceeded("too many layer channels")); - } - let fixed = u64::from(nch) * if version.is_psb() { 10 } else { 6 } + 16; - record.extend(self.bytes(fixed, end)?); - let extra = record.get(record.len().saturating_sub(4)..).ok_or_else(invalid)?; - let extra = u32::from_be_bytes(extra.try_into().map_err(|_| invalid())?); - record.extend(self.bytes(u64::from(extra), end)?); - let (rec, lens) = LayerRecord::read(&mut Reader::new(&record), version)?; - layers.push(rec); - lengths.push(lens); - } - let mut ranges = Vec::with_capacity(n); - for (rec, lens) in layers.iter_mut().zip(lengths) { - let mut channels = Vec::new(); - for (ch, len) in rec.channels.iter_mut().zip(lens) { - if len == 0 { - continue; - } - if len < 2 { - return Err(invalid()); - } - let channel_end = self.end(len, end)?; - let compression = Compression::from_u16(u16::from_be_bytes(array(self.reader)?)); - ch.compression = Some(compression); - channels.push(ChannelRange { id: ch.id, offset: self.position()?, length: len - 2, compression }); - self.reader.seek(SeekFrom::Start(channel_end)).map_err(io)?; - } - ranges.push(channels); - } - let body = self.position()?.saturating_sub(start); - let rest = end.saturating_sub(self.position()?); - let pad = self.bytes(rest, end)?; - let padding = if pad.len() as u64 == body % 2 && pad.iter().all(|b| *b == 0) { None } else { Some(pad) }; - Ok((LayerInfo { merged_alpha: count < 0, layers, padding }, ranges)) - } -} - -impl Index { - /// Index a seekable file with an aggregate metadata cap and cooperative cancellation. - pub fn read(reader: &mut R, metadata_budget: u64, cancelled: &dyn Fn() -> bool) -> Result { - let end = reader.seek(SeekFrom::End(0)).map_err(io)?; - reader.seek(SeekFrom::Start(0)).map_err(io)?; - let header = Header::read(&mut Reader::new(&array::<26>(reader)?))?; - let mut s = Scan { reader, allowance: metadata_budget, cancelled }; - let n = length(s.reader, false)?; - let color_mode_data = s.bytes(n, end)?; - let n = length(s.reader, false)?; - let resources = s.bytes(n, end)?; - let mut resource_bytes = (n as u32).to_be_bytes().to_vec(); - resource_bytes.extend(resources); - let resources = crate::resources::read_section(&mut Reader::new(&resource_bytes))?; - drop(resource_bytes); - let lm_len = length(s.reader, header.version.is_psb())?; - let lm_end = s.end(lm_len, end)?; - let mut layer_info = None; - let mut channels = Vec::new(); - let mut global_layer_mask = None; - let mut global_blocks = Vec::new(); - let mut layer_mask_trailing = Vec::new(); - let mut layer_info_placement = LayerInfoPlacement::Section; - if lm_len > 0 { - let n = length(s.reader, header.version.is_psb())?; - let li_end = s.end(n, lm_end)?; - if n > 0 { - let (info, c) = s.layers(li_end, header.version)?; - layer_info = Some(info); - channels = c; - } - if lm_end.saturating_sub(s.position()?) >= 4 { - let n = length(s.reader, false)?; - global_layer_mask = Some(GlobalLayerMask { data: s.bytes(n, lm_end)? }); - while lm_end.saturating_sub(s.position()?) >= 12 { - let start = s.position()?; - let signature = array::<4>(s.reader)?; - if signature != *b"8BIM" && signature != *b"8B64" { - s.reader.seek(SeekFrom::Start(start)).map_err(io)?; - break; - } - let key = array::<4>(s.reader)?; - let n = length(s.reader, crate::tagged::uses_long_length(header.version, &key))?; - let block_end = s.end(n, lm_end)?; - let is_layers = layer_info.is_none() && matches!(&key, b"Lr16" | b"Lr32" | b"Layr"); - let data = if is_layers { - let (info, c) = s.layers(block_end, header.version)?; - layer_info = Some(info); - channels = c; - Vec::new() - } else { - s.bytes(n, lm_end)? - }; - // Same padding detector as the byte parser, without reading the next block. - let at = s.position()?; - let rest = lm_end.saturating_sub(at); - let peek = s.bytes(rest.min(7), lm_end)?; - s.reader.seek(SeekFrom::Start(at)).map_err(io)?; - let pad = (0..=3usize) - .find(|k| *k as u64 == rest || peek.get(*k..).is_some_and(|b| b.starts_with(b"8BIM") || b.starts_with(b"8B64"))) - .unwrap_or(0); - let bytes = s.bytes(pad as u64, lm_end)?; - let padding = if bytes.len() as u64 == (4 - n % 4) % 4 && bytes.iter().all(|b| *b == 0) { None } else { Some(bytes) }; - if is_layers { - layer_info_placement = LayerInfoPlacement::GlobalBlock { index: global_blocks.len(), signature, key, padding }; - } else { - global_blocks.push(TaggedBlock { signature, key, data, padding }); - } - } - } - let left = lm_end.saturating_sub(s.position()?); - layer_mask_trailing = s.bytes(left, lm_end)?; - } - // The merged image is intentionally not read: layered imports below consume only layers. - let compression = Compression::from_u16(u16::from_be_bytes(array(s.reader)?)); - Ok(Self { - metadata: PsdFile { - header, - color_mode_data, - resources, - layer_info, - layer_info_placement, - global_layer_mask, - global_blocks, - layer_mask_trailing, - image_data: ImageData { compression, data: Vec::new() }, - }, - channels, - }) - } -} - -enum Input { - Raw(Take), - Rle(Take, Vec), - Zip(flate2::read::ZlibDecoder>), -} -/// A sequential channel decoder with bounded row buffers, including ZIP prediction. -pub struct Rows { - input: Input, - layout: PlaneLayout, - prediction: bool, - row: usize, - encoded: Vec, -} -impl Rows { - /// Open a checked range on an independent reader. Clone file handles with shared seek - /// positions must not be used for concurrent channels. - pub fn new(mut reader: R, range: ChannelRange, width: usize, height: usize, depth: u16, version: Version) -> Result { - if width > 300_000 || height > 300_000 || !matches!(depth, 1 | 8 | 16 | 32) { - return Err(PsdError::LimitExceeded("stream channel dimensions")); - } - reader.seek(SeekFrom::Start(range.offset)).map_err(io)?; - let mut source = reader.take(range.length); - let layout = PlaneLayout { planes: 1, width, height, depth, version }; - let input = match range.compression { - Compression::Raw => Input::Raw(source), - Compression::Rle => { - let mut counts = Vec::with_capacity(height); - let mut total = (height as u64).checked_mul(if version.is_psb() { 4 } else { 2 }).ok_or_else(invalid)?; - for _ in 0..height { - let n = - if version.is_psb() { u32::from_be_bytes(array(&mut source)?) as usize } else { usize::from(u16::from_be_bytes(array(&mut source)?)) }; - if n > layout.row_bytes().saturating_mul(2).saturating_add(1024) { - return Err(PsdError::LimitExceeded("encoded RLE row")); - } - total = total.checked_add(n as u64).ok_or_else(invalid)?; - counts.push(n); - } - if total > range.length { - return Err(invalid()); - } - Input::Rle(source, counts) - } - Compression::Zip | Compression::ZipPrediction => Input::Zip(flate2::read::ZlibDecoder::new(source)), - Compression::Unknown(n) => return Err(PsdError::Unsupported(format!("stream compression {n}"))), - }; - Ok(Self { input, layout, prediction: range.compression == Compression::ZipPrediction, row: 0, encoded: Vec::new() }) - } - /// Decode the next native big-endian sample row, reusing `out`. - pub fn read_row(&mut self, out: &mut Vec) -> Result<()> { - if self.row >= self.layout.height { - return Err(invalid()); - } - let bytes = self.layout.row_bytes(); - out.resize(bytes, 0); - match &mut self.input { - Input::Raw(r) => r.read_exact(out).map_err(io)?, - Input::Zip(r) => r.read_exact(out).map_err(io)?, - Input::Rle(r, counts) => { - out.clear(); - self.encoded.resize(*counts.get(self.row).ok_or_else(invalid)?, 0); - r.read_exact(&mut self.encoded).map_err(io)?; - crate::compression::packbits::decode_into(&self.encoded, bytes, out)?; - } - } - if self.prediction { - let row = PlaneLayout { height: 1, ..self.layout }; - crate::compression::unpredict(out, &row)?; - } - self.row += 1; - Ok(()) - } - /// Checks the zlib checksum and rejects extra decoded samples after the expected last row. - pub fn finish(mut self) -> Result<()> { - if self.row != self.layout.height { - return Err(invalid()); - } - let mut extra = [0; 1]; - let n = match &mut self.input { - Input::Raw(r) | Input::Rle(r, _) => r.read(&mut extra), - Input::Zip(r) => r.read(&mut extra), - } - .map_err(io)?; - if n != 0 { - return Err(PsdError::invalid("extra stream channel samples")); - } - Ok(()) - } -} - -fn patch(out: &mut W, at: u64, value: u64) -> Result<()> { - let end = out.stream_position().map_err(io)?; - out.seek(SeekFrom::Start(at)).map_err(io)?; - out.write_all(&value.to_be_bytes()).map_err(io)?; - out.seek(SeekFrom::Start(end)).map_err(io)?; - Ok(()) -} - -fn write_layers(out: &mut W, info: &LayerInfo, pixels: &mut impl FnMut(usize, i16, &mut W) -> Result<()>) -> Result<()> { - let start = out.stream_position().map_err(io)?; - let count = i16::try_from(info.layers.len()).map_err(|_| PsdError::LimitExceeded("too many streamed layers"))?; - out.write_all(&(if info.merged_alpha { -count } else { count }).to_be_bytes()).map_err(io)?; - let mut records = Vec::with_capacity(info.layers.len()); - for rec in &info.layers { - records.push(out.stream_position().map_err(io)?); - let mut encoded = Vec::new(); - rec.write(&mut encoded, Version::Psb)?; - out.write_all(&encoded).map_err(io)?; - } - for (i, rec) in info.layers.iter().enumerate() { - for (c, channel) in rec.channels.iter().enumerate() { - let begin = out.stream_position().map_err(io)?; - if let Some(compression) = channel.compression { - out.write_all(&compression.as_u16().to_be_bytes()).map_err(io)?; - pixels(i, channel.id, out)?; - } - let len = out.stream_position().map_err(io)?.saturating_sub(begin); - let at = records.get(i).copied().ok_or_else(invalid)?.checked_add(20 + c as u64 * 10).ok_or_else(invalid)?; - patch(out, at, len)?; - } - } - let length = out.stream_position().map_err(io)?.saturating_sub(start); - let pad = (4 - length % 4) % 4; - for _ in 0..pad { - out.write_all(&[0]).map_err(io)?; - } - Ok(()) -} - -/// Write a PSB directly to a seekable sink, patching channel and section lengths after each -/// bounded encoded stream. Callbacks supply encoded layer channels and the merged image; this -/// function never buffers their data. Publication/atomic replacement belongs to the caller. -pub fn write_layered( - file: &PsdFile, - out: &mut W, - mut pixels: impl FnMut(usize, i16, &mut W) -> Result<()>, - merged: impl FnOnce(&mut W) -> Result<()>, -) -> Result<()> { - if file.header.version != Version::Psb { - return Err(PsdError::Unsupported("seekable writer requires PSB".into())); - } - file.header.validate()?; - let mut prefix = Vec::new(); - file.header.write(&mut prefix); - prefix.extend(u32::try_from(file.color_mode_data.len()).map_err(|_| invalid())?.to_be_bytes()); - prefix.extend(&file.color_mode_data); - crate::resources::write_section(&mut prefix, &file.resources)?; - out.write_all(&prefix).map_err(io)?; - let lm_at = out.stream_position().map_err(io)?; - out.write_all(&0u64.to_be_bytes()).map_err(io)?; - let start = out.stream_position().map_err(io)?; - let li_at = start; - out.write_all(&0u64.to_be_bytes()).map_err(io)?; - if let (Some(info), LayerInfoPlacement::Section) = (&file.layer_info, &file.layer_info_placement) { - let begin = out.stream_position().map_err(io)?; - write_layers(out, info, &mut pixels)?; - let n = out.stream_position().map_err(io)?.saturating_sub(begin); - patch(out, li_at, n)?; - } - let mask = file.global_layer_mask.as_ref().map_or(&[][..], |m| m.data.as_slice()); - out.write_all(&u32::try_from(mask.len()).map_err(|_| invalid())?.to_be_bytes()).map_err(io)?; - out.write_all(mask).map_err(io)?; - for i in 0..=file.global_blocks.len() { - if let (Some(info), LayerInfoPlacement::GlobalBlock { index, signature, key, padding }) = (&file.layer_info, &file.layer_info_placement) - && i == (*index).min(file.global_blocks.len()) - { - out.write_all(signature).map_err(io)?; - out.write_all(key).map_err(io)?; - let at = out.stream_position().map_err(io)?; - out.write_all(&0u64.to_be_bytes()).map_err(io)?; - let begin = out.stream_position().map_err(io)?; - write_layers(out, info, &mut pixels)?; - let n = out.stream_position().map_err(io)?.saturating_sub(begin); - patch(out, at, n)?; - match padding { - Some(bytes) => out.write_all(bytes).map_err(io)?, - None => out.write_all(&[0u8; 3][..((4 - n % 4) % 4) as usize]).map_err(io)?, - } - } - if let Some(block) = file.global_blocks.get(i) { - let mut bytes = Vec::new(); - crate::tagged::write_blocks(&mut bytes, std::slice::from_ref(block), Version::Psb, 4)?; - out.write_all(&bytes).map_err(io)?; - } - } - out.write_all(&file.layer_mask_trailing).map_err(io)?; - let n = out.stream_position().map_err(io)?.saturating_sub(start); - patch(out, lm_at, n)?; - out.write_all(&file.image_data.compression.as_u16().to_be_bytes()).map_err(io)?; - merged(out) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::io::Cursor; - - #[test] - fn indexed_channels_match_byte_decoder_at_every_depth_and_compression() { - for version in [Version::Psd, Version::Psb] { - for depth in [8, 16, 32] { - for compression in Compression::ALL { - let file = crate::testgen::layered(version, crate::ColorMode::Rgb, depth, compression); - let bytes = file.to_bytes().unwrap(); - let index = Index::read(&mut Cursor::new(&bytes), 32 << 20, &|| false).unwrap(); - assert_eq!(index.metadata.resources, file.resources); - assert_eq!(index.metadata.global_blocks, file.global_blocks); - for (rec, ranges) in index.metadata.layers().iter().zip(&index.channels) { - for range in ranges { - let (w, h) = rec.channel_rect(range.id).size().unwrap(); - let mut rows = Rows::new(Cursor::new(&bytes), *range, w, h, depth, version).unwrap(); - let mut decoded = Vec::new(); - let mut row = Vec::new(); - for _ in 0..h { - rows.read_row(&mut row).unwrap(); - decoded.extend_from_slice(&row); - } - rows.finish().unwrap(); - let original = file.layers().iter().find(|l| l.name == rec.name).unwrap(); - assert_eq!(decoded, original.decode_channel(range.id, depth, version).unwrap()); - } - } - } - } - } - } - - #[test] - fn bounded_index_and_cancel_do_not_allocate_declared_pixel_bytes() { - let file = crate::testgen::layered(Version::Psb, crate::ColorMode::Rgb, 8, Compression::Rle); - let bytes = file.to_bytes().unwrap(); - assert!(Index::read(&mut Cursor::new(&bytes), 2, &|| false).is_err()); - assert!(Index::read(&mut Cursor::new(&bytes), 32 << 20, &|| true).is_err()); - for n in [0, 25, 30, bytes.len() / 2] { - assert!(Index::read(&mut Cursor::new(&bytes[..n]), 32 << 20, &|| false).is_err()); - } - } - - #[test] - fn rows_reject_corrupt_checksum_and_truncated_data() { - let layout = PlaneLayout { planes: 1, width: 10, height: 4, depth: 8, version: Version::Psb }; - let encoded = crate::compression::encode_planes(Compression::Zip, &[1; 40], &layout).unwrap(); - for corrupt in [false, true] { - let mut bytes = encoded.clone(); - if corrupt { - let n = bytes.len(); - bytes[n - 1] ^= 0xff; - } else { - bytes.truncate(bytes.len() - 3); - } - let range = ChannelRange { id: 0, offset: 0, length: bytes.len() as u64, compression: Compression::Zip }; - let mut rows = Rows::new(Cursor::new(&bytes), range, 10, 4, 8, Version::Psb).unwrap(); - let mut row = Vec::new(); - let mut error = false; - for _ in 0..4 { - if rows.read_row(&mut row).is_err() { - error = true; - break; - } - } - assert!(error || rows.finish().is_err(), "truncated or corrupt zlib must fail"); - } - } - - #[test] - fn seekable_writer_patches_layer_channel_and_global_block_lengths() { - for depth in [8, 16, 32] { - for compression in Compression::ALL { - let file = crate::testgen::layered(Version::Psb, crate::ColorMode::Rgb, depth, compression); - let mut out = Cursor::new(Vec::new()); - write_layered( - &file, - &mut out, - |layer, id, out| out.write_all(&file.layers()[layer].channel(id).unwrap().data).map_err(io), - |out| out.write_all(&file.image_data.data).map_err(io), - ) - .unwrap(); - let read = PsdFile::from_bytes(&out.into_inner()).unwrap(); - assert_eq!(read.header, file.header); - assert_eq!(read.global_blocks, file.global_blocks); - assert_eq!(read.resources, file.resources); - assert_eq!(read.image_data, file.image_data); - assert_eq!(read.layers(), file.layers()); - } - } - } -} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 3cb1818e5c..42d43f9782 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -1219,11 +1219,6 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) Err(e) => return err(e), } } - if matches!(path.rsplit('.').next().map(|s| s.to_ascii_lowercase()).as_deref(), Some("psd" | "psb" | "psdt" | "exr")) - && let Some(import) = app.services.automation_import_path.clone() - { - return run_waiting(app, wait, |app| crate::jobs_ui::open_authorized(app, path.to_string(), import)); - } let opened = match app.services.automation_read.as_mut() { Some(read) => read(path), None => Err("automation read authority is not configured".into()), @@ -2333,51 +2328,6 @@ mod tests { assert_eq!(written.borrow().len(), 3, "a bad tgaBits writes nothing"); } - #[test] - fn automation_exr_open_uses_authorized_seekable_import() { - use std::sync::{ - Arc, - atomic::{AtomicUsize, Ordering}, - }; - for source_read_only in [false, true] { - let reads = Arc::new(AtomicUsize::new(0)); - let imported = reads.clone(); - let services = crate::Services { - automation_import_path: Some(Arc::new(move |path, _| { - assert_eq!(path, "in/starmap.EXR"); - imported.fetch_add(1, Ordering::Relaxed); - Ok(photocraft_io::ImportResult { - document: photocraft_doc::Document::new( - "starmap.EXR", - photocraft_geom::Size::new(65536, 32768), - photocraft_color::ColorMode::Rgb, - photocraft_color::SampleType::F32, - ), - warnings: Vec::new(), - source_read_only, - preview_only: source_read_only, - }) - })), - automation_read: Some(Box::new(|_| Err("whole-file read must not run".into()))), - ..Default::default() - }; - let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), services); - let response = call(&mut app, &egui::Context::default(), "app.open", json!({"path": "in/starmap.EXR", "wait": false})); - assert_eq!(response["ok"], true, "{response}"); - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); - while !app.session.jobs().is_empty() && std::time::Instant::now() < deadline { - app.session.poll_jobs(); - std::thread::sleep(std::time::Duration::from_millis(1)); - } - assert!(app.session.jobs().is_empty(), "EXR import did not finish"); - assert_eq!(reads.load(Ordering::Relaxed), 1); - assert_eq!(app.session.active().unwrap().doc.size, photocraft_geom::Size::new(65536, 32768)); - let st = app.session.active().unwrap(); - assert_eq!(st.source_read_only, source_read_only); - assert_eq!(st.path.is_none(), source_read_only, "read-only imports must not keep a source save path"); - } - } - #[test] fn automation_open_and_save_use_the_roots_and_reply_with_warnings() { use photocraft_color::{ColorMode, SampleType}; diff --git a/crates/ui-egui/src/file_open.rs b/crates/ui-egui/src/file_open.rs index 1cec43e5df..d67e56dbf0 100644 --- a/crates/ui-egui/src/file_open.rs +++ b/crates/ui-egui/src/file_open.rs @@ -112,30 +112,8 @@ impl PhotocraftApp { crate::jobs_ui::start_open(self, &display_name(path), Some(path.to_string()), photocraft_engine::jobs::OpenSource::Path(path.to_string()))?; return Ok(Vec::new()); } - let seekable = ext == "exr" || matches!(ext.as_str(), "psd" | "psb" | "psdt") && std::fs::metadata(path).is_ok_and(|m| m.len() >= 64 << 20); - if !seekable { - let bytes = photocraft_format::read_file(std::path::Path::new(path)).map_err(|e| format!("{path}: {e}"))?; - return self.open_file(path, &bytes); - } - let name = self.open_name(&display_name(path)); - let imported = photocraft_io::import_path_with(&name, std::path::Path::new(path), &photocraft_raster::Interrupt::NONE).map_err(|e| e.to_string())?; - let warnings = with_missing_fonts_of(&imported.document, imported.warnings); - let question = self.session.color.open_question(&imported.document); - crate::open_prompt::arrive( - self, - crate::open_prompt::Held { - doc: imported.document, - read_only: imported.source_read_only, - name, - path: Some(path.to_string()), - warnings: warnings.clone(), - bytes: None, - slot: None, - question, - policy_applied: false, - }, - ); - Ok(warnings) + let bytes = photocraft_format::read_file(std::path::Path::new(path)).map_err(|e| format!("{path}: {e}"))?; + self.open_file(path, &bytes) } #[cfg(target_arch = "wasm32")] diff --git a/crates/ui-egui/src/jobs_ui.rs b/crates/ui-egui/src/jobs_ui.rs index 5fc586fe5b..29da41d481 100644 --- a/crates/ui-egui/src/jobs_ui.rs +++ b/crates/ui-egui/src/jobs_ui.rs @@ -249,51 +249,6 @@ pub(crate) fn finish_open_tab(app: &mut PhotocraftApp, tab: &OpenTab, v: &Value) r } -pub(crate) fn open_authorized(app: &mut PhotocraftApp, path: String, import: crate::AutomationImportPathFn) -> Result { - let name = app.open_name(&crate::file_open::display_name(&path)); - let worker_path = path.clone(); - let apply_name = name.clone(); - let apply_path = path.clone(); - match app - .session - .start_job( - OPEN_JOB, - json!({"path": path}), - "Opening", - false, - move |ctx| { - ctx.stage(0.0, 1.0, "Decoding", |ctl| import(&worker_path, ctl)) - .map_err(|e| if ctx.cancelled() { photocraft_engine::EngineError::Cancelled } else { photocraft_engine::EngineError::Other(e) }) - }, - move |session, imported| { - let source_read_only = imported.source_read_only; - let (mut doc, warnings) = (imported.document, imported.warnings); - doc.name = apply_name.clone(); - let document_path = (!source_read_only && !photocraft_engine::file_cmds::is_template(&apply_path)).then(|| apply_path.clone()); - let (index, color) = session.open_document(doc, document_path); - if let Some(st) = session.active_mut() { - st.source_read_only = source_read_only; - } - Ok(json!({"document": index, "name": apply_name, "warnings": warnings, "color": color, "path": apply_path, "automation": true})) - }, - ) - .map_err(|e| e.to_string())? - { - Started::Done(v) => { - finish_open(app, &name, Some(&path), &v, None)?; - Ok(v) - } - Started::Job(job) => { - app.jobs.opens.push(OpenTab { job, name, path: Some(path), slot: None, order: None }); - app.jobs.focus = Some(job); - app.jobs.last_started = Some(job); - app.ui.chrome.home = None; - app.ui.status_error = false; - Ok(json!({"job": job.0, "pending": true})) - } - } -} - /// Save the active document to `path` in the background: `save` encodes and writes it on a /// worker while the window keeps drawing and shows the progress dialog. The document is locked /// until the job ends, which [`tick`] records like a synchronous save. `Ok(None)`: started; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 10d050d5e4..ea6841d4ed 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -326,8 +326,6 @@ pub(crate) enum SaveKind { /// Automation: flat formats stay copies (#2579) and hooks never fire. Automation, } -/// Decode a document through handles beneath the desktop control session's authorized root. -pub type AutomationImportPathFn = std::sync::Arc) -> Result + Send + Sync>; /// Read bytes through the desktop control session's authorized read root. pub type AutomationReadFn = Box Result<(String, Vec), String>>; /// Write bytes through the desktop control session's authorized write root. @@ -418,7 +416,6 @@ pub struct Services { /// File access used only by control/MCP requests. Interactive dialogs keep /// using `file_dialog` and `write` with the user's authority. pub automation_read: Option, - pub automation_import_path: Option, pub automation_write: Option, pub automation_open_pcraft: Option, pub automation_command: Option, diff --git a/crates/ui-egui/tests/exr_open.rs b/crates/ui-egui/tests/exr_open.rs deleted file mode 100644 index ae3b737bec..0000000000 --- a/crates/ui-egui/tests/exr_open.rs +++ /dev/null @@ -1,44 +0,0 @@ -#![cfg(not(target_arch = "wasm32"))] -use photocraft_codecs::{ChannelLayout, EncodeOptions, Format, Image}; -use photocraft_ui_egui::PhotocraftApp; - -#[test] -fn synchronous_native_exr_open_uses_tile_admission_before_huge_pixel_decode() { - let image = Image::from_f32(1, 1, ChannelLayout::Rgb, &[0.5, 2.0, -0.25]).unwrap(); - let bytes = photocraft_codecs::encode(&image, Format::OpenExr, &EncodeOptions::default()).unwrap(); - let mut header_end = 8; - loop { - if bytes[header_end] == 0 { - header_end += 1; - break; - } - for _ in 0..2 { - while bytes[header_end] != 0 { - header_end += 1; - } - header_end += 1; - } - let length = i32::from_le_bytes(bytes[header_end..header_end + 4].try_into().unwrap()) as usize; - header_end += 4 + length; - } - let mut huge = bytes[..header_end].to_vec(); - let needle = b"dataWindow\0box2i\0\x10\0\0\0"; - let at = huge.windows(needle.len()).position(|v| v == needle).unwrap() + needle.len(); - huge[at + 8..at + 12].copy_from_slice(&65535_i32.to_le_bytes()); - huge[at + 12..at + 16].copy_from_slice(&32767_i32.to_le_bytes()); - let offset = (header_end + 2048 * 8) as u64; - for _ in 0..2048 { - huge.extend_from_slice(&offset.to_le_bytes()); - } - huge.extend_from_slice(&[0; 8]); - let path = std::env::temp_dir().join(format!("photocraft-exr-ui-{}.exr", std::process::id())); - std::fs::write(&path, huge).unwrap(); - let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); - app.background_jobs = false; - photocraft_raster::spill::configure(None, 0).unwrap(); - photocraft_raster::memory::configure(photocraft_raster::memory::Policy { percent: None, absolute_bytes: 1 << 30 }); - let error = app.open_path(path.to_str().unwrap()).unwrap_err(); - assert!(error.contains("decoded image tiles"), "{error}"); - assert!(app.session.documents().is_empty()); - std::fs::remove_file(path).unwrap(); -} diff --git a/docs/roadmap.md b/docs/roadmap.md index dca334cfe0..8b3d6a44d0 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -125,6 +125,4 @@ Each milestone has a **definition of done (DoD)** and must leave `main` green on 2026-10-13: native scratch storage pages current and history tiles through compressed, CRC-checked files. Fresh RAM budgets default to 70%; engine-owned memory supervision stops with its last session. Scratch errors block edit/save commits and permit retry. Unix files are private and stale files swept at startup; Windows files use delete-on-close. See [scratch disks](scratch-disks.md). Streaming import and viewport rendering follow separately. -2026-10-13: seekable native PSD/PSB and supported scanline EXR imports decode bounded bands at the original resolution. Authorized opens retain read-only source protection. See [native streaming I/O](streaming-io.md). - 2026-10-13: large canvases render visible original-resolution pages with bounded RAM/VRAM, cancellable workers and stale-result rejection; the new upstream background redraw remains available for full-canvas paths. See [GPU streaming](streaming-gpu.md). diff --git a/docs/scorecard.md b/docs/scorecard.md index f7d7d5f29e..3b7b0f40e8 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -248,7 +248,7 @@ Never-crash attribute on 28 of 28 crate roots. | REL-2 | Every workspace crate carries the never-crash deny(clippy::unwrap_used, ...) attribute | done | | Counted automatically above (crate roots under crates/ and apps/); testkit gained it in #221 | | REL-3 | The app catches escaped panics around command dispatch and import/export | done | | a_panic_in_a_command_... runs the test-only `test.panic` spec through Session::execute and asserts Err plus an unchanged document (engine jobs_tests.rs); the worker guard is covered by a_panic_in_the_worker_...; crash_guard.rs guard_turns_a_panic_into_an_error covers import/export (apps/photocraft) | | REL-4 | Atomic save (temp, fsync, rename) for every document write | done | [#203](https://github.com/storytold/photocraft/issues/203) | photocraft_format::atomic_write (format/src/atomic.rs, #230): same-dir temp, sync_all, rename with Windows PermissionDenied retry, Unix dir fsync; every document write site routes through it and format/src/atomic/tests.rs failure-injects write, rename and read-only-dir errors | -| REL-212-1 | Fallible allocation for paste/erase/fill/transform/save/import, clear out-of-memory error | partial | [#212](https://github.com/storytold/photocraft/issues/212) | Raster region/tile buffers, paste/erase/fill/transform and warp output use fallible reservations; filter and lens working memory is admitted before edits, and current/history tiles spill to CRC-checked scratch. Native layered PSD/PSB and supported scanline EXR imports decode bounded bands; save/import boundaries preserve state; remaining encoder/decoder allocations still need broader audit. | +| REL-212-1 | Fallible allocation for paste/erase/fill/transform/save/import, clear out-of-memory error | partial | [#212](https://github.com/storytold/photocraft/issues/212) | Raster region/tile buffers, paste/erase/fill/transform and warp output use fallible reservations; filter and lens working memory is admitted before edits, and current/history tiles spill to CRC-checked scratch. Save/import boundaries preserve state; remaining encoder/decoder allocations still need broader audit. | | REL-212-2 | Export switches to PSB when the estimated file size exceeds 2 GB | done | [#212](https://github.com/storytold/photocraft/issues/212) | PSD export uses checked size estimates for raster, masks, channels, and retained metadata | | REL-212-3 | Tests: an allocation-limit injection makes each command Err with the document unchanged | partial | [#212](https://github.com/storytold/photocraft/issues/212) | Engine failpoint tests cover paste, erase, fill, transform, import and save transaction boundaries; codec-level allocator failure coverage remains open | diff --git a/docs/scratch-disks.md b/docs/scratch-disks.md index c05f6e19f5..39fa6f57f1 100644 --- a/docs/scratch-disks.md +++ b/docs/scratch-disks.md @@ -36,5 +36,4 @@ until the tile is readable again. Recovery can retry the original bytes after th becomes available; zeros from a failed display read must never become saved pixels. Scratch storage alone does not make every format, filter or renderer streamable. See -[native PSD/EXR streaming](streaming-io.md) for the seekable import and output APIs and [viewport streaming](streaming-gpu.md) for rendering built on these guards and reservations. diff --git a/docs/streaming-gpu.md b/docs/streaming-gpu.md index 9095e330bb..ff0f922157 100644 --- a/docs/streaming-gpu.md +++ b/docs/streaming-gpu.md @@ -23,4 +23,3 @@ the upstream full-canvas/proxy route for its repeated copies; ordinary large can Synthetic regressions cover oversized staging/readback allocations, coarse/fine page replacement, cancelled workers, eviction and tab switching. Native stress uses Earth.psb with the 2K/4K/8K/16K Milky Way EXR variants, rapid magnifier clicks and tab changes, then the original-size 64K EXR. -Public large fixtures are linked from [native streaming I/O](streaming-io.md). diff --git a/docs/streaming-io.md b/docs/streaming-io.md deleted file mode 100644 index 3cc4d3a674..0000000000 --- a/docs/streaming-io.md +++ /dev/null @@ -1,36 +0,0 @@ -# Native PSD/EXR streaming - -Native path opens inspect a seekable source before allocating decoded pixels. Supported layered -PSD/PSB files and flat scanline EXR files decode bounded bands directly into guarded raster -tiles. Their document dimensions and samples remain at the source resolution; scratch residency -can move cold tiles to disk. The byte decoder remains available for web and unsupported layouts, -subject to working-memory admission rather than a file-sized allocation without a budget. - -PSD indexing retains bounded metadata and channel offsets without copying all channel data or -decoding the stored merged image. Independent seekable channel handles feed the existing document -mapping, preserving layers, masks, groups, metadata, colour models and depth. Unsupported nesting -and metadata limits produce errors. The native PSB writer uses the same metadata mapper with -deferred pixel sources and writes original-depth channel bands to a seekable output. - -EXR block decoding supports the flat scanline layouts accepted by the streaming decoder, including -the RGB HALF ZIP test image `65536 × 32768`. HALF samples are stored as F32, with a warning; this -does not reduce the document dimensions. Unsupported EXR layouts fall back to the normal codec -when its limits allow them. Chunk sizes, source bounds and transient buffers are checked before -allocation. Cancellation is checked between bands, and progress describes completed work. - -Control and CLI opens use the same importer. Authorized control opens create independent handles -under the held read-directory capability, including channel reopens. They do not gain ambient -filesystem access. Path opens check that file size and modification time remain unchanged. Read-only -and preview import flags survive the native and authorized routes, and templates keep an untitled -document without a source save path. - -The seekable output API accepts a caller-owned writer; atomic path output encodes into a temporary -file beside the destination, syncs it using the shared platform helper, then renames it. Encoder -failure, cancellation or unwinding removes the temporary and preserves the old destination. Its -integration with the desktop background-save service is a separate change, using upstream's -`SaveCtl` and document-lock/saved-revision contract. - -Synthetic tests compare streamed import with the byte decoder across PSD/PSB versions, depths, -colour modes and compression, and verify masks, metadata, cancellation, EXR chunk handling, -source protection and capability bounds. Large-file runtime checks use the public fixtures at -[vistaero.es/photocraft](https://vistaero.es/photocraft/). diff --git a/scorecard/reliability.toml b/scorecard/reliability.toml index 7bc708e548..d5fc1fb5a7 100644 --- a/scorecard/reliability.toml +++ b/scorecard/reliability.toml @@ -35,7 +35,7 @@ id = "REL-212-1" target = "Fallible allocation for paste/erase/fill/transform/save/import, clear out-of-memory error" status = "partial" issue = 212 -note = "Raster region/tile buffers, paste/erase/fill/transform and warp output use fallible reservations; filter and lens working memory is admitted before edits, and current/history tiles spill to CRC-checked scratch. Native layered PSD/PSB and supported scanline EXR imports decode bounded bands; save/import boundaries preserve state; remaining encoder/decoder allocations still need broader audit." +note = "Raster region/tile buffers, paste/erase/fill/transform and warp output use fallible reservations; filter and lens working memory is admitted before edits, and current/history tiles spill to CRC-checked scratch. Save/import boundaries preserve state; remaining encoder/decoder allocations still need broader audit." [[item]] id = "REL-212-2" From f7b7e7f6187b965516f35e5479317871ca42f8c8 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:06:31 +0100 Subject: [PATCH 200/216] Rescope to EXR streaming: drop the separate PSD/PSB reader, seekable outputs and export pre-check Main already opens PSD/PSB from the file in bands (4915f0970, #5106) through psd_to_document_owned, which keeps damaged-file flagging, failed-layer drops, type-layer setup and the PSD pixel budget; PSD limits are handled in #10778. This drops the second PSD/PSB stream reader (psd::stream, io::psd_stream), the seekable PSB writer and atomic stream helpers, the authorized-workspace seekable open plumbing and the working-memory pre-check that refused ordinary exports. The flat scanline EXR band decoder and its tests stay and are wired into main's path open after merging main. --- Cargo.lock | 1 - apps/photocraft/src/services.rs | 6 - crates/automation/Cargo.toml | 1 - crates/automation/src/files.rs | 22 +- crates/automation/src/headless.rs | 40 +- crates/automation/src/workspace.rs | 105 +---- crates/codecs/examples/exr_region_probe.rs | 74 --- crates/engine/src/file_cmds.rs | 30 +- crates/engine/src/jobs.rs | 31 +- crates/format/src/atomic.rs | 60 --- crates/format/src/atomic/tests.rs | 84 ---- crates/io/examples/scratch_psb_probe.rs | 36 -- crates/io/src/lib.rs | 165 ------- crates/io/src/psd_export.rs | 85 +--- crates/io/src/psd_import.rs | 19 +- crates/io/src/psd_stream.rs | 395 ---------------- crates/io/tests/streaming_psd.rs | 136 ------ crates/io/tests/svg.rs | 17 - crates/psd/src/layer.rs | 4 +- crates/psd/src/lib.rs | 1 - crates/psd/src/stream.rs | 499 --------------------- crates/ui-egui/src/control.rs | 50 --- crates/ui-egui/src/file_open.rs | 26 +- crates/ui-egui/src/jobs_ui.rs | 45 -- crates/ui-egui/src/lib.rs | 3 - 25 files changed, 62 insertions(+), 1873 deletions(-) delete mode 100644 crates/codecs/examples/exr_region_probe.rs delete mode 100644 crates/io/examples/scratch_psb_probe.rs delete mode 100644 crates/io/src/psd_stream.rs delete mode 100644 crates/io/tests/streaming_psd.rs delete mode 100644 crates/psd/src/stream.rs diff --git a/Cargo.lock b/Cargo.lock index 9dfd9946f6..79b80aae04 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4295,7 +4295,6 @@ dependencies = [ "photocraft-format", "photocraft-geom", "photocraft-io", - "photocraft-raster", "rmcp", "schemars", "serde", diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index 3cae110ea3..d4a3869f31 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -406,11 +406,6 @@ fn shell_word(s: &str) -> String { pub fn native(automation: Option) -> Services { let clip: Rc>> = Rc::default(); - let automation_import_path = automation.clone().map(|workspace| { - std::sync::Arc::new(move |path: &str, ctl: &photocraft_raster::Interrupt<'_>| { - crate::crash_guard::guard("Open", || workspace.read_document(path, ctl).map_err(|e| e.to_string())) - }) as photocraft_ui_egui::AutomationImportPathFn - }); let automation_read = automation.clone().map(|workspace| { Box::new(move |path: &str| { let bytes = workspace.read(path).map_err(|error| error.to_string())?; @@ -454,7 +449,6 @@ pub fn native(automation: Option) -> file_dialog: Some(Box::new(show_file_dialog)), write: Some(Box::new(|path: &str, bytes: &[u8]| write_atomic(Path::new(path), bytes))), automation_read, - automation_import_path, automation_write, automation_open_pcraft, automation_save, diff --git a/crates/automation/Cargo.toml b/crates/automation/Cargo.toml index f15f500e05..0738140a41 100644 --- a/crates/automation/Cargo.toml +++ b/crates/automation/Cargo.toml @@ -15,7 +15,6 @@ photocraft-doc = { workspace = true } photocraft-compose = { workspace = true } photocraft-codecs = { workspace = true } photocraft-io = { workspace = true } -photocraft-raster = { workspace = true } photocraft-format = { path = "../format" } photocraft-engine = { workspace = true } serde = { workspace = true } diff --git a/crates/automation/src/files.rs b/crates/automation/src/files.rs index 61c6ba246a..cd34ab3410 100644 --- a/crates/automation/src/files.rs +++ b/crates/automation/src/files.rs @@ -31,17 +31,9 @@ pub fn open(path: &Path) -> Result { if path.is_dir() { return Ok(Opened { document: photocraft_format::load_path(path)?, warnings: Vec::new(), source_read_only: false }); } + let bytes = photocraft_format::read_file(path).map_err(|e| AutomationError::Io(format!("{}: {e}", path.display())))?; let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); - #[cfg(not(target_arch = "wasm32"))] - { - let r = photocraft_io::import_path_with(&name, path, &photocraft_raster::Interrupt::NONE)?; - Ok(Opened { document: r.document, warnings: r.warnings, source_read_only: r.source_read_only }) - } - #[cfg(target_arch = "wasm32")] - { - let bytes = photocraft_format::read_file(path).map_err(|e| AutomationError::Io(format!("{}: {e}", path.display())))?; - open_bytes(&name, &bytes) - } + open_bytes(&name, &bytes) } /// Previews for a `.pcraft` bundle. @@ -81,16 +73,6 @@ pub fn save( w.save_path(&named, path, &previews(&named))?; return Ok(Vec::new()); } - #[cfg(not(target_arch = "wasm32"))] - if ext == "psb" || ext == "psd" && (opts.force_psb || doc.size.width > 30_000 || doc.size.height > 30_000) { - let mut warnings = Vec::new(); - photocraft_format::atomic::atomic_write_stream(path, |file| { - warnings = photocraft_io::export_psb_into(doc, file, &photocraft_raster::Interrupt::NONE).map_err(|e| std::io::Error::other(e.to_string()))?; - Ok(()) - }) - .map_err(|e| AutomationError::Io(e.to_string()))?; - return Ok(warnings); - } let r = photocraft_io::export(doc, &ext, opts)?; // Crash-safe: a failed write never destroys the previous file. photocraft_format::atomic_write(path, &r.bytes).map_err(|e| AutomationError::Io(e.to_string()))?; diff --git a/crates/automation/src/headless.rs b/crates/automation/src/headless.rs index 3a61ffa4dc..d6b3c17102 100644 --- a/crates/automation/src/headless.rs +++ b/crates/automation/src/headless.rs @@ -79,17 +79,9 @@ impl Headless { Filesystem::Denied => return Err(AutomationError::BadRequest("automation filesystem access is not granted: read authority is absent".into())), Filesystem::TrustedLocal => files::open(path)?, Filesystem::Workspace(workspace) => { - #[cfg(not(target_arch = "wasm32"))] - { - let r = workspace.read_document(requested, &photocraft_raster::Interrupt::NONE)?; - files::Opened { document: r.document, warnings: r.warnings, source_read_only: r.source_read_only } - } - #[cfg(target_arch = "wasm32")] - { - let bytes = workspace.read(requested)?; - let name = path.file_name().and_then(|name| name.to_str()).unwrap_or(requested); - files::open_bytes(name, &bytes)? - } + let bytes = workspace.read(requested)?; + let name = path.file_name().and_then(|name| name.to_str()).unwrap_or(requested); + files::open_bytes(name, &bytes)? } }; let path = match file_cmds::template_name(&self.session, requested) { @@ -259,29 +251,9 @@ impl Headless { files::save(&doc, &target, format, opts, Some(writer))? } Filesystem::Workspace(workspace) => { - #[cfg(not(target_arch = "wasm32"))] - if format - .map(|f| f.trim_start_matches('.').eq_ignore_ascii_case("psb")) - .unwrap_or_else(|| target.extension().is_some_and(|ext| ext.eq_ignore_ascii_case("psb"))) - { - let mut warnings = Vec::new(); - workspace.write_stream(target_text, |file| { - warnings = photocraft_io::export_psb_into(&doc, file, &photocraft_raster::Interrupt::NONE) - .map_err(|e| std::io::Error::other(e.to_string()))?; - Ok(()) - })?; - warnings - } else { - let (bytes, warnings) = files::save_bytes(&doc, target_text, format, opts)?; - workspace.write(target_text, &bytes)?; - warnings - } - #[cfg(target_arch = "wasm32")] - { - let (bytes, warnings) = files::save_bytes(&doc, target_text, format, opts)?; - workspace.write(target_text, &bytes)?; - warnings - } + let (bytes, warnings) = files::save_bytes(&doc, target_text, format, opts)?; + workspace.write(target_text, &bytes)?; + warnings } }; warnings.extend(photocraft_engine::type_extra_cmds::missing_font_warnings(&doc)); diff --git a/crates/automation/src/workspace.rs b/crates/automation/src/workspace.rs index 6321bc241f..cde3a3a702 100644 --- a/crates/automation/src/workspace.rs +++ b/crates/automation/src/workspace.rs @@ -68,24 +68,6 @@ impl AuthorizedWorkspace { let source = DirHandle { dir }; Ok(Some(photocraft_format::load_bytes_source(&source, &photocraft_format::LoadOptions::default())?)) } - /// Open a large document by rows through the held read capability, without a file-sized - /// byte array. Independent channel cursors remain rooted beneath the authorized directory. - #[cfg(not(target_arch = "wasm32"))] - pub fn read_document(&self, path: &str, ctl: &photocraft_raster::Interrupt<'_>) -> Result { - let relative = request_path(path, self.read.as_ref())?; - let root = self.read.as_ref().ok_or_else(|| AutomationError::BadRequest(format!("{DENIED}: read authority is absent")))?; - let original = root.dir.metadata(&relative).map_err(|e| file_error("read", path, e))?; - if !original.is_file() { - return Err(AutomationError::BadRequest("automation read path is not a regular file".into())); - } - let name = relative.file_name().and_then(|n| n.to_str()).unwrap_or(path); - let result = photocraft_io::import_seekable_with(name, &|| root.dir.open(&relative), ctl)?; - let current = root.dir.metadata(&relative).map_err(|e| file_error("read", path, e))?; - if current.len() != original.len() || current.modified().ok() != original.modified().ok() { - return Err(AutomationError::Io("source changed during import; open it again".into())); - } - Ok(result) - } /// Create or replace one file below the configured write root, crash-safely: the bytes go to /// a temporary file beside the target, which is synced and renamed over it (the same steps @@ -96,30 +78,18 @@ impl AuthorizedWorkspace { /// resolution and file creation relative to the held directory handle, so /// a non-existent final target is supported without ambient path access. pub fn write(&self, path: &str, bytes: &[u8]) -> Result<(), AutomationError> { - self.write_stream(path, |file| file.write_all(bytes)) - } - - /// Atomically publish a seekable encoder through the held write capability. No ambient - /// paths are exposed to the encoder, including for temporary-file creation and rename. - pub fn write_stream(&self, path: &str, encode: impl FnOnce(&mut cap_std::fs::File) -> std::io::Result<()>) -> Result<(), AutomationError> { - struct Cleanup(Arc, PathBuf); - impl Drop for Cleanup { - fn drop(&mut self) { - let _ = self.0.remove_file(&self.1); - } - } let relative = request_path(path, self.write.as_ref())?; let root = self.write.as_ref().ok_or_else(|| AutomationError::BadRequest(format!("{DENIED}: write authority is absent")))?; let leaf = relative.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); let parent = relative.parent().map(Path::to_path_buf).unwrap_or_default(); let tmp = parent.join(photocraft_format::atomic::temp_name(&leaf)); let mut options = OpenOptions::new(); - options.read(true).write(true).create_new(true); - let file = root.dir.open_with(&tmp, &options).map_err(|e| file_error("write", path, e))?; - let _cleanup = Cleanup(root.dir.clone(), tmp.clone()); - // This binding drops before the cleanup guard if an encoder unwinds. - let mut file = file; - let written = encode(&mut file).and_then(|()| photocraft_format::atomic::sync_file(&file.into_std())); + options.write(true).create_new(true); + let written = root.dir.open_with(&tmp, &options).and_then(|mut file| { + file.write_all(bytes)?; + // Falls back to a plain fsync on shares that refuse a full flush (#1336). + photocraft_format::atomic::sync_file(&file.into_std()) + }); let renamed = written.and_then(|()| photocraft_format::atomic::retry_rename(RenameRetry::platform(), || root.dir.rename(&tmp, &root.dir, &relative))); if let Err(e) = renamed { let _ = root.dir.remove_file(&tmp); @@ -476,69 +446,6 @@ impl Drop for ExportSequence { mod tests { use super::*; - #[cfg(not(target_arch = "wasm32"))] - #[test] - fn seekable_document_import_stays_within_read_capability() { - let (_, _, workspace) = roots("stream-read"); - let mut session = photocraft_engine::Session::new(); - session.execute("file.new", serde_json::json!({"width": 32, "height": 16})).unwrap(); - session.execute("layer.new.layer", serde_json::json!({})).unwrap(); - let doc = &session.active().unwrap().doc; - workspace - .write_stream("small.psb", |file| { - photocraft_io::export_psb_into(doc, file, &photocraft_raster::Interrupt::NONE).map_err(|e| std::io::Error::other(e.to_string()))?; - Ok(()) - }) - .unwrap(); - let opened = workspace.read_document("small.psb", &photocraft_raster::Interrupt::NONE).unwrap(); - assert_eq!(opened.document.size, doc.size); - assert_eq!(opened.document.layers.len(), doc.layers.len()); - assert!(workspace.read_document("../small.psb", &photocraft_raster::Interrupt::NONE).is_err()); - assert!(workspace.read_document("C:/small.psb", &photocraft_raster::Interrupt::NONE).is_err()); - } - - #[test] - fn seekable_capability_writer_rejects_escape_and_preserves_failed_destination() { - use std::io::{Seek, SeekFrom}; - let (inside, outside, workspace) = roots("stream"); - std::fs::write(inside.join("kept.psb"), b"original").unwrap(); - assert!(workspace.write_stream("../escaped.psb", |_| Ok(())).is_err()); - assert!(!outside.join("escaped.psb").exists()); - assert!( - workspace - .write_stream("kept.psb", |file| { - file.write_all(b"partial")?; - Err(std::io::Error::new(std::io::ErrorKind::StorageFull, "injected full disk")) - }) - .is_err() - ); - assert_eq!(std::fs::read(inside.join("kept.psb")).unwrap(), b"original"); - assert!(temp_files(&inside).is_empty()); - workspace - .write_stream("kept.psb", |file| { - file.write_all(b"0000payload")?; - file.seek(SeekFrom::Start(0))?; - file.write_all(b"0007") - }) - .unwrap(); - assert_eq!(std::fs::read(inside.join("kept.psb")).unwrap(), b"0007payload"); - } - - #[test] - fn seekable_capability_unwind_removes_temporary_after_closing_handle() { - let (inside, _, workspace) = roots("stream-unwind"); - std::fs::write(inside.join("kept.psb"), b"original").unwrap(); - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - let _ = workspace.write_stream("kept.psb", |file| { - file.write_all(b"partial")?; - panic!("injected encoder unwind"); - }); - })); - assert!(result.is_err()); - assert_eq!(std::fs::read(inside.join("kept.psb")).unwrap(), b"original"); - assert!(temp_files(&inside).is_empty()); - } - fn roots(name: &str) -> (PathBuf, PathBuf, AuthorizedWorkspace) { let base = std::env::temp_dir().join(format!("photocraft-workspace-{}-{name}", std::process::id())); let inside = base.join("inside"); diff --git a/crates/codecs/examples/exr_region_probe.rs b/crates/codecs/examples/exr_region_probe.rs deleted file mode 100644 index 4b57e803aa..0000000000 --- a/crates/codecs/examples/exr_region_probe.rs +++ /dev/null @@ -1,74 +0,0 @@ -//! Read-only independent EXR sample oracle: `exr_region_probe file.exr x y [x y ...]`. -//! Uses the dependency's filtered block reader, independently of our band conversion. -use exr::{block::reader::ChunksReader, meta::attribute::SampleType}; -use std::io::BufReader; - -fn run() -> Result<(), Box> { - let args: Vec = std::env::args().skip(1).collect(); - let path = args.first().ok_or("usage: exr_region_probe file.exr x y [x y ...]")?; - let coordinates = args.get(1..).ok_or("missing coordinates")?; - if coordinates.is_empty() || !coordinates.len().is_multiple_of(2) || coordinates.len() > 512 { - return Err("provide 1 to 256 coordinate pairs".into()); - } - let points: Vec<(usize, usize)> = coordinates - .chunks_exact(2) - .map(|p| Ok((p.first().ok_or("x")?.parse()?, p.get(1).ok_or("y")?.parse()?))) - .collect::>>()?; - // Bound metadata, dimensions and block sizes before using the oracle reader. - let info = photocraft_codecs::exr_stream::Decoder::new(std::fs::File::open(path)?, &|| false)?.info(); - if points.iter().any(|&(x, y)| x >= info.width as usize || y >= info.height as usize) { - return Err("coordinate outside the data window".into()); - } - let reader = exr::block::read(BufReader::new(std::fs::File::open(path)?), false)?; - let mut blocks = reader - .filter_chunks(false, |_, _, block| points.iter().any(|&(_, y)| y >= block.pixel_position.1 && y < block.pixel_position.1 + block.pixel_size.1))? - .sequential_decompressor(false); - let channels = blocks.meta_data().headers.first().ok_or("missing header")?.channels.clone(); - let mut samples = vec![[0_f32, 0.0, 0.0, 1.0]; points.len()]; - let mut seen = vec![[false; 3]; points.len()]; - while let Some(block) = blocks.decompress_next_block() { - let block = block?; - for line in block.lines(&channels) { - let channel = channels.list.get(line.location.channel).ok_or("channel")?; - let name = channel.name.to_string(); - let component = match name.rsplit('.').next() { - Some("R") => 0, - Some("G") => 1, - Some("B") => 2, - _ => continue, - }; - for (index, &(x, y)) in points.iter().enumerate().filter(|(_, p)| p.1 == line.location.position.1) { - let value = match channel.sample_type { - SampleType::F16 => line.read_samples::().nth(x).ok_or("sample")??.to_f32(), - SampleType::F32 => line.read_samples::().nth(x).ok_or("sample")??, - SampleType::U32 => line.read_samples::().nth(x).ok_or("sample")?? as f32, - }; - if !value.is_finite() { - return Err("non-finite sample cannot be represented in JSON".into()); - } - *samples.get_mut(index).and_then(|s| s.get_mut(component)).ok_or("sample index")? = value; - *seen.get_mut(index).and_then(|s| s.get_mut(component)).ok_or("sample index")? = true; - let _ = y; - } - } - } - if seen.iter().flatten().any(|v| !v) { - return Err("missing RGB samples".into()); - } - println!("{{\"width\":{},\"height\":{},\"samples\":[", info.width, info.height); - for (index, (&(x, y), sample)) in points.iter().zip(samples).enumerate() { - println!("{}{{\"x\":{x},\"y\":{y},\"rgba\":{:?}}}", if index == 0 { "" } else { "," }, sample); - } - println!("]}}"); - Ok(()) -} - -fn main() -> std::process::ExitCode { - match run() { - Ok(()) => std::process::ExitCode::SUCCESS, - Err(error) => { - eprintln!("{error}"); - std::process::ExitCode::FAILURE - } - } -} diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs index 58c4e6ca7f..7689f39679 100644 --- a/crates/engine/src/file_cmds.rs +++ b/crates/engine/src/file_cmds.rs @@ -357,25 +357,9 @@ pub(crate) fn encode(doc: &Document, path: &str, save: impl Into) -> R } pub(crate) fn save_doc(doc: &Document, path: &str, save: impl Into) -> Result> { - #[cfg(not(target_arch = "wasm32"))] - { - let save = save.into(); - let mut opts = photocraft_io::ExportOptions { tiff_layers: save.tiff_layers, ..Default::default() }; - if let Some(q) = save.quality { - let q = (q.clamp(0.0, 12.0) / 12.0 * 99.0 + 1.0).round() as u8; - opts.encode.jpeg_quality = q; - opts.encode.webp_quality = q; - opts.encode.webp_lossless = false; - } - photocraft_io::export_path_with(doc, std::path::Path::new(path), &opts, &photocraft_raster::Interrupt::NONE) - .map_err(|e| EngineError::Other(format!("{path}: {e}"))) - } - #[cfg(target_arch = "wasm32")] - { - let (bytes, warnings) = encode(doc, path, save)?; - write_file(path, &bytes)?; - Ok(warnings) - } + let (bytes, warnings) = encode(doc, path, save)?; + write_file(path, &bytes)?; + Ok(warnings) } pub(crate) fn str_param<'a>(p: &'a Value, key: &str, cmd: &str) -> Result<&'a str> { @@ -442,12 +426,8 @@ fn close_others(s: &mut Session, p: &Value) -> Result { /// "Revert" history state); the document is clean afterwards. fn revert(s: &mut Session) -> Result { let path = s.active().and_then(|d| d.path.clone()).ok_or(EngineError::Other("the document has never been saved".into()))?; - #[cfg(not(target_arch = "wasm32"))] - let fresh = photocraft_io::import_path_with(&file_name(&path), std::path::Path::new(&path), &photocraft_raster::Interrupt::NONE) - .map_err(|e| EngineError::Other(e.to_string()))? - .document; - #[cfg(target_arch = "wasm32")] - let fresh = import(&file_name(&path), &read_file(&path)?)?; + let bytes = read_file(&path)?; + let fresh = import(&file_name(&path), &bytes)?; s.edit("Revert", |doc, active| { let (id, name) = (doc.id, doc.name.clone()); *doc = fresh; diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index 374300ad06..1fc75ead29 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -258,6 +258,17 @@ fn independent_of_document(id: &str) -> bool { PREFIXES.iter().any(|p| id.starts_with(p)) || IDS.contains(&id) } +#[cfg(not(target_arch = "wasm32"))] +fn read_path(path: &str) -> Result> { + // Bounded reads, and a clear error for a file larger than memory (#375). + photocraft_format::read_file(std::path::Path::new(path)).map_err(|e| EngineError::Other(format!("{path}: {e}"))) +} + +#[cfg(target_arch = "wasm32")] +fn read_path(path: &str) -> Result> { + Err(EngineError::Other(format!("cannot open paths on the web: {path}"))) +} + /// Run a job-capable command's heavy part. `work` runs on a worker thread when the command was /// started with [`Session::start`] (inline otherwise, and always on wasm); `apply` then runs on /// the calling thread with its output and returns the command's result. With `lock_document`, @@ -425,19 +436,17 @@ impl Session { false, move |ctx| { ctx.progress(0.0, "Reading"); + let bytes = match source { + OpenSource::Bytes(b) => b, + OpenSource::Path(p) => Arc::new(read_path(&p)?), + }; ctx.check()?; ctx.progress(0.02, "Decoding"); - ctx.stage(0.02, 1.0, "Decoding", |ctl| match source { - OpenSource::Bytes(b) => photocraft_io::import_with_svg_group_depth_and_interrupt(&name_w, &b, max_svg_group_depth, ctl), - #[cfg(not(target_arch = "wasm32"))] - OpenSource::Path(p) => photocraft_io::import_path_with_svg_group_depth(&name_w, std::path::Path::new(&p), max_svg_group_depth, ctl), - #[cfg(target_arch = "wasm32")] - OpenSource::Path(_) => Err(photocraft_io::IoError::Unsupported("paths cannot be opened on the web".into())), - }) - .map_err(|e| match e { - photocraft_io::IoError::Cancelled => EngineError::Cancelled, - e => EngineError::Other(e.to_string()), - }) + ctx.stage(0.02, 1.0, "Decoding", |ctl| photocraft_io::import_with_svg_group_depth_and_interrupt(&name_w, &bytes, max_svg_group_depth, ctl)) + .map_err(|e| match e { + photocraft_io::IoError::Cancelled => EngineError::Cancelled, + e => EngineError::Other(e.to_string()), + }) }, move |s, r: photocraft_io::ImportResult| { // Ask When Opening: held back until the prompt is answered (`take_held_open`). A diff --git a/crates/format/src/atomic.rs b/crates/format/src/atomic.rs index fb86d9eb38..bf39fe2761 100644 --- a/crates/format/src/atomic.rs +++ b/crates/format/src/atomic.rs @@ -155,66 +155,6 @@ pub fn atomic_write_progress(path: &Path, bytes: &[u8], progress: &dyn Fn(f32)) atomic_write_with_progress(&RealFs, RenameRetry::platform(), path, bytes, progress) } -/// Stream into a new seekable temporary file and publish it only after successful encoding -/// and fsync. A failed encoder, cancellation or full disk leaves the destination intact. -#[cfg(not(target_arch = "wasm32"))] -pub fn atomic_write_stream(path: &Path, encode: impl FnOnce(&mut std::fs::File) -> io::Result<()>) -> io::Result<()> { - atomic_write_stream_with_commit(path, encode, || Ok(())) -} - -/// Like [`atomic_write_stream`], with a final approval immediately before replacement. -/// The encoder and flush remain cancellable; after `commit` succeeds publication runs to -/// completion. A rejected commit or an unwinding callback removes the temporary file. -#[cfg(not(target_arch = "wasm32"))] -pub fn atomic_write_stream_with_commit( - path: &Path, - encode: impl FnOnce(&mut std::fs::File) -> io::Result<()>, - commit: impl FnOnce() -> io::Result<()>, -) -> io::Result<()> { - struct Cleanup(PathBuf); - impl Drop for Cleanup { - fn drop(&mut self) { - let _ = std::fs::remove_file(&self.0); - } - } - let target = resolve_symlink(path); - let name = target.file_name().ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "not a file path"))?; - let dir = target.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new(".")); - std::fs::create_dir_all(dir).map_err(|e| write_error(e, path, dir))?; - let mut temp = None; - for _ in 0..8 { - let candidate = dir.join(temp_name(&name.to_string_lossy())); - match std::fs::OpenOptions::new().read(true).write(true).create_new(true).open(&candidate) { - Ok(file) => { - temp = Some((candidate, file)); - break; - } - Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue, - Err(e) => return Err(write_error(e, path, dir)), - } - } - let (tmp, file) = temp.ok_or_else(|| io::Error::new(io::ErrorKind::AlreadyExists, "cannot create a save temporary"))?; - let _cleanup = Cleanup(tmp.clone()); - // Close the handle before cleanup runs during unwinding, including on Windows. - let mut file = file; - let result = encode(&mut file).and_then(|()| sync_file(&file)); - drop(file); - if let Err(e) = result { - let _ = std::fs::remove_file(&tmp); - return Err(write_error(e, path, dir)); - } - if target.exists() { - let _ = RealFs.copy_permissions(&target, &tmp); - } - commit()?; - if let Err(e) = retry_rename(RenameRetry::platform(), || RealFs.rename(&tmp, &target)) { - let _ = RealFs.remove(&tmp); - return Err(rename_error(e, path)); - } - let _ = RealFs.sync_dir(dir); - Ok(()) -} - /// [`atomic_write`] through an explicit file system and retry policy (the failure-injection seam). pub fn atomic_write_with(fs: &dyn AtomicFs, retry: RenameRetry, path: &Path, bytes: &[u8]) -> io::Result<()> { atomic_write_with_progress(fs, retry, path, bytes, &|_| {}) diff --git a/crates/format/src/atomic/tests.rs b/crates/format/src/atomic/tests.rs index eef149d476..8e71bc7ce3 100644 --- a/crates/format/src/atomic/tests.rs +++ b/crates/format/src/atomic/tests.rs @@ -31,74 +31,6 @@ fn assert_no_temp(dir: &Path) { const ORIGINAL: &[u8] = b"the user's precious original document"; -#[test] -fn streaming_commit_runs_after_encoding_and_before_replacement() { - let dir = TempDir::new("stream-commit"); - let path = dir.0.join("document.psb"); - std::fs::write(&path, ORIGINAL).unwrap(); - let encoded = Cell::new(false); - let result = atomic_write_stream_with_commit( - &path, - |file| { - file.write_all(b"complete")?; - encoded.set(true); - Ok(()) - }, - || { - assert!(encoded.get()); - assert_eq!(std::fs::read(&path).unwrap(), ORIGINAL); - Err(io::Error::new(io::ErrorKind::Interrupted, "cancelled before commit")) - }, - ); - assert_eq!(result.unwrap_err().kind(), io::ErrorKind::Interrupted); - assert_eq!(std::fs::read(&path).unwrap(), ORIGINAL); - assert_no_temp(&dir.0); - - atomic_write_stream_with_commit(&path, |file| file.write_all(b"complete"), || Ok(())).unwrap(); - assert_eq!(std::fs::read(&path).unwrap(), b"complete"); - assert_no_temp(&dir.0); -} - -#[test] -fn streaming_commit_unwind_removes_temporary_and_keeps_original() { - let dir = TempDir::new("stream-commit-unwind"); - let path = dir.0.join("document.psb"); - std::fs::write(&path, ORIGINAL).unwrap(); - let result = std::panic::catch_unwind(|| atomic_write_stream_with_commit(&path, |file| file.write_all(b"complete"), || panic!("injected commit panic"))); - assert!(result.is_err()); - assert_eq!(std::fs::read(&path).unwrap(), ORIGINAL); - assert_no_temp(&dir.0); -} - -#[test] -fn seekable_encoder_failure_keeps_original_and_cleans_temp() { - use std::io::{Seek, SeekFrom}; - let dir = TempDir::new("stream"); - let path = dir.0.join("document.psb"); - std::fs::write(&path, ORIGINAL).unwrap(); - for kind in [io::ErrorKind::StorageFull, io::ErrorKind::Interrupted] { - assert!( - atomic_write_stream(&path, |file| { - file.write_all(b"partial")?; - file.seek(SeekFrom::Start(0))?; - file.write_all(b"PATCH")?; - Err(io::Error::new(kind, "injected encoder failure")) - }) - .is_err() - ); - assert_eq!(std::fs::read(&path).unwrap(), ORIGINAL); - assert_no_temp(&dir.0); - } - atomic_write_stream(&path, |file| { - file.write_all(b"0000payload")?; - file.seek(SeekFrom::Start(0))?; - file.write_all(b"0007") - }) - .unwrap(); - assert_eq!(std::fs::read(&path).unwrap(), b"0007payload"); - assert_no_temp(&dir.0); -} - /// Real file system with failures injected at chosen steps. #[derive(Default)] struct Faulty { @@ -333,19 +265,3 @@ fn sync_file_flushes_a_local_file() { let file = std::fs::OpenOptions::new().write(true).create_new(true).open(&path).unwrap(); sync_file(&file).unwrap(); } - -#[test] -fn streaming_encoder_unwind_closes_and_removes_temporary_file() { - let d = TempDir::new("stream-unwind"); - let p = d.0.join("doc.psb"); - std::fs::write(&p, ORIGINAL).unwrap(); - let result = std::panic::catch_unwind(|| { - let _ = atomic_write_stream(&p, |file| { - file.write_all(b"partial output")?; - panic!("injected encoder unwind"); - }); - }); - assert!(result.is_err()); - assert_eq!(std::fs::read(&p).unwrap(), ORIGINAL); - assert_no_temp(&d.0); -} diff --git a/crates/io/examples/scratch_psb_probe.rs b/crates/io/examples/scratch_psb_probe.rs deleted file mode 100644 index 99c210aae0..0000000000 --- a/crates/io/examples/scratch_psb_probe.rs +++ /dev/null @@ -1,36 +0,0 @@ -//! Read-only large-document import probe. Never exports or changes the input file. -use photocraft_raster::spill; -use std::time::Instant; - -fn main() -> Result<(), String> { - let mut args = std::env::args().skip(1); - let path = args.next().ok_or("usage: scratch_psb_probe ")?; - let dir = args.next().ok_or("missing scratch directory")?; - spill::configure(Some(std::path::Path::new(&dir)), 1 << 30)?; - let start = Instant::now(); - let result = photocraft_io::import_path_with(&path, std::path::Path::new(&path), &photocraft_raster::Interrupt::NONE).map_err(|e| e.to_string())?; - spill::settle(); - let d = &result.document; - println!( - "import_seconds={:.3} size={}x{} depth={:?} mode={:?} warnings={:?}", - start.elapsed().as_secs_f64(), - d.size.width, - d.size.height, - d.depth, - d.mode, - result.warnings - ); - for (_, _, layer) in d.walk() { - if let Some(surface) = layer.surface() { - // Access source pixels directly, without creating a GPU canvas or flattening. - let bounds = surface.tile_bounds(); - let x = bounds.x0.saturating_add(bounds.width() as i32 / 2); - let y = bounds.y0.saturating_add(bounds.height() as i32 / 2); - println!("layer={:?} tiles={} bounds={bounds:?} sample={:?}", layer.name, surface.tile_count(), surface.pixel(x, y)); - } - } - spill::check_integrity()?; - println!("scratch={}", serde_json::to_string(&spill::stats()).map_err(|e| e.to_string())?); - drop(result); - spill::configure(None, 0) -} diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 0cb2b481f9..936bab32b9 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -41,8 +41,6 @@ pub mod camera_raw_xmp; mod channel_map; pub mod comps_map; pub mod effects_map; -#[cfg(not(target_arch = "wasm32"))] -mod exr_native; pub mod exr_parts; mod flat; mod gradient_bake; @@ -57,8 +55,6 @@ mod pdn; pub mod pixels; mod psd_export; mod psd_import; -#[cfg(not(target_arch = "wasm32"))] -mod psd_stream; pub mod psd_thumbnail; pub mod raw; pub mod slices_map; @@ -245,120 +241,6 @@ pub fn import(name: &str, bytes: &[u8]) -> Result { import_with_svg_group_depth(name, bytes, photocraft_doc::MAX_GROUP_DEPTH) } -/// Native seekable import. Layered PSD/PSB and flat scanline EXR decode bounded bands directly into tiles; -/// the bytes API remains available for web and for formats without a seekable decoder. -#[cfg(not(target_arch = "wasm32"))] -pub fn import_path_with(name: &str, path: &std::path::Path, ctl: &photocraft_raster::Interrupt<'_>) -> Result { - import_path_with_svg_group_depth(name, path, photocraft_doc::MAX_GROUP_DEPTH, ctl) -} - -/// Seekable import preserving the caller's SVG rasterization preference on fallback. -#[cfg(not(target_arch = "wasm32"))] -pub fn import_path_with_svg_group_depth( - name: &str, - path: &std::path::Path, - max_svg_group_depth: usize, - ctl: &photocraft_raster::Interrupt<'_>, -) -> Result { - use std::io::Read; - ctl.check().map_err(|_| IoError::Cancelled)?; - photocraft_compose::discard_unreadable_pixel_caches(); - photocraft_raster::spill::check_integrity().map_err(IoError::Unsupported)?; - let before = photocraft_raster::spill::read_error_generation(); - let mut source = std::fs::File::open(path).map_err(|e| IoError::Unsupported(e.to_string()))?; - let original = source.metadata().map_err(|e| IoError::Unsupported(e.to_string()))?; - let size = original.len(); - let mut magic = [0; 4]; - let n = source.read(&mut magic).map_err(|e| IoError::Unsupported(e.to_string()))?; - if n == 4 && is_psd(&magic) { - let budget = (photocraft_raster::memory::stats().1.transient_bytes / 8).min(128 << 20); - let _metadata = photocraft_raster::memory::try_reserve(budget.saturating_mul(3)).map_err(IoError::Unsupported)?; - let index = photocraft_psd::stream::Index::read(&mut source, budget, &|| ctl.cancelled()) - .map_err(|e| if ctl.cancelled() { IoError::Cancelled } else { e.into() })?; - if psd_stream::eligible(&index) { - if psd_import::group_depth(&index.metadata) > photocraft_doc::MAX_GROUP_DEPTH { - return Err(IoError::Unsupported("PSD layer nesting exceeds the document limit".into())); - } - let mut result = psd_stream::import(&|| std::fs::File::open(path), &index, *ctl)?; - let current = std::fs::metadata(path).map_err(|e| IoError::Unsupported(e.to_string()))?; - if current.len() != original.len() || current.modified().ok() != original.modified().ok() { - return Err(IoError::Unsupported("the source file changed while it was being opened; open it again".into())); - } - result.document.name = name.into(); - photocraft_raster::spill::check_since(before).map_err(IoError::Unsupported)?; - ctl.check().map_err(|_| IoError::Cancelled)?; - return Ok(result); - } - } - if n == 4 - && photocraft_codecs::detect(&magic) == Some(photocraft_codecs::Format::OpenExr) - && let Some(mut result) = exr_native::import(&mut source, ctl)? - { - let current = std::fs::metadata(path).map_err(|e| IoError::Unsupported(e.to_string()))?; - if current.len() != original.len() || current.modified().ok() != original.modified().ok() { - return Err(IoError::Unsupported("the source file changed while it was being opened; open it again".into())); - } - result.document.name = name.into(); - photocraft_raster::spill::check_since(before).map_err(IoError::Unsupported)?; - ctl.check().map_err(|_| IoError::Cancelled)?; - return Ok(result); - } - drop(source); - let _bytes = photocraft_raster::memory::try_reserve(size) - .map_err(|_| IoError::Unsupported("this format requires a whole-file decoder and exceeds the working-memory budget".into()))?; - let bytes = photocraft_format::read_file(path).map_err(|e| IoError::Unsupported(e.to_string()))?; - import_with_svg_group_depth_and_interrupt(name, &bytes, max_svg_group_depth, ctl) -} - -/// Read from handles already authorized by the caller. Independent opens prevent channel -/// readers sharing a seek cursor, and no ambient filesystem access occurs in this function. -#[cfg(not(target_arch = "wasm32"))] -pub fn import_seekable_with( - name: &str, - open: &dyn Fn() -> std::io::Result, - ctl: &photocraft_raster::Interrupt<'_>, -) -> Result { - use std::io::SeekFrom; - ctl.check().map_err(|_| IoError::Cancelled)?; - photocraft_compose::discard_unreadable_pixel_caches(); - photocraft_raster::spill::check_integrity().map_err(IoError::Unsupported)?; - let before = photocraft_raster::spill::read_error_generation(); - let mut source = open().map_err(|e| IoError::Unsupported(e.to_string()))?; - let size = source.seek(SeekFrom::End(0)).map_err(|e| IoError::Unsupported(e.to_string()))?; - source.seek(SeekFrom::Start(0)).map_err(|e| IoError::Unsupported(e.to_string()))?; - let mut magic = [0; 4]; - let n = source.read(&mut magic).map_err(|e| IoError::Unsupported(e.to_string()))?; - if n == 4 && is_psd(&magic) { - let budget = (photocraft_raster::memory::stats().1.transient_bytes / 8).min(128 << 20); - let _metadata = photocraft_raster::memory::try_reserve(budget.saturating_mul(3)).map_err(IoError::Unsupported)?; - let index = photocraft_psd::stream::Index::read(&mut source, budget, &|| ctl.cancelled()) - .map_err(|e| if ctl.cancelled() { IoError::Cancelled } else { e.into() })?; - if psd_stream::eligible(&index) { - if psd_import::group_depth(&index.metadata) > photocraft_doc::MAX_GROUP_DEPTH { - return Err(IoError::Unsupported("PSD layer nesting exceeds the document limit".into())); - } - let mut result = psd_stream::import(open, &index, *ctl)?; - result.document.name = name.into(); - photocraft_raster::spill::check_since(before).map_err(IoError::Unsupported)?; - ctl.check().map_err(|_| IoError::Cancelled)?; - return Ok(result); - } - } - if n == 4 - && photocraft_codecs::detect(&magic) == Some(photocraft_codecs::Format::OpenExr) - && let Some(mut result) = exr_native::import(&mut source, ctl)? - { - result.document.name = name.into(); - photocraft_raster::spill::check_since(before).map_err(IoError::Unsupported)?; - ctl.check().map_err(|_| IoError::Cancelled)?; - return Ok(result); - } - source.seek(SeekFrom::Start(0)).map_err(|e| IoError::Unsupported(e.to_string()))?; - let _working = photocraft_raster::memory::try_reserve_operation(size).map_err(IoError::Unsupported)?; - let bytes = photocraft_format::read::read_all(&mut source, size).map_err(|e| IoError::Unsupported(e.to_string()))?; - import_with(name, &bytes, ctl) -} - /// [`import`] for a background open: checks `ctl` between stages (and per layer for PSD/PSB) and /// reports progress. A cancelled import fails with [`IoError::Cancelled`]. pub fn import_with(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt) -> Result { @@ -477,17 +359,6 @@ pub fn export(doc: &Document, name_or_ext: &str, opts: &ExportOptions) -> Result /// export fails with [`IoError::Cancelled`]; otherwise the bytes are exactly [`export`]'s. pub fn export_with(doc: &Document, name_or_ext: &str, opts: &ExportOptions, ctl: &photocraft_raster::Interrupt) -> Result { ctl.check().map_err(|_| IoError::Cancelled)?; - // The byte API has to retain its complete output. Native large saves use the seekable - // path below; reject an inadmissible legacy working set before building channel arrays. - let tiles: u64 = doc - .walk() - .iter() - .filter_map(|(_, _, l)| l.surface()) - .map(|s| (s.tile_count() as u64).saturating_mul(256 * 256).saturating_mul(s.format().bytes_per_pixel() as u64)) - .sum(); - let estimate = doc.size.area().saturating_mul(32).saturating_add(tiles.saturating_mul(3)); - let _working = photocraft_raster::memory::try_reserve_operation(estimate) - .map_err(|_| IoError::Unsupported("export exceeds the working-memory budget; use native PSB streaming or export a smaller region".into()))?; photocraft_compose::discard_unreadable_pixel_caches(); photocraft_raster::spill::check_integrity().map_err(IoError::Unsupported)?; let scratch_generation = photocraft_raster::spill::read_error_generation(); @@ -498,42 +369,6 @@ pub fn export_with(doc: &Document, name_or_ext: &str, opts: &ExportOptions, ctl: Ok(r) } -/// Native atomic save. PSB writes metadata and bounded original-pixel bands directly to disk; -/// formats with only a bytes encoder use the checked working-set admission in [`export`]. -#[cfg(not(target_arch = "wasm32"))] -pub fn export_path_with(doc: &Document, path: &std::path::Path, opts: &ExportOptions, ctl: &photocraft_raster::Interrupt<'_>) -> Result, IoError> { - ctl.check().map_err(|_| IoError::Cancelled)?; - let ext = path.extension().and_then(|s| s.to_str()).unwrap_or("").to_ascii_lowercase(); - if ext == "psb" || ext == "psd" && (opts.force_psb || doc.size.width > 30_000 || doc.size.height > 30_000) { - photocraft_raster::spill::check_integrity().map_err(IoError::Unsupported)?; - let mut warnings = Vec::new(); - let result = photocraft_format::atomic::atomic_write_stream_with_commit( - path, - |out| { - warnings = psd_stream::export_into(doc, out, *ctl).map_err(|e| std::io::Error::other(e.to_string()))?; - Ok(()) - }, - || ctl.check().map_err(|e| std::io::Error::new(std::io::ErrorKind::Interrupted, e)), - ); - if result.is_err() && ctl.cancelled() { - return Err(IoError::Cancelled); - } - result.map_err(|e| IoError::Unsupported(e.to_string()))?; - return Ok(warnings); - } - let result = export(doc, &path.to_string_lossy(), opts)?; - ctl.check().map_err(|_| IoError::Cancelled)?; - photocraft_format::atomic_write(path, &result.bytes).map_err(|e| IoError::Unsupported(e.to_string()))?; - Ok(result.warnings) -} - -/// Write a native PSB to an already authorized seekable output. The caller owns atomic -/// publication and cleanup; this never opens an ambient path. -#[cfg(not(target_arch = "wasm32"))] -pub fn export_psb_into(doc: &Document, out: &mut W, ctl: &photocraft_raster::Interrupt<'_>) -> Result, IoError> { - psd_stream::export_into(doc, out, *ctl) -} - /// Run `f` with an interrupt whose progress `0..=1` fills `lo..=hi` of `ctl`'s (same cancellation). pub(crate) fn with_span(ctl: &photocraft_raster::Interrupt, lo: f32, hi: f32, f: impl FnOnce(&photocraft_raster::Interrupt) -> R) -> R { let cancelled = || ctl.cancelled(); diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index e2117b237e..9db3d4799f 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -81,8 +81,6 @@ struct Ex { patterns: Vec, /// The layer written as Photoshop's Background (see [`psd_background`]). background: Option, - deferred: bool, - sources: StreamSources, } /// The layer Photoshop should open as its Background: the bottom top-level layer when it is a @@ -105,14 +103,10 @@ fn psd_background(doc: &Document) -> Option { return None; } let n = s.channels(); - let opaque = canvas - .tiles() - .all(|tc| s.try_read_region(tc.rect().intersect(&canvas)).is_ok_and(|pixels| pixels.chunks_exact(n).all(|p| p.last().is_some_and(|a| *a >= 1.0)))); + let opaque = s.read_region(canvas).chunks_exact(n).all(|p| p.last().is_some_and(|a| *a >= 1.0)); opaque.then_some(l.id) } -pub(crate) type StreamSources = std::collections::HashMap, Option)>; - /// How deep embedded documents are exported inside each other before a smart object is written /// as pixels instead. const MAX_NESTING: u32 = 8; @@ -342,13 +336,10 @@ impl Ex { } fn empty_channels(&self) -> Vec { - if self.deferred { - return (-1..self.cc as i16).map(|id| ChannelData { id, compression: Some(Compression::Raw), data: Vec::new() }).collect(); - } (-1..self.cc as i16).map(|id| self.encode(id, &[], 0, 0)).collect() } - fn pixel_channels(&mut self, s: &Surface, name: &str) -> (PsdRect, Vec, Vec, Option) { + fn pixel_channels(&self, s: &Surface, name: &str) -> (PsdRect, Vec, Vec) { let mut warnings = Vec::new(); let s = if s.format() != self.fmt { warnings.push(format!("layer \"{name}\": pixels converted from {:?} to {:?}", s.format(), self.fmt)); @@ -358,11 +349,7 @@ impl Ex { }; let r = s.content_bounds(); if r.is_empty() { - return (PsdRect::default(), self.empty_channels(), warnings, None); - } - if self.deferred { - let channels = (-1..self.cc as i16).map(|id| ChannelData { id, compression: Some(Compression::Raw), data: Vec::new() }).collect(); - return (to_psd_rect(r), channels, warnings, Some(s)); + return (PsdRect::default(), self.empty_channels(), warnings); } // The interleaved gather is the sequential pass for full-canvas layers, so it runs // in row bands on all cores and the band results concatenate in order. @@ -386,15 +373,15 @@ impl Ex { let order: Vec<(i16, &Vec)> = std::iter::once((-1, &planes[self.cc])).chain(planes.iter().take(self.cc).enumerate().map(|(c, p)| (c as i16, p))).collect(); let ch = crate::pixels::par_map(order, |(id, plane)| self.encode(id, plane, w, h)); - (to_psd_rect(r), ch, warnings, None) + (to_psd_rect(r), ch, warnings) } - fn mask(&self, m: &LayerMask, vector: Option<(f32, f32)>) -> (MaskData, Option, Option) { + fn mask(&self, m: &LayerMask, vector: Option<(f32, f32)>) -> (MaskData, Option) { let s = if m.surface.format() != self.mask_fmt { m.surface.convert(self.mask_fmt) } else { m.surface.clone() }; let default = s.default_pixel().first().copied().unwrap_or(0.0); let r = s.content_bounds(); let (w, h) = (r.width() as usize, r.height() as usize); - let plane = if self.deferred || r.is_empty() { Vec::new() } else { deinterleave(&s.to_interleaved(r), 1, self.mask_fmt.sample, &[false]).remove(0) }; + let plane = if r.is_empty() { Vec::new() } else { deinterleave(&s.to_interleaved(r), 1, self.mask_fmt.sample, &[false]).remove(0) }; let mut flags = 0u8; if !m.linked { flags |= PsdMask::FLAG_RELATIVE; @@ -415,11 +402,7 @@ impl Ex { real: None, real_first: true, }; - if self.deferred { - (MaskData::Mask(pm), Some(ChannelData { id: -2, compression: Some(Compression::Raw), data: Vec::new() }), Some(s)) - } else { - (MaskData::Mask(pm), Some(self.encode(-2, &plane, w, h)), None) - } + (MaskData::Mask(pm), Some(self.encode(-2, &plane, w, h))) } /// Prefer numbers above imported layer IDs, wrapping safely at u32::MAX. @@ -441,18 +424,16 @@ impl Ex { fn record(&mut self, l: &Layer, extra: Vec, pixels: Option<&Surface>) -> LayerRecord { let id = self.layer_id(l); - let (rect, mut channels, warnings, source) = match pixels { + let (rect, mut channels, warnings) = match pixels { Some(s) => self.pixel_channels(s, &l.name), - None => (PsdRect::default(), self.empty_channels(), Vec::new(), None), + None => (PsdRect::default(), self.empty_channels(), Vec::new()), }; self.warnings.extend(warnings); let mut mask = MaskData::None; - let mut mask_source = None; // Vector mask density/feather live in the mask parameters, with or without a user mask. let vector = l.vector_mask.as_ref().map(|vm| (vm.density, vm.feather)); if let Some(m) = &l.mask { - let (md, ch, s) = self.mask(m, vector); - mask_source = s; + let (md, ch) = self.mask(m, vector); mask = md; channels.extend(ch); } else if let Some(parameters) = mask_parameters(None, vector) { @@ -467,9 +448,6 @@ impl Ex { real_first: true, }); } - if self.deferred { - self.sources.insert(id, (source, mask_source)); - } // A Background carries no transparency channel: that is how Photoshop tells it apart. if self.background == Some(l.id) { channels.retain(|c| c.id != -1); @@ -1000,7 +978,7 @@ impl Ex { } let doc = photocraft_format::load_from_bytes(bytes).map_err(|e| format!("its contents can't be read: {e}"))?; let (file, warnings) = - document_to_psd_nested(&doc, &PsdExportOptions { force_psb: true, ..Default::default() }, self.smart.depth + 1, &Interrupt::NONE, None); + document_to_psd_nested(&doc, &PsdExportOptions { force_psb: true, ..Default::default() }, self.smart.depth + 1, &Interrupt::NONE); let data = file.to_bytes().map_err(|e| format!("its contents can't be written: {e}"))?; let stem = file_name.rsplit_once('.').map_or(file_name, |(a, _)| a); self.warnings.extend(warnings.into_iter().map(|w| format!("smart object {stem}: {w}"))); @@ -1365,21 +1343,14 @@ pub fn document_to_psd(doc: &Document) -> PsdFile { /// anything that could not be represented. The merged composite is rendered /// with `photocraft_compose::flatten`. pub fn document_to_psd_with(doc: &Document, opts: &PsdExportOptions) -> (PsdFile, Vec) { - document_to_psd_nested(doc, opts, 0, &Interrupt::NONE, None) -} - -#[cfg(not(target_arch = "wasm32"))] -pub(crate) fn stream_metadata(doc: &Document, opts: &PsdExportOptions) -> (PsdFile, Vec, StreamSources) { - let mut sources = StreamSources::new(); - let (file, warnings) = document_to_psd_nested(doc, opts, 0, &Interrupt::NONE, Some(&mut sources)); - (file, warnings, sources) + document_to_psd_nested(doc, opts, 0, &Interrupt::NONE) } /// [`document_to_psd_with`] for a background save (#3389): reports progress through `ctl` after /// each layer record and each band of the merged composite. Once `ctl` is cancelled it stops /// early and returns an **incomplete** model: callers check `ctl` and discard it. pub(crate) fn document_to_psd_interruptible(doc: &Document, opts: &PsdExportOptions, ctl: &Interrupt) -> (PsdFile, Vec) { - document_to_psd_nested(doc, opts, 0, ctl, None) + document_to_psd_nested(doc, opts, 0, ctl) } /// How much of a PSD conversion the merged composite weighs, in layer records: rendering it @@ -1509,13 +1480,7 @@ pub(crate) fn psd_size_exceeds_limit(doc: &Document) -> bool { } /// [`document_to_psd_with`] for a document embedded `depth` smart objects deep. -fn document_to_psd_nested( - doc: &Document, - opts: &PsdExportOptions, - depth: u32, - ctl: &Interrupt, - deferred: Option<&mut StreamSources>, -) -> (PsdFile, Vec) { +fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, ctl: &Interrupt) -> (PsdFile, Vec) { if doc.mode == ColorMode::Multichannel { return crate::multichannel_map::document_to_psd(doc, opts.force_psb); } @@ -1545,8 +1510,6 @@ fn document_to_psd_nested( smart: SmartOut::new(doc, depth), patterns: doc.patterns.clone(), background: psd_background(doc), - deferred: deferred.is_some(), - sources: Default::default(), }; if big && !opts.force_psb { ex.warnings.push("document exceeds 30000 px; written as PSB".into()); @@ -1621,9 +1584,7 @@ fn document_to_psd_nested( // isn't rendered at all. A document without layer records keeps the real image: its pixels // live nowhere else. let real_merged = opts.maximize_compatibility || ex.records.is_empty(); - let (mut planes, has_alpha) = if ex.deferred { - (Vec::new(), true) - } else if real_merged { + let (mut planes, has_alpha) = if real_merged { // Merged composite, rendered and encoded in bands (no full-size float composite). Matting // against white only changes pixels with alpha < 1; if some are slightly translucent but // all round to opaque (so no alpha channel is written), encode once more without the matte. @@ -1661,9 +1622,6 @@ fn document_to_psd_nested( None => None, }; for a in &extra { - if ex.deferred { - continue; - } let s = if a.surface.format() != ex.mask_fmt { a.surface.convert(ex.mask_fmt) } else { a.surface.clone() }; let bytes = s.to_interleaved(canvas); planes.extend(deinterleave(&bytes, 1, sample, &[false]).remove(0)); @@ -1671,13 +1629,9 @@ fn document_to_psd_nested( let channels = (cc + usize::from(has_alpha) + extra.len()) as u16; let header = Header::new(version, doc.size.width, doc.size.height, channels, psd_depth(sample), psd_mode(fmt.mode)); let mcomp = if sample == SampleType::F32 { Compression::Raw } else { opts.merged_compression }; - let image_data = if ex.deferred { - ImageData { compression: Compression::Raw, data: Vec::new() } - } else { - ImageData::encode(mcomp, &planes, &header) - .or_else(|_| ImageData::encode(Compression::Raw, &planes, &header)) - .unwrap_or(ImageData { compression: Compression::Raw, data: planes }) - }; + let image_data = ImageData::encode(mcomp, &planes, &header) + .or_else(|_| ImageData::encode(Compression::Raw, &planes, &header)) + .unwrap_or(ImageData { compression: Compression::Raw, data: planes }); ctl.progress(1.0); // Resources. @@ -1823,9 +1777,6 @@ fn document_to_psd_nested( layer_mask_trailing: Vec::new(), image_data, }; - if let Some(out) = deferred { - *out = ex.sources; - } (file, ex.warnings) } diff --git a/crates/io/src/psd_import.rs b/crates/io/src/psd_import.rs index 82814f3762..444c2d6e5b 100644 --- a/crates/io/src/psd_import.rs +++ b/crates/io/src/psd_import.rs @@ -66,7 +66,6 @@ pub(crate) struct Ctx<'a> { /// Layer records decoded so far, out of `total` (progress). pub done: usize, pub total: usize, - pub streamed: Option<&'a std::collections::HashMap<(usize, i16), Surface>>, } fn doc_mode(m: PsdMode) -> Option { @@ -172,9 +171,6 @@ impl Ctx<'_> { } fn record_surface(&mut self, rec: &LayerRecord, name: &str) -> Surface { - if let Some(streamed) = self.streamed { - return streamed.get(&(rec as *const LayerRecord as usize, i16::MIN)).cloned().unwrap_or_else(|| Surface::new(self.fmt)); - } let r = rec.rect; if r.is_empty() || r.size().is_err() { return Surface::new(self.fmt); @@ -235,11 +231,7 @@ impl Ctx<'_> { } }; let mut surface = Surface::with_default(self.mask_fmt, &[f32::from(default) / 255.0]); - if let Some(streamed) = self.streamed { - if let Some(decoded) = streamed.get(&(rec as *const LayerRecord as usize, id)) { - surface = decoded.clone(); - } - } else if !rect.is_empty() + if !rect.is_empty() && let Ok((w, h)) = rect.size() && let Some(plane) = self.channel_plane(rec, id, name) { @@ -665,14 +657,6 @@ pub fn psd_to_document(file: &PsdFile) -> (Document, Vec) { /// [`psd_to_document`] for a background open: checks `ctl` per layer record and reports /// progress. `None` when cancelled. pub fn psd_to_document_with(file: &PsdFile, ctl: &photocraft_raster::Interrupt) -> Option<(Document, Vec)> { - psd_to_document_decoded(file, ctl, None) -} - -pub(crate) fn psd_to_document_decoded<'a>( - file: &'a PsdFile, - ctl: &photocraft_raster::Interrupt<'a>, - streamed: Option<&'a std::collections::HashMap<(usize, i16), Surface>>, -) -> Option<(Document, Vec)> { let h = &file.header; let mut warnings = Vec::new(); let mode = doc_mode(h.color_mode).unwrap_or_else(|| { @@ -770,7 +754,6 @@ pub(crate) fn psd_to_document_decoded<'a>( ctl: *ctl, done: 0, total: file.layers().len(), - streamed, }; for b in file.global_blocks.iter().filter(|b| matches!(&b.key, b"FEid" | b"FXid")) { // Smart-filter caches can be large: a cancelled open stops between blocks. diff --git a/crates/io/src/psd_stream.rs b/crates/io/src/psd_stream.rs deleted file mode 100644 index c11a9e593b..0000000000 --- a/crates/io/src/psd_stream.rs +++ /dev/null @@ -1,395 +0,0 @@ -//! Native import without a whole PSB byte buffer or whole decoded channel buffers. -use crate::{ - ImportResult, IoError, - pixels::{interleave, max_sample, zero_sample}, -}; -use photocraft_color::{ColorMode, PixelFormat, SampleType}; -use photocraft_geom::Rect; -use photocraft_psd::{ - LayerRecord, - stream::{Index, Rows}, -}; -use photocraft_raster::{Interrupt, Surface}; -use std::{ - collections::HashMap, - io::{BufReader, Read, Seek, SeekFrom, Write}, -}; - -pub(crate) fn eligible(index: &Index) -> bool { - let file = &index.metadata; - matches!( - file.header.color_mode, - photocraft_psd::ColorMode::Rgb | photocraft_psd::ColorMode::Grayscale | photocraft_psd::ColorMode::Cmyk | photocraft_psd::ColorMode::Lab - ) && matches!(file.header.depth, 8 | 16 | 32) - && !file.layers().is_empty() - && usize::from(file.header.channels) <= usize::from(file.header.color_mode.color_channels().unwrap_or(0)) + usize::from(file.merged_has_alpha()) -} - -struct Decode<'a, R> { - open: &'a dyn Fn() -> std::io::Result, - index: &'a Index, - ctl: Interrupt<'a>, - done: u64, - total: u64, - scratch_expected: bool, -} - -fn tile_footprint(r: photocraft_psd::Rect, bytes_per_pixel: usize) -> u64 { - if r.is_empty() { - return 0; - } - let side = i64::from(photocraft_geom::TILE_SIZE); - let columns = ((i64::from(r.right) - 1).div_euclid(side) - i64::from(r.left).div_euclid(side) + 1) as u64; - let rows = ((i64::from(r.bottom) - 1).div_euclid(side) - i64::from(r.top).div_euclid(side) + 1) as u64; - columns.saturating_mul(rows).saturating_mul(side as u64 * side as u64).saturating_mul(bytes_per_pixel as u64) -} - -pub(super) fn ram_tile_capacity(scratch: bool, resident: u64, budget: u64, growth: u64) -> Result<(), IoError> { - if !scratch && resident.checked_add(growth).is_none_or(|required| required > budget) { - return Err(IoError::Unsupported("decoded image tiles exceed the available RAM budget; enable a scratch disk or close other documents".into())); - } - Ok(()) -} - -pub(super) fn require_scratch(expected: bool) -> Result<(), IoError> { - if expected && !photocraft_raster::spill::enabled() { - return Err(IoError::Unsupported( - photocraft_raster::spill::last_error().unwrap_or_else(|| "scratch storage was disabled while loading; retry the import".into()), - )); - } - Ok(()) -} - -impl Decode<'_, R> { - fn surface(&mut self, layer: usize, rec: &LayerRecord, ids: &[i16], fmt: PixelFormat, default: &[f32]) -> Result { - let r = if ids.len() == 1 { rec.channel_rect(*ids.first().ok_or_else(|| IoError::Unsupported("empty channel set".into()))?) } else { rec.rect }; - if r.is_empty() { - return Ok(Surface::with_default(fmt, default)); - } - let (width, height) = r.size()?; - let working = - (width as u64).saturating_mul(64).saturating_mul(fmt.bytes_per_pixel() as u64).saturating_mul(3).saturating_add(ids.len() as u64 * (3 << 20)); - let _working = photocraft_raster::memory::try_reserve(working).map_err(IoError::Unsupported)?; - let ranges = self.index.channels.get(layer).ok_or_else(|| IoError::Unsupported("missing indexed layer".into()))?; - let mut readers = Vec::new(); - for id in ids { - readers.push(if let Some(range) = ranges.iter().find(|c| c.id == *id) { - Some(Rows::new( - BufReader::with_capacity(1 << 20, (self.open)().map_err(|e| IoError::Unsupported(e.to_string()))?), - *range, - width, - height, - self.index.metadata.header.depth, - self.index.metadata.header.version, - )?) - } else { - None - }); - } - let mut out = Surface::with_default(fmt, default); - let fill: Vec> = ids.iter().map(|id| if *id == -1 { max_sample(fmt.sample) } else { zero_sample(fmt.sample) }).collect(); - let invert: Vec = ids.iter().map(|id| fmt.mode == ColorMode::Cmyk && *id >= 0).collect(); - let mut planes: Vec> = ids.iter().map(|_| Vec::new()).collect(); - let mut row = Vec::new(); - let mut y = 0usize; - while y < height { - self.ctl.check().map_err(|_| IoError::Cancelled)?; - let rows = (height - y).min(64); - for (reader, plane) in readers.iter_mut().zip(&mut planes) { - plane.clear(); - if let Some(reader) = reader { - for _ in 0..rows { - self.ctl.check().map_err(|_| IoError::Cancelled)?; - reader.read_row(&mut row)?; - plane.extend_from_slice(&row); - } - } - } - let refs: Vec> = readers.iter().zip(&planes).map(|(r, p)| r.as_ref().map(|_| p.as_slice())).collect(); - let mut bytes = interleave(&refs, &fill, width * rows, fmt.sample, &invert); - if fmt.mode == ColorMode::Lab && fmt.sample == SampleType::U16 { - crate::pixels::lab16_chroma(&mut bytes, ids.len(), true); - } - let top = i32::try_from(i64::from(r.top) + y as i64).map_err(|_| IoError::Unsupported("layer coordinates overflow".into()))?; - let bottom = top.checked_add(rows as i32).ok_or_else(|| IoError::Unsupported("layer coordinates overflow".into()))?; - let band = Rect::new(r.left, top, r.right, bottom); - let scratch = photocraft_raster::spill::enabled(); - require_scratch(self.scratch_expected)?; - if !scratch { - let missing = band.tiles().filter(|c| out.tile(*c).is_none()).count() as u64; - let growth = missing.saturating_mul((photocraft_geom::TILE_SIZE as u64).pow(2)).saturating_mul(fmt.bytes_per_pixel() as u64); - ram_tile_capacity(false, photocraft_raster::spill::stats().resident_bytes as u64, photocraft_raster::memory::stats().1.tile_bytes, growth)?; - } - out.write_interleaved(band, &bytes); - // A final band can trigger a scratch write failure too. - require_scratch(self.scratch_expected)?; - y += rows; - self.done = self.done.saturating_add(rows as u64 * ids.len() as u64); - self.ctl.progress(0.05 + 0.85 * self.done as f32 / self.total.max(1) as f32); - } - for reader in readers.into_iter().flatten() { - reader.finish()?; - } - out.prune(); - require_scratch(self.scratch_expected)?; - Ok(out) - } -} - -pub(crate) fn import(open: &dyn Fn() -> std::io::Result, index: &Index, ctl: Interrupt<'_>) -> Result { - let file = &index.metadata; - let mode = match file.header.color_mode { - photocraft_psd::ColorMode::Rgb => ColorMode::Rgb, - photocraft_psd::ColorMode::Grayscale => ColorMode::Grayscale, - photocraft_psd::ColorMode::Cmyk => ColorMode::Cmyk, - photocraft_psd::ColorMode::Lab => ColorMode::Lab, - _ => return Err(IoError::Unsupported("stream color mode".into())), - }; - let sample = crate::pixels::sample_for_depth(file.header.depth); - let fmt = PixelFormat::new(mode, sample, true); - let scratch_expected = photocraft_raster::spill::enabled(); - if !scratch_expected { - let budget = photocraft_raster::memory::refresh(); - let required = file.layers().iter().fold(0_u64, |sum, rec| { - let mut bytes = tile_footprint(rec.rect, fmt.bytes_per_pixel()); - for id in [-2, -3] { - if rec.channel(id).is_some() { - bytes = bytes.saturating_add(tile_footprint(rec.channel_rect(id), sample.bytes())); - } - } - sum.saturating_add(bytes) - }); - ram_tile_capacity(false, photocraft_raster::spill::stats().resident_bytes as u64, budget.tile_bytes, required)?; - } - let total = file.layers().iter().map(|r| r.channel_rect(0).size().map_or(0, |(_, h)| h as u64 * (mode.color_channels() as u64 + 1))).sum(); - let mut decoder = Decode { open, index, ctl, done: 0, total, scratch_expected }; - let mut surfaces = HashMap::new(); - for (i, rec) in file.layers().iter().enumerate() { - ctl.check().map_err(|_| IoError::Cancelled)?; - let key = rec as *const LayerRecord as usize; - let ids: Vec = (0..mode.color_channels() as i16).chain([-1]).collect(); - surfaces.insert((key, i16::MIN), decoder.surface(i, rec, &ids, fmt, &vec![0.0; fmt.channels()])?); - for id in [-2, -3] { - if rec.channel(id).is_some() { - let default = rec.layer_mask().map_or(0, |m| if id == -3 { m.real.map_or(m.default_color, |m| m.background) } else { m.default_color }); - let fmt = PixelFormat::new(ColorMode::Grayscale, sample, false); - surfaces.insert((key, id), decoder.surface(i, rec, &[id], fmt, &[f32::from(default) / 255.0])?); - } - } - } - let (document, warnings) = crate::psd_import::psd_to_document_decoded(file, &ctl, Some(&surfaces)).ok_or(IoError::Cancelled)?; - Ok(ImportResult { document, warnings, source_read_only: false, preview_only: false }) -} - -fn sink_error(e: std::io::Error) -> photocraft_psd::PsdError { - photocraft_psd::PsdError::Invalid(format!("stream output: {e}")) -} - -pub(crate) fn export_into(doc: &photocraft_doc::Document, out: &mut W, ctl: Interrupt<'_>) -> Result, IoError> { - if !matches!(doc.mode, ColorMode::Rgb | ColorMode::Grayscale | ColorMode::Cmyk | ColorMode::Lab) { - return Err(IoError::Unsupported("seekable PSB export requires RGB, Gray, CMYK or Lab".into())); - } - ctl.check().map_err(|_| IoError::Cancelled)?; - let before = photocraft_raster::spill::read_error_generation(); - let fmt = doc.pixel_format(); - let metadata: u64 = doc - .metadata - .psd_resources - .iter() - .map(|(_, _, bytes)| bytes.len() as u64) - .chain(doc.metadata.psd_global_blocks.iter().map(|(_, _, bytes)| bytes.len() as u64)) - .chain(doc.walk().iter().flat_map(|(_, _, l)| l.psd_blocks.iter().map(|(_, b)| b.len() as u64))) - .fold(0_u64, u64::saturating_add); - let embedded = doc - .walk() - .iter() - .filter_map(|(_, _, l)| match &l.content { - photocraft_doc::LayerContent::Smart(sm) => match &sm.source { - photocraft_doc::SmartSource::Embedded { bytes, .. } => Some(bytes.len() as u64), - _ => None, - }, - _ => None, - }) - .fold(0_u64, u64::saturating_add); - let working = u64::from(doc.size.width) - .saturating_mul(64) - .saturating_mul(128) - .saturating_add(64 << 20) - .saturating_add(metadata.saturating_mul(4)) - .saturating_add(embedded.saturating_mul(4)); - let _working = photocraft_raster::memory::try_reserve(working).map_err(IoError::Unsupported)?; - let opts = crate::psd_export::PsdExportOptions { force_psb: true, ..Default::default() }; - let (file, warnings, sources) = crate::psd_export::stream_metadata(doc, &opts); - let check = || -> photocraft_psd::Result<()> { - if ctl.cancelled() { - return Err(photocraft_psd::PsdError::Invalid("stream export cancelled".into())); - } - photocraft_raster::spill::check_since(before).map_err(photocraft_psd::PsdError::Invalid) - }; - let result = photocraft_psd::stream::write_layered( - &file, - out, - |i, id, out| { - check()?; - let rec = file.layers().get(i).ok_or_else(|| photocraft_psd::PsdError::Invalid("missing output record".into()))?; - let source = rec.layer_id().and_then(|id| sources.get(&id)).and_then(|(pixels, mask)| if id == -2 { mask.as_ref() } else { pixels.as_ref() }); - let r = rec.channel_rect(id); - if r.is_empty() { - return Ok(()); - } - let s = source.ok_or_else(|| photocraft_psd::PsdError::Invalid("missing output pixel source".into()))?; - let r = Rect::new(r.left, r.top, r.right, r.bottom); - let channels = s.format().channels(); - let channel = if id == -1 { channels.saturating_sub(1) } else { id.max(0) as usize }; - let mut invert = vec![s.format().mode == ColorMode::Cmyk; channels]; - if (id < 0 || s.format().alpha) - && let Some(a) = invert.last_mut() - { - *a = false; - } - let mut y = r.y0; - while y < r.y1 { - check()?; - let band = Rect::new(r.x0, y, r.x1, y.saturating_add(64).min(r.y1)); - let mut bytes = s.to_interleaved(band); - if s.format().mode == ColorMode::Lab && s.format().sample == SampleType::U16 { - crate::pixels::lab16_chroma(&mut bytes, channels, false); - } - let planes = crate::pixels::deinterleave(&bytes, channels, s.format().sample, &invert); - out.write_all(planes.get(channel).ok_or_else(|| photocraft_psd::PsdError::Invalid("invalid output channel".into()))?).map_err(sink_error)?; - y = band.y1; - } - Ok(()) - }, - |out| { - check()?; - let start = out.stream_position().map_err(sink_error)?; - let row_bytes = u64::from(doc.size.width) * fmt.sample.bytes() as u64; - let plane_bytes = - row_bytes.checked_mul(u64::from(doc.size.height)).ok_or_else(|| photocraft_psd::PsdError::Invalid("output size overflow".into()))?; - let cc = fmt.mode.color_channels(); - let white = photocraft_raster::from_rgba(&fmt, [1.0; 4]); - let space = photocraft_compose::cmyk_space(doc); - photocraft_compose::render_bands(doc, doc.bounds(), 64, |band| -> photocraft_psd::Result<()> { - check()?; - let mut planes: Vec> = (0..=cc).map(|_| Vec::with_capacity(band.px.len() * fmt.sample.bytes())).collect(); - photocraft_color::convert::with_cmyk_space(space.as_ref(), || { - let mut v = [0.0; 5]; - for p in &band.px { - photocraft_raster::from_rgba_into(&fmt, *p, &mut v); - for (c, plane) in planes.iter_mut().enumerate() { - let value = v.get(c).copied().unwrap_or(0.0); - let value = if c < cc { - crate::pixels::matte(value, v.get(cc).copied().unwrap_or(0.0), white.get(c).copied().unwrap_or(1.0)) - } else { - value - }; - let value = if fmt.mode == ColorMode::Cmyk && c < cc { 1.0 - value } else { value }; - let value = if fmt.mode == ColorMode::Lab && fmt.sample == SampleType::U16 && matches!(c, 1 | 2) { - value * crate::pixels::LAB16_CHROMA_MAX / 65535.0 - } else { - value - }; - crate::pixels::encode_be(value, fmt.sample, plane); - } - } - }); - for (c, bytes) in planes.iter().enumerate() { - let offset = start - .checked_add(c as u64 * plane_bytes) - .and_then(|v| v.checked_add(band.rect.y0 as u64 * row_bytes)) - .ok_or_else(|| photocraft_psd::PsdError::Invalid("output offset overflow".into()))?; - out.seek(SeekFrom::Start(offset)).map_err(sink_error)?; - out.write_all(bytes).map_err(sink_error)?; - } - ctl.progress(0.5 + 0.5 * band.rect.y1 as f32 / doc.size.height.max(1) as f32); - Ok(()) - })?; - for (i, channel) in doc.channels.iter().chain(doc.quick_mask.iter()).take(usize::from(file.header.channels).saturating_sub(cc + 1)).enumerate() { - let index = cc + 1 + i; - let mask_fmt = photocraft_color::PixelFormat::new(ColorMode::Grayscale, fmt.sample, false); - let converted = (channel.surface.format() != mask_fmt).then(|| channel.surface.convert(mask_fmt)); - let surface = converted.as_ref().unwrap_or(&channel.surface); - let mut y = 0; - while y < doc.size.height { - check()?; - let end = y.saturating_add(64).min(doc.size.height); - let r = Rect::from_xywh(0, y as i32, doc.size.width, end - y); - let bytes = surface.to_interleaved(r); - let plane = crate::pixels::deinterleave(&bytes, 1, surface.format().sample, &[false]); - out.seek(SeekFrom::Start(start + index as u64 * plane_bytes + u64::from(y) * row_bytes)).map_err(sink_error)?; - out.write_all(plane.first().ok_or_else(|| photocraft_psd::PsdError::Invalid("missing extra output plane".into()))?).map_err(sink_error)?; - y = end; - } - } - let end = start - .checked_add(u64::from(file.header.channels).saturating_mul(plane_bytes)) - .ok_or_else(|| photocraft_psd::PsdError::Invalid("output size overflow".into()))?; - out.seek(SeekFrom::Start(end)).map_err(sink_error)?; - check() - }, - ); - if ctl.cancelled() { - return Err(IoError::Cancelled); - } - result?; - photocraft_raster::spill::check_since(before).map_err(IoError::Unsupported)?; - Ok(warnings) -} - -#[cfg(test)] -mod memory_tests { - use super::*; - use photocraft_psd::{Compression, Version}; - use std::io::Cursor; - - #[test] - fn tile_footprint_counts_partial_negative_tiles_at_each_depth() { - let side = photocraft_geom::TILE_SIZE as u64; - for bpp in [1, 2, 4, 8, 16, 20] { - let rect = photocraft_psd::Rect::from_xywh(-1, -1, 2, 2); - assert_eq!(tile_footprint(rect, bpp), 4 * side * side * bpp as u64); - let aligned = photocraft_psd::Rect::from_xywh(-(side as i32), 0, side as u32, side as u32); - assert_eq!(tile_footprint(aligned, bpp), side * side * bpp as u64); - } - assert_eq!(tile_footprint(photocraft_psd::Rect::default(), 16), 0); - let hostile = photocraft_psd::Rect { top: i32::MIN, left: i32::MIN, bottom: i32::MAX, right: i32::MAX }; - assert_eq!(tile_footprint(hostile, 16), u64::MAX); - } - - #[test] - fn ram_only_admission_accounts_for_live_tiles_and_overflow() { - assert!(ram_tile_capacity(false, 60, 100, 40).is_ok()); - assert!(ram_tile_capacity(false, 60, 100, 41).is_err()); - assert!(ram_tile_capacity(false, u64::MAX, u64::MAX, 1).is_err()); - assert!(ram_tile_capacity(true, 60, 100, 1000).is_ok()); - } - - #[test] - fn oversized_ram_only_import_is_rejected_before_opening_channels() { - assert!(!photocraft_raster::spill::enabled()); - let file = photocraft_psd::testgen::layered(Version::Psb, photocraft_psd::ColorMode::Rgb, 32, Compression::Raw); - let bytes = file.to_bytes().unwrap(); - let mut index = Index::read(&mut Cursor::new(&bytes), 128 << 20, &|| false).unwrap(); - // Valid dimensions would need over a terabyte; the source remains a tiny fixture. - index.metadata.layers_mut()[0].rect = photocraft_psd::Rect::from_xywh(0, 0, 300_000, 300_000); - let opened = std::cell::Cell::new(0); - let result = import( - &|| { - opened.set(opened.get() + 1); - Ok(Cursor::new(&bytes)) - }, - &index, - Interrupt::NONE, - ); - assert!(matches!(result, Err(IoError::Unsupported(ref message)) if message.contains("enable a scratch disk"))); - assert_eq!(opened.get(), 0); - } - - #[test] - fn missing_scratch_is_not_silently_treated_as_ram_only() { - assert!(!photocraft_raster::spill::enabled()); - assert!(require_scratch(false).is_ok()); - assert!(require_scratch(true).is_err()); - } -} diff --git a/crates/io/tests/streaming_psd.rs b/crates/io/tests/streaming_psd.rs deleted file mode 100644 index 0a8038b3ac..0000000000 --- a/crates/io/tests/streaming_psd.rs +++ /dev/null @@ -1,136 +0,0 @@ -#![cfg(not(target_arch = "wasm32"))] -use photocraft_psd::{ColorMode, Compression, Version}; -use photocraft_raster::Interrupt; - -// Native imports and exports reserve the same process-wide working-memory allowance. -// Keep fidelity fixtures from rejecting each other solely because the test runner is parallel. -static WORKING_MEMORY_TESTS: std::sync::Mutex<()> = std::sync::Mutex::new(()); - -#[test] -fn seekable_import_matches_byte_import_pixels_masks_and_metadata() { - let _test = WORKING_MEMORY_TESTS.lock().unwrap(); - let path = std::env::temp_dir().join(format!("photocraft-stream-parity-{}.psb", std::process::id())); - for version in [Version::Psd, Version::Psb] { - for mode in [ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Cmyk, ColorMode::Lab] { - for depth in [8, 16, 32] { - for compression in Compression::ALL { - let mut file = photocraft_psd::testgen::layered(version, mode, depth, compression); - // The streaming layer path need not consume the stored merged image. - file.header.channels = mode.color_channels().unwrap() + u16::from(file.merged_has_alpha()); - let bytes = file.to_bytes().unwrap(); - std::fs::write(&path, &bytes).unwrap(); - let a = photocraft_io::import("d", &bytes).unwrap(); - let b = photocraft_io::import_path_with("d", &path, &Interrupt::NONE).unwrap(); - let authorized = photocraft_io::import_seekable_with("d", &|| std::fs::File::open(&path), &Interrupt::NONE).unwrap(); - assert_eq!( - photocraft_compose::render(&authorized.document, authorized.document.bounds()).px, - photocraft_compose::render(&b.document, b.document.bounds()).px - ); - assert_eq!(a.warnings, b.warnings, "{version:?}/{mode:?}/{depth}/{compression:?}"); - let aa = photocraft_compose::render(&a.document, a.document.bounds()); - let bb = photocraft_compose::render(&b.document, b.document.bounds()); - assert_eq!(aa.px, bb.px, "{version:?}/{mode:?}/{depth}/{compression:?}"); - let options = photocraft_io::PsdExportOptions::default(); - let (a, _) = photocraft_io::document_to_psd_with(&a.document, &options); - let (b, _) = photocraft_io::document_to_psd_with(&b.document, &options); - assert_eq!(a, b, "layer, mask and unknown-block round trip: {version:?}/{mode:?}/{depth}/{compression:?}"); - } - } - } - } - std::fs::remove_file(path).unwrap(); -} - -#[test] -fn cancelled_and_truncated_imports_publish_no_document() { - let _test = WORKING_MEMORY_TESTS.lock().unwrap(); - let path = std::env::temp_dir().join(format!("photocraft-stream-cancel-{}.psb", std::process::id())); - let mut file = photocraft_psd::testgen::layered(Version::Psb, ColorMode::Rgb, 8, Compression::Zip); - file.header.channels = 3 + u16::from(file.merged_has_alpha()); - let bytes = file.to_bytes().unwrap(); - std::fs::write(&path, &bytes).unwrap(); - assert!(matches!(photocraft_io::import_path_with("d", &path, &Interrupt::cancel_only(&|| true)), Err(photocraft_io::IoError::Cancelled))); - std::fs::write(&path, &bytes[..bytes.len() / 2]).unwrap(); - assert!(photocraft_io::import_path_with("d", &path, &Interrupt::NONE).is_err()); - std::fs::remove_file(path).unwrap(); -} - -#[test] -fn seekable_export_preserves_merged_layers_masks_and_unknown_metadata() { - let _test = WORKING_MEMORY_TESTS.lock().unwrap(); - let path = std::env::temp_dir().join(format!("photocraft-export-parity-{}.psb", std::process::id())); - for mode in [ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Cmyk, ColorMode::Lab] { - for depth in [8, 16, 32] { - let input = photocraft_psd::testgen::layered(Version::Psb, mode, depth, Compression::ZipPrediction); - let doc = photocraft_io::import("input", &input.to_bytes().unwrap()).unwrap().document; - let (reference, _) = photocraft_io::document_to_psd_with(&doc, &photocraft_io::PsdExportOptions { force_psb: true, ..Default::default() }); - photocraft_io::export_path_with(&doc, &path, &Default::default(), &Interrupt::NONE).unwrap(); - let output = photocraft_psd::PsdFile::from_bytes(&std::fs::read(&path).unwrap()).unwrap(); - assert_eq!(output.header, reference.header, "{mode:?}/{depth}"); - assert_eq!(output.resources, reference.resources); - let normalized_reference = photocraft_psd::PsdFile::from_bytes(&reference.to_bytes().unwrap()).unwrap(); - assert_eq!(output.global_blocks, normalized_reference.global_blocks); - assert_eq!(output.image_data.decode(&output.header).unwrap(), reference.image_data.decode(&reference.header).unwrap(), "merged {mode:?}/{depth}"); - let loaded = photocraft_io::import_path_with("output", &path, &Interrupt::NONE).unwrap(); - let (loaded, _) = photocraft_io::document_to_psd_with(&loaded.document, &photocraft_io::PsdExportOptions { force_psb: true, ..Default::default() }); - assert_eq!(loaded.to_bytes().unwrap(), reference.to_bytes().unwrap(), "layer/mask/metadata {mode:?}/{depth}"); - } - } - std::fs::remove_file(path).unwrap(); -} - -#[test] -fn cancelled_stream_save_keeps_destination_and_removes_temporary_output() { - let _test = WORKING_MEMORY_TESTS.lock().unwrap(); - let dir = std::env::temp_dir().join(format!("photocraft-export-cancel-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("kept.psb"); - std::fs::write(&path, b"precious original").unwrap(); - let input = photocraft_psd::testgen::layered(Version::Psb, ColorMode::Rgb, 8, Compression::Raw); - let doc = photocraft_io::import("input", &input.to_bytes().unwrap()).unwrap().document; - let calls = std::sync::atomic::AtomicUsize::new(0); - let cancel = || calls.fetch_add(1, std::sync::atomic::Ordering::Relaxed) > 5; - assert!(matches!( - photocraft_io::export_path_with(&doc, &path, &Default::default(), &Interrupt::cancel_only(&cancel)), - Err(photocraft_io::IoError::Cancelled) - )); - assert_eq!(std::fs::read(&path).unwrap(), b"precious original"); - assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1); - std::fs::remove_dir_all(dir).unwrap(); -} - -#[cfg(feature = "corpus")] -#[test] -fn native_path_import_matches_byte_import_across_real_psd_corpus() { - let _test = WORKING_MEMORY_TESTS.lock().unwrap(); - fn collect(p: &std::path::Path, out: &mut Vec) { - for e in std::fs::read_dir(p).unwrap().flatten() { - let p = e.path(); - if p.is_dir() { - collect(&p, out); - } else if p.extension().is_some_and(|x| x.eq_ignore_ascii_case("psd") || x.eq_ignore_ascii_case("psb")) { - out.push(p); - } - } - } - let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/psd"); - assert!(root.is_dir(), "run cargo xtask corpus --all"); - let mut files = Vec::new(); - collect(&root, &mut files); - let mut compared = 0; - for path in files { - let bytes = std::fs::read(&path).unwrap(); - let Ok(a) = photocraft_io::import("d", &bytes) else { continue }; - let b = photocraft_io::import_path_with("d", &path, &Interrupt::NONE).unwrap_or_else(|e| panic!("{}: {e}", path.display())); - assert_eq!(a.warnings, b.warnings, "{}", path.display()); - assert_eq!( - photocraft_compose::render(&a.document, a.document.bounds()).px, - photocraft_compose::render(&b.document, b.document.bounds()).px, - "{}", - path.display() - ); - compared += 1; - } - assert!(compared >= 20, "{compared} corpus files compared"); - eprintln!("seekable corpus parity: {compared} files"); -} diff --git a/crates/io/tests/svg.rs b/crates/io/tests/svg.rs index fd56d35b86..d1baf418fa 100644 --- a/crates/io/tests/svg.rs +++ b/crates/io/tests/svg.rs @@ -93,23 +93,6 @@ fn svg_group_rasterization_threshold_is_configurable_and_clamped() { let LayerContent::Group(outer) = &unlimited.document.layers[0].content else { panic!("outer group should remain editable") }; assert!(outer.children.iter().any(|child| matches!(child.content, LayerContent::Group(_))), "zero disables the preference threshold"); - #[cfg(not(target_arch = "wasm32"))] - { - let path = std::env::temp_dir().join(format!("photocraft-svg-depth-{}.svg", std::process::id())); - std::fs::write(&path, source.as_bytes()).unwrap(); - for (depth, reference) in [(1, &shallow), (0, &unlimited)] { - let seekable = photocraft_io::import_path_with_svg_group_depth("nested.svg", &path, depth, &photocraft_raster::Interrupt::NONE).unwrap(); - assert_eq!(seekable.warnings, reference.warnings); - let LayerContent::Group(outer) = &seekable.document.layers[0].content else { panic!("outer group should remain editable") }; - assert!(outer.children.iter().any(|child| if depth == 1 { - matches!(child.content, LayerContent::Raster(_)) - } else { - matches!(child.content, LayerContent::Group(_)) - })); - } - std::fs::remove_file(path).unwrap(); - } - let clamped = import_with_svg_group_depth("nested.svg", source.as_bytes(), usize::MAX).unwrap(); assert!(matches!(clamped.document.layers[0].content, LayerContent::Group(_)), "out-of-range settings clamp to the document's hard group limit"); } diff --git a/crates/psd/src/layer.rs b/crates/psd/src/layer.rs index 42036a994b..5450ed7e67 100644 --- a/crates/psd/src/layer.rs +++ b/crates/psd/src/layer.rs @@ -544,7 +544,7 @@ impl LayerRecord { ch.decode(w, h, depth, version) } - pub(crate) fn read(r: &mut Reader<'_>, version: Version) -> Result<(Self, Vec)> { + fn read(r: &mut Reader<'_>, version: Version) -> Result<(Self, Vec)> { let rect = Rect::read(r)?; let nch = r.u16()?; if nch > MAX_LAYER_CHANNELS { @@ -586,7 +586,7 @@ impl LayerRecord { Ok((LayerRecord { rect, channels, blend_mode, opacity, clipping, flags, filler, mask, blending_ranges, name, blocks, extra_trailing }, lens)) } - pub(crate) fn write(&self, out: &mut Vec, version: Version) -> Result<()> { + fn write(&self, out: &mut Vec, version: Version) -> Result<()> { self.rect.write(out); let nch = u16::try_from(self.channels.len()).map_err(|_| PsdError::LimitExceeded("too many channels"))?; out.put_u16(nch); diff --git a/crates/psd/src/lib.rs b/crates/psd/src/lib.rs index 32efb0267c..00d73c4003 100644 --- a/crates/psd/src/lib.rs +++ b/crates/psd/src/lib.rs @@ -64,7 +64,6 @@ pub mod pixels; pub mod resources; pub mod shc; pub mod slices; -pub mod stream; pub mod tagged; #[cfg(any(test, feature = "testgen"))] // Test-data generator (tests and fuzz seeds only): failing loudly is the point. diff --git a/crates/psd/src/stream.rs b/crates/psd/src/stream.rs deleted file mode 100644 index 3023b5750a..0000000000 --- a/crates/psd/src/stream.rs +++ /dev/null @@ -1,499 +0,0 @@ -//! Seekable PSD/PSB metadata and row decoding. Pixel ranges stay in the source; no whole-file -//! or whole-channel buffer is created. `metadata` deliberately contains no encoded pixels. -use std::io::{Read, Seek, SeekFrom, Take, Write}; - -use crate::image_data::ImageData; -use crate::io::Reader; -use crate::{Compression, GlobalLayerMask, Header, LayerInfo, LayerInfoPlacement, LayerRecord, PlaneLayout, PsdError, PsdFile, Result, TaggedBlock, Version}; - -fn io(e: std::io::Error) -> PsdError { - PsdError::invalid(format!("stream I/O: {e}")) -} -fn invalid() -> PsdError { - PsdError::invalid("invalid stream range") -} -fn array(r: &mut impl Read) -> Result<[u8; N]> { - let mut b = [0; N]; - r.read_exact(&mut b).map_err(io)?; - Ok(b) -} -fn length(r: &mut impl Read, long: bool) -> Result { - Ok(if long { u64::from_be_bytes(array(r)?) } else { u64::from(u32::from_be_bytes(array(r)?)) }) -} - -#[derive(Clone, Copy, Debug)] -/// One checked encoded layer-channel range, excluding its compression marker. -pub struct ChannelRange { - /// Photoshop channel id (negative for transparency and masks). - pub id: i16, - /// Absolute byte offset in the source. - pub offset: u64, - /// Encoded byte count. - pub length: u64, - /// Channel codec. - pub compression: Compression, -} - -/// Metadata and independent source ranges; encoded pixels are never retained in this model. -pub struct Index { - /// Parsed metadata only. Do not serialize this model: pixel channels are in `channels`. - pub metadata: PsdFile, - /// Ranges per layer record, in stored order. - pub channels: Vec>, -} - -struct Scan<'a, R> { - reader: &'a mut R, - allowance: u64, - cancelled: &'a dyn Fn() -> bool, -} -impl Scan<'_, R> { - fn position(&mut self) -> Result { - self.reader.stream_position().map_err(io) - } - fn end(&mut self, length: u64, boundary: u64) -> Result { - self.position()?.checked_add(length).filter(|end| *end <= boundary).ok_or_else(invalid) - } - fn bytes(&mut self, n: u64, boundary: u64) -> Result> { - self.end(n, boundary)?; - self.allowance = self.allowance.checked_sub(n).ok_or(PsdError::LimitExceeded("stream metadata exceeds working-memory budget"))?; - let n = usize::try_from(n).map_err(|_| invalid())?; - let mut out = Vec::new(); - out.try_reserve_exact(n).map_err(|_| PsdError::LimitExceeded("stream metadata allocation"))?; - out.resize(n, 0); - for chunk in out.chunks_mut(64 << 10) { - if (self.cancelled)() { - return Err(PsdError::invalid("stream cancelled")); - } - self.reader.read_exact(chunk).map_err(io)?; - } - Ok(out) - } - fn layers(&mut self, end: u64, version: Version) -> Result<(LayerInfo, Vec>)> { - let start = self.position()?; - let count = i16::from_be_bytes(array(self.reader)?); - let n = usize::from(count.unsigned_abs()); - if (n as u64).saturating_mul(34) > end.saturating_sub(self.position()?) { - return Err(invalid()); - } - let mut layers = Vec::with_capacity(n); - let mut lengths = Vec::with_capacity(n); - for _ in 0..n { - let mut record = self.bytes(18, end)?; - let nch = u16::from_be_bytes(record.get(16..18).ok_or_else(invalid)?.try_into().map_err(|_| invalid())?); - if nch > crate::layer::MAX_LAYER_CHANNELS { - return Err(PsdError::LimitExceeded("too many layer channels")); - } - let fixed = u64::from(nch) * if version.is_psb() { 10 } else { 6 } + 16; - record.extend(self.bytes(fixed, end)?); - let extra = record.get(record.len().saturating_sub(4)..).ok_or_else(invalid)?; - let extra = u32::from_be_bytes(extra.try_into().map_err(|_| invalid())?); - record.extend(self.bytes(u64::from(extra), end)?); - let (rec, lens) = LayerRecord::read(&mut Reader::new(&record), version)?; - layers.push(rec); - lengths.push(lens); - } - let mut ranges = Vec::with_capacity(n); - for (rec, lens) in layers.iter_mut().zip(lengths) { - let mut channels = Vec::new(); - for (ch, len) in rec.channels.iter_mut().zip(lens) { - if len == 0 { - continue; - } - if len < 2 { - return Err(invalid()); - } - let channel_end = self.end(len, end)?; - let compression = Compression::from_u16(u16::from_be_bytes(array(self.reader)?)); - ch.compression = Some(compression); - channels.push(ChannelRange { id: ch.id, offset: self.position()?, length: len - 2, compression }); - self.reader.seek(SeekFrom::Start(channel_end)).map_err(io)?; - } - ranges.push(channels); - } - let body = self.position()?.saturating_sub(start); - let rest = end.saturating_sub(self.position()?); - let pad = self.bytes(rest, end)?; - let padding = if pad.len() as u64 == body % 2 && pad.iter().all(|b| *b == 0) { None } else { Some(pad) }; - Ok((LayerInfo { merged_alpha: count < 0, layers, padding }, ranges)) - } -} - -impl Index { - /// Index a seekable file with an aggregate metadata cap and cooperative cancellation. - pub fn read(reader: &mut R, metadata_budget: u64, cancelled: &dyn Fn() -> bool) -> Result { - let end = reader.seek(SeekFrom::End(0)).map_err(io)?; - reader.seek(SeekFrom::Start(0)).map_err(io)?; - let header = Header::read(&mut Reader::new(&array::<26>(reader)?))?; - let mut s = Scan { reader, allowance: metadata_budget, cancelled }; - let n = length(s.reader, false)?; - let color_mode_data = s.bytes(n, end)?; - let n = length(s.reader, false)?; - let resources = s.bytes(n, end)?; - let mut resource_bytes = (n as u32).to_be_bytes().to_vec(); - resource_bytes.extend(resources); - let resources = crate::resources::read_section(&mut Reader::new(&resource_bytes))?; - drop(resource_bytes); - let lm_len = length(s.reader, header.version.is_psb())?; - let lm_end = s.end(lm_len, end)?; - let mut layer_info = None; - let mut channels = Vec::new(); - let mut global_layer_mask = None; - let mut global_blocks = Vec::new(); - let mut layer_mask_trailing = Vec::new(); - let mut layer_info_placement = LayerInfoPlacement::Section; - if lm_len > 0 { - let n = length(s.reader, header.version.is_psb())?; - let li_end = s.end(n, lm_end)?; - if n > 0 { - let (info, c) = s.layers(li_end, header.version)?; - layer_info = Some(info); - channels = c; - } - if lm_end.saturating_sub(s.position()?) >= 4 { - let n = length(s.reader, false)?; - global_layer_mask = Some(GlobalLayerMask { data: s.bytes(n, lm_end)? }); - while lm_end.saturating_sub(s.position()?) >= 12 { - let start = s.position()?; - let signature = array::<4>(s.reader)?; - if signature != *b"8BIM" && signature != *b"8B64" { - s.reader.seek(SeekFrom::Start(start)).map_err(io)?; - break; - } - let key = array::<4>(s.reader)?; - let n = length(s.reader, crate::tagged::uses_long_length(header.version, &key))?; - let block_end = s.end(n, lm_end)?; - let is_layers = layer_info.is_none() && matches!(&key, b"Lr16" | b"Lr32" | b"Layr"); - let data = if is_layers { - let (info, c) = s.layers(block_end, header.version)?; - layer_info = Some(info); - channels = c; - Vec::new() - } else { - s.bytes(n, lm_end)? - }; - // Same padding detector as the byte parser, without reading the next block. - let at = s.position()?; - let rest = lm_end.saturating_sub(at); - let peek = s.bytes(rest.min(7), lm_end)?; - s.reader.seek(SeekFrom::Start(at)).map_err(io)?; - let pad = (0..=3usize) - .find(|k| *k as u64 == rest || peek.get(*k..).is_some_and(|b| b.starts_with(b"8BIM") || b.starts_with(b"8B64"))) - .unwrap_or(0); - let bytes = s.bytes(pad as u64, lm_end)?; - let padding = if bytes.len() as u64 == (4 - n % 4) % 4 && bytes.iter().all(|b| *b == 0) { None } else { Some(bytes) }; - if is_layers { - layer_info_placement = LayerInfoPlacement::GlobalBlock { index: global_blocks.len(), signature, key, padding }; - } else { - global_blocks.push(TaggedBlock { signature, key, data, padding }); - } - } - } - let left = lm_end.saturating_sub(s.position()?); - layer_mask_trailing = s.bytes(left, lm_end)?; - } - // The merged image is intentionally not read: layered imports below consume only layers. - let compression = Compression::from_u16(u16::from_be_bytes(array(s.reader)?)); - Ok(Self { - metadata: PsdFile { - header, - color_mode_data, - resources, - layer_info, - layer_info_placement, - global_layer_mask, - global_blocks, - layer_mask_trailing, - image_data: ImageData { compression, data: Vec::new() }, - }, - channels, - }) - } -} - -enum Input { - Raw(Take), - Rle(Take, Vec), - Zip(flate2::read::ZlibDecoder>), -} -/// A sequential channel decoder with bounded row buffers, including ZIP prediction. -pub struct Rows { - input: Input, - layout: PlaneLayout, - prediction: bool, - row: usize, - encoded: Vec, -} -impl Rows { - /// Open a checked range on an independent reader. Clone file handles with shared seek - /// positions must not be used for concurrent channels. - pub fn new(mut reader: R, range: ChannelRange, width: usize, height: usize, depth: u16, version: Version) -> Result { - if width > 300_000 || height > 300_000 || !matches!(depth, 1 | 8 | 16 | 32) { - return Err(PsdError::LimitExceeded("stream channel dimensions")); - } - reader.seek(SeekFrom::Start(range.offset)).map_err(io)?; - let mut source = reader.take(range.length); - let layout = PlaneLayout { planes: 1, width, height, depth, version }; - let input = match range.compression { - Compression::Raw => Input::Raw(source), - Compression::Rle => { - let mut counts = Vec::with_capacity(height); - let mut total = (height as u64).checked_mul(if version.is_psb() { 4 } else { 2 }).ok_or_else(invalid)?; - for _ in 0..height { - let n = - if version.is_psb() { u32::from_be_bytes(array(&mut source)?) as usize } else { usize::from(u16::from_be_bytes(array(&mut source)?)) }; - if n > layout.row_bytes().saturating_mul(2).saturating_add(1024) { - return Err(PsdError::LimitExceeded("encoded RLE row")); - } - total = total.checked_add(n as u64).ok_or_else(invalid)?; - counts.push(n); - } - if total > range.length { - return Err(invalid()); - } - Input::Rle(source, counts) - } - Compression::Zip | Compression::ZipPrediction => Input::Zip(flate2::read::ZlibDecoder::new(source)), - Compression::Unknown(n) => return Err(PsdError::Unsupported(format!("stream compression {n}"))), - }; - Ok(Self { input, layout, prediction: range.compression == Compression::ZipPrediction, row: 0, encoded: Vec::new() }) - } - /// Decode the next native big-endian sample row, reusing `out`. - pub fn read_row(&mut self, out: &mut Vec) -> Result<()> { - if self.row >= self.layout.height { - return Err(invalid()); - } - let bytes = self.layout.row_bytes(); - out.resize(bytes, 0); - match &mut self.input { - Input::Raw(r) => r.read_exact(out).map_err(io)?, - Input::Zip(r) => r.read_exact(out).map_err(io)?, - Input::Rle(r, counts) => { - out.clear(); - self.encoded.resize(*counts.get(self.row).ok_or_else(invalid)?, 0); - r.read_exact(&mut self.encoded).map_err(io)?; - crate::compression::packbits::decode_into(&self.encoded, bytes, out)?; - } - } - if self.prediction { - let row = PlaneLayout { height: 1, ..self.layout }; - crate::compression::unpredict(out, &row)?; - } - self.row += 1; - Ok(()) - } - /// Checks the zlib checksum and rejects extra decoded samples after the expected last row. - pub fn finish(mut self) -> Result<()> { - if self.row != self.layout.height { - return Err(invalid()); - } - let mut extra = [0; 1]; - let n = match &mut self.input { - Input::Raw(r) | Input::Rle(r, _) => r.read(&mut extra), - Input::Zip(r) => r.read(&mut extra), - } - .map_err(io)?; - if n != 0 { - return Err(PsdError::invalid("extra stream channel samples")); - } - Ok(()) - } -} - -fn patch(out: &mut W, at: u64, value: u64) -> Result<()> { - let end = out.stream_position().map_err(io)?; - out.seek(SeekFrom::Start(at)).map_err(io)?; - out.write_all(&value.to_be_bytes()).map_err(io)?; - out.seek(SeekFrom::Start(end)).map_err(io)?; - Ok(()) -} - -fn write_layers(out: &mut W, info: &LayerInfo, pixels: &mut impl FnMut(usize, i16, &mut W) -> Result<()>) -> Result<()> { - let start = out.stream_position().map_err(io)?; - let count = i16::try_from(info.layers.len()).map_err(|_| PsdError::LimitExceeded("too many streamed layers"))?; - out.write_all(&(if info.merged_alpha { -count } else { count }).to_be_bytes()).map_err(io)?; - let mut records = Vec::with_capacity(info.layers.len()); - for rec in &info.layers { - records.push(out.stream_position().map_err(io)?); - let mut encoded = Vec::new(); - rec.write(&mut encoded, Version::Psb)?; - out.write_all(&encoded).map_err(io)?; - } - for (i, rec) in info.layers.iter().enumerate() { - for (c, channel) in rec.channels.iter().enumerate() { - let begin = out.stream_position().map_err(io)?; - if let Some(compression) = channel.compression { - out.write_all(&compression.as_u16().to_be_bytes()).map_err(io)?; - pixels(i, channel.id, out)?; - } - let len = out.stream_position().map_err(io)?.saturating_sub(begin); - let at = records.get(i).copied().ok_or_else(invalid)?.checked_add(20 + c as u64 * 10).ok_or_else(invalid)?; - patch(out, at, len)?; - } - } - let length = out.stream_position().map_err(io)?.saturating_sub(start); - let pad = (4 - length % 4) % 4; - for _ in 0..pad { - out.write_all(&[0]).map_err(io)?; - } - Ok(()) -} - -/// Write a PSB directly to a seekable sink, patching channel and section lengths after each -/// bounded encoded stream. Callbacks supply encoded layer channels and the merged image; this -/// function never buffers their data. Publication/atomic replacement belongs to the caller. -pub fn write_layered( - file: &PsdFile, - out: &mut W, - mut pixels: impl FnMut(usize, i16, &mut W) -> Result<()>, - merged: impl FnOnce(&mut W) -> Result<()>, -) -> Result<()> { - if file.header.version != Version::Psb { - return Err(PsdError::Unsupported("seekable writer requires PSB".into())); - } - file.header.validate()?; - let mut prefix = Vec::new(); - file.header.write(&mut prefix); - prefix.extend(u32::try_from(file.color_mode_data.len()).map_err(|_| invalid())?.to_be_bytes()); - prefix.extend(&file.color_mode_data); - crate::resources::write_section(&mut prefix, &file.resources)?; - out.write_all(&prefix).map_err(io)?; - let lm_at = out.stream_position().map_err(io)?; - out.write_all(&0u64.to_be_bytes()).map_err(io)?; - let start = out.stream_position().map_err(io)?; - let li_at = start; - out.write_all(&0u64.to_be_bytes()).map_err(io)?; - if let (Some(info), LayerInfoPlacement::Section) = (&file.layer_info, &file.layer_info_placement) { - let begin = out.stream_position().map_err(io)?; - write_layers(out, info, &mut pixels)?; - let n = out.stream_position().map_err(io)?.saturating_sub(begin); - patch(out, li_at, n)?; - } - let mask = file.global_layer_mask.as_ref().map_or(&[][..], |m| m.data.as_slice()); - out.write_all(&u32::try_from(mask.len()).map_err(|_| invalid())?.to_be_bytes()).map_err(io)?; - out.write_all(mask).map_err(io)?; - for i in 0..=file.global_blocks.len() { - if let (Some(info), LayerInfoPlacement::GlobalBlock { index, signature, key, padding }) = (&file.layer_info, &file.layer_info_placement) - && i == (*index).min(file.global_blocks.len()) - { - out.write_all(signature).map_err(io)?; - out.write_all(key).map_err(io)?; - let at = out.stream_position().map_err(io)?; - out.write_all(&0u64.to_be_bytes()).map_err(io)?; - let begin = out.stream_position().map_err(io)?; - write_layers(out, info, &mut pixels)?; - let n = out.stream_position().map_err(io)?.saturating_sub(begin); - patch(out, at, n)?; - match padding { - Some(bytes) => out.write_all(bytes).map_err(io)?, - None => out.write_all(&[0u8; 3][..((4 - n % 4) % 4) as usize]).map_err(io)?, - } - } - if let Some(block) = file.global_blocks.get(i) { - let mut bytes = Vec::new(); - crate::tagged::write_blocks(&mut bytes, std::slice::from_ref(block), Version::Psb, 4)?; - out.write_all(&bytes).map_err(io)?; - } - } - out.write_all(&file.layer_mask_trailing).map_err(io)?; - let n = out.stream_position().map_err(io)?.saturating_sub(start); - patch(out, lm_at, n)?; - out.write_all(&file.image_data.compression.as_u16().to_be_bytes()).map_err(io)?; - merged(out) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::io::Cursor; - - #[test] - fn indexed_channels_match_byte_decoder_at_every_depth_and_compression() { - for version in [Version::Psd, Version::Psb] { - for depth in [8, 16, 32] { - for compression in Compression::ALL { - let file = crate::testgen::layered(version, crate::ColorMode::Rgb, depth, compression); - let bytes = file.to_bytes().unwrap(); - let index = Index::read(&mut Cursor::new(&bytes), 32 << 20, &|| false).unwrap(); - assert_eq!(index.metadata.resources, file.resources); - assert_eq!(index.metadata.global_blocks, file.global_blocks); - for (rec, ranges) in index.metadata.layers().iter().zip(&index.channels) { - for range in ranges { - let (w, h) = rec.channel_rect(range.id).size().unwrap(); - let mut rows = Rows::new(Cursor::new(&bytes), *range, w, h, depth, version).unwrap(); - let mut decoded = Vec::new(); - let mut row = Vec::new(); - for _ in 0..h { - rows.read_row(&mut row).unwrap(); - decoded.extend_from_slice(&row); - } - rows.finish().unwrap(); - let original = file.layers().iter().find(|l| l.name == rec.name).unwrap(); - assert_eq!(decoded, original.decode_channel(range.id, depth, version).unwrap()); - } - } - } - } - } - } - - #[test] - fn bounded_index_and_cancel_do_not_allocate_declared_pixel_bytes() { - let file = crate::testgen::layered(Version::Psb, crate::ColorMode::Rgb, 8, Compression::Rle); - let bytes = file.to_bytes().unwrap(); - assert!(Index::read(&mut Cursor::new(&bytes), 2, &|| false).is_err()); - assert!(Index::read(&mut Cursor::new(&bytes), 32 << 20, &|| true).is_err()); - for n in [0, 25, 30, bytes.len() / 2] { - assert!(Index::read(&mut Cursor::new(&bytes[..n]), 32 << 20, &|| false).is_err()); - } - } - - #[test] - fn rows_reject_corrupt_checksum_and_truncated_data() { - let layout = PlaneLayout { planes: 1, width: 10, height: 4, depth: 8, version: Version::Psb }; - let encoded = crate::compression::encode_planes(Compression::Zip, &[1; 40], &layout).unwrap(); - for corrupt in [false, true] { - let mut bytes = encoded.clone(); - if corrupt { - let n = bytes.len(); - bytes[n - 1] ^= 0xff; - } else { - bytes.truncate(bytes.len() - 3); - } - let range = ChannelRange { id: 0, offset: 0, length: bytes.len() as u64, compression: Compression::Zip }; - let mut rows = Rows::new(Cursor::new(&bytes), range, 10, 4, 8, Version::Psb).unwrap(); - let mut row = Vec::new(); - let mut error = false; - for _ in 0..4 { - if rows.read_row(&mut row).is_err() { - error = true; - break; - } - } - assert!(error || rows.finish().is_err(), "truncated or corrupt zlib must fail"); - } - } - - #[test] - fn seekable_writer_patches_layer_channel_and_global_block_lengths() { - for depth in [8, 16, 32] { - for compression in Compression::ALL { - let file = crate::testgen::layered(Version::Psb, crate::ColorMode::Rgb, depth, compression); - let mut out = Cursor::new(Vec::new()); - write_layered( - &file, - &mut out, - |layer, id, out| out.write_all(&file.layers()[layer].channel(id).unwrap().data).map_err(io), - |out| out.write_all(&file.image_data.data).map_err(io), - ) - .unwrap(); - let read = PsdFile::from_bytes(&out.into_inner()).unwrap(); - assert_eq!(read.header, file.header); - assert_eq!(read.global_blocks, file.global_blocks); - assert_eq!(read.resources, file.resources); - assert_eq!(read.image_data, file.image_data); - assert_eq!(read.layers(), file.layers()); - } - } - } -} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 69dc5ddc34..64f9c31556 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -1219,11 +1219,6 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) Err(e) => return err(e), } } - if matches!(path.rsplit('.').next().map(|s| s.to_ascii_lowercase()).as_deref(), Some("psd" | "psb" | "psdt" | "exr")) - && let Some(import) = app.services.automation_import_path.clone() - { - return run_waiting(app, wait, |app| crate::jobs_ui::open_authorized(app, path.to_string(), import)); - } let opened = match app.services.automation_read.as_mut() { Some(read) => read(path), None => Err("automation read authority is not configured".into()), @@ -2332,51 +2327,6 @@ mod tests { assert_eq!(written.borrow().len(), 3, "a bad tgaBits writes nothing"); } - #[test] - fn automation_exr_open_uses_authorized_seekable_import() { - use std::sync::{ - Arc, - atomic::{AtomicUsize, Ordering}, - }; - for source_read_only in [false, true] { - let reads = Arc::new(AtomicUsize::new(0)); - let imported = reads.clone(); - let services = crate::Services { - automation_import_path: Some(Arc::new(move |path, _| { - assert_eq!(path, "in/starmap.EXR"); - imported.fetch_add(1, Ordering::Relaxed); - Ok(photocraft_io::ImportResult { - document: photocraft_doc::Document::new( - "starmap.EXR", - photocraft_geom::Size::new(65536, 32768), - photocraft_color::ColorMode::Rgb, - photocraft_color::SampleType::F32, - ), - warnings: Vec::new(), - source_read_only, - preview_only: source_read_only, - }) - })), - automation_read: Some(Box::new(|_| Err("whole-file read must not run".into()))), - ..Default::default() - }; - let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), services); - let response = call(&mut app, &egui::Context::default(), "app.open", json!({"path": "in/starmap.EXR", "wait": false})); - assert_eq!(response["ok"], true, "{response}"); - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); - while !app.session.jobs().is_empty() && std::time::Instant::now() < deadline { - app.session.poll_jobs(); - std::thread::sleep(std::time::Duration::from_millis(1)); - } - assert!(app.session.jobs().is_empty(), "EXR import did not finish"); - assert_eq!(reads.load(Ordering::Relaxed), 1); - assert_eq!(app.session.active().unwrap().doc.size, photocraft_geom::Size::new(65536, 32768)); - let st = app.session.active().unwrap(); - assert_eq!(st.source_read_only, source_read_only); - assert_eq!(st.path.is_none(), source_read_only, "read-only imports must not keep a source save path"); - } - } - #[test] fn automation_open_and_save_use_the_roots_and_reply_with_warnings() { use photocraft_color::{ColorMode, SampleType}; diff --git a/crates/ui-egui/src/file_open.rs b/crates/ui-egui/src/file_open.rs index 1cec43e5df..d67e56dbf0 100644 --- a/crates/ui-egui/src/file_open.rs +++ b/crates/ui-egui/src/file_open.rs @@ -112,30 +112,8 @@ impl PhotocraftApp { crate::jobs_ui::start_open(self, &display_name(path), Some(path.to_string()), photocraft_engine::jobs::OpenSource::Path(path.to_string()))?; return Ok(Vec::new()); } - let seekable = ext == "exr" || matches!(ext.as_str(), "psd" | "psb" | "psdt") && std::fs::metadata(path).is_ok_and(|m| m.len() >= 64 << 20); - if !seekable { - let bytes = photocraft_format::read_file(std::path::Path::new(path)).map_err(|e| format!("{path}: {e}"))?; - return self.open_file(path, &bytes); - } - let name = self.open_name(&display_name(path)); - let imported = photocraft_io::import_path_with(&name, std::path::Path::new(path), &photocraft_raster::Interrupt::NONE).map_err(|e| e.to_string())?; - let warnings = with_missing_fonts_of(&imported.document, imported.warnings); - let question = self.session.color.open_question(&imported.document); - crate::open_prompt::arrive( - self, - crate::open_prompt::Held { - doc: imported.document, - read_only: imported.source_read_only, - name, - path: Some(path.to_string()), - warnings: warnings.clone(), - bytes: None, - slot: None, - question, - policy_applied: false, - }, - ); - Ok(warnings) + let bytes = photocraft_format::read_file(std::path::Path::new(path)).map_err(|e| format!("{path}: {e}"))?; + self.open_file(path, &bytes) } #[cfg(target_arch = "wasm32")] diff --git a/crates/ui-egui/src/jobs_ui.rs b/crates/ui-egui/src/jobs_ui.rs index 5fc586fe5b..29da41d481 100644 --- a/crates/ui-egui/src/jobs_ui.rs +++ b/crates/ui-egui/src/jobs_ui.rs @@ -249,51 +249,6 @@ pub(crate) fn finish_open_tab(app: &mut PhotocraftApp, tab: &OpenTab, v: &Value) r } -pub(crate) fn open_authorized(app: &mut PhotocraftApp, path: String, import: crate::AutomationImportPathFn) -> Result { - let name = app.open_name(&crate::file_open::display_name(&path)); - let worker_path = path.clone(); - let apply_name = name.clone(); - let apply_path = path.clone(); - match app - .session - .start_job( - OPEN_JOB, - json!({"path": path}), - "Opening", - false, - move |ctx| { - ctx.stage(0.0, 1.0, "Decoding", |ctl| import(&worker_path, ctl)) - .map_err(|e| if ctx.cancelled() { photocraft_engine::EngineError::Cancelled } else { photocraft_engine::EngineError::Other(e) }) - }, - move |session, imported| { - let source_read_only = imported.source_read_only; - let (mut doc, warnings) = (imported.document, imported.warnings); - doc.name = apply_name.clone(); - let document_path = (!source_read_only && !photocraft_engine::file_cmds::is_template(&apply_path)).then(|| apply_path.clone()); - let (index, color) = session.open_document(doc, document_path); - if let Some(st) = session.active_mut() { - st.source_read_only = source_read_only; - } - Ok(json!({"document": index, "name": apply_name, "warnings": warnings, "color": color, "path": apply_path, "automation": true})) - }, - ) - .map_err(|e| e.to_string())? - { - Started::Done(v) => { - finish_open(app, &name, Some(&path), &v, None)?; - Ok(v) - } - Started::Job(job) => { - app.jobs.opens.push(OpenTab { job, name, path: Some(path), slot: None, order: None }); - app.jobs.focus = Some(job); - app.jobs.last_started = Some(job); - app.ui.chrome.home = None; - app.ui.status_error = false; - Ok(json!({"job": job.0, "pending": true})) - } - } -} - /// Save the active document to `path` in the background: `save` encodes and writes it on a /// worker while the window keeps drawing and shows the progress dialog. The document is locked /// until the job ends, which [`tick`] records like a synchronous save. `Ok(None)`: started; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a62ab4c161..04027176b1 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -325,8 +325,6 @@ pub(crate) enum SaveKind { /// Automation: flat formats stay copies (#2579) and hooks never fire. Automation, } -/// Decode a document through handles beneath the desktop control session's authorized root. -pub type AutomationImportPathFn = std::sync::Arc) -> Result + Send + Sync>; /// Read bytes through the desktop control session's authorized read root. pub type AutomationReadFn = Box Result<(String, Vec), String>>; /// Write bytes through the desktop control session's authorized write root. @@ -417,7 +415,6 @@ pub struct Services { /// File access used only by control/MCP requests. Interactive dialogs keep /// using `file_dialog` and `write` with the user's authority. pub automation_read: Option, - pub automation_import_path: Option, pub automation_write: Option, pub automation_open_pcraft: Option, pub automation_command: Option, From 7e29a9999c581d55fc6adc377d7fb4e4c06305e8 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:23:15 +0100 Subject: [PATCH 201/216] Wire EXR band streaming into main's path open - import_file_for_open_with decodes single-part flat scanline EXR block by block into tiles; headers the band decoder refuses fall back to the whole-file decoder, so files main opened still open. Band-time failures and cancellation publish no document. - The RAM tile budget and scratch-disk checks live in exr_native (the PSD stream module they came from is dropped); over-budget opens fail with IoError::OutOfMemory. - The band decoder picks colour channels with exr_color_channels, like the byte decoder. - Tests move into the per-crate it binaries and use import_file; an engine open-job test covers the OpenSource::Path route. The ui-egui sync open test is dropped: main's synchronous open reads bytes, and the io test covers the oversized header. - docs/streaming-io.md, roadmap, scratch-disks and REL-212-1 describe EXR only. --- crates/codecs/src/exr_stream.rs | 18 ++++---- crates/codecs/tests/{ => it}/exr_stream.rs | 4 +- crates/codecs/tests/it/main.rs | 1 + crates/engine/src/jobs/tests.rs | 32 +++++++++++++ crates/io/src/exr_native.rs | 44 +++++++++++++----- crates/io/src/lib.rs | 23 +++++++++- crates/io/tests/it/main.rs | 1 + crates/io/tests/{ => it}/streaming_exr.rs | 24 +++++----- crates/ui-egui/tests/exr_open.rs | 44 ------------------ docs/roadmap.md | 2 +- docs/scorecard.md | 2 +- docs/scratch-disks.md | 4 +- docs/streaming-io.md | 53 +++++++++------------- scorecard/reliability.toml | 2 +- 14 files changed, 139 insertions(+), 115 deletions(-) rename crates/codecs/tests/{ => it}/exr_stream.rs (98%) rename crates/io/tests/{ => it}/streaming_exr.rs (82%) delete mode 100644 crates/ui-egui/tests/exr_open.rs diff --git a/crates/codecs/src/exr_stream.rs b/crates/codecs/src/exr_stream.rs index a1d5818a16..1ed8ba1276 100644 --- a/crates/codecs/src/exr_stream.rs +++ b/crates/codecs/src/exr_stream.rs @@ -141,15 +141,17 @@ impl Decoder { if list.is_empty() || list.len() > 64 || list.iter().any(|c| c.sampling.0 != 1 || c.sampling.1 != 1) { return Err(unsupported("EXR channel count or subsampling")); } + // The same channels the whole-file decoder shows, so both routes open the same image. let names: Vec = list.iter().map(|c| c.name.to_string()).collect(); - let find = |name| names.iter().position(|n| n.rsplit('.').next() == Some(name)); - let (selected, gray) = if let (Some(r), Some(g), Some(b)) = (find("R"), find("G"), find("B")) { - (vec![Some(r), Some(g), Some(b), find("A")], false) - } else if let Some(y) = find("Y").or_else(|| (list.len() == 1).then_some(0)) { - (vec![Some(y), find("A")], true) - } else { - return Err(unsupported("no R/G/B or Y channels")); - }; + let (layout, picked) = crate::image::exr_color_channels(names.iter().map(String::as_str)).ok_or_else(|| unsupported("no R/G/B or Y channels"))?; + let gray = matches!(layout, crate::ChannelLayout::Gray | crate::ChannelLayout::GrayA); + let mut selected: Vec> = picked.into_iter().map(Some).collect(); + if !matches!(layout, crate::ChannelLayout::GrayA | crate::ChannelLayout::Rgba) { + selected.push(None); + } + if selected.len() != if gray { 2 } else { 4 } { + return Err(unsupported("EXR channel selection")); + } // The document path currently tags Rec. 709 linear samples. Keep custom // primaries on the existing decoder until their profiles can be mapped. if !gray && header.shared_attributes.chromaticities.is_some() { diff --git a/crates/codecs/tests/exr_stream.rs b/crates/codecs/tests/it/exr_stream.rs similarity index 98% rename from crates/codecs/tests/exr_stream.rs rename to crates/codecs/tests/it/exr_stream.rs index acf03e8ae1..65be2387b9 100644 --- a/crates/codecs/tests/exr_stream.rs +++ b/crates/codecs/tests/it/exr_stream.rs @@ -135,8 +135,8 @@ fn mixed_samples_and_offset_data_window_keep_coordinates_and_values() { while let Some(band) = decoder.next_band(&|| false).unwrap() { assert_eq!(band.y, rows); rows += band.height; - for p in band.data.chunks_exact(16) { - let v: Vec = p.chunks_exact(4).map(|s| f32::from_le_bytes(s.try_into().unwrap())).collect(); + for p in band.data.as_chunks::<16>().0 { + let v: Vec = p.as_chunks::<4>().0.iter().map(|s| f32::from_le_bytes(*s)).collect(); assert_eq!(v, [-0.25, 3.5, 42.0, 1.0]); } } diff --git a/crates/codecs/tests/it/main.rs b/crates/codecs/tests/it/main.rs index 4293fb4a48..72e878c44e 100644 --- a/crates/codecs/tests/it/main.rs +++ b/crates/codecs/tests/it/main.rs @@ -10,6 +10,7 @@ mod detect_caps; mod dpx; mod exr_cryptomatte; mod exr_multipart; +mod exr_stream; mod fidelity; mod gif_frames; mod heif; diff --git a/crates/engine/src/jobs/tests.rs b/crates/engine/src/jobs/tests.rs index 0974a5c17a..271162e3c6 100644 --- a/crates/engine/src/jobs/tests.rs +++ b/crates/engine/src/jobs/tests.rs @@ -543,6 +543,38 @@ fn open_runs_as_a_job_and_cancel_adds_nothing() { assert!(matches!(wait_event(&mut s, id).outcome, JobOutcome::Failed(_))); } +/// A flat scanline EXR opened from a path goes through the band decoder in the open job and +/// arrives as the same 32-bit document the whole-file decoder makes; cancelling adds nothing. +#[cfg(any(unix, windows))] +#[test] +fn an_exr_path_open_decodes_in_bands_and_cancels_cleanly() { + use photocraft_codecs::{ChannelLayout, EncodeOptions, ExrCompression, Format, Image, SampleType}; + let values: Vec = (0..64 * 48 * 3).map(|i| [0.25, 3.0, -0.5][i % 3]).collect(); + let image = Image::from_f32(64, 48, ChannelLayout::Rgb, &values).unwrap().convert(ChannelLayout::Rgb, SampleType::F16); + let bytes = photocraft_codecs::encode(&image, Format::OpenExr, &EncodeOptions { exr_compression: ExrCompression::Zip16, ..Default::default() }).unwrap(); + let path = std::env::temp_dir().join(format!("photocraft-open-job-{}.exr", std::process::id())); + std::fs::write(&path, &bytes).unwrap(); + let reference = photocraft_io::import("band.exr", &bytes).unwrap().document; + let mut s = Session::new(); + let id = job(s.start_open("band.exr", OpenSource::Path(path.to_string_lossy().into_owned())).unwrap()); + let e = wait_event(&mut s, id); + assert!(matches!(e.outcome, JobOutcome::Done(_)), "{e:?}"); + let doc = &s.documents()[0].doc; + assert_eq!((doc.size, doc.mode, doc.depth), (reference.size, reference.mode, reference.depth)); + use photocraft_doc::LayerContent; + let (LayerContent::Raster(a), LayerContent::Raster(r)) = (&doc.layers[0].content, &reference.layers[0].content) else { panic!("raster background") }; + assert_eq!(a.to_interleaved(reference.bounds()), r.to_interleaved(reference.bounds())); + + let id = job(s.start_open("again.exr", OpenSource::Path(path.to_string_lossy().into_owned())).unwrap()); + s.cancel_job(id); + let outcome = wait_event(&mut s, id).outcome; + assert!(matches!(outcome, JobOutcome::Cancelled | JobOutcome::Done(_)), "{outcome:?}"); + if outcome == JobOutcome::Cancelled { + assert_eq!(s.documents().len(), 1, "a cancelled open adds no document"); + } + std::fs::remove_file(path).unwrap(); +} + /// Ask When Opening: a file whose profile doesn't match is decoded but not added; the shell takes /// it once the prompt is answered. Without Ask it opens as usual. #[test] diff --git a/crates/io/src/exr_native.rs b/crates/io/src/exr_native.rs index 0d7555bd7b..af76690056 100644 --- a/crates/io/src/exr_native.rs +++ b/crates/io/src/exr_native.rs @@ -6,13 +6,37 @@ use photocraft_geom::{Rect, Size, TILE_SIZE}; use photocraft_raster::{Interrupt, Surface}; use std::io::{Read, Seek}; +/// Tiles the decoded image would add must fit the RAM tile budget when no scratch disk can +/// take cold tiles. +fn ram_tile_capacity(resident: u64, budget: u64, growth: u64) -> Result<(), IoError> { + if resident.checked_add(growth).is_none_or(|required| required > budget) { + return Err(IoError::OutOfMemory("decoded image tiles exceed the available RAM budget; enable a scratch disk or close other documents".into())); + } + Ok(()) +} + +/// A scratch disk that was on when the import started must still be on: its tiles may +/// already have moved there. +fn require_scratch(expected: bool) -> Result<(), IoError> { + if expected && !photocraft_raster::spill::enabled() { + return Err(IoError::Read( + photocraft_raster::spill::last_error().unwrap_or_else(|| "scratch storage was disabled while loading; retry the import".into()), + )); + } + Ok(()) +} + +/// Decode a single-part flat scanline EXR from `source` one compression block at a time. +/// `Ok(None)`: the header is not one the band decoder takes (multi-part, deep, tiled, custom +/// chromaticities, over its block limits or malformed by its stricter checks); the caller +/// falls back to the whole-file decoder. Once pixels are being decoded any failure fails the +/// open, so no partial document is published. pub(crate) fn import(source: R, ctl: &Interrupt<'_>) -> Result, IoError> { - let _metadata = photocraft_raster::memory::try_reserve(32 << 20).map_err(IoError::Unsupported)?; + let _metadata = photocraft_raster::memory::try_reserve(32 << 20).map_err(IoError::OutOfMemory)?; let mut decoder = match photocraft_codecs::exr_stream::Decoder::new(source, &|| ctl.cancelled()) { Ok(decoder) => decoder, Err(_) if ctl.cancelled() => return Err(IoError::Cancelled), - Err(photocraft_codecs::CodecError::Unsupported { .. }) => return Ok(None), - Err(e) => return Err(e.into()), + Err(_) => return Ok(None), }; let info = decoder.info(); let mode = if info.gray { ColorMode::Grayscale } else { ColorMode::Rgb }; @@ -22,19 +46,18 @@ pub(crate) fn import(source: R, ctl: &Interrupt<'_>) -> Result(source: R, ctl: &Interrupt<'_>) -> Result(source: R, ctl: &Interrupt<'_>) -> Result Vec { let values: Vec = (0..31 * 35 * layout.channels()).map(|i| [-0.25, 4.0, 0.0, 0.0001, 0.5][i % 5]).collect(); @@ -13,6 +9,10 @@ fn fixture(layout: ChannelLayout, sample: SampleType, compression: ExrCompressio photocraft_codecs::encode(&image, Format::OpenExr, &EncodeOptions { exr_compression: compression, ..Default::default() }).unwrap() } +fn open(path: &std::path::Path, ctl: &Interrupt) -> Result { + photocraft_io::import_file("HDR", path, &photocraft_io::ImportOptions::default(), ctl) +} + // One test keeps these imports from competing for the global working allowance. #[test] fn native_scanline_import_preserves_hdr_document_and_cancels_without_publication() { @@ -23,9 +23,8 @@ fn native_scanline_import_preserves_hdr_document_and_cancels_without_publication let bytes = fixture(layout, sample, compression); std::fs::write(&path, &bytes).unwrap(); let reference = photocraft_io::import("HDR", &bytes).unwrap(); - let native = photocraft_io::import_path_with("HDR", &path, &Interrupt::NONE).unwrap(); - let authorized = photocraft_io::import_seekable_with("HDR", &|| Ok(Cursor::new(&bytes)), &Interrupt::NONE).unwrap(); - for actual in [native, authorized] { + let actual = open(&path, &Interrupt::NONE).unwrap(); + { let a = &actual.document; let r = &reference.document; assert_eq!(actual.warnings, reference.warnings); @@ -56,9 +55,9 @@ fn native_scanline_import_preserves_hdr_document_and_cancels_without_publication cancelled.store(true, Ordering::Relaxed); } }; - assert!(matches!(photocraft_io::import_path_with("HDR", &path, &Interrupt::new(&cancel, &progress)), Err(photocraft_io::IoError::Cancelled))); + assert!(matches!(open(&path, &Interrupt::new(&cancel, &progress)), Err(photocraft_io::IoError::Cancelled))); std::fs::write(&path, &bytes[..bytes.len() / 2]).unwrap(); - assert!(photocraft_io::import_path_with("HDR", &path, &Interrupt::NONE).is_err()); + assert!(open(&path, &Interrupt::NONE).is_err()); // A syntactically complete huge header/table must be refused before reading // its pixel blocks when scratch is unavailable. No giant fixture is needed. assert!(!photocraft_raster::spill::enabled()); @@ -87,8 +86,7 @@ fn native_scanline_import_preserves_hdr_document_and_cancels_without_publication huge.extend_from_slice(&offset.to_le_bytes()); } huge.extend_from_slice(&[0; 8]); - assert!( - matches!(photocraft_io::import_seekable_with("huge", &|| Ok(Cursor::new(&huge)), &Interrupt::NONE), Err(photocraft_io::IoError::Unsupported(ref message)) if message.contains("decoded image tiles")) - ); + std::fs::write(&path, &huge).unwrap(); + assert!(matches!(open(&path, &Interrupt::NONE), Err(photocraft_io::IoError::OutOfMemory(ref message)) if message.contains("decoded image tiles"))); std::fs::remove_file(path).unwrap(); } diff --git a/crates/ui-egui/tests/exr_open.rs b/crates/ui-egui/tests/exr_open.rs deleted file mode 100644 index ae3b737bec..0000000000 --- a/crates/ui-egui/tests/exr_open.rs +++ /dev/null @@ -1,44 +0,0 @@ -#![cfg(not(target_arch = "wasm32"))] -use photocraft_codecs::{ChannelLayout, EncodeOptions, Format, Image}; -use photocraft_ui_egui::PhotocraftApp; - -#[test] -fn synchronous_native_exr_open_uses_tile_admission_before_huge_pixel_decode() { - let image = Image::from_f32(1, 1, ChannelLayout::Rgb, &[0.5, 2.0, -0.25]).unwrap(); - let bytes = photocraft_codecs::encode(&image, Format::OpenExr, &EncodeOptions::default()).unwrap(); - let mut header_end = 8; - loop { - if bytes[header_end] == 0 { - header_end += 1; - break; - } - for _ in 0..2 { - while bytes[header_end] != 0 { - header_end += 1; - } - header_end += 1; - } - let length = i32::from_le_bytes(bytes[header_end..header_end + 4].try_into().unwrap()) as usize; - header_end += 4 + length; - } - let mut huge = bytes[..header_end].to_vec(); - let needle = b"dataWindow\0box2i\0\x10\0\0\0"; - let at = huge.windows(needle.len()).position(|v| v == needle).unwrap() + needle.len(); - huge[at + 8..at + 12].copy_from_slice(&65535_i32.to_le_bytes()); - huge[at + 12..at + 16].copy_from_slice(&32767_i32.to_le_bytes()); - let offset = (header_end + 2048 * 8) as u64; - for _ in 0..2048 { - huge.extend_from_slice(&offset.to_le_bytes()); - } - huge.extend_from_slice(&[0; 8]); - let path = std::env::temp_dir().join(format!("photocraft-exr-ui-{}.exr", std::process::id())); - std::fs::write(&path, huge).unwrap(); - let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); - app.background_jobs = false; - photocraft_raster::spill::configure(None, 0).unwrap(); - photocraft_raster::memory::configure(photocraft_raster::memory::Policy { percent: None, absolute_bytes: 1 << 30 }); - let error = app.open_path(path.to_str().unwrap()).unwrap_err(); - assert!(error.contains("decoded image tiles"), "{error}"); - assert!(app.session.documents().is_empty()); - std::fs::remove_file(path).unwrap(); -} diff --git a/docs/roadmap.md b/docs/roadmap.md index 1710997e45..d2ca4a8435 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -125,4 +125,4 @@ Each milestone has a **definition of done (DoD)** and must leave `main` green on 2026-10-13: native scratch storage pages current and history tiles through compressed, CRC-checked files. Fresh RAM budgets default to 70%; engine-owned memory supervision stops with its last session. Scratch errors block edit/save commits and permit retry. Unix files are private and stale files swept at startup; Windows files use delete-on-close. See [scratch disks](scratch-disks.md). Streaming import and viewport rendering follow separately. -2026-10-13: seekable native PSD/PSB and supported scanline EXR imports decode bounded bands at the original resolution. Authorized opens retain read-only source protection. See [native streaming I/O](streaming-io.md). +2026-10-13: native path opens of single-part flat scanline EXR files decode one compression block at a time into tiles at the original resolution. See [EXR streaming](streaming-io.md). diff --git a/docs/scorecard.md b/docs/scorecard.md index 4733080bb1..db283ba4be 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -276,7 +276,7 @@ Never-crash attribute on 30 of 30 crate roots. | REL-2 | Every workspace crate carries the never-crash deny(clippy::unwrap_used, ...) attribute | done | | Counted automatically above (crate roots under crates/ and apps/); testkit gained it in #221 | | REL-3 | The app catches escaped panics around command dispatch and import/export | done | | a_panic_in_a_command_... runs the test-only `test.panic` spec through Session::execute and asserts Err plus an unchanged document (engine jobs_tests.rs); the worker guard is covered by a_panic_in_the_worker_...; crash_guard.rs guard_turns_a_panic_into_an_error covers import/export (apps/photocraft) | | REL-4 | Atomic save (temp, fsync, rename) for every document write | done | [#203](https://github.com/storytold/photocraft/issues/203) | photocraft_format::atomic_write (format/src/atomic.rs, #230): same-dir temp, sync_all, rename with Windows PermissionDenied retry, Unix dir fsync; every document write site routes through it and format/src/atomic/tests.rs failure-injects write, rename and read-only-dir errors | -| REL-212-1 | Fallible allocation for paste/erase/fill/transform/save/import, clear out-of-memory error | partial | [#212](https://github.com/storytold/photocraft/issues/212) | Raster region/tile buffers, paste/erase/fill/transform and warp output use fallible reservations; filter and lens working memory is admitted before edits, and current/history tiles spill to CRC-checked scratch. Native layered PSD/PSB and supported scanline EXR imports decode bounded bands; save/import boundaries preserve state; remaining encoder/decoder allocations still need broader audit. | +| REL-212-1 | Fallible allocation for paste/erase/fill/transform/save/import, clear out-of-memory error | partial | [#212](https://github.com/storytold/photocraft/issues/212) | Raster region/tile buffers, paste/erase/fill/transform and warp output use fallible reservations; filter and lens working memory is admitted before edits, and current/history tiles spill to CRC-checked scratch. Native path opens of flat scanline EXR decode bounded blocks into tiles; save/import boundaries preserve state; remaining encoder/decoder allocations still need broader audit. | | REL-212-2 | Export switches to PSB when the estimated file size exceeds 2 GB | done | [#212](https://github.com/storytold/photocraft/issues/212) | PSD export uses checked size estimates for raster, masks, channels, and retained metadata | | REL-212-3 | Tests: an allocation-limit injection makes each command Err with the document unchanged | partial | [#212](https://github.com/storytold/photocraft/issues/212) | Engine failpoint tests cover paste, erase, fill, transform, import and save transaction boundaries; codec-level allocator failure coverage remains open | diff --git a/docs/scratch-disks.md b/docs/scratch-disks.md index bf73af1948..bcc3103bce 100644 --- a/docs/scratch-disks.md +++ b/docs/scratch-disks.md @@ -57,5 +57,5 @@ read must never become saved pixels. Limitation: a tile that was already unreada read from outside the edited document (for example a clipboard copy) is not detected. Scratch storage alone does not make every format, filter or renderer streamable. See -[native PSD/EXR streaming](streaming-io.md) for the seekable import and output APIs. Viewport -rendering is a separate change built on these guards and memory reservations. +[EXR streaming](streaming-io.md) for the block-by-block EXR import. Viewport rendering is a +separate change built on these guards and memory reservations. diff --git a/docs/streaming-io.md b/docs/streaming-io.md index 3cc4d3a674..b7a84de6a2 100644 --- a/docs/streaming-io.md +++ b/docs/streaming-io.md @@ -1,36 +1,27 @@ -# Native PSD/EXR streaming +# EXR streaming -Native path opens inspect a seekable source before allocating decoded pixels. Supported layered -PSD/PSB files and flat scanline EXR files decode bounded bands directly into guarded raster -tiles. Their document dimensions and samples remain at the source resolution; scratch residency -can move cold tiles to disk. The byte decoder remains available for web and unsupported layouts, -subject to working-memory admission rather than a file-sized allocation without a budget. +Native path opens (`photocraft_io::import_file_for_open_with`, used by the background open job +and by automation file opens) decode single-part flat scanline EXR files one compression block +at a time straight into guarded raster tiles. The document keeps the source dimensions and +samples; HALF samples are stored as F32, with a warning. Tiles count against the RAM tile +budget, and scratch residency can move cold tiles to disk; without a scratch disk an image whose +tiles cannot fit the budget fails with a clear out-of-memory error before any pixels are +decoded. Layered PSD/PSB files use the existing streaming PSD reader on the same route. -PSD indexing retains bounded metadata and channel offsets without copying all channel data or -decoding the stored merged image. Independent seekable channel handles feed the existing document -mapping, preserving layers, masks, groups, metadata, colour models and depth. Unsupported nesting -and metadata limits produce errors. The native PSB writer uses the same metadata mapper with -deferred pixel sources and writes original-depth channel bands to a seekable output. +The block decoder (`photocraft_codecs::exr_stream`) treats the file as untrusted. Header metadata +is bounded (8 MiB, 4096 attributes); dimensions, channel count, chunk count, offsets, block +lengths and sample counts are checked against the file length and the header before anything is +allocated, and one decoded block is capped at 64 MiB. Duplicate, misaligned or truncated blocks +fail the open. It picks the same colour channels as the whole-file decoder. -EXR block decoding supports the flat scanline layouts accepted by the streaming decoder, including -the RGB HALF ZIP test image `65536 × 32768`. HALF samples are stored as F32, with a warning; this -does not reduce the document dimensions. Unsupported EXR layouts fall back to the normal codec -when its limits allow them. Chunk sizes, source bounds and transient buffers are checked before -allocation. Cancellation is checked between bands, and progress describes completed work. +Multi-part, deep, tiled, subsampled and custom-chromaticity files, and headers the block decoder +refuses, fall back to the whole-file decoder, under its own limits and working-memory admission. +Web and byte opens always use the whole-file decoder. -Control and CLI opens use the same importer. Authorized control opens create independent handles -under the held read-directory capability, including channel reopens. They do not gain ambient -filesystem access. Path opens check that file size and modification time remain unchanged. Read-only -and preview import flags survive the native and authorized routes, and templates keep an untitled -document without a source save path. +Cancellation is checked between reads and between scanlines, and progress reports completed +lines. A cancelled or failed open publishes no partial document. The open also fails if the +file's size or modification time changed while it was being read. -The seekable output API accepts a caller-owned writer; atomic path output encodes into a temporary -file beside the destination, syncs it using the shared platform helper, then renames it. Encoder -failure, cancellation or unwinding removes the temporary and preserves the old destination. Its -integration with the desktop background-save service is a separate change, using upstream's -`SaveCtl` and document-lock/saved-revision contract. - -Synthetic tests compare streamed import with the byte decoder across PSD/PSB versions, depths, -colour modes and compression, and verify masks, metadata, cancellation, EXR chunk handling, -source protection and capability bounds. Large-file runtime checks use the public fixtures at -[vistaero.es/photocraft](https://vistaero.es/photocraft/). +Tests compare the streamed document with the whole-file decoder across Gray/GrayA/RGB/RGBA, HALF +and FLOAT, and None/RLE/ZIP/ZIPS/PIZ compression, and cover cancellation, truncation, forged +offsets and lengths, hostile metadata and oversized headers. diff --git a/scorecard/reliability.toml b/scorecard/reliability.toml index 7bc708e548..2939a9152a 100644 --- a/scorecard/reliability.toml +++ b/scorecard/reliability.toml @@ -35,7 +35,7 @@ id = "REL-212-1" target = "Fallible allocation for paste/erase/fill/transform/save/import, clear out-of-memory error" status = "partial" issue = 212 -note = "Raster region/tile buffers, paste/erase/fill/transform and warp output use fallible reservations; filter and lens working memory is admitted before edits, and current/history tiles spill to CRC-checked scratch. Native layered PSD/PSB and supported scanline EXR imports decode bounded bands; save/import boundaries preserve state; remaining encoder/decoder allocations still need broader audit." +note = "Raster region/tile buffers, paste/erase/fill/transform and warp output use fallible reservations; filter and lens working memory is admitted before edits, and current/history tiles spill to CRC-checked scratch. Native path opens of flat scanline EXR decode bounded blocks into tiles; save/import boundaries preserve state; remaining encoder/decoder allocations still need broader audit." [[item]] id = "REL-212-2" From 369518632967c9b117de5643c91152451a7c5d76 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:48:22 +0100 Subject: [PATCH 202/216] Consolidate RAM detection in raster::memory::system; scratch-aware PSD/TIFF admission One RAM detector: the cgroup v1/v2, macOS reclaimable and Windows commit providers move from photocraft-io into photocraft_raster::memory::system (L0), which the residency budget (#5330) and photocraft_io::memory both read. Unreadable container limits still fail closed, swap is never counted as RAM. sysinfo is unified on =0.40.1 (raster only). While the scratch disk is spilling (on, not paused), import admission credits the tile pixels of an estimate against spill::scratch_capacity() (free space above the reserve, within the Scratch Size Limit); decode buffers, conversion copies and merged planes stay RAM-only. Spilling off/paused and the web keep the RAM-only policy unchanged. PsdMemoryEstimate gains tile_bytes; the flat TIFF streaming check credits its tile surface the same way; engine approveMemory's fast pre-check uses the credited hard limit and the import re-checks exactly. --- Cargo.lock | 184 +-- crates/engine/src/cmd/file_cmds/open.rs | 6 +- crates/io/Cargo.toml | 10 - crates/io/src/flat.rs | 30 +- crates/io/src/lib.rs | 2 + crates/io/src/memory.rs | 1356 ++++------------------- crates/io/src/psd_budget.rs | 61 +- crates/io/src/psd_budget/tests.rs | 52 +- crates/io/src/psd_import.rs | 4 +- crates/io/src/tiff_layers.rs | 10 +- crates/raster/Cargo.toml | 7 +- crates/raster/src/memory.rs | 17 +- crates/raster/src/memory/system.rs | 1175 ++++++++++++++++++++ crates/raster/src/scratch_file.rs | 2 +- crates/raster/src/spill.rs | 32 +- crates/raster/tests/scratch_capacity.rs | 40 + docs/scratch-disks.md | 9 + 17 files changed, 1651 insertions(+), 1346 deletions(-) create mode 100644 crates/raster/src/memory/system.rs create mode 100644 crates/raster/tests/scratch_capacity.rs diff --git a/Cargo.lock b/Cargo.lock index ae3a99d103..ab0417b79b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -93,8 +93,8 @@ dependencies = [ "accesskit_consumer", "hashbrown 0.17.1", "static_assertions", - "windows 0.62.2", - "windows-core 0.62.2", + "windows", + "windows-core", ] [[package]] @@ -1021,7 +1021,7 @@ dependencies = [ "num-traits", "serde", "wasm-bindgen", - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -2372,8 +2372,8 @@ dependencies = [ "read-fonts", "roxmltree 0.21.1", "smallvec", - "windows 0.62.2", - "windows-core 0.62.2", + "windows", + "windows-core", "yeslogic-fontconfig-sys", ] @@ -2572,7 +2572,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ "rustix 1.1.5", - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -2774,7 +2774,7 @@ dependencies = [ "log", "presser", "thiserror 2.0.21", - "windows 0.62.2", + "windows", ] [[package]] @@ -2998,7 +2998,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core", ] [[package]] @@ -3402,7 +3402,7 @@ dependencies = [ "simd_cesu8", "thiserror 2.0.21", "walkdir", - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -3752,7 +3752,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" dependencies = [ "cfg-if", - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -4841,7 +4841,7 @@ dependencies = [ "libc", "redox_syscall 0.5.18", "smallvec", - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -5310,10 +5310,8 @@ dependencies = [ "resvg", "roxmltree 0.21.1", "serde_json", - "sysinfo 0.40.1", "thiserror 2.0.21", "tiff", - "winsafe", "zune-core 0.5.3", "zune-jpegxl", ] @@ -5382,7 +5380,8 @@ dependencies = [ "proptest", "rayon", "serde", - "sysinfo 0.37.2", + "sysinfo", + "winsafe", ] [[package]] @@ -7382,20 +7381,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "sysinfo" -version = "0.37.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16607d5caffd1c07ce073528f9ed972d88db15dd44023fa57142963be3feb11f" -dependencies = [ - "libc", - "memchr", - "ntapi", - "objc2-core-foundation", - "objc2-io-kit", - "windows 0.61.3", -] - [[package]] name = "sysinfo" version = "0.40.1" @@ -7407,7 +7392,7 @@ dependencies = [ "ntapi", "objc2-core-foundation", "objc2-io-kit", - "windows 0.62.2", + "windows", ] [[package]] @@ -8681,9 +8666,9 @@ dependencies = [ "web-sys", "wgpu-naga-bridge", "wgpu-types", - "windows 0.62.2", - "windows-core 0.62.2", - "windows-result 0.4.1", + "windows", + "windows-core", + "windows-result", ] [[package]] @@ -8749,38 +8734,16 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" -[[package]] -name = "windows" -version = "0.61.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" -dependencies = [ - "windows-collections 0.2.0", - "windows-core 0.61.2", - "windows-future 0.2.1", - "windows-link 0.1.3", - "windows-numerics 0.2.0", -] - [[package]] name = "windows" version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" dependencies = [ - "windows-collections 0.3.2", - "windows-core 0.62.2", - "windows-future 0.3.2", - "windows-numerics 0.3.1", -] - -[[package]] -name = "windows-collections" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" -dependencies = [ - "windows-core 0.61.2", + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", ] [[package]] @@ -8789,20 +8752,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" dependencies = [ - "windows-core 0.62.2", -] - -[[package]] -name = "windows-core" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link 0.1.3", - "windows-result 0.3.4", - "windows-strings 0.4.2", + "windows-core", ] [[package]] @@ -8813,20 +8763,9 @@ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement", "windows-interface", - "windows-link 0.2.1", - "windows-result 0.4.1", - "windows-strings 0.5.1", -] - -[[package]] -name = "windows-future" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" -dependencies = [ - "windows-core 0.61.2", - "windows-link 0.1.3", - "windows-threading 0.1.0", + "windows-link", + "windows-result", + "windows-strings", ] [[package]] @@ -8835,9 +8774,9 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" dependencies = [ - "windows-core 0.62.2", - "windows-link 0.2.1", - "windows-threading 0.2.1", + "windows-core", + "windows-link", + "windows-threading", ] [[package]] @@ -8862,36 +8801,20 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "windows-link" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" - [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-numerics" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" -dependencies = [ - "windows-core 0.61.2", - "windows-link 0.1.3", -] - [[package]] name = "windows-numerics" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" dependencies = [ - "windows-core 0.62.2", - "windows-link 0.2.1", + "windows-core", + "windows-link", ] [[package]] @@ -8900,18 +8823,9 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" dependencies = [ - "windows-link 0.2.1", - "windows-result 0.4.1", - "windows-strings 0.5.1", -] - -[[package]] -name = "windows-result" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" -dependencies = [ - "windows-link 0.1.3", + "windows-link", + "windows-result", + "windows-strings", ] [[package]] @@ -8920,16 +8834,7 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-link 0.2.1", -] - -[[package]] -name = "windows-strings" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" -dependencies = [ - "windows-link 0.1.3", + "windows-link", ] [[package]] @@ -8938,7 +8843,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -8965,7 +8870,7 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -8984,22 +8889,13 @@ dependencies = [ "windows_x86_64_msvc", ] -[[package]] -name = "windows-threading" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" -dependencies = [ - "windows-link 0.1.3", -] - [[package]] name = "windows-threading" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" dependencies = [ - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -9125,8 +9021,8 @@ dependencies = [ "strum", "tar", "thiserror 2.0.21", - "windows 0.62.2", - "windows-core 0.62.2", + "windows", + "windows-core", "windows-implement", "xml", ] @@ -9299,7 +9195,7 @@ dependencies = [ "thiserror 2.0.21", "url", "widestring", - "windows 0.62.2", + "windows", "xcb", "zbus", ] diff --git a/crates/engine/src/cmd/file_cmds/open.rs b/crates/engine/src/cmd/file_cmds/open.rs index f4ad017787..1397702318 100644 --- a/crates/engine/src/cmd/file_cmds/open.rs +++ b/crates/engine/src/cmd/file_cmds/open.rs @@ -214,8 +214,10 @@ pub(super) fn approve_memory(s: &mut Session, p: &Value) -> Result { .ok_or_else(|| EngineError::BadParams { cmd: "file.approveMemory".into(), msg: format!("no pending high-memory preflight for id {id}") })?; // The resolved policy is the platform's: desktop uses its measured adaptive budget, the web // its 1 GiB automatic / 2 GiB hard product cap. Explicit consent is available on both; the - // hard ceiling is never raised by an approval. - let budget = photocraft_io::memory::resolve_budget(photocraft_io::memory::MemorySnapshot::current(), None); + // hard ceiling is never raised by an approval. While the scratch disk is spilling, tile + // pixels may use its room too; this fast refusal grants the whole room (the pending prompt + // does not keep the tile share), and the import re-checks the exact split before decoding. + let budget = photocraft_io::memory::resolve_budget_with_scratch(photocraft_io::memory::MemorySnapshot::current(), None, u64::MAX); if preflight.required > budget.hard_limit { return Err(EngineError::PsdHardBudget { name: preflight.name, required: preflight.required, limit: budget.hard_limit }); } diff --git a/crates/io/Cargo.toml b/crates/io/Cargo.toml index 3c80dc3511..1c6a7324d6 100644 --- a/crates/io/Cargo.toml +++ b/crates/io/Cargo.toml @@ -52,16 +52,6 @@ resvg = { version = "0.45", default-features = false, features = ["text", "syste [target.'cfg(target_arch = "wasm32")'.dependencies] resvg = { version = "0.45", default-features = false, features = ["text", "raster-images"] } -[target.'cfg(windows)'.dependencies] -winsafe = { version = "=0.0.29", default-features = false, features = ["kernel"] } - -# Available physical memory for the adaptive import budget (memory.rs). `sysinfo` is a safe -# Rust API over `host_statistics64`'s `vm_statistics64` on macOS; memory.rs derives the -# reclaimable capacity from its total/free/available/used counters (see its doc comment). -# Only the `system` feature is needed, and its MSRV 1.95 matches the workspace. -[target.'cfg(target_os = "macos")'.dependencies] -sysinfo = { version = "=0.40.1", default-features = false, features = ["system"] } - [dev-dependencies] miniz_oxide = "0.9" crc32fast = "1.5" diff --git a/crates/io/src/flat.rs b/crates/io/src/flat.rs index 27a35015d0..c7a4101c36 100644 --- a/crates/io/src/flat.rs +++ b/crates/io/src/flat.rs @@ -331,7 +331,7 @@ fn import_tiff_layered( let plan = crate::tiff_layers::preflight(&bands)?; // A file this machine cannot open is refused here, before any composite pixel is decoded, // and an eligible one rechecks a fresh measurement after the gate. - let _admission = tiff_admission(plan.required, opts, ctl)?; + let _admission = tiff_admission(plan.required, plan.tiles, opts, ctl)?; drop(bands); before_composite_decode(); // The orientation is applied after the layer check: rotating the composite but not the @@ -356,7 +356,8 @@ fn import_tiff_layered( Ok(r) } -/// Admits a layered TIFF import for `required` bytes and holds decode admission for it. +/// Admits a layered TIFF import for `required` bytes, `spillable` of them tile pixels (see +/// [`crate::memory::evaluate_psd_admission`]), and holds decode admission for it. /// /// A nested import of a different file spends the enclosing attempt's remaining allowance: it /// never resolves a fresh budget, resets the parent or charges the parent's image again. A @@ -364,10 +365,15 @@ fn import_tiff_layered( /// measurement once admitted, so a memory drop during the wait is seen before pixels are /// decoded. Cancellation is checked before an allowance is consumed, so a cancelled nested /// import never spends its parent's budget. -fn tiff_admission(required: u64, opts: &crate::ImportOptions, ctl: &photocraft_raster::Interrupt) -> Result { +fn tiff_admission( + required: u64, + spillable: u64, + opts: &crate::ImportOptions, + ctl: &photocraft_raster::Interrupt, +) -> Result { let caller_limit = opts.psd_max_pixel_bytes; let admit = |snapshot: crate::memory::MemorySnapshot| { - crate::memory::evaluate_psd_admission(required, caller_limit, opts.psd_high_memory_approved, opts.psd_approved_estimate, snapshot) + crate::memory::evaluate_psd_admission(required, spillable, caller_limit, opts.psd_high_memory_approved, opts.psd_approved_estimate, snapshot) }; if crate::psd_gate::is_holding_admission() { // A nested, independent TIFF: the same-file continuation never gets here, because @@ -422,7 +428,11 @@ fn tiff_to_document( // they can't be backed, and writing them would have the process killed: refuse first. let group = bands.group_bytes()? as u64; let group_px = group / (layout.channels() as u64 * sample.bytes() as u64).max(1); - let needed = surface_bytes(w, h, fmt).saturating_add(group.saturating_mul(3)).saturating_add(group_px.saturating_mul(bpp)); + let surface = surface_bytes(w, h, fmt); + let needed = surface.saturating_add(group.saturating_mul(3)).saturating_add(group_px.saturating_mul(bpp)); + // The layer is streamed into tiles a group at a time, so while the scratch disk is + // spilling the tiles it has room for need no RAM; the groups always do. + let needed = needed.saturating_sub(crate::memory::scratch_credit(surface)); crate::memory::check_fits(w, h, needed, available())?; } let mut s = Surface::new(fmt); @@ -1835,7 +1845,7 @@ mod streamed_png_tests { crate::psd_gate::set_active_attempt_budget(1000); let yes = || true; let cancelled = Interrupt::cancel_only(&yes); - match tiff_admission(100, &crate::ImportOptions::default(), &cancelled) { + match tiff_admission(100, 0, &crate::ImportOptions::default(), &cancelled) { Err(IoError::Cancelled) => {} other => panic!("expected a cancelled nested admission, got {:?}", other.map(|_| ())), } @@ -1852,21 +1862,21 @@ mod streamed_png_tests { crate::psd_gate::set_active_attempt_budget(ample); let lowered = crate::ImportOptions { psd_max_pixel_bytes: 99, ..Default::default() }; - match tiff_admission(100, &lowered, &ctl) { + match tiff_admission(100, 0, &lowered, &ctl) { Err(IoError::PsdImportBudget { required, limit }) => assert_eq!((required, limit), (100, 99)), other => panic!("expected the caller's limit to refuse, got {:?}", other.map(|_| ())), } assert_eq!(crate::psd_gate::active_attempt_remaining(), Some(ample), "a refused nested import leaves the parent's allowance untouched"); let stale = crate::ImportOptions { psd_high_memory_approved: true, psd_approved_estimate: Some(99), ..Default::default() }; - match tiff_admission(100, &stale, &ctl) { + match tiff_admission(100, 0, &stale, &ctl) { Err(IoError::PsdImportBudget { required, limit }) => assert_eq!((required, limit), (100, 99)), other => panic!("expected the approved estimate bound to refuse, got {:?}", other.map(|_| ())), } assert_eq!(crate::psd_gate::active_attempt_remaining(), Some(ample), "a refused nested import leaves the parent's allowance untouched"); let allowed = crate::ImportOptions { psd_max_pixel_bytes: 100, psd_high_memory_approved: true, psd_approved_estimate: Some(100), ..Default::default() }; - let guard = tiff_admission(100, &allowed, &ctl).expect("within the caller's bounds"); + let guard = tiff_admission(100, 0, &allowed, &ctl).expect("within the caller's bounds"); assert_eq!(crate::psd_gate::active_attempt_remaining(), Some(ample - 100), "the nested import is charged once"); drop(guard); assert_eq!(crate::psd_gate::active_attempt_remaining(), Some(ample - 100), "releasing the nested guard neither refunds nor resets the parent"); @@ -1886,7 +1896,7 @@ mod streamed_png_tests { crate::ImportOptions { psd_high_memory_approved: true, ..Default::default() }, crate::ImportOptions { psd_high_memory_approved: true, psd_approved_estimate: Some(u64::MAX), ..Default::default() }, ] { - match tiff_admission(u64::MAX, &opts, &Interrupt::NONE) { + match tiff_admission(u64::MAX, 0, &opts, &Interrupt::NONE) { Err(IoError::PsdImportBudget { required, limit }) => assert_eq!((required, limit), (u64::MAX, u64::MAX)), other => panic!("expected a hard refusal for {opts:?}, got {:?}", other.map(|_| ())), } diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index e1c592e49e..99aab4dd46 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -534,6 +534,7 @@ fn import_psd( // Invalid or unapproved files fail fast without blocking other decodes or acquiring gate. memory::evaluate_psd_admission( est.total_bytes, + est.tile_bytes, opts.psd_max_pixel_bytes, opts.psd_high_memory_approved, opts.psd_approved_estimate, @@ -548,6 +549,7 @@ fn import_psd( // guard is dropped (releasing gate immediately), and error is returned before prompting. let outcome = memory::evaluate_psd_admission( est.total_bytes, + est.tile_bytes, opts.psd_max_pixel_bytes, opts.psd_high_memory_approved, opts.psd_approved_estimate, diff --git a/crates/io/src/memory.rs b/crates/io/src/memory.rs index 8d53723425..c9b8c54378 100644 --- a/crates/io/src/memory.rs +++ b/crates/io/src/memory.rs @@ -90,13 +90,52 @@ thread_local! { /// Tests use it to change the measured capacity between admission checks from another /// thread; it is thread-local, so it never affects unrelated parallel tests. static MOCK_PROVIDER: std::cell::RefCell MemorySnapshot>>> = const { std::cell::RefCell::new(None) }; + /// Mocked [`scratch_capacity`]: `Some(None)` = spilling off, `Some(Some(n))` = `n` bytes. + static MOCK_SCRATCH: std::cell::Cell>> = const { std::cell::Cell::new(None) }; +} + +/// Tile bytes the scratch disk can take beyond RAM: `None` while spilling is off, paused or +/// unavailable (web), or its room is unknown. See [`photocraft_raster::spill::scratch_capacity`]. +/// +/// Test builds: a mocked memory snapshot without a mocked scratch disk measures no scratch, so a +/// test that mocks RAM keeps the RAM-only policy whatever another test did to the process-wide +/// scratch disk. +pub fn scratch_capacity() -> Option { + #[cfg(any(test, feature = "testgen"))] + { + if let Some(mock) = MOCK_SCRATCH.with(std::cell::Cell::get) { + return mock; + } + if MOCK_SNAPSHOT.with(|m| m.borrow().is_some()) || MOCK_PROVIDER.with(|p| p.borrow().is_some()) { + return None; + } + } + if cfg!(target_arch = "wasm32") { + return None; + } + photocraft_raster::spill::scratch_capacity() +} + +/// How much of `spillable` tile bytes the scratch disk covers on `platform` right now. The web +/// has no scratch disk, so it never gets credit. +fn scratch_credit_for(platform: Platform, spillable: u64) -> u64 { + if platform == Platform::Web || spillable == 0 { + return 0; + } + scratch_capacity().map_or(0, |room| spillable.min(room)) +} + +/// [`scratch_credit_for`] on this build's platform. +pub(crate) fn scratch_credit(spillable: u64) -> u64 { + scratch_credit_for(active_platform(), spillable) } impl MemorySnapshot { /// Unknown memory measurement. pub const UNKNOWN: Self = Self { available_ram: None, swap_free: None, commit_headroom: None }; - /// Obtains the current system memory snapshot from platform providers. + /// The current measurement from the shared detector, [`photocraft_raster::memory::system`], + /// which also sizes the tile residency budget. pub fn current() -> Self { #[cfg(any(test, feature = "testgen"))] { @@ -108,22 +147,15 @@ impl MemorySnapshot { } } - #[cfg(target_os = "linux")] - { - linux::snapshot() - } - #[cfg(windows)] - { - windows_mem::snapshot() - } - #[cfg(target_os = "macos")] - { - macos_mem::snapshot() - } - #[cfg(not(any(target_os = "linux", windows, target_os = "macos")))] - { - fallback_mem::snapshot() - } + photocraft_raster::memory::system::current().into() + } +} + +impl From for MemorySnapshot { + /// The shared detector's measurement; an unreadable constraint is already the fail-closed + /// `Some(0)` available RAM, which admission refuses. + fn from(m: photocraft_raster::memory::system::SystemMemory) -> Self { + Self { available_ram: m.available_ram, swap_free: m.swap_free, commit_headroom: m.commit_headroom } } } @@ -236,8 +268,27 @@ pub fn resolve_budget(snapshot: MemorySnapshot, caller_limit: Option) -> Me resolve_budget_for(active_platform(), snapshot, caller_limit) } +/// [`resolve_budget`] for an import whose estimate includes `spillable` bytes of tile pixels. +/// +/// While the scratch disk is spilling (on and not paused), tiles beyond the residency budget are +/// written to it, so those pixels are not bounded by physical RAM: the part of `spillable` the +/// disk has room for ([`scratch_capacity`], after the Scratch Size Limit and the free-space +/// reserve) is added to both S and H before the caller's explicit limit applies. Equivalently, +/// the non-spillable working memory (decode buffers, conversion copies, merged planes) is +/// admitted against the RAM policy alone, and the whole estimate against RAM plus scratch room. +/// With spilling off or paused, and always on the web, this is exactly [`resolve_budget`]. +pub fn resolve_budget_with_scratch(snapshot: MemorySnapshot, caller_limit: Option, spillable: u64) -> MemoryBudget { + let platform = active_platform(); + resolve_budget_credited(platform, snapshot, caller_limit, scratch_credit_for(platform, spillable)) +} + /// [`resolve_budget`] for an explicit platform, so tests can exercise the web policy on a native host. fn resolve_budget_for(platform: Platform, snapshot: MemorySnapshot, caller_limit: Option) -> MemoryBudget { + resolve_budget_credited(platform, snapshot, caller_limit, 0) +} + +/// The policy with `credit` bytes of scratch room added to S and H before the caller's limit. +fn resolve_budget_credited(platform: Platform, snapshot: MemorySnapshot, caller_limit: Option, credit: u64) -> MemoryBudget { if platform == Platform::Web { let mut hard = WASM_MAX_PEAK_BUDGET; if let Some(caller) = caller_limit.filter(|&c| c > 0) { @@ -274,6 +325,9 @@ fn resolve_budget_for(platform: Platform, snapshot: MemorySnapshot, caller_limit (h, s, Some(UNKNOWN_MEMORY_WARNING_THRESHOLD)) } }; + // Tile pixels the scratch disk takes need no RAM (see [`resolve_budget_with_scratch`]). + hard = hard.saturating_add(credit); + soft = soft.saturating_add(credit); if let Some(caller) = caller_limit.filter(|&c| c > 0) { hard = hard.min(caller); @@ -300,27 +354,33 @@ pub struct AdmissionOutcome { /// - `required > soft_limit` and high memory is not approved; /// - high memory is approved, but `approved_estimate` is specified and `required > approved_estimate` /// (preventing unbounded approval for altered/enlarged files). +/// +/// `spillable` is the part of `required` that is tile pixels; while the scratch disk is spilling +/// the limits include the room it has for them (see [`resolve_budget_with_scratch`]). Errors +/// still report the whole `required` (the figure approval binds to) and the effective limits. pub(crate) fn evaluate_psd_admission( required: u64, + spillable: u64, caller_limit: u64, high_memory_approved: bool, approved_estimate: Option, snapshot: MemorySnapshot, ) -> Result { - evaluate_psd_admission_for(active_platform(), required, caller_limit, high_memory_approved, approved_estimate, snapshot) + evaluate_psd_admission_for(active_platform(), required, spillable, caller_limit, high_memory_approved, approved_estimate, snapshot) } /// [`evaluate_psd_admission`] for an explicit platform, so tests can exercise the web policy on a native host. fn evaluate_psd_admission_for( platform: Platform, required: u64, + spillable: u64, caller_limit: u64, high_memory_approved: bool, approved_estimate: Option, snapshot: MemorySnapshot, ) -> Result { let limit_opt = (caller_limit > 0).then_some(caller_limit); - let budget = resolve_budget_for(platform, snapshot, limit_opt); + let budget = resolve_budget_credited(platform, snapshot, limit_opt, scratch_credit_for(platform, spillable.min(required))); let authorized_total = match budget.decide(required) { BudgetDecision::Automatic => { @@ -399,16 +459,10 @@ pub(crate) fn nested_import_local_bound(required: u64, caller_limit: u64, approv /// swap (capped by the cgroup's swap limits). Elsewhere `None`: Windows refuses an allocation past /// its commit limit, which the importers already report as an error, and macOS pages to disk as it /// needs. macOS's physical-memory provider feeds the adaptive budget, not this legacy -/// RAM-plus-swap helper; flat and layered TIFF conversion keep their existing behaviour. +/// RAM-plus-swap helper; flat and layered TIFF conversion keep their existing behaviour. Measured +/// by [`photocraft_raster::memory::system::available_with_swap`]. pub fn available() -> Option { - #[cfg(target_os = "linux")] - { - linux::available() - } - #[cfg(not(target_os = "linux"))] - { - None - } + photocraft_raster::memory::system::available_with_swap() } /// Refuses to open a `w` × `h` px image whose layer and decode buffers take `needed` bytes when @@ -479,6 +533,21 @@ pub fn with_mock_memory_provider(provider: std::rc::Rc MemorySnap f() } +/// Runs a closure with a mocked scratch disk on the current thread: `Some(n)` = spilling with +/// `n` bytes of room, `None` = spilling off or paused. Restored on return or panic, and captured +/// by [`TestMemoryScope`]. +#[cfg(any(test, feature = "testgen"))] +pub fn with_mock_scratch(capacity: Option, f: impl FnOnce() -> R) -> R { + struct Reset(Option>); + impl Drop for Reset { + fn drop(&mut self) { + MOCK_SCRATCH.with(|m| m.set(self.0)); + } + } + let _reset = Reset(MOCK_SCRATCH.with(|m| m.replace(Some(capacity)))); + f() +} + /// Explicit test-support capture/scope object to propagate the thread-local fixed mock snapshot /// and the scoped PSD estimate charge across worker threads without modifying global process /// state. @@ -502,6 +571,12 @@ pub struct TestMemoryScope { /// The estimate charge captured with the scope (zero when the test set none). #[cfg(any(test, feature = "testgen"))] estimate_charge: u64, + /// The tile-pixel share of that charge. + #[cfg(any(test, feature = "testgen"))] + tile_charge: u64, + /// The mocked scratch disk ([`with_mock_scratch`]), if any. + #[cfg(any(test, feature = "testgen"))] + scratch: Option>, } /// RAII guard that restores the previous thread-local mock memory and estimate charge upon drop. @@ -509,14 +584,17 @@ pub struct TestMemoryGuard { #[cfg(any(test, feature = "testgen"))] prev: Option, #[cfg(any(test, feature = "testgen"))] - prev_charge: u64, + prev_charge: (u64, u64), + #[cfg(any(test, feature = "testgen"))] + prev_scratch: Option>, } #[cfg(any(test, feature = "testgen"))] impl Drop for TestMemoryGuard { fn drop(&mut self) { MOCK_SNAPSHOT.with(|m| *m.borrow_mut() = self.prev); - crate::psd_budget::enter_test_estimate_charge(self.prev_charge); + crate::psd_budget::enter_test_estimate_charge(self.prev_charge.0, self.prev_charge.1); + MOCK_SCRATCH.with(|m| m.set(self.prev_scratch)); } } @@ -530,7 +608,8 @@ impl TestMemoryScope { #[cfg(any(test, feature = "testgen"))] { let mock = MOCK_SNAPSHOT.with(|m| *m.borrow()); - Self { mock, estimate_charge: crate::psd_budget::test_estimate_charge() } + let (estimate_charge, tile_charge) = crate::psd_budget::test_estimate_charge(); + Self { mock, estimate_charge, tile_charge, scratch: MOCK_SCRATCH.with(std::cell::Cell::get) } } #[cfg(not(any(test, feature = "testgen")))] { @@ -548,8 +627,9 @@ impl TestMemoryScope { *m.borrow_mut() = self.mock; old }); - let prev_charge = crate::psd_budget::enter_test_estimate_charge(self.estimate_charge); - TestMemoryGuard { prev, prev_charge } + let prev_charge = crate::psd_budget::enter_test_estimate_charge(self.estimate_charge, self.tile_charge); + let prev_scratch = MOCK_SCRATCH.with(|m| m.replace(self.scratch)); + TestMemoryGuard { prev, prev_charge, prev_scratch } } #[cfg(not(any(test, feature = "testgen")))] { @@ -564,707 +644,6 @@ impl TestMemoryScope { } } -#[cfg(target_os = "linux")] -pub(super) mod linux { - use super::MemorySnapshot; - use std::path::{Path, PathBuf}; - - /// cgroup v1 reports `PAGE_COUNTER_MAX * page_size` for "no limit", which is always close to - /// `i64::MAX`. Any limit at or above 4 EiB is treated as unlimited. - const V1_UNLIMITED: u64 = 1 << 62; - - /// Bound on ancestor walks; cgroup hierarchies are far shallower than this. - const MAX_CGROUP_DEPTH: usize = 128; - - #[derive(Clone, Debug, PartialEq, Eq)] - enum Limit { - Missing, - Unlimited, - Value(u64), - Invalid, - } - - /// What the process's memory cgroup says, independent of host `/proc/meminfo`. - #[derive(Clone, Copy, Debug, PartialEq, Eq)] - pub(super) enum CgroupLimits { - /// No memory controller in this process's hierarchy: host memory applies. - NotConstrained, - /// Headroom under the memory controller; `None` means that dimension is unlimited. - Limited { ram: Option, swap: Option }, - /// A memory controller exists but its mount, path or limit could not be read. Callers - /// must fail closed instead of falling back to unrestricted host memory. - Unreadable, - } - - #[derive(Clone, Debug, PartialEq, Eq)] - struct CgroupDir { - leaf: PathBuf, - /// The visible root of the walk (the mount point). - root: PathBuf, - /// Whether `root` is the hierarchy's own root rather than a subtree exposed by a mount. - /// Only the hierarchy root is exempt from exposing its controller interface files. - hierarchy_root: bool, - } - - #[derive(Clone, Copy, Debug, PartialEq, Eq)] - struct Membership<'a> { - /// Unified (v2) hierarchy path (`0::`). - v2: Option<&'a str>, - /// v1 hierarchy path for the `memory` controller. - v1_memory: Option<&'a str>, - } - - /// Result of reading `/proc/self/cgroup`. - #[derive(Clone, Copy, Debug, PartialEq, Eq)] - enum MembershipParse<'a> { - Parsed(Membership<'a>), - /// A non-empty line could not be understood, so the absence of a memory controller cannot - /// be established. - Malformed, - } - - #[derive(Clone, Debug, PartialEq, Eq)] - struct Mount { - root: String, - mount_point: String, - fstype: String, - options: String, - super_options: String, - } - - /// Measures available RAM and free swap from `/proc/meminfo`, capped by the process's memory - /// cgroup. Unreadable cgroup constraints fail closed as zero available memory rather than - /// falling back to unrestricted host memory. - pub(super) fn snapshot() -> MemorySnapshot { - snapshot_at(Path::new("/proc"), Path::new("/sys/fs/cgroup")) - } - - /// Provider body with explicit roots, so tests exercise the real filesystem parsing without - /// global mocks. - pub(super) fn snapshot_at(proc_root: &Path, sys_root: &Path) -> MemorySnapshot { - let info = std::fs::read_to_string(proc_root.join("meminfo")).ok(); - let host_ram = info.as_deref().and_then(|i| meminfo(i, "MemAvailable:")); - let host_swap = info.as_deref().and_then(|i| meminfo(i, "SwapFree:")); - - let (cgroup_ram, cgroup_swap) = match cgroup_limits(proc_root, sys_root) { - CgroupLimits::NotConstrained => (None, None), - CgroupLimits::Limited { ram, swap } => (ram, swap), - CgroupLimits::Unreadable => return MemorySnapshot { available_ram: Some(0), swap_free: Some(0), commit_headroom: None }, - }; - - let available_ram = match (host_ram, cgroup_ram) { - (Some(host), Some(cgroup)) => Some(host.min(cgroup)), - (Some(host), None) => Some(host), - (None, Some(cgroup)) => Some(cgroup), - (None, None) => None, - }; - let swap_free = match (host_swap, cgroup_swap) { - (Some(host), Some(cgroup)) => Some(host.min(cgroup)), - (Some(host), None) => Some(host), - (None, Some(cgroup)) => Some(cgroup), - (None, None) => None, - }; - MemorySnapshot { available_ram, swap_free, commit_headroom: None } - } - - pub(super) fn available() -> Option { - available_at(Path::new("/proc"), Path::new("/sys/fs/cgroup")) - } - - /// Legacy RAM-plus-swap helper with explicit roots. Its meaning is deliberately unchanged: - /// the adaptive budget excludes swap, but flat and layered TIFF conversion still count it - /// (now bounded by the cgroup figures derived above, including v1 `memsw`). - pub(super) fn available_at(proc_root: &Path, sys_root: &Path) -> Option { - let snap = snapshot_at(proc_root, sys_root); - snap.available_ram.map(|ram| ram.saturating_add(snap.swap_free.unwrap_or(0))) - } - - /// A `/proc/meminfo` field in bytes. - pub(super) fn meminfo(info: &str, field: &str) -> Option { - let kb: u64 = info.lines().find_map(|l| l.strip_prefix(field))?.trim().trim_end_matches("kB").trim().parse().ok()?; - kb.checked_mul(1024) - } - - /// Resolves the memory cgroup from `/proc/self/cgroup` plus `/proc/self/mountinfo`. - /// - /// v1 is preferred when the `memory` controller appears there; otherwise the unified v2 - /// hierarchy is used. A memory controller that cannot be resolved or read, or membership that - /// cannot be parsed, is [`CgroupLimits::Unreadable`], never host memory. Ancestors above a - /// cgroup namespace's root are not visible to an unprivileged reader (the kernel still - /// enforces them), so only visible ancestors can be accounted for. The widest visible view of - /// the hierarchy is used, not merely the deepest matching mount point. - pub(super) fn cgroup_limits(proc_root: &Path, sys_root: &Path) -> CgroupLimits { - let Ok(groups) = std::fs::read_to_string(proc_root.join("self/cgroup")) else { - // Without membership we cannot prove that no memory cgroup applies. - return CgroupLimits::Unreadable; - }; - let membership = match parse_membership(&groups) { - MembershipParse::Parsed(membership) => membership, - MembershipParse::Malformed => return CgroupLimits::Unreadable, - }; - let mountinfo = std::fs::read_to_string(proc_root.join("self/mountinfo")).ok(); - - if let Some(path) = membership.v1_memory { - let Some(dir) = resolve_dir(sys_root, mountinfo.as_deref(), path, "cgroup", Some("memory")) else { - return CgroupLimits::Unreadable; - }; - return v1_limits(&dir); - } - if let Some(path) = membership.v2 { - let Some(dir) = resolve_dir(sys_root, mountinfo.as_deref(), path, "cgroup2", None) else { - return CgroupLimits::Unreadable; - }; - return v2_limits(&dir); - } - CgroupLimits::NotConstrained - } - - /// Parses `/proc/self/cgroup`. - /// - /// Kernel format is `::`; the - /// unified hierarchy uses `0::`. A line that does not have that shape makes the whole - /// file untrustworthy: returning "no memory controller" from a partial parse would silently - /// fall back to unrestricted host memory, so an empty or malformed file is reported as - /// malformed instead. - fn parse_membership(text: &str) -> MembershipParse<'_> { - let mut membership = Membership { v2: None, v1_memory: None }; - let mut saw_line = false; - for line in text.lines() { - let line = line.trim_end_matches('\r'); - if line.is_empty() { - continue; - } - saw_line = true; - let mut fields = line.splitn(3, ':'); - let (Some(hierarchy), Some(controllers), Some(path)) = (fields.next(), fields.next(), fields.next()) else { - return MembershipParse::Malformed; - }; - let Ok(hierarchy) = hierarchy.parse::() else { return MembershipParse::Malformed }; - if !path.starts_with('/') { - return MembershipParse::Malformed; - } - if hierarchy == 0 { - if !controllers.is_empty() { - return MembershipParse::Malformed; - } - membership.v2.get_or_insert(path); - } else if controllers.is_empty() { - return MembershipParse::Malformed; - } else if controllers.split(',').any(|c| c.trim() == "memory") { - membership.v1_memory.get_or_insert(path); - } - } - if !saw_line { - return MembershipParse::Malformed; - } - MembershipParse::Parsed(membership) - } - - /// Parses `/proc/self/mountinfo` into the mount fields used to locate a cgroup hierarchy. - /// - /// Malformed lines are skipped; callers fail closed when no matching mount is found. - fn parse_mountinfo(text: &str) -> Vec { - let mut mounts = Vec::new(); - for line in text.lines() { - let fields: Vec<&str> = line.split_whitespace().collect(); - let Some(separator) = fields.iter().position(|f| *f == "-") else { continue }; - let (Some(root), Some(point), Some(fstype), Some(super_options)) = - (fields.get(3).copied(), fields.get(4).copied(), fields.get(separator + 1).copied(), fields.get(separator + 3).copied()) - else { - continue; - }; - let options = fields.get(5).copied().unwrap_or(""); - mounts.push(Mount { - root: unescape(root), - mount_point: unescape(point), - fstype: fstype.to_string(), - options: options.to_string(), - super_options: super_options.to_string(), - }); - } - mounts - } - - /// Finds the mount for `fstype`/`controller` and joins the process's cgroup path to it. - /// - /// Reads `/proc/self/mountinfo` (dedicated controller mounts, bind mounts and non-root mount - /// roots). If that file cannot be read, the conventional `/sys/fs/cgroup[/]` - /// layout is the only guess available. When mountinfo *is* readable, a missing matching mount - /// fails closed instead of guessing another mount's hierarchy: a wrong directory must never - /// become unrestricted host memory. - /// - /// A bind mount of a subtree has a deeper `root` and hides the cgroup ancestors above that - /// subtree, so the *widest* matching view is preferred: the mount whose cgroup root is - /// highest in the hierarchy (fewest path components), not the longest mount point. A deep - /// bind mount must not be allowed to hide a limit on an ancestor that is visible through the - /// hierarchy's real mount. - fn resolve_dir(sys_root: &Path, mountinfo: Option<&str>, membership: &str, fstype: &str, controller: Option<&str>) -> Option { - let membership = unescape(membership.trim()); - let Some(info) = mountinfo else { - let root = match controller { - Some(c) => sys_root.join(c), - None => sys_root.to_path_buf(), - }; - let leaf = join_membership(&root, &membership)?; - // The conventional layout mounts each hierarchy at its own root. - return leaf.is_dir().then_some(CgroupDir { leaf, root, hierarchy_root: true }); - }; - - let mut best: Option<(usize, usize, CgroupDir)> = None; - for mount in parse_mountinfo(info) { - if mount.fstype != fstype { - continue; - } - if controller.is_some_and(|c| !has_option(&mount.options, c) && !has_option(&mount.super_options, c)) { - continue; - } - let Some(relative) = strip_mount_root(&mount.root, &membership) else { continue }; - let root = normalize_root(&mount.mount_point); - let Some(leaf) = join_membership(&root, relative) else { continue }; - if !leaf.is_dir() { - continue; - } - let key = (cgroup_path_depth(&mount.root), mount.mount_point.len()); - if best.as_ref().is_none_or(|(depth, point, _)| key < (*depth, *point)) { - let hierarchy_root = mount.root.trim_end_matches('/').is_empty(); - best = Some((key.0, key.1, CgroupDir { leaf, root, hierarchy_root })); - } - } - best.map(|(_, _, dir)| dir) - } - - /// Trims trailing slashes so `Path` equality holds while walking ancestors, keeping `/`. - fn normalize_root(point: &str) -> PathBuf { - let trimmed = point.trim_end_matches('/'); - PathBuf::from(if trimmed.is_empty() { "/" } else { trimmed }) - } - - /// Joins a cgroup path below a visible hierarchy root, normalizing `.` components and - /// rejecting parent traversal so a malformed path cannot escape the mount. - fn join_membership(root: &Path, path: &str) -> Option { - let mut leaf = root.to_path_buf(); - for component in Path::new(path.trim_start_matches('/')).components() { - match component { - std::path::Component::Normal(part) => leaf.push(part), - std::path::Component::CurDir => {} - std::path::Component::ParentDir | std::path::Component::RootDir | std::path::Component::Prefix(_) => return None, - } - } - Some(leaf) - } - - /// Number of path components in a mount's cgroup root; `/` or an empty root is zero. - fn cgroup_path_depth(root: &str) -> usize { - Path::new(root).components().filter(|c| matches!(c, std::path::Component::Normal(_))).count() - } - - /// Whether a v2 mount's `cgroup.controllers` exposes the memory controller. - #[derive(Clone, Copy, Debug, PartialEq, Eq)] - enum V2Controller { - /// `cgroup.controllers` lists `memory`. - Available, - /// `cgroup.controllers` is readable and omits `memory`, so no cgroup memory limit exists. - Absent, - /// `cgroup.controllers` could not be read, so absence cannot be established. - Unknown, - } - - /// Whether the cgroup v2 hierarchy at `root` exposes the memory controller. An unreadable - /// `cgroup.controllers` is `Unknown`, so its absence cannot be proven. - fn v2_controller(root: &Path) -> V2Controller { - match std::fs::read_to_string(root.join("cgroup.controllers")) { - Ok(text) => { - if text.split_whitespace().any(|controller| controller == "memory") { - V2Controller::Available - } else { - V2Controller::Absent - } - } - Err(_) => V2Controller::Unknown, - } - } - - /// Whether a cgroup's `cgroup.subtree_control` enables memory for its children. - /// - /// Interface files are created by the parent, so this decides whether a child *must* expose - /// `memory.max`: a readable control file without memory means the child legitimately has no - /// limit, while an unreadable one means that cannot be established. - #[derive(Clone, Copy, Debug, PartialEq, Eq)] - enum Delegation { - Enabled, - Disabled, - Unknown, - } - - /// Whether `parent` enables the memory controller for its children in - /// `cgroup.subtree_control`. An unreadable file is `Unknown`, not "disabled". - fn v2_delegation(parent: &Path) -> Delegation { - match std::fs::read_to_string(parent.join("cgroup.subtree_control")) { - Ok(text) => { - if text.split_whitespace().any(|controller| controller.trim_start_matches('+') == "memory") { - Delegation::Enabled - } else { - Delegation::Disabled - } - } - Err(_) => Delegation::Unknown, - } - } - - /// cgroup v2: `memory.max`/`memory.current`, reclaiming `inactive_file`, plus - /// `memory.swap.max`/`memory.swap.current`. Every visible ancestor limit applies. - /// - /// A missing `memory.max` is not always an error: the hierarchy root is exempt from resource - /// control, and a child has the file only when its parent delegates the controller. A missing - /// file where the parent *does* delegate memory, or where delegation cannot be read, means the - /// visible view is incomplete and is reported as unreadable instead of unrestricted. - fn v2_limits(dir: &CgroupDir) -> CgroupLimits { - if !dir.leaf.starts_with(&dir.root) || !dir.leaf.is_dir() { - return CgroupLimits::Unreadable; - } - let controller = v2_controller(&dir.root); - - let mut nodes = Vec::new(); - let mut node = Some(dir.leaf.as_path()); - while let Some(path) = node { - nodes.push(path); - if path == dir.root.as_path() { - break; - } - if nodes.len() > MAX_CGROUP_DEPTH { - return CgroupLimits::Unreadable; - } - node = path.parent(); - } - if nodes.last().is_none_or(|path| *path != dir.root.as_path()) { - return CgroupLimits::Unreadable; - } - nodes.reverse(); - - let mut ram: Option = None; - let mut swap: Option = None; - let mut enabled = false; - for (index, path) in nodes.iter().enumerate() { - let expected = if index == 0 { - if dir.hierarchy_root { - // The hierarchy root is exempt from resource control and may have no - // `memory.max` at all. - None - } else if controller == V2Controller::Absent { - Some(Delegation::Disabled) - } else { - // A mounted subtree root must expose the controller when it is enabled for - // that cgroup; without the parent's control file its absence is unprovable. - Some(Delegation::Unknown) - } - } else { - Some(v2_delegation(nodes[index - 1])) - }; - match read_limit(&path.join("memory.max")) { - Limit::Value(max) => { - enabled = true; - let Some(current) = read_u64(&path.join("memory.current")) else { return CgroupLimits::Unreadable }; - let inactive = read_stat(&path.join("memory.stat"), "inactive_file"); - let room = max.saturating_sub(current.saturating_sub(inactive)); - ram = Some(ram.map_or(room, |r| r.min(room))); - } - Limit::Unlimited => enabled = true, - Limit::Invalid => return CgroupLimits::Unreadable, - Limit::Missing => match expected { - None | Some(Delegation::Disabled) => {} - Some(Delegation::Enabled | Delegation::Unknown) => return CgroupLimits::Unreadable, - }, - } - match read_limit(&path.join("memory.swap.max")) { - Limit::Value(max) => { - enabled = true; - let Some(current) = read_u64(&path.join("memory.swap.current")) else { return CgroupLimits::Unreadable }; - let room = max.saturating_sub(current); - swap = Some(swap.map_or(room, |r| r.min(room))); - } - Limit::Unlimited => enabled = true, - Limit::Invalid => return CgroupLimits::Unreadable, - // Kernels before swap accounting expose `memory.max` without `memory.swap.*`; - // absence there means no swap limit at this cgroup, not an unreadable constraint. - Limit::Missing => {} - } - } - - if enabled { - return CgroupLimits::Limited { ram, swap }; - } - match controller { - // The controller is genuinely absent, or available but not enabled anywhere on the - // visible path (the root may normally have no interface files): no cgroup limit. - V2Controller::Absent | V2Controller::Available => CgroupLimits::NotConstrained, - // No readable limit file and no metadata proving the controller absent. - V2Controller::Unknown => CgroupLimits::Unreadable, - } - } - - /// cgroup v1: `memory.limit_in_bytes`/`memory.usage_in_bytes` (inactive cache reclaimable) - /// and the separate `memory.memsw` memory-plus-swap bound. - /// - /// A memsw limit also caps RAM because memory usage counts toward it, but its remaining room - /// is not swap that can be added on top of a different ancestor's RAM headroom. Each - /// ancestor's swap allowance is `memsw limit - memory limit - swap already used`, and those - /// allowances are combined separately. Subtracting one global combined headroom from one - /// global RAM headroom would invent swap room from different ancestors. - fn v1_limits(dir: &CgroupDir) -> CgroupLimits { - if !dir.leaf.starts_with(&dir.root) || !dir.leaf.is_dir() { - return CgroupLimits::Unreadable; - } - let mut ram: Option = None; - let mut swap: Option = None; - let mut enabled = false; - let mut node = Some(dir.leaf.as_path()); - let mut depth = 0; - while let Some(path) = node { - if depth >= MAX_CGROUP_DEPTH { - return CgroupLimits::Unreadable; - } - - // `None` means unlimited at this ancestor; the raw usage feeds the swap bound. - let mut memory_usage = None; - let memory_limit = match read_limit(&path.join("memory.limit_in_bytes")) { - Limit::Missing | Limit::Invalid => return CgroupLimits::Unreadable, - Limit::Unlimited => { - enabled = true; - None - } - Limit::Value(bytes) if bytes >= V1_UNLIMITED => { - enabled = true; - None - } - Limit::Value(bytes) => { - enabled = true; - let Some(usage) = read_u64(&path.join("memory.usage_in_bytes")) else { return CgroupLimits::Unreadable }; - memory_usage = Some(usage); - let inactive = std::fs::read_to_string(path.join("memory.stat")) - .ok() - .and_then(|s| stat(&s, "total_inactive_file").or_else(|| stat(&s, "inactive_file"))) - .unwrap_or(0); - let room = bytes.saturating_sub(usage.saturating_sub(inactive)); - ram = Some(ram.map_or(room, |r| r.min(room))); - Some(bytes) - } - }; - - match read_limit(&path.join("memory.memsw.limit_in_bytes")) { - Limit::Missing | Limit::Unlimited => {} - Limit::Invalid => return CgroupLimits::Unreadable, - Limit::Value(bytes) if bytes >= V1_UNLIMITED => {} - Limit::Value(bytes) => { - let Some(memsw_usage) = read_u64(&path.join("memory.memsw.usage_in_bytes")) else { - return CgroupLimits::Unreadable; - }; - // Memory plus swap is bounded by memsw, so its remaining room also bounds RAM. - let combined_room = bytes.saturating_sub(memsw_usage); - ram = Some(ram.map_or(combined_room, |r| r.min(combined_room))); - if let Some(memory_limit) = memory_limit { - // Swap can only use the part of memsw above the memory limit after the - // swap already in use; filling RAM to its own limit does not consume it. - let used_swap = memsw_usage.saturating_sub(memory_usage.unwrap_or(0)); - let room = bytes.saturating_sub(memory_limit).saturating_sub(used_swap); - swap = Some(swap.map_or(room, |s| s.min(room))); - } else { - // With no RAM limit the remaining memsw room can all be consumed by RAM, - // so no swap room is guaranteed on top of the RAM allowance. - swap = Some(0); - } - } - } - - if path == dir.root.as_path() { - break; - } - node = path.parent(); - depth += 1; - } - if !enabled { - // The v1 hierarchy claims a memory controller but never exposed a limit file. - return CgroupLimits::Unreadable; - } - CgroupLimits::Limited { ram, swap } - } - - /// Reads a cgroup limit file: `max` is unlimited, a number is a byte limit, and a missing file - /// is distinguished from one that cannot be read or parsed. - fn read_limit(path: &Path) -> Limit { - match std::fs::read_to_string(path) { - Ok(text) => match text.trim() { - "max" => Limit::Unlimited, - value => match value.parse::() { - Ok(bytes) => Limit::Value(bytes), - Err(_) => Limit::Invalid, - }, - }, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Limit::Missing, - Err(_) => Limit::Invalid, - } - } - - /// Reads a whitespace-terminated unsigned value from a cgroup interface file. - fn read_u64(path: &Path) -> Option { - std::fs::read_to_string(path).ok()?.trim().parse().ok() - } - - /// Reads `field` from a cgroup `*.stat` file, treating a missing file or field as zero. - fn read_stat(path: &Path, field: &str) -> u64 { - std::fs::read_to_string(path).ok().and_then(|s| stat(&s, field)).unwrap_or(0) - } - - /// Finds `field` in `key value` stat lines and parses its value. - fn stat(s: &str, field: &str) -> Option { - s.lines().find_map(|l| { - let mut parts = l.split_whitespace(); - let key = parts.next()?; - if key == field { parts.next()?.parse().ok() } else { None } - }) - } - - /// Whether a comma-separated mount option list contains `controller`. - fn has_option(options: &str, controller: &str) -> bool { - options.split(',').any(|option| option.trim() == controller) - } - - /// Strips a mount's root prefix from a cgroup path, returning the path relative to the mount. - fn strip_mount_root<'a>(root: &str, path: &'a str) -> Option<&'a str> { - let root = root.trim_end_matches('/'); - if root.is_empty() { - return Some(path); - } - let rest = path.strip_prefix(root)?; - if rest.is_empty() { - return Some(""); - } - rest.strip_prefix('/') - } - - /// Decodes mountinfo/mount-path octal escapes (`\040` space, `\011` tab, `\134` backslash). - fn unescape(value: &str) -> String { - let bytes = value.as_bytes(); - let mut out = Vec::with_capacity(bytes.len()); - let mut index = 0; - while let Some(&byte) = bytes.get(index) { - if byte == b'\\' - && let Some(digits) = bytes.get(index + 1..index + 4) - { - let code = digits.iter().try_fold(0u32, |acc, digit| { - let value = digit.checked_sub(b'0')?; - (value < 8).then_some(acc * 8 + u32::from(value)) - }); - if let Some(byte) = code.and_then(|code| u8::try_from(code).ok()) { - out.push(byte); - index += 4; - continue; - } - } - out.push(byte); - index += 1; - } - String::from_utf8_lossy(&out).into_owned() - } - - /// Exposed for the Linux provider tests; the policy itself never calls this. - #[cfg(test)] - pub(super) fn membership_for_test(text: &str) -> Option<(Option, Option)> { - match parse_membership(text) { - MembershipParse::Parsed(membership) => Some((membership.v2.map(str::to_string), membership.v1_memory.map(str::to_string))), - MembershipParse::Malformed => None, - } - } -} - -#[cfg(windows)] -mod windows_mem { - use super::MemorySnapshot; - - /// Measures available physical and commit memory through `GlobalMemoryStatusEx`. A failed - /// query reports unknown memory rather than inventing a measurement. - pub(super) fn snapshot() -> MemorySnapshot { - match winsafe::GlobalMemoryStatusEx() { - Ok(msx) => MemorySnapshot { available_ram: Some(msx.ullAvailPhys), swap_free: None, commit_headroom: Some(msx.ullAvailPageFile) }, - Err(_) => MemorySnapshot::UNKNOWN, - } - } -} - -/// Conservative reclaimable physical RAM from macOS VM counters, or `None` when the query failed. -/// -/// `total` is installed physical memory (`sysctl` `HW_MEMSIZE`). The other three are the same -/// `host_statistics64` `vm_statistics64` fields that `sysinfo` derives: -/// -/// - `free` = `free_memory` = `free_count - speculative_count` pages; -/// - `available` = `available_memory` = `active + inactive + free_count` pages; -/// - `used` = `used_memory` = `internal_page_count - purgeable_count + wire_count + -/// compressor_page_count` pages. -/// -/// XNU documents `AVAILABLE_NON_COMPRESSED_MEMORY = active + inactive + free + speculative` as -/// "everything that is not either wired, compressed, or stolen" -/// (). -/// That includes anonymous working sets, which can only be released by compressing or swapping -/// them, so on its own it is too optimistic for a budget that excludes swap. -/// -/// `internal_page_count` counts anonymous pages and `external_page_count` counts file-backed -/// pages (), -/// so `total - used` is the pool that can be handed out without evicting a working set: -/// `free + file-backed + purgeable`. That is close to the fully-reclaimable pool jetsam monitors -/// (`file-backed + free + purgeable`, same document). The smaller of the two derivations is -/// taken so a counter disagreement stays conservative, and the result is never below `free`: -/// pages that are already free are allocatable by definition, so a derived value below them -/// means the counters disagree and cannot be used to claim less. -/// -/// Only a demonstrably failed query is unknown. A live host always has a non-zero installed size -/// and non-zero used memory (wired kernel pages alone), so either reading zero proves the -/// provider query failed. A genuine "no reclaimable pages" reading stays a known zero rather -/// than becoming the 2 GiB unknown-memory allowance. -#[cfg(any(target_os = "macos", test))] -fn macos_available_ram(total: u64, free: u64, available: u64, used: u64) -> Option { - if total == 0 || used == 0 { - return None; - } - let free = free.min(total); - let reclaimable = total.saturating_sub(used).min(available).max(free); - Some(reclaimable.min(total)) -} - -#[cfg(target_os = "macos")] -mod macos_mem { - use super::MemorySnapshot; - use sysinfo::{MemoryRefreshKind, RefreshKind, System}; - - /// Available physical memory through `sysinfo`'s safe API. - /// - /// On macOS that is `host_statistics64`'s `vm_statistics64` (see - /// [`super::macos_available_ram`]). The estimate counts free pages, file-backed pages and - /// purgeable pages, not anonymous working sets, wired memory, compressed memory or swap. A - /// failed query reports unknown; a successful zero reading stays zero. - pub(super) fn snapshot() -> MemorySnapshot { - let system = match System::new_with_specifics(RefreshKind::nothing().with_memory(MemoryRefreshKind::nothing().with_ram())) { - Ok(system) => system, - Err(_) => return MemorySnapshot::UNKNOWN, - }; - match super::macos_available_ram(system.total_memory(), system.free_memory(), system.available_memory(), system.used_memory()) { - Some(ram) => MemorySnapshot { available_ram: Some(ram), swap_free: None, commit_headroom: None }, - None => MemorySnapshot::UNKNOWN, - } - } -} - -#[cfg(not(any(target_os = "linux", windows, target_os = "macos")))] -mod fallback_mem { - use super::MemorySnapshot; - - /// Fallback provider for platforms without a validated memory-availability provider. - /// - /// Available memory is reported as unknown (`None`), which applies the conservative native - /// fallback budget (2 GiB, approval above it) without inventing a measurement. - pub(super) fn snapshot() -> MemorySnapshot { - MemorySnapshot::UNKNOWN - } -} - #[cfg(test)] mod tests { use super::*; @@ -1387,7 +766,7 @@ mod tests { assert_eq!(budget.decide(u64::MAX - 1), BudgetDecision::RequiresApproval); for (approved, estimate) in [(false, None), (true, None), (true, Some(u64::MAX))] { - match evaluate_psd_admission_for(Platform::Native, u64::MAX, 0, approved, estimate, MemorySnapshot::UNKNOWN) { + match evaluate_psd_admission_for(Platform::Native, u64::MAX, 0, 0, approved, estimate, MemorySnapshot::UNKNOWN) { Err(IoError::PsdImportBudget { required, limit }) => { assert_eq!((required, limit), (u64::MAX, u64::MAX), "the typed refusal keeps the estimate and limit unchanged") } @@ -1397,7 +776,7 @@ mod tests { // A caller limit equal to the sentinel, or an approved estimate of it, is no exception. assert!(matches!( - evaluate_psd_admission_for(Platform::Native, u64::MAX, u64::MAX, true, Some(u64::MAX), MemorySnapshot::UNKNOWN), + evaluate_psd_admission_for(Platform::Native, u64::MAX, 0, u64::MAX, true, Some(u64::MAX), MemorySnapshot::UNKNOWN), Err(IoError::PsdImportBudget { required: u64::MAX, limit: u64::MAX }) )); } @@ -1455,14 +834,14 @@ mod tests { let snap = MemorySnapshot::UNKNOWN; let required = 9 * gb; - let err = evaluate_psd_admission_for(Platform::Native, required, 0, false, None, snap).unwrap_err(); + let err = evaluate_psd_admission_for(Platform::Native, required, 0, 0, false, None, snap).unwrap_err(); assert!(matches!(err, IoError::PsdApprovalRequired { available_ram: None, .. })); - let out = evaluate_psd_admission_for(Platform::Native, required, 0, true, Some(required), snap).unwrap(); + let out = evaluate_psd_admission_for(Platform::Native, required, 0, 0, true, Some(required), snap).unwrap(); assert_eq!(out.authorized_total, required); assert!(out.budget.requires_uncertainty_warning(required), "the consent warning must carry through admission"); - let err = evaluate_psd_admission_for(Platform::Native, required, 0, true, Some(required - 1), snap).unwrap_err(); + let err = evaluate_psd_admission_for(Platform::Native, required, 0, 0, true, Some(required - 1), snap).unwrap_err(); assert!(matches!(err, IoError::PsdApprovalRequired { .. }), "a larger estimate needs fresh consent"); } @@ -1481,7 +860,7 @@ mod tests { assert_eq!(b.decide(WASM_MAX_PEAK_BUDGET), BudgetDecision::RequiresApproval); assert_eq!(b.decide(WASM_MAX_PEAK_BUDGET + 1), BudgetDecision::Refused); const { assert!(WASM_MAX_SINGLE_ALLOCATION < WASM_MAX_PEAK_BUDGET, "the aggregate cap must not be the single-allocation layout bound") }; - let out = evaluate_psd_admission_for(Platform::Web, WASM_MAX_PEAK_BUDGET, 0, true, Some(WASM_MAX_PEAK_BUDGET), MemorySnapshot::UNKNOWN).unwrap(); + let out = evaluate_psd_admission_for(Platform::Web, WASM_MAX_PEAK_BUDGET, 0, 0, true, Some(WASM_MAX_PEAK_BUDGET), MemorySnapshot::UNKNOWN).unwrap(); assert_eq!(out.authorized_total, WASM_MAX_PEAK_BUDGET, "approval may authorize an aggregate above one buffer's layout limit"); let capped = resolve_budget_for(Platform::Web, MemorySnapshot::UNKNOWN, Some(512 << 20)); @@ -1497,23 +876,23 @@ mod tests { let snap = MemorySnapshot::UNKNOWN; let approval = 3 * (WASM_BUDGET / 2); - let out = evaluate_psd_admission_for(Platform::Web, WASM_BUDGET, 0, false, None, snap).unwrap(); + let out = evaluate_psd_admission_for(Platform::Web, WASM_BUDGET, 0, 0, false, None, snap).unwrap(); assert_eq!(out.authorized_total, WASM_BUDGET); - let err = evaluate_psd_admission_for(Platform::Web, WASM_BUDGET + 1, 0, false, None, snap).unwrap_err(); + let err = evaluate_psd_admission_for(Platform::Web, WASM_BUDGET + 1, 0, 0, false, None, snap).unwrap_err(); assert!(matches!( err, IoError::PsdApprovalRequired { required, soft_limit, hard_limit, available_ram } if required == WASM_BUDGET + 1 && soft_limit == WASM_BUDGET && hard_limit == WASM_MAX_PEAK_BUDGET && available_ram.is_none() )); - let out = evaluate_psd_admission_for(Platform::Web, approval, 0, true, Some(approval), snap).unwrap(); + let out = evaluate_psd_admission_for(Platform::Web, approval, 0, 0, true, Some(approval), snap).unwrap(); assert_eq!(out.authorized_total, approval); - let err = evaluate_psd_admission_for(Platform::Web, approval, 0, true, Some(approval - 1), snap).unwrap_err(); + let err = evaluate_psd_admission_for(Platform::Web, approval, 0, 0, true, Some(approval - 1), snap).unwrap_err(); assert!(matches!(err, IoError::PsdApprovalRequired { .. })); - let err = evaluate_psd_admission_for(Platform::Web, WASM_MAX_PEAK_BUDGET + 1, 0, true, None, snap).unwrap_err(); + let err = evaluate_psd_admission_for(Platform::Web, WASM_MAX_PEAK_BUDGET + 1, 0, 0, true, None, snap).unwrap_err(); assert!(matches!(err, IoError::PsdImportBudget { .. })); } @@ -1569,30 +948,109 @@ mod tests { // S = 6 GiB, H = 9 GiB // Below S: automatic, authorized_total = S (6 GiB) - let out = evaluate_psd_admission(5 * gb, 0, false, None, snap).unwrap(); + let out = evaluate_psd_admission(5 * gb, 0, 0, false, None, snap).unwrap(); assert_eq!(out.authorized_total, 6 * gb); // In [S, H]: requires approval - let err = evaluate_psd_admission(7 * gb, 0, false, None, snap).unwrap_err(); + let err = evaluate_psd_admission(7 * gb, 0, 0, false, None, snap).unwrap_err(); assert!(matches!(err, IoError::PsdApprovalRequired { required, .. } if required == 7 * gb)); // In [S, H] with approval and no approved_estimate bound: admitted, authorized_total = H (9 GiB) - let out = evaluate_psd_admission(7 * gb, 0, true, None, snap).unwrap(); + let out = evaluate_psd_admission(7 * gb, 0, 0, true, None, snap).unwrap(); assert_eq!(out.authorized_total, 9 * gb); // In [S, H] with approval but required exceeds approved_estimate: requires fresh approval - let err = evaluate_psd_admission(7 * gb, 0, true, Some(6 * gb), snap).unwrap_err(); + let err = evaluate_psd_admission(7 * gb, 0, 0, true, Some(6 * gb), snap).unwrap_err(); assert!(matches!(err, IoError::PsdApprovalRequired { .. })); // In [S, H] with approval and required within approved_estimate: admitted, authorized_total = approved_estimate (7 GiB) - let out = evaluate_psd_admission(7 * gb, 0, true, Some(7 * gb), snap).unwrap(); + let out = evaluate_psd_admission(7 * gb, 0, 0, true, Some(7 * gb), snap).unwrap(); assert_eq!(out.authorized_total, 7 * gb); // Exceeding H: refused even if high_memory_approved is true - let err = evaluate_psd_admission(10 * gb, 0, true, Some(10 * gb), snap).unwrap_err(); + let err = evaluate_psd_admission(10 * gb, 0, 0, true, Some(10 * gb), snap).unwrap_err(); assert!(matches!(err, IoError::PsdImportBudget { .. })); } + /// While the scratch disk spills, tile pixels are admitted against RAM plus its room, and + /// everything else against RAM alone. + #[test] + fn spilling_scratch_admits_tile_pixels_beyond_ram() { + if cfg!(any(target_arch = "wasm32", target_pointer_width = "32")) { + return; + } + let gb = 1u64 << 30; + let snap = MemorySnapshot { available_ram: Some(12 * gb), swap_free: None, commit_headroom: None }; + // RAM-only: S = 6 GiB, H = 9 GiB. + with_mock_scratch(Some(40 * gb), || { + // 20 GiB of which 18 GiB tiles: 2 GiB non-spillable fits S, so it is automatic. + let out = evaluate_psd_admission(20 * gb, 18 * gb, 0, false, None, snap).unwrap(); + assert_eq!(out.authorized_total, 6 * gb + 18 * gb); + // 8 GiB non-spillable sits in [S, H]: approval is still required, and is bound to + // the whole estimate. + let err = evaluate_psd_admission(26 * gb, 18 * gb, 0, false, None, snap).unwrap_err(); + assert!(matches!(err, IoError::PsdApprovalRequired { required, soft_limit, hard_limit, .. } + if required == 26 * gb && soft_limit == 24 * gb && hard_limit == 27 * gb)); + assert!(evaluate_psd_admission(26 * gb, 18 * gb, 0, true, Some(26 * gb), snap).is_ok()); + assert!(evaluate_psd_admission(26 * gb, 18 * gb, 0, true, Some(26 * gb - 1), snap).is_err()); + // 10 GiB non-spillable is over H: refused even when approved. + let err = evaluate_psd_admission(28 * gb, 18 * gb, 0, true, Some(28 * gb), snap).unwrap_err(); + assert!(matches!(err, IoError::PsdImportBudget { required, limit } if required == 28 * gb && limit == 27 * gb)); + // A spillable share claimed beyond the total is clamped to the total. + assert!(evaluate_psd_admission(5 * gb, u64::MAX, 0, false, None, snap).is_ok()); + // The caller's explicit limit still bounds the whole import. + let err = evaluate_psd_admission(20 * gb, 18 * gb, 10 * gb, false, None, snap).unwrap_err(); + assert!(matches!(err, IoError::PsdImportBudget { limit, .. } if limit == 10 * gb)); + }); + // Less room than tiles: only the room is credited. + with_mock_scratch(Some(4 * gb), || { + let err = evaluate_psd_admission(20 * gb, 18 * gb, 0, true, Some(20 * gb), snap).unwrap_err(); + assert!(matches!(err, IoError::PsdImportBudget { limit, .. } if limit == 13 * gb)); + assert_eq!(resolve_budget_with_scratch(snap, None, 18 * gb).soft_limit, 10 * gb); + }); + } + + /// Spilling off or paused, or the web: the RAM-only policy, unchanged. + #[test] + fn without_spilling_scratch_the_ram_policy_is_unchanged() { + if cfg!(any(target_arch = "wasm32", target_pointer_width = "32")) { + return; + } + let gb = 1u64 << 30; + let snap = MemorySnapshot { available_ram: Some(12 * gb), swap_free: None, commit_headroom: None }; + for scratch in [None, Some(0)] { + with_mock_scratch(scratch, || { + assert_eq!(resolve_budget_with_scratch(snap, None, 18 * gb), resolve_budget(snap, None)); + let err = evaluate_psd_admission(20 * gb, 18 * gb, 0, true, Some(20 * gb), snap).unwrap_err(); + assert!(matches!(err, IoError::PsdImportBudget { limit, .. } if limit == 9 * gb)); + let err = evaluate_psd_admission(7 * gb, 7 * gb, 0, false, None, snap).unwrap_err(); + assert!(matches!(err, IoError::PsdApprovalRequired { soft_limit, hard_limit, .. } if soft_limit == 6 * gb && hard_limit == 9 * gb)); + }); + } + // A mocked RAM snapshot without a mocked scratch disk never measures the real one. + with_mock_memory(snap, || assert_eq!(scratch_capacity(), None)); + with_mock_scratch(Some(40 * gb), || { + assert_eq!(scratch_credit_for(Platform::Web, 18 * gb), 0); + let err = evaluate_psd_admission_for(Platform::Web, WASM_BUDGET + 1, WASM_BUDGET, 0, false, None, snap).unwrap_err(); + assert!(matches!(err, IoError::PsdApprovalRequired { soft_limit, .. } if soft_limit == WASM_BUDGET)); + let err = evaluate_psd_admission_for(Platform::Web, WASM_MAX_PEAK_BUDGET + 1, WASM_MAX_PEAK_BUDGET, 0, true, None, snap).unwrap_err(); + assert!(matches!(err, IoError::PsdImportBudget { .. })); + }); + } + + /// A [`TestMemoryScope`] carries the mocked scratch disk to a worker thread. + #[test] + fn test_memory_scope_captures_the_mocked_scratch_disk() { + let scope = with_mock_scratch(Some(123), TestMemoryScope::capture); + std::thread::spawn(move || { + assert_eq!(MOCK_SCRATCH.with(std::cell::Cell::get), None); + let _g = scope.enter(); + assert_eq!(scratch_capacity(), Some(123)); + }) + .join() + .unwrap(); + } + #[test] fn known_snapshot_uses_known_memory_policy_on_every_target() { let gb = 1u64 << 30; @@ -1616,87 +1074,6 @@ mod tests { } } - #[test] - fn macos_available_ram_counts_reclaimable_pages_not_free_only() { - let gb = 1u64 << 30; - // A failed provider query: no installed size, or no used memory on a live host. - assert_eq!(macos_available_ram(0, 0, 0, 0), None, "an empty query is not a measurement"); - assert_eq!(macos_available_ram(0, 1 << 20, 4 * gb, 4 * gb), None, "no installed size: the sysctl query failed"); - assert_eq!(macos_available_ram(16 * gb, 1 << 20, 8 * gb, 0), None, "no used memory on a live host: the VM query failed"); - - // A genuine zero-capacity reading stays zero, not unknown. - assert_eq!(macos_available_ram(16 * gb, 0, 0, 16 * gb), Some(0), "everything is wired or compressed"); - - // Low free memory beside a large file-backed cache: free alone would report ~0 and the - // hard budget would collapse, while 12 GiB is reclaimable without touching a working set. - assert_eq!(macos_available_ram(16 * gb, 100 << 20, 12 * gb, 4 * gb), Some(12 * gb)); - - // A loaded machine: most memory is anonymous working set, which is not reclaimable, so - // `available` (which counts it) does not raise the estimate above total - used. - assert_eq!(macos_available_ram(16 * gb, 200 << 20, 8 * gb, 14 * gb), Some(2 * gb)); - - // Counters that disagree: free pages are allocatable by definition, so never claim less. - assert_eq!(macos_available_ram(16 * gb, 3 * gb, gb, 15 * gb), Some(3 * gb)); - - // Never above installed memory. - assert_eq!(macos_available_ram(gb, 2 * gb, 4 * gb, 1), Some(gb)); - } - - #[cfg(target_os = "macos")] - #[test] - fn macos_provider_excludes_swap_and_commit_headroom() { - let snap = MemorySnapshot::current(); - assert_eq!(snap.swap_free, None, "the adaptive budget must not count swap"); - assert_eq!(snap.commit_headroom, None); - // The numeric floor and zero-versus-unknown semantics are covered deterministically by - // `macos_available_ram_counts_reclaimable_pages_not_free_only`; asserting a live `> 0` - // here would be vacuous because a genuine zero is a valid reading. - } - - #[cfg(target_os = "linux")] - use std::path::{Path, PathBuf}; - - #[cfg(target_os = "linux")] - fn fixture(name: &str) -> (PathBuf, PathBuf) { - let root = std::env::temp_dir().join(format!("photocraft-cgroup-{}-{name}", std::process::id())); - let _ = std::fs::remove_dir_all(&root); - std::fs::create_dir_all(root.join("proc/self")).unwrap(); - std::fs::create_dir_all(root.join("sys/fs/cgroup")).unwrap(); - (root.join("proc"), root.join("sys/fs/cgroup")) - } - - #[cfg(target_os = "linux")] - fn write(path: &Path, text: &str) { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).unwrap(); - } - std::fs::write(path, text).unwrap(); - } - - #[cfg(target_os = "linux")] - #[test] - fn linux_memory_is_read_from_meminfo() { - let info = "MemTotal: 24314352 kB\nMemAvailable: 12626632 kB\nSwapFree: 1024 kB\n"; - assert_eq!(linux::meminfo(info, "MemAvailable:"), Some(12_626_632 * 1024)); - assert_eq!(linux::meminfo(info, "SwapFree:"), Some(1024 * 1024)); - assert_eq!(linux::meminfo(info, "Missing:"), None); - assert_eq!(linux::meminfo("MemAvailable: lots kB", "MemAvailable:"), None); - } - - #[cfg(target_os = "linux")] - #[test] - fn linux_cgroup_membership_distinguishes_v1_and_v2() { - assert_eq!(linux::membership_for_test("0::/user.slice/session.scope\n"), Some((Some("/user.slice/session.scope".into()), None))); - assert_eq!(linux::membership_for_test("4:memory:/docker/abc\n"), Some((None, Some("/docker/abc".into())))); - assert_eq!(linux::membership_for_test("4:cpu,cpuacct:/user.slice\n5:memory:/a/b\n"), Some((None, Some("/a/b".into())))); - assert_eq!(linux::membership_for_test("12:pids:/\n"), Some((None, None)), "no memory controller is demonstrably absent"); - assert_eq!(linux::membership_for_test("garbage\n"), None); - assert_eq!(linux::membership_for_test(""), None, "an empty file cannot prove absence"); - assert_eq!(linux::membership_for_test("0:memory:/app\n"), None, "the unified hierarchy cannot name controllers"); - assert_eq!(linux::membership_for_test("4::/app\n"), None, "a v1 line must name a controller"); - assert_eq!(linux::membership_for_test("4:memory:relative\n"), None, "cgroup paths are absolute"); - } - #[test] fn swap_is_never_part_of_the_adaptive_budget() { let gb = 1u64 << 30; @@ -1704,319 +1081,4 @@ mod tests { let without_swap = MemorySnapshot { available_ram: Some(4 * gb), swap_free: None, commit_headroom: None }; assert_eq!(resolve_budget_for(Platform::Native, with_swap, None), resolve_budget_for(Platform::Native, without_swap, None)); } - - #[cfg(target_os = "linux")] - #[test] - fn linux_mountinfo_octal_escapes_are_decoded() { - let (proc, sys) = fixture("escaped"); - write(&proc.join("self/cgroup"), "0::/app\n"); - let mount = sys.join("cg root"); - let escaped = mount.display().to_string().replace(' ', "\\040"); - write(&proc.join("self/mountinfo"), &format!("30 20 0:26 / {} rw,relatime - cgroup2 cgroup2 rw\n", escaped)); - write(&mount.join("app/memory.max"), "1073741824\n"); - write(&mount.join("app/memory.current"), "0\n"); - assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::Limited { ram: Some(1 << 30), swap: None }); - std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); - } - - #[cfg(target_os = "linux")] - #[test] - fn linux_v2_cgroup_applies_mount_path_and_ancestor_limits() { - let (proc, sys) = fixture("v2"); - write(&proc.join("self/cgroup"), "0::/app.scope\n"); - write(&proc.join("self/mountinfo"), &format!("30 20 0:26 / {} rw,nosuid,nodev,noexec,relatime - cgroup2 cgroup2 rw,nsdelegate\n", sys.display())); - let leaf = sys.join("app.scope"); - write(&sys.join("memory.max"), "8589934592\n"); - write(&sys.join("memory.current"), "2147483648\n"); - write(&sys.join("memory.stat"), "inactive_file 1073741824\n"); - write(&leaf.join("memory.max"), "max\n"); - assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::Limited { ram: Some(7 << 30), swap: None }, "the parent's limit applies"); - - write(&leaf.join("memory.max"), "4294967296\n"); - write(&leaf.join("memory.current"), "1073741824\n"); - write(&leaf.join("memory.stat"), "inactive_file 0\n"); - assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::Limited { ram: Some(3 << 30), swap: None }, "the tighter leaf limit wins"); - - write(&leaf.join("memory.swap.max"), "1073741824\n"); - write(&leaf.join("memory.swap.current"), "268435456\n"); - assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::Limited { ram: Some(3 << 30), swap: Some(768 << 20) }); - - std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); - } - - #[cfg(target_os = "linux")] - #[test] - fn linux_v2_distinguishes_disabled_controller_from_missing_expected_limit() { - // The hierarchy root may legitimately have no `memory.max`; a child has one exactly when - // its parent delegates the controller. - let (proc, sys) = fixture("v2-metadata"); - write(&proc.join("self/cgroup"), "0::/app\n"); - write(&proc.join("self/mountinfo"), &format!("30 20 0:26 / {} rw - cgroup2 cgroup2 rw\n", sys.display())); - write(&sys.join("cgroup.controllers"), "cpu io memory\n"); - write(&sys.join("cgroup.subtree_control"), "+cpu +memory\n"); - write(&sys.join("app/memory.max"), "1073741824\n"); - write(&sys.join("app/memory.current"), "0\n"); - assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::Limited { ram: Some(1 << 30), swap: None }, "a missing root file is normal"); - - write(&sys.join("cgroup.controllers"), "cpu io\n"); - write(&sys.join("cgroup.subtree_control"), "+cpu\n"); - std::fs::remove_file(sys.join("app/memory.max")).unwrap(); - assert_eq!( - linux::cgroup_limits(&proc, &sys), - linux::CgroupLimits::NotConstrained, - "readable controllers without memory prove the controller is absent" - ); - - write(&sys.join("cgroup.controllers"), "cpu io memory\n"); - write(&sys.join("cgroup.subtree_control"), "+memory\n"); - assert_eq!( - linux::cgroup_limits(&proc, &sys), - linux::CgroupLimits::Unreadable, - "memory is delegated to the child, so the missing child file means the view is incomplete" - ); - - std::fs::remove_file(sys.join("cgroup.controllers")).unwrap(); - std::fs::remove_file(sys.join("cgroup.subtree_control")).unwrap(); - assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::Unreadable, "without metadata the absence of a constraint cannot be established"); - std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); - - // Available but not delegated anywhere on the path: no cgroup limit applies. - let (proc, sys) = fixture("v2-not-enabled"); - write(&proc.join("self/cgroup"), "0::/app\n"); - write(&proc.join("self/mountinfo"), &format!("30 20 0:26 / {} rw - cgroup2 cgroup2 rw\n", sys.display())); - write(&sys.join("cgroup.controllers"), "cpu io memory\n"); - write(&sys.join("cgroup.subtree_control"), "+cpu\n"); - write(&sys.join("app/cgroup.controllers"), "\n"); - assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::NotConstrained); - std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); - } - - #[cfg(target_os = "linux")] - #[test] - fn linux_prefers_the_widest_hierarchy_view_over_a_deep_bind_mount() { - let (proc, sys) = fixture("bind-widest"); - write(&proc.join("self/cgroup"), "0::/user.slice/app.scope\n"); - let narrow = sys.join("bind"); - write( - &proc.join("self/mountinfo"), - &format!("30 20 0:26 / {} rw - cgroup2 cgroup2 rw\n31 30 0:26 /user.slice/app.scope {} rw - cgroup2 cgroup2 rw\n", sys.display(), narrow.display()), - ); - write(&sys.join("user.slice/memory.max"), "2147483648\n"); - write(&sys.join("user.slice/memory.current"), "0\n"); - write(&sys.join("user.slice/cgroup.subtree_control"), "+memory\n"); - write(&sys.join("user.slice/app.scope/memory.max"), "max\n"); - write(&narrow.join("memory.max"), "max\n"); - assert_eq!( - linux::cgroup_limits(&proc, &sys), - linux::CgroupLimits::Limited { ram: Some(2 << 30), swap: None }, - "the hierarchy's own mount exposes user.slice's limit; the deep bind mount would hide it" - ); - - write(&proc.join("self/mountinfo"), &format!("31 30 0:26 /user.slice/app.scope {} rw - cgroup2 cgroup2 rw\n", narrow.display())); - assert_eq!( - linux::cgroup_limits(&proc, &sys), - linux::CgroupLimits::Limited { ram: None, swap: None }, - "the bind mount alone cannot see the ancestor limit, which is why the widest view is preferred" - ); - - std::fs::remove_file(narrow.join("memory.max")).unwrap(); - assert_eq!( - linux::cgroup_limits(&proc, &sys), - linux::CgroupLimits::Unreadable, - "a mounted subtree root cannot silently lose the controller file it should expose" - ); - std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); - } - - #[cfg(target_os = "linux")] - #[test] - fn linux_v1_cgroup_honours_controller_mounts_and_ancestors() { - let (proc, sys) = fixture("v1"); - write(&proc.join("self/cgroup"), "4:memory:/docker/abc\n"); - let mount = sys.join("memory"); - write(&proc.join("self/mountinfo"), &format!("31 24 0:26 / {} rw,nosuid,nodev,noexec,relatime shared:12 - cgroup cgroup rw,memory\n", mount.display())); - let leaf = mount.join("docker/abc"); - write(&mount.join("docker/memory.limit_in_bytes"), "9223372036854771712\n"); - write(&mount.join("memory.limit_in_bytes"), "8589934592\n"); - write(&mount.join("memory.usage_in_bytes"), "2147483648\n"); - write(&mount.join("memory.stat"), "total_inactive_file 1073741824\n"); - write(&leaf.join("memory.limit_in_bytes"), "9223372036854771712\n"); - assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::Limited { ram: Some(7 << 30), swap: None }, "the v1 sentinel is unlimited"); - - write(&leaf.join("memory.limit_in_bytes"), "3221225472\n"); - write(&leaf.join("memory.usage_in_bytes"), "1073741824\n"); - write(&leaf.join("memory.stat"), "total_inactive_file 0\n"); - assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::Limited { ram: Some(2 << 30), swap: None }, "the tighter leaf limit wins"); - - write(&mount.join("memory.memsw.limit_in_bytes"), "9663676416\n"); - write(&mount.join("memory.memsw.usage_in_bytes"), "2147483648\n"); - assert_eq!( - linux::cgroup_limits(&proc, &sys), - linux::CgroupLimits::Limited { ram: Some(2 << 30), swap: Some(1 << 30) }, - "swap room is memsw limit - memory limit - swap used at the same ancestor, not combined headroom minus RAM headroom" - ); - - write(&mount.join("memory.memsw.usage_in_bytes"), "3221225472\n"); - assert_eq!( - linux::cgroup_limits(&proc, &sys), - linux::CgroupLimits::Limited { ram: Some(2 << 30), swap: Some(0) }, - "a gigabyte already swapped uses the ancestor's whole swap allowance" - ); - - write(&mount.join("memory.memsw.limit_in_bytes"), "10737418240\n"); - assert_eq!( - linux::cgroup_limits(&proc, &sys), - linux::CgroupLimits::Limited { ram: Some(2 << 30), swap: Some(1 << 30) }, - "only the memsw allowance above the memory limit counts as added swap room" - ); - - write(&mount.join("memory.limit_in_bytes"), "9223372036854771712\n"); - write(&leaf.join("memory.limit_in_bytes"), "9223372036854771712\n"); - write(&mount.join("memory.memsw.limit_in_bytes"), "2147483648\n"); - write(&mount.join("memory.memsw.usage_in_bytes"), "0\n"); - assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::Limited { ram: Some(2 << 30), swap: Some(0) }, "memsw alone still caps RAM"); - - std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); - } - - #[cfg(target_os = "linux")] - #[test] - fn linux_v1_swap_bounds_combine_per_ancestor() { - let (proc, sys) = fixture("v1-swap-ancestors"); - write(&proc.join("self/cgroup"), "4:memory:/a/b\n"); - let mount = sys.join("memory"); - write(&proc.join("self/mountinfo"), &format!("31 24 0:26 / {} rw - cgroup cgroup rw,memory\n", mount.display())); - write(&mount.join("memory.limit_in_bytes"), "8589934592\n"); - write(&mount.join("memory.usage_in_bytes"), "2147483648\n"); - write(&mount.join("memory.memsw.limit_in_bytes"), "9663676416\n"); - write(&mount.join("memory.memsw.usage_in_bytes"), "2147483648\n"); - write(&mount.join("a/memory.limit_in_bytes"), "10737418240\n"); - write(&mount.join("a/memory.usage_in_bytes"), "1073741824\n"); - write(&mount.join("a/memory.memsw.limit_in_bytes"), "21474836480\n"); - write(&mount.join("a/memory.memsw.usage_in_bytes"), "2147483648\n"); - write(&mount.join("a/b/memory.limit_in_bytes"), "3221225472\n"); - write(&mount.join("a/b/memory.usage_in_bytes"), "1073741824\n"); - - assert_eq!( - linux::cgroup_limits(&proc, &sys), - linux::CgroupLimits::Limited { ram: Some(2 << 30), swap: Some(1 << 30) }, - "root swap allowance is 9 - 8 - 0 = 1 GiB; /a contributes 9 GiB and the leaf adds no swap limit" - ); - std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); - } - - #[cfg(target_os = "linux")] - #[test] - fn linux_v1_mount_root_prefix_is_stripped() { - let (proc, sys) = fixture("v1-root"); - write(&proc.join("self/cgroup"), "4:memory:/docker/abc/child\n"); - let mount = sys.join("memory"); - write(&proc.join("self/mountinfo"), &format!("31 24 0:26 /docker/abc {} rw,relatime - cgroup cgroup rw,memory\n", mount.display())); - write(&mount.join("memory.limit_in_bytes"), "1073741824\n"); - write(&mount.join("memory.usage_in_bytes"), "0\n"); - write(&mount.join("memory.stat"), "total_inactive_file 0\n"); - write(&mount.join("child/memory.limit_in_bytes"), "1073741824\n"); - write(&mount.join("child/memory.usage_in_bytes"), "0\n"); - assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::Limited { ram: Some(1 << 30), swap: None }); - std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); - } - - #[cfg(target_os = "linux")] - #[test] - fn linux_cgroup_falls_back_to_conventional_layouts() { - let (proc, sys) = fixture("fallback-v2"); - write(&proc.join("self/cgroup"), "0::/app.scope\n"); - write(&sys.join("app.scope/memory.max"), "1073741824\n"); - write(&sys.join("app.scope/memory.current"), "0\n"); - assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::Limited { ram: Some(1 << 30), swap: None }); - std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); - - let (proc, sys) = fixture("fallback-v1"); - write(&proc.join("self/cgroup"), "4:memory:/app\n"); - write(&sys.join("memory/memory.limit_in_bytes"), "1073741824\n"); - write(&sys.join("memory/memory.usage_in_bytes"), "0\n"); - write(&sys.join("memory/memory.stat"), "total_inactive_file 0\n"); - write(&sys.join("memory/app/memory.limit_in_bytes"), "1073741824\n"); - write(&sys.join("memory/app/memory.usage_in_bytes"), "0\n"); - write(&sys.join("memory/app/memory.stat"), "total_inactive_file 0\n"); - assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::Limited { ram: Some(1 << 30), swap: None }); - std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); - } - - #[cfg(target_os = "linux")] - #[test] - fn linux_unreadable_constraints_fail_closed() { - let (proc, sys) = fixture("unreadable-missing-dir"); - write(&proc.join("self/cgroup"), "0::/missing\n"); - write(&proc.join("meminfo"), "MemAvailable: 12582912 kB\nSwapFree: 2097152 kB\n"); - assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::Unreadable); - let snap = linux::snapshot_at(&proc, &sys); - assert_eq!(snap.available_ram, Some(0), "host memory must not be assumed"); - assert_eq!(snap.swap_free, Some(0)); - std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); - - let (proc, sys) = fixture("unreadable-v1-limit"); - write(&proc.join("self/cgroup"), "4:memory:/app\n"); - let mount = sys.join("memory"); - write(&proc.join("self/mountinfo"), &format!("31 24 0:26 / {} rw,relatime - cgroup cgroup rw,memory\n", mount.display())); - std::fs::create_dir_all(mount.join("app")).unwrap(); - assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::Unreadable); - std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); - - let (proc, sys) = fixture("unreadable-v2-limit"); - write(&proc.join("self/cgroup"), "0::/app\n"); - write(&sys.join("app/memory.max"), "lots\n"); - write(&sys.join("app/memory.current"), "0\n"); - assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::Unreadable); - std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); - - let (proc, sys) = fixture("no-memory-controller"); - write(&proc.join("self/cgroup"), "4:cpu,cpuacct:/user.slice\n"); - assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::NotConstrained); - std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); - - let (proc, sys) = fixture("malformed-membership"); - write(&proc.join("self/cgroup"), "0:memory:/app\n"); - write(&proc.join("meminfo"), "MemAvailable: 12582912 kB\nSwapFree: 2097152 kB\n"); - assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::Unreadable, "a malformed membership line cannot prove the controller absent"); - assert_eq!(linux::snapshot_at(&proc, &sys).available_ram, Some(0), "host memory must not be assumed"); - std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); - - let (proc, sys) = fixture("empty-membership"); - write(&proc.join("self/cgroup"), ""); - assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::Unreadable, "an empty membership file cannot prove absence"); - std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); - - let (proc, sys) = fixture("mountinfo-without-cgroup"); - write(&proc.join("self/cgroup"), "0::/app\n"); - write(&proc.join("self/mountinfo"), "30 20 8:1 / / rw - ext4 /dev/root rw\n"); - write(&sys.join("app/memory.max"), "1073741824\n"); - write(&sys.join("app/memory.current"), "0\n"); - assert_eq!( - linux::cgroup_limits(&proc, &sys), - linux::CgroupLimits::Unreadable, - "a readable mountinfo without a cgroup mount must not be guessed away by the conventional path" - ); - std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); - } - - #[cfg(target_os = "linux")] - #[test] - fn linux_snapshot_combines_host_meminfo_and_cgroup_room() { - let (proc, sys) = fixture("snapshot"); - write(&proc.join("self/cgroup"), "0::/app.scope\n"); - write(&proc.join("meminfo"), "MemTotal: 16777216 kB\nMemAvailable: 12582912 kB\nSwapFree: 2097152 kB\n"); - write(&proc.join("self/mountinfo"), &format!("30 20 0:26 / {} rw,relatime - cgroup2 cgroup2 rw\n", sys.display())); - write(&sys.join("app.scope/memory.max"), "4294967296\n"); - write(&sys.join("app.scope/memory.current"), "0\n"); - write(&sys.join("app.scope/memory.swap.max"), "536870912\n"); - write(&sys.join("app.scope/memory.swap.current"), "0\n"); - - let snap = linux::snapshot_at(&proc, &sys); - assert_eq!(snap.available_ram, Some(4 << 30), "the cgroup limit is smaller than host MemAvailable"); - assert_eq!(snap.swap_free, Some(512 << 20)); - assert_eq!(snap.commit_headroom, None); - assert_eq!(linux::available_at(&proc, &sys), Some((4 << 30) + (512 << 20)), "legacy helper keeps RAM + swap"); - std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); - } } diff --git a/crates/io/src/psd_budget.rs b/crates/io/src/psd_budget.rs index 4a4463abd4..a80230322d 100644 --- a/crates/io/src/psd_budget.rs +++ b/crates/io/src/psd_budget.rs @@ -25,18 +25,26 @@ pub struct PsdMemoryEstimate { pub workspace_bytes: u64, /// Total peak requirement (retained + workspace). pub total_bytes: u64, + /// The part of `retained_bytes` that is tile pixels (layer, mask, channel, fallback and + /// pattern surfaces). Tiles can be spilled to the scratch disk while it is on, so admission + /// does not bound them by physical RAM; the merged planes, pattern decodes and the workspace + /// are ordinary memory. + pub tile_bytes: u64, } #[derive(Default)] struct ImportBudget { retained: u64, workspace: u64, + tiles: u64, } #[cfg(any(test, feature = "testgen"))] thread_local! { /// Extra bytes added to every estimate on this thread by [`with_test_estimate_charge`]. static TEST_ESTIMATE_CHARGE: std::cell::Cell = const { std::cell::Cell::new(0) }; + /// Extra tile-pixel bytes added by [`with_test_tile_charge`]. + static TEST_TILE_CHARGE: std::cell::Cell = const { std::cell::Cell::new(0) }; } /// Adds `bytes` to every PSD memory estimate made on this thread while `f` runs. @@ -64,28 +72,45 @@ pub fn with_test_estimate_charge(bytes: u64, f: impl FnOnce() -> R) -> R { f() } -/// The calling thread's current estimate charge, for an explicit scope to capture. +/// [`with_test_estimate_charge`] for tile pixels: `bytes` are added to every estimate made on +/// this thread while `f` runs, as spillable tile bytes ([`PsdMemoryEstimate::tile_bytes`]), so a +/// scratch-aware admission test needs no huge fixture. Captured by +/// [`crate::memory::TestMemoryScope`] like the plain charge. #[cfg(any(test, feature = "testgen"))] -pub(crate) fn test_estimate_charge() -> u64 { - TEST_ESTIMATE_CHARGE.with(|c| c.get()) +pub fn with_test_tile_charge(bytes: u64, f: impl FnOnce() -> R) -> R { + struct Reset(u64); + impl Drop for Reset { + fn drop(&mut self) { + TEST_TILE_CHARGE.with(|c| c.set(self.0)); + } + } + let prev = TEST_TILE_CHARGE.with(|c| c.replace(bytes)); + let _reset = Reset(prev); + f() } -/// Enters a captured estimate charge (an explicit [`crate::memory::TestMemoryScope`] worker) and -/// returns the charge it replaced, to restore after the scope ends. +/// The calling thread's current (plain, tile) estimate charges, for an explicit scope to capture. #[cfg(any(test, feature = "testgen"))] -pub(crate) fn enter_test_estimate_charge(bytes: u64) -> u64 { - TEST_ESTIMATE_CHARGE.with(|c| c.replace(bytes)) +pub(crate) fn test_estimate_charge() -> (u64, u64) { + (TEST_ESTIMATE_CHARGE.with(|c| c.get()), TEST_TILE_CHARGE.with(|c| c.get())) } -/// The test seam's extra charge; zero in every other build. -fn test_estimate_charge_value() -> u64 { +/// Enters captured estimate charges (an explicit [`crate::memory::TestMemoryScope`] worker) and +/// returns the charges they replaced, to restore after the scope ends. +#[cfg(any(test, feature = "testgen"))] +pub(crate) fn enter_test_estimate_charge(bytes: u64, tiles: u64) -> (u64, u64) { + (TEST_ESTIMATE_CHARGE.with(|c| c.replace(bytes)), TEST_TILE_CHARGE.with(|c| c.replace(tiles))) +} + +/// The test seam's extra (plain, tile) charges; zero in every other build. +fn test_estimate_charge_value() -> (u64, u64) { #[cfg(any(test, feature = "testgen"))] { test_estimate_charge() } #[cfg(not(any(test, feature = "testgen")))] { - 0 + (0, 0) } } @@ -125,7 +150,15 @@ impl ImportBudget { // origin occupies four tiles. Widen first, including the exclusive right/bottom edge. let nx = (i64::from(rect.right) - 1).div_euclid(tile) - i64::from(rect.left).div_euclid(tile) + 1; let ny = (i64::from(rect.bottom) - 1).div_euclid(tile) - i64::from(rect.top).div_euclid(tile) + 1; - self.retain(mul(mul(mul(nx as u64, ny as u64)?, (TILE_SIZE as u64).pow(2))?, mul(channels, sample)?)?) + let bytes = mul(mul(mul(nx as u64, ny as u64)?, (TILE_SIZE as u64).pow(2))?, mul(channels, sample)?)?; + self.tile(bytes) + } + + /// Retained tile pixels: spillable to the scratch disk. + fn tile(&mut self, bytes: u64) -> Result<(), IoError> { + self.retain(bytes)?; + self.tiles = self.tiles.checked_add(bytes).ok_or_else(overflow)?; + Ok(()) } } @@ -205,9 +238,11 @@ pub fn estimate(file: &PsdFile) -> Result { budget.scratch(converted.checked_add(p.decoded_bytes).ok_or_else(overflow)?); } } - budget.retain(test_estimate_charge_value())?; + let (charge, tile_charge) = test_estimate_charge_value(); + budget.retain(charge)?; + budget.tile(tile_charge)?; let total_bytes = budget.required()?; - Ok(PsdMemoryEstimate { retained_bytes: budget.retained, workspace_bytes: budget.workspace, total_bytes }) + Ok(PsdMemoryEstimate { retained_bytes: budget.retained, workspace_bytes: budget.workspace, total_bytes, tile_bytes: budget.tiles }) } /// Checks that `file`'s estimate does not exceed `limit`. diff --git a/crates/io/src/psd_budget/tests.rs b/crates/io/src/psd_budget/tests.rs index f6ace6a4c3..9e24676716 100644 --- a/crates/io/src/psd_budget/tests.rs +++ b/crates/io/src/psd_budget/tests.rs @@ -328,15 +328,15 @@ fn retry_estimate_cap_rejects_altered_or_enlarged_file() { // S = 6 GiB, H = 9 GiB // 1. Within approved_estimate: passes - let res = crate::memory::evaluate_psd_admission(7 * gb, 0, true, Some(7 * gb), snap); + let res = crate::memory::evaluate_psd_admission(7 * gb, 0, 0, true, Some(7 * gb), snap); assert!(res.is_ok()); // 2. Fresh requirement exceeds approved_estimate: requires fresh approval - let res = crate::memory::evaluate_psd_admission(8 * gb, 0, true, Some(7 * gb), snap); + let res = crate::memory::evaluate_psd_admission(8 * gb, 0, 0, true, Some(7 * gb), snap); assert!(matches!(res, Err(IoError::PsdApprovalRequired { required, .. }) if required == 8 * gb)); // 3. Exceeds hard limit: refused completely regardless of approval - let res = crate::memory::evaluate_psd_admission(10 * gb, 0, true, Some(10 * gb), snap); + let res = crate::memory::evaluate_psd_admission(10 * gb, 0, 0, true, Some(10 * gb), snap); assert!(matches!(res, Err(IoError::PsdImportBudget { required, limit }) if required == 10 * gb && limit == 9 * gb)); } @@ -532,3 +532,49 @@ fn memory_drop_while_waiting_for_the_gate_refuses_before_decoding() { } handle.join().expect("worker thread"); } + +/// A real import counts its tile pixels as spillable: with a spilling scratch disk they are +/// admitted against RAM plus its room, while non-spillable working memory still needs RAM, and +/// with spilling off the RAM-only policy applies unchanged. Nothing near a gigabyte is allocated. +#[test] +fn real_import_admits_tile_pixels_against_scratch_room_only_while_spilling() { + if cfg!(any(target_arch = "wasm32", target_pointer_width = "32")) { + return; + } + let gb = 1u64 << 30; + let file = testgen::small(Version::Psd, Compression::Zip); + let bytes = file.to_bytes().unwrap(); + let est = estimate(&file); + let snapshot = crate::memory::MemorySnapshot { available_ram: Some(12 * gb), swap_free: None, commit_headroom: None }; + let import = |opts: &crate::ImportOptions| crate::import_with_options("scratch.psd", &bytes, opts); + crate::memory::with_mock_memory(snapshot, || { + assert!(super::estimate(&file).unwrap().tile_bytes > 0, "layer surfaces are tile pixels"); + with_test_tile_charge(18 * gb, || { + let charged = super::estimate(&file).unwrap(); + assert_eq!(charged.total_bytes, est + 18 * gb); + // Spilling off (or paused): 18 GiB of tiles is over H = 9 GiB. + crate::memory::with_mock_scratch(None, || { + assert!(matches!(import(&Default::default()), Err(IoError::PsdImportBudget { limit, .. }) if limit == 9 * gb)); + }); + // Spilling with room for every tile: automatic, no approval. + crate::memory::with_mock_scratch(Some(40 * gb), || { + assert!(import(&Default::default()).is_ok()); + }); + // Room for only part of the tiles: the rest must fit RAM. + crate::memory::with_mock_scratch(Some(4 * gb), || { + assert!(matches!(import(&Default::default()), Err(IoError::PsdImportBudget { .. }))); + }); + }); + // Non-spillable memory gets no scratch credit: 7 GiB still asks for approval. + with_test_estimate_charge(7 * gb, || { + crate::memory::with_mock_scratch(Some(40 * gb), || match import(&Default::default()) { + Err(IoError::PsdApprovalRequired { required, soft_limit, .. }) => { + let tiles = super::estimate(&file).unwrap().tile_bytes; + assert_eq!(soft_limit, 6 * gb + tiles); + assert!(required > soft_limit); + } + other => panic!("expected an approval request, got {other:?}"), + }); + }); + }); +} diff --git a/crates/io/src/psd_import.rs b/crates/io/src/psd_import.rs index 154f0fb27d..12aabf6e8d 100644 --- a/crates/io/src/psd_import.rs +++ b/crates/io/src/psd_import.rs @@ -1245,9 +1245,9 @@ fn legacy_admission(est: &crate::psd_budget::PsdMemoryEstimate, ctl: &photocraft } let snapshot = || crate::memory::MemorySnapshot::current(); // Legacy standalone is unconfirmed: it cannot silently auto allow an estimate above S. - crate::memory::evaluate_psd_admission(est.total_bytes, 0, false, None, snapshot())?; + crate::memory::evaluate_psd_admission(est.total_bytes, est.tile_bytes, 0, false, None, snapshot())?; let guard = crate::psd_gate::acquire_decode_admission(ctl)?; - let outcome = crate::memory::evaluate_psd_admission(est.total_bytes, 0, false, None, snapshot())?; + let outcome = crate::memory::evaluate_psd_admission(est.total_bytes, est.tile_bytes, 0, false, None, snapshot())?; crate::psd_gate::set_active_attempt_budget(outcome.authorized_total.saturating_sub(est.total_bytes)); Ok(guard) } diff --git a/crates/io/src/tiff_layers.rs b/crates/io/src/tiff_layers.rs index 4bdd0b224e..877fed294a 100644 --- a/crates/io/src/tiff_layers.rs +++ b/crates/io/src/tiff_layers.rs @@ -64,6 +64,10 @@ pub(crate) struct LayeredPlan { /// Bytes this import needs beside what it already holds, for admission; see /// [`layered_import_bytes`]. pub(crate) required: u64, + /// The part of `required` that is tile pixels, spillable to the scratch disk + /// ([`crate::psd_budget::PsdMemoryEstimate::tile_bytes`]); 0 for a flattened fallback, whose + /// admission charge is decode and conversion memory. + pub(crate) tiles: u64, } struct LayeredModel { @@ -97,7 +101,7 @@ pub(crate) fn preflight(bands: &codecs::TiffBands<'_>) -> Result = isd_warnings.into_iter().map(|w| format!("layer data: {w}")).collect(); @@ -159,7 +163,7 @@ pub(crate) fn preflight(bands: &codecs::TiffBands<'_>) -> Result Result { - let LayeredPlan { model, flatten_warning, mut warnings, required: _ } = plan; + let LayeredPlan { model, flatten_warning, mut warnings, required: _, tiles: _ } = plan; let Some(model) = model else { let mut r = image_to_document(name, img)?; if let Some(warning) = flatten_warning { diff --git a/crates/raster/Cargo.toml b/crates/raster/Cargo.toml index e7f5af6420..058e275b60 100644 --- a/crates/raster/Cargo.toml +++ b/crates/raster/Cargo.toml @@ -18,7 +18,12 @@ lz4_flex = { version = "0.11", default-features = false, features = ["std", "saf # sysinfo reads the physical RAM size for the memory limit and the scratch volume's free space. [target.'cfg(not(target_arch = "wasm32"))'.dependencies] rayon = { workspace = true } -sysinfo = { version = "0.37", default-features = false, features = ["system", "disk"] } +sysinfo = { version = "=0.40.1", default-features = false, features = ["system", "disk"] } + +# Available physical memory and commit headroom (memory/system.rs) through winsafe's safe +# `GlobalMemoryStatusEx` wrapper. +[target.'cfg(windows)'.dependencies] +winsafe = { version = "=0.0.29", default-features = false, features = ["kernel"] } [dev-dependencies] proptest = { workspace = true } diff --git a/crates/raster/src/memory.rs b/crates/raster/src/memory.rs index 5ad3df50d0..b996bd3152 100644 --- a/crates/raster/src/memory.rs +++ b/crates/raster/src/memory.rs @@ -6,6 +6,8 @@ use std::sync::OnceLock; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Mutex, PoisonError}; +pub mod system; + const MIB: u64 = 1 << 20; #[derive(Clone, Copy, Debug, Default, serde::Serialize)] @@ -76,14 +78,23 @@ pub fn configuration_generation() -> u64 { #[cfg(not(target_arch = "wasm32"))] pub fn measure() -> Snapshot { use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, System}; - static SYSTEM: OnceLock> = OnceLock::new(); - let mut s = SYSTEM.get_or_init(|| Mutex::new(System::new())).lock().unwrap_or_else(PoisonError::into_inner); + static SYSTEM: OnceLock>> = OnceLock::new(); + let mut guard = SYSTEM.get_or_init(|| Mutex::new(System::new().ok())).lock().unwrap_or_else(PoisonError::into_inner); + // No provider: an unknown total keeps the configured ceiling (see [`budgets`]). + let Some(s) = guard.as_mut() else { return Snapshot::default() }; s.refresh_memory(); let pid = Pid::from_u32(std::process::id()); s.refresh_processes_specifics(ProcessesToUpdate::Some(&[pid]), true, ProcessRefreshKind::nothing().with_memory()); + // The shared detector bounds host memory by the process's cgroup and, on macOS, counts only + // reclaimable pages. A constraint it could not read keeps the host figure here: the tile + // budget only decides what to spill, and a fail-closed zero would stall every operation. + let available = match system::current() { + system::SystemMemory { available_ram: Some(ram), constraint_unreadable: false, .. } => ram, + _ => s.available_memory(), + }; Snapshot { total_bytes: s.total_memory(), - available_bytes: s.available_memory(), + available_bytes: available, process_resident_bytes: s.process(pid).map_or(0, |p| p.memory()), process_virtual_bytes: s.process(pid).map_or(0, |p| p.virtual_memory()), } diff --git a/crates/raster/src/memory/system.rs b/crates/raster/src/memory/system.rs new file mode 100644 index 0000000000..ac2e4b772b --- /dev/null +++ b/crates/raster/src/memory/system.rs @@ -0,0 +1,1175 @@ +//! Platform measurements of the memory this process can use, shared by the tile residency +//! budget ([`super::measure`]) and the import admission budget (`photocraft_io::memory`), so +//! there is one detector of available RAM. +//! +//! - Linux: `/proc/meminfo` `MemAvailable`, capped by the process's memory cgroup (v2 +//! `memory.max`/`memory.high` and v1 `memory.limit_in_bytes`/`memsw`, with every visible +//! ancestor); a cgroup that exists but cannot be read fails closed. +//! - Windows: `GlobalMemoryStatusEx` available physical memory and commit headroom. +//! - macOS: the reclaimable pool from the VM counters (free, file-backed, purgeable). +//! - Elsewhere (including the web): unknown. +//! +//! Swap is reported separately and never counted as available RAM. + +/// Memory this process can use now, distinguishing physical RAM from swap and commit headroom. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub struct SystemMemory { + /// Available physical RAM in bytes, bounded by applicable container or process limits. Free + /// swap is excluded. `Some(0)` is zero capacity, not unknown memory. + pub available_ram: Option, + /// Available swap headroom in bytes, if known. + pub swap_free: Option, + /// System or process commit headroom in bytes, if measurable. + pub commit_headroom: Option, + /// A memory constraint exists but could not be read, so `available_ram` is the fail-closed + /// `Some(0)` rather than a measurement. Admission refuses on it; the tile residency budget + /// falls back to the host figure instead, because a zero budget would stall every operation. + pub constraint_unreadable: bool, +} + +impl SystemMemory { + /// Unknown memory measurement. + pub const UNKNOWN: Self = Self { available_ram: None, swap_free: None, commit_headroom: None, constraint_unreadable: false }; +} + +/// Measures [`SystemMemory`] from the platform provider. +pub fn current() -> SystemMemory { + #[cfg(target_os = "linux")] + { + linux::snapshot() + } + #[cfg(windows)] + { + windows_mem::snapshot() + } + #[cfg(target_os = "macos")] + { + macos_mem::snapshot() + } + #[cfg(not(any(target_os = "linux", windows, target_os = "macos")))] + { + fallback_mem::snapshot() + } +} + +/// Bytes this process can still use without running out, counting free swap, or `None` where +/// that isn't known. On Linux: [`current`]'s available RAM plus free swap (both capped by the +/// memory cgroup). Elsewhere `None`: Windows refuses an allocation past its commit limit, which +/// the importers already report as an error, and macOS pages to disk as it needs. +pub fn available_with_swap() -> Option { + #[cfg(target_os = "linux")] + { + linux::available() + } + #[cfg(not(target_os = "linux"))] + { + None + } +} + +#[cfg(target_os = "linux")] +pub(super) mod linux { + use super::SystemMemory; + use std::path::{Path, PathBuf}; + + /// cgroup v1 reports `PAGE_COUNTER_MAX * page_size` for "no limit", which is always close to + /// `i64::MAX`. Any limit at or above 4 EiB is treated as unlimited. + const V1_UNLIMITED: u64 = 1 << 62; + + /// Bound on ancestor walks; cgroup hierarchies are far shallower than this. + const MAX_CGROUP_DEPTH: usize = 128; + + #[derive(Clone, Debug, PartialEq, Eq)] + enum Limit { + Missing, + Unlimited, + Value(u64), + Invalid, + } + + /// What the process's memory cgroup says, independent of host `/proc/meminfo`. + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + pub(super) enum CgroupLimits { + /// No memory controller in this process's hierarchy: host memory applies. + NotConstrained, + /// Headroom under the memory controller; `None` means that dimension is unlimited. + Limited { ram: Option, swap: Option }, + /// A memory controller exists but its mount, path or limit could not be read. Callers + /// must fail closed instead of falling back to unrestricted host memory. + Unreadable, + } + + #[derive(Clone, Debug, PartialEq, Eq)] + struct CgroupDir { + leaf: PathBuf, + /// The visible root of the walk (the mount point). + root: PathBuf, + /// Whether `root` is the hierarchy's own root rather than a subtree exposed by a mount. + /// Only the hierarchy root is exempt from exposing its controller interface files. + hierarchy_root: bool, + } + + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + struct Membership<'a> { + /// Unified (v2) hierarchy path (`0::`). + v2: Option<&'a str>, + /// v1 hierarchy path for the `memory` controller. + v1_memory: Option<&'a str>, + } + + /// Result of reading `/proc/self/cgroup`. + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + enum MembershipParse<'a> { + Parsed(Membership<'a>), + /// A non-empty line could not be understood, so the absence of a memory controller cannot + /// be established. + Malformed, + } + + #[derive(Clone, Debug, PartialEq, Eq)] + struct Mount { + root: String, + mount_point: String, + fstype: String, + options: String, + super_options: String, + } + + /// Measures available RAM and free swap from `/proc/meminfo`, capped by the process's memory + /// cgroup. Unreadable cgroup constraints fail closed as zero available memory rather than + /// falling back to unrestricted host memory. + pub(super) fn snapshot() -> SystemMemory { + snapshot_at(Path::new("/proc"), Path::new("/sys/fs/cgroup")) + } + + /// Provider body with explicit roots, so tests exercise the real filesystem parsing without + /// global mocks. + pub(super) fn snapshot_at(proc_root: &Path, sys_root: &Path) -> SystemMemory { + let info = std::fs::read_to_string(proc_root.join("meminfo")).ok(); + let host_ram = info.as_deref().and_then(|i| meminfo(i, "MemAvailable:")); + let host_swap = info.as_deref().and_then(|i| meminfo(i, "SwapFree:")); + + let (cgroup_ram, cgroup_swap) = match cgroup_limits(proc_root, sys_root) { + CgroupLimits::NotConstrained => (None, None), + CgroupLimits::Limited { ram, swap } => (ram, swap), + CgroupLimits::Unreadable => return SystemMemory { available_ram: Some(0), swap_free: Some(0), commit_headroom: None, constraint_unreadable: true }, + }; + + let available_ram = match (host_ram, cgroup_ram) { + (Some(host), Some(cgroup)) => Some(host.min(cgroup)), + (Some(host), None) => Some(host), + (None, Some(cgroup)) => Some(cgroup), + (None, None) => None, + }; + let swap_free = match (host_swap, cgroup_swap) { + (Some(host), Some(cgroup)) => Some(host.min(cgroup)), + (Some(host), None) => Some(host), + (None, Some(cgroup)) => Some(cgroup), + (None, None) => None, + }; + SystemMemory { available_ram, swap_free, commit_headroom: None, constraint_unreadable: false } + } + + pub(super) fn available() -> Option { + available_at(Path::new("/proc"), Path::new("/sys/fs/cgroup")) + } + + /// Legacy RAM-plus-swap helper with explicit roots. Its meaning is deliberately unchanged: + /// the adaptive budget excludes swap, but flat and layered TIFF conversion still count it + /// (now bounded by the cgroup figures derived above, including v1 `memsw`). + pub(super) fn available_at(proc_root: &Path, sys_root: &Path) -> Option { + let snap = snapshot_at(proc_root, sys_root); + snap.available_ram.map(|ram| ram.saturating_add(snap.swap_free.unwrap_or(0))) + } + + /// A `/proc/meminfo` field in bytes. + pub(super) fn meminfo(info: &str, field: &str) -> Option { + let kb: u64 = info.lines().find_map(|l| l.strip_prefix(field))?.trim().trim_end_matches("kB").trim().parse().ok()?; + kb.checked_mul(1024) + } + + /// Resolves the memory cgroup from `/proc/self/cgroup` plus `/proc/self/mountinfo`. + /// + /// v1 is preferred when the `memory` controller appears there; otherwise the unified v2 + /// hierarchy is used. A memory controller that cannot be resolved or read, or membership that + /// cannot be parsed, is [`CgroupLimits::Unreadable`], never host memory. Ancestors above a + /// cgroup namespace's root are not visible to an unprivileged reader (the kernel still + /// enforces them), so only visible ancestors can be accounted for. The widest visible view of + /// the hierarchy is used, not merely the deepest matching mount point. + pub(super) fn cgroup_limits(proc_root: &Path, sys_root: &Path) -> CgroupLimits { + let Ok(groups) = std::fs::read_to_string(proc_root.join("self/cgroup")) else { + // Without membership we cannot prove that no memory cgroup applies. + return CgroupLimits::Unreadable; + }; + let membership = match parse_membership(&groups) { + MembershipParse::Parsed(membership) => membership, + MembershipParse::Malformed => return CgroupLimits::Unreadable, + }; + let mountinfo = std::fs::read_to_string(proc_root.join("self/mountinfo")).ok(); + + if let Some(path) = membership.v1_memory { + let Some(dir) = resolve_dir(sys_root, mountinfo.as_deref(), path, "cgroup", Some("memory")) else { + return CgroupLimits::Unreadable; + }; + return v1_limits(&dir); + } + if let Some(path) = membership.v2 { + let Some(dir) = resolve_dir(sys_root, mountinfo.as_deref(), path, "cgroup2", None) else { + return CgroupLimits::Unreadable; + }; + return v2_limits(&dir); + } + CgroupLimits::NotConstrained + } + + /// Parses `/proc/self/cgroup`. + /// + /// Kernel format is `::`; the + /// unified hierarchy uses `0::`. A line that does not have that shape makes the whole + /// file untrustworthy: returning "no memory controller" from a partial parse would silently + /// fall back to unrestricted host memory, so an empty or malformed file is reported as + /// malformed instead. + fn parse_membership(text: &str) -> MembershipParse<'_> { + let mut membership = Membership { v2: None, v1_memory: None }; + let mut saw_line = false; + for line in text.lines() { + let line = line.trim_end_matches('\r'); + if line.is_empty() { + continue; + } + saw_line = true; + let mut fields = line.splitn(3, ':'); + let (Some(hierarchy), Some(controllers), Some(path)) = (fields.next(), fields.next(), fields.next()) else { + return MembershipParse::Malformed; + }; + let Ok(hierarchy) = hierarchy.parse::() else { return MembershipParse::Malformed }; + if !path.starts_with('/') { + return MembershipParse::Malformed; + } + if hierarchy == 0 { + if !controllers.is_empty() { + return MembershipParse::Malformed; + } + membership.v2.get_or_insert(path); + } else if controllers.is_empty() { + return MembershipParse::Malformed; + } else if controllers.split(',').any(|c| c.trim() == "memory") { + membership.v1_memory.get_or_insert(path); + } + } + if !saw_line { + return MembershipParse::Malformed; + } + MembershipParse::Parsed(membership) + } + + /// Parses `/proc/self/mountinfo` into the mount fields used to locate a cgroup hierarchy. + /// + /// Malformed lines are skipped; callers fail closed when no matching mount is found. + fn parse_mountinfo(text: &str) -> Vec { + let mut mounts = Vec::new(); + for line in text.lines() { + let fields: Vec<&str> = line.split_whitespace().collect(); + let Some(separator) = fields.iter().position(|f| *f == "-") else { continue }; + let (Some(root), Some(point), Some(fstype), Some(super_options)) = + (fields.get(3).copied(), fields.get(4).copied(), fields.get(separator + 1).copied(), fields.get(separator + 3).copied()) + else { + continue; + }; + let options = fields.get(5).copied().unwrap_or(""); + mounts.push(Mount { + root: unescape(root), + mount_point: unescape(point), + fstype: fstype.to_string(), + options: options.to_string(), + super_options: super_options.to_string(), + }); + } + mounts + } + + /// Finds the mount for `fstype`/`controller` and joins the process's cgroup path to it. + /// + /// Reads `/proc/self/mountinfo` (dedicated controller mounts, bind mounts and non-root mount + /// roots). If that file cannot be read, the conventional `/sys/fs/cgroup[/]` + /// layout is the only guess available. When mountinfo *is* readable, a missing matching mount + /// fails closed instead of guessing another mount's hierarchy: a wrong directory must never + /// become unrestricted host memory. + /// + /// A bind mount of a subtree has a deeper `root` and hides the cgroup ancestors above that + /// subtree, so the *widest* matching view is preferred: the mount whose cgroup root is + /// highest in the hierarchy (fewest path components), not the longest mount point. A deep + /// bind mount must not be allowed to hide a limit on an ancestor that is visible through the + /// hierarchy's real mount. + fn resolve_dir(sys_root: &Path, mountinfo: Option<&str>, membership: &str, fstype: &str, controller: Option<&str>) -> Option { + let membership = unescape(membership.trim()); + let Some(info) = mountinfo else { + let root = match controller { + Some(c) => sys_root.join(c), + None => sys_root.to_path_buf(), + }; + let leaf = join_membership(&root, &membership)?; + // The conventional layout mounts each hierarchy at its own root. + return leaf.is_dir().then_some(CgroupDir { leaf, root, hierarchy_root: true }); + }; + + let mut best: Option<(usize, usize, CgroupDir)> = None; + for mount in parse_mountinfo(info) { + if mount.fstype != fstype { + continue; + } + if controller.is_some_and(|c| !has_option(&mount.options, c) && !has_option(&mount.super_options, c)) { + continue; + } + let Some(relative) = strip_mount_root(&mount.root, &membership) else { continue }; + let root = normalize_root(&mount.mount_point); + let Some(leaf) = join_membership(&root, relative) else { continue }; + if !leaf.is_dir() { + continue; + } + let key = (cgroup_path_depth(&mount.root), mount.mount_point.len()); + if best.as_ref().is_none_or(|(depth, point, _)| key < (*depth, *point)) { + let hierarchy_root = mount.root.trim_end_matches('/').is_empty(); + best = Some((key.0, key.1, CgroupDir { leaf, root, hierarchy_root })); + } + } + best.map(|(_, _, dir)| dir) + } + + /// Trims trailing slashes so `Path` equality holds while walking ancestors, keeping `/`. + fn normalize_root(point: &str) -> PathBuf { + let trimmed = point.trim_end_matches('/'); + PathBuf::from(if trimmed.is_empty() { "/" } else { trimmed }) + } + + /// Joins a cgroup path below a visible hierarchy root, normalizing `.` components and + /// rejecting parent traversal so a malformed path cannot escape the mount. + fn join_membership(root: &Path, path: &str) -> Option { + let mut leaf = root.to_path_buf(); + for component in Path::new(path.trim_start_matches('/')).components() { + match component { + std::path::Component::Normal(part) => leaf.push(part), + std::path::Component::CurDir => {} + std::path::Component::ParentDir | std::path::Component::RootDir | std::path::Component::Prefix(_) => return None, + } + } + Some(leaf) + } + + /// Number of path components in a mount's cgroup root; `/` or an empty root is zero. + fn cgroup_path_depth(root: &str) -> usize { + Path::new(root).components().filter(|c| matches!(c, std::path::Component::Normal(_))).count() + } + + /// Whether a v2 mount's `cgroup.controllers` exposes the memory controller. + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + enum V2Controller { + /// `cgroup.controllers` lists `memory`. + Available, + /// `cgroup.controllers` is readable and omits `memory`, so no cgroup memory limit exists. + Absent, + /// `cgroup.controllers` could not be read, so absence cannot be established. + Unknown, + } + + /// Whether the cgroup v2 hierarchy at `root` exposes the memory controller. An unreadable + /// `cgroup.controllers` is `Unknown`, so its absence cannot be proven. + fn v2_controller(root: &Path) -> V2Controller { + match std::fs::read_to_string(root.join("cgroup.controllers")) { + Ok(text) => { + if text.split_whitespace().any(|controller| controller == "memory") { + V2Controller::Available + } else { + V2Controller::Absent + } + } + Err(_) => V2Controller::Unknown, + } + } + + /// Whether a cgroup's `cgroup.subtree_control` enables memory for its children. + /// + /// Interface files are created by the parent, so this decides whether a child *must* expose + /// `memory.max`: a readable control file without memory means the child legitimately has no + /// limit, while an unreadable one means that cannot be established. + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + enum Delegation { + Enabled, + Disabled, + Unknown, + } + + /// Whether `parent` enables the memory controller for its children in + /// `cgroup.subtree_control`. An unreadable file is `Unknown`, not "disabled". + fn v2_delegation(parent: &Path) -> Delegation { + match std::fs::read_to_string(parent.join("cgroup.subtree_control")) { + Ok(text) => { + if text.split_whitespace().any(|controller| controller.trim_start_matches('+') == "memory") { + Delegation::Enabled + } else { + Delegation::Disabled + } + } + Err(_) => Delegation::Unknown, + } + } + + /// cgroup v2: `memory.max`/`memory.current`, reclaiming `inactive_file`, plus + /// `memory.swap.max`/`memory.swap.current`. Every visible ancestor limit applies. + /// + /// A missing `memory.max` is not always an error: the hierarchy root is exempt from resource + /// control, and a child has the file only when its parent delegates the controller. A missing + /// file where the parent *does* delegate memory, or where delegation cannot be read, means the + /// visible view is incomplete and is reported as unreadable instead of unrestricted. + fn v2_limits(dir: &CgroupDir) -> CgroupLimits { + if !dir.leaf.starts_with(&dir.root) || !dir.leaf.is_dir() { + return CgroupLimits::Unreadable; + } + let controller = v2_controller(&dir.root); + + let mut nodes = Vec::new(); + let mut node = Some(dir.leaf.as_path()); + while let Some(path) = node { + nodes.push(path); + if path == dir.root.as_path() { + break; + } + if nodes.len() > MAX_CGROUP_DEPTH { + return CgroupLimits::Unreadable; + } + node = path.parent(); + } + if nodes.last().is_none_or(|path| *path != dir.root.as_path()) { + return CgroupLimits::Unreadable; + } + nodes.reverse(); + + let mut ram: Option = None; + let mut swap: Option = None; + let mut enabled = false; + for (index, path) in nodes.iter().enumerate() { + let expected = if index == 0 { + if dir.hierarchy_root { + // The hierarchy root is exempt from resource control and may have no + // `memory.max` at all. + None + } else if controller == V2Controller::Absent { + Some(Delegation::Disabled) + } else { + // A mounted subtree root must expose the controller when it is enabled for + // that cgroup; without the parent's control file its absence is unprovable. + Some(Delegation::Unknown) + } + } else { + Some(v2_delegation(nodes[index - 1])) + }; + match read_limit(&path.join("memory.max")) { + Limit::Value(max) => { + enabled = true; + let Some(current) = read_u64(&path.join("memory.current")) else { return CgroupLimits::Unreadable }; + let inactive = read_stat(&path.join("memory.stat"), "inactive_file"); + let room = max.saturating_sub(current.saturating_sub(inactive)); + ram = Some(ram.map_or(room, |r| r.min(room))); + } + Limit::Unlimited => enabled = true, + Limit::Invalid => return CgroupLimits::Unreadable, + Limit::Missing => match expected { + None | Some(Delegation::Disabled) => {} + Some(Delegation::Enabled | Delegation::Unknown) => return CgroupLimits::Unreadable, + }, + } + match read_limit(&path.join("memory.swap.max")) { + Limit::Value(max) => { + enabled = true; + let Some(current) = read_u64(&path.join("memory.swap.current")) else { return CgroupLimits::Unreadable }; + let room = max.saturating_sub(current); + swap = Some(swap.map_or(room, |r| r.min(room))); + } + Limit::Unlimited => enabled = true, + Limit::Invalid => return CgroupLimits::Unreadable, + // Kernels before swap accounting expose `memory.max` without `memory.swap.*`; + // absence there means no swap limit at this cgroup, not an unreadable constraint. + Limit::Missing => {} + } + } + + if enabled { + return CgroupLimits::Limited { ram, swap }; + } + match controller { + // The controller is genuinely absent, or available but not enabled anywhere on the + // visible path (the root may normally have no interface files): no cgroup limit. + V2Controller::Absent | V2Controller::Available => CgroupLimits::NotConstrained, + // No readable limit file and no metadata proving the controller absent. + V2Controller::Unknown => CgroupLimits::Unreadable, + } + } + + /// cgroup v1: `memory.limit_in_bytes`/`memory.usage_in_bytes` (inactive cache reclaimable) + /// and the separate `memory.memsw` memory-plus-swap bound. + /// + /// A memsw limit also caps RAM because memory usage counts toward it, but its remaining room + /// is not swap that can be added on top of a different ancestor's RAM headroom. Each + /// ancestor's swap allowance is `memsw limit - memory limit - swap already used`, and those + /// allowances are combined separately. Subtracting one global combined headroom from one + /// global RAM headroom would invent swap room from different ancestors. + fn v1_limits(dir: &CgroupDir) -> CgroupLimits { + if !dir.leaf.starts_with(&dir.root) || !dir.leaf.is_dir() { + return CgroupLimits::Unreadable; + } + let mut ram: Option = None; + let mut swap: Option = None; + let mut enabled = false; + let mut node = Some(dir.leaf.as_path()); + let mut depth = 0; + while let Some(path) = node { + if depth >= MAX_CGROUP_DEPTH { + return CgroupLimits::Unreadable; + } + + // `None` means unlimited at this ancestor; the raw usage feeds the swap bound. + let mut memory_usage = None; + let memory_limit = match read_limit(&path.join("memory.limit_in_bytes")) { + Limit::Missing | Limit::Invalid => return CgroupLimits::Unreadable, + Limit::Unlimited => { + enabled = true; + None + } + Limit::Value(bytes) if bytes >= V1_UNLIMITED => { + enabled = true; + None + } + Limit::Value(bytes) => { + enabled = true; + let Some(usage) = read_u64(&path.join("memory.usage_in_bytes")) else { return CgroupLimits::Unreadable }; + memory_usage = Some(usage); + let inactive = std::fs::read_to_string(path.join("memory.stat")) + .ok() + .and_then(|s| stat(&s, "total_inactive_file").or_else(|| stat(&s, "inactive_file"))) + .unwrap_or(0); + let room = bytes.saturating_sub(usage.saturating_sub(inactive)); + ram = Some(ram.map_or(room, |r| r.min(room))); + Some(bytes) + } + }; + + match read_limit(&path.join("memory.memsw.limit_in_bytes")) { + Limit::Missing | Limit::Unlimited => {} + Limit::Invalid => return CgroupLimits::Unreadable, + Limit::Value(bytes) if bytes >= V1_UNLIMITED => {} + Limit::Value(bytes) => { + let Some(memsw_usage) = read_u64(&path.join("memory.memsw.usage_in_bytes")) else { + return CgroupLimits::Unreadable; + }; + // Memory plus swap is bounded by memsw, so its remaining room also bounds RAM. + let combined_room = bytes.saturating_sub(memsw_usage); + ram = Some(ram.map_or(combined_room, |r| r.min(combined_room))); + if let Some(memory_limit) = memory_limit { + // Swap can only use the part of memsw above the memory limit after the + // swap already in use; filling RAM to its own limit does not consume it. + let used_swap = memsw_usage.saturating_sub(memory_usage.unwrap_or(0)); + let room = bytes.saturating_sub(memory_limit).saturating_sub(used_swap); + swap = Some(swap.map_or(room, |s| s.min(room))); + } else { + // With no RAM limit the remaining memsw room can all be consumed by RAM, + // so no swap room is guaranteed on top of the RAM allowance. + swap = Some(0); + } + } + } + + if path == dir.root.as_path() { + break; + } + node = path.parent(); + depth += 1; + } + if !enabled { + // The v1 hierarchy claims a memory controller but never exposed a limit file. + return CgroupLimits::Unreadable; + } + CgroupLimits::Limited { ram, swap } + } + + /// Reads a cgroup limit file: `max` is unlimited, a number is a byte limit, and a missing file + /// is distinguished from one that cannot be read or parsed. + fn read_limit(path: &Path) -> Limit { + match std::fs::read_to_string(path) { + Ok(text) => match text.trim() { + "max" => Limit::Unlimited, + value => match value.parse::() { + Ok(bytes) => Limit::Value(bytes), + Err(_) => Limit::Invalid, + }, + }, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Limit::Missing, + Err(_) => Limit::Invalid, + } + } + + /// Reads a whitespace-terminated unsigned value from a cgroup interface file. + fn read_u64(path: &Path) -> Option { + std::fs::read_to_string(path).ok()?.trim().parse().ok() + } + + /// Reads `field` from a cgroup `*.stat` file, treating a missing file or field as zero. + fn read_stat(path: &Path, field: &str) -> u64 { + std::fs::read_to_string(path).ok().and_then(|s| stat(&s, field)).unwrap_or(0) + } + + /// Finds `field` in `key value` stat lines and parses its value. + fn stat(s: &str, field: &str) -> Option { + s.lines().find_map(|l| { + let mut parts = l.split_whitespace(); + let key = parts.next()?; + if key == field { parts.next()?.parse().ok() } else { None } + }) + } + + /// Whether a comma-separated mount option list contains `controller`. + fn has_option(options: &str, controller: &str) -> bool { + options.split(',').any(|option| option.trim() == controller) + } + + /// Strips a mount's root prefix from a cgroup path, returning the path relative to the mount. + fn strip_mount_root<'a>(root: &str, path: &'a str) -> Option<&'a str> { + let root = root.trim_end_matches('/'); + if root.is_empty() { + return Some(path); + } + let rest = path.strip_prefix(root)?; + if rest.is_empty() { + return Some(""); + } + rest.strip_prefix('/') + } + + /// Decodes mountinfo/mount-path octal escapes (`\040` space, `\011` tab, `\134` backslash). + fn unescape(value: &str) -> String { + let bytes = value.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut index = 0; + while let Some(&byte) = bytes.get(index) { + if byte == b'\\' + && let Some(digits) = bytes.get(index + 1..index + 4) + { + let code = digits.iter().try_fold(0u32, |acc, digit| { + let value = digit.checked_sub(b'0')?; + (value < 8).then_some(acc * 8 + u32::from(value)) + }); + if let Some(byte) = code.and_then(|code| u8::try_from(code).ok()) { + out.push(byte); + index += 4; + continue; + } + } + out.push(byte); + index += 1; + } + String::from_utf8_lossy(&out).into_owned() + } + + /// Exposed for the Linux provider tests; the policy itself never calls this. + #[cfg(test)] + pub(super) fn membership_for_test(text: &str) -> Option<(Option, Option)> { + match parse_membership(text) { + MembershipParse::Parsed(membership) => Some((membership.v2.map(str::to_string), membership.v1_memory.map(str::to_string))), + MembershipParse::Malformed => None, + } + } +} + +#[cfg(windows)] +mod windows_mem { + use super::SystemMemory; + + /// Measures available physical and commit memory through `GlobalMemoryStatusEx`. A failed + /// query reports unknown memory rather than inventing a measurement. + pub(super) fn snapshot() -> SystemMemory { + match winsafe::GlobalMemoryStatusEx() { + Ok(msx) => SystemMemory { + available_ram: Some(msx.ullAvailPhys), + swap_free: None, + commit_headroom: Some(msx.ullAvailPageFile), + constraint_unreadable: false, + }, + Err(_) => SystemMemory::UNKNOWN, + } + } +} + +/// Conservative reclaimable physical RAM from macOS VM counters, or `None` when the query failed. +/// +/// `total` is installed physical memory (`sysctl` `HW_MEMSIZE`). The other three are the same +/// `host_statistics64` `vm_statistics64` fields that `sysinfo` derives: +/// +/// - `free` = `free_memory` = `free_count - speculative_count` pages; +/// - `available` = `available_memory` = `active + inactive + free_count` pages; +/// - `used` = `used_memory` = `internal_page_count - purgeable_count + wire_count + +/// compressor_page_count` pages. +/// +/// XNU documents `AVAILABLE_NON_COMPRESSED_MEMORY = active + inactive + free + speculative` as +/// "everything that is not either wired, compressed, or stolen" +/// (). +/// That includes anonymous working sets, which can only be released by compressing or swapping +/// them, so on its own it is too optimistic for a budget that excludes swap. +/// +/// `internal_page_count` counts anonymous pages and `external_page_count` counts file-backed +/// pages (), +/// so `total - used` is the pool that can be handed out without evicting a working set: +/// `free + file-backed + purgeable`. That is close to the fully-reclaimable pool jetsam monitors +/// (`file-backed + free + purgeable`, same document). The smaller of the two derivations is +/// taken so a counter disagreement stays conservative, and the result is never below `free`: +/// pages that are already free are allocatable by definition, so a derived value below them +/// means the counters disagree and cannot be used to claim less. +/// +/// Only a demonstrably failed query is unknown. A live host always has a non-zero installed size +/// and non-zero used memory (wired kernel pages alone), so either reading zero proves the +/// provider query failed. A genuine "no reclaimable pages" reading stays a known zero rather +/// than becoming the 2 GiB unknown-memory allowance. +#[cfg(any(target_os = "macos", test))] +fn macos_available_ram(total: u64, free: u64, available: u64, used: u64) -> Option { + if total == 0 || used == 0 { + return None; + } + let free = free.min(total); + let reclaimable = total.saturating_sub(used).min(available).max(free); + Some(reclaimable.min(total)) +} + +#[cfg(target_os = "macos")] +mod macos_mem { + use super::SystemMemory; + use sysinfo::{MemoryRefreshKind, RefreshKind, System}; + + /// Available physical memory through `sysinfo`'s safe API. + /// + /// On macOS that is `host_statistics64`'s `vm_statistics64` (see + /// [`super::macos_available_ram`]). The estimate counts free pages, file-backed pages and + /// purgeable pages, not anonymous working sets, wired memory, compressed memory or swap. A + /// failed query reports unknown; a successful zero reading stays zero. + pub(super) fn snapshot() -> SystemMemory { + let system = match System::new_with_specifics(RefreshKind::nothing().with_memory(MemoryRefreshKind::nothing().with_ram())) { + Ok(system) => system, + Err(_) => return SystemMemory::UNKNOWN, + }; + match super::macos_available_ram(system.total_memory(), system.free_memory(), system.available_memory(), system.used_memory()) { + Some(ram) => SystemMemory { available_ram: Some(ram), swap_free: None, commit_headroom: None, constraint_unreadable: false }, + None => SystemMemory::UNKNOWN, + } + } +} + +#[cfg(not(any(target_os = "linux", windows, target_os = "macos")))] +mod fallback_mem { + use super::SystemMemory; + + /// Fallback provider for platforms without a validated memory-availability provider. + /// + /// Available memory is reported as unknown (`None`), which applies the conservative native + /// fallback budget (2 GiB, approval above it) without inventing a measurement. + pub(super) fn snapshot() -> SystemMemory { + SystemMemory::UNKNOWN + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn macos_available_ram_counts_reclaimable_pages_not_free_only() { + let gb = 1u64 << 30; + // A failed provider query: no installed size, or no used memory on a live host. + assert_eq!(macos_available_ram(0, 0, 0, 0), None, "an empty query is not a measurement"); + assert_eq!(macos_available_ram(0, 1 << 20, 4 * gb, 4 * gb), None, "no installed size: the sysctl query failed"); + assert_eq!(macos_available_ram(16 * gb, 1 << 20, 8 * gb, 0), None, "no used memory on a live host: the VM query failed"); + + // A genuine zero-capacity reading stays zero, not unknown. + assert_eq!(macos_available_ram(16 * gb, 0, 0, 16 * gb), Some(0), "everything is wired or compressed"); + + // Low free memory beside a large file-backed cache: free alone would report ~0 and the + // hard budget would collapse, while 12 GiB is reclaimable without touching a working set. + assert_eq!(macos_available_ram(16 * gb, 100 << 20, 12 * gb, 4 * gb), Some(12 * gb)); + + // A loaded machine: most memory is anonymous working set, which is not reclaimable, so + // `available` (which counts it) does not raise the estimate above total - used. + assert_eq!(macos_available_ram(16 * gb, 200 << 20, 8 * gb, 14 * gb), Some(2 * gb)); + + // Counters that disagree: free pages are allocatable by definition, so never claim less. + assert_eq!(macos_available_ram(16 * gb, 3 * gb, gb, 15 * gb), Some(3 * gb)); + + // Never above installed memory. + assert_eq!(macos_available_ram(gb, 2 * gb, 4 * gb, 1), Some(gb)); + } + + #[cfg(target_os = "macos")] + #[test] + fn macos_provider_excludes_swap_and_commit_headroom() { + let snap = current(); + assert_eq!(snap.swap_free, None, "the adaptive budget must not count swap"); + assert_eq!(snap.commit_headroom, None); + // The numeric floor and zero-versus-unknown semantics are covered deterministically by + // `macos_available_ram_counts_reclaimable_pages_not_free_only`; asserting a live `> 0` + // here would be vacuous because a genuine zero is a valid reading. + } + + #[cfg(target_os = "linux")] + use std::path::{Path, PathBuf}; + + #[cfg(target_os = "linux")] + fn fixture(name: &str) -> (PathBuf, PathBuf) { + let root = std::env::temp_dir().join(format!("photocraft-cgroup-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(root.join("proc/self")).unwrap(); + std::fs::create_dir_all(root.join("sys/fs/cgroup")).unwrap(); + (root.join("proc"), root.join("sys/fs/cgroup")) + } + + #[cfg(target_os = "linux")] + fn write(path: &Path, text: &str) { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(path, text).unwrap(); + } + + #[cfg(target_os = "linux")] + #[test] + fn linux_memory_is_read_from_meminfo() { + let info = "MemTotal: 24314352 kB\nMemAvailable: 12626632 kB\nSwapFree: 1024 kB\n"; + assert_eq!(linux::meminfo(info, "MemAvailable:"), Some(12_626_632 * 1024)); + assert_eq!(linux::meminfo(info, "SwapFree:"), Some(1024 * 1024)); + assert_eq!(linux::meminfo(info, "Missing:"), None); + assert_eq!(linux::meminfo("MemAvailable: lots kB", "MemAvailable:"), None); + } + + #[cfg(target_os = "linux")] + #[test] + fn linux_cgroup_membership_distinguishes_v1_and_v2() { + assert_eq!(linux::membership_for_test("0::/user.slice/session.scope\n"), Some((Some("/user.slice/session.scope".into()), None))); + assert_eq!(linux::membership_for_test("4:memory:/docker/abc\n"), Some((None, Some("/docker/abc".into())))); + assert_eq!(linux::membership_for_test("4:cpu,cpuacct:/user.slice\n5:memory:/a/b\n"), Some((None, Some("/a/b".into())))); + assert_eq!(linux::membership_for_test("12:pids:/\n"), Some((None, None)), "no memory controller is demonstrably absent"); + assert_eq!(linux::membership_for_test("garbage\n"), None); + assert_eq!(linux::membership_for_test(""), None, "an empty file cannot prove absence"); + assert_eq!(linux::membership_for_test("0:memory:/app\n"), None, "the unified hierarchy cannot name controllers"); + assert_eq!(linux::membership_for_test("4::/app\n"), None, "a v1 line must name a controller"); + assert_eq!(linux::membership_for_test("4:memory:relative\n"), None, "cgroup paths are absolute"); + } + + #[cfg(target_os = "linux")] + #[test] + fn linux_mountinfo_octal_escapes_are_decoded() { + let (proc, sys) = fixture("escaped"); + write(&proc.join("self/cgroup"), "0::/app\n"); + let mount = sys.join("cg root"); + let escaped = mount.display().to_string().replace(' ', "\\040"); + write(&proc.join("self/mountinfo"), &format!("30 20 0:26 / {} rw,relatime - cgroup2 cgroup2 rw\n", escaped)); + write(&mount.join("app/memory.max"), "1073741824\n"); + write(&mount.join("app/memory.current"), "0\n"); + assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::Limited { ram: Some(1 << 30), swap: None }); + std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); + } + + #[cfg(target_os = "linux")] + #[test] + fn linux_v2_cgroup_applies_mount_path_and_ancestor_limits() { + let (proc, sys) = fixture("v2"); + write(&proc.join("self/cgroup"), "0::/app.scope\n"); + write(&proc.join("self/mountinfo"), &format!("30 20 0:26 / {} rw,nosuid,nodev,noexec,relatime - cgroup2 cgroup2 rw,nsdelegate\n", sys.display())); + let leaf = sys.join("app.scope"); + write(&sys.join("memory.max"), "8589934592\n"); + write(&sys.join("memory.current"), "2147483648\n"); + write(&sys.join("memory.stat"), "inactive_file 1073741824\n"); + write(&leaf.join("memory.max"), "max\n"); + assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::Limited { ram: Some(7 << 30), swap: None }, "the parent's limit applies"); + + write(&leaf.join("memory.max"), "4294967296\n"); + write(&leaf.join("memory.current"), "1073741824\n"); + write(&leaf.join("memory.stat"), "inactive_file 0\n"); + assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::Limited { ram: Some(3 << 30), swap: None }, "the tighter leaf limit wins"); + + write(&leaf.join("memory.swap.max"), "1073741824\n"); + write(&leaf.join("memory.swap.current"), "268435456\n"); + assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::Limited { ram: Some(3 << 30), swap: Some(768 << 20) }); + + std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); + } + + #[cfg(target_os = "linux")] + #[test] + fn linux_v2_distinguishes_disabled_controller_from_missing_expected_limit() { + // The hierarchy root may legitimately have no `memory.max`; a child has one exactly when + // its parent delegates the controller. + let (proc, sys) = fixture("v2-metadata"); + write(&proc.join("self/cgroup"), "0::/app\n"); + write(&proc.join("self/mountinfo"), &format!("30 20 0:26 / {} rw - cgroup2 cgroup2 rw\n", sys.display())); + write(&sys.join("cgroup.controllers"), "cpu io memory\n"); + write(&sys.join("cgroup.subtree_control"), "+cpu +memory\n"); + write(&sys.join("app/memory.max"), "1073741824\n"); + write(&sys.join("app/memory.current"), "0\n"); + assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::Limited { ram: Some(1 << 30), swap: None }, "a missing root file is normal"); + + write(&sys.join("cgroup.controllers"), "cpu io\n"); + write(&sys.join("cgroup.subtree_control"), "+cpu\n"); + std::fs::remove_file(sys.join("app/memory.max")).unwrap(); + assert_eq!( + linux::cgroup_limits(&proc, &sys), + linux::CgroupLimits::NotConstrained, + "readable controllers without memory prove the controller is absent" + ); + + write(&sys.join("cgroup.controllers"), "cpu io memory\n"); + write(&sys.join("cgroup.subtree_control"), "+memory\n"); + assert_eq!( + linux::cgroup_limits(&proc, &sys), + linux::CgroupLimits::Unreadable, + "memory is delegated to the child, so the missing child file means the view is incomplete" + ); + + std::fs::remove_file(sys.join("cgroup.controllers")).unwrap(); + std::fs::remove_file(sys.join("cgroup.subtree_control")).unwrap(); + assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::Unreadable, "without metadata the absence of a constraint cannot be established"); + std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); + + // Available but not delegated anywhere on the path: no cgroup limit applies. + let (proc, sys) = fixture("v2-not-enabled"); + write(&proc.join("self/cgroup"), "0::/app\n"); + write(&proc.join("self/mountinfo"), &format!("30 20 0:26 / {} rw - cgroup2 cgroup2 rw\n", sys.display())); + write(&sys.join("cgroup.controllers"), "cpu io memory\n"); + write(&sys.join("cgroup.subtree_control"), "+cpu\n"); + write(&sys.join("app/cgroup.controllers"), "\n"); + assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::NotConstrained); + std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); + } + + #[cfg(target_os = "linux")] + #[test] + fn linux_prefers_the_widest_hierarchy_view_over_a_deep_bind_mount() { + let (proc, sys) = fixture("bind-widest"); + write(&proc.join("self/cgroup"), "0::/user.slice/app.scope\n"); + let narrow = sys.join("bind"); + write( + &proc.join("self/mountinfo"), + &format!("30 20 0:26 / {} rw - cgroup2 cgroup2 rw\n31 30 0:26 /user.slice/app.scope {} rw - cgroup2 cgroup2 rw\n", sys.display(), narrow.display()), + ); + write(&sys.join("user.slice/memory.max"), "2147483648\n"); + write(&sys.join("user.slice/memory.current"), "0\n"); + write(&sys.join("user.slice/cgroup.subtree_control"), "+memory\n"); + write(&sys.join("user.slice/app.scope/memory.max"), "max\n"); + write(&narrow.join("memory.max"), "max\n"); + assert_eq!( + linux::cgroup_limits(&proc, &sys), + linux::CgroupLimits::Limited { ram: Some(2 << 30), swap: None }, + "the hierarchy's own mount exposes user.slice's limit; the deep bind mount would hide it" + ); + + write(&proc.join("self/mountinfo"), &format!("31 30 0:26 /user.slice/app.scope {} rw - cgroup2 cgroup2 rw\n", narrow.display())); + assert_eq!( + linux::cgroup_limits(&proc, &sys), + linux::CgroupLimits::Limited { ram: None, swap: None }, + "the bind mount alone cannot see the ancestor limit, which is why the widest view is preferred" + ); + + std::fs::remove_file(narrow.join("memory.max")).unwrap(); + assert_eq!( + linux::cgroup_limits(&proc, &sys), + linux::CgroupLimits::Unreadable, + "a mounted subtree root cannot silently lose the controller file it should expose" + ); + std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); + } + + #[cfg(target_os = "linux")] + #[test] + fn linux_v1_cgroup_honours_controller_mounts_and_ancestors() { + let (proc, sys) = fixture("v1"); + write(&proc.join("self/cgroup"), "4:memory:/docker/abc\n"); + let mount = sys.join("memory"); + write(&proc.join("self/mountinfo"), &format!("31 24 0:26 / {} rw,nosuid,nodev,noexec,relatime shared:12 - cgroup cgroup rw,memory\n", mount.display())); + let leaf = mount.join("docker/abc"); + write(&mount.join("docker/memory.limit_in_bytes"), "9223372036854771712\n"); + write(&mount.join("memory.limit_in_bytes"), "8589934592\n"); + write(&mount.join("memory.usage_in_bytes"), "2147483648\n"); + write(&mount.join("memory.stat"), "total_inactive_file 1073741824\n"); + write(&leaf.join("memory.limit_in_bytes"), "9223372036854771712\n"); + assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::Limited { ram: Some(7 << 30), swap: None }, "the v1 sentinel is unlimited"); + + write(&leaf.join("memory.limit_in_bytes"), "3221225472\n"); + write(&leaf.join("memory.usage_in_bytes"), "1073741824\n"); + write(&leaf.join("memory.stat"), "total_inactive_file 0\n"); + assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::Limited { ram: Some(2 << 30), swap: None }, "the tighter leaf limit wins"); + + write(&mount.join("memory.memsw.limit_in_bytes"), "9663676416\n"); + write(&mount.join("memory.memsw.usage_in_bytes"), "2147483648\n"); + assert_eq!( + linux::cgroup_limits(&proc, &sys), + linux::CgroupLimits::Limited { ram: Some(2 << 30), swap: Some(1 << 30) }, + "swap room is memsw limit - memory limit - swap used at the same ancestor, not combined headroom minus RAM headroom" + ); + + write(&mount.join("memory.memsw.usage_in_bytes"), "3221225472\n"); + assert_eq!( + linux::cgroup_limits(&proc, &sys), + linux::CgroupLimits::Limited { ram: Some(2 << 30), swap: Some(0) }, + "a gigabyte already swapped uses the ancestor's whole swap allowance" + ); + + write(&mount.join("memory.memsw.limit_in_bytes"), "10737418240\n"); + assert_eq!( + linux::cgroup_limits(&proc, &sys), + linux::CgroupLimits::Limited { ram: Some(2 << 30), swap: Some(1 << 30) }, + "only the memsw allowance above the memory limit counts as added swap room" + ); + + write(&mount.join("memory.limit_in_bytes"), "9223372036854771712\n"); + write(&leaf.join("memory.limit_in_bytes"), "9223372036854771712\n"); + write(&mount.join("memory.memsw.limit_in_bytes"), "2147483648\n"); + write(&mount.join("memory.memsw.usage_in_bytes"), "0\n"); + assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::Limited { ram: Some(2 << 30), swap: Some(0) }, "memsw alone still caps RAM"); + + std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); + } + + #[cfg(target_os = "linux")] + #[test] + fn linux_v1_swap_bounds_combine_per_ancestor() { + let (proc, sys) = fixture("v1-swap-ancestors"); + write(&proc.join("self/cgroup"), "4:memory:/a/b\n"); + let mount = sys.join("memory"); + write(&proc.join("self/mountinfo"), &format!("31 24 0:26 / {} rw - cgroup cgroup rw,memory\n", mount.display())); + write(&mount.join("memory.limit_in_bytes"), "8589934592\n"); + write(&mount.join("memory.usage_in_bytes"), "2147483648\n"); + write(&mount.join("memory.memsw.limit_in_bytes"), "9663676416\n"); + write(&mount.join("memory.memsw.usage_in_bytes"), "2147483648\n"); + write(&mount.join("a/memory.limit_in_bytes"), "10737418240\n"); + write(&mount.join("a/memory.usage_in_bytes"), "1073741824\n"); + write(&mount.join("a/memory.memsw.limit_in_bytes"), "21474836480\n"); + write(&mount.join("a/memory.memsw.usage_in_bytes"), "2147483648\n"); + write(&mount.join("a/b/memory.limit_in_bytes"), "3221225472\n"); + write(&mount.join("a/b/memory.usage_in_bytes"), "1073741824\n"); + + assert_eq!( + linux::cgroup_limits(&proc, &sys), + linux::CgroupLimits::Limited { ram: Some(2 << 30), swap: Some(1 << 30) }, + "root swap allowance is 9 - 8 - 0 = 1 GiB; /a contributes 9 GiB and the leaf adds no swap limit" + ); + std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); + } + + #[cfg(target_os = "linux")] + #[test] + fn linux_v1_mount_root_prefix_is_stripped() { + let (proc, sys) = fixture("v1-root"); + write(&proc.join("self/cgroup"), "4:memory:/docker/abc/child\n"); + let mount = sys.join("memory"); + write(&proc.join("self/mountinfo"), &format!("31 24 0:26 /docker/abc {} rw,relatime - cgroup cgroup rw,memory\n", mount.display())); + write(&mount.join("memory.limit_in_bytes"), "1073741824\n"); + write(&mount.join("memory.usage_in_bytes"), "0\n"); + write(&mount.join("memory.stat"), "total_inactive_file 0\n"); + write(&mount.join("child/memory.limit_in_bytes"), "1073741824\n"); + write(&mount.join("child/memory.usage_in_bytes"), "0\n"); + assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::Limited { ram: Some(1 << 30), swap: None }); + std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); + } + + #[cfg(target_os = "linux")] + #[test] + fn linux_cgroup_falls_back_to_conventional_layouts() { + let (proc, sys) = fixture("fallback-v2"); + write(&proc.join("self/cgroup"), "0::/app.scope\n"); + write(&sys.join("app.scope/memory.max"), "1073741824\n"); + write(&sys.join("app.scope/memory.current"), "0\n"); + assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::Limited { ram: Some(1 << 30), swap: None }); + std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); + + let (proc, sys) = fixture("fallback-v1"); + write(&proc.join("self/cgroup"), "4:memory:/app\n"); + write(&sys.join("memory/memory.limit_in_bytes"), "1073741824\n"); + write(&sys.join("memory/memory.usage_in_bytes"), "0\n"); + write(&sys.join("memory/memory.stat"), "total_inactive_file 0\n"); + write(&sys.join("memory/app/memory.limit_in_bytes"), "1073741824\n"); + write(&sys.join("memory/app/memory.usage_in_bytes"), "0\n"); + write(&sys.join("memory/app/memory.stat"), "total_inactive_file 0\n"); + assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::Limited { ram: Some(1 << 30), swap: None }); + std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); + } + + #[cfg(target_os = "linux")] + #[test] + fn linux_unreadable_constraints_fail_closed() { + let (proc, sys) = fixture("unreadable-missing-dir"); + write(&proc.join("self/cgroup"), "0::/missing\n"); + write(&proc.join("meminfo"), "MemAvailable: 12582912 kB\nSwapFree: 2097152 kB\n"); + assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::Unreadable); + let snap = linux::snapshot_at(&proc, &sys); + assert_eq!(snap.available_ram, Some(0), "host memory must not be assumed"); + assert_eq!(snap.swap_free, Some(0)); + std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); + + let (proc, sys) = fixture("unreadable-v1-limit"); + write(&proc.join("self/cgroup"), "4:memory:/app\n"); + let mount = sys.join("memory"); + write(&proc.join("self/mountinfo"), &format!("31 24 0:26 / {} rw,relatime - cgroup cgroup rw,memory\n", mount.display())); + std::fs::create_dir_all(mount.join("app")).unwrap(); + assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::Unreadable); + std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); + + let (proc, sys) = fixture("unreadable-v2-limit"); + write(&proc.join("self/cgroup"), "0::/app\n"); + write(&sys.join("app/memory.max"), "lots\n"); + write(&sys.join("app/memory.current"), "0\n"); + assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::Unreadable); + std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); + + let (proc, sys) = fixture("no-memory-controller"); + write(&proc.join("self/cgroup"), "4:cpu,cpuacct:/user.slice\n"); + assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::NotConstrained); + std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); + + let (proc, sys) = fixture("malformed-membership"); + write(&proc.join("self/cgroup"), "0:memory:/app\n"); + write(&proc.join("meminfo"), "MemAvailable: 12582912 kB\nSwapFree: 2097152 kB\n"); + assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::Unreadable, "a malformed membership line cannot prove the controller absent"); + assert_eq!(linux::snapshot_at(&proc, &sys).available_ram, Some(0), "host memory must not be assumed"); + std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); + + let (proc, sys) = fixture("empty-membership"); + write(&proc.join("self/cgroup"), ""); + assert_eq!(linux::cgroup_limits(&proc, &sys), linux::CgroupLimits::Unreadable, "an empty membership file cannot prove absence"); + std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); + + let (proc, sys) = fixture("mountinfo-without-cgroup"); + write(&proc.join("self/cgroup"), "0::/app\n"); + write(&proc.join("self/mountinfo"), "30 20 8:1 / / rw - ext4 /dev/root rw\n"); + write(&sys.join("app/memory.max"), "1073741824\n"); + write(&sys.join("app/memory.current"), "0\n"); + assert_eq!( + linux::cgroup_limits(&proc, &sys), + linux::CgroupLimits::Unreadable, + "a readable mountinfo without a cgroup mount must not be guessed away by the conventional path" + ); + std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); + } + + #[cfg(target_os = "linux")] + #[test] + fn linux_snapshot_combines_host_meminfo_and_cgroup_room() { + let (proc, sys) = fixture("snapshot"); + write(&proc.join("self/cgroup"), "0::/app.scope\n"); + write(&proc.join("meminfo"), "MemTotal: 16777216 kB\nMemAvailable: 12582912 kB\nSwapFree: 2097152 kB\n"); + write(&proc.join("self/mountinfo"), &format!("30 20 0:26 / {} rw,relatime - cgroup2 cgroup2 rw\n", sys.display())); + write(&sys.join("app.scope/memory.max"), "4294967296\n"); + write(&sys.join("app.scope/memory.current"), "0\n"); + write(&sys.join("app.scope/memory.swap.max"), "536870912\n"); + write(&sys.join("app.scope/memory.swap.current"), "0\n"); + + let snap = linux::snapshot_at(&proc, &sys); + assert_eq!(snap.available_ram, Some(4 << 30), "the cgroup limit is smaller than host MemAvailable"); + assert_eq!(snap.swap_free, Some(512 << 20)); + assert_eq!(snap.commit_headroom, None); + assert_eq!(linux::available_at(&proc, &sys), Some((4 << 30) + (512 << 20)), "legacy helper keeps RAM + swap"); + std::fs::remove_dir_all(proc.parent().unwrap()).unwrap(); + } +} diff --git a/crates/raster/src/scratch_file.rs b/crates/raster/src/scratch_file.rs index bcb46c3d0f..188c0cbcd1 100644 --- a/crates/raster/src/scratch_file.rs +++ b/crates/raster/src/scratch_file.rs @@ -247,7 +247,7 @@ fn reap_abandoned(dir: &Path, own: &File) { use std::os::unix::fs::MetadataExt; use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, System}; let Ok(metadata) = own.metadata() else { return }; - let mut system = System::new(); + let Ok(mut system) = System::new() else { return }; system.refresh_processes_specifics(ProcessesToUpdate::All, true, ProcessRefreshKind::nothing()); // Fail closed when process enumeration isn't available. Live files are never removed. if system.process(Pid::from_u32(std::process::id())).is_none() { diff --git a/crates/raster/src/spill.rs b/crates/raster/src/spill.rs index 26e06785b7..ac37c85686 100644 --- a/crates/raster/src/spill.rs +++ b/crates/raster/src/spill.rs @@ -508,13 +508,8 @@ fn no_room(dir: &Path, cap: Option) -> String { #[cfg(not(target_arch = "wasm32"))] fn free_space(dir: &Path) -> Option<(u64, u64)> { let dir = std::fs::canonicalize(dir).ok()?; - let disks = sysinfo::Disks::new_with_refreshed_list_specifics(sysinfo::DiskRefreshKind::nothing().with_storage()); - disks - .list() - .iter() - .filter(|d| dir.starts_with(d.mount_point())) - .max_by_key(|d| d.mount_point().as_os_str().len()) - .map(|d| (d.available_space(), d.total_space())) + let disks = sysinfo::Disks::new_with_refreshed_list_specifics(sysinfo::DiskRefreshKind::nothing().with_storage()).ok()?; + disks.iter().filter(|d| dir.starts_with(d.mount_point())).max_by_key(|d| d.mount_point().as_os_str().len()).map(|d| (d.available_space(), d.total_space())) } #[cfg(target_arch = "wasm32")] @@ -695,6 +690,29 @@ pub fn last_error() -> Option { M.config().last_error.clone() } +/// Uncompressed tile bytes the scratch disk can still take before it reaches its size limit +/// ([`set_size_cap`]) or the volume's free-space reserve, so an import can admit tile pixels +/// beyond physical RAM. `None` while spilling is off or paused, and when neither the free space +/// nor a size limit is known (nothing to promise). Tiles are written compressed; this counts +/// them uncompressed, less 1/64 for the allocator's block rounding, so it never over-promises. +pub fn scratch_capacity() -> Option { + if !enabled() || paused() { + return None; + } + let (dir, used, cap) = { + let c = M.config(); + (c.dir.clone()?, c.file.as_ref().map_or(0, |f| f.used()), c.size_cap) + }; + let room = free_space(&dir).map(|(available, total)| available.saturating_sub(RESERVE_MIN.max(total / 20))); + let room = match (room, cap) { + (Some(room), Some(cap)) => room.min(cap.saturating_sub(used)), + (Some(room), None) => room, + (None, Some(cap)) => cap.saturating_sub(used), + (None, None) => return None, + }; + Some(room - room / 64) +} + /// Whether spilling is on (tiles beyond the budget go to disk). pub fn enabled() -> bool { M.budget.load(Ordering::Relaxed) > 0 diff --git a/crates/raster/tests/scratch_capacity.rs b/crates/raster/tests/scratch_capacity.rs new file mode 100644 index 0000000000..33698e7d15 --- /dev/null +++ b/crates/raster/tests/scratch_capacity.rs @@ -0,0 +1,40 @@ +//! [`spill::scratch_capacity`]: the room imports may count on for tile pixels beyond RAM. +//! +//! One test function: the spill manager is process-wide, so its steps must not interleave. + +use photocraft_raster::spill; + +#[test] +fn scratch_capacity_follows_spilling_the_size_limit_and_pauses() { + let root = std::env::temp_dir().join(format!("photocraft-scratch-capacity-{}", std::process::id())); + let dir = root.join("scratch"); + + // Spilling off: nothing to promise. + spill::configure(None, 0).unwrap(); + assert_eq!(spill::scratch_capacity(), None); + + spill::configure(Some(&dir), 1 << 20).unwrap(); + assert!(spill::enabled() && !spill::paused()); + // The free-space reserve applies; an unknown volume without a size limit promises nothing. + if let Some(room) = spill::scratch_capacity() { + assert!(room < u64::MAX / 2, "the reserve and margin are applied: {room}"); + } + + // The Scratch Size Limit bounds it, less the 1/64 margin, whatever the volume has free. + let cap = 64u64 << 20; + spill::set_size_cap(Some(cap)); + let room = spill::scratch_capacity().expect("a size limit is known room"); + assert!(room <= cap - cap / 64, "{room}"); + + // Paused (an unusable directory): no credit while tiles cannot leave memory. + let blocked = root.join("not-a-dir"); + std::fs::write(&blocked, b"x").unwrap(); + assert!(spill::configure(Some(&blocked), 1 << 20).is_err()); + assert!(spill::paused()); + assert_eq!(spill::scratch_capacity(), None); + + spill::set_size_cap(None); + spill::configure(None, 0).unwrap(); + assert_eq!(spill::scratch_capacity(), None); + let _ = std::fs::remove_dir_all(&root); +} diff --git a/docs/scratch-disks.md b/docs/scratch-disks.md index 93f314a426..02a2a1ce71 100644 --- a/docs/scratch-disks.md +++ b/docs/scratch-disks.md @@ -58,3 +58,12 @@ read from outside the edited document (for example a clipboard copy) is not dete Scratch storage alone does not make every format, filter or renderer streamable. The native PSD/EXR import and viewport streaming changes build on these guards and memory reservations. + +Available RAM is measured in one place, `photocraft_raster::memory::system` (cgroup v1/v2 +limits on Linux, reclaimable pages on macOS, the commit limit on Windows; swap never counts as +RAM, and an unreadable container limit fails closed). The residency budget and the PSD/PSB and +layered-TIFF import admission (`photocraft_io::memory`) both read it. While spilling is on and +not paused, import admission counts the tile pixels of a file against RAM plus the scratch room +(`spill::scratch_capacity`: the free space above the reserve, within the Scratch Size Limit); +decode buffers, conversion copies and merged planes still need RAM. With spilling off or +paused, and on the web, the RAM-only limits apply unchanged. From 3ba7052474819c2c6780325c68122816584d5e10 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:26:23 +0100 Subject: [PATCH 203/216] Draw over-budget canvases from viewport pages on the GPU canvas Redesign of the viewport streaming for #5332 on current main: - Only documents whose display texture set doesn't fit the canvas budget (GpuCanvas::fits_canvas, from detected RAM/VRAM) use pages, so GPU Memory on Auto (gpu_memory_mb = 0) no longer streams every document. - Documents that fit keep main's texture set path (LayerFit, DEFER_PX deferral, background redraw, admit_output); no pixel-count cutoff. - Edits dirty only the pages their damage reaches; nothing scans layer tiles. - Pages are held to half the per-document canvas budget; no change to the global wgpu resource budget or to gpu_budget's split between documents. - Bounded workers (2) and uploads per frame, cancellation on pan/zoom/edit, CPU canvas fallback when a page fails or no page fits. --- crates/compose/src/lib.rs | 1 + crates/ui-egui/src/app.rs | 1 + crates/ui-egui/src/app/commands.rs | 2 + crates/ui-egui/src/canvas.rs | 24 + .../src/canvas/background_redraw/tests.rs | 70 ++ crates/ui-egui/src/canvas/gpu_canvas.rs | 4 +- .../src/canvas/gpu_canvas/doc_textures.rs | 13 +- crates/ui-egui/src/canvas/gpu_canvas/pages.rs | 140 +++ .../src/canvas/gpu_canvas/resources.rs | 3 + .../ui-egui/src/canvas/gpu_canvas/shaders.rs | 4 +- crates/ui-egui/src/canvas/textures.rs | 14 +- crates/ui-egui/src/canvas/viewport_pages.rs | 811 ++++++++++++++++++ crates/ui-egui/src/canvas_stream.rs | 577 ------------- crates/ui-egui/src/gpu_status.rs | 2 + crates/ui-egui/src/lib.rs | 2 + 15 files changed, 1086 insertions(+), 582 deletions(-) create mode 100644 crates/ui-egui/src/canvas/gpu_canvas/pages.rs create mode 100644 crates/ui-egui/src/canvas/viewport_pages.rs delete mode 100644 crates/ui-egui/src/canvas_stream.rs diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 9039a2c621..825a0d6190 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -32,6 +32,7 @@ pub mod pattern; pub mod proxy; pub mod psblend; pub mod shape_split; +pub mod viewport; mod uniform; use photocraft_color::blend::BlendMode; diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 2ae20a041a..1aac580d35 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -43,6 +43,7 @@ impl PhotocraftApp { display_memo: HashMap::new(), navigator_gpu: HashMap::new(), navigator_plain: std::collections::HashSet::new(), + streams: HashMap::new(), info_samplers: None, monitors: Default::default(), checker: None, diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index 1a43ffe77f..9a5621c5be 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -346,6 +346,8 @@ impl PhotocraftApp { } self.canvases.retain(|(doc, _), _| ids.contains(doc)); self.navigator_textures.retain(|doc, _| ids.contains(doc)); + // Closing a document cancels its page work; its GPU pages go with `retain_gpu_documents`. + self.streams.retain(|doc, _| ids.contains(doc)); self.view_docs = ids; self.prune_thumbs(); self.sync_mask_targets(); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 12b4a8a766..388ee633ce 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -53,6 +53,7 @@ pub mod tool_feedback; mod tool_input; mod transform_controls; mod view_xform; +pub(crate) mod viewport_pages; pub mod wheel_nav; pub mod zoom_field; pub mod zoom_hold; @@ -418,6 +419,29 @@ fn paint_canvas( offset: [0.0, 0.0], }; paint_gpu_view(painter, rect, params, &pattern); + } else if gpu_ok && viewport_pages::show(app, ctx, idx, &xf, view.zoom) { + // Too large for the document's texture set: the pages in view, rendered in the background. + on_gpu = true; + app.perf.gpu = true; + let key = viewport_pages::stream_key(doc.id.0); + let params = crate::gpu_canvas::ViewParams { + doc: key, + doc_size: [doc.size.width, doc.size.height], + zoom: point_zoom, + center: xf.center, + rotation: if view.rotation.is_finite() { view.rotation.to_radians() } else { 0.0 }, + shadow: { + let t = crate::theme::Tokens::get(ctx); + !t.bevel && !t.pro && drop_shadow + }, + pixel_grid, + view_key: egui::Id::unique(("pc-canvas-pages", ctx.viewport_id(), doc.id.0)).value(), + display: sync_display_lut(app, doc, key, output), + output: output.unwrap_or(0), + hdr: hdr_preview(app, doc), + offset: [0.0, 0.0], + }; + paint_gpu_view(painter, rect, params, &pattern); } else if gpu_ok && ensure_gpu(app, ctx, idx, redraw_focus(&target_xf, &xf)) { on_gpu = true; app.perf.gpu = true; diff --git a/crates/ui-egui/src/canvas/background_redraw/tests.rs b/crates/ui-egui/src/canvas/background_redraw/tests.rs index b13692d83c..5cb8f6ac5e 100644 --- a/crates/ui-egui/src/canvas/background_redraw/tests.rs +++ b/crates/ui-egui/src/canvas/background_redraw/tests.rs @@ -612,6 +612,76 @@ fn a_canvas_over_the_texture_budget_records_its_reason() { assert_eq!(g.harness.state().render_path.history().filter(|c| c.doc == doc).count(), 1, "once, not every frame"); } +/// Frames until the active document's viewport pages are all rendered and uploaded. +fn settle_pages(g: &mut Gpu) { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60); + loop { + g.harness.step(); + let app = g.harness.state(); + let id = app.session.active().expect("document").doc.id; + if app.streams.get(&id).is_some_and(|s| !s.busy() && s.stats.wanted > 0 && s.stats.ready == s.stats.wanted) { + return; + } + assert!(std::time::Instant::now() < deadline, "the viewport pages didn't finish"); + std::thread::sleep(std::time::Duration::from_millis(2)); + } +} + +/// The screen pixel at the window's centre. +fn centre_pixel(g: &mut Gpu) -> [u8; 4] { + let img = g.harness.render().expect("render"); + let p = img.get_pixel(img.width() / 2, img.height() / 2); + p.0 +} + +/// #5332: a document whose texture set doesn't fit the canvas budget stays on the GPU canvas, +/// drawn from the pages in view instead of the CPU canvas, with the same pixels; an edit +/// re-renders only the pages, and closing it frees them. +#[test] +fn a_canvas_over_the_texture_budget_draws_its_viewport_pages_on_the_gpu() { + let _gpu = photocraft_testkit::gpu_lock(); + let Some(mut g) = gpu_app() else { return }; + g.harness.state_mut().run("file.new", json!({"width": 3000, "height": 2000, "background": "#336699"})).expect("new"); + g.harness.run_steps(3); + assert!(on_gpu_canvas(&g)); + let whole = centre_pixel(&mut g); + let gpu = g.harness.state().gpu.clone().expect("gpu canvas"); + let doc = g.harness.state().session.active().expect("document").doc.clone(); + let key = crate::canvas::viewport_pages::stream_key(doc.id.0); + // Room for pages (half the budget), not for the document's texture set. + let (set, _) = gpu.canvas_fit(&doc).expect("fit"); + gpu.set_canvas_budget(set - 1); + settle_pages(&mut g); + assert!(on_gpu_canvas(&g), "{:?}", render_path_of(&g)); + assert!(gpu.texture_info(doc.id.0).is_none(), "no texture set"); + let (pages, bytes) = gpu.pages(key); + let stats = g.harness.state().streams[&doc.id].stats.clone(); + assert_eq!(pages.len(), stats.resident); + assert!(stats.wanted > 0 && stats.last_error.is_none(), "{stats:?}"); + assert!(bytes <= (set - 1) / 2, "pages stay within half the budget: {bytes}"); + let shown = centre_pixel(&mut g); + assert!((0..3).all(|i| shown[i].abs_diff(whole[i]) <= 2), "pages {shown:?}, texture set {whole:?}"); + // An edit re-renders the pages it reaches. + let uploads = stats.uploads; + g.harness.state_mut().run("edit.fill", json!({"color": "#cc8844"})).expect("fill"); + settle_pages(&mut g); + let stats = g.harness.state().streams[&doc.id].stats.clone(); + assert!(stats.dirtied > 0 && stats.uploads > uploads, "{stats:?}"); + let filled = centre_pixel(&mut g); + assert!(filled[0] > 0xb0 && filled[2] < 0x60, "the fill is shown: {filled:?}"); + // A budget too small for one page draws it on the CPU canvas, as before. + gpu.set_canvas_budget(1); + g.harness.run_steps(3); + assert_eq!(render_path_of(&g), (Some("canvasTooLarge"), Some("CPU Rendering"))); + gpu.set_canvas_budget(set - 1); + settle_pages(&mut g); + // Closing the document cancels its work and frees its pages. + g.harness.state_mut().run("file.close", json!({"save": false})).expect("close"); + g.harness.run_steps(3); + assert!(g.harness.state().streams.is_empty()); + assert!(gpu.pages(key).0.is_empty()); +} + #[test] fn a_flipped_view_records_why_it_draws_on_the_cpu() { let _gpu = photocraft_testkit::gpu_lock(); diff --git a/crates/ui-egui/src/canvas/gpu_canvas.rs b/crates/ui-egui/src/canvas/gpu_canvas.rs index 48aeb07e0f..19a3b55846 100644 --- a/crates/ui-egui/src/canvas/gpu_canvas.rs +++ b/crates/ui-egui/src/canvas/gpu_canvas.rs @@ -34,11 +34,13 @@ use eframe::wgpu; use crate::render_path::CpuReason; +pub use pages::{MAX_PAGE_SIDE, PageUpload}; pub use photocraft_gpu::{DeviceHealth, Fault}; mod budget; mod callback; mod doc_textures; +mod pages; mod perf; mod resources; mod setup; @@ -93,7 +95,7 @@ const FORMAT_HIGH: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float; pub const F16_BUDGET_PX: u64 = 100_000_000; const VIEW_UNIFORM_SIZE: u64 = 128; const VIEW_FLOATS: usize = 32; -const TILE_UNIFORM_SIZE: u64 = 16; +const TILE_UNIFORM_SIZE: u64 = 32; /// Whether this view can use the GPU canvas. Flip and non-square pixel aspect correction are /// still CPU blits (the shader's filtering assumes square pixels); rotation is a shader uniform. diff --git a/crates/ui-egui/src/canvas/gpu_canvas/doc_textures.rs b/crates/ui-egui/src/canvas/gpu_canvas/doc_textures.rs index fb09f08e14..155bb21c91 100644 --- a/crates/ui-egui/src/canvas/gpu_canvas/doc_textures.rs +++ b/crates/ui-egui/src/canvas/gpu_canvas/doc_textures.rs @@ -35,6 +35,15 @@ pub(super) fn init_buffer(device: &wgpu::Device, label: &str, contents: &[u8], u buffer } +/// A tile's uniform: its document rect (x, y, w, h) and the exclusive document bounds it draws +/// (x0, y0, x1, y1). A whole-document tile draws all its quad covers (no bound, so rounding at a +/// shared edge cannot open a seam); a viewport page only its core, so the one-texel apron around +/// it filters its edge without being drawn twice. +pub(super) fn tile_uniform([x, y, w, h]: [u32; 4], core: Option<[i32; 4]>) -> [f32; 8] { + let [cx0, cy0, cx1, cy1] = core.map_or([-1e30, -1e30, 1e30, 1e30], |c| c.map(|v| v as f32)); + [x as f32, y as f32, w as f32, h as f32, cx0, cy0, cx1, cy1] +} + impl DocTextures { /// The texture set for a `size` canvas, or `None` (nothing created) when it doesn't fit /// the canvas budget or the GPU's memory. A set the GPU can't hold (a 3 GP canvas is 16 GB @@ -87,7 +96,7 @@ impl DocTextures { .map(|l| texture.create_view(&wgpu::TextureViewDescriptor { base_mip_level: l, mip_level_count: Some(1), ..Default::default() })) .collect(); let full = texture.create_view(&wgpu::TextureViewDescriptor::default()); - let uniform = init_buffer(device, "pc_canvas_tile", &f32_bytes(&[tx as f32, ty as f32, w as f32, h as f32]), wgpu::BufferUsages::UNIFORM); + let uniform = init_buffer(device, "pc_canvas_tile", &f32_bytes(&tile_uniform([tx, ty, w, h], None)), wgpu::BufferUsages::UNIFORM); let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("pc_canvas_tile"), layout: &res.tile_bgl, @@ -96,7 +105,7 @@ impl DocTextures { wgpu::BindGroupEntry { binding: 1, resource: uniform.as_entire_binding() }, ], }); - tiles.push(Tile { rect: [tx, ty, w, h], texture, levels, bind_group, _uniform: uniform }); + tiles.push(Tile { rect: [tx, ty, w, h], texture, levels, bind_group, _uniform: uniform, page: None }); } } Self { size, format, tiles } diff --git a/crates/ui-egui/src/canvas/gpu_canvas/pages.rs b/crates/ui-egui/src/canvas/gpu_canvas/pages.rs new file mode 100644 index 0000000000..b4e60d92d5 --- /dev/null +++ b/crates/ui-egui/src/canvas/gpu_canvas/pages.rs @@ -0,0 +1,140 @@ +//! Viewport pages of a document too large for its whole-document texture set (see +//! `canvas::viewport_pages`): independent single-level textures at their own reduction, drawn +//! by the same tile pipeline. They live under their own key, never next to a texture set. + +use super::doc_textures::{init_buffer, tile_uniform}; +use super::resources::{DocTextures, Resources, Tile, f32_bytes}; +use super::texels::{texel_bytes, texels}; +use super::{GpuCanvas, Ordering, wgpu}; +use photocraft_geom::Rect as DRect; + +/// The largest page texture side [`GpuCanvas::upload_page`] accepts (a 512 px page and its apron). +pub const MAX_PAGE_SIDE: u32 = 1024; + +/// One page to store: `buf` (document pixels reduced by `factor`, its rect in reduced pixels) +/// drawn over document area `core` of the `size` document `key`. +pub struct PageUpload<'a> { + pub key: u64, + pub page: u64, + pub size: [u32; 2], + pub depth: photocraft_doc::SampleType, + pub factor: u32, + pub core: DRect, + pub buf: &'a photocraft_compose::Buffer, +} + +impl DocTextures { + /// GPU bytes held by this set's textures (pages have one level, texture sets their mips). + pub(super) fn page_bytes(&self) -> u64 { + let bpp = texel_bytes(self.format); + self.tiles.iter().filter(|t| t.page.is_some()).map(|t| u64::from(t.texture.width()) * u64::from(t.texture.height()) * bpp).sum() + } +} + +impl GpuCanvas { + /// The GPU bytes one page of `w`×`h` texels takes for a `depth` document of `size`. + pub fn page_texture_bytes(&self, depth: photocraft_doc::SampleType, size: [u32; 2], w: u32, h: u32) -> u64 { + u64::from(w) * u64::from(h) * texel_bytes(self.format_for(depth, size)) + } + + /// Store (or replace) page `page` of `key`. False, with nothing changed, when the device is + /// not healthy, the page is malformed, or the pages of `key` would exceed the canvas budget + /// (the caller evicts its cold pages first; this is the backstop). + pub fn upload_page(&self, p: PageUpload<'_>) -> bool { + let PageUpload { key, page, size, depth, factor, core, buf } = p; + let (w, h) = (buf.rect.width(), buf.rect.height()); + if !self.health.is_ok() + || factor == 0 + || w == 0 + || h == 0 + || w > MAX_PAGE_SIDE + || h > MAX_PAGE_SIDE + || buf.rect.x0 < 0 + || buf.rect.y0 < 0 + || buf.px.len() as u64 != u64::from(w) * u64::from(h) + || core.is_empty() + { + return false; + } + let (Some(x), Some(y), Some(dw), Some(dh)) = + ((buf.rect.x0 as u32).checked_mul(factor), (buf.rect.y0 as u32).checked_mul(factor), w.checked_mul(factor), h.checked_mul(factor)) + else { + return false; + }; + let format = self.format_for(depth, size); + let bytes = u64::from(w) * u64::from(h) * texel_bytes(format); + let (device, queue) = (&self.rs.device, &self.rs.queue); + let mut renderer = self.rs.renderer.write(); + let Some(res) = renderer.callback_resources.get_mut::() else { return false }; + let budget = res.canvas_budget.load(Ordering::Relaxed); + if res.docs.get(&key).is_none_or(|d| d.size != size || d.format != format || d.tiles.iter().any(|t| t.page.is_none())) { + res.docs.insert(key, DocTextures { size, format, tiles: Vec::new() }); + } + let Some(d) = res.docs.get_mut(&key) else { return false }; + let replaced = d.tiles.iter().position(|t| t.page.is_some_and(|(id, _)| id == page)); + let held = d.page_bytes() - replaced.map_or(0, |i| u64::from(d.tiles[i].texture.width()) * u64::from(d.tiles[i].texture.height()) * texel_bytes(format)); + if held.saturating_add(bytes) > budget { + return false; + } + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("pc_viewport_page"), + size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + let view = texture.create_view(&Default::default()); + let rect = [x, y, dw, dh]; + let uniform = init_buffer(device, "pc_viewport_page", &f32_bytes(&tile_uniform(rect, Some([core.x0, core.y0, core.x1, core.y1]))), wgpu::BufferUsages::UNIFORM); + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("pc_viewport_page"), + layout: &res.tile_bgl, + entries: &[ + wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&view) }, + wgpu::BindGroupEntry { binding: 1, resource: uniform.as_entire_binding() }, + ], + }); + queue.write_texture( + texture.as_image_copy(), + &texels(format, &buf.px), + wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(w * texel_bytes(format) as u32), rows_per_image: Some(h) }, + wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, + ); + let Some(d) = res.docs.get_mut(&key) else { return false }; + if let Some(i) = replaced { + d.tiles.remove(i); + } + let tile = Tile { rect, texture, levels: vec![view], bind_group, _uniform: uniform, page: Some((page, factor)) }; + // Coarser pages first: where pages of two zooms overlap, the finer one is drawn on top. + let at = d.tiles.iter().position(|t| t.page.is_some_and(|(_, f)| f < factor)).unwrap_or(d.tiles.len()); + d.tiles.insert(at, tile); + true + } + + /// Drop the pages of `key` not in `live` (all of them, and the key, when `live` is empty). + pub fn retain_pages(&self, key: u64, live: &[u64]) { + let mut renderer = self.rs.renderer.write(); + let Some(res) = renderer.callback_resources.get_mut::() else { return }; + if live.is_empty() { + res.docs.remove(&key); + } else if let Some(d) = res.docs.get_mut(&key) { + d.tiles.retain(|t| t.page.is_some_and(|(id, _)| live.contains(&id))); + } + } + + /// The pages of `key` the GPU holds (page ids), and the bytes they take. + pub fn pages(&self, key: u64) -> (Vec, u64) { + let r = self.rs.renderer.read(); + let Some(d) = r.callback_resources.get::().and_then(|res| res.docs.get(&key)) else { return (Vec::new(), 0) }; + (d.tiles.iter().filter_map(|t| t.page.map(|(id, _)| id)).collect(), d.page_bytes()) + } + + /// The per-document display texture budget ([`super::canvas_budget`]) pages are held to. + pub fn canvas_budget(&self) -> u64 { + self.canvas_budget.load(Ordering::Relaxed) + } +} + diff --git a/crates/ui-egui/src/canvas/gpu_canvas/resources.rs b/crates/ui-egui/src/canvas/gpu_canvas/resources.rs index 721377e928..ce8f8b0881 100644 --- a/crates/ui-egui/src/canvas/gpu_canvas/resources.rs +++ b/crates/ui-egui/src/canvas/gpu_canvas/resources.rs @@ -166,6 +166,9 @@ pub(super) struct Tile { pub(super) levels: Vec, pub(super) bind_group: wgpu::BindGroup, pub(super) _uniform: wgpu::Buffer, + /// A viewport page ([`GpuCanvas::upload_page`]): its id and reduction factor (document + /// pixels per texel); `None` for a tile of a whole-document texture set. + pub(super) page: Option<(u64, u32)>, } fn uniform_entry(binding: u32, size: u64, vis: wgpu::ShaderStages) -> wgpu::BindGroupLayoutEntry { diff --git a/crates/ui-egui/src/canvas/gpu_canvas/shaders.rs b/crates/ui-egui/src/canvas/gpu_canvas/shaders.rs index c29376a936..27d9249443 100644 --- a/crates/ui-egui/src/canvas/gpu_canvas/shaders.rs +++ b/crates/ui-egui/src/canvas/gpu_canvas/shaders.rs @@ -9,7 +9,7 @@ struct View { g: vec4, // gamut warning rgb, 32-bit preview 1 / gamma h: vec4, // cos, sin, center_doc_x, center_doc_y }; -struct Tile { r: vec4 }; // x, y, w, h in doc px +struct Tile { r: vec4, core: vec4 }; // x, y, w, h in doc px; drawn x0, y0, x1, y1 @group(0) @binding(0) var view: View; @group(0) @binding(1) var samp: sampler; @@ -163,6 +163,8 @@ fn fs_tile(in: VOut) -> @location(0) vec4 { let p = in.pos.xy; let scale = view.a.z; let d = px_to_doc(p) - view.d.xy; // document pixel coordinates (within this copy) + // A viewport page's apron is only sampled; its neighbour draws those pixels. + if (any(d < tile.core.xy) || any(d >= tile.core.zw)) { discard; } let size = tile.r.zw; let t = d - tile.r.xy; // texel coordinates within this tile let mode = view.c.x; diff --git a/crates/ui-egui/src/canvas/textures.rs b/crates/ui-egui/src/canvas/textures.rs index 3e19633e41..455f2edb56 100644 --- a/crates/ui-egui/src/canvas/textures.rs +++ b/crates/ui-egui/src/canvas/textures.rs @@ -677,11 +677,23 @@ pub(crate) fn retain_gpu_documents(app: &mut PhotocraftApp) { if let Some(gpu) = &app.gpu { // Keep each document's texture plus its preview textures (filter preview, adjustment proxy); // retaining only document ids freed the previews every frame (blank canvas while previewing). - let mut live: Vec = app.session.documents().iter().flat_map(|st| [st.doc.id.0, st.doc.id.0 ^ (1u64 << 61), st.doc.id.0 ^ (1u64 << 62)]).collect(); + let mut live: Vec = app + .session + .documents() + .iter() + .flat_map(|st| [st.doc.id.0, st.doc.id.0 ^ (1u64 << 61), st.doc.id.0 ^ (1u64 << 62), super::viewport_pages::stream_key(st.doc.id.0)]) + .collect(); live.extend(crate::adjust_preview::gpu_keys(app)); gpu.retain(&live); } crate::background_redraw::retain(app); + // Page work for closed documents (or of a dropped GPU canvas) is cancelled. + if app.gpu.is_none() { + app.streams.clear(); + } else { + let ids: Vec<_> = app.session.documents().iter().map(|st| st.doc.id).collect(); + app.streams.retain(|id, _| ids.contains(id)); + } // Upload markers must not outlive the resources they describe: native reopen can reuse // both the document ID and revision before the next frame while a preview dialog stays open. let documents = app.session.documents(); diff --git a/crates/ui-egui/src/canvas/viewport_pages.rs b/crates/ui-egui/src/canvas/viewport_pages.rs new file mode 100644 index 0000000000..a29b990452 --- /dev/null +++ b/crates/ui-egui/src/canvas/viewport_pages.rs @@ -0,0 +1,811 @@ +//! Viewport pages for a document whose display textures don't fit the GPU canvas budget +//! ([`super::gpu_canvas::GpuCanvas::fits_canvas`], from the detected RAM and VRAM, so also with +//! GPU Memory on Auto). Instead of the whole-document texture set, the view's pages are rendered +//! on two background workers at the view's reduction and uploaded as their own GPU textures, +//! under [`stream_key`]: the work and the GPU memory follow what is on screen, not the document. +//! +//! Documents that fit keep the texture set and everything around it (the layer budget, deferred +//! refresh, background redraw). Without a GPU canvas the CPU canvas draws every document. +//! +//! Pages are derived data: an edit marks the pages its damage reaches dirty (the rest stay), a +//! dirty page stays on screen until its replacement is uploaded, and work for pages that are no +//! longer wanted (a pan, a zoom, an edit) is cancelled. + +use std::collections::{HashMap, HashSet}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Arc, mpsc}; + +use photocraft_doc::Document; +use photocraft_engine::display_color::CanvasDisplay; +use photocraft_geom::Rect as DRect; + +use super::textures::texture_buffer; +use super::{ViewXform, canvas_display, damage_since, display_doc, effect_reach, texture_key, visible_doc_rect}; +use crate::PhotocraftApp; + +/// Document pixels per page side at the page's reduction. +pub(crate) const PAGE_SIDE: u32 = 512; +/// The most pages a view asks for: coarser pages are used beyond it. +const MAX_PAGES: usize = 256; +/// Pages rendering at once, over all views and documents. +const MAX_JOBS: usize = 2; +/// Pages uploaded per frame, and the texel bytes they may take: staging stays bounded however +/// many pages finish at once. +const UPLOADS_PER_FRAME: usize = 2; +const UPLOAD_BYTES_PER_FRAME: u64 = 32 << 20; +/// The coarsest reduction (document pixels per texel). +const MAX_FACTOR: u32 = 1 << 16; + +static ACTIVE: AtomicUsize = AtomicUsize::new(0); + +/// The GPU canvas key of `doc`'s pages, apart from its texture set and the preview keys (bits 58 +/// to 63 tag those: the filter preview, adjustment proxies, gradients and thumbnails). +pub(crate) fn stream_key(doc: u64) -> u64 { + doc ^ (1u64 << 57) +} + +/// A page: `factor` document pixels per texel, column `x` and row `y` of `PAGE_SIDE` texels. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub(crate) struct PageId { + pub factor: u32, + pub x: u32, + pub y: u32, +} + +impl PageId { + /// The GPU canvas page id. + pub fn id(self) -> u64 { + (u64::from(self.factor.trailing_zeros()) << 56) | (u64::from(self.y) << 28) | u64::from(self.x) + } + + /// The document pixels this page draws. + pub fn core(self, size: [u32; 2]) -> DRect { + let side = u64::from(PAGE_SIDE) * u64::from(self.factor); + let (x, y) = (u64::from(self.x) * side, u64::from(self.y) * side); + if self.factor == 0 || x >= u64::from(size[0]) || y >= u64::from(size[1]) { + return DRect::EMPTY; + } + let end = |o: u64, n: u32| (o + side).min(u64::from(n)) as i32; + DRect::new(x as i32, y as i32, end(x, size[0]), end(y, size[1])) + } + + /// The document pixels it is rendered from: the core and one texel around it, so filtering + /// at its edge matches its neighbour's. + pub fn source(self, size: [u32; 2]) -> DRect { + let core = self.core(size); + if core.is_empty() { + return core; + } + core.inflate(self.factor as i32).intersect(&DRect::new(0, 0, size[0] as i32, size[1] as i32)) + } +} + +/// The page reduction for `zoom` (screen pixels per document pixel): the largest power of two at +/// which a texel still covers at most one screen pixel. +pub(crate) fn factor(zoom: f32) -> u32 { + if !zoom.is_finite() || zoom <= 0.0 { + return 1; + } + let mut k = 1u32; + while k < MAX_FACTOR && zoom * (k * 2) as f32 <= 1.0 { + k *= 2; + } + k +} + +/// The pages covering `visible` of a `size` document at `zoom`, centre first: at most +/// `max_pages` (coarser when the view needs more). +pub(crate) fn pages(size: [u32; 2], visible: DRect, zoom: f32, max_pages: usize) -> Vec { + let visible = visible.intersect(&DRect::new(0, 0, size[0].min(i32::MAX as u32) as i32, size[1].min(i32::MAX as u32) as i32)); + if visible.is_empty() || max_pages == 0 { + return Vec::new(); + } + let mut k = factor(zoom); + loop { + let side = u64::from(PAGE_SIDE) * u64::from(k); + let span = |a: i32, b: i32| (a as u64 / side, (b as u64).div_ceil(side)); + let ((x0, x1), (y0, y1)) = (span(visible.x0, visible.x1), span(visible.y0, visible.y1)); + if (x1 - x0) * (y1 - y0) <= max_pages.min(MAX_PAGES) as u64 { + let mut out: Vec = (y0..y1).flat_map(|y| (x0..x1).map(move |x| PageId { factor: k, x: x as u32, y: y as u32 })).collect(); + let (cx, cy) = ((x0 + x1) as i64, (y0 + y1) as i64); + out.sort_by_key(|p| (i64::from(p.x) * 2 + 1 - cx).abs() + (i64::from(p.y) * 2 + 1 - cy).abs()); + return out; + } + if k >= MAX_FACTOR { + return Vec::new(); + } + k *= 2; + } +} + +/// What a stream's pages show: the document revision, its preview and display keys, its size. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct Identity { + pub revision: u64, + /// The raw preview key ([`display_doc`]); `display` is folded in for [`damage_since`]. + pub preview: u64, + pub display: u64, + pub size: [u32; 2], +} + +/// A page on the GPU. +#[derive(Debug)] +struct Page { + /// Rendered before the latest edit that reached it: shown until its replacement arrives. + dirty: bool, + /// The exact reduction (not the quick preview). + exact: bool, + /// Bumped whenever the page's content is invalidated: a result from older work is dropped. + epoch: u64, + touched: u64, + bytes: u64, +} + +struct Job { + page: PageId, + epoch: u64, + cancel: Arc, +} + +/// A finished page render. +pub(crate) struct Done { + pub page: PageId, + pub epoch: u64, + pub exact: bool, + pub result: Result, +} + +/// The counters `inspect` reports. +#[derive(Clone, Debug, Default, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct Stats { + pub wanted: usize, + pub ready: usize, + pub resident: usize, + pub resident_bytes: u64, + pub limit: u64, + pub jobs: usize, + pub rendered: u64, + pub uploads: u64, + pub cancelled: u64, + pub dirtied: u64, + pub evicted: u64, + pub last_error: Option, +} + +/// One document's viewport pages ([`PhotocraftApp::streams`]). +pub(crate) struct Stream { + pages: HashMap, + /// Epochs of pages not (or no longer) on the GPU, so their results can still be checked. + epochs: HashMap, + jobs: Vec, + ready: Vec, + failed: HashSet<(PageId, u64)>, + tx: mpsc::Sender, + rx: mpsc::Receiver, + pub seen: Option, + clock: u64, + /// The page ids last given to the GPU canvas, to tell it only of changes. + synced: Vec, + health: (u64, u64), + pub stats: Stats, +} + +impl Default for Stream { + fn default() -> Self { + let (tx, rx) = mpsc::channel(); + Self { + pages: HashMap::new(), + epochs: HashMap::new(), + jobs: Vec::new(), + ready: Vec::new(), + failed: HashSet::new(), + tx, + rx, + seen: None, + clock: 0, + synced: Vec::new(), + health: (0, 0), + stats: Stats::default(), + } + } +} + +impl Drop for Stream { + fn drop(&mut self) { + for j in &self.jobs { + j.cancel.store(true, Ordering::Relaxed); + } + } +} + +impl Stream { + fn epoch(&self, page: PageId) -> u64 { + self.pages.get(&page).map_or_else(|| self.epochs.get(&page).copied().unwrap_or(0), |p| p.epoch) + } + + fn bump(&mut self, page: PageId) { + let e = self.epoch(page) + 1; + match self.pages.get_mut(&page) { + Some(p) => p.epoch = e, + None => { + self.epochs.insert(page, e); + } + } + } + + /// The document changed to `now`: the pages whose source `damage` reaches (every page + /// without it) are dirty, and work on them is cancelled. Nothing else is scanned: the cost + /// is the pages held, not the document's layers or tiles. + pub fn invalidate(&mut self, now: Identity, damage: Option) { + let size_changed = self.seen.is_some_and(|s| s.size != now.size); + self.seen = Some(now); + if size_changed { + self.reset(); + return; + } + let hit = |p: PageId| damage.is_none_or(|d| !p.source(now.size).intersect(&d).is_empty()); + let pages: Vec = self.pages.keys().copied().filter(|p| hit(*p)).collect(); + for p in &pages { + if let Some(page) = self.pages.get_mut(p) { + page.dirty = true; + page.exact = false; + } + self.bump(*p); + } + self.stats.dirtied += pages.len() as u64; + let jobs: Vec = self.jobs.iter().map(|j| j.page).filter(|p| hit(*p)).collect(); + for p in jobs { + if !self.pages.contains_key(&p) { + self.bump(p); + } + } + self.cancel_stale(); + self.ready.retain(|d| !hit(d.page)); + self.failed.retain(|(p, _)| !hit(*p)); + } + + /// Forget every page (the GPU canvas lost them, or the document changed size). + pub fn reset(&mut self) { + let pages: Vec = self.pages.keys().chain(self.jobs.iter().map(|j| &j.page)).copied().collect(); + for p in pages { + let e = self.epoch(p) + 1; + self.epochs.insert(p, e); + } + self.pages.clear(); + self.ready.clear(); + self.failed.clear(); + self.synced.clear(); + self.cancel_stale(); + } + + /// Cancel jobs whose page moved to a newer epoch. + fn cancel_stale(&mut self) { + for j in &self.jobs { + if j.epoch != self.epoch(j.page) && !j.cancel.swap(true, Ordering::Relaxed) { + self.stats.cancelled += 1; + } + } + } + + /// Take finished work, cancel work for pages not `wanted`, and start work for wanted pages + /// that are missing, dirty or still a quick preview (`faithful`: the document has a quick + /// preview at reductions), as `spawn(page, epoch, exact, cancel)` allows. + pub fn update(&mut self, wanted: &[PageId], faithful: bool, mut spawn: impl FnMut(PageId, u64, bool, Arc) -> bool) { + self.clock += 1; + let want: HashSet = wanted.iter().copied().collect(); + while let Ok(d) = self.rx.try_recv() { + let Some(i) = self.jobs.iter().position(|j| j.page == d.page && j.epoch == d.epoch) else { continue }; + // Cancelled work ends in an interruption, not a failure: the page is asked for again. + let cancelled = self.jobs.swap_remove(i).cancel.load(Ordering::Relaxed); + if cancelled || d.epoch != self.epoch(d.page) || !want.contains(&d.page) { + continue; + } + match &d.result { + Ok(_) => { + self.stats.rendered += 1; + self.ready.retain(|r| r.page != d.page); + self.ready.push(d); + } + Err(e) => { + log::warn!("viewport page {:?}: {e}", d.page); + self.stats.last_error = Some(e.clone()); + self.failed.insert((d.page, d.epoch)); + } + } + } + for j in &self.jobs { + if !want.contains(&j.page) && !j.cancel.swap(true, Ordering::Relaxed) { + self.stats.cancelled += 1; + } + } + self.ready.retain(|d| want.contains(&d.page)); + for p in wanted { + if let Some(page) = self.pages.get_mut(p) { + page.touched = self.clock; + } + } + let current = |s: &Self, p: &PageId| s.pages.get(p).is_some_and(|q| !q.dirty) || s.ready.iter().any(|d| d.page == *p); + let all_shown = wanted.iter().all(|p| current(self, p)); + for p in wanted { + let live = self.jobs.iter().filter(|j| !j.cancel.load(Ordering::Relaxed)).count(); + if live >= MAX_JOBS { + break; + } + let epoch = self.epoch(*p); + if self.jobs.iter().any(|j| j.page == *p && j.epoch == epoch) || self.failed.contains(&(*p, epoch)) { + continue; + } + // First every page in view (a quick preview at a reduction), then the exact ones. + let exact = p.factor == 1 || !faithful || all_shown; + let have = self.ready.iter().find(|d| d.page == *p).map(|d| d.exact).or_else(|| self.pages.get(p).filter(|q| !q.dirty).map(|q| q.exact)); + if have.is_some_and(|e| e || !exact) { + continue; + } + let cancel = Arc::new(AtomicBool::new(false)); + if !spawn(*p, epoch, exact, cancel.clone()) { + break; + } + self.jobs.push(Job { page: *p, epoch, cancel }); + } + self.stats.wanted = wanted.len(); + self.stats.ready = wanted.iter().filter(|p| self.pages.contains_key(p)).count(); + self.stats.jobs = self.jobs.len(); + } + + /// Retry failed pages once the scratch storage or memory configuration changed (`health`). + pub fn retry_after(&mut self, health: (u64, u64)) { + if health != self.health { + self.health = health; + self.failed.clear(); + } + } + + /// Whether a wanted page can't be shown: its render or upload failed and nothing older of it + /// is held, so the view would have a hole (the CPU canvas draws instead). + pub fn stuck(&self, wanted: &[PageId]) -> bool { + wanted.iter().any(|p| !self.pages.contains_key(p) && self.failed.contains(&(*p, self.epoch(*p)))) + } + + /// Whether work is outstanding (the view repaints until it is done). + pub fn busy(&self) -> bool { + !self.jobs.is_empty() || !self.ready.is_empty() + } + + /// The finished pages to upload this frame: at most [`UPLOADS_PER_FRAME`], and + /// [`UPLOAD_BYTES_PER_FRAME`] of texels (`texel_bytes` each), the first always. + pub fn take_uploads(&mut self, wanted: &[PageId], texel_bytes: u64) -> Vec { + let order = |d: &Done| wanted.iter().position(|p| *p == d.page).unwrap_or(usize::MAX); + self.ready.sort_by_key(order); + let mut out = Vec::new(); + let mut bytes = 0u64; + while out.len() < UPLOADS_PER_FRAME && !self.ready.is_empty() { + let b = self.ready[0].result.as_ref().map_or(0, |b| b.px.len() as u64 * texel_bytes); + if !out.is_empty() && bytes + b > UPLOAD_BYTES_PER_FRAME { + break; + } + bytes += b; + out.push(self.ready.remove(0)); + } + out + } + + /// Make room for `bytes` more under `limit` by evicting the least recently wanted pages not + /// in `wanted`: whether any was evicted, or `None` when the wanted pages leave no room. + pub fn make_room(&mut self, page: PageId, bytes: u64, limit: u64, wanted: &[PageId]) -> Option { + let held = |s: &Self| s.pages.iter().filter(|(p, _)| **p != page).map(|(_, q)| q.bytes).sum::(); + let mut evicted = false; + while held(self) + bytes > limit { + let cold = self.pages.iter().filter(|(p, _)| **p != page && !wanted.contains(p)).min_by_key(|(_, q)| q.touched).map(|(p, _)| *p)?; + self.drop_page(cold); + self.stats.evicted += 1; + evicted = true; + } + Some(evicted) + } + + /// The GPU page ids of the pages held. + pub fn ids(&self) -> Vec { + self.pages.keys().map(|p| p.id()).collect() + } + + fn drop_page(&mut self, p: PageId) { + if let Some(q) = self.pages.remove(&p) { + self.epochs.insert(p, q.epoch); + } + } + + /// `done` is on the GPU (`ok`), or couldn't be stored. + pub fn uploaded(&mut self, done: &Done, bytes: u64, ok: bool) { + if !ok { + self.stats.last_error = Some("the GPU canvas couldn't store a viewport page".into()); + self.failed.insert((done.page, done.epoch)); + return; + } + self.stats.uploads += 1; + self.epochs.remove(&done.page); + self.pages.insert(done.page, Page { dirty: false, exact: done.exact, epoch: done.epoch, touched: self.clock, bytes }); + } + + /// Drop pages no longer worth keeping: once every wanted page is shown and current, the + /// dirty ones and the ones finer than the view (they would draw over it). Coarser clean + /// pages stay (under the view's own, for a quick pan or zoom out) until evicted. The GPU + /// page ids to keep when they changed since the last call. + pub fn prune(&mut self, wanted: &[PageId]) -> Option> { + let all_current = wanted.iter().all(|p| self.pages.get(p).is_some_and(|q| !q.dirty)); + if all_current && let Some(f) = wanted.first().map(|p| p.factor) { + let drop: Vec = self.pages.iter().filter(|(p, q)| !wanted.contains(p) && (q.dirty || p.factor < f)).map(|(p, _)| *p).collect(); + for p in drop { + self.drop_page(p); + } + } + // Epochs only guard results still on their way. + let jobs: HashSet = self.jobs.iter().map(|j| j.page).collect(); + self.epochs.retain(|p, _| jobs.contains(p)); + let mut ids = self.ids(); + ids.sort_unstable(); + self.stats.resident = ids.len(); + self.stats.resident_bytes = self.pages.values().map(|q| q.bytes).sum(); + (ids != self.synced).then(|| { + self.synced = ids.clone(); + ids + }) + } + + /// The pages this stream believes are on the GPU. + pub fn resident(&self) -> usize { + self.pages.len() + } +} + +/// Render `page` of `doc` on a worker, if one of the [`MAX_JOBS`] is free. +fn spawn( + ctx: &egui::Context, + tx: mpsc::Sender, + doc: Arc, + display: Option>, + (page, epoch, exact): (PageId, u64, bool), + cancel: Arc, +) -> bool { + if ACTIVE.fetch_update(Ordering::AcqRel, Ordering::Relaxed, |n| (n < MAX_JOBS).then_some(n + 1)).is_err() { + return false; + } + struct Slot(egui::Context); + impl Drop for Slot { + fn drop(&mut self) { + ACTIVE.fetch_sub(1, Ordering::AcqRel); + // A view waiting for a free worker (another document's) can start now. + self.0.request_repaint(); + } + } + let slot = Slot(ctx.clone()); + let work = move || { + let _slot = slot; + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| render(&doc, page, exact, display.as_deref(), &cancel))) + .unwrap_or_else(|_| Err("viewport page render panicked".into())); + let _ = tx.send(Done { page, epoch, exact, result }); + }; + #[cfg(not(target_arch = "wasm32"))] + { + std::thread::Builder::new().name("pc-viewport-page".into()).spawn(work).is_ok() + } + #[cfg(target_arch = "wasm32")] + { + work(); + true + } +} + +/// One page as the GPU canvas stores it (encoded for the display like the texture set). +fn render(doc: &Document, page: PageId, exact: bool, display: Option<&CanvasDisplay>, cancel: &AtomicBool) -> Result { + let size = [doc.size.width, doc.size.height]; + let source = page.source(size); + let rows = u64::from(source.height().min(128)); + let _working = photocraft_raster::memory::try_reserve(u64::from(source.width()) * rows * 16 + (16 << 20))?; + let before = photocraft_raster::spill::read_error_generation(); + let check = || cancel.load(Ordering::Relaxed); + let i = photocraft_raster::Interrupt::cancel_only(&check); + let buf = if exact { + photocraft_compose::viewport::render_page(doc, source, page.factor, i)? + } else { + photocraft_compose::viewport::preview_page(doc, source, page.factor, i)? + }; + i.check().map_err(|e| e.to_string())?; + photocraft_raster::spill::check_since(before)?; + Ok(texture_buffer(display, &buf).into_owned()) +} + +/// Draw document `idx` from viewport pages when its texture set doesn't fit the GPU canvas +/// budget: true when the view should paint [`stream_key`] with the GPU canvas. +pub(super) fn show(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, xf: &ViewXform, zoom: f32) -> bool { + let Some(gpu) = app.gpu.clone() else { return false }; + if app.ui.view.pattern_preview || gpu.fault().is_some() { + return false; + } + let Some((revision, last_damage, id, size)) = app.session.documents().get(idx).map(|st| { + (st.revision, st.last_damage.map(|r| if r.is_empty() { r } else { r.inflate(effect_reach(&st.doc.layers)) }), st.doc.id, st.doc.size) + }) else { + return false; + }; + let size = [size.width, size.height]; + let (doc, raw_key) = display_doc(app, idx); + // A size-changing preview draws as main does; a document that fits keeps its texture set. + if [doc.size.width, doc.size.height] != size || gpu.fits_canvas(&doc) { + if let Some(s) = app.streams.remove(&id) { + drop(s); + gpu.retain_pages(stream_key(id.0), &[]); + } + return false; + } + let (display, _) = canvas_display(app, &doc, None); + let display_key = texture_key(display.as_deref()); + let now = Identity { revision, preview: raw_key, display: display_key, size }; + let seen = app.streams.get(&id).and_then(|s| s.seen); + if seen != Some(now) { + let damage = seen.filter(|s| s.size == size && s.display == display_key).and_then(|s| damage_since(app, idx, (s.revision, s.preview ^ display_key), (revision, raw_key), display_key, last_damage)); + app.streams.entry(id).or_default().invalidate(now, damage); + } + let key = stream_key(id.0); + // Half the per-document canvas budget: the rest is headroom for what the compositor needs. + let limit = gpu.canvas_budget() / 2; + let page_bytes = gpu.page_texture_bytes(doc.depth, size, PAGE_SIDE + 2, PAGE_SIDE + 2).max(1); + let max_pages = usize::try_from(limit / page_bytes).unwrap_or(usize::MAX); + let wanted = pages(size, visible_doc_rect(xf), zoom, max_pages); + let stream = app.streams.entry(id).or_default(); + stream.retry_after((photocraft_raster::spill::read_error_generation(), photocraft_raster::memory::configuration_generation())); + // No room for a single page, or a page that failed: the CPU canvas draws the document, as + // main does for every document over the budget. + if max_pages == 0 || stream.stuck(&wanted) { + return false; + } + let faithful = photocraft_compose::proxy::proxy_faithful(&doc); + let texel_bytes = gpu.page_texture_bytes(doc.depth, size, 1, 1); + // The GPU canvas dropped the pages (released, or pruned with a closed document): start over. + if stream.resident() > 0 && gpu.pages(key).0.len() != stream.resident() { + stream.reset(); + } + // Drawn from pages: no texture set, so no layer budget or background redraw applies. + crate::background_redraw::cancel(app, id); + if app.perf.gpu_layer_fit.is_some_and(|f| f.doc == id.0) { + app.perf.gpu_layer_fit = None; + } + app.navigator_gpu.remove(&id); + crate::render_path::note(app, id.0, crate::render_path::Path::Gpu { full: true }); + let stream = app.streams.entry(id).or_default(); + let tx = stream.tx.clone(); + stream.update(&wanted, faithful, |page, epoch, exact, cancel| spawn(ctx, tx.clone(), doc.clone(), display.clone(), (page, epoch, exact), cancel)); + for d in stream.take_uploads(&wanted, texel_bytes) { + let Ok(buf) = &d.result else { continue }; + let bytes = buf.px.len() as u64 * texel_bytes; + let ok = match stream.make_room(d.page, bytes, limit, &wanted) { + None => false, + Some(evicted) => { + if evicted { + gpu.retain_pages(key, &stream.ids()); + } + gpu.upload_page(crate::gpu_canvas::PageUpload { key, page: d.page.id(), size, depth: doc.depth, factor: d.page.factor, core: d.page.core(size), buf }) + } + }; + stream.uploaded(&d, bytes, ok); + } + if let Some(ids) = stream.prune(&wanted) { + gpu.retain_pages(key, &ids); + } + stream.stats.limit = limit; + if stream.busy() { + ctx.request_repaint_after(std::time::Duration::from_millis(16)); + } + true +} + +#[cfg(test)] +mod tests { + use super::*; + + const SIZE: [u32; 2] = [20_000, 12_000]; + + fn id(revision: u64) -> Identity { + Identity { revision, preview: 1, display: 2, size: SIZE } + } + + fn page(factor: u32, x: u32, y: u32) -> PageId { + PageId { factor, x, y } + } + + fn done(p: PageId, epoch: u64, exact: bool, side: i32) -> Done { + let rect = DRect::new(0, 0, side, side); + Done { page: p, epoch, exact, result: Ok(photocraft_compose::Buffer { rect, px: vec![[0.0; 4]; (side * side) as usize] }) } + } + + /// A stream holding `held` clean, exact pages. + fn holding(held: &[PageId]) -> Stream { + let mut s = Stream::default(); + s.invalidate(id(1), None); + for p in held { + s.uploaded(&done(*p, s.epoch(*p), true, 4), 100, true); + } + s + } + + /// Spawns every job it is offered, recording it with its cancel flag. + fn recorder(log: &mut Vec<(PageId, u64, bool, Arc)>) -> impl FnMut(PageId, u64, bool, Arc) -> bool + '_ { + |p, e, x, c| { + log.push((p, e, x, c)); + true + } + } + + #[test] + fn factor_keeps_a_texel_at_most_one_screen_pixel() { + assert_eq!(factor(4.0), 1); + assert_eq!(factor(1.0), 1); + assert_eq!(factor(0.5), 2); + assert_eq!(factor(0.3), 2); + assert_eq!(factor(0.25), 4); + assert_eq!(factor(0.0), 1); + assert_eq!(factor(f32::NAN), 1); + assert_eq!(factor(1e-9), MAX_FACTOR); + } + + #[test] + fn pages_cover_the_view_centre_first_and_coarsen_past_the_limit() { + let visible = DRect::new(1000, 1000, 2500, 2000); + let got = pages(SIZE, visible, 1.0, 64); + // Columns 1..=4, rows 1..=3 of 512 px pages. + assert_eq!(got.len(), 4 * 3); + assert!(got.iter().all(|p| p.factor == 1)); + let union = got.iter().fold(DRect::EMPTY, |u, p| u.union(&p.core(SIZE))); + assert_eq!(union.intersect(&visible), visible, "every visible pixel is on a page"); + let centre = got[0].core(SIZE); + assert!(centre.inflate(512).contains(1750, 1500), "centre first: {:?}", got[0]); + // The whole document at 1:1 is 40×24 pages: over the limit it uses a reduction. + let whole = DRect::new(0, 0, SIZE[0] as i32, SIZE[1] as i32); + let coarse = pages(SIZE, whole, 1.0, 64); + assert!(!coarse.is_empty() && coarse.len() <= 64); + assert!(coarse.iter().all(|p| p.factor == 4), "{:?}", coarse[0]); + assert!(pages(SIZE, whole, 1.0, 0).is_empty()); + assert!(pages(SIZE, DRect::new(-500, -500, -1, -1), 1.0, 64).is_empty(), "off the document"); + } + + #[test] + fn a_page_core_tiles_the_document_and_its_source_adds_one_texel() { + let p = page(2, 1, 0); + assert_eq!(p.core(SIZE), DRect::new(1024, 0, 2048, 1024)); + assert_eq!(p.source(SIZE), DRect::new(1022, 0, 2050, 1026)); + // The last column is clipped to the document. + let last = page(1, SIZE[0] / 512, 0); + assert_eq!(last.core(SIZE).x1, SIZE[0] as i32); + assert!(page(1, 1000, 0).core(SIZE).is_empty()); + // Ids differ across factors and positions. + assert_ne!(page(1, 1, 0).id(), page(2, 1, 0).id()); + assert_ne!(page(1, 1, 0).id(), page(1, 0, 1).id()); + } + + #[test] + fn an_edit_dirties_only_the_pages_it_reaches() { + let (a, b, c) = (page(1, 0, 0), page(1, 1, 0), page(1, 4, 4)); + let mut s = holding(&[a, b, c]); + // Damage on page a's right edge also reaches b's one-texel apron. + s.invalidate(id(2), Some(DRect::new(500, 10, 512, 20))); + assert!(s.pages[&a].dirty && s.pages[&b].dirty); + assert!(!s.pages[&c].dirty && s.pages[&c].exact, "far page untouched"); + assert_eq!(s.stats.dirtied, 2); + // A dirty page stays on screen until replaced; no damage known dirties every page. + s.invalidate(id(3), None); + assert_eq!(s.pages.len(), 3); + assert!(s.pages.values().all(|p| p.dirty)); + // A new size starts over. + s.invalidate(Identity { size: [100, 100], ..id(4) }, None); + assert_eq!(s.resident(), 0); + } + + #[test] + fn pan_zoom_and_edits_cancel_obsolete_work() { + let mut s = holding(&[]); + let mut log = Vec::new(); + let view = [page(1, 0, 0), page(1, 1, 0), page(1, 2, 0)]; + s.update(&view, true, recorder(&mut log)); + assert_eq!(log.len(), MAX_JOBS, "bounded workers"); + assert!(log.iter().all(|j| j.2), "1:1 pages are rendered exactly"); + // Pan: the first page is no longer wanted. + s.update(&view[1..], true, recorder(&mut Vec::new())); + assert!(log[0].3.load(Ordering::Relaxed), "work for a page out of view is cancelled"); + assert!(!log[1].3.load(Ordering::Relaxed)); + // Edit on the second page: its result would be stale. + s.invalidate(id(2), Some(DRect::new(600, 0, 601, 1))); + assert!(log[1].3.load(Ordering::Relaxed), "work on an edited page is cancelled"); + // Its old result is dropped when it arrives, and so is a cancelled job's interruption. + s.tx.send(Done { page: log[0].0, epoch: log[0].1, exact: true, result: Err("interrupted".into()) }).unwrap(); + s.tx.send(done(log[1].0, log[1].1, true, 4)).unwrap(); + s.update(&view[1..], true, |_, _, _, _| false); + assert!(s.ready.is_empty(), "stale result dropped"); + assert!(!s.stuck(&view) && s.stats.last_error.is_none(), "a cancellation is not a failure"); + // Zoom: every page changes factor. + let mut log2 = Vec::new(); + s.update(&[page(1, 1, 0)], true, recorder(&mut log2)); + let jobs = s.jobs.len(); + s.update(&[page(2, 0, 0)], true, |_, _, _, _| true); + assert!(log2.iter().all(|j| j.3.load(Ordering::Relaxed)), "zoom cancels the old factor's work"); + assert!(s.stats.cancelled >= 3 && jobs > 0); + } + + #[test] + fn reductions_show_a_quick_preview_before_the_exact_page() { + let mut s = holding(&[]); + let view = [page(2, 0, 0)]; + let mut log = Vec::new(); + s.update(&view, true, recorder(&mut log)); + assert_eq!(log.len(), 1); + assert!(!log[0].2, "a faithful document previews first"); + s.tx.send(done(view[0], log[0].1, false, 4)).unwrap(); + let mut log2 = Vec::new(); + s.update(&view, true, recorder(&mut log2)); + assert!(log2.len() == 1 && log2[0].2, "then the exact page, once every page is shown"); + let mut log3 = Vec::new(); + holding(&[]).update(&view, false, recorder(&mut log3)); + assert!(log3[0].2, "an unfaithful preview is skipped"); + } + + #[test] + fn uploads_per_frame_are_bounded() { + let mut s = holding(&[]); + let view: Vec = (0..4).map(|x| page(1, x, 0)).collect(); + for p in &view { + s.ready.push(done(*p, 0, true, 514)); + } + // 514² texels of 16 bytes: 4.2 MB each. + let first = s.take_uploads(&view, 16); + assert_eq!(first.len(), UPLOADS_PER_FRAME); + assert_eq!(first[0].page, view[0], "in view order"); + // A page over the byte cap still goes alone. + let big = s.take_uploads(&view, 1 << 10); + assert_eq!(big.len(), 1); + assert_eq!(s.ready.len(), 1); + } + + #[test] + fn the_budget_evicts_cold_pages_but_never_wanted_ones() { + let held: Vec = (0..4).map(|x| page(1, x, 0)).collect(); + let mut s = holding(&held); + s.clock = 10; + s.pages.get_mut(&held[2]).unwrap().touched = 9; + let wanted = [held[0], held[1], page(1, 9, 9)]; + // 400 held, limit 450: room for 100 more means evicting one cold page, the oldest. + assert_eq!(s.make_room(wanted[2], 100, 450, &wanted), Some(true)); + assert!(!s.pages.contains_key(&held[3]) && s.pages.contains_key(&held[2])); + assert_eq!(s.make_room(wanted[2], 100, 399, &wanted), Some(true)); + assert_eq!(s.resident(), 2); + assert_eq!(s.make_room(wanted[2], 100, 250, &wanted), None, "wanted pages are never evicted"); + assert_eq!(s.stats.evicted, 2); + } + + #[test] + fn a_failed_page_falls_back_until_it_is_retried() { + let mut s = holding(&[]); + let view = [page(1, 0, 0)]; + let mut log = Vec::new(); + s.update(&view, true, recorder(&mut log)); + s.tx.send(Done { page: view[0], epoch: log[0].1, exact: true, result: Err("out of memory".into()) }).unwrap(); + s.update(&view, true, |_, _, _, _| panic!("no retry while failed")); + assert!(s.stuck(&view)); + assert_eq!(s.stats.last_error.as_deref(), Some("out of memory")); + s.retry_after((7, 7)); + assert!(!s.stuck(&view), "a new memory configuration retries"); + s.invalidate(id(2), None); + assert!(!s.stuck(&view)); + } + + #[test] + fn prune_drops_finer_and_dirty_pages_once_the_view_is_current() { + let (fine, coarse, view) = (page(1, 0, 0), page(4, 0, 0), page(2, 0, 0)); + let mut s = holding(&[fine, coarse]); + assert!(s.prune(&[view]).is_some()); + assert_eq!(s.resident(), 2, "kept until the view's own page is shown"); + s.uploaded(&done(view, s.epoch(view), true, 4), 100, true); + let ids = s.prune(&[view]).expect("changed"); + assert_eq!(ids.len(), 2); + assert!(!s.pages.contains_key(&fine) && s.pages.contains_key(&coarse)); + assert!(s.prune(&[view]).is_none(), "unchanged: nothing to tell the GPU"); + assert!(s.epochs.is_empty(), "epochs only for work in flight"); + } +} diff --git a/crates/ui-egui/src/canvas_stream.rs b/crates/ui-egui/src/canvas_stream.rs deleted file mode 100644 index b69adc8152..0000000000 --- a/crates/ui-egui/src/canvas_stream.rs +++ /dev/null @@ -1,577 +0,0 @@ -//! Viewport residency with bounded background work. A cached page never represents the -//! document's storage: zoom, device loss and eviction cannot change original pixels. -use crate::gpu_canvas::GpuCanvas; -use egui::{Color32, TextureHandle, TextureOptions}; -use photocraft_doc::Document; -use photocraft_engine::display_color::CanvasDisplay; -use photocraft_geom::Rect; -use std::collections::{HashMap, HashSet}; -use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; -use std::sync::{Arc, mpsc}; - -pub const PAGE_SIDE: u32 = 512; -const MAX_PAGES: usize = 512; -const MAX_JOBS: usize = 2; -// RGBA F32 plus its display image, including the one-pixel apron on each side. -const PAGE_BYTES: u64 = (PAGE_SIDE as u64 + 2) * (PAGE_SIDE as u64 + 2) * 20; -static ACTIVE: AtomicUsize = AtomicUsize::new(0); -struct WorkerSlot; -impl Drop for WorkerSlot { - fn drop(&mut self) { - ACTIVE.fetch_sub(1, Ordering::AcqRel); - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub struct PageKey { - pub doc: u64, - pub snapshot: u64, - pub revision: u64, - pub preview: u64, - pub display: u64, - pub factor: u32, - pub x: u32, - pub y: u32, -} - -impl PageKey { - pub fn id(self) -> u64 { - egui::Id::new(self).value() - } - pub fn core(self, doc: &Document) -> Rect { - let side = PAGE_SIDE.saturating_mul(self.factor); - let Some(x) = self.x.checked_mul(side).and_then(|x| i32::try_from(x).ok()) else { return Rect::EMPTY }; - let Some(y) = self.y.checked_mul(side).and_then(|y| i32::try_from(y).ok()) else { return Rect::EMPTY }; - if self.factor == 0 || self.factor > 1 << 16 { - return Rect::EMPTY; - } - Rect::from_xywh(x, y, side, side).intersect(&doc.bounds()) - } - pub fn source(self, doc: &Document) -> Rect { - self.core(doc).inflate(self.factor as i32).intersect(&doc.bounds()) - } -} - -pub fn factor(zoom: f32) -> u32 { - if !zoom.is_finite() || zoom <= 0.0 { - return 1; - } - let mut k = 1u32; - while k < 1 << 16 && zoom * (k * 2) as f32 <= 1.0 { - k *= 2; - } - k -} - -pub fn pages(doc: &Document, visible: Rect, zoom: f32, revision: u64, preview: u64, display: u64) -> Vec { - let visible = visible.intersect(&doc.bounds()); - if visible.is_empty() { - return Vec::new(); - } - let mut k = factor(zoom); - loop { - let side = PAGE_SIDE.saturating_mul(k); - let (x0, x1, y0, y1) = (visible.x0 as u32 / side, (visible.x1 as u32).div_ceil(side), visible.y0 as u32 / side, (visible.y1 as u32).div_ceil(side)); - if u64::from(x1 - x0) * u64::from(y1 - y0) <= MAX_PAGES as u64 { - let mut out = Vec::new(); - for y in y0..y1 { - for x in x0..x1 { - out.push(PageKey { doc: doc.id.0, snapshot: doc as *const Document as usize as u64, revision, preview, display, factor: k, x, y }); - } - } - let (cx, cy) = (i64::from(x0 + x1), i64::from(y0 + y1)); - out.sort_by_key(|p| (i64::from(p.x) * 2 - cx).abs() + (i64::from(p.y) * 2 - cy).abs()); - return out; - } - if k >= 1 << 16 { - return Vec::new(); - } - k *= 2; - } -} - -struct Page { - buffer: Option, - rect: Rect, - image: egui::ColorImage, - texture: Option, - gpu: bool, - exact: bool, - touched: u64, -} -impl Page { - fn bytes(&self) -> u64 { - self.image.pixels.len() as u64 * 4 + self.buffer.as_ref().map_or(0, |b| b.px.len() as u64 * 16) - } -} -struct Completed { - key: PageKey, - exact: bool, - result: Result<(photocraft_compose::Buffer, egui::ColorImage), String>, -} - -#[derive(Default, Clone, serde::Serialize)] -#[serde(rename_all = "camelCase")] -pub struct Stats { - pub resident_pages: usize, - pub resident_bytes: u64, - pub active_jobs: usize, - pub uploads: u64, - pub cancellations: u64, - pub gpu_composites: u64, - pub last_error: Option, - pub visible_pages: usize, - pub ready_pages: usize, - pub pending_bytes: u64, - pub cache_limit: u64, -} - -pub struct Stream { - cache: HashMap, - jobs: HashMap>, - tx: mpsc::SyncSender, - rx: mpsc::Receiver, - clock: u64, - failed: HashSet, - health_generation: u64, - memory_generation: u64, - pub stats: Stats, -} -impl Default for Stream { - fn default() -> Self { - let (tx, rx) = mpsc::sync_channel(MAX_JOBS); - Self { - cache: HashMap::new(), - jobs: HashMap::new(), - tx, - rx, - clock: 0, - failed: HashSet::new(), - health_generation: 0, - memory_generation: 0, - stats: Stats::default(), - } - } -} -impl Drop for Stream { - fn drop(&mut self) { - for c in self.jobs.values() { - c.store(true, Ordering::Relaxed); - } - } -} - -fn compute( - doc: &Document, - key: PageKey, - display: Option<&CanvasDisplay>, - exact: bool, - cancel: &AtomicBool, -) -> Result<(photocraft_compose::Buffer, egui::ColorImage), String> { - let source = key.source(doc); - let working = u64::from(source.width()) * u64::from(source.height().min(128)) * 16 + (16 << 20); - let _working = photocraft_raster::memory::try_reserve(working)?; - let before = photocraft_raster::spill::read_error_generation(); - let check = || cancel.load(Ordering::Relaxed); - let i = photocraft_raster::Interrupt::cancel_only(&check); - let buf = if exact { - photocraft_compose::viewport::render_page(doc, key.source(doc), key.factor, i)? - } else { - photocraft_compose::viewport::preview_page(doc, key.source(doc), key.factor, i)? - }; - i.check().map_err(|e| e.to_string())?; - photocraft_raster::spill::check_since(before)?; - let img = display.map_or_else(|| buf.to_rgba8(), |d| d.to_rgba8(&buf)); - let image = egui::ColorImage::from_rgba_unmultiplied([img.width as usize, img.height as usize], &img.pixels); - let encoded = display.map_or_else(|| buf.clone(), |d| d.texture_buffer(&buf).into_owned()); - Ok((encoded, image)) -} - -impl Stream { - /// Carry unaffected derived pages across a single local edit. Jobs still in flight retain - /// their old identity and are cancelled by update; no stale worker result is rebased. - pub fn rebase_damage(&mut self, doc: &Document, target: PageKey, damage: Rect, gpu: Option<&GpuCanvas>) { - if target.preview != 0 { - return; - } - let keys: Vec<_> = self - .cache - .keys() - .copied() - .filter(|k| { - k.doc == target.doc - && k.revision.checked_add(1) == Some(target.revision) - && k.preview == 0 - && k.display == target.display - && k.source(doc).intersect(&damage).is_empty() - }) - .collect(); - for key in keys { - let new = PageKey { snapshot: target.snapshot, revision: target.revision, ..key }; - if let Some(mut p) = self.cache.remove(&key) { - if p.gpu { - p.gpu = gpu.is_some_and(|g| g.rename_page(key.doc, key.id(), new.id())); - } - self.cache.insert(new, p); - } - } - } - pub fn gpu_keys(&self) -> Vec { - self.cache.keys().map(|k| k.id()).collect() - } - pub fn texture(&self, key: &PageKey) -> Option { - self.cache.get(key)?.texture.as_ref().map(TextureHandle::id) - } - pub fn reset_gpu(&mut self) { - for p in self.cache.values_mut() { - p.gpu = false; - } - } - pub fn update(&mut self, ctx: &egui::Context, doc: Arc, wanted: &[PageKey], display: Option>, options: StreamOptions<'_>) { - let StreamOptions { limit, gpu, refine } = options; - self.clock = self.clock.wrapping_add(1); - let generation = photocraft_raster::spill::read_error_generation(); - let memory_generation = photocraft_raster::memory::configuration_generation(); - if generation != self.health_generation || memory_generation != self.memory_generation { - self.failed.clear(); - self.health_generation = generation; - self.memory_generation = memory_generation; - } - let desired: HashSet = wanted.iter().copied().collect(); - // Keep cancelled workers in the job count until they exit, so rapid pans cannot spawn - // unbounded threads or retain unbounded document snapshots. - for (key, cancel) in &self.jobs { - if !desired.contains(key) && !cancel.swap(true, Ordering::Relaxed) { - self.stats.cancellations += 1; - } - } - while let Ok(c) = self.rx.try_recv() { - let cancelled = self.jobs.remove(&c.key).is_some_and(|c| c.load(Ordering::Relaxed)); - if cancelled || !desired.contains(&c.key) { - continue; - } - match c.result { - Ok((buffer, image)) => { - let rect = buffer.rect; - self.cache.insert(c.key, Page { buffer: Some(buffer), rect, image, texture: None, gpu: false, exact: c.exact, touched: self.clock }); - self.stats.last_error = None; - } - Err(e) => { - self.failed.insert(c.key); - self.stats.last_error = Some(e); - } - } - } - if let Some(g) = gpu { - g.touch_pages(doc.id.0, &wanted.iter().map(|k| k.id()).collect::>()); - self.cache.retain(|k, p| !(desired.contains(k) && p.gpu && !g.has_page(k.doc, k.id()))); - } - // Revision changes invalidate derived pages; master tiles and History remain untouched. - self.cache.retain(|k, _| { - k.doc != doc.id.0 - || wanted.first().is_some_and(|w| (k.snapshot, k.revision, k.preview, k.display) == (w.snapshot, w.revision, w.preview, w.display)) - }); - for key in wanted { - if let Some(p) = self.cache.get_mut(key) { - p.touched = self.clock; - } - } - let mut bytes: u64 = self.cache.values().map(Page::bytes).sum(); - let mut pending = self.jobs.len() as u64 * PAGE_BYTES; - while bytes.saturating_add(pending) > limit || self.cache.len() > MAX_PAGES { - let cold = self - .cache - .iter() - .filter(|(k, _)| !desired.contains(k)) - .min_by_key(|(_, p)| p.touched) - .map(|(k, _)| *k) - .or_else(|| self.cache.iter().min_by_key(|(_, p)| p.touched).map(|(k, _)| *k)); - let Some(key) = cold else { break }; - if let Some(p) = self.cache.remove(&key) { - bytes = bytes.saturating_sub(p.bytes()); - } - } - // First make every visible page available, then refine provisional reduced pages. - let previews_ready = wanted.iter().all(|k| self.cache.contains_key(k)); - for key in wanted { - if self.jobs.len() >= MAX_JOBS { - break; - } - if self.jobs.contains_key(key) || self.failed.contains(key) { - continue; - } - let exact = key.factor == 1 || !photocraft_compose::proxy::proxy_faithful(&doc) || (refine && previews_ready); - if self.cache.get(key).is_some_and(|p| !exact || p.exact) { - continue; - } - // Admission must make room before the budget is exhausted. Otherwise old zooms - // just below the limit block a new visible page forever, even with idle workers. - while bytes.saturating_add(pending).saturating_add(PAGE_BYTES) > limit { - let cold = self.cache.iter().filter(|(k, _)| !desired.contains(k)).min_by_key(|(_, p)| p.touched).map(|(k, _)| *k); - let Some(cold) = cold else { break }; - if let Some(page) = self.cache.remove(&cold) { - bytes = bytes.saturating_sub(page.bytes()); - } - } - if bytes.saturating_add(pending).saturating_add(PAGE_BYTES) > limit { - break; - } - if !self.spawn(ctx, doc.clone(), *key, display.clone(), exact) { - // Another view owns the shared workers. Keep this view runnable even if it - // has no local jobs, and stop probing the pool once per remaining page. - ctx.request_repaint_after(std::time::Duration::from_millis(16)); - break; - } - pending = pending.saturating_add(PAGE_BYTES); - } - self.stats.resident_pages = self.cache.len(); - self.stats.resident_bytes = bytes; - self.stats.active_jobs = self.jobs.len(); - self.stats.visible_pages = wanted.len(); - self.stats.ready_pages = wanted.iter().filter(|key| self.cache.get(key).is_some_and(|p| p.gpu || p.texture.is_some())).count(); - self.stats.pending_bytes = pending; - self.stats.cache_limit = limit; - if !self.jobs.is_empty() { - ctx.request_repaint_after(std::time::Duration::from_millis(16)); - } - } - - fn spawn(&mut self, ctx: &egui::Context, doc: Arc, key: PageKey, display: Option>, exact: bool) -> bool { - if ACTIVE.fetch_update(Ordering::AcqRel, Ordering::Relaxed, |n| (n < MAX_JOBS).then_some(n + 1)).is_err() { - return false; - } - let slot = WorkerSlot; - let cancel = Arc::new(AtomicBool::new(false)); - self.jobs.insert(key, cancel.clone()); - let tx = self.tx.clone(); - let ctx = ctx.clone(); - #[cfg(not(target_arch = "wasm32"))] - { - let result = std::thread::Builder::new().name("pc-viewport-page".into()).spawn(move || { - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| compute(&doc, key, display.as_deref(), exact, &cancel))) - .unwrap_or_else(|_| Err("viewport rendering failed; original document preserved".into())); - let _ = tx.try_send(Completed { key, exact, result }); - // Release capacity before waking any view waiting for it. - drop(slot); - ctx.request_repaint(); - }); - if let Err(e) = result { - self.jobs.remove(&key); - self.failed.insert(key); - self.stats.last_error = Some(e.to_string()); - return false; - } - } - #[cfg(target_arch = "wasm32")] - { - let result = compute(&doc, key, display.as_deref(), exact, &cancel); - let _ = tx.try_send(Completed { key, exact, result }); - drop(slot); - ctx.request_repaint(); - } - true - } - - /// Upload at most two pages per frame; all disk reads and colour transforms ran on workers. - pub fn upload(&mut self, ctx: &egui::Context, doc: &Document, wanted: &[PageKey], gpu: Option<&GpuCanvas>, limit: u64, encode_srgb: bool) { - let mut n = 0; - for key in wanted { - let Some(page) = self.cache.get_mut(key) else { continue }; - if let Some(g) = gpu { - if !page.gpu && n < 2 { - page.gpu = page.buffer.as_ref().is_some_and(|b| { - g.upload_page( - crate::gpu_canvas::PageUpload { - key: doc.id.0, - page: key.id(), - size: [doc.size.width, doc.size.height], - factor: key.factor, - depth: doc.depth, - limit, - core: key.core(doc), - }, - b, - ) - }); - if page.gpu { - if key.factor == 1 && g.composite_resident(doc, key.source(doc), encode_srgb).is_ok() { - self.stats.gpu_composites += 1; - } - self.stats.uploads += 1; - n += 1; - page.buffer = None; - } - } - } else if page.texture.is_none() && n < 2 { - page.texture = Some(ctx.load_texture(format!("page-{}", key.id()), page.image.clone(), TextureOptions::LINEAR)); - self.stats.uploads += 1; - n += 1; - page.buffer = None; - } - } - if n > 0 { - ctx.request_repaint(); - } - self.stats.resident_bytes = self.cache.values().map(Page::bytes).sum(); - self.stats.ready_pages = wanted.iter().filter(|key| self.cache.get(key).is_some_and(|p| p.gpu || p.texture.is_some())).count(); - } - - pub fn paint_cpu(&self, painter: &egui::Painter, xf: &crate::canvas::ViewXform, doc: &Document, wanted: &[PageKey]) { - for key in wanted { - let Some(p) = self.cache.get(key) else { continue }; - let Some(t) = &p.texture else { continue }; - let core = key.core(doc); - let full = Rect::from_xywh(p.rect.x0 * key.factor as i32, p.rect.y0 * key.factor as i32, p.rect.width() * key.factor, p.rect.height() * key.factor); - let uv = egui::Rect::from_min_max( - egui::pos2((core.x0 - full.x0) as f32 / full.width() as f32, (core.y0 - full.y0) as f32 / full.height() as f32), - egui::pos2((core.x1 - full.x0) as f32 / full.width() as f32, (core.y1 - full.y0) as f32 / full.height() as f32), - ); - let uv = if xf.flip { egui::Rect::from_min_max(egui::pos2(uv.max.x, uv.min.y), egui::pos2(uv.min.x, uv.max.y)) } else { uv }; - painter.image(t.id(), xf.doc_rect(core), uv, Color32::WHITE); - } - } -} - -pub struct StreamOptions<'a> { - pub limit: u64, - pub gpu: Option<&'a GpuCanvas>, - pub refine: bool, -} - -#[cfg(test)] -mod tests { - use super::*; - use photocraft_color::{ColorMode, SampleType}; - #[test] - fn page_count_depends_on_view_not_document_size() { - let d = Document::new("Earth", photocraft_doc::Size::new(86400, 43200), ColorMode::Rgb, SampleType::U8); - let p = pages(&d, Rect::new(64000, 22000, 66048, 23080), 1.0, 1, 0, 0); - assert!(p.len() <= 20); - assert!(p.iter().all(|p| p.factor == 1)); - assert!(pages(&d, d.bounds(), 0.01, 1, 0, 0).len() <= MAX_PAGES); - assert_eq!(factor(f32::NAN), 1); - } - #[test] - fn neighboring_pages_have_matching_border_footprints() { - let d = Document::new("d", photocraft_doc::Size::new(5000, 3000), ColorMode::Rgb, SampleType::U8); - let p = pages(&d, d.bounds(), 0.5, 1, 0, 0); - assert!(p.iter().all(|p| p.source(&d).width() <= 514 * p.factor)); - assert!(p.iter().all(|p| p.source(&d).x0 % p.factor as i32 == 0)); - } - - #[test] - fn local_damage_reuses_only_unaffected_pages() { - let d = Document::new("d", photocraft_doc::Size::new(2048, 512), ColorMode::Rgb, SampleType::U8); - let old = pages(&d, d.bounds(), 1.0, 1, 0, 0); - let mut s = Stream::default(); - for key in &old { - s.cache.insert( - *key, - Page { - buffer: None, - rect: key.core(&d), - image: egui::ColorImage::filled([1, 1], Color32::RED), - texture: None, - gpu: false, - exact: true, - touched: 0, - }, - ); - } - let target = PageKey { revision: 2, snapshot: 123, ..old[0] }; - let damage = Rect::new(10, 10, 20, 20); - s.rebase_damage(&d, target, damage, None); - assert_eq!(s.cache.keys().filter(|k| k.revision == 2).count(), 3); - assert!(s.cache.keys().filter(|k| k.revision == 2).all(|k| k.source(&d).intersect(&damage).is_empty())); - assert_eq!(PageKey { x: u32::MAX, ..target }.core(&d), Rect::EMPTY); - } - - #[test] - fn cold_zoom_pages_cannot_block_new_visible_pages() { - let doc = Arc::new(Document::new("tabs", photocraft_doc::Size::new(8192, 4096), ColorMode::Rgb, SampleType::F32)); - let ctx = egui::Context::default(); - let mut stream = Stream::default(); - let old = pages(&doc, doc.bounds(), 0.5, 1, 0, 0); - for key in old.iter().take(8) { - stream.cache.insert( - *key, - Page { - buffer: None, - rect: key.core(&doc), - image: egui::ColorImage::filled([514, 514], Color32::BLACK), - texture: None, - gpu: false, - exact: true, - touched: 0, - }, - ); - } - let wanted = pages(&doc, Rect::new(0, 0, 512, 512), 1.0, 1, 0, 0); - let limit = 12 << 20; - assert!(stream.cache.values().map(Page::bytes).sum::() < limit); - stream.update(&ctx, doc.clone(), &wanted, None, StreamOptions { limit, gpu: None, refine: true }); - assert!(stream.cache.len() < 8, "evict cold zoom pages before admitting the visible page, even when another view owns the workers"); - for _ in 0..1000 { - stream.update(&ctx, doc.clone(), &wanted, None, StreamOptions { limit, gpu: None, refine: true }); - stream.upload(&ctx, &doc, &wanted, None, 0, false); - if wanted.iter().all(|k| stream.texture(k).is_some()) && stream.jobs.is_empty() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } - assert!(wanted.iter().all(|k| stream.texture(k).is_some())); - assert!(stream.stats.resident_bytes <= limit); - } - - #[test] - fn pending_pages_reserve_cache_capacity_until_they_are_consumed() { - let doc = Arc::new(Document::new("pending", photocraft_doc::Size::new(8192, 4096), ColorMode::Rgb, SampleType::F32)); - let ctx = egui::Context::default(); - let mut stream = Stream::default(); - let wanted = pages(&doc, Rect::new(0, 0, 1024, 512), 1.0, 1, 0, 0); - // A completed inactive-view job still owns its queued result until update consumes it. - stream.jobs.insert(wanted[0], Arc::new(AtomicBool::new(false))); - let limit = PAGE_BYTES + 1; - stream.update(&ctx, doc.clone(), &wanted, None, StreamOptions { limit, gpu: None, refine: true }); - assert_eq!(stream.jobs.len(), 1, "the second result would exceed the shared view capacity"); - assert_eq!(stream.stats.pending_bytes, PAGE_BYTES); - assert!(stream.stats.resident_bytes + stream.stats.pending_bytes <= limit); - } - - #[test] - fn asynchronous_cpu_pages_preserve_source_pixels_and_discard_obsolete_work() { - let mut d = Document::new("large", photocraft_doc::Size::new(86400, 43200), ColorMode::Rgb, SampleType::U8); - let mut surface = photocraft_raster::Surface::new(d.pixel_format()); - for x in 65000..65080 { - surface.fill_rect(Rect::new(x, 22000, x + 1, 22080), &[if x % 2 == 0 { 1.0 } else { 0.0 }, 0.5, 0.25, 1.0]); - } - d.layers.push(photocraft_doc::Layer::new("detail", photocraft_doc::LayerContent::Raster(surface))); - let doc = Arc::new(d); - let ctx = egui::Context::default(); - let mut s = Stream::default(); - let old = pages(&doc, Rect::new(10, 10, 20, 20), 1.0, 0, 0, 0); - let wanted = pages(&doc, Rect::new(65000, 22000, 65080, 22080), 2.0, 1, 0, 0); - let limit = 12 << 20; - s.update(&ctx, doc.clone(), &old, None, StreamOptions { limit, gpu: None, refine: true }); - for _ in 0..1000 { - s.update(&ctx, doc.clone(), &wanted, None, StreamOptions { limit, gpu: None, refine: true }); - s.upload(&ctx, &doc, &wanted, None, 0, false); - assert!(s.stats.active_jobs <= MAX_JOBS); - assert!(s.stats.resident_bytes <= limit); - if wanted.iter().all(|k| s.texture(k).is_some()) && s.jobs.is_empty() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } - assert!(s.stats.cancellations > 0); - assert!(s.stats.last_error.is_none(), "{:?}", s.stats.last_error); - assert!(wanted.iter().all(|k| s.texture(k).is_some())); - assert!(s.cache.keys().all(|k| k.revision == 1 && k.factor == 1)); - for key in wanted { - let p = &s.cache[&key]; - let reference = photocraft_compose::render(&doc, p.rect).to_rgba8(); - assert_eq!(p.image, egui::ColorImage::from_rgba_unmultiplied([reference.width as usize, reference.height as usize], &reference.pixels)); - assert!(p.buffer.is_none(), "upload must release the float buffer"); - } - } -} diff --git a/crates/ui-egui/src/gpu_status.rs b/crates/ui-egui/src/gpu_status.rs index 75f1a05e85..ca832645b7 100644 --- a/crates/ui-egui/src/gpu_status.rs +++ b/crates/ui-egui/src/gpu_status.rs @@ -154,6 +154,8 @@ pub fn fall_back(app: &mut PhotocraftApp, fault: &photocraft_gpu::Fault) { app.gpu_fault = Some(fault.clone()); // Canvas caches pointed at GPU textures: rebuild them as egui textures. app.canvases.clear(); + // Viewport pages lived on the GPU too: their work stops, and the CPU canvas draws instead. + app.streams.clear(); app.proxy_uploaded = None; app.prefs_rt.gpu_style = None; app.perf.gpu = false; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8165b8a41d..87f7ddf100 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -350,6 +350,8 @@ pub struct PhotocraftApp { navigator_gpu: HashMap, /// Documents whose GPU canvas texture holds the plain document (`canvas::ensure_gpu`). navigator_plain: std::collections::HashSet, + /// Viewport pages of documents too large for their GPU texture set (`canvas::viewport_pages`). + streams: HashMap, /// The Info panel's Color Sampler readouts for (document, revision) (`panels::sampler_readouts`). info_samplers: Option, /// Display profile readings (#569). From 0fd110e15027533b04382fd77e94a8048715b3fc Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:41:04 +0100 Subject: [PATCH 204/216] Free the texture set a document leaves for viewport pages; cargo fmt A document that goes over the canvas budget kept its texture set (and the compositor's pages for it) resident while drawn from viewport pages, holding the memory the pages are budgeted against. texture_key is needed outside tests now; fetch_update is deprecated. --- crates/compose/src/lib.rs | 2 +- crates/ui-egui/src/canvas.rs | 1 - crates/ui-egui/src/canvas/gpu_canvas/pages.rs | 18 +++++++++-- crates/ui-egui/src/canvas/viewport_pages.rs | 30 +++++++++++++++---- 4 files changed, 40 insertions(+), 11 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 825a0d6190..2af1c2e8fb 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -32,8 +32,8 @@ pub mod pattern; pub mod proxy; pub mod psblend; pub mod shape_split; -pub mod viewport; mod uniform; +pub mod viewport; use photocraft_color::blend::BlendMode; use photocraft_doc::{Document, Fill, Layer, LayerContent, Pattern}; diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 388ee633ce..5c20d57f83 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -66,7 +66,6 @@ pub(crate) use cursors::{alt_picks, alt_samples, composite_color, paint_bucket_c use cursors::{brush_tip_centre, eyedropper_ring, precise_other_cursor, visible_crosshair}; #[cfg(test)] pub(crate) use display::GPU_OUTPUT; -#[cfg(test)] use display::texture_key; pub(crate) use display::{cache_key, canvas_display, display_doc, display_image}; use display::{checker, hdr_preview, sync_display_lut}; diff --git a/crates/ui-egui/src/canvas/gpu_canvas/pages.rs b/crates/ui-egui/src/canvas/gpu_canvas/pages.rs index b4e60d92d5..8afef3f570 100644 --- a/crates/ui-egui/src/canvas/gpu_canvas/pages.rs +++ b/crates/ui-egui/src/canvas/gpu_canvas/pages.rs @@ -72,7 +72,8 @@ impl GpuCanvas { } let Some(d) = res.docs.get_mut(&key) else { return false }; let replaced = d.tiles.iter().position(|t| t.page.is_some_and(|(id, _)| id == page)); - let held = d.page_bytes() - replaced.map_or(0, |i| u64::from(d.tiles[i].texture.width()) * u64::from(d.tiles[i].texture.height()) * texel_bytes(format)); + let held = + d.page_bytes() - replaced.map_or(0, |i| u64::from(d.tiles[i].texture.width()) * u64::from(d.tiles[i].texture.height()) * texel_bytes(format)); if held.saturating_add(bytes) > budget { return false; } @@ -88,7 +89,8 @@ impl GpuCanvas { }); let view = texture.create_view(&Default::default()); let rect = [x, y, dw, dh]; - let uniform = init_buffer(device, "pc_viewport_page", &f32_bytes(&tile_uniform(rect, Some([core.x0, core.y0, core.x1, core.y1]))), wgpu::BufferUsages::UNIFORM); + let uniform = + init_buffer(device, "pc_viewport_page", &f32_bytes(&tile_uniform(rect, Some([core.x0, core.y0, core.x1, core.y1]))), wgpu::BufferUsages::UNIFORM); let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("pc_viewport_page"), layout: &res.tile_bgl, @@ -125,6 +127,17 @@ impl GpuCanvas { } } + /// Free document `key`'s texture set and what the compositor holds for it, once it is drawn + /// from pages instead: memory the pages are budgeted against. + pub fn release_texture_set(&self, key: u64) { + let mut renderer = self.rs.renderer.write(); + let Some(res) = renderer.callback_resources.get_mut::() else { return }; + res.docs.remove(&key); + if let Some(c) = res.compositor.as_mut() { + c.forget_doc(photocraft_doc::DocId(key)); + } + } + /// The pages of `key` the GPU holds (page ids), and the bytes they take. pub fn pages(&self, key: u64) -> (Vec, u64) { let r = self.rs.renderer.read(); @@ -137,4 +150,3 @@ impl GpuCanvas { self.canvas_budget.load(Ordering::Relaxed) } } - diff --git a/crates/ui-egui/src/canvas/viewport_pages.rs b/crates/ui-egui/src/canvas/viewport_pages.rs index a29b990452..68a9266d96 100644 --- a/crates/ui-egui/src/canvas/viewport_pages.rs +++ b/crates/ui-egui/src/canvas/viewport_pages.rs @@ -467,7 +467,8 @@ fn spawn( (page, epoch, exact): (PageId, u64, bool), cancel: Arc, ) -> bool { - if ACTIVE.fetch_update(Ordering::AcqRel, Ordering::Relaxed, |n| (n < MAX_JOBS).then_some(n + 1)).is_err() { + if ACTIVE.fetch_add(1, Ordering::AcqRel) >= MAX_JOBS { + ACTIVE.fetch_sub(1, Ordering::AcqRel); return false; } struct Slot(egui::Context); @@ -522,9 +523,12 @@ pub(super) fn show(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, xf: if app.ui.view.pattern_preview || gpu.fault().is_some() { return false; } - let Some((revision, last_damage, id, size)) = app.session.documents().get(idx).map(|st| { - (st.revision, st.last_damage.map(|r| if r.is_empty() { r } else { r.inflate(effect_reach(&st.doc.layers)) }), st.doc.id, st.doc.size) - }) else { + let Some((revision, last_damage, id, size)) = app + .session + .documents() + .get(idx) + .map(|st| (st.revision, st.last_damage.map(|r| if r.is_empty() { r } else { r.inflate(effect_reach(&st.doc.layers)) }), st.doc.id, st.doc.size)) + else { return false; }; let size = [size.width, size.height]; @@ -542,7 +546,9 @@ pub(super) fn show(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, xf: let now = Identity { revision, preview: raw_key, display: display_key, size }; let seen = app.streams.get(&id).and_then(|s| s.seen); if seen != Some(now) { - let damage = seen.filter(|s| s.size == size && s.display == display_key).and_then(|s| damage_since(app, idx, (s.revision, s.preview ^ display_key), (revision, raw_key), display_key, last_damage)); + let damage = seen + .filter(|s| s.size == size && s.display == display_key) + .and_then(|s| damage_since(app, idx, (s.revision, s.preview ^ display_key), (revision, raw_key), display_key, last_damage)); app.streams.entry(id).or_default().invalidate(now, damage); } let key = stream_key(id.0); @@ -551,6 +557,10 @@ pub(super) fn show(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, xf: let page_bytes = gpu.page_texture_bytes(doc.depth, size, PAGE_SIDE + 2, PAGE_SIDE + 2).max(1); let max_pages = usize::try_from(limit / page_bytes).unwrap_or(usize::MAX); let wanted = pages(size, visible_doc_rect(xf), zoom, max_pages); + if gpu.texture_info(id.0).is_some() { + // Switching from the texture set: free it, the pages are budgeted against that memory. + gpu.release_texture_set(id.0); + } let stream = app.streams.entry(id).or_default(); stream.retry_after((photocraft_raster::spill::read_error_generation(), photocraft_raster::memory::configuration_generation())); // No room for a single page, or a page that failed: the CPU canvas draws the document, as @@ -583,7 +593,15 @@ pub(super) fn show(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, xf: if evicted { gpu.retain_pages(key, &stream.ids()); } - gpu.upload_page(crate::gpu_canvas::PageUpload { key, page: d.page.id(), size, depth: doc.depth, factor: d.page.factor, core: d.page.core(size), buf }) + gpu.upload_page(crate::gpu_canvas::PageUpload { + key, + page: d.page.id(), + size, + depth: doc.depth, + factor: d.page.factor, + core: d.page.core(size), + buf, + }) } }; stream.uploaded(&d, bytes, ok); From 6efb0682b2cb6e54f8fa08c2d7b63c1932a34c89 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Tue, 3 Nov 2026 11:16:05 +0100 Subject: [PATCH 205/216] Re-render only the texels a small edit reaches into the viewport page held --- .../src/canvas/background_redraw/tests.rs | 14 ++ crates/ui-egui/src/canvas/gpu_canvas/pages.rs | 31 ++++ crates/ui-egui/src/canvas/viewport_pages.rs | 153 +++++++++++++++--- 3 files changed, 178 insertions(+), 20 deletions(-) diff --git a/crates/ui-egui/src/canvas/background_redraw/tests.rs b/crates/ui-egui/src/canvas/background_redraw/tests.rs index 5cb8f6ac5e..a5397c77dd 100644 --- a/crates/ui-egui/src/canvas/background_redraw/tests.rs +++ b/crates/ui-egui/src/canvas/background_redraw/tests.rs @@ -669,6 +669,20 @@ fn a_canvas_over_the_texture_budget_draws_its_viewport_pages_on_the_gpu() { assert!(stats.dirtied > 0 && stats.uploads > uploads, "{stats:?}"); let filled = centre_pixel(&mut g); assert!(filled[0] > 0xb0 && filled[2] < 0x60, "the fill is shown: {filled:?}"); + // A small edit re-renders only the part of each page it reaches, into the page held. + let (held, partial) = (gpu.pages(key).0.len(), stats.partial); + // (A frame between the steps, as in use: the edit's damage is known one revision at a time.) + g.harness.state_mut().run("select.rect", json!({"x": 1400, "y": 900, "width": 200, "height": 200})).expect("select"); + settle_pages(&mut g); + g.harness.state_mut().run("edit.fill", json!({"color": "#22aa22"})).expect("small fill"); + settle_pages(&mut g); + let stats = g.harness.state().streams[&doc.id].stats.clone(); + assert!(stats.partial > partial, "{stats:?}"); + assert_eq!(gpu.pages(key).0.len(), held, "no new page textures"); + // Just its square is shown: 200 px at about 20% zoom, some 40 screen pixels a side. + let img = g.harness.render().expect("render"); + let green = img.pixels().filter(|p| p.0[1] > 0x90 && p.0[0] < 0x50 && p.0[2] < 0x50).count(); + assert!((1000..2500).contains(&green), "the small fill is shown: {green} pixels"); // A budget too small for one page draws it on the CPU canvas, as before. gpu.set_canvas_budget(1); g.harness.run_steps(3); diff --git a/crates/ui-egui/src/canvas/gpu_canvas/pages.rs b/crates/ui-egui/src/canvas/gpu_canvas/pages.rs index 8afef3f570..b5f9fe7e7d 100644 --- a/crates/ui-egui/src/canvas/gpu_canvas/pages.rs +++ b/crates/ui-egui/src/canvas/gpu_canvas/pages.rs @@ -40,6 +40,37 @@ impl GpuCanvas { /// Store (or replace) page `page` of `key`. False, with nothing changed, when the device is /// not healthy, the page is malformed, or the pages of `key` would exceed the canvas budget /// (the caller evicts its cold pages first; this is the backstop). + /// Writes `buf` (texels at the page's reduction, a part of it) into the page `page` of `key` + /// already resident: an edit's re-render, without a new texture. False when that page isn't + /// held or `buf` falls outside it. + pub fn update_page(&self, key: u64, page: u64, buf: &photocraft_compose::Buffer) -> bool { + let (w, h) = (buf.rect.width(), buf.rect.height()); + if !self.health.is_ok() || w == 0 || h == 0 || buf.px.len() as u64 != u64::from(w) * u64::from(h) { + return false; + } + let renderer = self.rs.renderer.read(); + let Some(res) = renderer.callback_resources.get::() else { return false }; + let Some(tile) = res.docs.get(&key).and_then(|d| d.tiles.iter().find(|t| t.page.is_some_and(|(id, _)| id == page))) else { + return false; + }; + let Some((_, factor)) = tile.page.filter(|(_, f)| *f > 0) else { return false }; + // The texture's first texel, at the page's reduction. + let (ox, oy) = (i64::from(tile.rect[0] / factor), i64::from(tile.rect[1] / factor)); + let (x, y) = (i64::from(buf.rect.x0) - ox, i64::from(buf.rect.y0) - oy); + let texture = &tile.texture; + if x < 0 || y < 0 || x + i64::from(w) > i64::from(texture.width()) || y + i64::from(h) > i64::from(texture.height()) { + return false; + } + let format = texture.format(); + self.rs.queue.write_texture( + wgpu::TexelCopyTextureInfo { texture, mip_level: 0, origin: wgpu::Origin3d { x: x as u32, y: y as u32, z: 0 }, aspect: wgpu::TextureAspect::All }, + &texels(format, &buf.px), + wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(w * texel_bytes(format) as u32), rows_per_image: Some(h) }, + wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, + ); + true + } + pub fn upload_page(&self, p: PageUpload<'_>) -> bool { let PageUpload { key, page, size, depth, factor, core, buf } = p; let (w, h) = (buf.rect.width(), buf.rect.height()); diff --git a/crates/ui-egui/src/canvas/viewport_pages.rs b/crates/ui-egui/src/canvas/viewport_pages.rs index 68a9266d96..72483f9c0e 100644 --- a/crates/ui-egui/src/canvas/viewport_pages.rs +++ b/crates/ui-egui/src/canvas/viewport_pages.rs @@ -7,7 +7,8 @@ //! Documents that fit keep the texture set and everything around it (the layer budget, deferred //! refresh, background redraw). Without a GPU canvas the CPU canvas draws every document. //! -//! Pages are derived data: an edit marks the pages its damage reaches dirty (the rest stay), a +//! Pages are derived data: an edit marks the pages its damage reaches dirty (the rest stay) and, +//! for a small one, re-renders just the texels it reaches into the page held; a //! dirty page stays on screen until its replacement is uploaded, and work for pages that are no //! longer wanted (a pan, a zoom, an edit) is cancelled. @@ -93,6 +94,19 @@ pub(crate) fn factor(zoom: f32) -> u32 { k } +/// The part of `page` a change to `damage` reaches, widened to whole texels: rendered from just +/// that area, its texels are the ones the whole page has (reductions are globally aligned). +fn partial_region(page: PageId, size: [u32; 2], damage: DRect) -> DRect { + let k = page.factor.max(1) as i32; + let source = page.source(size); + let d = damage.intersect(&source); + if d.is_empty() { + return DRect::EMPTY; + } + let up = |v: i32| v.div_euclid(k).saturating_add(i32::from(v.rem_euclid(k) != 0)).saturating_mul(k); + DRect::new(d.x0.div_euclid(k) * k, d.y0.div_euclid(k) * k, up(d.x1), up(d.y1)).intersect(&source) +} + /// The pages covering `visible` of a `size` document at `zoom`, centre first: at most /// `max_pages` (coarser when the view needs more). pub(crate) fn pages(size: [u32; 2], visible: DRect, zoom: f32, max_pages: usize) -> Vec { @@ -135,6 +149,9 @@ struct Page { dirty: bool, /// The exact reduction (not the quick preview). exact: bool, + /// While dirty, the only document area that changed since it was uploaded (aligned to its + /// texels): just that part is rendered again and written into the page. `None`: all of it. + partial: Option, /// Bumped whenever the page's content is invalidated: a result from older work is dropped. epoch: u64, touched: u64, @@ -152,6 +169,8 @@ pub(crate) struct Done { pub page: PageId, pub epoch: u64, pub exact: bool, + /// Rendered for [`Page::partial`]: written into the page held, not a page of its own. + pub region: Option, pub result: Result, } @@ -169,6 +188,8 @@ pub(crate) struct Stats { pub uploads: u64, pub cancelled: u64, pub dirtied: u64, + /// Pages updated in part, by re-rendering only what an edit reached. + pub partial: u64, pub evicted: u64, pub last_error: Option, } @@ -248,8 +269,16 @@ impl Stream { let pages: Vec = self.pages.keys().copied().filter(|p| hit(*p)).collect(); for p in &pages { if let Some(page) = self.pages.get_mut(p) { + // An exact page that is current, or only partly stale, takes the new damage too. + let partial = damage.filter(|_| page.exact && (!page.dirty || page.partial.is_some())).and_then(|d| { + let r = page.partial.unwrap_or(DRect::EMPTY).union(&partial_region(*p, now.size, d)); + let source = p.source(now.size); + // Most of the page: rendering it whole is no slower. + (r.width() as u64 * r.height() as u64 * 2 <= source.width() as u64 * source.height() as u64).then_some(r) + }); page.dirty = true; - page.exact = false; + page.exact &= partial.is_some(); + page.partial = partial; } self.bump(*p); } @@ -290,8 +319,9 @@ impl Stream { /// Take finished work, cancel work for pages not `wanted`, and start work for wanted pages /// that are missing, dirty or still a quick preview (`faithful`: the document has a quick - /// preview at reductions), as `spawn(page, epoch, exact, cancel)` allows. - pub fn update(&mut self, wanted: &[PageId], faithful: bool, mut spawn: impl FnMut(PageId, u64, bool, Arc) -> bool) { + /// preview at reductions), as `spawn(page, epoch, exact, cancel, region)` allows: `region`, the + /// part of a held page to render again ([`Page::partial`]), or the whole page. + pub fn update(&mut self, wanted: &[PageId], faithful: bool, mut spawn: impl FnMut(PageId, u64, bool, Arc, Option) -> bool) { self.clock += 1; let want: HashSet = wanted.iter().copied().collect(); while let Ok(d) = self.rx.try_recv() { @@ -301,6 +331,10 @@ impl Stream { if cancelled || d.epoch != self.epoch(d.page) || !want.contains(&d.page) { continue; } + // A part of a page no longer held (evicted meanwhile): the whole page is asked for. + if d.region.is_some() && !self.pages.get(&d.page).is_some_and(|q| q.dirty && q.partial == d.region) { + continue; + } match &d.result { Ok(_) => { self.stats.rendered += 1; @@ -342,8 +376,9 @@ impl Stream { if have.is_some_and(|e| e || !exact) { continue; } + let region = self.pages.get(p).filter(|q| q.dirty).and_then(|q| q.partial); let cancel = Arc::new(AtomicBool::new(false)); - if !spawn(*p, epoch, exact, cancel.clone()) { + if !spawn(*p, epoch, exact || region.is_some(), cancel.clone(), region) { break; } self.jobs.push(Job { page: *p, epoch, cancel }); @@ -424,7 +459,20 @@ impl Stream { } self.stats.uploads += 1; self.epochs.remove(&done.page); - self.pages.insert(done.page, Page { dirty: false, exact: done.exact, epoch: done.epoch, touched: self.clock, bytes }); + if done.region.is_some() + && let Some(page) = self.pages.get_mut(&done.page) + { + (page.dirty, page.partial, page.epoch, page.touched) = (false, None, done.epoch, self.clock); + self.stats.partial += 1; + return; + } + self.pages.insert(done.page, Page { dirty: false, exact: done.exact, partial: None, epoch: done.epoch, touched: self.clock, bytes }); + } + + /// The part of `page` written by `done` couldn't be stored: drop the page, so it is + /// rendered whole. + pub fn partial_failed(&mut self, page: PageId) { + self.drop_page(page); } /// Drop pages no longer worth keeping: once every wanted page is shown and current, the @@ -464,7 +512,7 @@ fn spawn( tx: mpsc::Sender, doc: Arc, display: Option>, - (page, epoch, exact): (PageId, u64, bool), + (page, epoch, exact, region): (PageId, u64, bool, Option), cancel: Arc, ) -> bool { if ACTIVE.fetch_add(1, Ordering::AcqRel) >= MAX_JOBS { @@ -482,9 +530,9 @@ fn spawn( let slot = Slot(ctx.clone()); let work = move || { let _slot = slot; - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| render(&doc, page, exact, display.as_deref(), &cancel))) + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| render(&doc, page, exact, region, display.as_deref(), &cancel))) .unwrap_or_else(|_| Err("viewport page render panicked".into())); - let _ = tx.send(Done { page, epoch, exact, result }); + let _ = tx.send(Done { page, epoch, exact, region, result }); }; #[cfg(not(target_arch = "wasm32"))] { @@ -498,9 +546,16 @@ fn spawn( } /// One page as the GPU canvas stores it (encoded for the display like the texture set). -fn render(doc: &Document, page: PageId, exact: bool, display: Option<&CanvasDisplay>, cancel: &AtomicBool) -> Result { +fn render( + doc: &Document, + page: PageId, + exact: bool, + region: Option, + display: Option<&CanvasDisplay>, + cancel: &AtomicBool, +) -> Result { let size = [doc.size.width, doc.size.height]; - let source = page.source(size); + let source = region.unwrap_or_else(|| page.source(size)); let rows = u64::from(source.height().min(128)); let _working = photocraft_raster::memory::try_reserve(u64::from(source.width()) * rows * 16 + (16 << 20))?; let before = photocraft_raster::spill::read_error_generation(); @@ -583,9 +638,20 @@ pub(super) fn show(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, xf: crate::render_path::note(app, id.0, crate::render_path::Path::Gpu { full: true }); let stream = app.streams.entry(id).or_default(); let tx = stream.tx.clone(); - stream.update(&wanted, faithful, |page, epoch, exact, cancel| spawn(ctx, tx.clone(), doc.clone(), display.clone(), (page, epoch, exact), cancel)); + stream.update(&wanted, faithful, |page, epoch, exact, cancel, region| { + spawn(ctx, tx.clone(), doc.clone(), display.clone(), (page, epoch, exact, region), cancel) + }); for d in stream.take_uploads(&wanted, texel_bytes) { let Ok(buf) = &d.result else { continue }; + if d.region.is_some() { + if gpu.update_page(key, d.page.id(), buf) { + stream.uploaded(&d, 0, true); + } else { + stream.partial_failed(d.page); + gpu.retain_pages(key, &stream.ids()); + } + continue; + } let bytes = buf.px.len() as u64 * texel_bytes; let ok = match stream.make_room(d.page, bytes, limit, &wanted) { None => false, @@ -632,7 +698,7 @@ mod tests { fn done(p: PageId, epoch: u64, exact: bool, side: i32) -> Done { let rect = DRect::new(0, 0, side, side); - Done { page: p, epoch, exact, result: Ok(photocraft_compose::Buffer { rect, px: vec![[0.0; 4]; (side * side) as usize] }) } + Done { page: p, epoch, exact, region: None, result: Ok(photocraft_compose::Buffer { rect, px: vec![[0.0; 4]; (side * side) as usize] }) } } /// A stream holding `held` clean, exact pages. @@ -646,8 +712,8 @@ mod tests { } /// Spawns every job it is offered, recording it with its cancel flag. - fn recorder(log: &mut Vec<(PageId, u64, bool, Arc)>) -> impl FnMut(PageId, u64, bool, Arc) -> bool + '_ { - |p, e, x, c| { + fn recorder(log: &mut Vec<(PageId, u64, bool, Arc)>) -> impl FnMut(PageId, u64, bool, Arc, Option) -> bool + '_ { + |p, e, x, c, _| { log.push((p, e, x, c)); true } @@ -733,16 +799,16 @@ mod tests { s.invalidate(id(2), Some(DRect::new(600, 0, 601, 1))); assert!(log[1].3.load(Ordering::Relaxed), "work on an edited page is cancelled"); // Its old result is dropped when it arrives, and so is a cancelled job's interruption. - s.tx.send(Done { page: log[0].0, epoch: log[0].1, exact: true, result: Err("interrupted".into()) }).unwrap(); + s.tx.send(Done { page: log[0].0, epoch: log[0].1, exact: true, region: None, result: Err("interrupted".into()) }).unwrap(); s.tx.send(done(log[1].0, log[1].1, true, 4)).unwrap(); - s.update(&view[1..], true, |_, _, _, _| false); + s.update(&view[1..], true, |_, _, _, _, _| false); assert!(s.ready.is_empty(), "stale result dropped"); assert!(!s.stuck(&view) && s.stats.last_error.is_none(), "a cancellation is not a failure"); // Zoom: every page changes factor. let mut log2 = Vec::new(); s.update(&[page(1, 1, 0)], true, recorder(&mut log2)); let jobs = s.jobs.len(); - s.update(&[page(2, 0, 0)], true, |_, _, _, _| true); + s.update(&[page(2, 0, 0)], true, |_, _, _, _, _| true); assert!(log2.iter().all(|j| j.3.load(Ordering::Relaxed)), "zoom cancels the old factor's work"); assert!(s.stats.cancelled >= 3 && jobs > 0); } @@ -764,6 +830,53 @@ mod tests { assert!(log3[0].2, "an unfaithful preview is skipped"); } + #[test] + fn a_small_edit_re_renders_only_the_texels_it_reaches() { + let (a, b) = (page(4, 1, 1), page(4, 3, 3)); + let mut s = holding(&[a, b]); + // Page a: core 2048..4096 at 4 px per texel, source one texel more. + s.invalidate(id(2), Some(DRect::new(2101, 2102, 2110, 2111))); + assert_eq!(s.pages[&a].partial, Some(DRect::new(2100, 2100, 2112, 2112)), "whole texels"); + assert!(s.pages[&a].dirty && s.pages[&a].exact); + // More damage on a dirty page adds to its part. + s.invalidate(id(3), Some(DRect::new(2200, 2100, 2204, 2104))); + assert_eq!(s.pages[&a].partial, Some(DRect::new(2100, 2100, 2204, 2112))); + let mut regions = Vec::new(); + s.update(&[a], true, |p, e, x, _, r| { + regions.push((p, e, x, r)); + true + }); + assert_eq!(regions, vec![(a, s.epoch(a), true, s.pages[&a].partial)]); + // The part is written into the page held: same page, same bytes, current again. + let mut d = done(a, regions[0].1, true, 4); + d.region = regions[0].3; + s.tx.send(d).unwrap(); + s.update(&[a], true, |_, _, _, _, _| panic!("nothing left to render")); + let up = s.take_uploads(&[a], 16); + assert_eq!(up.len(), 1); + s.uploaded(&up[0], 0, true); + assert!(!s.pages[&a].dirty && s.pages[&a].partial.is_none() && s.pages[&a].bytes == 100); + assert_eq!(s.stats.partial, 1); + // Most of a page, or damage of unknown extent, renders it whole. + s.invalidate(id(4), Some(DRect::new(2048, 2048, 4000, 4000))); + assert!(s.pages[&a].dirty && s.pages[&a].partial.is_none() && !s.pages[&a].exact); + s.invalidate(id(5), None); + assert!(s.pages[&b].partial.is_none()); + // A part of a page evicted meanwhile is dropped: the whole page is asked for. + let mut s = holding(&[a]); + s.invalidate(id(2), Some(DRect::new(2101, 2102, 2110, 2111))); + let region = s.pages[&a].partial; + let epoch = s.epoch(a); + s.partial_failed(a); + let mut d = done(a, epoch, true, 4); + d.region = region; + s.tx.send(d).unwrap(); + let mut log = Vec::new(); + s.update(&[a], true, recorder(&mut log)); + assert!(s.ready.is_empty()); + assert_eq!(log.len(), 1, "the whole page"); + } + #[test] fn uploads_per_frame_are_bounded() { let mut s = holding(&[]); @@ -803,8 +916,8 @@ mod tests { let view = [page(1, 0, 0)]; let mut log = Vec::new(); s.update(&view, true, recorder(&mut log)); - s.tx.send(Done { page: view[0], epoch: log[0].1, exact: true, result: Err("out of memory".into()) }).unwrap(); - s.update(&view, true, |_, _, _, _| panic!("no retry while failed")); + s.tx.send(Done { page: view[0], epoch: log[0].1, exact: true, region: None, result: Err("out of memory".into()) }).unwrap(); + s.update(&view, true, |_, _, _, _, _| panic!("no retry while failed")); assert!(s.stuck(&view)); assert_eq!(s.stats.last_error.as_deref(), Some("out of memory")); s.retry_after((7, 7)); From 76f67c0f10366b6c7376e7c1daa21fd6d1efbd36 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 06:03:09 -0500 Subject: [PATCH 206/216] Portable mode: create the write probe exclusively, never through an existing entry (#10963) The portable data folder's writability check wrote a fixed, PID-named file with std::fs::write, which follows an existing symlink and truncates its target. Create the probe with create_new, try another name on a collision, and remove only a probe this call created. --- apps/photocraft/src/app_dirs.rs | 27 ++++++++++++++++++++++----- apps/photocraft/src/app_dirs/tests.rs | 25 +++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/apps/photocraft/src/app_dirs.rs b/apps/photocraft/src/app_dirs.rs index 4405608842..4d5330d58c 100644 --- a/apps/photocraft/src/app_dirs.rs +++ b/apps/photocraft/src/app_dirs.rs @@ -491,14 +491,31 @@ pub fn portable_marker(exe_dir: &Path) -> Option { PORTABLE_MARKERS.iter().map(|m| exe_dir.join(m)).find(|p| p.is_file()) } +/// Probe names tried before giving up on a portable data folder. +const WRITE_PROBE_ATTEMPTS: u32 = 64; + /// Create `dir` and prove a file can be written in it (a folder can exist yet be read-only). +/// +/// The probe is created exclusively (`create_new`), so an entry already at its name (a link +/// someone else placed there, say) is never opened, written through or removed: the next name +/// is tried instead. Only a probe this call created is removed. fn ensure_writable(dir: &Path) -> std::io::Result<()> { std::fs::create_dir_all(dir)?; - let probe = dir.join(format!(".write-test-{}", std::process::id())); - std::fs::write(&probe, b"")?; - // A leftover probe is harmless; don't fail portable mode over it. - let _ = std::fs::remove_file(&probe); - Ok(()) + let pid = std::process::id(); + for attempt in 0..WRITE_PROBE_ATTEMPTS { + let probe = dir.join(format!(".write-test-{pid}-{attempt}")); + match std::fs::OpenOptions::new().write(true).create_new(true).open(&probe) { + Ok(file) => { + drop(file); + // A leftover probe is harmless; don't fail portable mode over it. + let _ = std::fs::remove_file(&probe); + return Ok(()); + } + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(e) => return Err(e), + } + } + Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "every write-test name is already taken")) } #[cfg(test)] diff --git a/apps/photocraft/src/app_dirs/tests.rs b/apps/photocraft/src/app_dirs/tests.rs index c54299fb58..b7f750ba37 100644 --- a/apps/photocraft/src/app_dirs/tests.rs +++ b/apps/photocraft/src/app_dirs/tests.rs @@ -443,3 +443,28 @@ fn missing_exe_dir_or_env_never_panics() { let d = resolve(env_of(&[("PHOTOCRAFT_CONFIG_DIR", &empty), ("HOME", &empty), ("APPDATA", &empty)]), None); assert_eq!(d.dir, None, "empty variables are ignored"); } + +#[cfg(unix)] +#[test] +fn the_write_probe_never_follows_an_entry_already_at_its_name() { + let root = temp("probe-link"); + let data = root.join("data"); + std::fs::create_dir_all(&data).unwrap(); + let victim = root.join("victim.txt"); + std::fs::write(&victim, b"keep me").unwrap(); + // Every name the probe tries, except the last, is a link to a file outside the folder. + let pid = std::process::id(); + for attempt in 0..WRITE_PROBE_ATTEMPTS - 1 { + std::os::unix::fs::symlink(&victim, data.join(format!(".write-test-{pid}-{attempt}"))).unwrap(); + } + ensure_writable(&data).unwrap(); + assert_eq!(std::fs::read(&victim).unwrap(), b"keep me"); + // The links are left alone, and the probe that was created is gone. + assert!(std::fs::symlink_metadata(data.join(format!(".write-test-{pid}-0"))).unwrap().file_type().is_symlink()); + assert!(!data.join(format!(".write-test-{pid}-{}", WRITE_PROBE_ATTEMPTS - 1)).exists()); + // With every name taken the folder is reported unwritable, still without touching the file. + std::os::unix::fs::symlink(&victim, data.join(format!(".write-test-{pid}-{}", WRITE_PROBE_ATTEMPTS - 1))).unwrap(); + assert!(ensure_writable(&data).is_err()); + assert_eq!(std::fs::read(&victim).unwrap(), b"keep me"); + let _ = std::fs::remove_dir_all(&root); +} From 4a65ae3fb0b8a1518f2e1da7051a6285f82af4e5 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 06:03:16 -0500 Subject: [PATCH 207/216] codecs: refuse EXR preview attributes larger than their value (#10982) The exr crate sizes a preview attribute's pixel buffer from its declared dimensions (up to 4 MiB at once, zero-filled) before it finds the attribute too short, and in the non-pedantic mode every reader here uses, it then skips the attribute and continues. A header with thousands of tiny malformed previews therefore cost thousands of 4 MiB allocations before any size check ran. check_previews walks the raw headers and refuses a preview whose declared width x height x 4 exceeds its value size. It runs before every header read: read_meta, read_layer, the Cryptomatte listing and decode_deep_exr. --- crates/codecs/src/codecs/exr.rs | 97 +++++++++++++++++++++ crates/codecs/src/codecs/exr_cryptomatte.rs | 1 + crates/codecs/src/lib.rs | 1 + 3 files changed, 99 insertions(+) diff --git a/crates/codecs/src/codecs/exr.rs b/crates/codecs/src/codecs/exr.rs index e179b13044..ec0956a4ec 100644 --- a/crates/codecs/src/codecs/exr.rs +++ b/crates/codecs/src/codecs/exr.rs @@ -33,9 +33,63 @@ fn map_exr(e: exr::error::Error) -> CodecError { } } +/// Refuses headers whose `preview` attributes declare more pixels than their value holds. +/// +/// The `exr` crate sizes a preview's pixel buffer from its declared dimensions (up to 4 MiB at +/// once, zero-filled) before it finds the attribute too short, and in the non-pedantic mode +/// every reader here uses it then skips the attribute and goes on: thousands of tiny malformed +/// previews would each cost that allocation. This walks the raw headers (name, type, size, +/// value per attribute; an empty name ends a header, an empty header ends a multi-part list) +/// and checks each preview against its own size, so the crate only reads consistent ones. +/// Anything else odd is left to the crate to report. +pub(crate) fn check_previews(b: &[u8]) -> Result<(), CodecError> { + if b.get(..4) != Some(&[0x76, 0x2f, 0x31, 0x01][..]) { + return Ok(()); + } + let Some(flags) = b.get(4..8).and_then(|v| <[u8; 4]>::try_from(v).ok()).map(u32::from_le_bytes) else { return Ok(()) }; + let multipart = flags & 0x1000 != 0; + let cstr = |at: usize| -> Option<(&[u8], usize)> { + let rest = b.get(at..)?; + let n = rest.iter().position(|&c| c == 0)?; + Some((rest.get(..n)?, at + n + 1)) + }; + let u32_at = |at: usize| b.get(at..at.checked_add(4)?).and_then(|v| <[u8; 4]>::try_from(v).ok()).map(u32::from_le_bytes); + let mut at = 8; + loop { + // One header's attributes. + loop { + let Some((name, next)) = cstr(at) else { return Ok(()) }; + at = next; + if name.is_empty() { + break; + } + let Some((kind, next)) = cstr(at) else { return Ok(()) }; + let Some(size) = u32_at(next).and_then(|n| i32::try_from(n).ok()).and_then(|n| usize::try_from(n).ok()) else { return Ok(()) }; + let value = next + 4; + let end = match value.checked_add(size) { + Some(end) if end <= b.len() => end, + _ => return Ok(()), + }; + if kind == b"preview" { + let declared = + u32_at(value).zip(u32_at(value + 4)).and_then(|(w, h)| u64::from(w).checked_mul(u64::from(h))?.checked_mul(4)).filter(|_| size >= 8); + if declared.is_none_or(|n| n > (size - 8) as u64) { + return Err(err("a preview attribute declares more pixels than it holds")); + } + } + at = end; + } + // A multi-part list ends with an empty header (a lone NUL). + if !multipart || b.get(at) == Some(&0) { + return Ok(()); + } + } +} + /// Read the headers without the `exr` crate's validation (it rejects deep parts outright), /// after checking every part's declared size against the limits, before any pixel allocation. pub(super) fn read_meta(bytes: &[u8], limits: &Limits) -> Result { + check_previews(bytes)?; let meta = MetaData::read_from_buffered(Cursor::new(bytes), false).map_err(err)?; // Flat decoding reads every part at once, so the parts' sizes are also capped together. let mut total: u64 = 0; @@ -130,6 +184,7 @@ impl<'s, C: ReadChannels<'s>> ReadChannels<'s> for OnlyPart { /// Decode one part's flat layer via the `exr` crate (it handles every flat compression). /// Other parts are not decompressed, so an AOV file costs one part per request, not the file. pub(super) fn read_layer(bytes: &[u8], index: usize) -> Result>, CodecError> { + check_previews(bytes)?; let only = OnlyPart { inner: read().no_deep_data().largest_resolution_level().all_channels(), index, seen: std::cell::Cell::new(0) }; only.first_valid_layer().all_attributes().from_buffered(Cursor::new(bytes)).map(|image| image.layer_data).map_err(map_exr) } @@ -334,3 +389,45 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result Vec { + let mut attrs = LayerAttributes::named("rgb"); + attrs.preview = Some(Preview { size: exr::math::Vec2(2, 1), pixel_data: vec![7; 8] }); + let list = ["R", "G", "B"].into_iter().map(|n| AnyChannel::new(n, FlatSamples::F32(vec![0.5; 4]))).collect(); + let layer = Layer::new( + (2, 2), + attrs, + Encoding { compression: Compression::Uncompressed, blocks: Blocks::ScanLines, line_order: LineOrder::Increasing }, + AnyChannels::sort(list), + ); + let image = exr::image::Image::from_layers(ImageAttributes::new(IntegerBounds::new((0, 0), (2, 2))), vec![layer]); + let mut out = Cursor::new(Vec::new()); + image.write().to_buffered(&mut out).unwrap(); + out.into_inner() + } + + #[test] + fn a_preview_declaring_more_pixels_than_it_holds_is_refused_before_the_crate_reads_it() { + let good = with_preview(); + check_previews(&good).unwrap(); + assert_eq!(decode(&good, &Limits::default()).unwrap().dimensions(), (2, 2)); + // Patch the preview to 1024x1024 (4 MiB) while its value still holds 8 bytes. + let tag = b"preview\0preview\0"; + let at = good.windows(tag.len()).position(|w| w == tag).unwrap() + tag.len() + 4; + let mut bad = good.clone(); + bad[at..at + 4].copy_from_slice(&1024u32.to_le_bytes()); + bad[at + 4..at + 8].copy_from_slice(&1024u32.to_le_bytes()); + for r in [check_previews(&bad), decode(&bad, &Limits::default()).map(|_| ()), read_layer(&bad, 0).map(|_| ())] { + assert!(matches!(&r, Err(CodecError::Malformed { message, .. }) if message.contains("preview")), "{r:?}"); + } + // A header cut off mid-attribute is left to the crate. + check_previews(&good[..at]).unwrap(); + } +} diff --git a/crates/codecs/src/codecs/exr_cryptomatte.rs b/crates/codecs/src/codecs/exr_cryptomatte.rs index 370c7c7e56..62fd2e1e0d 100644 --- a/crates/codecs/src/codecs/exr_cryptomatte.rs +++ b/crates/codecs/src/codecs/exr_cryptomatte.rs @@ -148,6 +148,7 @@ fn layer_streams(header: &exr::meta::header::Header, name: &str) -> Vec /// Lists every Cryptomatte layer of the file from the headers alone: what a Cryptomatte /// picker offers. The embedded manifest (capped, JSON) is parsed into (ID, name) pairs. pub(crate) fn layers(bytes: &[u8], limits: &Limits) -> Result, CodecError> { + super::exr::check_previews(bytes)?; let meta = MetaData::read_from_buffered(Cursor::new(bytes), false).map_err(|e| CodecError::malformed(F, e))?; // Manifests can be large; bound the embedded text before parsing anything. const MANIFEST_CAP: usize = 16 << 20; diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index 7d71f7ef4e..8588019d79 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -275,6 +275,7 @@ pub fn decode_exr_channels(bytes: &[u8], part: usize, names: &[&str], opts: &Dec /// [`decode_as_with`] instead flattens a deep file into a normal [`Image`] with a /// [`DecodeWarning::DeepFlattened`]. `Err(Unsupported)` when the file has no deep part. pub fn decode_deep_exr(bytes: &[u8], limits: &Limits) -> Result { + codecs::exr::check_previews(bytes)?; let mut cur = std::io::Cursor::new(bytes); let meta = ::exr::meta::MetaData::read_from_buffered(&mut cur, false).map_err(|e| CodecError::malformed(Format::OpenExr, e))?; codecs::deep_exr::decode_deep(&meta, bytes, limits) From 9779daf3577eb1d831fa078e3094a7c70b7b23cf Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 06:03:23 -0500 Subject: [PATCH 208/216] security(affinity): charge allocation records, empty subpaths, paint lists, tile indexes and shape lookups (#10966) * security(affinity): charge allocation records, empty subpaths, paint lists, tile indexes and shape lookups Several readers still did work per occurrence of a shared object that no document-wide budget counted: - Archive::open walked every allocation table's records, and overlapping tables could reparse one long run thousands of times. Records are now charged against MAX_TABLE_RECORDS before each table is walked. - curves() charged a subpath's records but not the subpath, so a shared curve full of empty subpaths was walked for free. Each subpath now costs one record. - Colours and gradient positions decoded their whole float record (up to 64 KiB) to read two to five components; only those are decoded now. - Paint selection collected whole BFFl/LIFl/LILn lists without charging their slots; the lists are charged and walked once without collecting. - Dash patterns were copied per occurrence with no length limit; they are capped at 64 entries and charged against the document budget. - The raster tile index skipped null Idx slots uncharged; every slot walked is charged now. - shapes::last rescanned all fields per lookup and CTyp/ShCR were copied whole; the field index records last occurrences too, and only four corners are read. * test(affinity): cover the allocation record budget across chained tables * test(affinity): directory records count against the allocation record budget --- crates/affinity/src/container.rs | 9 +++ crates/affinity/src/geometry.rs | 4 +- crates/affinity/src/paint.rs | 113 +++++++++++++++++++++++-------- crates/affinity/src/raster.rs | 19 ++++-- crates/affinity/src/shapes.rs | 15 ++-- crates/affinity/src/stream.rs | 46 +++++++++---- crates/affinity/src/tests.rs | 62 +++++++++++++++++ 7 files changed, 216 insertions(+), 52 deletions(-) diff --git a/crates/affinity/src/container.rs b/crates/affinity/src/container.rs index 96fdbd706c..569ab65c03 100644 --- a/crates/affinity/src/container.rs +++ b/crates/affinity/src/container.rs @@ -20,6 +20,11 @@ const MAX_ENTRIES: usize = 1 << 22; /// Bytes of entry names the allocation tables may name. A name is retained twice (in the entry /// and as its map key), and tables can point many names at the same bytes. const MAX_NAME_BYTES: usize = 32 << 20; +/// File and directory records parsed across every allocation table. Each table walks its own +/// records, and tables may start inside one another and share a long suffix, so the per-table +/// counts do not bound the work: a few thousand overlapping tables could reparse one large run +/// of records each. Real files hold a few hundred saves of a few thousand entries. +const MAX_TABLE_RECORDS: usize = 1 << 25; /// zstd window ceiling: real documents use a few MiB; this stops a header from reserving gigabytes. const MAX_ZSTD_WINDOW: u64 = 64 << 20; @@ -175,6 +180,7 @@ impl<'a> Archive<'a> { let mut seen = HashSet::new(); let mut next = header.fat_offset; let mut tables = 0usize; + let mut records_left = MAX_TABLE_RECORDS; while next != 0 { if !seen.insert(next) { return Err(Error::Malformed("allocation tables form a cycle")); @@ -199,6 +205,9 @@ impl<'a> Archive<'a> { c.take(8)?; let dirs = c.u16()?; c.take(1)?; + // Charge the declared records before walking them. + let declared = usize::try_from(files).unwrap_or(usize::MAX).saturating_add(usize::from(dirs)); + records_left = records_left.checked_sub(declared).ok_or(Error::Limit("too many allocation records"))?; for _ in 0..files { let id = c.u32()?; let flag = c.u8()?; diff --git a/crates/affinity/src/geometry.rs b/crates/affinity/src/geometry.rs index 0c1a871b27..f834e50598 100644 --- a/crates/affinity/src/geometry.rs +++ b/crates/affinity/src/geometry.rs @@ -177,7 +177,9 @@ pub(crate) fn curves(r: &mut Reader, crvs: ObjId) -> Option { r.warn("curves with more than four million nodes"); return None; } - if let Err(reason) = r.geometry_budget.records(len) { + // Charge the subpath itself as well as its records: an empty subpath adds no records + // or points, but a shared curve full of them is still walked for every occurrence. + if let Err(reason) = r.geometry_budget.records(len.saturating_add(1)) { r.warn(reason); return None; } diff --git a/crates/affinity/src/paint.rs b/crates/affinity/src/paint.rs index 904f642123..2f1fb86e2d 100644 --- a/crates/affinity/src/paint.rs +++ b/crates/affinity/src/paint.rs @@ -125,18 +125,24 @@ pub struct Stroke { /// Gradient stops per gradient. const MAX_STOPS: usize = 1024; +/// Dash and gap lengths per stroke. Affinity's editor offers three dash/gap pairs; a longer +/// pattern would be copied into every shape that shares its style. +const MAX_DASHES: usize = 64; -fn floats(v: &Value) -> Option> { +/// The first `n` components of a float vector or packed `f32` record. Colours and gradient +/// positions use a few leading components; a shared colour with a huge record is decoded for +/// every occurrence, so the rest is neither converted nor copied. +fn floats(v: &Value, n: usize) -> Option> { match v { - Value::Floats(f) => Some(f.clone()), - Value::Bytes(b) if b.len() % 4 == 0 => Some(f32_samples(b)), + Value::Floats(f) => Some(f.iter().take(n).copied().collect()), + Value::Bytes(b) if b.len() % 4 == 0 => Some(f32_samples(b, n)), _ => None, } } -/// Little-endian `f32` samples widened to `f64`. -fn f32_samples(b: &[u8]) -> Vec { - b.as_chunks::<4>().0.iter().map(|c| f64::from(f32::from_le_bytes(*c))).collect() +/// Up to `n` little-endian `f32` samples widened to `f64`. +fn f32_samples(b: &[u8], n: usize) -> Vec { + b.as_chunks::<4>().0.iter().take(n).map(|c| f64::from(f32::from_le_bytes(*c))).collect() } /// Spot colours defined through spot colours. A spot colour (`Pant`) is read through its RGB @@ -154,26 +160,27 @@ pub(crate) fn color(r: &mut Reader, id: ObjId) -> Option { fn color_at(r: &mut Reader, id: ObjId, depth: usize) -> Option { let s = r.s; let class = s.class(id)?; - let col = || -> Option> { - let v = floats(s.field(id, b"_col")?)?; + // `n` is how many components the model reads; only those are decoded and checked. + let col = |n: usize| -> Option> { + let v = floats(s.field(id, b"_col")?, n)?; v.iter().all(|f| f.is_finite()).then_some(v) }; Some(match &class.0.to_be_bytes() { b"RGBA" => { - let v = col()?; + let v = col(4)?; Color::Rgb { r: *v.first()?, g: *v.get(1)?, b: *v.get(2)?, a: v.get(3)?.clamp(0.0, 1.0) } } b"HSLA" => { - let v = col()?; + let v = col(4)?; let (r2, g, b) = hsl(*v.first()?, *v.get(1)?, *v.get(2)?); Color::Rgb { r: r2, g, b, a: v.get(3)?.clamp(0.0, 1.0) } } b"CMYK" => { - let v = col()?; + let v = col(5)?; Color::Cmyk { c: *v.first()?, m: *v.get(1)?, y: *v.get(2)?, k: *v.get(3)?, a: v.get(4)?.clamp(0.0, 1.0) } } b"GRAY" => { - let v = col()?; + let v = col(2)?; Color::Gray { v: *v.first()?, a: v.get(1)?.clamp(0.0, 1.0) } } b"LABA" => { @@ -245,9 +252,10 @@ pub(crate) fn descriptor(r: &mut Reader, id: ObjId, local: Affine) -> Option> = match s.field(grad, b"Posn") { - Some(Value::Array(a)) => a.iter().filter_map(floats).take(MAX_STOPS).collect(), + // A position is (offset, bias): later components are not decoded. + Some(Value::Array(a)) => a.iter().filter_map(|v| floats(v, 2)).take(MAX_STOPS).collect(), Some(Value::Records { size, data }) if *size % 4 == 0 && *size > 0 => { - data.chunks_exact(usize::from(*size)).map(f32_samples).take(MAX_STOPS).collect() + data.chunks_exact(usize::from(*size)).map(|b| f32_samples(b, 2)).take(MAX_STOPS).collect() } _ => Vec::new(), }; @@ -274,23 +282,52 @@ pub(crate) fn descriptor(r: &mut Reader, id: ObjId, local: Affine) -> Option Option { - let items = r.s.objs(id, list); - let i = r.s.int(id, index).and_then(|i| usize::try_from(i).ok()).unwrap_or(0); - items.get(i).or_else(|| items.first()).copied() +/// The active entry of a paint list (the `index`-th object, else the first) and how many +/// objects the list holds. Every raw slot, null ones included, is charged before the list is +/// walked: a shared layer's lists are read again for each occurrence. A list beyond the +/// document's work budget reads as empty. +fn current(r: &mut Reader, id: ObjId, list: &[u8; 4], index: &[u8; 4]) -> (Option, usize) { + let s = r.s; + let Some(Value::Array(items)) = s.field(id, list) else { return (None, 0) }; + if r.charge_slots(items.len()).is_err() { + r.warn("fill and stroke lists beyond the document work budget (left empty)"); + return (None, 0); + } + let want = s.int(id, index).and_then(|i| usize::try_from(i).ok()).unwrap_or(0); + let (mut first, mut chosen, mut count) = (None, None, 0usize); + for o in items.iter().filter_map(|v| if let Value::Obj(Some(o)) = v { Some(*o) } else { None }) { + first = first.or(Some(o)); + if count == want { + chosen = Some(o); + } + count += 1; + } + (chosen.or(first), count) } /// The fills of `list` (artboard backgrounds use `BFFl`). pub(crate) fn fills(r: &mut Reader, id: ObjId, local: Affine, list: &[u8; 4]) -> Vec { - let d = current(r, id, list, b"BFCr").or_else(|| r.s.obj(id, b"BFil")); - d.and_then(|d| descriptor(r, d, local)).filter(|p| *p != Paint::None).into_iter().collect() + fills_counted(r, id, local, list).0 +} + +/// [`fills`], and how many entries `list` holds. +fn fills_counted(r: &mut Reader, id: ObjId, local: Affine, list: &[u8; 4]) -> (Vec, usize) { + let (d, count) = current(r, id, list, b"BFCr"); + let d = d.or_else(|| r.s.obj(id, b"BFil")); + (d.and_then(|d| descriptor(r, d, local)).filter(|p| *p != Paint::None).into_iter().collect(), count) } /// A node's fill and stroke. `local` maps the node's space to document pixels. pub(crate) fn node_paint(r: &mut Reader, id: ObjId, local: Affine) -> (Vec, Vec) { let s = r.s; - let fills = fills(r, id, local, b"BFFl"); - if r.s.objs(id, b"BFFl").len() > 1 || r.s.objs(id, b"LIFl").len() > 1 { + let (fills, fill_count) = fills_counted(r, id, local, b"BFFl"); + let (pen, style, pen_count) = if s.field(id, b"LIFl").is_some() { + let (pen, pens) = current(r, id, b"LIFl", b"LICr"); + (pen, current(r, id, b"LILn", b"LICr").0, pens) + } else { + (s.obj(id, b"PFil"), s.obj(id, b"LSty"), 0) + }; + if fill_count > 1 || pen_count > 1 { r.warn("several fills or strokes on one object (only the active one is imported)"); } if s.f64(id, b"FOpc").is_some_and(|o| o < 0.999) { @@ -299,11 +336,6 @@ pub(crate) fn node_paint(r: &mut Reader, id: ObjId, local: Affine) -> (Vec stroke(r, pen, style, local, behind), @@ -341,6 +373,17 @@ fn stroke(r: &mut Reader, pen: ObjId, ldsc: ObjId, local: Affine, behind: bool) _ => Align::Center, }; let dash = match s.field(lsty, b"Patn") { + // Each occurrence of a shared style keeps its own scaled copy: refuse long patterns + // before scanning or copying them. + Some(Value::Array(a)) if a.len() > MAX_DASHES => { + r.warn("dash patterns longer than 64 entries (imported as solid strokes)"); + None + } + // The copies are retained across the document: charge them like other shared slots. + Some(Value::Array(a)) if r.charge_slots(a.len()).is_err() => { + r.warn("dash patterns beyond the document work budget (imported as solid strokes)"); + None + } Some(Value::Array(a)) if data.get(10) == Some(&2) || !a.is_empty() => { let mut v: Vec = a.iter().filter_map(|x| if let Value::Float(f) = x { Some(*f) } else { None }).collect(); while v.len() >= 2 && v.iter().rev().take(2).all(|x| *x == 0.0) { @@ -356,3 +399,19 @@ fn stroke(r: &mut Reader, pen: ObjId, ldsc: ObjId, local: Affine, behind: bool) } Some(Stroke { paint, width, cap, join, miter_limit, align, dash, behind }) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_the_components_a_schema_reads_are_decoded() { + // A 64 KiB record decodes to the four components asked for, not sixteen thousand. + let record = Value::Bytes(1.0f32.to_le_bytes().repeat(16_383)); + assert_eq!(floats(&record, 4), Some(vec![1.0; 4])); + assert_eq!(floats(&Value::Floats(vec![0.5; 6]), 2), Some(vec![0.5; 2])); + // Fewer components than asked for are returned as they are, for the caller to refuse. + assert_eq!(floats(&Value::Floats(vec![0.5; 3]), 5), Some(vec![0.5; 3])); + assert_eq!(floats(&Value::Bytes(vec![0; 3]), 4), None); + } +} diff --git a/crates/affinity/src/raster.rs b/crates/affinity/src/raster.rs index 226c66a459..b0a8c2daab 100644 --- a/crates/affinity/src/raster.rs +++ b/crates/affinity/src/raster.rs @@ -187,12 +187,12 @@ fn plane( Some(Value::Array(a)) => a, _ => &[], }; - let mut tiles = match s.field(bitmap, &tag(b"Idx", c)) { - Some(Value::Array(a)) => a.as_slice(), + // Tile references in order. Null slots are skipped, but every slot walked (null or not) is + // charged below, so a shared bitmap's long null prefix is not rescanned for free. + let mut tiles: &[Value] = match s.field(bitmap, &tag(b"Idx", c)) { + Some(Value::Array(a)) => a, _ => &[], - } - .iter() - .filter_map(|v| if let Value::Obj(Some(o)) = v { Some(*o) } else { None }); + }; // The scan below runs for every occurrence of a shared bitmap, whatever it produces. r.charge_slots(states.len().min(tw.saturating_mul(th)))?; let row = (w as usize) * bps; @@ -206,7 +206,14 @@ fn plane( 0 | 1 => None, 2 => Some(0xFF), 4 => { - let Some(blck) = tiles.next() else { return Err(Error::Malformed("missing pixel tile")) }; + let Some(at) = tiles.iter().position(|v| matches!(v, Value::Obj(Some(_)))) else { + r.charge_slots(tiles.len())?; + return Err(Error::Malformed("missing pixel tile")); + }; + r.charge_slots(at + 1)?; + let Some(Value::Obj(Some(blck))) = tiles.get(at) else { return Err(Error::Malformed("missing pixel tile")) }; + let blck = *blck; + tiles = tiles.get(at + 1..).unwrap_or_default(); let (ox, oy) = (tx * TILE, ty * TILE); if ox >= bx1 || ox + TILE <= bx0 || oy >= py1 || oy + TILE <= py0 { continue; diff --git a/crates/affinity/src/shapes.rs b/crates/affinity/src/shapes.rs index c7751da885..a79f7eac03 100644 --- a/crates/affinity/src/shapes.rs +++ b/crates/affinity/src/shapes.rs @@ -12,9 +12,10 @@ const KAPPA: f64 = 0.552_284_749_830_793_4; const MAX_SIDES: i64 = 10_000; /// The last value of a tag: a shape changed from one kind to another keeps a stale first block. +/// Indexed: a shared shape is read again for every occurrence, and rescanning a large field +/// list each time multiplied into work far beyond the file's size. fn last<'a>(r: &'a Reader, id: ObjId, tag: &[u8; 4]) -> Option<&'a Value> { - let t = Tag::of(tag); - r.s.object(id)?.fields.iter().rev().find(|(k, _)| *k == t).map(|(_, v)| v) + r.s.object(id)?.get_last(Tag::of(tag)) } fn num(r: &Reader, id: ObjId, tag: &[u8; 4]) -> Option { @@ -51,12 +52,14 @@ pub(crate) fn shape(r: &mut Reader, shape: ObjId, b: [f64; 4], world: Affine) -> let class = r.s.class(shape)?; let sub = match &class.0.to_be_bytes() { b"ShNR" | b"ShRR" => { - let types = match last(r, shape, b"CTyp") { - Some(Value::Array(a)) => a.iter().map(|t| if let Value::Enum { id, .. } = t { Some(*id) } else { None }).collect(), + // Only the four corners are read: a longer array is not scanned or copied, which + // would otherwise repeat for every occurrence of a shared shape. + let types: Vec> = match last(r, shape, b"CTyp") { + Some(Value::Array(a)) => a.iter().take(4).map(|t| if let Value::Enum { id, .. } = t { Some(*id) } else { None }).collect(), _ => Vec::new(), }; - let radii = match last(r, shape, b"ShCR") { - Some(Value::Floats(v)) => v.iter().map(|f| if f.is_finite() && *f > 0.0 { *f } else { 0.0 }).collect(), + let radii: Vec = match last(r, shape, b"ShCR") { + Some(Value::Floats(v)) => v.iter().take(4).map(|f| if f.is_finite() && *f > 0.0 { *f } else { 0.0 }).collect(), _ => Vec::new(), }; // Corners follow the first one (top left) unless they were unlocked; then each has diff --git a/crates/affinity/src/stream.rs b/crates/affinity/src/stream.rs index 348fc6540a..74f240b873 100644 --- a/crates/affinity/src/stream.rs +++ b/crates/affinity/src/stream.rs @@ -90,26 +90,34 @@ pub struct Object { pub chain: Vec, /// Fields in stream order; positional fields have tag 0. pub fields: Vec<(Tag, Value)>, - /// Index for objects with more than [`SCAN`] fields: (tag, index of its first occurrence) - /// sorted by tag, so `get` is a binary search. A shared object can be referenced by many - /// sibling layers, and each reference used to rescan all of its fields; indexing once keeps - /// repeated lookups from multiplying into quadratic work. Smaller objects have no index - /// (and no per-object hash map to build): scanning a handful of fields is cheaper. - index: Vec<(Tag, u32)>, + /// Index for objects with more than [`SCAN`] fields: (tag, index of its first occurrence, + /// index of its last occurrence) sorted by tag, so `get` and `get_last` are a binary search. + /// A shared object can be referenced by many sibling layers, and each reference used to + /// rescan all of its fields; indexing once keeps repeated lookups from multiplying into + /// quadratic work. Smaller objects have no index (and no per-object hash map to build): + /// scanning a handful of fields is cheaper. + index: Vec<(Tag, u32, u32)>, } /// Objects with at most this many fields are scanned instead of indexed. const SCAN: usize = 12; -fn build_index(fields: &[(Tag, Value)]) -> Vec<(Tag, u32)> { +fn build_index(fields: &[(Tag, Value)]) -> Vec<(Tag, u32, u32)> { if fields.len() <= SCAN { return Vec::new(); } - // Positional fields share tag 0 and are not looked up by tag. Sorting by (tag, index) and - // keeping the first of each tag preserves first-match semantics. - let mut index: Vec<(Tag, u32)> = fields.iter().zip(0u32..).filter(|((t, _), _)| t.0 != 0).map(|((t, _), i)| (*t, i)).collect(); + // Positional fields share tag 0 and are not looked up by tag. Sorting by (tag, index) puts + // each tag's occurrences together in stream order: the first and last of each run keep + // first-match semantics for `get` and last-match semantics for `get_last`. + let mut index: Vec<(Tag, u32, u32)> = fields.iter().zip(0u32..).filter(|((t, _), _)| t.0 != 0).map(|((t, _), i)| (*t, i, i)).collect(); index.sort_unstable(); - index.dedup_by_key(|e| e.0); + index.dedup_by(|later, kept| { + let same = later.0 == kept.0; + if same { + kept.2 = later.2; + } + same + }); index } @@ -127,7 +135,21 @@ impl Object { return self.fields.iter().find(|(t, _)| *t == tag).map(|(_, v)| v); } let at = self.index.binary_search_by_key(&tag, |e| e.0).ok()?; - self.index.get(at).and_then(|&(_, i)| self.fields.get(i as usize)).map(|(_, v)| v) + self.index.get(at).and_then(|&(_, i, _)| self.fields.get(i as usize)).map(|(_, v)| v) + } + + /// The last field with `tag`, for objects that may repeat a tag (a shape changed from one + /// kind to another keeps a stale first block). Indexed like [`Object::get`], so repeated + /// lookups on a large shared object do not rescan its fields. + pub fn get_last(&self, tag: Tag) -> Option<&Value> { + if tag.0 == 0 { + return None; + } + if self.fields.len() <= SCAN { + return self.fields.iter().rev().find(|(t, _)| *t == tag).map(|(_, v)| v); + } + let at = self.index.binary_search_by_key(&tag, |e| e.0).ok()?; + self.index.get(at).and_then(|&(_, _, i)| self.fields.get(i as usize)).map(|(_, v)| v) } /// Replaces the fields (the root object is filled in after it is pushed) and rebuilds the diff --git a/crates/affinity/src/tests.rs b/crates/affinity/src/tests.rs index 021b532529..a8a1187da5 100644 --- a/crates/affinity/src/tests.rs +++ b/crates/affinity/src/tests.rs @@ -297,6 +297,42 @@ mod archive { assert_eq!(Archive::open(&file, Limits::default()).unwrap_err(), Error::Malformed("allocation tables form a cycle")); } + #[test] + fn allocation_records_are_charged_before_they_are_walked() { + let d = doc(); + let mut file = synth::container(&[("doc.dat", &d, Method::Stored)], None); + let fat = u64::from_le_bytes(file[16..24].try_into().unwrap()) as usize; + // The table's file count sits after its tag, next, date and 24 reserved bytes. + let files = fat + 4 + 8 + 8 + 24; + file[files..files + 4].copy_from_slice(&u32::MAX.to_le_bytes()); + assert_eq!(Archive::open(&file, Limits::default()).unwrap_err(), Error::Limit("too many allocation records")); + // A count within the budget still reaches the records (and here fails on missing data). + file[files..files + 4].copy_from_slice(&1_000_000u32.to_le_bytes()); + assert!(matches!(Archive::open(&file, Limits::default()), Err(Error::Malformed(_)))); + } + + #[test] + fn allocation_records_are_charged_across_every_table() { + let d = doc(); + let mut file = synth::container(&[("doc.dat", &d, Method::Stored), ("other.dat", &d, Method::Stored)], None); + let fat = u64::from_le_bytes(file[16..24].try_into().unwrap()) as usize; + // Chain a copy of the (valid, two-record) table after it; the copy is the last table. + let second = file.len(); + let table = file[fat..].to_vec(); + file.extend(&table); + file[fat + 4..fat + 12].copy_from_slice(&(second as u64).to_le_bytes()); + let files = second + 4 + 8 + 8 + 24; + // Each count alone is within the archive budget of 2^25; together they exceed it by one. + file[files..files + 4].copy_from_slice(&((1u32 << 25) - 1).to_le_bytes()); + assert_eq!(Archive::open(&file, Limits::default()).unwrap_err(), Error::Limit("too many allocation records")); + // Exactly the budget is allowed: the second table is walked (and fails on missing data). + file[files..files + 4].copy_from_slice(&((1u32 << 25) - 2).to_le_bytes()); + assert!(matches!(Archive::open(&file, Limits::default()), Err(Error::Malformed(_)))); + // Directory records count too: one more (after the file count and 8 bytes) is over. + file[files + 12..files + 14].copy_from_slice(&1u16.to_le_bytes()); + assert_eq!(Archive::open(&file, Limits::default()).unwrap_err(), Error::Limit("too many allocation records")); + } + #[test] fn hostile_streams_fail_cleanly() { let deep = (0..2000).fold(Vec::new(), |inner, _| vec![(tag(b"Chld"), F::Obj(tag(b"Grup"), inner))]); @@ -416,6 +452,20 @@ mod shapes { rx.recv_timeout(std::time::Duration::from_secs(20)).unwrap_or_else(|_| panic!("reading the document did not finish")).unwrap() } + #[test] + fn empty_subpaths_of_a_shared_curve_are_charged_for_every_occurrence() { + // One curve of 100k empty subpaths, placed 100 times: no records and no points, but + // ten million subpaths walked unless each one is charged. + let mut data = vec![F::U8(0), F::U32(100_000)]; + for _ in 0..100_000 { + data.extend([F::Bool(false), F::Records(18, Vec::new())]); + } + let curve = F::Def(5, vec![tag(b"PCrv")], vec![(tag(b"Crvs"), F::Obj(tag(b"PCvD"), vec![(tag(b"Data"), F::Pos(data))]))]); + let group = F::Def(6, vec![tag(b"Grup")], vec![(tag(b"Chld"), F::Shared((0..100).map(|_| F::Ref(5)).collect()))]); + let d = read(vec![curve, group], 6); + assert!(d.warnings.iter().any(|w| w.contains("curve geometry budget exhausted")), "{:?}", d.warnings); + } + fn path(document: &Document) -> &Path { match &document.spreads[0].nodes[0].kind { Kind::Shape { path, .. } => path, @@ -786,6 +836,18 @@ mod stream_storage { } } + #[test] + fn last_lookup_keeps_last_match_for_every_object_size() { + for n in 0..60u32 { + let fields: Vec<(Tag, Value)> = (0..n).map(|i| (if i % 5 == 0 { Tag(0) } else { Tag(100 + i % 7) }, Value::UInt(u64::from(i)))).collect(); + let object = Object::new(Tag(1), Kind::Inline, vec![Tag(1)], fields.clone()); + for t in 0..120 { + let expected = if t == 0 { None } else { fields.iter().rev().find(|(k, _)| *k == Tag(t)).map(|(_, v)| v) }; + assert_eq!(object.get_last(Tag(t)), expected, "{n} fields, tag {t}"); + } + } + } + #[test] fn a_fixed_size_array_is_one_buffer_and_one_value_per_record() { let records: Vec = (0..18 * 5).map(|i| i as u8).collect(); From 0f4e4625707d2c8d03b46320fb5530547b5c4dd0 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Tue, 3 Nov 2026 12:21:27 +0100 Subject: [PATCH 209/216] Render zoomed-out viewport pages through the reduced composite Pages reduced by k > 1 whose source starts on a multiple of k now use the band/tile reduced render (one-colour tile skipping, parallel tiles) on a factor-aligned cell grid instead of compositing every pixel at full size and averaging. Unaligned sources keep the averaging path, which tests use as the reference (U8/U16/F32, k = 2, 4, 16, max difference < 1e-4). The reduced render now checks the page's interrupt between bands. --- crates/compose/src/reduced.rs | 68 +++++++++++++++++++++++++++------ crates/compose/src/viewport.rs | 69 ++++++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+), 11 deletions(-) diff --git a/crates/compose/src/reduced.rs b/crates/compose/src/reduced.rs index 1249df6437..e342beedb0 100644 --- a/crates/compose/src/reduced.rs +++ b/crates/compose/src/reduced.rs @@ -2,6 +2,7 @@ use super::content::RENDER_TILE; use super::ctx::Ctx; use super::render::{band_rows_for, render, render_bands, render_tiled_with}; use super::{Buffer, Document, Rect, Rgba8Image, pattern, proxy, uniform}; +use photocraft_raster::Interrupt; /// Documents above this many pixels get thumbnails from a proxy (see [`proxy`]) when that is /// faithful; smaller ones are reduced from the exact composite. @@ -101,23 +102,66 @@ pub(crate) fn render_reduced_with( uniform: bool, stats: &mut ReducedStats, ) -> Buffer { + match render_reduced_cells(doc, b, Cells::Fit(w, h), damage, band_rows, uniform, stats, Interrupt::NONE) { + Ok(buf) => buf, + // Only an interrupt fails a reduction, and this one has none. + Err(_) => Buffer::transparent(Rect::EMPTY), + } +} + +/// How a reduced render maps document pixels of its area to output pixels. +#[derive(Clone, Copy, Debug)] +pub(crate) enum Cells { + /// `w`×`h` output pixels spread evenly over the area ([`render_reduced`]). + Fit(u32, u32), + /// Output pixel `t` covers the area's pixels `[t·k, (t+1)·k)`, clipped to the area: a + /// globally aligned reduction by `k` when the area starts on a multiple of `k` + /// ([`crate::viewport::render_page`]). + Factor(u32), +} + +/// [`render_reduced_with`] on a cell grid, checking `interrupt` between bands. +#[allow(clippy::too_many_arguments)] +pub(crate) fn render_reduced_cells( + doc: &Document, + b: Rect, + cells: Cells, + damage: Option, + band_rows: i32, + uniform: bool, + stats: &mut ReducedStats, + interrupt: Interrupt<'_>, +) -> Result { let (fw, fh) = (b.width() as usize, b.height() as usize); - let (w, h) = (w.clamp(1, b.width().max(1)) as usize, h.clamp(1, b.height().max(1)) as usize); + let (w, h, k) = match cells { + Cells::Fit(w, h) => (w.clamp(1, b.width().max(1)) as usize, h.clamp(1, b.height().max(1)) as usize, None), + Cells::Factor(k) => { + let k = k.max(1); + (b.width().div_ceil(k).max(1) as usize, b.height().div_ceil(k).max(1) as usize, Some(k as usize)) + } + }; let full = Rect::from_xywh(0, 0, w as u32, h as u32); if fw == 0 || fh == 0 { - return Buffer::transparent(if damage.is_some() { Rect::EMPTY } else { full }); + return Ok(Buffer::transparent(if damage.is_some() { Rect::EMPTY } else { full })); } - // Output column / row of each document column / row: output pixel t covers [t·f/n, (t+1)·f/n). - let span = |t: usize, f: usize, n: usize| (t * f / n, ((t + 1) * f / n).max(t * f / n + 1).min(f)); + // Output column / row of each document column / row: output pixel t covers [t·f/n, (t+1)·f/n) + // (or [t·k, (t+1)·k) on a factor grid). + let span = |t: usize, f: usize, n: usize| match k { + Some(k) => ((t * k).min(f), ((t + 1) * k).min(f)), + None => (t * f / n, ((t + 1) * f / n).max(t * f / n + 1).min(f)), + }; // The output pixel whose span holds document column / row `x` (spans are contiguous: n <= f). - let cell = |x: usize, f: usize, n: usize| ((x + 1) * n).saturating_sub(1) / f; + let cell = |x: usize, f: usize, n: usize| match k { + Some(k) => x / k, + None => ((x + 1) * n).saturating_sub(1) / f, + }; // Output pixels to produce and the document area they cover. let out = match damage { None => full, Some(d) => { let d = d.intersect(&b); if d.is_empty() { - return Buffer::transparent(Rect::EMPTY); + return Ok(Buffer::transparent(Rect::EMPTY)); } let (x0, x1) = ((d.x0 - b.x0) as usize, (d.x1 - b.x0) as usize); let (y0, y1) = ((d.y0 - b.y0) as usize, (d.y1 - b.y0) as usize); @@ -131,7 +175,7 @@ pub(crate) fn render_reduced_with( if (w, h) == (fw, fh) { let mut px = render(doc, src); px.rect = out; - return px; + return Ok(px); } let map = |f: usize, n: usize| { let mut m = vec![0u32; f]; @@ -200,6 +244,7 @@ pub(crate) fn render_reduced_with( let band_h = band_rows_for(src.width(), band_rows).max(RENDER_TILE) as usize; let mut y = grid(sy0, b.y0); while y < sy1 as i64 { + interrupt.check().map_err(|e| e.to_string())?; let y1 = (y + band_h as i64).min(sy1 as i64); let tiles: Vec<(Rect, Rect)> = (y..y1) .step_by(t) @@ -255,7 +300,8 @@ pub(crate) fn render_reduced_with( y = y1; } } else { - let ok: Result<(), ()> = render_bands(doc, src, band_rows, |band| { + render_bands(doc, src, band_rows, |band| -> Result<(), String> { + interrupt.check().map_err(|e| e.to_string())?; stats.composited += band.px.len() as u64; let (by0, by1) = ((band.rect.y0 - b.y0) as usize, (band.rect.y1 - b.y0) as usize); let sw = sx1 - sx0; @@ -265,8 +311,7 @@ pub(crate) fn render_reduced_with( } }); Ok(()) - }); - debug_assert!(ok.is_ok()); + })?; } let col_w: Vec = (0..ow) .map(|x| { @@ -296,5 +341,6 @@ pub(crate) fn render_reduced_with( for (row, out) in acc.chunks_mut(ow).enumerate() { norm_row(row, out); } - Buffer { rect: out, px: acc } + interrupt.check().map_err(|e| e.to_string())?; + Ok(Buffer { rect: out, px: acc }) } diff --git a/crates/compose/src/viewport.rs b/crates/compose/src/viewport.rs index 4f60ce80ed..c7a5cc3c10 100644 --- a/crates/compose/src/viewport.rs +++ b/crates/compose/src/viewport.rs @@ -112,6 +112,25 @@ pub fn render_page(doc: &Document, source: Rect, factor: u32, interrupt: Interru if k == 1 { return Ok(render(doc, source)); } + if source.x0.rem_euclid(k) != 0 || source.y0.rem_euclid(k) != 0 { + return average_page(doc, source, k, out, interrupt); + } + // The reduced-render path the CPU canvas and navigator use: one-colour document tiles are + // composited once instead of per pixel, and the rest in parallel tiles, then area-averaged + // into cells aligned to multiples of `k` (the same cells as [`average_page`]). + let stats = &mut crate::reduced::ReducedStats::default(); + let mut buf = crate::reduced::render_reduced_cells(doc, source, crate::reduced::Cells::Factor(factor), None, 128, true, stats, interrupt)?; + if buf.rect.width() != out.width() || buf.rect.height() != out.height() { + return Err("invalid viewport reduction".into()); + } + buf.rect = out; + Ok(buf) +} + +/// [`render_page`] by compositing every source pixel and averaging each `k`×`k` footprint (also +/// the reference its reduced path is tested against). +fn average_page(doc: &Document, source: Rect, k: i32, out: Rect, interrupt: Interrupt<'_>) -> Result { + let factor = k.unsigned_abs(); let mut result = Buffer::transparent(out); render_bands(doc, source, 128, |band| -> Result<(), String> { interrupt.check().map_err(|e| e.to_string())?; @@ -181,11 +200,61 @@ mod tests { } } } + /// A white document with a noisy, partly transparent layer and a multiplied solid over one-colour + /// tiles, so the reduced path takes both its one-colour and per-pixel branches. + fn busy(depth: SampleType) -> Document { + let mut d = Document::with_background("busy", Size::new(1100, 700), ColorMode::Rgb, depth, Color::WHITE); + let mut s = photocraft_raster::Surface::new(d.pixel_format()); + let mut seed = 0x2545_f491_u32; + let mut next = || { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + (seed % 1000) as f32 / 999.0 + }; + for y in (100..530).step_by(3) { + for x in (190..905).step_by(3) { + let px = [next(), next(), next(), next()]; + s.fill_rect(Rect::from_xywh(x, y, 3, 3), &px); + } + } + d.layers.push(photocraft_doc::Layer::new("noise", photocraft_doc::LayerContent::Raster(s))); + let mut s = photocraft_raster::Surface::new(d.pixel_format()); + s.fill_rect(Rect::new(400, 0, 1100, 650), &[0.2, 0.5, 0.9, 1.0]); + let mut top = photocraft_doc::Layer::new("tint", photocraft_doc::LayerContent::Raster(s)); + top.opacity = 0.6; + top.blend = photocraft_color::blend::BlendMode::Multiply; + d.layers.push(top); + d + } + #[test] + fn reduced_pages_match_averaging_every_composited_pixel() { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let d = busy(depth); + for k in [2u32, 4, 16] { + let ki = k as i32; + for source in [d.bounds(), Rect::new(512, 256, 1024, 700), Rect::new(ki * 13, ki * 5, 1100, ki * 37)] { + let source = source.intersect(&d.bounds()); + let out = Rect::new(source.x0 / ki, source.y0 / ki, (source.x1 + ki - 1) / ki, (source.y1 + ki - 1) / ki); + let reference = average_page(&d, source, ki, out, Interrupt::NONE).unwrap(); + let got = render_page(&d, source, k, Interrupt::NONE).unwrap(); + assert_eq!(got.rect, reference.rect, "{depth:?} k={k} {source:?}"); + let worst = got.px.iter().zip(&reference.px).flat_map(|(a, b)| a.iter().zip(b).map(|(a, b)| (a - b).abs())).fold(0.0f32, f32::max); + assert!(worst < 1e-4, "{depth:?} k={k} {source:?}: differs by {worst}"); + } + } + } + } #[test] fn rejects_invalid_sizes_and_honors_cancellation() { let d = Document::new("d", Size::new(86400, 43200), ColorMode::Rgb, SampleType::U8); assert!(render_page(&d, d.bounds(), 1, Interrupt::NONE).is_err()); assert!(render_page(&d, d.bounds(), 0, Interrupt::NONE).is_err()); assert!(render_page(&d, Rect::new(0, 0, 512, 512), 1, Interrupt::cancel_only(&|| true)).is_err()); + // Cancelled after the first band of the reduced path. + let checks = std::sync::atomic::AtomicUsize::new(0); + let later = || checks.fetch_add(1, std::sync::atomic::Ordering::Relaxed) >= 2; + assert!(render_page(&d, Rect::new(0, 0, 4096, 4096), 8, Interrupt::cancel_only(&later)).is_err()); + assert!(checks.load(std::sync::atomic::Ordering::Relaxed) >= 3); } } From 825a1d169dc500bfff80c4d997464667ff0c855d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= <55043719+hnoerenberg@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:22:33 +0100 Subject: [PATCH 210/216] fix(ui): Save for Web Matte takes only whole colours while typed (#8830) (#8890) * fix(ui): Save for Web Matte takes only whole colours while typed (#8830) The Matte field wrote every keystroke into the export settings, so a partial colour reached the previews: mid-type the settings held `#1111`, or nothing at all after a select-all plus Backspace, and the pane's encode failed (its error row shows above the panes). `matte_field` shows exactly what is typed while the field has focus and commits only `#rrggbb`, normalised through `color_picker_ui::parse_hex`, or `none` - the pattern the Color panel's hex field already uses; anything else snaps back when focus leaves. aedab302b had already moved the encode error out of the pane row, so the dialog no longer widens on an invalid matte; this removes the cause and brings back this PR's first version's width test as a guard for that half. Co-Authored-By: Claude Opus 5.5 * fix(ui): the Save for Web matte field keeps typed text across window focus (#8830) CodeRabbit 4431801542 flagged that the Matte field reads its cached text with ui.memory(|m| m.has_focus(id)) but caches/clears it with resp.has_focus(). The pinned egui 0.37 fork shows the difference is real: Response::has_focus ANDs the OS-window flag (response.rs 405-407: self.ctx.input(|i| i.focused) && self.ctx.memory(|mem| mem.has_focus(self.id))), while Memory::has_focus is only self.focused() == Some(id) (memory/mod.rs 913-915), and egui never clears the focused widget when the window loses focus - Event::WindowFocused(false) (input_state/mod.rs 443-451) only clears keys and modifiers. So alt-tabbing away deleted the typed text and the field came back showing the last whole colour. Both sides now ask the same question. The new test focuses the Matte field, types a partial #1111, drops and restores RawInput::focused, and asserts the partial text survives and that finishing the colour still reaches the settings; on the old write side it fails with the partial Matte is still in the field after the window came back: ["#ffffff"]. Gates: cargo test -p photocraft-ui-egui --lib -- file_ui = 46 passed, 0 failed; cargo clippy -p photocraft-ui-egui --all-targets -- -D warnings = clean; cargo xtask layers = OK: 31 crates, no layering violations. Formatting: CI's `cargo fmt --all -- --check` caught an unwrapped assert in the first version of the test (tests.rs:586) - I had run test, clippy and layers locally but skipped fmt, so the gate I left out is the one that broke. The assert is now wrapped as rustfmt wants it and `cargo fmt -p photocraft-ui-egui -- --check` is clean; that last push is formatting only, no behaviour or test-logic change. Co-Authored-By: Claude Opus 5.5 * fix(ui): the Save for Web matte field caches text when focus arrives mid-edit (#8830) The Matte field asked Memory::has_focus once, before ui.add, and used that one value for both sides of the cache: what the field shows, and whether typed text is kept. Those are two different questions. The display side must keep using the pre-add state: that is the frame as it started. The cache side must ask again after ui.add, because egui can move focus into the field while the add runs. In the pinned egui fork (6d518fc) the MiddleClickPaste branch of TextEdit inserts the pasted text into an unfocused field and only then calls Memory::request_focus inside the add: text_edit/builder.rs:933-974 (insert at 953-959, request_focus at 968), and Memory::request_focus sets the focus immediately, memory/mod.rs:945-950, so has_focus is true right after ui.add in the same frame. Reading focus only before the add left the pasted partial text uncached: the settings parse rejects the pasted value (the click pos inserts it inside "#ffffff"), the remove branch ran with the stale focused=false, and the next frame restored the saved colour although the field now had focus. Fix: read the pre-add focus for the display value, re-read has_focus after ui.add for the insert_temp/remove decision. Regression test drives a real focus-gain-and-text-in-one-frame: hover over the Matte field, then a single frame carrying egui::Event::MiddleClickPaste (fork-only event, data/input/event.rs:40) at the field's centre, with run_steps(1) so the one stale frame is visible. Fail-first output: the pasted partial must survive into the next frame, not be restored to the saved colour: ["#ffffff"] test result: FAILED. 46 passed; 1 failed; 0 ignored; 0 measured; 4439 filtered out; finished in 0.49s After the fix: test result: ok. 47 passed; 0 failed. clippy -D warnings, cargo fmt --check and cargo xtask layers are clean. Co-Authored-By: Claude Opus 5.5 * test(ui): pin Save for Web Matte short form, case, paste and blur (#8830) Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/dialogs/file_ui/tests.rs | 216 ++++++++++++++++++++ crates/ui-egui/src/dialogs/file_ui/web.rs | 45 +++- 2 files changed, 254 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/dialogs/file_ui/tests.rs b/crates/ui-egui/src/dialogs/file_ui/tests.rs index 6de4ed340d..3f1ca59d08 100644 --- a/crates/ui-egui/src/dialogs/file_ui/tests.rs +++ b/crates/ui-egui/src/dialogs/file_ui/tests.rs @@ -475,6 +475,222 @@ fn save_for_web_authorizes_generated_palette_before_opening_a_picker() { } } +/// #8830: an invalid Matte (`#1111`, empty; set here the way `ui.dialog.set` would) makes the +/// preview's encode fail, and the error the dialog shows must not widen it. +#[test] +fn save_for_web_keeps_its_width_while_the_matte_is_invalid() { + let (mut app, _) = app(); + let id = open_web(&mut app); + app.ui.dialog_mut(id).unwrap().fields.insert("format".into(), json!("png8")); + app.ui.dialog_mut(id).unwrap().fields.insert("matte".into(), json!("#111111")); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1600.0, 1100.0)).build_ui_state( + |ui, app| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("semibold".into()))) { + crate::dialogs::show(app, ui.ctx()); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, Default::default()); + h.run_steps(5); + let dialog = |h: &egui_kittest::Harness<'_, PhotocraftApp>| h.ctx.data(|m| m.get_temp::>(egui::Id::unique("pc-dialog-rects"))).unwrap()[0]; + let before = dialog(&h); + for matte in ["#1111", "", "#22222", "#111111"] { + h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("matte".into(), json!(matte)); + h.run_steps(4); + let after = dialog(&h); + assert_eq!(after.width(), before.width(), "matte {matte:?}: before {before:?}, after {after:?}"); + } +} + +/// #8830: a typed Matte reaches the settings only once it is a whole colour (or `none`), so the +/// previews never see a partial one such as `#1111` while the user is still typing, and the dialog +/// does not resize under them (the report: "the whole interface jumps wider"). +#[test] +fn save_for_web_matte_takes_only_whole_colours() { + use egui_kittest::kittest::{NodeT, Queryable}; + let (app, _) = app(); + let (mut h, id) = web_size_dialog(app); + h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("format".into(), json!("png8")); + h.run_steps(3); + let matte = |h: &egui_kittest::Harness<'static, PhotocraftApp>| h.state().ui.dialogs.iter().find(|d| d.id == id).unwrap().fields["matte"].clone(); + let dialog = + |h: &egui_kittest::Harness<'static, PhotocraftApp>| h.ctx.data(|m| m.get_temp::>(egui::Id::unique("pc-dialog-rects"))).unwrap()[0]; + let before = dialog(&h); + let field = h + .query_all_by_role(egui::accesskit::Role::TextInput) + .find(|n| n.accesskit_node().value().as_deref() == Some("#ffffff")) + .map(|n| n.rect()) + .expect("the Matte field"); + h.hover_at(field.center()); + h.run_steps(1); + h.drag_at(field.center()); + h.run_steps(1); + h.drop_at(field.center()); + h.run_steps(2); + for (typed, want) in [("#1111", "#ffffff"), ("", "#ffffff"), ("#22222g", "#ffffff"), ("#2A2a2A", "#2a2a2a"), ("None", "none")] { + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.key_press(egui::Key::Backspace); + if !typed.is_empty() { + h.event(egui::Event::Text(typed.into())); + } + h.run_steps(2); + let now = dialog(&h); + assert_eq!((now.width(), now.height()), (before.width(), before.height()), "typing {typed:?} resized the dialog: {before:?} -> {now:?}"); + assert_eq!(matte(&h), json!(want), "typed {typed:?}"); + let shown = h.query_all_by_role(egui::accesskit::Role::TextInput).any(|n| n.accesskit_node().value().unwrap_or_default() == typed); + assert!(shown, "the field shows {typed:?} as typed"); + } +} + +/// #8830, review follow-ups: the field takes `#rrggbb` only (no `#rgb` short form: `parse_hex` +/// wants six digits), accepts upper case (stored lower case) and a paste, keeps the typed draft in +/// the field while the setting holds the last whole colour, and on blur shows that committed colour +/// instead of the invalid draft. +#[test] +fn save_for_web_matte_short_form_case_paste_and_blur() { + use egui_kittest::kittest::{NodeT, Queryable}; + let (app, _) = app(); + let (mut h, id) = web_size_dialog(app); + h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("format".into(), json!("png8")); + h.run_steps(3); + let matte = |h: &egui_kittest::Harness<'static, PhotocraftApp>| h.state().ui.dialogs.iter().find(|d| d.id == id).unwrap().fields["matte"].clone(); + let values = |h: &egui_kittest::Harness<'static, PhotocraftApp>| -> Vec { + h.query_all_by_role(egui::accesskit::Role::TextInput).map(|n| n.accesskit_node().value().unwrap_or_default()).collect() + }; + let field = h + .query_all_by_role(egui::accesskit::Role::TextInput) + .find(|n| n.accesskit_node().value().as_deref() == Some("#ffffff")) + .map(|n| n.rect()) + .expect("the Matte field"); + h.hover_at(field.center()); + h.run_steps(1); + h.drag_at(field.center()); + h.run_steps(1); + h.drop_at(field.center()); + h.run_steps(2); + let replace_with = |h: &mut egui_kittest::Harness<'static, PhotocraftApp>, text: &str, paste: bool| { + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.key_press(egui::Key::Backspace); + h.event(if paste { egui::Event::Paste(text.into()) } else { egui::Event::Text(text.into()) }); + h.run_steps(2); + }; + // (text, pasted, setting afterwards) + for (typed, paste, want) in [ + ("#fff", false, "#ffffff"), // short form is not a colour here: draft only, setting unchanged + ("#ABCDEF", false, "#abcdef"), // upper case is accepted and stored lower case + ("#12AB3", true, "#abcdef"), // a partial paste is a draft like partial typing + ("#12AB34", true, "#12ab34"), // a whole pasted colour is taken + ] { + replace_with(&mut h, typed, paste); + assert_eq!(matte(&h), json!(want), "{typed:?} (paste: {paste})"); + assert!(values(&h).iter().any(|v| v == typed), "the field keeps {typed:?} as typed: {:?}", values(&h)); + } + // Blur with an invalid draft: the field shows the last committed colour, not the draft. + replace_with(&mut h, "#22222g", false); + assert_eq!(matte(&h), json!("#12ab34")); + assert!(values(&h).iter().any(|v| v == "#22222g"), "the draft is shown while focused"); + h.key_press(egui::Key::Tab); + h.run_steps(3); + let shown = values(&h); + assert!(shown.iter().any(|v| v == "#12ab34"), "blur restores the committed colour: {shown:?}"); + assert!(!shown.iter().any(|v| v == "#22222g"), "the invalid draft is gone after blur: {shown:?}"); + assert_eq!(matte(&h), json!("#12ab34")); +} + +/// #8830, the review follow-up: alt-tabbing away from the window and back must not throw away a +/// partial Matte that is still being typed. egui keeps keyboard focus on the field while the OS +/// window is unfocused (`Memory::has_focus` ignores the window, `Response::has_focus` does not), +/// so the side that caches the typed text has to ask the same question the read side asks. +#[test] +fn save_for_web_matte_keeps_typed_text_across_window_focus_loss() { + use egui_kittest::kittest::{NodeT, Queryable}; + let (app, _) = app(); + let (mut h, id) = web_size_dialog(app); + h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("format".into(), json!("png8")); + h.run_steps(3); + let shown = |h: &egui_kittest::Harness<'static, PhotocraftApp>, want: &str| { + h.query_all_by_role(egui::accesskit::Role::TextInput).any(|n| n.accesskit_node().value().unwrap_or_default() == want) + }; + let matte = |h: &egui_kittest::Harness<'static, PhotocraftApp>| h.state().ui.dialogs.iter().find(|d| d.id == id).unwrap().fields["matte"].clone(); + let field = h + .query_all_by_role(egui::accesskit::Role::TextInput) + .find(|n| n.accesskit_node().value().as_deref() == Some("#ffffff")) + .map(|n| n.rect()) + .expect("the Matte field"); + h.hover_at(field.center()); + h.run_steps(1); + h.drag_at(field.center()); + h.run_steps(1); + h.drop_at(field.center()); + h.run_steps(2); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.key_press(egui::Key::Backspace); + h.event(egui::Event::Text("#1111".into())); + h.run_steps(2); + assert!(shown(&h, "#1111"), "the field shows the partial value as typed"); + assert_eq!(matte(&h), json!("#ffffff"), "a partial value must not reach the settings"); + let focused = h.ctx.memory(|m| m.focused()); + assert!(focused.is_some(), "the Matte field holds egui focus"); + // The OS window loses focus (alt-tab away): only `RawInput::focused` goes false, egui itself + // keeps focus on the field. + h.input_mut().focused = false; + h.run_steps(1); + assert!(!h.ctx.input(|i| i.focused), "the frame ran with the window unfocused"); + assert_eq!(h.ctx.memory(|m| m.focused()), focused, "egui keeps focus on the field while the window is unfocused"); + // …and comes back to the same window. + h.input_mut().focused = true; + h.run_steps(2); + assert!( + shown(&h, "#1111"), + "the partial Matte is still in the field after the window came back: {:?}", + h.query_all_by_role(egui::accesskit::Role::TextInput).map(|n| n.accesskit_node().value().unwrap_or_default()).collect::>() + ); + assert_eq!(matte(&h), json!("#ffffff"), "the settings still hold the last whole colour"); + // Finishing the colour takes it, so the field was live, not frozen. + h.event(egui::Event::Text("11".into())); + h.run_steps(2); + assert_eq!(matte(&h), json!("#111111"), "typing on completes as usual"); +} + +/// #8830, the second follow-up: a middle-click paste into an unfocused Matte field inserts the text +/// and moves focus to the field inside the same `ui.add`. In the pinned egui fork +/// (`crates/egui/src/widgets/text_edit/builder.rs:933-974`) the `MiddleClickPaste` branch writes the +/// text into the field's buffer and then calls `Memory::request_focus`, which takes effect the same +/// frame (`crates/egui/src/memory/mod.rs:945-950`). If the cache write side looks at the focus state +/// read before `add`, it drops the pasted partial text, and the following frame restores the saved +/// colour even though the field now has focus. +#[test] +fn save_for_web_matte_caches_a_middle_click_paste_that_arrives_with_focus() { + use egui_kittest::kittest::{NodeT, Queryable}; + let (app, _) = app(); + let (mut h, id) = web_size_dialog(app); + h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("format".into(), json!("png8")); + h.run_steps(3); + let matte = |h: &egui_kittest::Harness<'static, PhotocraftApp>| h.state().ui.dialogs.iter().find(|d| d.id == id).unwrap().fields["matte"].clone(); + let values = |h: &egui_kittest::Harness<'static, PhotocraftApp>| -> Vec { + h.query_all_by_role(egui::accesskit::Role::TextInput).map(|n| n.accesskit_node().value().unwrap_or_default()).collect() + }; + let field = h + .query_all_by_role(egui::accesskit::Role::TextInput) + .find(|n| n.accesskit_node().value().as_deref() == Some("#ffffff")) + .map(|n| n.rect()) + .expect("the Matte field"); + assert!(h.ctx.memory(|m| m.focused()).is_none(), "the Matte field starts unfocused"); + h.hover_at(field.center()); + h.run_steps(1); + // One frame: the PRIMARY-selection "#1111" is pasted at the click pos while the field gains + // focus, all inside `ui.add` - run_steps(1) so the single stale frame is visible, not a run() + // that would settle past the restore. + h.event(egui::Event::MiddleClickPaste { pos: field.center(), text: "#1111".into() }); + h.run_steps(1); + assert!(h.ctx.memory(|m| m.focused()).is_some(), "the paste moved focus to the field"); + h.run_steps(1); + let shown = values(&h); + assert!(shown.iter().any(|v| v.contains("#1111")), "the pasted partial must survive into the next frame, not be restored to the saved colour: {shown:?}"); + assert_eq!(matte(&h), json!("#ffffff"), "a partial value must not reach the settings"); +} + #[test] fn web_preview_grows_with_resize_and_accepts_zoom_and_pan() { let (mut app, _) = app(); diff --git a/crates/ui-egui/src/dialogs/file_ui/web.rs b/crates/ui-egui/src/dialogs/file_ui/web.rs index 865215d4b5..fe9941f9e7 100644 --- a/crates/ui-egui/src/dialogs/file_ui/web.rs +++ b/crates/ui-egui/src/dialogs/file_ui/web.rs @@ -380,13 +380,7 @@ pub(super) fn web_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map) { + let t = Tokens::get(ui.ctx()); + let label = ui.label(egui::RichText::new(tl!("Matte")).color(t.text_dim)); + let id = ui.make_persistent_id("web-matte"); + let was_focused = ui.memory(|m| m.has_focus(id)); + let typed = if was_focused { ui.data(|d| d.get_temp::(id)) } else { None }; + let mut text = typed.unwrap_or_else(|| s(f, "matte", "#ffffff")); + let resp = ui.add(egui::TextEdit::singleline(&mut text).id(id).desired_width(80.0)).labelled_by(label.id); + if ui.memory(|m| m.has_focus(id)) { + ui.data_mut(|d| d.insert_temp(id, text.clone())); + } else { + ui.data_mut(|d| d.remove::(id)); + } + if !resp.changed() { + return; + } + let matte = crate::color_picker_ui::parse_hex(&text) + .map(crate::color_picker_ui::hex) + .or_else(|| text.trim().eq_ignore_ascii_case("none").then(|| "none".to_string())); + if let Some(matte) = matte { + f.insert("matte".into(), json!(matte)); + } +} + /// A size row in a two-column grid: the label, then a field showing `shown`. Returns the typed /// value when it changed, compared with the value the widget was given so a field that only /// clamps does not count as an edit. From efc3cf04a36f19c74f641a6c4a8036d48a9798f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= <55043719+hnoerenberg@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:22:41 +0100 Subject: [PATCH 211/216] test(ui): pin the options-bar swatch's rounded checker clip (#8741) (#8898) * test(ui): pin the options-bar swatch's rounded checker clip (#8741) The fix itself is already on main: 1505c4260 (the shape Fill/Stroke popover branch) clips the swatch's transparency checker to the rounded corners with widgets::checker_rounded, and cites this issue. What main does not have is anything pinning it, so the white specks dark themes showed past the corners could silently come back. This test drives the options-bar Fill swatch through the kittest Harness and asserts that every paint primitive inside the swatch rect is rounded. With the square checker its light cells painted past the corner radius, which is what #8741 reported, so the assert fails on the square version and passes on the clipped one. Co-Authored-By: Claude Opus 5.5 * test(ui): cover both shape swatch callers and assert the rounded checker is painted (#8741) The test rendered only the Properties panel although named for the options bar. Now it covers both callers of the shared swatch (Properties Appearance row and the shape tools' options bar) and asserts exactly one rounded textured checker inside each swatch, so a swatch that paints no checker fails too. Co-Authored-By: Claude Opus 5.5 * test(ui): the swatch checker must be rounded exactly like the fill (#8741) A checker rounded less than the fill would still show past its corners, so the test compares the corner radius with the theme's radius_sm, which the swatch fill uses, instead of accepting any non-zero radius. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .../src/tools/vector_ui/paint_tests.rs | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/crates/ui-egui/src/tools/vector_ui/paint_tests.rs b/crates/ui-egui/src/tools/vector_ui/paint_tests.rs index 4f20f228c6..6c738735b0 100644 --- a/crates/ui-egui/src/tools/vector_ui/paint_tests.rs +++ b/crates/ui-egui/src/tools/vector_ui/paint_tests.rs @@ -617,6 +617,54 @@ fn options_bar_fill_swatch_recolours_the_shape_just_drawn() { assert_eq!(shape(h.state()).fill, original.fill); } +/// #8741: a shape paint swatch draws a rounded fill over its transparency checker, so the checker +/// has to be rounded too. The square one painted light cells past the rounded corners, which read +/// as specks on dark themes; the other colour swatches went to `checker_rounded` for the same +/// reason in #3363. Both callers of the shared `swatch` are pinned: the Properties panel's +/// Appearance row and the shape tools' options bar. +fn assert_swatch_checkers_are_rounded(h: &Harness<'_, PhotocraftApp>, labels: [&str; 2]) { + let panel = h.ctx.global_style().visuals.panel_fill; + // the fill's own corners: a checker rounded less would still show past them + let radius = egui::CornerRadius::from(crate::theme::Tokens::get(&h.ctx).radius_sm); + for label in labels { + let swatch = h.get_by_label(label).rect(); + let (mut square, mut rounded) = (Vec::new(), 0); + for c in &h.output().shapes { + // the harness's own panel background behind the widget is not the swatch's checker + if let egui::Shape::Rect(r) = &c.shape { + if !swatch.contains_rect(r.rect) { + continue; + } + if r.corner_radius == egui::CornerRadius::ZERO && r.fill != panel { + square.push(r.rect); + } else if r.corner_radius == radius && r.brush.is_some() { + // `checker_rounded`: a textured rect rounded like the fill + rounded += 1; + } + } + } + assert!(square.is_empty(), "{label}: square shapes inside the rounded swatch {swatch:?}: {square:?}"); + assert_eq!(rounded, 1, "{label}: exactly one checker rounded like the fill inside the swatch {swatch:?}"); + } +} + +#[test] +fn shape_paint_swatch_checker_keeps_to_the_rounded_corners() { + let shape = |a: &mut PhotocraftApp| { + a.run("shape.create", json!({"rect": [20,20,80,60], "fill": "#80808080", "stroke": {"width": 4, "color": "#ee335580"}})).unwrap(); + }; + // Properties panel (Appearance row) + let mut a = app("rgb", 8); + shape(&mut a); + let h = harness(a, true); + assert_swatch_checkers_are_rounded(&h, ["Set shape fill type", "Set shape stroke type"]); + // Options bar + let mut a = app("rgb", 8); + shape(&mut a); + let h = harness(a, false); + assert_swatch_checkers_are_rounded(&h, ["Fill color", "Stroke color"]); +} + fn test_gradient() -> photocraft_doc::Fill { photocraft_doc::Fill::Gradient { stops: vec![(0.0, photocraft_doc::Color::rgba(0.123456, 0.5, 0.75, 1.0)), (1.0, photocraft_doc::Color::rgba(0.9, 0.2, 0.1, 0.6))], From 6b116fadd07f4b5af10f252128aeab85140d6c83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= <55043719+hnoerenberg@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:22:50 +0100 Subject: [PATCH 212/216] fix(ui): the Stroke Path dialog remembers Simulate Pressure (#10527) (#10583) * fix(ui): the Stroke Path dialog remembers Simulate Pressure (#10527) The Stroke Path dialog is a spec-driven Command dialog: `pen_dialog` builds a fresh `fields` map on every opening (the path name plus each command's own extras) and never inserted `simulatePressure`, so the checkbox always came back at the baked-in spec default, off, however often it was applied. `filter_dialog::remember` cannot cover it either: it bails when a dialog carries its own `__spec`. Seed the field from the last applied choice when the dialog opens, and write the applied choice back when OK runs the stroke. The value lives in the UI-state preferences' dialogs map under the command id, `path.stroke` - the same key style as `edit.fill`, `view.newGuide` and `file.print` - so the control channel can still read it. The read is defensive: anything but an object holding a real bool falls back to off. Cancel never reaches the write, so it changes nothing, and a Paths panel Stroke button (no dialog) writes nothing either. Only Simulate Pressure is remembered: the tool choice still opens on the spec's default, and the engine's `path.stroke` default stays `false`, so scripts, Actions and the control channel are unaffected unless they pass the field. Tests: canvas_tool_menu/tests.rs - the applied choice reopens the dialog, Cancel leaves the remembered choice alone, a corrupt saved value falls back to off, and the tool is not remembered. No docs touched: no ui-parity row covers this behaviour, and the scorecard row that mentions Simulate Pressure is generated. Co-Authored-By: Claude Opus 5.5 * test(ui): a failed Stroke Path confirmation does not remember Simulate Pressure (#10527) Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/canvas/canvas_tool_menu.rs | 33 ++++ .../src/canvas/canvas_tool_menu/tests.rs | 145 ++++++++++++++++++ crates/ui-egui/src/dialogs.rs | 4 + 3 files changed, 182 insertions(+) diff --git a/crates/ui-egui/src/canvas/canvas_tool_menu.rs b/crates/ui-egui/src/canvas/canvas_tool_menu.rs index 9d5b84651f..146c3a1990 100644 --- a/crates/ui-egui/src/canvas/canvas_tool_menu.rs +++ b/crates/ui-egui/src/canvas/canvas_tool_menu.rs @@ -1019,6 +1019,33 @@ fn spec_dialog(app: &mut PhotocraftApp, command: &str, label: &str, spec: &str, /// current options) and Simulate Pressure (#2978). const STROKE_PATH_SPEC: &str = r##"{"tool":"brush|pencil|eraser|backgroundEraser|cloneStamp|patternStamp|healingBrush|spotHealingBrush|historyBrush|blur|sharpen|smudge|dodge|burn|sponge|colorReplacement|mixerBrush"="brush","simulatePressure":bool=false}"##; +/// Where the Stroke Path dialog's Simulate Pressure choice is remembered: the UI-state preferences' +/// dialogs map, under the command id, as `edit.fill` and `view.newGuide` use their own (#10527). +const STROKE_PATH_REMEMBERED: &str = "path.stroke"; + +/// The choice the next Stroke Path dialog opens with: what the last OK applied, else off. Saved data +/// is never trusted — anything but an object holding a real bool (a hand-edited preferences file, +/// an agent that wrote something else) falls back to Photoshop's default, off. +fn remembered_simulate_pressure(app: &PhotocraftApp) -> bool { + app.session + .prefs() + .dialogs + .get(STROKE_PATH_REMEMBERED) + .and_then(Value::as_object) + .and_then(|saved| saved.get("simulatePressure")) + .and_then(Value::as_bool) + .unwrap_or(false) +} + +/// OK ran the stroke: remember the Simulate Pressure it applied so the dialog opens with it again +/// next time (#10527). Cancel never reaches here, so cancelling changes nothing, and a stroke run +/// without the dialog (the Paths panel's Stroke button) writes nothing either. A stroke whose params +/// said nothing about pressure applied the engine's default, off. +pub(crate) fn remember_simulate_pressure(app: &mut PhotocraftApp, params: &Value) { + let on = params.get("simulatePressure").and_then(Value::as_bool).unwrap_or(false); + app.session.prefs.edit(|p| p.dialogs.insert(STROKE_PATH_REMEMBERED.into(), json!({ "simulatePressure": on }))); +} + /// The Stroke Path dialog for the path `name` (Paths panel menu, Alt-click on its Stroke button). pub(crate) fn stroke_path_dialog(app: &mut PhotocraftApp, name: &str) { pen_dialog(app, "path.stroke", "Stroke Path…", name); @@ -1055,6 +1082,12 @@ fn pen_dialog(app: &mut PhotocraftApp, command: &str, label: &str, name: &str) { fields.insert(key.into(), json!(value)); } } + if command == "path.stroke" { + // The spec-driven dialog reappears with fresh fields, so the checkbox needs its own seed: + // a schema dialog carrying a `__spec` is outside `filter_dialog::remember`. Only Simulate + // Pressure is remembered; the tool stays on the spec's default (#10527). + fields.insert("simulatePressure".into(), json!(remembered_simulate_pressure(app))); + } spec_dialog(app, command, label, spec, fields); } diff --git a/crates/ui-egui/src/canvas/canvas_tool_menu/tests.rs b/crates/ui-egui/src/canvas/canvas_tool_menu/tests.rs index 6f8d0a3aa8..f07b5e1966 100644 --- a/crates/ui-egui/src/canvas/canvas_tool_menu/tests.rs +++ b/crates/ui-egui/src/canvas/canvas_tool_menu/tests.rs @@ -905,6 +905,151 @@ fn pen_menu_saved_path_clipping_and_numeric_transform() { assert_eq!(app.session.journal.last().map(|(id, _)| id.as_str()), Some("path.transform")); } +// #10527: the Stroke Path dialog's Simulate Pressure checkbox keeps the choice the last OK applied. + +/// A document with the Pen tool picked and a Work Path. Every route to the Stroke Path dialog (the +/// Pen canvas menu, the Paths panel context menu, Alt-click on its Stroke button) ends in the same +/// `pen_dialog`, so driving the menu route covers the other two. +fn stroke_app() -> PhotocraftApp { + let mut app = app(); + app.ui.tool = Tool::Pen; + work_path(&mut app); + app.ui.selected_path = Some("work".into()); + app +} + +/// Open the Stroke Path dialog through the Pen canvas menu and return its id. +fn open_stroke(app: &mut PhotocraftApp) -> u64 { + choose_open(app, "path.stroke"); + let d = app.ui.dialogs.last().expect("Stroke Path… opens a dialog"); + assert_eq!(d.fields.get("__command").and_then(Value::as_str), Some("path.stroke")); + assert_eq!(d.fields.get("name").and_then(Value::as_str), Some("work"), "the Work Path is stroked"); + d.id +} + +/// The Simulate Pressure the freshly opened dialog carries, or `None` when it carries none at all. +/// The spec body draws a missing field as the schema default (off), so `None` and `Some(false)` look +/// the same on screen; asserting a seeded `Some(bool)` pins the defensive read, which must never +/// trust a saved value into a non-bool. +fn pressure_on_open(app: &mut PhotocraftApp) -> Option { + let id = open_stroke(app); + let on = app.ui.dialog_mut(id).unwrap().fields.get("simulatePressure").and_then(Value::as_bool); + app.ui.close_dialog(id); + on +} + +/// Tick or untick Simulate Pressure in the open dialog. +fn set_pressure(app: &mut PhotocraftApp, id: u64, on: bool) { + app.ui.dialog_mut(id).unwrap().fields.insert("simulatePressure".into(), json!(on)); +} + +#[test] +fn stroke_path_reopens_with_the_simulate_pressure_that_was_applied() { + let mut app = stroke_app(); + // The first one opens off: no remembered choice, and Photoshop's default is off. + assert_eq!(pressure_on_open(&mut app), Some(false), "the first Stroke Path opens with a seeded, off checkbox"); + // Tick Simulate Pressure and press OK: the stroke runs with it. + let id = open_stroke(&mut app); + set_pressure(&mut app, id, true); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert_eq!(app.session.journal.last().map(|(id, _)| id.as_str()), Some("path.stroke"), "OK stroked the path"); + // Re-opening shows the choice that was applied (#10527). + assert_eq!(pressure_on_open(&mut app), Some(true), "the applied choice opens next time"); + // Unticking it and pressing OK applies off, which is what opens next time. + let id = open_stroke(&mut app); + set_pressure(&mut app, id, false); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert_eq!(pressure_on_open(&mut app), Some(false), "an applied off wins over the remembered on"); +} + +#[test] +fn stroke_path_cancel_leaves_the_remembered_simulate_pressure_alone() { + let mut app = stroke_app(); + let id = open_stroke(&mut app); + set_pressure(&mut app, id, true); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert_eq!(pressure_on_open(&mut app), Some(true), "the applied choice opens again"); + // Untick it and press Cancel: nothing was applied, so the next opening is unchanged. + let id = open_stroke(&mut app); + set_pressure(&mut app, id, false); + crate::dialogs::cancel(&mut app, id).unwrap(); + assert!(app.ui.dialogs.is_empty(), "Cancel closes the dialog"); + assert_eq!(app.session.journal.iter().filter(|(id, _)| id == "path.stroke").count(), 1, "Cancel ran no stroke"); + assert_eq!(pressure_on_open(&mut app), Some(true), "Cancel leaves the remembered choice alone (#10527)"); + // Cancel can also be the first thing done: still nothing to remember. + let mut fresh = stroke_app(); + let id = open_stroke(&mut fresh); + set_pressure(&mut fresh, id, true); + crate::dialogs::cancel(&mut fresh, id).unwrap(); + assert_eq!(pressure_on_open(&mut fresh), Some(false), "a cancelled on never becomes the remembered choice"); +} + +#[test] +fn stroke_path_falls_back_to_off_for_a_corrupt_saved_simulate_pressure() { + let mut app = stroke_app(); + // Saved data is never trusted: anything but a real bool in the remembered choice reopens the + // checkbox off rather than showing or applying it. + let corrupt = + [json!("not an object"), json!(null), json!({}), json!({"simulatePressure": "yes"}), json!({"simulatePressure": 1}), json!({"simulatePressure": null})]; + for bad in corrupt { + app.session.prefs.edit(|p| p.dialogs.insert("path.stroke".into(), bad.clone())); + assert_eq!(pressure_on_open(&mut app), Some(false), "{bad} must reopen the checkbox off"); + } + // No remembered key at all: off as well, and one applied OK writes a clean value back. + app.session.prefs.edit(|p| p.dialogs.remove("path.stroke")); + assert_eq!(pressure_on_open(&mut app), Some(false), "no remembered choice opens off"); + let id = open_stroke(&mut app); + set_pressure(&mut app, id, true); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert_eq!(app.session.prefs().dialogs.get("path.stroke"), Some(&json!({"simulatePressure": true})), "only the checkbox is stored"); + assert_eq!(pressure_on_open(&mut app), Some(true), "and it comes back after a clean write"); +} + +#[test] +fn stroke_path_failed_confirmation_does_not_remember_simulate_pressure() { + let mut app = stroke_app(); + let id = open_stroke(&mut app); + set_pressure(&mut app, id, true); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert_eq!(pressure_on_open(&mut app), Some(true), "the applied choice is remembered"); + // Untick it, point the dialog at a path that does not exist and press OK: the stroke fails, so + // nothing was applied and nothing may be remembered. + let id = open_stroke(&mut app); + set_pressure(&mut app, id, false); + app.ui.dialog_mut(id).unwrap().fields.insert("name".into(), json!("no such path")); + assert!(crate::dialogs::confirm(&mut app, id).is_err(), "stroking a missing path fails"); + assert_eq!(app.session.journal.iter().filter(|(id, _)| id == "path.stroke").count(), 1, "the failed OK ran no stroke"); + while let Some(d) = app.ui.dialogs.last().map(|d| d.id) { + app.ui.close_dialog(d); + } + assert_eq!(app.session.prefs().dialogs.get("path.stroke"), Some(&json!({"simulatePressure": true})), "a failed OK leaves the memory alone"); + assert_eq!(pressure_on_open(&mut app), Some(true), "the next opening still shows the last applied choice"); +} + +#[test] +fn stroke_path_does_not_remember_its_tool_choice() { + // Only Simulate Pressure sticks (#10527). Nobody asked for the tool to be remembered, so the + // dialog keeps opening on the spec's default and the engine keeps defaulting to the brush. + let mut app = stroke_app(); + let id = open_stroke(&mut app); + app.ui.dialog_mut(id).unwrap().fields.insert("tool".into(), json!("pencil")); + set_pressure(&mut app, id, true); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert_eq!(app.session.journal.last().map(|(id, _)| id.as_str()), Some("path.stroke")); + let id = open_stroke(&mut app); + let (pressure, tool) = { + let f = &app.ui.dialog_mut(id).unwrap().fields; + (f.get("simulatePressure").cloned(), f.get("tool").cloned()) + }; + app.ui.close_dialog(id); + assert_eq!(pressure, Some(json!(true)), "the checkbox is remembered"); + assert_eq!(tool, None, "the tool is not: the dialog opens on the spec default"); + // The Paths panel's plain Stroke button strokes without a dialog, so it writes nothing: the + // remembered choice is still the one the dialog applied. + app.run("path.stroke", json!({"name": "work", "tool": "brush"})).unwrap(); + assert_eq!(app.session.prefs().dialogs.get("path.stroke"), Some(&json!({"simulatePressure": true})), "a dialog-less stroke changes no memory"); +} + #[test] fn pen_shape_menu_enables_shape_ops_and_style_copy_only_when_applicable() { let mut app = app(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 0e527bf908..a280cafd48 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -946,6 +946,10 @@ fn confirm_generic_command(app: &mut PhotocraftApp, d: Dialog) -> Result Date: Tue, 3 Nov 2026 15:22:58 +0100 Subject: [PATCH 213/216] fix(plugins): the install error names the format the file actually is (#10362) (#10633) * fix(plugins): the install error names the format the file actually is (#10362) Plug-ins > Install Plug-in pointed at an old Photoshop plug-in (.8bt) answered with "not a valid plug-in module: not a WebAssembly binary (missing \0asm header)". That was one fixed message for every file that is not wasm: it said what we wanted and nothing about the file the person is holding. format_hint in runtime.rs now reads the magic number and the error says what the file looks like. For a Windows module the message is (one line in the code, wrapped here): not a valid plug-in module: not a WebAssembly binary (missing \0asm header); this file looks like a Windows native executable or library (a .dll, or a Photoshop plug-in such as .8bf/.8bt) - PhotoCraft plug-ins are WebAssembly modules (.wasm) 7F 45 4C 46 says "a Linux native library (ELF)", CE FA ED FE / CF FA ED FE / CA FE BA BE / BE BA FE CA say "a macOS native binary (Mach-O, thin or fat)", 50 4B 03 04 / 50 4B 05 06 say "a zip archive". Any other header keeps today's message byte for byte, so nothing keyed on it changes. The signatures are public format facts, read with starts_with/get so a short or hostile file cannot panic. The error variant, the enum, the ABI check, check_code_section and the wasmi setup are untouched, and native-code loading stays out of scope (PR #7907). Fail-first, before the production change: cargo test -p photocraft-plugins --lib --tests lib: test result: ok. 10 passed; 0 failed abi: test result: FAILED. 19 passed; 1 failed; 0 ignored; 0 measured assertion line: "windows dll or .8bt: not a valid plug-in module: not a WebAssembly binary (missing \0asm header)" (the MZ fixture that was already in the file) cargo test -p photocraft-engine --lib -- plugin test result: FAILED. 7 passed; 1 failed; 0 ignored; 0 measured; 2518 filtered out After: cargo test -p photocraft-plugins lib 10 passed; abi 20 passed; doc-tests 0 cargo test -p photocraft-engine --lib -- plugin test result: ok. 8 passed; 0 failed; 2518 filtered out The new engine test drives the real plugin.install command with a `path` whose file starts with MZ bytes, so it covers the menu path (registry::load_file) end to end, not only the library check. Gates: cargo clippy -p photocraft-plugins --all-targets -- -D warnings and cargo clippy -p photocraft-engine --all-targets -- -D warnings (clean; the only stderr noise is the pre-existing "patch `egui_*` was not used in the crate graph" cargo warnings); cargo xtask layers ("OK: 31 crates, no layering violations."); cargo xtask wasm (" ok 24 crates", " ok photocraft- codecs --features heif", no FAIL line); cargo fmt -p photocraft-plugins -- --check and cargo fmt -p photocraft-engine -- --check (silent, exit 0). The whole engine --lib suite is not run here: it fails on clean main in this checkout for font/i18n reasons (no CRAFT_FONTS_DIR). No doc quoted the old message (git grep "WebAssembly binary" finds only this crate), so nothing to update there. Co-Authored-By: Claude Opus 5.5 * test(plugins): one install-error test per recognised format, tempfile in the engine test (#10362) Review feedback on #10633. The single looped test in abi.rs is now one test per signature (PE/COFF MZ, ELF, Mach-O in all four byte orders, zip), one for an empty or unrecognised file (including signatures cut short, which keep the old message byte for byte) and one showing a valid WebAssembly module still loads. The engine test uses tempfile::tempdir (already a dependency of photocraft-engine) so the directory is removed on a panic, and asserts the message does not contain the temp path: the hint is static text, so the message adds no path. Gates: cargo test -p photocraft-plugins --lib --tests (10 lib, 26 tests); cargo test -p photocraft-engine --lib -- plugin (8 passed); clippy -D warnings on both crates; cargo fmt --all -- --check; cargo xtask layers (OK). Co-Authored-By: Claude Opus 5.5 * test(engine): keep the non-wasm install-error test off wasm32, as tempfile is (#10362) tempfile is a non-wasm32 dependency of photocraft-engine, so the test that uses it is gated like the crate's other file-system tests. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/plugin_cmds/tests.rs | 17 +++++++ crates/plugins/src/runtime.rs | 26 +++++++++- crates/plugins/tests/abi.rs | 58 ++++++++++++++++++++++ 3 files changed, 100 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/plugin_cmds/tests.rs b/crates/engine/src/cmd/plugin_cmds/tests.rs index 5f93ce7909..44b129bc5c 100644 --- a/crates/engine/src/cmd/plugin_cmds/tests.rs +++ b/crates/engine/src/cmd/plugin_cmds/tests.rs @@ -133,6 +133,23 @@ fn example_invert_matches_builtin_invert_and_round_trips_the_registry() { std::fs::remove_dir_all(&dir).ok(); } +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn installing_a_file_that_is_not_wasm_names_what_it_is() { + // #10362: the Plug-ins > Install Plug-in dialog pointed at an old Photoshop plug-in (.8bt, a + // native Windows module) failed with a message that said nothing about that file. `path` goes + // through `registry::load_file`, so this is the exact user path. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("Old Plug-in.8bt"); + std::fs::write(&path, b"MZ\x90\x00\x03\0").unwrap(); + let e = Session::new().execute("plugin.install", json!({"path": path.to_str().unwrap()})).unwrap_err(); + let msg = e.to_string(); + assert!(msg.contains("Windows") && msg.contains("8bf"), "{msg}"); + assert!(msg.contains("WebAssembly"), "{msg}"); + // The hint is static text: no path the person did not choose is added to the message. + assert!(!msg.contains(dir.path().to_str().unwrap()), "{msg}"); +} + #[test] fn params_are_validated_and_passed() { let bytes = wat_plugin("test.cmds.grey", r#"{"amount":{"type":"number","min":0,"max":1,"default":1}}"#, GREY); diff --git a/crates/plugins/src/runtime.rs b/crates/plugins/src/runtime.rs index c3dd8d9ccc..af3b3b8655 100644 --- a/crates/plugins/src/runtime.rs +++ b/crates/plugins/src/runtime.rs @@ -167,6 +167,26 @@ fn check_code_section(bytes: &[u8]) -> Result<()> { Ok(()) } +/// What a file that is not WebAssembly actually is, judged from its magic number, so the install +/// error can name the format the person picked instead of only the one we wanted (public format +/// facts: the PE/COFF, ELF, Mach-O and zip signatures). `None` for anything else. Reads with +/// `starts_with`/`get`, so a file shorter than the signature is fine. +fn format_hint(bytes: &[u8]) -> Option<&'static str> { + if bytes.starts_with(b"MZ") { + return Some("a Windows native executable or library (a .dll, or a Photoshop plug-in such as .8bf/.8bt)"); + } + if bytes.get(..4) == Some(&[0x7f, b'E', b'L', b'F']) { + return Some("a Linux native library (ELF)"); + } + if matches!(bytes.get(..4), Some(&[0xce, 0xfa, 0xed, 0xfe] | &[0xcf, 0xfa, 0xed, 0xfe] | &[0xca, 0xfe, 0xba, 0xbe] | &[0xbe, 0xba, 0xfe, 0xca])) { + return Some("a macOS native binary (Mach-O, thin or fat)"); + } + if matches!(bytes.get(..4), Some(&[0x50, 0x4b, 0x03, 0x04] | &[0x50, 0x4b, 0x05, 0x06])) { + return Some("a zip archive"); + } + None +} + impl Plugin { /// Validates and loads a module: checks the ABI version and reads the manifest. pub fn load(bytes: &[u8], limits: Limits) -> Result { @@ -174,7 +194,11 @@ impl Plugin { return Err(Error::Module(format!("module is {} bytes (limit {})", bytes.len(), limits.max_module_bytes))); } if !bytes.starts_with(b"\0asm") { - return Err(Error::Module("not a WebAssembly binary (missing \\0asm header)".into())); + let msg = "not a WebAssembly binary (missing \\0asm header)"; + return Err(Error::Module(match format_hint(bytes) { + Some(hint) => format!("{msg}; this file looks like {hint} - PhotoCraft plug-ins are WebAssembly modules (.wasm)"), + None => msg.to_string(), + })); } check_code_section(bytes)?; let mut config = Config::default(); diff --git a/crates/plugins/tests/abi.rs b/crates/plugins/tests/abi.rs index bb06069ac8..113d401dcf 100644 --- a/crates/plugins/tests/abi.rs +++ b/crates/plugins/tests/abi.rs @@ -219,6 +219,64 @@ fn malformed_and_oversized_modules_are_refused() { assert!(matches!(e, Error::Abi(_)), "{e}"); } +/// The message `Plugin::load` gives a file that is not WebAssembly and not recognised (#10362). +const NOT_WASM: &str = "not a valid plug-in module: not a WebAssembly binary (missing \\0asm header)"; + +/// Loads `bytes`, which must be refused as a module, and returns the message. +fn refusal(bytes: &[u8]) -> String { + let Err(e) = Plugin::load(bytes, Limits::default()) else { panic!("{bytes:?} was accepted") }; + assert!(matches!(e, Error::Module(_)), "{e}"); + e.to_string() +} + +/// The message names the format (`format`), says plug-ins are WebAssembly, and stays on one line +/// (`Plugin::load` has only bytes, so the hint cannot carry a path). +fn assert_names(bytes: &[u8], format: &str) { + let m = refusal(bytes); + assert!(m.starts_with(NOT_WASM), "{m}"); + assert!(m.contains(format), "{m}"); + assert!(m.contains("PhotoCraft plug-ins are WebAssembly modules"), "{m}"); + assert!(!m.contains('\n'), "{m}"); +} + +#[test] +fn a_pe_coff_file_is_named_as_a_windows_binary() { + // #10362: an old Photoshop plug-in (.8bt) is a native Windows module. + assert_names(b"MZ\x90\x00\x03\0", "Windows"); + assert!(refusal(b"MZ").contains("8bf")); +} + +#[test] +fn an_elf_file_is_named_as_a_linux_library() { + assert_names(&[0x7f, b'E', b'L', b'F', 2, 1, 1], "Linux"); +} + +#[test] +fn a_mach_o_file_is_named_as_a_macos_binary() { + for head in [[0xce, 0xfa, 0xed, 0xfe], [0xcf, 0xfa, 0xed, 0xfe], [0xca, 0xfe, 0xba, 0xbe], [0xbe, 0xba, 0xfe, 0xca]] { + assert_names(&[&head[..], &[7, 1][..]].concat(), "macOS"); + } +} + +#[test] +fn a_zip_file_is_named_as_a_zip_archive() { + assert_names(b"PK\x03\x04\x14\0", "zip"); + assert_names(b"PK\x05\x06\0\0", "zip"); +} + +#[test] +fn an_empty_or_unrecognised_file_keeps_the_plain_message() { + // Nothing recognised (including a signature cut short): today's message, exactly. + for bytes in [&b""[..], &b"x"[..], &b"junkjunk"[..], &[0x7f, b'E'][..], &b"PK"[..]] { + assert_eq!(refusal(bytes), NOT_WASM, "{bytes:?}"); + } +} + +#[test] +fn a_valid_wasm_module_still_loads_unchanged() { + assert!(Plugin::load(INVERT, Limits::default()).is_ok()); +} + #[test] fn a_code_section_claiming_no_functions_but_many_bytes_is_refused() { // Header, then a code section (id 10) declaring 2000 bytes whose function count is 0, cut off From 2fff1f8688ef6a87d1910a89631b5f1706990b16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= <55043719+hnoerenberg@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:36:39 +0100 Subject: [PATCH 214/216] fix(ui): Ctrl+Alt + wheel zooms the canvas like Alt + wheel (#10488) (#10517) * fix(ui): Ctrl+Alt + wheel zooms the canvas like Alt + wheel (#10488) Ctrl+Alt+wheel over the canvas did nothing useful because two conditions in wheel_nav.rs excluded it from the Alt gesture: let latest_alt = wheel.last().map(|(_, _, m, _)| m.alt && !m.command && !m.ctrl); and, in the raw-event accumulator, every branch was filtered with `!m.command && !m.ctrl`. Ctrl+Alt+wheel therefore never set the tracked `alt` and never contributed a notch to `raw`: with Preferences > General > Zoom with Scroll Wheel ON it produced nothing at all (raw stayed zero, so the zoom read an eased ~0), and with it OFF it fell through to classify's `_` arm and merely scrolled vertically, because egui keeps vertical_scroll_modifier = ALT and does not fold the delta onto x when both scroll patterns match. The combination is a dead/incorrect gesture today, so no working behaviour is being replaced. The new `alt_gesture` helper forgives `command` only when `ctrl` is set as well. winit mirrors a held Ctrl on Windows (and Linux) into BOTH ctrl and command, so a bare `!command` test can never match what a Windows reporter typed; macOS Cmd+Alt (command set, ctrl not) is left out of the zoom gesture (egui 0.37 does not fold it sideways either: with Alt held both scroll patterns match, so its delta stays vertical). `latest_shift` stays strict, so Ctrl+Alt+Shift+wheel zooms continuously instead of snapping to the ladder, and the Shift-only and plain branches are untouched, so plain Ctrl+wheel still scrolls sideways (#635). `legacy_pinch_to_zoom` is untouched and cannot double-count a fractional Ctrl+Alt pinch step: it matches `modifiers.ctrl` whatever else is held, but it *replaces* the wheel event with `Event::Zoom`, so `wheel_events` never sees that step a second time. Deliberately out of scope: the other half of the report, the centred-zoom / overscroll complaint. The per-frame view clamp at canvas.rs:1737-1741 runs only when Preferences > Tools > Overscroll is off (it is on by default), so with the defaults nothing pulls the view centre back, and what the reporter is seeing is more likely the deliberate wheel_anchor nearest-image-point clamp (view_xform.rs:239, #3720) or Zoom Clicked Point to Center. That needs its own reproduction and issue, and is noted as still open in the dev log. Tests, fail-first: each_modifier_maps_like_photoshop gained Windows Ctrl+Alt rows (preference off and on, instant and eased, a fractional pinch step counted once, and a macOS Cmd+Alt guard row) while keeping its Ctrl-only sideways-scroll rows; new full-app kittest test input_tests::ctrl_alt_wheel_zooms_the_canvas_like_alt_wheel zooms five notches around the pointer and holds the document point under it. Both failed before the production change and pass after. Co-Authored-By: Claude Opus 5.5 * test(ui): Ctrl + Alt + Shift + wheel is dead today, not a zoom (#10488) CodeRabbit 4432102938 asked for an assertion on the Ctrl + Alt + Shift combination instead of only a comment, and for the reason: the filter rejects the wheel event. Both are right. With ctrl held, latest_shift (wheel_nav.rs:381) is false because it excludes ctrl and command, so the alt branch of the filter (wheel_nav.rs:413-422) runs and its "!m.shift" drops the notch out of raw; shift stays false, so the snap branch never takes it either. The new row in each_modifier_maps_like_photoshop first asserted what my own comment claimed - a continuous zoom, with Zoom with Scroll Wheel off and Animated Zoom off - and failed exactly that way: "Ctrl + Alt + Shift + wheel zooms 10%, at once: [0.0 0.0] 1 snaps 0". Pan zero, zoom 1.0, snaps 0, and the same numbers with Animated Zoom on, while plain Alt + Shift still snaps. The gesture is dead, and only the ctrl half of it kills it. So the three places that asserted a continuous zoom are corrected here instead of left to rot: the module doc (wheel_nav.rs:8-11), the comment above the filter (wheel_nav.rs:407-413) and the parity row (docs/ui-parity.md:193). They now state that the gesture is dead, point at the test that pins it, and name the open question. No behaviour change: the repo has no measured Photoshop evidence for this combination (the issue names only Ctrl + Alt), so the choice - (a) accept Shift in the alt branch when Ctrl is held, making it zoom like Alt + wheel, or (b) treat it as exactly Alt + Shift and snap - goes to a maintainer rather than being invented. Option (b) has a measured cost worth knowing before anyone picks it: plain Ctrl + Shift + wheel pans sideways today ([40,0], zoom 1.0, snaps 0, and no test covers it), and under (b) that becomes a no-op with Zoom with Scroll Wheel on. Gates: cargo test -p photocraft-ui-egui --lib -- wheel = 102 passed, 0 failed (with the promised assertion first: 101 passed, 1 failed); cargo clippy -p photocraft-ui-egui --all-targets -- -D warnings clean; cargo fmt -p photocraft-ui-egui -- --check clean. Co-Authored-By: Claude Opus 5.5 * test(ui): pin Ctrl + Alt + Shift + wheel with Zoom with Scroll Wheel on (#10488) Review 4433950859: both Ctrl + Alt + Shift + wheel assertions ran with Zoom with Scroll Wheel off, while the test comment, the wheel_nav.rs filter comment and the ui-parity.md Wheel row stated the no-action result unqualified. Measured, not copied: a temporary probe test ran one Ctrl + Alt + Shift notch (Line unit, gesture_with, 60 frames summed) in all four preference combinations and printed (pan, zoom, snaps) through an assert that was made to fail: zww=off anim=off ([0.0 0.0], 1.0, 0) | zww=off anim=on ([0.0 0.0], 1.0, 0) zww=on anim=off ([0.0 40.0], 1.0, 0) | zww=on anim=on ([0.0 40.0], 1.0, 0) test result: FAILED. 102 passed; 1 failed; 0 ignored; 0 measured; 4526 filtered out; finished in 2.63s The suggestion's premise holds: the dead-gesture claim is true only with Zoom with Scroll Wheel off. With the preference on, alt_gesture keeps the notch out of the branch's raw, but `alt != zoom_with_wheel` is false so the zoom arms are skipped, and egui's own smoothed scroll delta (which the raw filter never gates) pans the notch through the ordinary scroll path: one whole notch, 40 points vertical, no zoom, no snap, with Animated Zoom off AND on (it only spreads the same 40 points over later frames). Probe deleted; the qualified version landed in all three artefacts: - wheel_nav/tests.rs: the two dead assertions now say "with Zoom with Scroll Wheel off", two new assertions pin the preference-on pan (near [0.0 40.0], zoom 1.0, snaps 0, both Animated Zoom modes), and the comment and the "not even a scroll" sentence carry the preference condition and the numbers. - wheel_nav.rs classification comment: the dead-gesture sentence is qualified the same way. - docs/ui-parity.md Wheel row: same qualification plus the measured numbers. Final gates: cargo test -p photocraft-ui-egui --lib -- wheel -> test result: ok. 102 passed; 0 failed (also re-run after cargo fmt re-wrapped one over-long assert, same result); clippy -D warnings clean; fmt --check exit 0; cargo xtask layers: OK, 31 crates, no layering violations. Co-Authored-By: Claude Opus 5.5 * fix(ui): keep Cmd+Ctrl+Alt + wheel off the Alt zoom path, pin AltGr (#10488) alt_gesture now also requires !mac_cmd when forgiving command, so macOS Cmd+Ctrl+Alt (command, ctrl, mac_cmd, alt) is not the zoom gesture. Tests pin the macOS and Windows/Linux modifier shapes and that AltGr reaches egui as neither ctrl nor alt. Co-Authored-By: Claude Opus 5.5 * fix(ui): keep Ctrl+Alt+Shift + wheel as before, correct the AltGr note, pin the Alt key fallback (#10488) Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/canvas/wheel_nav.rs | 38 +++++++- crates/ui-egui/src/canvas/wheel_nav/tests.rs | 92 ++++++++++++++++++++ crates/ui-egui/src/input_tests.rs | 34 ++++++++ docs/ui-parity.md | 7 +- 4 files changed, 165 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/canvas/wheel_nav.rs b/crates/ui-egui/src/canvas/wheel_nav.rs index 0d190a0633..3ada75339b 100644 --- a/crates/ui-egui/src/canvas/wheel_nav.rs +++ b/crates/ui-egui/src/canvas/wheel_nav.rs @@ -3,6 +3,12 @@ //! - The wheel scrolls up and down; ⇧ or ⌘/Ctrl + wheel scrolls sideways. //! - ⌥/Alt + wheel zooms around the pointer, 10% per wheel notch (eased or instant, see below). //! ⇧/Shift + ⌥/Alt + wheel snaps to the next zoom ladder level instead. +//! On Windows and Linux Ctrl + ⌥/Alt + wheel (no ⇧) is the same zoom gesture (`alt_gesture`, +//! #10488): the windowing layer reports a held Ctrl as both `ctrl` and `command`, and Ctrl + Alt + +//! wheel was doing nothing before. Ctrl + Alt + ⇧ + wheel is deliberately left exactly as it was +//! before #10488 (a plain scroll with Zoom with Scroll Wheel off, nothing with it on; pinned by a +//! test in `wheel_nav/tests.rs`). Which Photoshop behaviour it should have is open on #10488: +//! there is no measured Photoshop evidence for the combination. //! - Preferences › General › Zoom with Scroll Wheel swaps the two: the wheel zooms and //! ⌥/Alt + wheel scrolls; ⇧/Shift + wheel snaps to the next zoom ladder level. //! - A trackpad pinch zooms around the pointer, unless Preferences › Enhanced Controls › @@ -339,6 +345,27 @@ pub fn read(ctx: &Context, zoom_with_wheel: bool, pinch_zooms: bool, animated_zo read_with(ctx, zoom_with_wheel, pinch_zooms, animated_zoom, owner, hovered, true, true) } +/// Is this wheel event part of the ⌥/Alt zoom gesture (#10488)? +/// +/// winit reports a held Ctrl on Windows (and Linux) as BOTH `ctrl` and `command`, so a plain +/// `!command` test excluded Ctrl + Alt + wheel there and left it doing nothing. `command` is only +/// forgiven together with `ctrl`, without `mac_cmd` and without ⇧: Ctrl + Alt + ⇧ + wheel keeps +/// the behaviour it had before #10488 (it is not an Alt gesture). On macOS egui-winit sets +/// `command` and `mac_cmd` from the Command key and `ctrl` from Control, so ⌘ + ⌥ and ⌘ + ⌃ + ⌥ +/// both stay out of the zoom gesture and are left to egui's own scroll handling; on Windows and +/// Linux `mac_cmd` is never set. +/// +/// AltGr: on Windows, winit (`get_agnostic_mods`) removes ctrl and alt from the modifier state +/// while AltGr is down on a layout that has one, and on X11/Wayland AltGr is `ISO_Level3_Shift` +/// (Mod5), which is neither; so AltGr does not arrive as Ctrl + Alt. It is not excluded here +/// though: egui-winit also turns the physical AltRight key into a `Key::AltRight` event, and +/// `wheel_events` sets `alt` from it (the #8049 fallback), so AltGr + wheel can reach this +/// function with `alt` set and `command` clear and zoom like ⌥. Not verified on a physical AltGr +/// keyboard; behaviour deliberately unchanged. +fn alt_gesture(m: &Modifiers) -> bool { + m.alt && (!m.command || (m.ctrl && !m.mac_cmd && !m.shift)) +} + /// [`read`], choosing how a plain wheel scroll pans: `smooth` glides it over a few frames (egui's /// smoothed delta); off, as with Interface › Enable Animations off, the view moves the whole notch /// in the frame the wheel turns and stops there (#5721), as scroll areas already do. @@ -362,7 +389,7 @@ pub fn read_with( // --- Modifier tracking (always runs for all callers) --- let (scroll, zoom_delta, latest_alt, latest_shift, wheel, page) = ctx.input(|i| { let wheel = wheel_events(i); - let latest_alt = wheel.last().map(|(_, _, m, _)| m.alt && !m.command && !m.ctrl); + let latest_alt = wheel.last().map(|(_, _, m, _)| alt_gesture(m)); let latest_shift = wheel.last().map(|(_, _, m, _)| m.shift && !m.command && !m.ctrl); (i.smooth_scroll_delta, pinch_factor(i, touch_gestures), latest_alt, latest_shift, wheel, i.content_rect().height()) }); @@ -389,14 +416,19 @@ pub fn read_with( // --- Input classification (hovered viewport only) --- // The wheel events that belong to the gesture in progress: Shift+Alt (snap zoom), Alt-only // (continuous zoom), Shift-only (snap zoom with zoom_with_wheel), or plain (scroll/zoom). + // The Alt branches take [`alt_gesture`], so Ctrl + Alt (Windows' ctrl + command mirror) counts + // as Alt here too. Ctrl + Alt + ⇧ does not (`alt_gesture` refuses it, `latest_shift` keeps its + // strict test): it is neither alt nor shift here, as before #10488, so the notch is a plain + // scroll with Zoom with Scroll Wheel off and nothing with it on. Pinned in `wheel_nav/tests.rs`; + // which Photoshop gesture it should be is open on #10488. // `instant`: the part of `raw` egui leaves unsmoothed (a trackpad; see `wheel_events`). let (raw, instant) = wheel .iter() .filter(|(_, _, m, _)| { if alt && shift { - m.alt && m.shift && !m.command && !m.ctrl + alt_gesture(m) && m.shift } else if alt { - m.alt && !m.shift && !m.command && !m.ctrl + alt_gesture(m) && !m.shift } else if shift { m.shift && !m.alt && !m.command && !m.ctrl } else { diff --git a/crates/ui-egui/src/canvas/wheel_nav/tests.rs b/crates/ui-egui/src/canvas/wheel_nav/tests.rs index d2211a7daa..423cc4b488 100644 --- a/crates/ui-egui/src/canvas/wheel_nav/tests.rs +++ b/crates/ui-egui/src/canvas/wheel_nav/tests.rs @@ -616,6 +616,50 @@ fn each_modifier_maps_like_photoshop() { assert!(pan == Vec2::ZERO && (zoom - 1.1).abs() < 1e-4, "{pan:?} {zoom}"); let (pan, zoom, _) = gesture(vec![vec![wheel(notch, CTRL)]], true, false); assert!(near(pan, Vec2::new(40.0, 0.0)) && zoom == 1.0, "⌘/Ctrl + wheel still scrolls sideways: {pan:?} {zoom}"); + + // #10488, as Windows sends it: winit reports a held Ctrl as BOTH `ctrl` and `command`, so a + // gesture condition that only asks for `!command` never fires there. Ctrl + ⌥ + wheel is a dead + // gesture today (nothing at all with Zoom with Scroll Wheel on, a plain vertical scroll with it + // off), so teaching it to zoom like ⌥ + wheel replaces no working behaviour. + let ctrl_alt = Modifiers { alt: true, ctrl: true, command: true, ..Modifiers::NONE }; + let (pan, zoom, _) = one(ctrl_alt); + assert!(pan == Vec2::ZERO && (zoom - NOTCH).abs() < 1e-4, "Ctrl + ⌥ + wheel zooms 10%, at once: {pan:?} {zoom}"); + // With the preference on the gesture swaps: ⌥ + wheel scrolls, so Ctrl + ⌥ + wheel scrolls too. + let (pan, zoom, _) = gesture(vec![vec![wheel(notch, ctrl_alt)]], true, false); + assert!(near(pan, Vec2::new(0.0, 40.0)) && zoom == 1.0, "with the preference on, Ctrl + ⌥ + wheel scrolls like ⌥: {pan:?} {zoom}"); + // Animated Zoom on (the default): the notch is eased over its frames, ×1.1 in total. + let (pan, zoom, _) = gesture_with(vec![vec![wheel(notch, ctrl_alt)]], false, false, true); + assert!(pan == Vec2::ZERO && (zoom - NOTCH).abs() < 1e-4, "eased, one Ctrl + ⌥ notch is still ×1.1: {pan:?} {zoom}"); + // A Windows touchpad pinch while ⌥ is held keeps folding into one pinch event: the fold looks at + // `ctrl` whatever else is held, and *replaces* the wheel event, so nothing is counted twice. + let (pan, zoom, _) = gesture(vec![vec![wheel(Vec2::new(0.0, 0.25), ctrl_alt)]], false, true); + let speed = egui::InputOptions::default().scroll_zoom_speed; + assert!(pan == Vec2::ZERO && (zoom - (speed * 40.0 * 0.25).exp()).abs() < 1e-4, "a fractional Ctrl + ⌥ step zooms once: {pan:?} {zoom}"); + // Ctrl + ⌥ + ⇧ + wheel keeps the behaviour it had on main before #10488, in all four + // combinations of Zoom with Scroll Wheel and Animated Zoom (measured on origin/main with a + // probe test): with the preference off the notch is a plain vertical scroll (pan [0 40], no + // zoom, no snap); with it on the notch does nothing at all. `alt_gesture` refuses the + // ctrl + command mirror together with ⇧, so the classification sees neither alt nor shift, + // exactly as `!command && !ctrl` did. Which Photoshop gesture the combination should be is + // open on #10488 (no measured evidence), so it is pinned, not chosen. + let ctrl_alt_shift = Modifiers { alt: true, ctrl: true, command: true, shift: true, ..Modifiers::NONE }; + for animated in [false, true] { + let (pan, zoom, snaps) = gesture_with(vec![vec![wheel(notch, ctrl_alt_shift)]], false, false, animated); + assert!( + near(pan, Vec2::new(0.0, 40.0)) && zoom == 1.0 && snaps == 0, + "Ctrl + ⌥ + ⇧ + wheel scrolls as before with the preference off (animated {animated}): {pan:?} {zoom} snaps {snaps}" + ); + let (pan, zoom, snaps) = gesture_with(vec![vec![wheel(notch, ctrl_alt_shift)]], true, false, animated); + assert!(pan == Vec2::ZERO && zoom == 1.0 && snaps == 0, "and does nothing with the preference on (animated {animated}): {pan:?} {zoom} snaps {snaps}"); + } + // Without the Windows ctrl mirror the same wheel does snap, so only the ctrl half kills it. + let (pan, zoom, snaps) = one(Modifiers { alt: true, shift: true, ..Modifiers::NONE }); + assert!(pan == Vec2::ZERO && zoom == 1.0 && snaps == 1, "⌥ + ⇧ + wheel still snaps: {pan:?} {zoom} snaps {snaps}"); + // macOS ⌘ + ⌥ (command set, ctrl not) is deliberately NOT the zoom gesture: it is left to egui's + // own scroll handling. Ctrl + ⌥ + ⇧ is not the zoom gesture either (the rows above, unchanged + // from before #10488); which behaviour is right for it is the open question on #10488. + let (pan, zoom, _) = one(Modifiers { alt: true, ..CMD }); + assert!(zoom == 1.0 && pan != Vec2::ZERO, "⌘ + ⌥ + wheel keeps scrolling: {pan:?} {zoom}"); } #[test] @@ -1013,3 +1057,51 @@ fn enable_gestures_off_keeps_the_trackpad_pinch() { let zoom = touch_zoom(frames, false); assert!((zoom - 1.25).abs() < 1e-4, "fingers resting: {zoom}"); } + +#[test] +fn alt_gesture_is_per_platform_modifier_shape() { + let alt = Modifiers::ALT; + // Windows / Linux: a held Ctrl arrives as ctrl + command, `mac_cmd` never set. + assert!(alt_gesture(&Modifiers { ctrl: true, command: true, ..alt }), "Ctrl + Alt zooms"); + assert!(alt_gesture(&alt), "Alt zooms"); + // macOS: ⌘ is command + mac_cmd, ⌃ is ctrl alone. + assert!(!alt_gesture(&Modifiers { mac_cmd: true, command: true, ..alt }), "⌘ + ⌥ keeps its meaning"); + assert!(!alt_gesture(&Modifiers { mac_cmd: true, command: true, ctrl: true, ..alt }), "⌘ + ⌃ + ⌥ is not the zoom gesture"); + assert!(alt_gesture(&Modifiers { ctrl: true, ..alt }), "macOS ⌃ + ⌥ (command unset) still counts as Alt"); + // AltGr: winit reports neither alt nor ctrl for it (Windows `get_agnostic_mods` filters both + // out on layouts with an AltGr key; X11/Wayland deliver ISO_Level3_Shift), so it is NONE here. + assert!(!alt_gesture(&Modifiers::NONE), "AltGr alone is no modifier egui sees"); + assert!(!alt_gesture(&Modifiers::CTRL), "Ctrl alone is not Alt"); +} + +#[test] +fn mac_cmd_alt_wheel_does_not_zoom() { + let notch = Vec2::new(0.0, 1.0); + let cmd_alt = Modifiers { mac_cmd: true, command: true, ctrl: true, alt: true, ..Modifiers::NONE }; + let (pan, zoom, _) = gesture(vec![vec![wheel(notch, cmd_alt)]], false, false); + assert!((zoom - 1.0).abs() < 1e-6, "⌘ + ⌃ + ⌥ + wheel must not zoom: {zoom}"); + assert!(pan != Vec2::ZERO, "⌘ + ⌃ + ⌥ + wheel keeps scrolling: {pan:?}"); + let (_, zoom, _) = gesture(vec![vec![wheel(notch, Modifiers { ctrl: true, command: true, alt: true, ..Modifiers::NONE })]], false, false); + assert!(zoom > 1.0, "Ctrl + Alt + wheel zooms on Windows/Linux: {zoom}"); +} + +#[test] +fn alt_key_press_before_the_wheel_counts_as_alt_modifier() { + // `wheel_events` adds ⌥ from an Alt key event when the wheel's own modifiers miss it (#8049): + // the same frame with the key press and a modifier-less wheel must equal the wheel with `alt`. + let notch = Vec2::new(0.0, 1.0); + let key = |pressed| Event::Key { key: Key::AltRight, physical_key: Some(Key::AltRight), pressed, repeat: false, modifiers: Modifiers::NONE }; + for animated in [false, true] { + let via_key = gesture_with(vec![vec![key(true), wheel(notch, Modifiers::NONE)]], false, false, animated); + let via_mods = gesture_with(vec![vec![wheel(notch, Modifiers::ALT)]], false, false, animated); + assert_eq!(via_key, via_mods, "AltRight key press + wheel == wheel with alt (animated {animated})"); + assert!(via_key.1 > 1.0, "and it zooms: {via_key:?}"); + } +} + +#[test] +fn ctrl_alt_shift_mirror_is_not_an_alt_gesture() { + let m = Modifiers { alt: true, ctrl: true, command: true, shift: true, ..Modifiers::NONE }; + assert!(!alt_gesture(&m), "Ctrl + Alt + Shift keeps its pre-#10488 behaviour"); + assert!(alt_gesture(&Modifiers { alt: true, shift: true, ..Modifiers::NONE }), "Alt + Shift is still the snap gesture"); +} diff --git a/crates/ui-egui/src/input_tests.rs b/crates/ui-egui/src/input_tests.rs index 04fe9b8cbe..fb708d9438 100644 --- a/crates/ui-egui/src/input_tests.rs +++ b/crates/ui-egui/src/input_tests.rs @@ -1286,6 +1286,40 @@ fn wheel_zoom_over_the_image_still_anchors_on_the_pointer() { assert!((d1[0] - d0[0]).abs() < 0.1 && (d1[1] - d0[1]).abs() < 0.1, "{d0:?} -> {d1:?}"); } +/// #10488: a Windows reporter pressed Ctrl + Alt + the wheel over the canvas and nothing happened. +/// winit reports a held Ctrl as both `ctrl` and `command`, so the Alt zoom gesture has to accept +/// `command` when `ctrl` is set as well. Ctrl + Alt + wheel now zooms like ⌥ + wheel, ×1.1 a notch +/// around the pointer, and holds the document point under it fixed. +#[test] +fn ctrl_alt_wheel_zooms_the_canvas_like_alt_wheel() { + let mut h = harness(); + // Animated Zoom off: a notch is one instant step, so the frame count does not matter here. + h.state_mut().session.edit_prefs(|p| p.general.animated_zoom = false); + let r = h.state().last_canvas_rect; + let p = pos2(r.center().x - 150.0, r.center().y - 100.0); + h.hover_at(p); + h.run_steps(2); + let (z0, d0) = (zoom(&h), doc_at(&h, p)); + assert!(d0[0] > 0.0 && d0[1] > 0.0, "the pointer starts on the image: {d0:?}"); + let ctrl_alt = Modifiers { alt: true, ctrl: true, command: true, ..Modifiers::NONE }; + for _ in 0..5 { + wheel(&h, 1.0, ctrl_alt); + h.run_steps(3); + } + h.run_steps(20); + let (z1, d1) = (zoom(&h), doc_at(&h, p)); + assert!((z1 / z0 - 1.1f32.powi(5)).abs() < 1e-3, "five Ctrl + Alt notches, ×1.1 each: {z0} -> {z1}"); + assert!((d1[0] - d0[0]).abs() < 0.1 && (d1[1] - d0[1]).abs() < 0.1, "the point under the pointer held: {d0:?} -> {d1:?}"); + // A plain Ctrl + wheel keeps its sideways scroll (#635). + let ca = h.state().ui.views[0].center; + let za = zoom(&h); + wheel(&h, -1.0, Modifiers { ctrl: true, command: true, ..Modifiers::NONE }); + h.run_steps(40); + assert_eq!(zoom(&h), za, "Ctrl + wheel does not zoom"); + let c1 = h.state().ui.views[0].center; + assert!(c1[0] > ca[0] && c1[1] == ca[1], "Ctrl + wheel still pans sideways: {ca:?} -> {c1:?}"); +} + /// #4658: with the Move tool's Show Transform Controls, the band just outside the box shows the /// curved rotate arrow, not a plain pointer, and one drag from there rotates the layer. #[test] diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 56f26760e1..07192e710f 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity: tools, handles, modifiers and feel -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; Shape Fill/Stroke popover as Photoshop's, every colour swatch opens the full Color Picker with a live preview; the text and gradient stop Color Pickers show the picked colour on the canvas before OK, #5530; selected colour sets edit drawing, tone and supported filters; geometry remains open; a paragraph box that can't hold its text enlarges its lower-right handle and shows a black + in it, as the reference application; Free Transform of several selected layers, #6501; Path Operations fixes: the first component keeps combining, a hidden component selection is left alone, a dragged live shape stays live, a refused Pen draft ends once; Pen, Freeform Pen, Curvature Pen and Path Selection Path Operations menu, which changes selected components, `+` / `-` keys, #1397; the Pen carries on an open shape or vector mask path; selection-aware font controls, pending caret fonts and focused commit, plus direct font-name editing with a compact no-match message, #9338; New Group dialog and button modifiers; new layers go into an open active group and join a clipping mask, except that type, shapes, pastes and placed files go above a group whose lock would refuse them; Color panel Hue and Brightness Cube show the out-of-gamut ⚠ and nearest printable colour when the field is at least 53 pt tall, #8672; Art History Brush in the History group of the Y flyout, partial, TOOL-213-20, #1610; Histogram panel: Compact / Expanded / All Channels views, Luminosity and Colors channels, Source, range statistics, Uncached Refresh, live adjustment overlay, Cache Levels preference; the font menu opens on the current family, marked in the recent group when it is a recent one, #9160; Type options bar alignment and direction follow the target paragraph; a swatch click recolours selected type and shape layers with any tool, #9238; UI scale presets done: 105–120% steps, #3222; ⇧⌥ blend mode letters; painting tool Mode menus; ⌥ + an adjustment's shortcut reopens it at its last settings, ⌥⌘L / ⌥⌘M, #8900; Crop default mode: moving the image under the box snaps the frame's edges and centre, #8580; View › Extras hides the Move transform controls, #6699; adjustment clipping stays visible in compact Properties, #5231; Color panel modes from its panel menu, the same in every theme, UI-217-17, #6000; a picked colour recolours the selected type and shapes; Hand tool ⌘ / ⌥ are Zoom In / Zoom Out while held, #6434; the wheel steps value fields, #5473; a dragged guide shows its X or Y beside the pointer, #7680; Type options bar "T" icon scrubs the font size, #6437; Move tool: dragged selected pixels take their own outline as the selection, #7538; artboard backgrounds remain below child Free Transform previews; ⌫/Delete removes the selected smart filter, #4751; Object Finder foreground/background discrimination (#6548); rounded triangle, polygon and star corners; shape Fill/Stroke popovers accept hex RGB values, #6176; a guide let go over either ruler is removed, #6286; Crop rows corrected: saved crop presets and Delete Cropped Pixels off are done, #6562; marching ants in device pixels, #7489; Animated Zoom: holding the Zoom tool zooms continuously, #6210; shape Density and Feather, TOOL-214-12, #5846; Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed; selection-modifier and ⌥-wheel rows match their tests; Properties › Transform shows a shape's path geometry, without its stroke) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (Ctrl + Alt + wheel zooms the canvas like ⌥ + wheel on Windows and Linux, where winit reports a held Ctrl as both ctrl and command, #10488; 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; Shape Fill/Stroke popover as Photoshop's, every colour swatch opens the full Color Picker with a live preview; the text and gradient stop Color Pickers show the picked colour on the canvas before OK, #5530; selected colour sets edit drawing, tone and supported filters; geometry remains open; a paragraph box that can't hold its text enlarges its lower-right handle and shows a black + in it, as the reference application; Free Transform of several selected layers, #6501; Path Operations fixes: the first component keeps combining, a hidden component selection is left alone, a dragged live shape stays live, a refused Pen draft ends once; Pen, Freeform Pen, Curvature Pen and Path Selection Path Operations menu, which changes selected components, `+` / `-` keys, #1397; the Pen carries on an open shape or vector mask path; selection-aware font controls, pending caret fonts and focused commit, plus direct font-name editing with a compact no-match message, #9338; New Group dialog and button modifiers; new layers go into an open active group and join a clipping mask, except that type, shapes, pastes and placed files go above a group whose lock would refuse them; Color panel Hue and Brightness Cube show the out-of-gamut ⚠ and nearest printable colour when the field is at least 53 pt tall, #8672; Art History Brush in the History group of the Y flyout, partial, TOOL-213-20, #1610; Histogram panel: Compact / Expanded / All Channels views, Luminosity and Colors channels, Source, range statistics, Uncached Refresh, live adjustment overlay, Cache Levels preference; the font menu opens on the current family, marked in the recent group when it is a recent one, #9160; Type options bar alignment and direction follow the target paragraph; a swatch click recolours selected type and shape layers with any tool, #9238; UI scale presets done: 105–120% steps, #3222; ⇧⌥ blend mode letters; painting tool Mode menus; ⌥ + an adjustment's shortcut reopens it at its last settings, ⌥⌘L / ⌥⌘M, #8900; Crop default mode: moving the image under the box snaps the frame's edges and centre, #8580; View › Extras hides the Move transform controls, #6699; adjustment clipping stays visible in compact Properties, #5231; Color panel modes from its panel menu, the same in every theme, UI-217-17, #6000; a picked colour recolours the selected type and shapes; Hand tool ⌘ / ⌥ are Zoom In / Zoom Out while held, #6434; the wheel steps value fields, #5473; a dragged guide shows its X or Y beside the pointer, #7680; Type options bar "T" icon scrubs the font size, #6437; Move tool: dragged selected pixels take their own outline as the selection, #7538; artboard backgrounds remain below child Free Transform previews; ⌫/Delete removes the selected smart filter, #4751; Object Finder foreground/background discrimination (#6548); rounded triangle, polygon and star corners; shape Fill/Stroke popovers accept hex RGB values, #6176; a guide let go over either ruler is removed, #6286; Crop rows corrected: saved crop presets and Delete Cropped Pixels off are done, #6562; marching ants in device pixels, #7489; Animated Zoom: holding the Zoom tool zooms continuously, #6210; shape Density and Feather, TOOL-214-12, #5846; Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed; selection-modifier and ⌥-wheel rows match their tests; Properties › Transform shows a shape's path geometry, without its stroke) · **Target:** Adobe Photoshop 2026 How PhotoCraft's direct manipulation compares with Photoshop 2026 (27.11.0): the toolbox, the handles and modifier keys of each gesture, nudging, snapping, numeric precision, navigation, @@ -190,8 +190,8 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Flick panning | done | `flick_pan.rs` (Preferences › Tools › Enable Flick Panning) | | Animated zoom | done | `canvas.rs`, `zoom_tool.rs`, `zoom_hold.rs`; discrete Zoom clicks and View zoom controls ease over 220 ms with anchored transitions and immediate direct navigation (#566); holding a Zoom press still past 0.3 s zooms continuously about the press point (2 doublings a second, ⌥ out) until release (#6210); General › Animated Zoom (default on) controls both behaviors; off snaps discrete zoom immediately, including when disabled mid-transition | | Double-click Zoom tool = 100%, double-click Hand = fit | partial | Zoom missing (#2508) | -| ⌥-wheel zoom around the pointer | partial | ×1.1 per notch, measured on Photoshop (#1718; `wheel_nav.rs`, `input_tests.rs`); #217 asked for ~5 % steps (UI-217-18) | -| Wheel, smooth scrolling and pinch as in Photoshop | partial | #2485, #2463, #1989 | +| ⌥-wheel zoom around the pointer | partial | ×1.1 per notch, measured on Photoshop (#1718; `wheel_nav.rs`, `input_tests.rs`); #217 asked for ~5 % steps (UI-217-18). Ctrl + Alt + wheel is accepted as the same gesture on Windows and Linux (2026-10-31, #10488): winit reports a held Ctrl as both ctrl and command, so `wheel_nav::alt_gesture` forgives `command` only together with `ctrl` and without `mac_cmd`; macOS ⌘ + ⌥ and ⌘ + ⌃ + ⌥ stay out of the gesture and keeps whatever scroll egui 0.37 gives it — with Alt held both scroll patterns match, so its delta is not folded sideways — and Ctrl + Alt + ⇧ + wheel is deliberately left as it was before #10488 (a plain vertical scroll with General › Zoom with Scroll Wheel off, nothing with it on; checked on origin/main and pinned in all four Zoom with Scroll Wheel / Animated Zoom combinations by tests in `wheel_nav/tests.rs`). Which Photoshop gesture that combination should map to is open on #10488 (the issue names only Ctrl + Alt, and nothing here has measured the combination), so no behaviour is claimed. AltGr does not arrive as Ctrl + Alt: winit's Windows `get_agnostic_mods` drops ctrl and alt from the modifier state while AltGr is down on a layout that has one, and X11/Wayland deliver it as ISO_Level3_Shift. It is not excluded from the Alt gesture, though: egui-winit also emits a `Key::AltRight` event for the physical key and `wheel_events` turns that into `alt`, so AltGr + wheel can zoom like ⌥ (not verified on a physical AltGr keyboard; same AltGr situation as in the Brush size/hardness keys row, Keyboard shortcuts) | +| Wheel, smooth scrolling and pinch as in Photoshop | partial | #2485, #2463, #1989; #10488 (Ctrl + Alt + wheel) | | Trackpad rotate drives Rotate View | done | `rotate_view.rs:136` | | Zoom levels to 3200% and beyond | partial | #1813 | | Physical 100% at any display scale | done | #1943 | @@ -350,3 +350,4 @@ Paragraph; the rest open as in-app floating windows. | 2026-10-15 | minor | ⇧⌥ blend mode letters work while a Free Transform box is open, without picking a tool | | 2026-10-14 | minor | Mode dropdowns for Clone Stamp, Pattern Stamp, History Brush, Healing and Paint Bucket; the blend mode keys reach them, and tools without a blend mode (not Dodge, Burn or Sponge) pass the keys to the layers | | 2026-10-16 | minor | Hand tool: ⌘ held zooms in and ⌥ zooms out, as in Photoshop (#6434) | +| 2026-10-31 | minor | Ctrl + Alt + wheel over the canvas zooms like ⌥ + wheel (#10488): ×1.1 a notch around the pointer with Animated Zoom off, eased with it on, and it follows the ⌥ swap when General › Zoom with Scroll Wheel is on. Windows and Linux report a held Ctrl as ctrl AND command (`wheel_nav::alt_gesture`), macOS ⌘ + ⌥ is left alone, plain ⌘/Ctrl + wheel still scrolls sideways; the combination was a dead gesture, so nothing existing was replaced. The centred-zoom half of the report is still open: `canvas.rs:1737` re-clamps the view centre each frame only when Tools › Overscroll is off (it is on by default), so the drift the reporter saw is more likely the deliberate nearest-image-point anchor (`view_xform.rs:239`, #3720) or Zoom Clicked Point to Center, which needs its own repro; no percentage changed | From e3a84b628dc7bb6b9cd821ee089fa3775f9ad67b Mon Sep 17 00:00:00 2001 From: Yuki-init Date: Tue, 3 Nov 2026 14:39:15 +0000 Subject: [PATCH 215/216] TIFF Options: compression, image pyramid and Save Transparency (#8827) (#10932) Offer TIFF compression, image pyramid and transparency options on Save As and Save a Copy, with matching CLI and automation parameters. Follow-up: preserve the unsaved state when a copy discards layers over the document's own file, and check the other export formats for the same issue. Closes #8827. --- apps/photocraft-cli/src/args.rs | 18 + apps/photocraft-cli/src/output.rs | 9 +- apps/photocraft-cli/tests/it/cli.rs | 32 ++ apps/photocraft/src/services/tests.rs | 19 + crates/automation/src/rpc.rs | 6 + crates/automation/src/server.rs | 35 +- .../automation/tests/it/layered_save_dirty.rs | 25 ++ crates/automation/tests/it/mcp.rs | 47 +++ crates/codecs/src/codecs/tiff/encode.rs | 141 +++++++ crates/codecs/src/lib.rs | 3 +- crates/codecs/src/options.rs | 10 + crates/codecs/tests/it/main.rs | 1 + crates/codecs/tests/it/tiff_bench.rs | 17 + crates/codecs/tests/it/tiff_pyramid.rs | 184 +++++++++ crates/engine/src/cmd/file_cmds.rs | 11 +- crates/engine/src/cmd/file_cmds/save.rs | 22 ++ crates/engine/src/cmd/file_cmds/tests.rs | 30 ++ crates/io/src/flat.rs | 2 +- crates/io/src/lib.rs | 8 + crates/io/src/tiff_layers.rs | 123 ++++-- crates/io/tests/it/main.rs | 1 + crates/io/tests/it/tiff_save_options.rs | 365 ++++++++++++++++++ crates/ui-egui/src/app.rs | 1 + crates/ui-egui/src/app/files.rs | 23 +- .../ui-egui/src/dialogs/discard_ui/tests.rs | 3 + .../discard_ui/tests/tiff_options_tests.rs | 308 +++++++++++++++ crates/ui-egui/src/dialogs/tiff_options_ui.rs | 270 +++++++++++-- crates/ui-egui/src/lib.rs | 4 +- crates/ui-egui/src/menus/view_cmds/dialogs.rs | 9 +- crates/ui-egui/src/services.rs | 30 +- docs/control-protocol.md | 4 +- docs/file-format-parity.md | 5 +- docs/gaps.md | 5 +- docs/scorecard.md | 4 +- scorecard/bugs.toml | 2 +- scorecard/file_compat.toml | 2 +- 36 files changed, 1670 insertions(+), 109 deletions(-) create mode 100644 crates/codecs/tests/it/tiff_pyramid.rs create mode 100644 crates/io/tests/it/tiff_save_options.rs create mode 100644 crates/ui-egui/src/dialogs/discard_ui/tests/tiff_options_tests.rs diff --git a/apps/photocraft-cli/src/args.rs b/apps/photocraft-cli/src/args.rs index 33175a15be..fa9b1a655e 100644 --- a/apps/photocraft-cli/src/args.rs +++ b/apps/photocraft-cli/src/args.rs @@ -55,6 +55,14 @@ pub(crate) struct ExportArgs { /// Compress TIFF output: none (the default), lzw or zip #[arg(long, value_name = "none|lzw|zip", value_parser = tiff_compression)] pub(crate) tiff_compression: Option, + /// TIFF Save Transparency: on keeps the transparency (ahead of any alpha or spot channels), + /// off composites the image over white. Without it: kept unless the document has alpha or + /// spot channels + #[arg(long, value_name = "on|off", value_parser = on_off)] + pub(crate) tiff_transparency: Option, + /// Also write a TIFF image pyramid: half-size copies after the full image + #[arg(long)] + pub(crate) tiff_pyramid: bool, /// A TGA's bits per pixel: 32 writes the alpha channel (or else the transparency) as its /// alpha, 24 none. Without it: 32 with an alpha channel or transparency, else 24 #[arg(long, value_name = "24|32", value_parser = tga_bits)] @@ -268,6 +276,8 @@ enum ValueError { TiffCompression, #[error("expected baseline, optimized or progressive")] JpegFormat, + #[error("expected on or off")] + OnOff, /// Why a `--bridge` value, or a PHOTOCRAFT_CONTROL_PORT value for `mcp`, is refused. #[error("expected a port, 0-65535 (--bridge also takes 127.0.0.1:)")] Bridge, @@ -322,6 +332,14 @@ fn tiff_compression(s: &str) -> Result { } } +fn on_off(s: &str) -> Result { + match s { + "on" => Ok(true), + "off" => Ok(false), + _ => Err(ValueError::OnOff), + } +} + fn jpeg_format(s: &str) -> Result { JpegFormat::parse(s).ok_or(ValueError::JpegFormat) } diff --git a/apps/photocraft-cli/src/output.rs b/apps/photocraft-cli/src/output.rs index 831258b315..ac37ec0537 100644 --- a/apps/photocraft-cli/src/output.rs +++ b/apps/photocraft-cli/src/output.rs @@ -8,7 +8,13 @@ use crate::{Result, args::ExportArgs}; impl From<&ExportArgs> for ExportOptions { fn from(a: &ExportArgs) -> Self { // TIFF output is flat unless asked: scripted conversions keep predictable sizes. - let mut o = ExportOptions { tiff_layers: a.tiff_layers, tga_bits: a.tga_bits, pcraft_full_composite: a.pcraft_full_composite, ..Default::default() }; + let mut o = ExportOptions { + tiff_layers: a.tiff_layers, + tiff_transparency: a.tiff_transparency, + tga_bits: a.tga_bits, + pcraft_full_composite: a.pcraft_full_composite, + ..Default::default() + }; if let Some(q) = a.quality { o.encode.jpeg_quality = q; // Asking for a quality asks for a lossy WebP; the default WebP stays lossless. @@ -19,6 +25,7 @@ impl From<&ExportArgs> for ExportOptions { if let Some(c) = a.tiff_compression { o.encode.tiff_compression = c; } + o.encode.tiff_pyramid = a.tiff_pyramid; if let Some(f) = a.jpeg_format { o.encode.jpeg_format = f; } diff --git a/apps/photocraft-cli/tests/it/cli.rs b/apps/photocraft-cli/tests/it/cli.rs index 176fb970fd..5c5ffc7870 100644 --- a/apps/photocraft-cli/tests/it/cli.rs +++ b/apps/photocraft-cli/tests/it/cli.rs @@ -1530,3 +1530,35 @@ fn batch_skips_deselect_with_nothing_selected() { assert_eq!((info["width"].as_u64(), info["height"].as_u64()), (Some(6), Some(8)), "the step after Deselect ran: {info}"); let _ = std::fs::remove_dir_all(&dir); } + +/// TIFF Options from the command line (#8827): `--tiff-compression`, `--tiff-transparency on|off` +/// and `--tiff-pyramid`; a bad value is a usage error that writes nothing. +#[test] +fn tiff_options_choose_compression_transparency_and_pyramid() { + let d = tmp("tiff-options"); + let a = d.join("a.png"); + write_png(&a, 200, 100, 3); + // (flags, compression tag, alpha, directories) + for (flags, compression, alpha, dirs) in [ + (&[][..], 1, true, 1), + (&["--tiff-compression", "lzw"][..], 5, true, 1), + (&["--tiff-transparency", "off", "--tiff-compression", "zip"][..], 8, false, 1), + (&["--tiff-transparency", "on", "--tiff-pyramid"][..], 1, true, 3), + ] { + let out = d.join("a.tif"); + ok(bin().arg("convert").arg(&a).arg(&out).args(flags)); + let bytes = std::fs::read(&out).unwrap(); + let info = photocraft_codecs::tiff_info(&bytes).unwrap(); + assert_eq!(info.pages.len(), dirs, "{flags:?}"); + assert!(info.pages.iter().all(|p| p.compression == compression), "{flags:?}: {:?}", info.pages); + assert_eq!(photocraft_codecs::decode(&bytes).unwrap().layout().has_alpha(), alpha, "{flags:?}"); + } + for (flag, bad, expected) in [("--tiff-transparency", "maybe", "expected on or off"), ("--tiff-compression", "jpeg", "expected none, lzw or zip")] { + let out = d.join("bad.tif"); + let o = bin().arg("convert").arg(&a).arg(&out).args([flag, bad]).output().unwrap(); + let err = String::from_utf8_lossy(&o.stderr); + assert_eq!(o.status.code(), Some(2), "{flag} {bad}"); + assert!(err.contains(flag) && err.contains(expected), "{flag} {bad}: {err}"); + assert!(!out.exists(), "{flag} {bad} wrote a file"); + } +} diff --git a/apps/photocraft/src/services/tests.rs b/apps/photocraft/src/services/tests.rs index 3f8c6a142a..fb2188cdab 100644 --- a/apps/photocraft/src/services/tests.rs +++ b/apps/photocraft/src/services/tests.rs @@ -948,3 +948,22 @@ fn a_panicking_clipboard_read_is_reported_not_a_crash() { let mut set = guarded_clipboard_set(|_: u32, _: u32, _: &[u8]| panic!("clipboard writer bug")); assert!(set(1, 1, &[0; 4]).unwrap_err().contains("Copy failed")); } + +/// #8827: what TIFF Options sets in `ExportSettings` reaches the file the app writes: the +/// compression, Save Transparency and Save Image Pyramid. +#[test] +fn tiff_options_reach_the_written_file() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 200, "height": 100, "background": "transparent"})).unwrap(); + let doc = s.active().unwrap().doc.clone(); + let settings = photocraft_ui_egui::ExportSettings { + tiff_compression: photocraft_codecs::TiffCompression::Lzw, + tiff_transparency: Some(false), + tiff_pyramid: true, + ..Default::default() + }; + let bytes = photocraft_io::export(&doc, "t.tif", &export_options(&settings)).unwrap().bytes; + let info = photocraft_codecs::tiff_info(&bytes).unwrap(); + assert_eq!(info.pages.iter().map(|p| (p.width, p.compression)).collect::>(), [(200, 5), (100, 5), (50, 5)]); + assert!(!photocraft_codecs::decode(&bytes).unwrap().layout().has_alpha()); +} diff --git a/crates/automation/src/rpc.rs b/crates/automation/src/rpc.rs index c7576e08c4..6ac8d96247 100644 --- a/crates/automation/src/rpc.rs +++ b/crates/automation/src/rpc.rs @@ -174,6 +174,12 @@ impl Headless { }; opts.encode.dpx_bits = photocraft_engine::file_cmds::dpx_bits_param(&p, "doc.save").map_err(|e| bad(e.to_string()))?; opts.encode.dds = photocraft_engine::file_cmds::dds_param(&p, "doc.save").map_err(|e| bad(e.to_string()))?.unwrap_or_default(); + if let Some(c) = photocraft_engine::file_cmds::tiff_compression_param(&p, "doc.save").map_err(|e| bad(e.to_string()))? { + opts.encode.tiff_compression = c; + } + let tiff_bool = |key| photocraft_engine::file_cmds::tiff_bool_param(&p, key, "doc.save").map_err(|e| bad(e.to_string())); + opts.tiff_transparency = tiff_bool("tiffTransparency")?; + opts.encode.tiff_pyramid = tiff_bool("tiffPyramid")?.unwrap_or(false); match p.get("quality") { None | Some(Value::Null) => {} Some(v) => { diff --git a/crates/automation/src/server.rs b/crates/automation/src/server.rs index b3bf9f3f11..e5929f2864 100644 --- a/crates/automation/src/server.rs +++ b/crates/automation/src/server.rs @@ -183,6 +183,17 @@ pub struct SaveParams { /// TIFF: keep the layers (Photoshop layer data). Off by default: a flat TIFF. #[serde(default, rename = "tiffLayers")] pub tiff_layers: bool, + /// TIFF: image compression, "none" (the default), "lzw" or "zip". + #[serde(default, rename = "tiffCompression")] + pub tiff_compression: Option, + /// TIFF: Save Transparency. On keeps the transparency, ahead of any alpha or spot channels; + /// off composites the image over white. Unset: kept unless the document has alpha or spot + /// channels. + #[serde(default, rename = "tiffTransparency")] + pub tiff_transparency: Option, + /// TIFF: Save Image Pyramid, half-size copies after the full image. + #[serde(default, rename = "tiffPyramid")] + pub tiff_pyramid: bool, /// TGA: 24 or 32 bits per pixel. 32 writes the alpha channel (or else the transparency) as the /// alpha, 24 none. Unset: 32 with an alpha channel or transparency, else 24. #[serde(default, rename = "tgaBits")] @@ -1122,6 +1133,10 @@ impl PhotocraftMcp { Some((_, Some(bits))) => Some(bits), Some((b, None)) => return Ok(fail(format!("`dpxBits` must be 8, 10, 12 or 16, got {b}"))), }; + let tiff_compression = match photocraft_engine::file_cmds::tiff_compression_param(&json!({"tiffCompression": p.tiff_compression}), "save") { + Ok(c) => c, + Err(e) => return Ok(fail(e.to_string())), + }; if let Some(q) = p.quality.filter(|q| !(1..=100).contains(q)) { return Ok(fail(format!("`quality` must be a whole number from 1 to 100, got {q}"))); } @@ -1133,8 +1148,9 @@ impl PhotocraftMcp { if let Some(b) = self.bridge_client() { // The bridge forwards to the running app's `app.save`, which takes only an optional // path (without one it writes back to the document's own layered file, as headless - // does), the Targa bits and the DDS options, so the other options are headless-only. Saying so beats - // saving with defaults while the caller believes their quality or format was applied. + // does), the Targa bits, the DDS options and the TIFF compression, transparency and + // pyramid, so the other options are headless-only. Saying so beats saving with + // defaults while the caller believes their quality or format was applied. let unsupported: Vec<&str> = [ p.format.is_some().then_some("format"), p.quality.is_some().then_some("quality"), @@ -1162,17 +1178,32 @@ impl PhotocraftMcp { if p.dds_mipmaps { params["ddsMipmaps"] = json!(true); } + // The app's TIFF Options answer, as its Save As takes it. + if let Some(c) = &p.tiff_compression { + params["tiffCompression"] = json!(c); + } + if let Some(t) = p.tiff_transparency { + params["tiffTransparency"] = json!(t); + } + if p.tiff_pyramid { + params["tiffPyramid"] = json!(true); + } return to_result(b.call("app.save", params).await); } let Some(r) = self .headless_op(move |h| { let mut opts = photocraft_io::ExportOptions { tiff_layers: p.tiff_layers, + tiff_transparency: p.tiff_transparency, tga_bits, pcraft_full_composite: p.pcraft_full_composite.unwrap_or(false), ..Default::default() }; opts.encode.dpx_bits = dpx_bits; + if let Some(c) = tiff_compression { + opts.encode.tiff_compression = c; + } + opts.encode.tiff_pyramid = p.tiff_pyramid; opts.encode.dds = photocraft_io::DdsOptions { compression: dds_compression.unwrap_or_default(), mipmaps: p.dds_mipmaps }; if let Some(q) = p.quality { opts.encode.jpeg_quality = q; diff --git a/crates/automation/tests/it/layered_save_dirty.rs b/crates/automation/tests/it/layered_save_dirty.rs index 731a52e850..060560a62f 100644 --- a/crates/automation/tests/it/layered_save_dirty.rs +++ b/crates/automation/tests/it/layered_save_dirty.rs @@ -80,3 +80,28 @@ fn a_flat_export_is_a_copy_and_leaves_the_document_dirty() { assert!(!headless.session.active().unwrap().is_dirty()); std::fs::remove_dir_all(dir).unwrap(); } + +/// The control channel's `doc.save` takes the TIFF Options (#8827): compression, Save +/// Transparency and Save Image Pyramid; a bad value is an error and writes nothing. +#[test] +fn doc_save_takes_tiff_options() { + let dir = tmp("tiff-options"); + let mut headless = Headless::trusted_local(); + headless.handle("doc.new", json!({"width": 200, "height": 100, "background": "transparent"})).unwrap(); + let path = dir.join("o.tif"); + let p = path.to_str().unwrap(); + headless.handle("doc.save", json!({"path": p, "tiffCompression": "zip", "tiffTransparency": false, "tiffPyramid": true})).unwrap(); + let bytes = std::fs::read(&path).unwrap(); + let info = photocraft_codecs::tiff_info(&bytes).unwrap(); + assert_eq!(info.pages.len(), 3); + assert!(info.pages.iter().all(|pg| pg.compression == 8)); + assert!(!photocraft_codecs::decode(&bytes).unwrap().layout().has_alpha()); + let bad = dir.join("bad.tif"); + for params in [json!({"tiffCompression": "jpeg"}), json!({"tiffPyramid": 1}), json!({"tiffTransparency": "no"})] { + let mut params = params; + params["path"] = json!(bad.to_str().unwrap()); + assert!(headless.handle("doc.save", params.clone()).is_err(), "{params}"); + } + assert!(!bad.exists()); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/automation/tests/it/mcp.rs b/crates/automation/tests/it/mcp.rs index 13f1195a17..2320bf7adc 100644 --- a/crates/automation/tests/it/mcp.rs +++ b/crates/automation/tests/it/mcp.rs @@ -466,6 +466,53 @@ async fn headless_doc_save_writes_dds_options() { cleanup(&dir); } +/// TIFF Options (#8827) reach the app's `app.save` through the bridge; a bad compression is +/// refused before anything is sent. +#[tokio::test(flavor = "multi_thread")] +async fn bridge_doc_save_forwards_tiff_options() { + let (addr, app) = fake_app().await; + let client = connect(PhotocraftMcp::bridge(&addr, CONTROL_TOKEN).unwrap()).await; + let saved = json_of(&call(&client, "doc_save", json!({"path": "out.tif", "tiffCompression": "lzw", "tiffTransparency": false, "tiffPyramid": true})).await); + assert_eq!(saved, json!({"saved": {"path": "out.tif", "tiffCompression": "lzw", "tiffTransparency": false, "tiffPyramid": true}})); + for bad in [json!({"path": "out.tif", "tiffCompression": "jpeg"}), json!({"path": "out.tif", "tiffPyramid": "yes"})] { + let r = call(&client, "doc_save", bad.clone()).await; + assert_eq!(r.is_error, Some(true), "{bad}: {}", text(&r)); + } + client.cancel().await.unwrap(); + app.abort(); +} + +/// Headless `doc_save`/`doc_export` write the TIFF Options asked for (#8827): compression, +/// Save Transparency and Save Image Pyramid. +#[tokio::test(flavor = "multi_thread")] +async fn headless_doc_save_writes_tiff_options() { + let dir = tmp("tiff-options"); + let client = connect(headless_in(&dir)).await; + call(&client, "doc_new", json!({"width": 200, "height": 100, "background": "transparent"})).await; + // (params, compression tag, alpha, directories) + for (tool, params, compression, alpha, dirs) in [ + ("doc_export", json!({"path": "plain.tif"}), 1, true, 1), + ("doc_export", json!({"path": "lzw.tif", "tiffCompression": "lzw"}), 5, true, 1), + ("doc_save", json!({"path": "zip.tif", "tiffCompression": "zip", "tiffTransparency": false}), 8, false, 1), + ("doc_save", json!({"path": "pyr.tif", "tiffPyramid": true, "tiffTransparency": true}), 1, true, 3), + ] { + let r = call(&client, tool, params.clone()).await; + assert_ne!(r.is_error, Some(true), "{params}: {}", text(&r)); + let bytes = std::fs::read(dir.join(params["path"].as_str().unwrap())).unwrap(); + let info = photocraft_codecs::tiff_info(&bytes).unwrap(); + assert!(info.pages.iter().all(|p| p.compression == compression), "{params}: {:?}", info.pages); + assert_eq!(info.pages.len(), dirs, "{params}"); + assert_eq!(photocraft_codecs::decode(&bytes).unwrap().layout().has_alpha(), alpha, "{params}"); + } + for bad in [json!({"path": "bad.tif", "tiffCompression": "jpeg"}), json!({"path": "bad.tif", "tiffTransparency": "off"})] { + let r = call(&client, "doc_export", bad.clone()).await; + assert_eq!(r.is_error, Some(true), "{bad}: {}", text(&r)); + } + assert!(!dir.join("bad.tif").exists()); + client.cancel().await.unwrap(); + cleanup(&dir); +} + #[tokio::test(flavor = "multi_thread")] async fn bridge_response_budget_drops_connection_without_retrying_the_operation() { use photocraft_automation::{BridgeClient, budgets::MAX_RESPONSE_BYTES}; diff --git a/crates/codecs/src/codecs/tiff/encode.rs b/crates/codecs/src/codecs/tiff/encode.rs index 5c0e25b1b2..18b02a6031 100644 --- a/crates/codecs/src/codecs/tiff/encode.rs +++ b/crates/codecs/src/codecs/tiff/encode.rs @@ -260,8 +260,13 @@ struct TagSet<'a> { extra_samples: &'a [u16], photoshop_resources: Option<&'a [u8]>, photoshop_layers: Option<&'a [u8]>, + /// An image pyramid level: written with `NewSubfileType` 1 (reduced resolution). + reduced: bool, } +/// TIFF `NewSubfileType` (254). +const TAG_NEW_SUBFILE_TYPE: u16 = 254; + /// Writes one image directory. (The `tiff` encoder only compresses through `write_data`, which /// takes every sample at once.) fn write_one(enc: &mut TiffEncoder<&mut Cursor>, K>, w: u32, h: u32, data: &[C::Inner], tags: &TagSet<'_>) -> tiff::TiffResult<()> @@ -287,6 +292,9 @@ where im.y_resolution(Rational { n: (y * 1000.0).round() as u32, d: 1000 }); } let d = im.encoder(); + if tags.reduced { + d.write_tag(Tag::Unknown(TAG_NEW_SUBFILE_TYPE), 1u32)?; + } if !tags.extra_samples.is_empty() { d.write_tag(Tag::ExtraSamples, tags.extra_samples)?; } @@ -374,6 +382,117 @@ fn write_image(enc: &mut TiffEncoder<&mut Cursor>, K>, img: } } +/// Writes the image pyramid after the full image: each level half the size of the one before +/// (rounded up), colour and alpha only, until the longer side is at most +/// [`crate::TIFF_PYRAMID_MIN`]. An image already that small gets no levels. +fn write_pyramid(enc: &mut TiffEncoder<&mut Cursor>, K>, img: &Image, tags: &TagSet<'_>) -> tiff::TiffResult<()> { + let mut level: Option = None; + loop { + let prev = level.as_ref().unwrap_or(img); + let (w, h) = prev.dimensions(); + if w.max(h) <= crate::TIFF_PYRAMID_MIN { + return Ok(()); + } + let next = half_size(prev).map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e.to_string()))?; + write_image::(enc, &next, tags)?; + level = Some(next); + } +} + +/// `img` at half its size, rounded up, by averaging each 2×2 block (an odd last row or column +/// averages what it has). Colour is weighted by alpha, so transparent pixels don't darken +/// the edges of what they surround. Extra samples are not carried over. Rows are worked in +/// parallel on native targets. +pub(super) fn half_size(img: &Image) -> Result { + let (w, h) = img.dimensions(); + let (nw, nh) = (w.div_ceil(2).max(1), h.div_ceil(2).max(1)); + let layout = img.layout(); + let sample = img.sample_type(); + let ch = layout.channels(); + let bps = sample.bytes(); + let src_row = (w as usize).checked_mul(ch).and_then(|n| n.checked_mul(bps)).ok_or_else(|| CodecError::encode(F, "image row size overflows"))?; + let out_row = (nw as usize).checked_mul(ch).and_then(|n| n.checked_mul(bps)).ok_or_else(|| CodecError::encode(F, "pyramid row size overflows"))?; + let len = out_row.checked_mul(nh as usize).ok_or_else(|| CodecError::encode(F, "pyramid level size overflows"))?; + if img.data().len() < src_row.saturating_mul(h as usize) { + return Err(CodecError::encode(F, "image buffer is shorter than its dimensions")); + } + let mut out = super::bands::zeroed(len)?; + let level = Level { data: img.data(), w: w as usize, h: h as usize, ch, sample, src_row, alpha: layout.has_alpha() }; + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + out.par_chunks_mut(out_row.max(1)).enumerate().for_each(|(y, row)| level.row(y, row)); + } + #[cfg(target_arch = "wasm32")] + out.chunks_mut(out_row.max(1)).enumerate().for_each(|(y, row)| level.row(y, row)); + let mut img2 = Image::from_raw(nw, nh, layout, sample, out)?; + img2.icc = img.icc.clone(); + Ok(img2) +} + +/// The source of one [`half_size`] level. +struct Level<'a> { + data: &'a [u8], + w: usize, + h: usize, + ch: usize, + sample: SampleType, + src_row: usize, + /// The last channel is alpha. + alpha: bool, +} + +impl Level<'_> { + /// Source row `y` as numbers (empty past the bottom). + fn samples(&self, y: usize) -> Vec { + let Some(row) = (y < self.h).then(|| self.data.get(y * self.src_row..(y + 1) * self.src_row)).flatten() else { return Vec::new() }; + match self.sample { + SampleType::U8 => row.iter().map(|&v| f64::from(v)).collect(), + SampleType::U16 => row.as_chunks::<2>().0.iter().map(|b| f64::from(u16::from_ne_bytes(*b))).collect(), + SampleType::F32 => row.as_chunks::<4>().0.iter().map(|b| f64::from(f32::from_ne_bytes(*b))).collect(), + SampleType::F16 => row.as_chunks::<2>().0.iter().map(|b| f64::from(half::f16::from_bits(u16::from_ne_bytes(*b)).to_f32())).collect(), + } + } + + /// Writes output row `y` into `out`. + fn row(&self, y: usize, out: &mut [u8]) { + let rows = [self.samples(y * 2), self.samples(y * 2 + 1)]; + let ch = self.ch; + let bps = self.sample.bytes(); + let mut sums = vec![0f64; ch]; + for (x, px) in out.chunks_exact_mut(ch * bps).enumerate() { + sums.iter_mut().for_each(|s| *s = 0.0); + let (mut n, mut weight) = (0f64, 0f64); + for row in rows.iter().filter(|r| !r.is_empty()) { + for sx in [x * 2, x * 2 + 1].into_iter().filter(|&sx| sx < self.w) { + let Some(p) = row.get(sx * ch..(sx + 1) * ch) else { continue }; + let a = if self.alpha { p.last().copied().unwrap_or(1.0) } else { 1.0 }; + for (c, (sum, v)) in sums.iter_mut().zip(p).enumerate() { + *sum += if self.alpha && c + 1 == ch { *v } else { v * a }; + } + n += 1.0; + weight += a; + } + } + for (c, (sum, dst)) in sums.iter().zip(px.chunks_exact_mut(bps)).enumerate() { + let v = if self.alpha && c + 1 == ch { + sum / n.max(1.0) + } else if weight > 0.0 { + sum / weight + } else { + 0.0 + }; + match self.sample { + SampleType::U8 => dst.copy_from_slice(&[v.round().clamp(0.0, 255.0) as u8]), + SampleType::U16 => dst.copy_from_slice(&(v.round().clamp(0.0, 65535.0) as u16).to_ne_bytes()), + SampleType::F32 => dst.copy_from_slice(&(v as f32).to_ne_bytes()), + SampleType::F16 => dst.copy_from_slice(&half::f16::from_f32(v as f32).to_bits().to_ne_bytes()), + } + } + } + } +} + /// Classic TIFF offsets are 32-bit: a file that may grow past this is written as BigTIFF. const CLASSIC_LIMIT: u64 = u32::MAX as u64; @@ -476,12 +595,28 @@ fn encode_ready(img: &Image, opts: &EncodeOptions) -> Result, CodecError // Document content, not metadata: written whenever present. photoshop_resources: img.meta.photoshop_resources.as_deref().filter(|r| !r.is_empty()), photoshop_layers: img.meta.photoshop_layers.as_deref().filter(|l| !l.is_empty()), + reduced: false, + }; + // A pyramid level holds the colour and alpha only, with the profile so it shows the same. + let alpha_kind: &[u16] = if img.layout().has_alpha() { &[2] } else { &[] }; + let level_tags = TagSet { + icc: tags.icc, + exif: None, + xmp: None, + dpi: None, + text: Vec::new(), + extra_samples: alpha_kind, + photoshop_resources: None, + photoshop_layers: None, + reduced: true, }; // BigTIFF when asked, or when the file could pass the 4 GiB a classic TIFF can address. A // classic write that still runs out of 32-bit offsets (compression grew the data) is redone // as BigTIFF. let estimate = estimated_size(img, &tags); + // The pyramid adds at most a third of the image (1/4 + 1/16 + …). + let estimate = if opts.tiff_pyramid { estimate.saturating_add(img.data().len() as u64 / 3) } else { estimate }; let write = |big: bool| -> tiff::TiffResult> { // An uncompressed file is allocated once at its estimated size: grown as written, the // buffer would double near the end and copy the file (#6607). Compressed output is @@ -497,9 +632,15 @@ fn encode_ready(img: &Image, opts: &EncodeOptions) -> Result, CodecError if big { let mut enc = TiffEncoder::new_big(&mut cursor)?.with_compression(compression).with_predictor(predictor); write_image::(&mut enc, img, &tags)?; + if opts.tiff_pyramid { + write_pyramid::(&mut enc, img, &level_tags)?; + } } else { let mut enc = TiffEncoder::new(&mut cursor)?.with_compression(compression).with_predictor(predictor); write_image::(&mut enc, img, &tags)?; + if opts.tiff_pyramid { + write_pyramid::(&mut enc, img, &level_tags)?; + } } // Growth (or an estimate that was too high) can leave spare capacity: give it back // instead of holding it while the file is saved. diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index 8588019d79..ec9f0995de 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -45,7 +45,8 @@ pub use crate::image::{ MAX_EXTRA_SAMPLES, Metadata, SampleType, }; pub use crate::options::{ - DdsCompression, DdsOptions, DecodeOptions, DpxBits, EncodeOptions, ExrCompression, JpegFormat, Limits, PngCompression, PngStereo, TiffCompression, + DdsCompression, DdsOptions, DecodeOptions, DpxBits, EncodeOptions, ExrCompression, JpegFormat, Limits, PngCompression, PngStereo, TIFF_PYRAMID_MIN, + TiffCompression, }; pub use crate::orientation::{exif_orientation, upright_exif, upright_xmp}; pub use crate::resolution::{exif_resolution, export_exif, export_xmp, photoshop_resolution, xmp_resolution}; diff --git a/crates/codecs/src/options.rs b/crates/codecs/src/options.rs index 2fa3eb699d..9674b4edfb 100644 --- a/crates/codecs/src/options.rs +++ b/crates/codecs/src/options.rs @@ -286,6 +286,10 @@ pub struct DdsOptions { pub mipmaps: bool, } +/// The longer side, in pixels, at which a TIFF image pyramid stops: the last level written is +/// the first one no longer than this ([`EncodeOptions::tiff_pyramid`]). +pub const TIFF_PYRAMID_MIN: u32 = 64; + /// Options for [`crate::encode`]. #[derive(Debug, Clone)] pub struct EncodeOptions { @@ -310,6 +314,11 @@ pub struct EncodeOptions { /// Always write TIFF as BigTIFF (8-byte offsets). Without it a TIFF is written as BigTIFF /// only when it could pass the 4 GiB a classic TIFF can address. pub tiff_bigtiff: bool, + /// TIFF: after the full image, write Photoshop's "Save Image Pyramid" levels: copies at half, + /// a quarter, … of the size, each marked reduced-resolution (`NewSubfileType` 1), down to + /// [`crate::TIFF_PYRAMID_MIN`] px on the longer side. Readers that don't know pyramids open + /// the full image, which comes first. + pub tiff_pyramid: bool, pub exr_compression: ExrCompression, /// DPX bits per sample. `None` follows the image: 8 for 8-bit, 16 otherwise. pub dpx_bits: Option, @@ -334,6 +343,7 @@ impl Default for EncodeOptions { webp_quality: 80, tiff_compression: TiffCompression::None, tiff_bigtiff: false, + tiff_pyramid: false, exr_compression: ExrCompression::Zip16, dpx_bits: None, dds: DdsOptions::default(), diff --git a/crates/codecs/tests/it/main.rs b/crates/codecs/tests/it/main.rs index 72e878c44e..f465a0cbbd 100644 --- a/crates/codecs/tests/it/main.rs +++ b/crates/codecs/tests/it/main.rs @@ -31,6 +31,7 @@ mod tiff_exif; mod tiff_lab; mod tiff_pages; mod tiff_photoshop; +mod tiff_pyramid; mod tiff_regressions; mod tiff_ycbcr; mod webp_frames; diff --git a/crates/codecs/tests/it/tiff_bench.rs b/crates/codecs/tests/it/tiff_bench.rs index e17f6466d4..3ab5c6dd5b 100644 --- a/crates/codecs/tests/it/tiff_bench.rs +++ b/crates/codecs/tests/it/tiff_bench.rs @@ -105,3 +105,20 @@ fn decode_30mp() { let d = strips(W, H, 8, 3, 2, 64, &rgb); time("RGB 8 BigTIFF strips, none", &build(le, true, &[d], &[0]).bytes); } + +/// Save Image Pyramid's cost on top of a plain encode (#8827): RGB/8 and RGBA/16. +#[test] +#[ignore = "benchmark: run in release with --ignored --nocapture"] +fn encode_with_pyramid() { + for (layout, sample, spp) in [(ChannelLayout::Rgb, SampleType::U8, 3), (ChannelLayout::Rgba, SampleType::U16, 4)] { + let raw = pixels(spp); + let data = if sample == SampleType::U16 { raw.iter().flat_map(|&v| (u16::from(v) * 257).to_ne_bytes()).collect() } else { raw }; + let img = Image::from_raw(W, H, layout, sample, data).unwrap(); + for pyramid in [false, true] { + let opts = EncodeOptions { tiff_pyramid: pyramid, ..Default::default() }; + let t = Instant::now(); + let bytes = encode(&img, Format::Tiff, &opts).unwrap(); + println!("{layout:?} {sample:?} pyramid {pyramid}: {:.0} ms, {} MB", t.elapsed().as_secs_f64() * 1000.0, bytes.len() >> 20); + } + } +} diff --git a/crates/codecs/tests/it/tiff_pyramid.rs b/crates/codecs/tests/it/tiff_pyramid.rs new file mode 100644 index 0000000000..5bc0e4bb23 --- /dev/null +++ b/crates/codecs/tests/it/tiff_pyramid.rs @@ -0,0 +1,184 @@ +//! TIFF "Save Image Pyramid" (#8827): reduced-resolution levels after the full image, each half +//! the size of the one before, that every reader skips when it opens the file. + +use photocraft_codecs::*; + +fn pyramid() -> EncodeOptions { + EncodeOptions { tiff_pyramid: true, ..Default::default() } +} + +/// An image of `layout`/`sample` where every sample is `v` (on the 0–1 scale). +fn solid(w: u32, h: u32, layout: ChannelLayout, sample: SampleType, v: f32) -> Image { + let n = w as usize * h as usize * layout.channels(); + let data: Vec = match sample { + SampleType::U8 => vec![(v * 255.0).round() as u8; n], + SampleType::U16 => (0..n).flat_map(|_| ((v * 65535.0).round() as u16).to_ne_bytes()).collect(), + SampleType::F32 => (0..n).flat_map(|_| v.to_ne_bytes()).collect(), + SampleType::F16 => (0..n).flat_map(|_| f16::from_f32(v).to_bits().to_ne_bytes()).collect(), + }; + Image::from_raw(w, h, layout, sample, data).unwrap() +} + +fn sizes(bytes: &[u8]) -> Vec<(TiffPageKind, u32, u32)> { + tiff_info(bytes).unwrap().pages.iter().map(|p| (p.kind, p.width, p.height)).collect() +} + +#[test] +fn levels_halve_down_to_the_minimum() { + use TiffPageKind::*; + let img = solid(300, 200, ChannelLayout::Rgb, SampleType::U8, 0.5); + let bytes = encode(&img, Format::Tiff, &pyramid()).unwrap(); + // 300 → 150 → 75 → 38: the first level no longer than 64 px is the last. + assert_eq!(sizes(&bytes), [(Page, 300, 200), (ReducedResolution, 150, 100), (ReducedResolution, 75, 50), (ReducedResolution, 38, 25)]); + // Opening the file opens the full image, and the levels are not extra pages. + let back = decode(&bytes).unwrap(); + assert_eq!(back.dimensions(), (300, 200)); + assert_eq!(back.data(), img.data()); + assert!(!back.warnings.iter().any(|w| matches!(w, DecodeWarning::MorePages { .. })), "{:?}", back.warnings); +} + +#[test] +fn off_by_default_and_none_for_small_images() { + let img = solid(300, 200, ChannelLayout::Rgb, SampleType::U8, 0.5); + assert_eq!(sizes(&encode(&img, Format::Tiff, &EncodeOptions::default()).unwrap()).len(), 1); + let small = solid(64, 10, ChannelLayout::Rgb, SampleType::U8, 0.5); + assert_eq!(sizes(&encode(&small, Format::Tiff, &pyramid()).unwrap()).len(), 1); + // A 1-px-wide strip still halves its long side. + let strip = solid(1, 200, ChannelLayout::Gray, SampleType::U8, 0.5); + let s = sizes(&encode(&strip, Format::Tiff, &pyramid()).unwrap()); + assert_eq!(s.iter().map(|&(_, w, h)| (w, h)).collect::>(), [(1, 200), (1, 100), (1, 50)]); +} + +#[test] +fn every_depth_layout_and_compression() { + let layouts = + [ChannelLayout::Gray, ChannelLayout::GrayA, ChannelLayout::Rgb, ChannelLayout::Rgba, ChannelLayout::Cmyk, ChannelLayout::CmykA, ChannelLayout::Lab]; + for layout in layouts { + for sample in [SampleType::U8, SampleType::U16, SampleType::F32] { + for compression in [TiffCompression::None, TiffCompression::Lzw, TiffCompression::Deflate, TiffCompression::PackBits] { + let img = solid(120, 70, layout, sample, 0.6); + let opts = EncodeOptions { tiff_compression: compression, ..pyramid() }; + let bytes = encode(&img, Format::Tiff, &opts).unwrap_or_else(|e| panic!("{layout:?} {sample:?} {compression:?}: {e}")); + let info = tiff_info(&bytes).unwrap(); + assert_eq!(info.pages.len(), 2, "{layout:?} {sample:?} {compression:?}"); + let level = decode_tiff_page(&bytes, 1, &DecodeOptions::default()).unwrap(); + assert_eq!(level.dimensions(), (60, 35)); + assert_eq!(level.layout(), decode(&bytes).unwrap().layout(), "{layout:?} {sample:?}"); + // A solid image stays the same colour at every level. + let full = decode(&bytes).unwrap().convert(level.layout(), SampleType::F32); + let level = level.convert(level.layout(), SampleType::F32); + let (a, b) = (full.to_f32_samples().unwrap(), level.to_f32_samples().unwrap()); + for (c, v) in b.iter().take(level.layout().channels()).enumerate() { + assert!((v - a[c]).abs() < 1e-3, "{layout:?} {sample:?} channel {c}: {v} vs {}", a[c]); + } + } + } + } +} + +/// A 128×128 image of 2×2 `tile`s (top left, top right, bottom left, bottom right; alpha last), +/// and the first pixel of its first reduced level. +fn first_level_pixel(layout: ChannelLayout, tile: [&[u8]; 4]) -> Vec { + let mut data = Vec::new(); + for _ in 0..64 { + for x in 0..128 { + data.extend_from_slice(tile[x % 2]); + } + for x in 0..128 { + data.extend_from_slice(tile[2 + x % 2]); + } + } + let img = Image::from_raw(128, 128, layout, SampleType::U8, data).unwrap(); + let bytes = encode(&img, Format::Tiff, &pyramid()).unwrap(); + let level = decode_tiff_page(&bytes, 1, &DecodeOptions::default()).unwrap(); + assert_eq!(level.dimensions(), (64, 64), "{layout:?}"); + assert_eq!(level.layout(), layout); + level.data()[..layout.channels()].to_vec() +} + +#[test] +fn colour_is_weighted_by_alpha() { + // One opaque pixel and three transparent ones of another colour: the level keeps the opaque + // colour at a quarter alpha. Unweighted, the transparent colour would bleed in (pink, not red). + assert_eq!(first_level_pixel(ChannelLayout::Rgba, [&[255, 0, 0, 255], &[255, 255, 255, 0], &[255, 255, 255, 0], &[255, 255, 255, 0]]), [255, 0, 0, 64]); + // Gray and CMYK reach the same path with other channel counts. + assert_eq!(first_level_pixel(ChannelLayout::GrayA, [&[40, 255], &[255, 0], &[255, 0], &[255, 0]]), [40, 64]); + assert_eq!(first_level_pixel(ChannelLayout::CmykA, [&[200, 10, 30, 90, 255], &[0; 5], &[0; 5], &[0; 5]]), [200, 10, 30, 90, 64]); +} + +#[test] +fn colour_is_weighted_between_translucent_pixels() { + // Two half-transparent pixels of different colours (red, blue) and two clear ones: the level + // averages the two by their alpha (purple), where copying either pixel would give red or blue. + let tile: [&[u8]; 4] = [&[255, 0, 0, 128], &[0, 0, 255, 128], &[0; 4], &[0; 4]]; + assert_eq!(first_level_pixel(ChannelLayout::Rgba, tile), [128, 0, 128, 64]); +} + +#[test] +fn layered_and_tagged_data_stay_on_the_full_image() { + let mut img = solid(200, 100, ChannelLayout::Rgb, SampleType::U8, 0.25); + img.icc = Some(photocraft_cms_free_profile()); + img.meta.photoshop_layers = Some(vec![1, 2, 3, 4]); + img.meta.extra_samples = vec![ExtraSample { kind: 0, data: vec![9; 200 * 100] }]; + let bytes = encode(&img, Format::Tiff, &pyramid()).unwrap(); + let info = tiff_info(&bytes).unwrap(); + assert_eq!(info.pages.len(), 3); + // Only the full image carries the layers and the extra samples. + assert_eq!(info.pages[0].samples_per_pixel, 4); + assert!(info.pages[1..].iter().all(|p| p.samples_per_pixel == 3)); + assert_eq!(tiff_photoshop_tags(&bytes).1, Some(&[1u8, 2, 3, 4][..])); + let level = decode_tiff_page(&bytes, 1, &DecodeOptions::default()).unwrap(); + assert_eq!(level.icc, img.icc, "levels keep the profile so they show the same colours"); + assert!(level.meta.extra_samples.is_empty()); +} + +/// Any bytes a decoder keeps as the ICC profile: the codec stores it without parsing. +fn photocraft_cms_free_profile() -> Vec { + (0..=255u8).collect() +} + +#[test] +fn levels_average_exactly_at_every_depth() { + // One 2×2 block of gray values 0, 0.25, 0.5, 1 averages to 0.4375; a 2×2 block with half + // transparent pixels keeps only the opaque colour and averages the alpha. + for sample in [SampleType::U16, SampleType::F32] { + let vals = [0.0f32, 0.25, 0.5, 1.0]; + let mut img = solid(130, 2, ChannelLayout::Gray, sample, 0.0); + let w = 130; + for (i, v) in vals.iter().enumerate() { + let (x, y) = (i % 2, i / 2); + let at = (y * w + x) * sample.bytes(); + let bytes: Vec = match sample { + SampleType::U16 => ((v * 65535.0).round() as u16).to_ne_bytes().to_vec(), + _ => v.to_ne_bytes().to_vec(), + }; + img.data_mut()[at..at + bytes.len()].copy_from_slice(&bytes); + } + let bytes = encode(&img, Format::Tiff, &pyramid()).unwrap(); + let level = decode_tiff_page(&bytes, 1, &DecodeOptions::default()).unwrap().convert(ChannelLayout::Gray, SampleType::F32); + let v = level.to_f32_samples().unwrap()[0]; + assert!((v - 0.4375).abs() < 1e-4, "{sample:?}: {v}"); + } + let mut data = Vec::new(); + for _ in 0..2 { + for x in 0..130u32 { + // RGBA16: opaque blue on even columns, transparent red on odd. + let px: [u16; 4] = if x % 2 == 0 { [0, 0, 65535, 65535] } else { [65535, 0, 0, 0] }; + data.extend(px.iter().flat_map(|v| v.to_ne_bytes())); + } + } + let img = Image::from_raw(130, 2, ChannelLayout::Rgba, SampleType::U16, data).unwrap(); + let bytes = encode(&img, Format::Tiff, &pyramid()).unwrap(); + let level = decode_tiff_page(&bytes, 1, &DecodeOptions::default()).unwrap(); + assert_eq!(level.to_u16_samples().unwrap()[..4], [0, 0, 65535, 32768]); +} + +#[test] +fn bigtiff_files_carry_the_pyramid_too() { + let img = solid(300, 200, ChannelLayout::Rgba, SampleType::U8, 0.5); + let bytes = encode(&img, Format::Tiff, &EncodeOptions { tiff_bigtiff: true, ..pyramid() }).unwrap(); + let info = tiff_info(&bytes).unwrap(); + assert!(info.big_tiff); + assert_eq!(info.pages.iter().map(|p| (p.kind, p.width)).collect::>().len(), 4); + assert_eq!(decode_tiff_page(&bytes, 3, &DecodeOptions::default()).unwrap().dimensions(), (38, 25)); +} diff --git a/crates/engine/src/cmd/file_cmds.rs b/crates/engine/src/cmd/file_cmds.rs index 3280b49c79..5d78a8f72a 100644 --- a/crates/engine/src/cmd/file_cmds.rs +++ b/crates/engine/src/cmd/file_cmds.rs @@ -54,7 +54,8 @@ use processing::{conditional_mode_change, fit_image, flatten_all_effects, flatte use save::save_a_copy; pub(crate) use save::{SaveOpts, encode, save_doc}; pub use save::{ - dds_param, dpx_bits_param, jpeg_format_param, jpeg_quality, pcraft_full_composite_param, tga_bits_param, tiff_compression_param, tiff_would_write_layers, + dds_param, dpx_bits_param, jpeg_format_param, jpeg_quality, pcraft_full_composite_param, tga_bits_param, tiff_bool_param, tiff_compression_param, + tiff_would_write_layers, }; mod batch_naming; @@ -254,7 +255,7 @@ pub fn specs() -> Vec { "Save a Copy…", &["File"], Some("Cmd+Alt+S"), - r##"{"path":str (format from the extension),"quality":0..12? (JPEG),"jpegFormat":"baseline|optimized|progressive"? (JPEG; default baseline),"layers":bool=true,"overwrite":bool=false (replace the document's own file),"tiffLayers":bool=false (TIFF: keep the layers; flat by default),"tiffCompression":"none|lzw|zip"? (TIFF: image compression; unset, none),"tgaBits":24|32? (TGA: 32 writes the alpha channel, or else the transparency, as the alpha; unset, 32 with an alpha channel or transparency, else 24),"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"ddsCompression":"none|bc1|bc2|bc3"="none" (DDS: uncompressed, or block compression; BC1 keeps alpha on or off),"ddsMipmaps":bool=false (DDS: also write the full mip chain),"pcraftFullComposite":bool? (.pcraft: request the flattened document at its canvas size as composite/full.png; it may be omitted; check the manifest's full_composite member; unset, Preferences › File Handling)}"##, + r##"{"path":str (format from the extension),"quality":0..12? (JPEG),"jpegFormat":"baseline|optimized|progressive"? (JPEG; default baseline),"layers":bool=true,"overwrite":bool=false (replace the document's own file),"tiffLayers":bool=false (TIFF: keep the layers; flat by default),"tiffCompression":"none|lzw|zip"? (TIFF: image compression; unset, none),"tiffTransparency":bool? (TIFF: Save Transparency; off composites over white; unset, kept unless the document has alpha or spot channels),"tiffPyramid":bool=false (TIFF: Save Image Pyramid, half-size copies after the image),"tgaBits":24|32? (TGA: 32 writes the alpha channel, or else the transparency, as the alpha; unset, 32 with an alpha channel or transparency, else 24),"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"ddsCompression":"none|bc1|bc2|bc3"="none" (DDS: uncompressed, or block compression; BC1 keeps alpha on or off),"ddsMipmaps":bool=false (DDS: also write the full mip chain),"pcraftFullComposite":bool? (.pcraft: request the flattened document at its canvas size as composite/full.png; it may be omitted; check the manifest's full_composite member; unset, Preferences › File Handling)}"##, native_doc, save_a_copy ), @@ -331,7 +332,7 @@ pub fn specs() -> Vec { "Batch…", &["File", "Automate"], None, - r##"{"steps":[[commandId,params]|{"command":id,"params":{}}…] (a recorded action; a Save, Save As or Save a Copy step marks the document written to output, in this command's format and options: as the save found it if it was closed or another document is active at the end, else as the action leaves it; with no such step, the active document at the end),"input":folder|[paths],"includeSubfolders":bool=false (a folder input also takes the images in its subfolders, up to 32 levels and 100000 files; each result keeps its subfolder under output),"output":folder,"format":"same|png|jpg|psd|tiff|…"="same","quality":0..12?,"jpegFormat":"baseline|optimized|progressive"?,"naming":string? (blank keeps source names; {name}, {serial} padded to 3 digits, {date} as YYYY-MM-DD UTC; extension added automatically),"startingSerial":1..4294967295=1 (input position, including failed inputs),"tiffLayers":bool=false,"tiffCompression":"none|lzw|zip"?,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"ddsCompression":"none|bc1|bc2|bc3"="none" (DDS: uncompressed, or block compression; BC1 keeps alpha on or off),"ddsMipmaps":bool=false (DDS: also write the full mip chain),"calledActions":[{"name","steps"}…]? (the actions Play Action steps call; default: the session's)} → {files, errors} (an input whose output name was already written in the run goes to errors)"##, + r##"{"steps":[[commandId,params]|{"command":id,"params":{}}…] (a recorded action; a Save, Save As or Save a Copy step marks the document written to output, in this command's format and options: as the save found it if it was closed or another document is active at the end, else as the action leaves it; with no such step, the active document at the end),"input":folder|[paths],"includeSubfolders":bool=false (a folder input also takes the images in its subfolders, up to 32 levels and 100000 files; each result keeps its subfolder under output),"output":folder,"format":"same|png|jpg|psd|tiff|…"="same","quality":0..12?,"jpegFormat":"baseline|optimized|progressive"?,"naming":string? (blank keeps source names; {name}, {serial} padded to 3 digits, {date} as YYYY-MM-DD UTC; extension added automatically),"startingSerial":1..4294967295=1 (input position, including failed inputs),"tiffLayers":bool=false,"tiffCompression":"none|lzw|zip"?,"tiffTransparency":bool?,"tiffPyramid":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"ddsCompression":"none|bc1|bc2|bc3"="none" (DDS: uncompressed, or block compression; BC1 keeps alpha on or off),"ddsMipmaps":bool=false (DDS: also write the full mip chain),"calledActions":[{"name","steps"}…]? (the actions Play Action steps call; default: the session's)} → {files, errors} (an input whose output name was already written in the run goes to errors)"##, native, batch ), @@ -340,7 +341,7 @@ pub fn specs() -> Vec { "Image Processor…", &["File", "Scripts"], None, - r##"{"input":folder|[paths],"includeSubfolders":bool=false (a folder input also takes the images in its subfolders, up to 32 levels and 100000 files; each result keeps its subfolder under output),"output":folder,"format":"jpg|png|psd|tiff|…"="jpg","quality":0..12=8,"jpegFormat":"baseline|optimized|progressive"?,"tiffLayers":bool=false,"tiffCompression":"none|lzw|zip"?,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"ddsCompression":"none|bc1|bc2|bc3"="none" (DDS: uncompressed, or block compression; BC1 keeps alpha on or off),"ddsMipmaps":bool=false (DDS: also write the full mip chain),"width":px?,"height":px? (fit, never enlarge),"convertToSrgb":bool=false} → {files, errors} (an input whose output name was already written in the run goes to errors)"##, + r##"{"input":folder|[paths],"includeSubfolders":bool=false (a folder input also takes the images in its subfolders, up to 32 levels and 100000 files; each result keeps its subfolder under output),"output":folder,"format":"jpg|png|psd|tiff|…"="jpg","quality":0..12=8,"jpegFormat":"baseline|optimized|progressive"?,"tiffLayers":bool=false,"tiffCompression":"none|lzw|zip"?,"tiffTransparency":bool?,"tiffPyramid":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"ddsCompression":"none|bc1|bc2|bc3"="none" (DDS: uncompressed, or block compression; BC1 keeps alpha on or off),"ddsMipmaps":bool=false (DDS: also write the full mip chain),"width":px?,"height":px? (fit, never enlarge),"convertToSrgb":bool=false} → {files, errors} (an input whose output name was already written in the run goes to errors)"##, native, image_processor ), @@ -368,7 +369,7 @@ pub fn specs() -> Vec { "Layers to Files…", &["File", "Export"], None, - r##"{"dir":folder,"format":"png|jpg|psd|tiff|…"="png","prefix":str=document name,"visibleOnly":bool=true,"trim":bool=false,"quality":0..12?,"jpegFormat":"baseline|optimized|progressive"?,"tiffLayers":bool=false,"tiffCompression":"none|lzw|zip"?,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"ddsCompression":"none|bc1|bc2|bc3"="none" (DDS: uncompressed, or block compression; BC1 keeps alpha on or off),"ddsMipmaps":bool=false (DDS: also write the full mip chain)} → {files}"##, + r##"{"dir":folder,"format":"png|jpg|psd|tiff|…"="png","prefix":str=document name,"visibleOnly":bool=true,"trim":bool=false,"quality":0..12?,"jpegFormat":"baseline|optimized|progressive"?,"tiffLayers":bool=false,"tiffCompression":"none|lzw|zip"?,"tiffTransparency":bool?,"tiffPyramid":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"ddsCompression":"none|bc1|bc2|bc3"="none" (DDS: uncompressed, or block compression; BC1 keeps alpha on or off),"ddsMipmaps":bool=false (DDS: also write the full mip chain)} → {files}"##, native_doc, layers_to_files ), diff --git a/crates/engine/src/cmd/file_cmds/save.rs b/crates/engine/src/cmd/file_cmds/save.rs index 9c073df890..0f784671c8 100644 --- a/crates/engine/src/cmd/file_cmds/save.rs +++ b/crates/engine/src/cmd/file_cmds/save.rs @@ -26,6 +26,11 @@ pub(crate) struct SaveOpts { pub dpx_bits: Option, /// TIFF: the image compression (`"tiffCompression": "none" | "lzw" | "zip"`); unset, none. pub tiff_compression: Option, + /// TIFF: Save Transparency (`"tiffTransparency": bool`); unset, the transparency is kept + /// unless the document has alpha or spot channels ([`photocraft_io::ExportOptions::tiff_transparency`]). + pub tiff_transparency: Option, + /// TIFF: Save Image Pyramid (`"tiffPyramid": bool`); unset, off. + pub tiff_pyramid: bool, /// DDS: the compression and whether to write the mip chain (`"ddsCompression"`, /// `"ddsMipmaps"`); unset, uncompressed without mipmaps. pub dds: photocraft_io::DdsOptions, @@ -49,6 +54,8 @@ impl Default for SaveOpts { tga_bits: None, dpx_bits: None, tiff_compression: None, + tiff_transparency: None, + tiff_pyramid: false, dds: photocraft_io::DdsOptions::default(), image_previews: true, psd_maximize_compatibility: true, @@ -73,6 +80,8 @@ impl SaveOpts { tga_bits: tga_bits_param(p, cmd)?, dpx_bits: dpx_bits_param(p, cmd)?, tiff_compression: tiff_compression_param(p, cmd)?, + tiff_transparency: tiff_bool_param(p, "tiffTransparency", cmd)?, + tiff_pyramid: tiff_bool_param(p, "tiffPyramid", cmd)?.unwrap_or(false), dds: dds_param(p, cmd)?.unwrap_or_default(), image_previews: file_handling.image_previews != crate::prefs::Ask::Never, psd_maximize_compatibility: file_handling.maximize_psd_compatibility != crate::prefs::Ask::Never, @@ -133,6 +142,17 @@ pub fn tiff_compression_param(p: &Value, cmd: &str) -> Result Result> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(Value::Bool(b)) => Ok(Some(*b)), + Some(v) => Err(EngineError::BadParams { cmd: cmd.into(), msg: format!("`{key}` must be true or false, got {v}") }), + } +} + /// The JPEG format in `p` (`"jpegFormat": "baseline" | "optimized" | "progressive"`; absent or /// null: Baseline). Anything else is a bad parameter of command `cmd`. Shared like /// [`tga_bits_param`]. @@ -195,6 +215,7 @@ pub(crate) fn encode(doc: &Document, path: &str, save: impl Into) -> R let save = save.into(); let mut opts = photocraft_io::ExportOptions { tiff_layers: save.tiff_layers, + tiff_transparency: save.tiff_transparency, tga_bits: save.tga_bits, image_previews: save.image_previews, psd_maximize_compatibility: save.psd_maximize_compatibility, @@ -212,6 +233,7 @@ pub(crate) fn encode(doc: &Document, path: &str, save: impl Into) -> R if let Some(c) = save.tiff_compression { opts.encode.tiff_compression = c; } + opts.encode.tiff_pyramid = save.tiff_pyramid; opts.encode.dds = save.dds; if let Some(q) = save.quality { let q = jpeg_quality(q); diff --git a/crates/engine/src/cmd/file_cmds/tests.rs b/crates/engine/src/cmd/file_cmds/tests.rs index 0f353d76d7..ff66258820 100644 --- a/crates/engine/src/cmd/file_cmds/tests.rs +++ b/crates/engine/src/cmd/file_cmds/tests.rs @@ -2026,6 +2026,36 @@ fn save_a_copy_writes_the_tiff_compression_asked_for() { } } +/// #8827: `tiffPyramid` writes reduced-resolution levels after the image and `tiffTransparency` +/// false composites a transparent document over white. Bad values are refused before anything is +/// written. +#[test] +fn save_a_copy_writes_the_tiff_pyramid_and_transparency_asked_for() { + let dir = tmp("tiff-options"); + let mut s = Session::new(); + s.execute("file.new", json!({"width": 200, "height": 100, "background": "transparent"})).unwrap(); + let out = join(&dir, "p.tif"); + s.execute("file.saveACopy", json!({"path": out, "tiffPyramid": true})).unwrap(); + let bytes = std::fs::read(&out).unwrap(); + let sizes: Vec<(u32, u32)> = photocraft_codecs::tiff_info(&bytes).unwrap().pages.iter().map(|p| (p.width, p.height)).collect(); + assert_eq!(sizes, [(200, 100), (100, 50), (50, 25)]); + for (t, alpha) in [(None, true), (Some(true), true), (Some(false), false)] { + let mut p = json!({"path": out}); + if let Some(t) = t { + p["tiffTransparency"] = json!(t); + } + s.execute("file.saveACopy", p).unwrap(); + let img = photocraft_codecs::decode(&std::fs::read(&out).unwrap()).unwrap(); + assert_eq!(img.layout().has_alpha(), alpha, "{t:?}"); + } + for (key, bad) in [("tiffPyramid", json!("yes")), ("tiffPyramid", json!(1)), ("tiffTransparency", json!("off")), ("tiffTransparency", json!([]))] { + let out = join(&dir, "bad.tif"); + let r = s.execute("file.saveACopy", json!({"path": out, key: bad})); + assert!(matches!(r, Err(EngineError::BadParams { .. })), "{key} {bad}: {r:?}"); + assert!(!std::path::Path::new(&out).exists(), "{key} {bad}: nothing written"); + } +} + #[test] fn sanitize_keeps_windows_device_names_off_the_disk() { assert_eq!(sanitize("nul"), "nul_"); diff --git a/crates/io/src/flat.rs b/crates/io/src/flat.rs index c7a4101c36..3735514514 100644 --- a/crates/io/src/flat.rs +++ b/crates/io/src/flat.rs @@ -999,7 +999,7 @@ pub(crate) fn export_flat_with(doc: &Document, format: Format, opts: &ExportOpti img = dds_image(doc, img, &mut warnings)?; } if format == Format::Tiff { - warnings.extend(crate::tiff_layers::stamp_flat_channels(doc, &mut img)); + warnings.extend(crate::tiff_layers::flat_transparency_and_channels(doc, &mut img, opts.tiff_transparency)); } encode_image(&img, format, opts, warnings) } diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index f7d2881e4d..d133832ce2 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -370,6 +370,13 @@ pub struct ExportOptions { /// Save As sets it from its Layers option, which keeps them as Photoshop does. `false` is /// Photoshop's "Discard Layers and Save a Copy". pub tiff_layers: bool, + /// TIFF: Photoshop's "Save Transparency" for the composite (the merged image other readers + /// see; each layer of a layered TIFF keeps its own transparency either way). `Some(true)` + /// stores the transparency as the first extra sample (unassociated alpha), ahead of any + /// alpha and spot channels; `Some(false)` composites the image over white and stores no + /// transparency. `None`, the default, keeps it unless the document has alpha or spot + /// channels, which are then written as with it off. + pub tiff_transparency: Option, /// Which part of the document's XMP packet and free-form text a flat export embeds. /// PSD/PSB always keep XMP; `.pcraft` keeps both. Everything by default, as Save As does; /// Export As offers None. @@ -405,6 +412,7 @@ impl Default for ExportOptions { encode: EncodeOptions::default(), force_psb: false, tiff_layers: false, + tiff_transparency: None, xmp: XmpEmbed::All, tga_bits: None, image_previews: true, diff --git a/crates/io/src/tiff_layers.rs b/crates/io/src/tiff_layers.rs index e382ff108c..fdb31c93b5 100644 --- a/crates/io/src/tiff_layers.rs +++ b/crates/io/src/tiff_layers.rs @@ -355,11 +355,13 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr // on the encoded file size is not used here: PSB-style data is written only past 30000 px, // when asked for, or when a length really overflows 32 bits. let user_channels = !doc.channels.is_empty() || doc.quick_mask.is_some(); + // Save Transparency (see `ExportOptions::tiff_transparency`): off, the composite is matted. + let save_transparency = opts.tiff_transparency.unwrap_or(!user_channels); let force_psb = opts.force_psb || big; let psd_opts = PsdExportOptions { force_psb, size_limit_psb: false, - merged_matte: user_channels, + merged_matte: !save_transparency, merged_compression: Compression::Raw, maximize_compatibility: true, document_path: opts.document_path.clone(), @@ -384,7 +386,7 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr let mut has_alpha = file.layer_info.as_ref().is_some_and(|l| l.merged_alpha); // The layers move out of the model rather than being copied, and are freed once written // (#6607); the model keeps its header, resources and composite for the image below. - let (layers, w) = match tiff_image_source(doc, &mut file).into_bytes(order) { + let (layers, w) = match tiff_image_source(doc, &mut file, save_transparency && has_alpha).into_bytes(order) { Err(photocraft_psd::PsdError::LimitExceeded(_)) if !force_psb => { let psb_opts = PsdExportOptions { force_psb: true, ..psd_opts }; let (psb, w) = crate::with_span(ctl, 0.0, TIFF_MODEL_DONE, |ctl| crate::psd_export::document_to_psd_interruptible(doc, &psb_opts, ctl)); @@ -393,7 +395,7 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr warnings.push("layer data exceeds 4 GB; written with PSB-sized lengths, which some readers do not open".into()); file = psb; has_alpha = file.layer_info.as_ref().is_some_and(|l| l.merged_alpha); - tiff_image_source(doc, &mut file).into_bytes(order)? + tiff_image_source(doc, &mut file, save_transparency && has_alpha).into_bytes(order)? } r => r?, }; @@ -413,19 +415,22 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr if extra_n > kept { warnings.push(format!("{extra_n} extra channels; TIFF keeps the first {kept}")); } - // With spot or alpha channels, the IFD image is colour + those channels. The merged - // transparency plane stays out of the IFD (see above); a file with no extra channels keeps - // it, so a plain transparent TIFF still opens with alpha. - let write_alpha = has_alpha && kept == 0; + // The IFD image is colour, then the transparency with Save Transparency on, then the spot and + // alpha channels. With it on, the samples are the PSD merged image's channels in order, so + // the PSD model's channel resources (which list the transparency first) are kept as they are. + let write_alpha = has_alpha && save_transparency; // One identifier per extra sample actually stored. The PSD model's 1053 lists the merged - // transparency too, which the IFD leaves out next to extra samples, so it is not copied. - resources.retain(|r| r.id != crate::channel_map::ALPHA_IDENTIFIERS); - if kept > 0 { - resources.push(ImageResource::new(crate::channel_map::ALPHA_IDENTIFIERS, crate::channel_map::alpha_identifiers(false, kept))); + // transparency too, which the IFD leaves out without it, so it is not copied then. + if !write_alpha { + resources.retain(|r| r.id != crate::channel_map::ALPHA_IDENTIFIERS); + if kept > 0 { + resources.push(ImageResource::new(crate::channel_map::ALPHA_IDENTIFIERS, crate::channel_map::alpha_identifiers(false, kept))); + } } // Resource 1022 names a channel of the PSD merged image, which counts the transparency plane. - // The IFD below does not, so the id moves back by one. + // An IFD without it does not, so the id moves back by one. if has_alpha + && !write_alpha && kept > 0 && let Some(r) = resources.iter_mut().find(|r| r.id == crate::channel_map::QUICK_MASK_INFO) && r.data.len() >= 2 @@ -443,8 +448,10 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr } // Past MAX_EXTRA_SAMPLES the TIFF stores only the first `kept` channels: the name and // DisplayInfo resources describe just those, and a Quick Mask among the rest is dropped. + // With the transparency written, those lists hold it ahead of the channels, and the + // channels sit one sample later in the IFD. if extra_n > kept { - keep_first_channels(&mut resources, kept, cc); + keep_first_channels(&mut resources, kept + usize::from(write_alpha), cc); } let resources = resources_to_bytes(&resources)?; let ifd_channels = cc + usize::from(write_alpha); @@ -478,26 +485,34 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr /// The layer data (`ImageSourceData`) of a layered TIFF saved from `file`, adjusted the way /// Photoshop writes it. The layers, global mask and global blocks move out of `file` (the -/// export's own model), which keeps its header, resources and merged image. -fn tiff_image_source(doc: &Document, file: &mut photocraft_psd::PsdFile) -> ImageSourceData { +/// export's own model), which keeps its header, resources and merged image. `ifd_alpha`: the +/// composite in the IFD keeps its transparency as the first extra sample (Save Transparency). +fn tiff_image_source(doc: &Document, file: &mut photocraft_psd::PsdFile, ifd_alpha: bool) -> ImageSourceData { // Photoshop pairs alpha-name resources with samples past the colour channels and ignores - // the ExtraSamples kind. The merged transparency must not occupy that first slot when a - // spot is also stored. Layer transparency stays in each layer's channel −1. + // the ExtraSamples kind. Layer transparency stays in each layer's channel −1. let spot_extras = file.header.channels as usize > { let cc = doc.pixel_format().mode.color_channels(); let alpha = file.layer_info.as_ref().is_some_and(|l| l.merged_alpha); cc + usize::from(alpha) }; let mut isd = ImageSourceData::take_from_psd(file); - // Positive layer count: the merged image's first extra sample is not transparency - // (Photoshop's own spot TIFF does this; a negative count would claim the spot plane is alpha). - if spot_extras && let Some(info) = &mut isd.layer_info { - info.merged_alpha = false; - } - // A merged-transparency marker kept from an opened file (Photoshop's `MTrn`) would make the - // first spot read back as transparency. The PSD export already drops it over an opaque - // composite; a transparent one keeps it there, but the TIFF leaves that sample out here. - if spot_extras { + if ifd_alpha && spot_extras { + // Save Transparency next to spot or alpha channels, as Photoshop (27.11) writes it: a + // positive layer count and an `MTrn` block first in the layer data mark the first extra + // sample as the merged transparency. + if let Some(info) = &mut isd.layer_info { + info.merged_alpha = false; + } + } else if !ifd_alpha { + // Positive layer count: the merged image's first extra sample, if any, is not + // transparency (Photoshop's own spot TIFF does this; a negative count would claim the + // spot plane is alpha). + if let Some(info) = &mut isd.layer_info { + info.merged_alpha = false; + } + // A merged-transparency marker kept from an opened file (Photoshop's `MTrn`) would make + // the first spot read back as transparency. The PSD export already drops it over an + // opaque composite; a transparent one keeps it there, but the IFD leaves that sample out. isd.global_blocks.retain(|b| !b.is_merged_transparency_marker()); } if let Some(info) = &mut isd.layer_info { @@ -523,6 +538,10 @@ fn tiff_image_source(doc: &Document, file: &mut photocraft_psd::PsdFile) -> Imag if !isd.global_blocks.iter().any(|b| &b.key == b"FMsk") { isd.global_blocks.push(photocraft_psd::TaggedBlock::new(*b"FMsk", FILTER_MASK_DEFAULT.to_vec())); } + // Photoshop writes the marker first, ahead of `Patt`. + if ifd_alpha && spot_extras && !isd.global_blocks.iter().any(|b| b.is_merged_transparency_marker()) { + isd.global_blocks.insert(0, photocraft_psd::TaggedBlock::new(*b"MTrn", Vec::new())); + } isd } @@ -796,7 +815,11 @@ fn matte_and_drop_alpha(img: &mut Image) -> bool { let white_f: f32 = if cmyk { 0.0 } else { 1.0 }; // Lab's white is L* 100 with a* and b* 0, which PhotoCraft holds mid-range (`(0 + 128) / 255`). let lab = img.layout().is_lab(); - let mut out = Vec::with_capacity(img.data().len() / stride * keep); + // Out of memory keeps the transparency (the caller warns) rather than aborting the app. + let mut out = Vec::new(); + if out.try_reserve_exact(img.data().len() / stride * keep).is_err() { + return false; + } for px in img.data().chunks_exact(stride) { let Some(color) = px.get(..keep) else { return false }; let Some(alpha) = px.get(keep..keep.saturating_add(bps)) else { return false }; @@ -905,12 +928,27 @@ fn ne_f32(b: &[u8]) -> Option { Some(f32::from_ne_bytes(arr)) } +/// A flat TIFF's composite under Save Transparency (`ExportOptions::tiff_transparency`), then +/// its alpha and spot channels ([`stamp_flat_channels`]). Off, the image is matted over white +/// and keeps no transparency; on, it keeps it next to the channels; unset, it keeps it only +/// when there are no channels. +pub(crate) fn flat_transparency_and_channels(doc: &Document, img: &mut Image, save_transparency: Option) -> Vec { + let mut warnings = Vec::new(); + if save_transparency == Some(false) && img.layout().has_alpha() && !matte_and_drop_alpha(img) { + warnings.push("the image transparency could not be composited over white and was kept".into()); + } + warnings.extend(stamp_flat_channels(doc, img, save_transparency == Some(true))); + warnings +} + /// Writes alpha and spot channels (and a Quick Mask) into a flat TIFF: extra samples in the /// IFD, plus Photoshop resources for the name, ink colour and solidity. At most /// [`codecs::MAX_EXTRA_SAMPLES`] are kept. A TIFF with no extra channels is left unchanged. /// When any are written, the composite's own transparency is left out of the IFD: readers pair /// the channel resources with every sample past the colour channels, in order. -pub(crate) fn stamp_flat_channels(doc: &Document, img: &mut Image) -> Vec { +/// `keep_transparency` (Save Transparency on) keeps the composite's transparency as the first +/// extra sample, ahead of the channels; off, the image is matted over white first. +pub(crate) fn stamp_flat_channels(doc: &Document, img: &mut Image, keep_transparency: bool) -> Vec { let total = doc.channels.len().saturating_add(usize::from(doc.quick_mask.is_some())); if total == 0 { return Vec::new(); @@ -953,7 +991,7 @@ pub(crate) fn stamp_flat_channels(doc: &Document, img: &mut Image) -> Vec Vec img.meta.photoshop_resources = Some(bytes), Err(e) => warnings.push(format!("spot channel options were not written to the TIFF: {e}")), } @@ -973,7 +1017,13 @@ pub(crate) fn stamp_flat_channels(doc: &Document, img: &mut Image) -> Vec, names: &[&str], channels: &[&AlphaChannel], quick_id: Option) -> Result, IoError> { +fn channel_resources( + existing: Option<&[u8]>, + names: &[&str], + channels: &[&AlphaChannel], + quick_id: Option, + transparency: bool, +) -> Result, IoError> { let mut resources = match existing.filter(|b| !b.is_empty()) { Some(b) => resources_from_bytes(b)?.0, None => Vec::new(), @@ -981,10 +1031,11 @@ fn channel_resources(existing: Option<&[u8]>, names: &[&str], channels: &[&Alpha resources.retain(|r| { !matches!(r.id, 1006 | 1045 | crate::channel_map::DISPLAY_INFO | crate::channel_map::QUICK_MASK_INFO | crate::channel_map::ALPHA_IDENTIFIERS) }); - resources.push(ImageResource::new(1006, crate::psd_export::pascal_names_resource(names))); - resources.push(ImageResource::new(1045, crate::psd_export::unicode_names_resource(names))); - resources.push(ImageResource::new(crate::channel_map::DISPLAY_INFO, crate::channel_map::display_info(channels, false))); - resources.push(ImageResource::new(crate::channel_map::ALPHA_IDENTIFIERS, crate::channel_map::alpha_identifiers(false, names.len()))); + let listed: Vec<&str> = transparency.then_some("Transparency").into_iter().chain(names.iter().copied()).collect(); + resources.push(ImageResource::new(1006, crate::psd_export::pascal_names_resource(&listed))); + resources.push(ImageResource::new(1045, crate::psd_export::unicode_names_resource(&listed))); + resources.push(ImageResource::new(crate::channel_map::DISPLAY_INFO, crate::channel_map::display_info(channels, transparency))); + resources.push(ImageResource::new(crate::channel_map::ALPHA_IDENTIFIERS, crate::channel_map::alpha_identifiers(transparency, names.len()))); if let Some(id) = quick_id { let mut data = id.to_be_bytes().to_vec(); data.push(0); diff --git a/crates/io/tests/it/main.rs b/crates/io/tests/it/main.rs index 0c29aa08ea..d9e597297e 100644 --- a/crates/io/tests/it/main.rs +++ b/crates/io/tests/it/main.rs @@ -81,6 +81,7 @@ mod tiff_banded_import; mod tiff_corpus; mod tiff_layers; mod tiff_pages; +mod tiff_save_options; mod tiff_type_geometry; mod vector; mod xcf; diff --git a/crates/io/tests/it/tiff_save_options.rs b/crates/io/tests/it/tiff_save_options.rs new file mode 100644 index 0000000000..dc1b564156 --- /dev/null +++ b/crates/io/tests/it/tiff_save_options.rs @@ -0,0 +1,365 @@ +//! TIFF Options (#8827): Save Transparency on and off, flat and layered, with and without spot +//! channels, and Save Image Pyramid. Unset Save Transparency is covered in `tiff_layers`. + +use crate::common::*; +use photocraft_color::{Color, ColorMode, PixelFormat, SampleType}; +use photocraft_doc::{AlphaChannel, Document, Layer, LayerContent}; +use photocraft_geom::{Rect, Size}; +use photocraft_io::*; +use photocraft_psd::tiff::ImageSourceData; +use photocraft_raster::Surface; + +/// A 4×4 document whose one layer is opaque red in its middle and transparent around it, with a +/// "White" spot channel inked in its left half when `spot`. +fn art(depth: SampleType, spot: bool) -> Document { + let mut doc = Document::new("t", Size::new(4, 4), ColorMode::Rgb, depth); + let fmt = doc.pixel_format(); + let mut s = Surface::new(fmt); + s.fill_rect(Rect::new(1, 1, 3, 3), &photocraft_raster::from_rgba(&fmt, [1.0, 0.0, 0.0, 1.0])); + doc.layers.push(Layer::new("Layer 1", LayerContent::Raster(s))); + if spot { + let gray = PixelFormat::new(ColorMode::Grayscale, depth, false); + let mut plate = Surface::new(gray); + plate.fill_rect(Rect::new(0, 0, 2, 4), &photocraft_raster::from_rgba(&gray, [1.0, 1.0, 1.0, 1.0])); + let mut white = AlphaChannel::new("White", plate); + white.spot = Some((Color::rgb(0.8, 0.2, 0.2), 1.0)); + doc.channels.push(white); + } + doc +} + +fn save(doc: &Document, layers: bool, transparency: Option) -> ExportResult { + export(doc, "t.tif", &ExportOptions { tiff_layers: layers, tiff_transparency: transparency, ..Default::default() }).expect("export") +} + +fn extra_samples_tag(bytes: &[u8]) -> Vec { + let mut dec = tiff::decoder::Decoder::new(std::io::Cursor::new(bytes)).unwrap(); + dec.get_tag_u16_vec(tiff::tags::Tag::ExtraSamples).unwrap_or_default() +} + +/// A TIFF's channel resources: the names in 1006, and the byte lengths of DisplayInfo (1077) +/// and the alpha identifiers (1053). +fn channel_resources(bytes: &[u8]) -> (Vec, usize, usize) { + let tag = photocraft_codecs::tiff_photoshop_tags(bytes).0.expect("resources"); + let (resources, _) = photocraft_psd::tiff::resources_from_bytes(tag).expect("parse resources"); + let data = |id: u16| resources.iter().find(|r| r.id == id).map(|r| r.data.clone()).unwrap_or_default(); + let pascal = data(1006); + let (mut at, mut names) = (0usize, Vec::new()); + while let Some(&len) = pascal.get(at) { + names.push(String::from_utf8_lossy(&pascal[at + 1..at + 1 + usize::from(len)]).into_owned()); + at += 1 + usize::from(len); + } + (names, data(1077).len(), data(1053).len()) +} + +/// The first sample of pixel (0, 0) and its alpha, on the 0–1 scale. +fn corner(img: &photocraft_codecs::Image) -> (f32, Option) { + let f = img.convert(img.layout(), photocraft_codecs::SampleType::F32); + let s = f.to_f32_samples().unwrap(); + (s[0], img.layout().has_alpha().then(|| s[img.layout().channels() - 1])) +} + +fn layer_alpha(doc: &Document, x: i32, y: i32) -> f32 { + let LayerContent::Raster(s) = &doc.layers[0].content else { panic!("raster") }; + // Alpha is the last channel (index 4 in CMYK). + s.pixel(x, y).last().copied().unwrap_or(1.0) +} + +#[test] +fn off_mattes_the_composite_on_white_and_layers_keep_theirs() { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + for spot in [false, true] { + for layers in [false, true] { + let case = format!("{depth:?} spot {spot} layers {layers}"); + let doc = art(depth, spot); + let r = save(&doc, layers, Some(false)); + assert!(!r.warnings.iter().any(|w| w.contains("Save Transparency")), "{case}: the user chose it: {:?}", r.warnings); + let img = photocraft_codecs::decode(&r.bytes).unwrap(); + assert!(!img.layout().has_alpha(), "{case}"); + assert_eq!(img.meta.extra_samples.len(), usize::from(spot), "{case}"); + assert_eq!(extra_samples_tag(&r.bytes), if spot { vec![0] } else { vec![] }, "{case}"); + assert!((corner(&img).0 - 1.0).abs() < 1e-4, "{case}: transparent corner is white"); + let back = import("t.tif", &r.bytes).unwrap().document; + assert_eq!(back.channels.iter().map(|c| c.name.as_str()).collect::>(), if spot { vec!["White"] } else { vec![] }, "{case}"); + if layers { + assert_eq!(layer_alpha(&back, 0, 0), 0.0, "{case}: the layer keeps its transparency"); + let (isd, _) = ImageSourceData::from_bytes(photocraft_codecs::tiff_photoshop_tags(&r.bytes).1.unwrap()).unwrap(); + assert!(!isd.layer_info.unwrap().merged_alpha, "{case}: no merged transparency is claimed"); + assert!(!isd.global_blocks.iter().any(|b| b.is_merged_transparency_marker()), "{case}"); + } + } + } + } +} + +#[test] +fn on_keeps_transparency_ahead_of_spot_channels() { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + for layers in [false, true] { + let case = format!("{depth:?} layers {layers}"); + let doc = art(depth, true); + let r = save(&doc, layers, Some(true)); + assert!(!r.warnings.iter().any(|w| w.contains("composited over white")), "{case}: {:?}", r.warnings); + let img = photocraft_codecs::decode(&r.bytes).unwrap(); + assert!(img.layout().has_alpha(), "{case}"); + assert_eq!(img.meta.extra_samples.len(), 1, "{case}"); + // Transparency (unassociated alpha) first, then the spot. + assert_eq!(extra_samples_tag(&r.bytes), vec![2, 0], "{case}"); + assert_eq!(corner(&img).1, Some(0.0), "{case}"); + let back = import("t.tif", &r.bytes).unwrap().document; + let names: Vec<&str> = back.channels.iter().map(|c| c.name.as_str()).collect(); + assert_eq!(names, ["White"], "{case}: the transparency is not opened as a channel"); + assert!(back.channels[0].spot.is_some(), "{case}"); + assert_eq!(back.channels[0].surface.pixel(0, 0)[0], 1.0, "{case}: inked"); + assert_eq!(back.channels[0].surface.pixel(3, 0)[0], 0.0, "{case}: bare"); + assert_eq!(layer_alpha(&back, 0, 0), 0.0, "{case}"); + assert_eq!(layer_alpha(&back, 1, 1), 1.0, "{case}"); + // Photoshop pairs the channel resources with every sample past the colour channels, + // so both paths list the transparency first: a name, a DisplayInfo record and an id. + let (names, display, ids) = channel_resources(&r.bytes); + assert_eq!(names, ["Transparency", "White"], "{case}"); + assert_eq!(display, 4 + 2 * 13, "{case}: DisplayInfo records"); + assert_eq!(ids, 2 * 4, "{case}: identifiers"); + if layers { + // As Photoshop writes it: a positive layer count and `MTrn` first. + let (isd, _) = ImageSourceData::from_bytes(photocraft_codecs::tiff_photoshop_tags(&r.bytes).1.unwrap()).unwrap(); + assert!(!isd.layer_info.as_ref().unwrap().merged_alpha, "{case}"); + assert_eq!(&isd.global_blocks[0].key, b"MTrn", "{case}"); + // The marker is kept for the next save, as from Photoshop's own file. + let mut want = doc.clone(); + want.metadata.psd_global_blocks = vec![(*b"8BIM", *b"MTrn", std::sync::Arc::new(Vec::new()))]; + assert_docs_eq(&want, &back); + } + } + } +} + +/// Channel names outside ASCII survive a save with Save Transparency on, flat and layered: the +/// Unicode names (1045) carry them, after the transparency's entry. +#[test] +fn on_keeps_non_ascii_channel_names() { + let mut doc = art(SampleType::U8, true); + doc.channels[0].name = "Blå".into(); + let gray = PixelFormat::new(ColorMode::Grayscale, SampleType::U8, false); + doc.channels.push(AlphaChannel::new("Máscara 2", Surface::new(gray))); + for layers in [false, true] { + let r = save(&doc, layers, Some(true)); + let back = import("t.tif", &r.bytes).unwrap().document; + assert_eq!(back.channels.iter().map(|c| c.name.as_str()).collect::>(), ["Blå", "Máscara 2"], "layers {layers}"); + assert!(back.channels[0].spot.is_some(), "layers {layers}: the spot keeps its ink"); + } +} + +#[test] +fn on_keeps_a_quick_mask_in_place() { + let mut doc = art(SampleType::U8, true); + let gray = PixelFormat::new(ColorMode::Grayscale, SampleType::U8, false); + let mut q = Surface::new(gray); + q.fill_rect(Rect::new(2, 2, 4, 4), &photocraft_raster::from_rgba(&gray, [1.0, 1.0, 1.0, 1.0])); + doc.quick_mask = Some(AlphaChannel::new("Quick Mask", q)); + for layers in [false, true] { + let r = save(&doc, layers, Some(true)); + assert_eq!(extra_samples_tag(&r.bytes), vec![2, 0, 0], "layers {layers}"); + let back = import("t.tif", &r.bytes).unwrap().document; + assert_eq!(back.channels.iter().map(|c| c.name.as_str()).collect::>(), ["White"], "layers {layers}"); + let quick = back.quick_mask.as_ref().unwrap_or_else(|| panic!("layers {layers}: quick mask")); + assert_eq!(quick.surface.pixel(3, 3)[0], 1.0, "layers {layers}"); + assert_eq!(quick.surface.pixel(0, 0)[0], 0.0, "layers {layers}"); + } +} + +/// Past 16 channels a layered TIFF keeps the first 16. With Save Transparency on, the name and +/// DisplayInfo lists hold the transparency ahead of them, so trimming keeps 17 entries and +/// every channel still opens under its own name (they used to shift by one). +#[test] +fn on_past_sixteen_channels_keeps_each_channels_name() { + let mut doc = art(SampleType::U8, false); + let gray = PixelFormat::new(ColorMode::Grayscale, SampleType::U8, false); + for i in 1..=18 { + doc.channels.push(AlphaChannel::new(format!("Alpha {i}"), Surface::new(gray))); + } + let r = save(&doc, true, Some(true)); + assert!(r.warnings.iter().any(|w| w.contains("18 extra channels") && w.contains("first 16")), "{:?}", r.warnings); + let img = photocraft_codecs::decode(&r.bytes).unwrap(); + assert!(img.layout().has_alpha()); + assert_eq!(img.meta.extra_samples.len(), 16); + let back = import("t.tif", &r.bytes).unwrap().document; + let want: Vec = (1..=16).map(|i| format!("Alpha {i}")).collect(); + assert_eq!(back.channels.iter().map(|c| c.name.as_str()).collect::>(), want.iter().map(String::as_str).collect::>()); +} + +#[test] +fn on_without_transparency_writes_no_alpha() { + // An opaque document has no transparency to save. + let mut doc = art(SampleType::U8, false); + doc.layers[0].locks.transparency = true; + let fmt = doc.pixel_format(); + if let LayerContent::Raster(s) = &mut doc.layers[0].content { + s.fill_rect(Rect::new(0, 0, 4, 4), &photocraft_raster::from_rgba(&fmt, [0.0, 0.0, 1.0, 1.0])); + } + for layers in [false, true] { + let r = save(&doc, layers, Some(true)); + let img = photocraft_codecs::decode(&r.bytes).unwrap(); + assert!(!img.layout().has_alpha() || corner(&img).1 == Some(1.0), "layers {layers}"); + } +} + +#[test] +fn pyramid_levels_follow_the_full_image_and_open_the_same_document() { + // Big enough to need a level: 100 px halves to 50. + let mut doc = Document::new("t", Size::new(100, 100), ColorMode::Rgb, SampleType::U8); + let fmt = doc.pixel_format(); + let mut s = Surface::new(fmt); + s.fill_rect(Rect::new(10, 10, 60, 30), &photocraft_raster::from_rgba(&fmt, [1.0, 0.5, 0.0, 1.0])); + doc.layers.push(Layer::new("Layer 1", LayerContent::Raster(s))); + let gray = PixelFormat::new(ColorMode::Grayscale, SampleType::U8, false); + let mut plate = Surface::new(gray); + plate.fill_rect(Rect::new(0, 0, 50, 100), &photocraft_raster::from_rgba(&gray, [1.0, 1.0, 1.0, 1.0])); + doc.channels.push(AlphaChannel::new("Alpha 1", plate)); + for layers in [false, true] { + let opts = |pyramid| ExportOptions { + tiff_layers: layers, + encode: photocraft_codecs::EncodeOptions { tiff_pyramid: pyramid, ..Default::default() }, + ..Default::default() + }; + let with = export(&doc, "t.tif", &opts(true)).unwrap(); + let without = export(&doc, "t.tif", &opts(false)).unwrap(); + let info = photocraft_codecs::tiff_info(&with.bytes).unwrap(); + let kinds: Vec<_> = info.pages.iter().map(|p| (p.kind, p.width)).collect(); + assert_eq!(kinds, [(photocraft_codecs::TiffPageKind::Page, 100), (photocraft_codecs::TiffPageKind::ReducedResolution, 50)], "layers {layers}"); + // The level holds the image at half size: level (15, 10) covers full-size (30..32, 20..22), + // inside the orange fill; (40, 40) is outside it, composited over white (the alpha + // channel keeps the transparency out of the file). + let level = photocraft_codecs::decode_tiff_page(&with.bytes, 1, &photocraft_codecs::DecodeOptions::default()).unwrap(); + assert_eq!(level.dimensions(), (50, 50), "layers {layers}"); + let level = level.convert(level.layout(), photocraft_codecs::SampleType::F32); + let px = level.to_f32_samples().unwrap(); + for ((x, y), want) in [((15, 10), [1.0f32, 0.5, 0.0]), ((40, 40), [1.0, 1.0, 1.0])] { + let i = (y * 50 + x) * level.layout().channels(); + for (c, want) in want.iter().enumerate() { + assert!((px[i + c] - want).abs() < 0.01, "layers {layers} ({x}, {y}) channel {c}: {}", px[i + c]); + } + } + let a = import("t.tif", &with.bytes).unwrap(); + let b = import("t.tif", &without.bytes).unwrap(); + assert!(!a.warnings.iter().any(|w| w.contains("page")), "layers {layers}: {:?}", a.warnings); + assert_docs_eq(&b.document, &a.document); + } +} + +/// `PHOTOCRAFT_TIFF_INTEROP=` writes every TIFF Options combination there, to check with +/// other readers (libtiff `tiffinfo`, ImageMagick, vips): +/// +/// ```sh +/// PHOTOCRAFT_TIFF_INTEROP=/tmp/tiff cargo test -p photocraft-io --test it -- tiff_save_options::write_interop_samples --ignored +/// ``` +#[test] +#[ignore = "writes sample files for other readers; set PHOTOCRAFT_TIFF_INTEROP"] +fn write_interop_samples() { + let Some(dir) = std::env::var_os("PHOTOCRAFT_TIFF_INTEROP") else { return }; + let dir = std::path::PathBuf::from(dir); + std::fs::create_dir_all(&dir).unwrap(); + for (mode, mname) in [(ColorMode::Rgb, "rgb"), (ColorMode::Cmyk, "cmyk"), (ColorMode::Grayscale, "gray")] { + for (depth, dname) in [(SampleType::U8, "8"), (SampleType::U16, "16"), (SampleType::F32, "32")] { + if mode == ColorMode::Cmyk && depth == SampleType::F32 { + continue; + } + let mut doc = Document::new("t", Size::new(300, 200), mode, depth); + let fmt = doc.pixel_format(); + let mut s = Surface::new(fmt); + s.fill_rect(Rect::new(40, 30, 260, 170), &photocraft_raster::from_rgba(&fmt, [0.9, 0.2, 0.1, 1.0])); + doc.layers.push(Layer::new("Layer 1", LayerContent::Raster(s))); + for spot in [false, true] { + if spot { + let gray = PixelFormat::new(ColorMode::Grayscale, depth, false); + let mut plate = Surface::new(gray); + plate.fill_rect(Rect::new(0, 0, 150, 200), &photocraft_raster::from_rgba(&gray, [1.0, 1.0, 1.0, 1.0])); + let mut white = AlphaChannel::new("White", plate); + white.spot = Some((Color::rgb(0.8, 0.2, 0.2), 1.0)); + doc.channels = vec![white]; + } + for layers in [false, true] { + for (t, tname) in [(None, "unset"), (Some(true), "on"), (Some(false), "off")] { + for (c, cname) in [ + (photocraft_codecs::TiffCompression::None, "none"), + (photocraft_codecs::TiffCompression::Lzw, "lzw"), + (photocraft_codecs::TiffCompression::Deflate, "zip"), + ] { + for pyramid in [false, true] { + let opts = ExportOptions { + tiff_layers: layers, + tiff_transparency: t, + encode: photocraft_codecs::EncodeOptions { tiff_compression: c, tiff_pyramid: pyramid, ..Default::default() }, + ..Default::default() + }; + let r = export(&doc, "t.tif", &opts).unwrap(); + let name = format!( + "{mname}{dname}-{}-{}-t{tname}-{cname}{}.tif", + if spot { "spot" } else { "plain" }, + if layers { "layered" } else { "flat" }, + if pyramid { "-pyramid" } else { "" } + ); + std::fs::write(dir.join(name), r.bytes).unwrap(); + } + } + } + } + } + } + } +} + +/// Save Transparency in every colour mode a TIFF holds: off mattes on the mode's white (paper +/// for CMYK, L* 100 for Lab), on keeps the transparency; flat and, where the mode keeps layers, +/// layered. +#[test] +fn every_colour_mode_saves_or_mattes_its_transparency() { + for mode in [ColorMode::Rgb, ColorMode::Cmyk, ColorMode::Grayscale, ColorMode::Lab] { + for depth in [SampleType::U8, SampleType::U16] { + let mut doc = Document::new("t", Size::new(4, 4), mode, depth); + let fmt = doc.pixel_format(); + let mut s = Surface::new(fmt); + s.fill_rect(Rect::new(1, 1, 3, 3), &photocraft_raster::from_rgba(&fmt, [0.2, 0.4, 0.6, 1.0])); + doc.layers.push(Layer::new("Layer 1", LayerContent::Raster(s))); + for layers in [false, true] { + for t in [Some(true), Some(false)] { + let case = format!("{mode:?} {depth:?} layers {layers} {t:?}"); + let r = save(&doc, layers, t); + let img = photocraft_codecs::decode(&r.bytes).unwrap_or_else(|e| panic!("{case}: {e}")); + assert_eq!(img.layout().has_alpha(), t == Some(true), "{case}"); + let back = import("t.tif", &r.bytes).unwrap().document; + assert_eq!(back.mode, mode, "{case}"); + if t == Some(false) { + // The merged image (what other readers show) is matted on the mode's own + // white, flat or layered: CMYK paper is no ink, Lab white L* 100 with a* + // and b* at their midpoint. + let white: &[f32] = match mode { + ColorMode::Cmyk => &[0.0, 0.0, 0.0, 0.0], + ColorMode::Lab => &[1.0, 128.0 / 255.0, 128.0 / 255.0], + ColorMode::Grayscale => &[1.0], + _ => &[1.0, 1.0, 1.0], + }; + let f = img.convert(img.layout(), photocraft_codecs::SampleType::F32); + let merged = f.to_f32_samples().unwrap(); + assert_eq!(img.layout().channels(), white.len(), "{case}"); + assert!(merged.iter().zip(white).all(|(a, b)| (a - b).abs() < 1e-2), "{case}: merged {:?} vs {white:?}", &merged[..white.len()]); + // Only the transparent corner is matted: the opaque centre at (1, 1) + // (sample 5 of the 4-px-wide image) keeps its colour. + let centre = &merged[5 * white.len()..6 * white.len()]; + assert!(centre.iter().zip(white).any(|(a, b)| (a - b).abs() >= 1e-2), "{case}: centre {centre:?} is matte white"); + // The corner was transparent: now the mode's white, read back opaque. + let LayerContent::Raster(s) = &back.layers[0].content else { panic!("raster") }; + let want = photocraft_raster::from_rgba(&back.pixel_format(), [1.0, 1.0, 1.0, 1.0]); + if !layers { + let px = s.pixel(0, 0); + assert!(px.iter().zip(&want).all(|(a, b)| (a - b).abs() < 1e-2), "{case}: {px:?} vs {want:?}"); + } + } else { + assert_eq!(layer_alpha(&back, 0, 0), 0.0, "{case}"); + } + assert_eq!(layer_alpha(&back, 1, 1), 1.0, "{case}"); + } + } + } + } +} diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 982dd5f6b3..1e6e82dfb5 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -188,6 +188,7 @@ impl PhotocraftApp { discard: None, file_dialog: None, tiff_options: None, + tiff_remembered: Default::default(), save_dirs: HashMap::new(), disk_stamps: HashMap::new(), targa_options: None, diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index b62f9cc2eb..5bfd070fcf 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -281,13 +281,14 @@ impl PhotocraftApp { jpeg_options_ui::park(self, path.clone(), copy, "file.saveAs")?; return Ok(serde_json::json!({"path": path, "warnings": []})); } - // A layered TIFF asks about its layers first (Preferences › File Handling); the save + // A TIFF asks for TIFF Options (compression, pyramid, transparency, layers); the save // continues from the prompt, which records the step once answered. - if tiff_options_ui::wants_prompt(self, &path) { - tiff_options_ui::park(self, path.clone())?; + if choices.tiff.is_none() && tiff_options_ui::wants_prompt(self, &path) { + tiff_options_ui::park(self, path.clone(), false, "file.saveAs")?; return Ok(serde_json::json!({"path": path, "warnings": []})); } - self.save_as_step(path, None, choices) + let tiff_layers = choices.tiff.and_then(|t| t.layers); + self.save_as_step(path, tiff_layers, choices) } /// File › Save back to the document's own file, recorded as a `file.save` step with the @@ -320,14 +321,15 @@ impl PhotocraftApp { } /// Re-label the Save As just journaled for `path` as File › Save (see [`Self::save_dialog_flow`]), - /// keeping its Targa, DDS or JPEG answer so playback writes the file the same way. A save - /// parked behind Targa, DDS or JPEG Options hasn't been journaled yet: it is recorded as + /// keeping its Targa, DDS, JPEG or TIFF answer so playback writes the file the same way. A save + /// parked behind Targa, DDS, JPEG or TIFF Options hasn't been journaled yet: it is recorded as /// File › Save once answered. pub(crate) fn journal_as_plain_save(&mut self, path: &str) { let parked = [ self.targa_options.as_mut().map(|p| (&p.path, &mut p.record)), self.dds_options.as_mut().map(|p| (&p.path, &mut p.record)), self.jpeg_options.as_mut().map(|p| (&p.path, &mut p.record)), + self.tiff_options.as_mut().map(|p| (&p.path, &mut p.record)), ]; if let Some((_, record)) = parked.into_iter().flatten().find(|(p, record)| p.as_str() == path && **record == "file.saveAs") { *record = "file.save"; @@ -339,7 +341,12 @@ impl PhotocraftApp { && last.0 == "file.saveAs" && last.1.get("path").and_then(Value::as_str) == Some(path) && let Some(step) = last.1.as_object() - && step.keys().all(|k| matches!(k.as_str(), "path" | "tgaBits" | "ddsCompression" | "ddsMipmaps" | "quality" | "jpegFormat")) + && step.keys().all(|k| { + matches!( + k.as_str(), + "path" | "tgaBits" | "ddsCompression" | "ddsMipmaps" | "quality" | "jpegFormat" | "tiffCompression" | "tiffTransparency" | "tiffPyramid" + ) + }) { let mut step = step.clone(); step.remove("path"); @@ -591,7 +598,7 @@ impl PhotocraftApp { self.tiff_options.is_some() || self.targa_options.is_some() || self.dds_options.is_some() || self.jpeg_options.is_some() } - /// Writes a save that waited on a format prompt (Targa, DDS or JPEG Options) once answered: + /// Writes a save that waited on a format prompt (Targa, DDS, JPEG or TIFF Options) once answered: /// journals `step` as its command (`file.saveAs` for Save As, `file.saveACopy` for Save a /// Copy, `file.save` for a plain Save), so an action being recorded keeps it with this answer /// (#2032, #4373), reports a failure in the status bar, and releases a close prompt waiting on diff --git a/crates/ui-egui/src/dialogs/discard_ui/tests.rs b/crates/ui-egui/src/dialogs/discard_ui/tests.rs index 5866a5ae12..a75a1aeb99 100644 --- a/crates/ui-egui/src/dialogs/discard_ui/tests.rs +++ b/crates/ui-egui/src/dialogs/discard_ui/tests.rs @@ -578,6 +578,9 @@ fn saving_from_the_prompt_closes_after_an_unconfirmed_write() { #[path = "tests/tiff_tests.rs"] mod tiff_tests; +#[path = "tests/tiff_options_tests.rs"] +mod tiff_options_tests; + #[path = "tests/targa_tests.rs"] mod targa_tests; diff --git a/crates/ui-egui/src/dialogs/discard_ui/tests/tiff_options_tests.rs b/crates/ui-egui/src/dialogs/discard_ui/tests/tiff_options_tests.rs new file mode 100644 index 0000000000..15826375a4 --- /dev/null +++ b/crates/ui-egui/src/dialogs/discard_ui/tests/tiff_options_tests.rs @@ -0,0 +1,308 @@ +//! TIFF Options on Save As and Save a Copy (#8827): Image Compression, Save Image Pyramid, Save +//! Transparency and the layers choice; the scripted params that skip it; recording and playback. +use super::*; +use crate::{FileDialogAnswer, tiff_options_ui}; +use egui_kittest::kittest::Queryable; +use photocraft_codecs::TiffCompression; +use std::{cell::RefCell, rc::Rc}; + +type Writes = Rc)>>>; + +/// What a write was asked for: compression, Save Transparency, pyramid, layers. +fn asked(compression: &str, transparency: Option, pyramid: bool, layers: bool) -> Vec { + serde_json::to_vec(&(compression, transparency, pyramid, layers)).unwrap() +} + +/// A harness with one 8×8 document (`background`: "white" or "transparent") whose exports record +/// their TIFF settings, showing TIFF Options and answering a save dialog with `to`. +fn tiff_app(background: &str, to: &str) -> (Prompted, Writes) { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 8, "height": 8, "background": background})).unwrap(); + app.sync_views(); + let writes: Writes = Rc::default(); + let written = writes.clone(); + app.services.file_dialog = Some(crate::file_dialog::fake(vec![Some(FileDialogAnswer::SaveTo(to.into()))]).0); + app.services.export = Some(Box::new(|_, _, s| { + Ok((asked(tiff_options_ui::compression_name(s.tiff_compression), s.tiff_transparency, s.tiff_pyramid, s.tiff_layers), Vec::new())) + })); + app.services.write = Some(Box::new(move |path, bytes| { + written.borrow_mut().push((path.to_string(), bytes.to_vec())); + Ok(()) + })); + let builder = egui_kittest::Harness::builder().with_size(egui::vec2(800.0, 600.0)); + let builder = if std::env::var_os("PHOTOCRAFT_TIFF_SNAPSHOTS").is_some() { builder.wgpu() } else { builder }; + let h = builder.build_ui_state( + |ui, app| { + show(app, ui.ctx()); + tiff_options_ui::show(app, ui.ctx()); + app.poll_file_dialog(ui.ctx(), None); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.ctx.set_os(egui::os::OperatingSystem::Windows); + (h, writes) +} + +/// `PHOTOCRAFT_TIFF_SNAPSHOTS=` saves the dialog as a PNG to look at. +fn capture(h: &mut Prompted, name: &str) { + if let Some(dir) = std::env::var_os("PHOTOCRAFT_TIFF_SNAPSHOTS") { + let dir = std::path::PathBuf::from(dir); + std::fs::create_dir_all(&dir).unwrap(); + h.render().unwrap().save(dir.join(format!("{name}.png"))).unwrap(); + } +} + +/// A flat TIFF (one Background) asks too: compression and pyramid, but no transparency or layers +/// to choose. The answer is written, recorded and remembered for the next TIFF. +#[test] +fn save_as_to_a_flat_tiff_asks_for_compression_and_pyramid() { + let _gpu = photocraft_testkit::gpu_lock(); + let (mut h, writes) = tiff_app("white", "unused.tif"); + h.state_mut().run("actions.record", json!({})).unwrap(); + h.state_mut().save_as(Some("p.tif".into())).unwrap(); + h.run_steps(2); + capture(&mut h, "tiff-options-flat"); + let p = h.state().tiff_options.as_ref().expect("prompt"); + assert_eq!((p.compression, p.pyramid, p.has_transparency, p.layered, p.copy), (TiffCompression::None, false, false, false, false)); + assert!(h.query_by_label("Save Transparency").is_none(), "nothing transparent to save"); + assert!(h.query_by_label("Discard Layers and Save a Copy").is_none()); + assert!(writes.borrow().is_empty(), "nothing is written before the answer"); + h.get_by_label("LZW").click(); + h.get_by_label("Save Image Pyramid").click(); + h.run_steps(2); + h.key_press(Key::Enter); + h.run_steps(3); + assert_eq!(writes.borrow().as_slice(), &[("p.tif".to_string(), asked("lzw", None, true, true))]); + assert!(h.state().tiff_options.is_none()); + assert_eq!(h.state().session.active().unwrap().path.as_deref(), Some("p.tif")); + // The next TIFF starts where this one ended. + h.state_mut().save_as(Some("q.tif".into())).unwrap(); + h.run_steps(2); + assert_eq!(h.state().tiff_options.as_ref().map(|p| (p.compression, p.pyramid)), Some((TiffCompression::Lzw, true))); + h.get_by_label("ZIP").click(); + h.run_steps(2); + h.key_press(Key::Enter); + h.run_steps(3); + h.state_mut().run("actions.stop", json!({})).unwrap(); + assert_eq!( + h.state().session.actions.list[0].steps, + [ + ("file.saveAs".into(), json!({"path": "p.tif", "tiffCompression": "lzw", "tiffPyramid": true})), + ("file.saveAs".into(), json!({"path": "q.tif", "tiffCompression": "zip", "tiffPyramid": true})), + ] + ); + // Playback writes the same files without asking. + writes.borrow_mut().clear(); + let played = h.state_mut().run("actions.play", json!({"action": 0})).unwrap(); + assert!(played.get("failed").is_none(), "{played}"); + h.run_steps(2); + assert!(h.state().tiff_options.is_none()); + assert_eq!(writes.borrow().as_slice(), &[("p.tif".to_string(), asked("lzw", None, true, true)), ("q.tif".to_string(), asked("zip", None, true, true))]); +} + +/// A document without a Background offers Save Transparency, on to start; with an alpha or spot +/// channel it starts off, as an unset Save Transparency writes. Its layers can be discarded, +/// which saves a copy. +#[test] +fn layered_transparent_documents_choose_transparency_and_layers() { + let _gpu = photocraft_testkit::gpu_lock(); + let (mut h, writes) = tiff_app("transparent", "unused.tif"); + h.state_mut().run("layer.new.layer", json!({})).unwrap(); + h.state_mut().save_as(Some("t.tif".into())).unwrap(); + h.run_steps(4); + capture(&mut h, "tiff-options-layered"); + let p = h.state().tiff_options.as_ref().expect("prompt"); + assert_eq!((p.has_transparency, p.transparency, p.layered), (true, true, true)); + assert!(h.query_by_label("Including layers will increase file size.").is_some(), "the preference is on by default"); + h.get_by_label("Save Transparency").click(); + h.run_steps(2); + h.key_press(Key::Enter); + h.run_steps(3); + assert_eq!(writes.borrow().as_slice(), &[("t.tif".to_string(), asked("none", Some(false), false, true))]); + assert_eq!(h.state().session.active().unwrap().path.as_deref(), Some("t.tif")); + + // An alpha channel: Save Transparency starts off. Discarding the layers writes a copy. + h.state_mut().run("select.all", json!({})).unwrap(); + h.state_mut().run("select.saveSelection", json!({})).unwrap(); + assert!(!h.state().session.active().unwrap().doc.channels.is_empty(), "an alpha channel"); + h.state_mut().save_as(Some("u.tif".into())).unwrap(); + h.run_steps(4); + assert_eq!(h.state().tiff_options.as_ref().map(|p| p.transparency), Some(false)); + h.get_by_label("Discard Layers and Save a Copy").click(); + h.run_steps(2); + assert!(h.query_by_label("Including layers will increase file size.").is_none(), "no layers, no note"); + h.key_press(Key::Enter); + h.run_steps(3); + assert_eq!(writes.borrow().last().unwrap(), &("u.tif".to_string(), asked("none", Some(false), false, false))); + assert_eq!(h.state().session.active().unwrap().path.as_deref(), Some("t.tif"), "a copy: the document keeps its file"); +} + +/// "Don't show again" turns off the layered-file note for the next save. +#[test] +fn dont_show_again_clears_the_layered_tiff_preference() { + let _gpu = photocraft_testkit::gpu_lock(); + let (mut h, _) = tiff_app("transparent", "unused.tif"); + h.state_mut().run("layer.new.layer", json!({})).unwrap(); + h.state_mut().save_as(Some("t.tif".into())).unwrap(); + h.run_steps(4); + h.get_by_label("Don't show again").click(); + h.run_steps(2); + h.key_press(Key::Enter); + h.run_steps(3); + assert!(!h.state().session.prefs().file_handling.ask_before_saving_layered_tiff); + h.state_mut().save_as(Some("t.tif".into())).unwrap(); + h.run_steps(4); + assert!(h.state().tiff_options.is_some(), "TIFF Options still asks"); + assert!(h.query_by_label("Including layers will increase file size.").is_none()); +} + +/// Save a Copy asks too, and its answer replays as a `file.saveACopy` step. +#[test] +fn save_a_copy_asks_and_replays() { + let _gpu = photocraft_testkit::gpu_lock(); + let (mut h, writes) = tiff_app("white", "copy.tif"); + h.state_mut().run("actions.record", json!({})).unwrap(); + let ctx = h.ctx.clone(); + crate::menus::invoke(h.state_mut(), &ctx, "file.saveACopy", json!({})).unwrap(); + h.run_steps(3); + assert_eq!(h.state().tiff_options.as_ref().map(|p| p.copy), Some(true)); + h.get_by_label("ZIP").click(); + h.run_steps(2); + h.key_press(Key::Enter); + h.run_steps(3); + h.state_mut().run("actions.stop", json!({})).unwrap(); + assert_eq!(writes.borrow().as_slice(), &[("copy.tif".to_string(), asked("zip", None, false, true))]); + assert_eq!(h.state().session.active().unwrap().path, None, "a copy"); + assert_eq!(h.state().session.actions.list[0].steps, [("file.saveACopy".into(), json!({"path": "copy.tif", "tiffCompression": "zip"}))]); + writes.borrow_mut().clear(); + let played = h.state_mut().run("actions.play", json!({"action": 0})).unwrap(); + assert!(played.get("failed").is_none(), "{played}"); + assert_eq!(writes.borrow().as_slice(), &[("copy.tif".to_string(), asked("zip", None, false, true))]); +} + +/// File › Save confirming the document's own TIFF asks TIFF Options, and an action being +/// recorded keeps it as a plain Save (no path) with the answer: played back, it writes the TIFF +/// the action is played on, not the one it was recorded on (#3531). +#[test] +fn recorded_plain_save_of_a_tiff_writes_the_file_it_is_played_on() { + let _gpu = photocraft_testkit::gpu_lock(); + let (mut h, writes) = tiff_app("white", "own.tif"); + let ctx = h.ctx.clone(); + crate::menus::invoke(h.state_mut(), &ctx, "file.saveAs", json!({"path": "own.tif", "tiffCompression": "none"})).unwrap(); + h.state_mut().run("actions.record", json!({})).unwrap(); + crate::menus::invoke(h.state_mut(), &ctx, "file.save", json!({})).unwrap(); + h.run_steps(4); + assert!(h.state().tiff_options.is_some(), "confirming its own TIFF asks TIFF Options"); + h.get_by_label("LZW").click(); + h.run_steps(2); + h.key_press(Key::Enter); + h.run_steps(3); + h.state_mut().run("actions.stop", json!({})).unwrap(); + assert_eq!(h.state().session.actions.list[0].steps, [("file.save".into(), json!({"tiffCompression": "lzw"}))]); + crate::menus::invoke(h.state_mut(), &ctx, "file.saveAs", json!({"path": "other.tif", "tiffCompression": "none"})).unwrap(); + writes.borrow_mut().clear(); + let played = h.state_mut().run("actions.play", json!({"action": 0})).unwrap(); + assert!(played.get("failed").is_none(), "{played}"); + assert!(h.state().tiff_options.is_none(), "playback doesn't ask"); + assert_eq!(writes.borrow().as_slice(), &[("other.tif".to_string(), asked("lzw", None, false, true))]); +} + +/// A scripted plain Save that passes the TIFF answers skips the prompt and is recorded the same +/// way: `file.save` with the answers and no path, so playback writes the file it is played on. +#[test] +fn recorded_scripted_plain_save_of_a_tiff_has_no_path() { + let _gpu = photocraft_testkit::gpu_lock(); + let (mut h, writes) = tiff_app("white", "own.tif"); + let ctx = h.ctx.clone(); + crate::menus::invoke(h.state_mut(), &ctx, "file.saveAs", json!({"path": "own.tif", "tiffCompression": "none"})).unwrap(); + h.state_mut().run("actions.record", json!({})).unwrap(); + crate::menus::invoke(h.state_mut(), &ctx, "file.save", json!({"tiffCompression": "lzw", "tiffTransparency": false, "tiffPyramid": true})).unwrap(); + h.run_steps(3); + assert!(h.state().tiff_options.is_none(), "the script answered"); + h.state_mut().run("actions.stop", json!({})).unwrap(); + assert_eq!( + h.state().session.actions.list[0].steps, + [("file.save".into(), json!({"tiffCompression": "lzw", "tiffTransparency": false, "tiffPyramid": true}))] + ); + crate::menus::invoke(h.state_mut(), &ctx, "file.saveAs", json!({"path": "other.tif", "tiffCompression": "none"})).unwrap(); + writes.borrow_mut().clear(); + let played = h.state_mut().run("actions.play", json!({"action": 0})).unwrap(); + assert!(played.get("failed").is_none(), "{played}"); + assert_eq!(writes.borrow().as_slice(), &[("other.tif".to_string(), asked("lzw", Some(false), true, true))]); +} + +/// OK writes the waiting document with its own save settings, even after another tab became +/// active: its path is what linked smart objects resolve against. +#[test] +fn confirm_uses_the_waiting_documents_settings_not_the_active_tabs() { + let _gpu = photocraft_testkit::gpu_lock(); + let (mut h, _) = tiff_app("white", "unused.tif"); + let seen: Rc>>> = Rc::default(); + let saw = seen.clone(); + h.state_mut().services.export = Some(Box::new(move |_, _, s| { + saw.borrow_mut().push(s.document_path.clone()); + Ok((Vec::new(), Vec::new())) + })); + h.state_mut().save_as(Some("a.tif".into())).unwrap(); + h.run_steps(4); + h.key_press(Key::Enter); + h.run_steps(3); + let a = h.state().active_doc_id().unwrap(); + assert_eq!(h.state().session.active().unwrap().path.as_deref(), Some("a.tif")); + h.state_mut().run("file.new", json!({"width": 8, "height": 8, "background": "white"})).unwrap(); + h.state_mut().sync_views(); + let b = h.state().active_doc_id().unwrap(); + h.state_mut().refocus(a).unwrap(); + h.state_mut().save_as(Some("b.tif".into())).unwrap(); + h.run_steps(4); + assert!(h.state().tiff_options.is_some()); + h.state_mut().refocus(b).unwrap(); + seen.borrow_mut().clear(); + h.key_press(Key::Enter); + h.run_steps(3); + assert_eq!(seen.borrow().as_slice(), &[Some(std::path::PathBuf::from("a.tif"))]); +} + +/// Scripted saves that pass TIFF answers skip the prompt; a bad one is an error, not a save. +#[test] +fn scripted_tiff_answers_skip_the_prompt() { + let _gpu = photocraft_testkit::gpu_lock(); + let (mut h, writes) = tiff_app("transparent", "unused.tif"); + let ctx = h.ctx.clone(); + crate::menus::invoke(h.state_mut(), &ctx, "file.saveAs", json!({"path": "s.tif", "tiffCompression": "zip", "tiffTransparency": true, "tiffPyramid": true})) + .unwrap(); + h.run_steps(2); + assert!(h.state().tiff_options.is_none()); + assert_eq!(writes.borrow().as_slice(), &[("s.tif".to_string(), asked("zip", Some(true), true, true))]); + for bad in [json!({"path": "b.tif", "tiffCompression": "jpeg"}), json!({"path": "b.tif", "tiffPyramid": "yes"})] { + assert!(crate::menus::invoke(h.state_mut(), &ctx, "file.saveAs", bad.clone()).is_err(), "{bad}"); + } + assert_eq!(writes.borrow().len(), 1); + assert!(h.state().tiff_options.is_none()); + // Automation saves never prompt, and take the answers too. + let choices = crate::FormatChoices::from_params(&json!({"tiffCompression": "lzw"}), "app.save").unwrap(); + h.state_mut().services.automation_write = Some(Box::new({ + let w = writes.clone(); + move |path, bytes| { + w.borrow_mut().push((path.to_string(), bytes.to_vec())); + Ok(()) + } + })); + h.state_mut().save_automation_with(Some("a.tif".into()), choices).unwrap(); + assert_eq!(writes.borrow().last().unwrap(), &("a.tif".to_string(), asked("lzw", None, false, true))); +} + +/// Cancel writes nothing. +#[test] +fn cancel_writes_nothing() { + let _gpu = photocraft_testkit::gpu_lock(); + let (mut h, writes) = tiff_app("white", "unused.tif"); + h.state_mut().save_as(Some("p.tif".into())).unwrap(); + h.run_steps(2); + h.key_press(Key::Escape); + h.run_steps(3); + assert!(h.state().tiff_options.is_none()); + assert!(writes.borrow().is_empty()); + assert_eq!(h.state().session.active().unwrap().path, None); +} diff --git a/crates/ui-egui/src/dialogs/tiff_options_ui.rs b/crates/ui-egui/src/dialogs/tiff_options_ui.rs index b387f1aa41..90f33aaf61 100644 --- a/crates/ui-egui/src/dialogs/tiff_options_ui.rs +++ b/crates/ui-egui/src/dialogs/tiff_options_ui.rs @@ -1,22 +1,118 @@ -//! TIFF Options: the prompt File › Save As shows before writing a layered TIFF, as Photoshop -//! does when "Ask Before Saving Layered TIFF Files" is on in Preferences › File Handling. +//! TIFF Options: what File › Save As and Save a Copy ask before writing a TIFF, as Photoshop's +//! TIFF Options dialog does (#8827): Image Compression (None, LZW, ZIP), Save Image Pyramid, +//! Save Transparency (offered when the document has no Background layer) and, for a document a +//! TIFF stores with its layers, "Discard Layers and Save a Copy". Photoshop's JPEG compression, +//! Pixel Order and Byte Order aren't offered: the TIFF encoder writes interleaved samples in the +//! machine's byte order without JPEG. +//! +//! With "Ask Before Saving Layered TIFF Files" on (Preferences › File Handling), a layered save +//! also notes that layers increase the file size; "Don't show again" clears the preference. //! //! The save is parked in [`Prompt`] while the modal is up (egui is immediate-mode) and written -//! once answered: with the layers (Photoshop layer data in the file), or flattened ("Discard -//! Layers and Save a Copy"). "Don't show again" clears the preference. Cancel drops the save. +//! once answered, journalled as the `record` command passed to [`park`] with its path, +//! `tiffLayers` (layered documents) and [`TiffChoice::record`]'s params. Cancel drops the save. +//! Scripts skip the prompt by passing any of `tiffCompression`, `tiffTransparency`, +//! `tiffPyramid` or `tiffLayers`. The answer is remembered for the next TIFF save of the session. + +use photocraft_codecs::TiffCompression; +use serde_json::Value; use crate::{ExportSettings, PhotocraftApp}; +/// A TIFF Options answer, from the prompt or from a command's params. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct TiffChoice { + pub compression: TiffCompression, + /// Save Transparency; `None` for the default (see [`ExportSettings::tiff_transparency`]). + pub transparency: Option, + /// Save Image Pyramid. + pub pyramid: bool, + /// `Some(false)`: "Discard Layers and Save a Copy". `None` keeps what the save would do. + pub layers: Option, +} + +impl TiffChoice { + /// `settings` writing this choice. + pub fn apply(self, settings: ExportSettings) -> ExportSettings { + ExportSettings { + tiff_compression: self.compression, + tiff_transparency: self.transparency, + tiff_pyramid: self.pyramid, + tiff_layers: self.layers.unwrap_or(settings.tiff_layers), + ..settings + } + } + + /// Adds `tiffCompression`, and `tiffTransparency` and `tiffPyramid` when set, to a recorded + /// save step, so replaying it doesn't ask again. `tiffLayers` is recorded by the save itself. + pub fn record(self, step: &mut Value) { + step["tiffCompression"] = compression_name(self.compression).into(); + if let Some(t) = self.transparency { + step["tiffTransparency"] = t.into(); + } + if self.pyramid { + step["tiffPyramid"] = true.into(); + } + } +} + +/// The `tiffCompression` name of `c` (PackBits, which the dialog doesn't offer, as none). +pub fn compression_name(c: TiffCompression) -> &'static str { + match c { + TiffCompression::Lzw => "lzw", + TiffCompression::Deflate => "zip", + TiffCompression::None | TiffCompression::PackBits => "none", + } +} + +/// The TIFF Options a command or control call gives (`tiffCompression`, `tiffTransparency`, +/// `tiffPyramid`, `tiffLayers`), which skip the prompt. None given: `None`. A bad value is an +/// error, as in the engine's saves. +pub fn choice_param(params: &Value, cmd: &str) -> Result, String> { + use photocraft_engine::file_cmds::{tiff_bool_param, tiff_compression_param}; + let compression = tiff_compression_param(params, cmd).map_err(|e| e.to_string())?; + let transparency = tiff_bool_param(params, "tiffTransparency", cmd).map_err(|e| e.to_string())?; + let pyramid = tiff_bool_param(params, "tiffPyramid", cmd).map_err(|e| e.to_string())?; + let layers = match params.get("tiffLayers") { + None | Some(Value::Null) => None, + Some(Value::Bool(b)) => Some(*b), + Some(v) => return Err(format!("{cmd}: `tiffLayers` must be true or false, got {v}")), + }; + let any = compression.is_some() || transparency.is_some() || pyramid.is_some() || layers.is_some(); + Ok(any.then(|| TiffChoice { compression: compression.unwrap_or_default(), transparency, pyramid: pyramid.unwrap_or(false), layers })) +} + +/// What the last TIFF Options answer of the session chose, where the next prompt starts. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Remembered { + pub compression: TiffCompression, + pub pyramid: bool, +} + /// A save waiting on the TIFF Options answer. pub struct Prompt { /// The document that requested the save, even if tabs change while this is open. doc: photocraft_doc::DocId, /// Where the document goes. pub path: String, + pub compression: TiffCompression, + pub pyramid: bool, + /// Save Transparency, offered when [`Self::has_transparency`]. + pub transparency: bool, + /// The document has no Background layer, so its composite can be transparent. + pub has_transparency: bool, + /// The TIFF would carry the document's layers: "Discard Layers and Save a Copy" is offered. + pub layered: bool, /// "Discard Layers and Save a Copy". pub discard_layers: bool, + /// Show the layered-file note ("Ask Before Saving Layered TIFF Files"). + pub layer_note: bool, /// Clears `ask_before_saving_layered_tiff` when the save goes ahead. pub dont_ask_again: bool, + /// Save a Copy: the document keeps its path and unsaved changes. + pub copy: bool, + /// The step the answered save is recorded as: `file.saveAs` (#2032) or `file.saveACopy` (#4373). + pub record: &'static str, } /// `true` when `path` is a TIFF name (`.tif` / `.tiff`). @@ -25,36 +121,70 @@ pub fn is_tiff_path(path: &str) -> bool { name.rsplit_once('.').is_some_and(|(stem, ext)| !stem.is_empty() && (ext.eq_ignore_ascii_case("tif") || ext.eq_ignore_ascii_case("tiff"))) } -/// Whether saving the active document to `path` should go through the prompt: a TIFF that -/// would carry layer data, with the preference on. +/// Whether saving the active document to `path` asks for TIFF Options: any TIFF. pub fn wants_prompt(app: &PhotocraftApp, path: &str) -> bool { - if !is_tiff_path(path) || !app.session.prefs().file_handling.ask_before_saving_layered_tiff { - return false; - } - app.session.active().is_some_and(|st| photocraft_engine::file_cmds::tiff_would_write_layers(&st.doc)) + is_tiff_path(path) && app.session.active().is_some() } -/// Parks the save behind the prompt. -pub fn park(app: &mut PhotocraftApp, path: String) -> Result<(), String> { +/// Parks the save of the active document to `path` behind the prompt. A prompt already waiting +/// is never replaced. +pub fn park(app: &mut PhotocraftApp, path: String, copy: bool, record: &'static str) -> Result<(), String> { + if app.save_options_open() { + return Err(crate::SAVE_OPTIONS_OPEN.into()); + } let doc = app.active_doc_id()?; - app.tiff_options = Some(Prompt { doc, path, discard_layers: false, dont_ask_again: false }); + let st = app.session.active().ok_or("no document")?; + let layered = photocraft_engine::file_cmds::tiff_would_write_layers(&st.doc); + let has_transparency = st.doc.background().is_none(); + // Starts where an unset Save Transparency writes: kept unless there are alpha or spot channels. + let transparency = st.doc.channels.is_empty() && st.doc.quick_mask.is_none(); + let layer_note = layered && app.session.prefs().file_handling.ask_before_saving_layered_tiff; + let Remembered { compression, pyramid } = app.tiff_remembered; + app.tiff_options = Some(Prompt { + doc, + path, + compression, + pyramid, + transparency, + has_transparency, + layered, + discard_layers: false, + layer_note, + dont_ask_again: false, + copy, + record, + }); Ok(()) } pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { let Some(p) = &mut app.tiff_options else { return }; let (mut ok, mut cancel) = (false, false); - let (mut discard, mut dont_ask) = (p.discard_layers, p.dont_ask_again); let modal = egui::Modal::new(egui::Id::unique("tiff-options")).show(ctx, |ui| { ui.set_max_width(420.0); ui.label(egui::RichText::new(tl!("TIFF Options")).font(crate::theme::semibold(15.0))); ui.add_space(4.0); crate::widgets::hairline(ui); ui.add_space(8.0); - ui.label(tl!("Including layers will increase file size.")); + crate::widgets::section_label(ui, tl!("Image Compression")); + ui.radio_value(&mut p.compression, TiffCompression::None, tl!("None")); + ui.radio_value(&mut p.compression, TiffCompression::Lzw, tl!("LZW")); + ui.radio_value(&mut p.compression, TiffCompression::Deflate, tl!("ZIP")); ui.add_space(8.0); - crate::widgets::checkbox(ui, &mut discard, tl!("Discard Layers and Save a Copy")); - crate::widgets::checkbox(ui, &mut dont_ask, tl!("Don't show again")); + crate::widgets::checkbox(ui, &mut p.pyramid, tl!("Save Image Pyramid")); + if p.has_transparency { + crate::widgets::checkbox(ui, &mut p.transparency, tl!("Save Transparency")); + } + if p.layered { + ui.add_space(8.0); + crate::widgets::section_label(ui, tl!("Layers")); + crate::widgets::checkbox(ui, &mut p.discard_layers, tl!("Discard Layers and Save a Copy")); + if p.layer_note && !p.discard_layers { + ui.add_space(4.0); + ui.label(tl!("Including layers will increase file size.")); + crate::widgets::checkbox(ui, &mut p.dont_ask_again, tl!("Don't show again")); + } + } ui.add_space(12.0); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.spacing_mut().item_spacing.x = 10.0; @@ -63,30 +193,9 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { }); }); cancel |= modal.should_close(); - p.discard_layers = discard; - p.dont_ask_again = dont_ask; // A focused button takes Enter as its click without consuming the key: Enter on Cancel cancels. if ok || (!cancel && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter))) { - let Some(p) = app.tiff_options.take() else { return }; - if p.dont_ask_again { - app.session.edit_prefs(|prefs| prefs.file_handling.ask_before_saving_layered_tiff = false); - } - let settings = ExportSettings { tiff_layers: !p.discard_layers, ..app.save_settings() }; - let step = serde_json::json!({"path": p.path, "tiffLayers": !p.discard_layers}); - let written = app.with_document(p.doc, |app| app.write_document(p.path, &settings, p.discard_layers)); - if written.is_ok() { - // An action being recorded keeps the Save As with this answer (#2032). - app.session.journal.push(("file.saveAs".into(), step)); - } - match written { - Err(e) => { - app.ui.status = crate::i18n::fmt(tl!("Save failed: {error}"), &[("error", &e)]); - app.ui.status_error = true; - } - Ok(Some(_)) if !p.discard_layers => crate::discard_ui::saved_document(app, ctx, p.doc), - // A background save releases the close prompt when it finishes (`jobs_ui`). - Ok(_) => {} - } + confirm(app, ctx); } else if cancel { app.tiff_options = None; app.ui.status = tl!("Save cancelled").into(); @@ -94,9 +203,41 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { } } +/// The answer the prompt gives as it stands. +pub fn choice(p: &Prompt) -> TiffChoice { + TiffChoice { + compression: p.compression, + transparency: p.has_transparency.then_some(p.transparency), + pyramid: p.pyramid, + layers: p.layered.then_some(!p.discard_layers), + } +} + +/// OK: writes the waiting save with the prompt's answer, and journals it. +pub fn confirm(app: &mut PhotocraftApp, ctx: &egui::Context) { + let Some(p) = app.tiff_options.take() else { return }; + if p.layer_note && p.dont_ask_again && !p.discard_layers { + app.session.edit_prefs(|prefs| prefs.file_handling.ask_before_saving_layered_tiff = false); + } + app.tiff_remembered = Remembered { compression: p.compression, pyramid: p.pyramid }; + let choice = choice(&p); + // A plain Save writes whichever file the action is played on (#3531): no path. + let mut step = if p.record == "file.save" { serde_json::json!({}) } else { serde_json::json!({"path": p.path}) }; + if let Some(layers) = choice.layers { + step["tiffLayers"] = layers.into(); + } + choice.record(&mut step); + // The waiting document's own settings (its path resolves linked smart objects), even if + // another tab is active now. Closed meanwhile, the write below reports it. + let settings = app.with_document(p.doc, |app| Ok(app.save_settings())).unwrap_or_else(|_| app.save_settings()); + let settings = choice.apply(settings); + app.write_answered_save(ctx, p.doc, p.path, &settings, p.copy || p.discard_layers, (p.record, step)); +} + #[cfg(test)] mod tests { use super::*; + use serde_json::json; #[test] fn tiff_names() { @@ -108,4 +249,55 @@ mod tests { assert!(!is_tiff_path("tif")); assert!(!is_tiff_path("a.png")); } + + #[test] + fn choice_params() { + assert_eq!(choice_param(&json!({}), "file.saveAs"), Ok(None)); + assert_eq!(choice_param(&json!({"tiffCompression": null, "tiffPyramid": null}), "file.saveAs"), Ok(None)); + assert_eq!( + choice_param(&json!({"tiffCompression": "lzw"}), "file.saveAs"), + Ok(Some(TiffChoice { compression: TiffCompression::Lzw, ..Default::default() })) + ); + assert_eq!( + choice_param(&json!({"tiffTransparency": false, "tiffPyramid": true, "tiffLayers": false}), "file.saveAs"), + Ok(Some(TiffChoice { compression: TiffCompression::None, transparency: Some(false), pyramid: true, layers: Some(false) })) + ); + for bad in [ + json!({"tiffCompression": "jpeg"}), + json!({"tiffCompression": 1}), + json!({"tiffTransparency": "yes"}), + json!({"tiffTransparency": 1}), + json!({"tiffPyramid": [true]}), + json!({"tiffLayers": "false"}), + ] { + assert!(choice_param(&bad, "file.saveAs").is_err(), "{bad}"); + } + } + + #[test] + fn recorded_steps_replay_the_same_answer() { + for choice in [ + TiffChoice::default(), + TiffChoice { compression: TiffCompression::Deflate, transparency: Some(true), pyramid: true, layers: None }, + TiffChoice { compression: TiffCompression::Lzw, transparency: Some(false), pyramid: false, layers: None }, + ] { + let mut step = json!({"path": "a.tif"}); + choice.record(&mut step); + assert_eq!(choice_param(&step, "file.saveAs"), Ok(Some(choice)), "{step}"); + } + } + + #[test] + fn apply_sets_the_export_settings() { + let base = ExportSettings::default(); + let s = TiffChoice { compression: TiffCompression::Deflate, transparency: Some(false), pyramid: true, layers: Some(false) }.apply(base.clone()); + assert_eq!((s.tiff_compression, s.tiff_transparency, s.tiff_pyramid, s.tiff_layers), (TiffCompression::Deflate, Some(false), true, false)); + let io = s.io_options(); + assert_eq!( + (io.encode.tiff_compression, io.tiff_transparency, io.encode.tiff_pyramid, io.tiff_layers), + (TiffCompression::Deflate, Some(false), true, false) + ); + // No layers answer keeps what the save would do. + assert!(TiffChoice::default().apply(base).tiff_layers); + } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8c82874876..b9a1380e9a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -674,8 +674,10 @@ pub struct PhotocraftApp { pub(crate) discard: Option, /// The Open or Save dialog in progress, and the action waiting on it (see `file_dialog`). pub(crate) file_dialog: Option, - /// A Save As to a layered TIFF parked behind the TIFF Options prompt (see `tiff_options_ui`). + /// A Save As or Save a Copy to a TIFF parked behind the TIFF Options prompt (see `tiff_options_ui`). pub(crate) tiff_options: Option, + /// The last TIFF Options compression and pyramid answer, where the next prompt starts. Session-only. + pub(crate) tiff_remembered: tiff_options_ui::Remembered, /// Where each document was last saved or exported to through a dialog (#1826): its next Save /// As or export dialog starts there rather than beside the document. Session-only. pub(crate) save_dirs: HashMap, diff --git a/crates/ui-egui/src/menus/view_cmds/dialogs.rs b/crates/ui-egui/src/menus/view_cmds/dialogs.rs index fe6dee7e2e..06f16ef927 100644 --- a/crates/ui-egui/src/menus/view_cmds/dialogs.rs +++ b/crates/ui-egui/src/menus/view_cmds/dialogs.rs @@ -589,8 +589,9 @@ fn save_a_copy(app: &mut PhotocraftApp) -> Result { let suggested = format!("{stem}{}.psd", app.session.prefs().file_handling.copy_suffix()); // The copy is of the document as it was when asked. app.pick_save(&suggested, move |app, path| { - // An RGB Targa asks for its bits per pixel first, a DDS texture for its compression, and - // a JPEG for its JPEG Options; the copy is written from the prompt. + // An RGB Targa asks for its bits per pixel first, a DDS texture for its compression, a + // JPEG for its JPEG Options and a TIFF for its TIFF Options; the copy is written from the + // prompt. let parked = app.with_document(doc.id, |app| { if crate::targa_options_ui::wants_prompt(app, &path) { crate::targa_options_ui::park(app, path.clone(), true, "file.saveACopy")?; @@ -604,6 +605,10 @@ fn save_a_copy(app: &mut PhotocraftApp) -> Result { crate::jpeg_options_ui::park(app, path.clone(), true, "file.saveACopy")?; return Ok(true); } + if crate::tiff_options_ui::wants_prompt(app, &path) { + crate::tiff_options_ui::park(app, path.clone(), true, "file.saveACopy")?; + return Ok(true); + } Ok(false) })?; if parked { diff --git a/crates/ui-egui/src/services.rs b/crates/ui-egui/src/services.rs index 8859fb606c..7f05fdc1de 100644 --- a/crates/ui-egui/src/services.rs +++ b/crates/ui-egui/src/services.rs @@ -1,20 +1,22 @@ use super::{ CaptionStyle, Document, FileDialogFn, OsEvent, Value, dds_options_ui, jobs_ui, jpeg_options_ui, monitor_status, native_menu, screen_picker, - targa_options_ui, + targa_options_ui, tiff_options_ui, }; /// The error a save gets while another save waits on its format prompt. pub(crate) const SAVE_OPTIONS_OPEN: &str = "Answer the TIFF, Targa, DDS or JPEG Options before starting another save"; /// The format answers a save takes instead of asking: the Targa bits per pixel (`"tgaBits"`), -/// the DDS options (`"ddsCompression"`, `"ddsMipmaps"`) and JPEG Options (`"quality"` 0–12, -/// `"jpegFormat"`). `None` asks in Save As and takes the default in automation saves, which never +/// the DDS options (`"ddsCompression"`, `"ddsMipmaps"`), JPEG Options (`"quality"` 0–12, +/// `"jpegFormat"`) and TIFF Options (`"tiffCompression"`, `"tiffTransparency"`, `"tiffPyramid"`, +/// `"tiffLayers"`). `None` asks in Save As and takes the default in automation saves, which never /// prompt. #[derive(Clone, Copy, Debug, Default, PartialEq)] pub struct FormatChoices { pub tga_bits: Option, pub dds: Option, pub jpeg: Option, + pub tiff: Option, } impl FormatChoices { @@ -24,6 +26,7 @@ impl FormatChoices { tga_bits: targa_options_ui::bits_param(params, cmd)?, dds: dds_options_ui::options_param(params, cmd)?, jpeg: jpeg_options_ui::choice_param(params, cmd)?, + tiff: tiff_options_ui::choice_param(params, cmd)?, }) } @@ -34,12 +37,17 @@ impl FormatChoices { tga_bits: targa_options_ui::bits_param(params, cmd)?, dds: dds_options_ui::options_param(params, cmd)?, jpeg: jpeg_options_ui::recorded_choice(params, cmd)?, + tiff: tiff_options_ui::choice_param(params, cmd)?, }) } /// The export settings with these answers applied. pub fn apply(self, settings: ExportSettings) -> ExportSettings { let settings = ExportSettings { tga_bits: self.tga_bits.or(settings.tga_bits), dds: self.dds.unwrap_or(settings.dds), ..settings }; + let settings = match self.tiff { + Some(t) => t.apply(settings), + None => settings, + }; match self.jpeg { Some(j) => j.apply(settings), None => settings, @@ -57,6 +65,9 @@ impl FormatChoices { if let Some(j) = self.jpeg { j.record(step); } + if let Some(t) = self.tiff { + t.record(step); + } } } @@ -76,6 +87,13 @@ pub struct ExportSettings { pub webp_quality: Option, /// TIFF: keep the layers (Photoshop layer data); `false` is "Discard Layers and Save a Copy". pub tiff_layers: bool, + /// TIFF Options › Image Compression. + pub tiff_compression: photocraft_codecs::TiffCompression, + /// TIFF Options › Save Transparency (`None`: kept unless the document has alpha or spot + /// channels; see [`photocraft_io::ExportOptions::tiff_transparency`]). + pub tiff_transparency: Option, + /// TIFF Options › Save Image Pyramid. + pub tiff_pyramid: bool, /// Embed the document's whole XMP packet. `true` by default (Save As keeps the metadata); /// Export As starts at Metadata: None, since the packet can carry the text of every type /// layer and one id per placed document (#647). @@ -119,6 +137,9 @@ impl Default for ExportSettings { webp_lossless: true, webp_quality: None, tiff_layers: true, + tiff_compression: photocraft_codecs::TiffCompression::None, + tiff_transparency: None, + tiff_pyramid: false, xmp_all: true, tga_bits: None, dds: photocraft_io::DdsOptions::default(), @@ -150,6 +171,9 @@ impl ExportSettings { opts.encode.embed_icc = self.embed_icc; opts.encode.embed_metadata = self.embed_metadata; opts.tiff_layers = self.tiff_layers; + opts.tiff_transparency = self.tiff_transparency; + opts.encode.tiff_compression = self.tiff_compression; + opts.encode.tiff_pyramid = self.tiff_pyramid; opts.xmp = if self.xmp_all { photocraft_io::XmpEmbed::All } else { photocraft_io::XmpEmbed::None }; opts.tga_bits = self.tga_bits; opts.encode.dds = self.dds; diff --git a/docs/control-protocol.md b/docs/control-protocol.md index e74938dc2f..9227b37f44 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -110,7 +110,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i another document becomes active before the capture, the request fails with an error rather than saving the wrong document - `ui.focus`: bring the main window to the front -- `app.open {path}` / `app.save {path?, tgaBits?, ddsCompression?, ddsMipmaps?, quality?, jpegFormat?}` (JPEG: `quality` on the 0–12 scale, `jpegFormat` `baseline`, `optimized` or `progressive`; automation never shows JPEG Options): relative file I/O through the configured automation roots (`app.open` reads under the read root, `app.save` writes under the write root; absolute paths beneath the canonical or configured root are accepted, while `..` and paths escaping the root are refused, and both fail closed when no root was granted). Both reply with `warnings` (import/export notes such as "adjustment layer flattened"; `[]` when none), also shown to the user in the status bar and as a notice (`notices` in `ui.inspect`); `app.open` also returns the `path` and document `name` (directory `.pcraft` — the native on-disk form — loads through the read root's capability; the single-file (zip) form takes the bytes path), `app.save` the `path` written. `app.save` without `path` writes back only to the document's own PSD, PSB or `.pcraft` file, like File › Save. `tgaBits` (24 or 32) sets a TGA's bits per pixel: 32 writes the alpha channel (or else the transparency) as its alpha, 24 none; without it, 32 with an alpha channel or transparency, else 24. `ddsCompression` (`none`, `bc1`, `bc2` or `bc3`) and `ddsMipmaps` (bool) set a DDS texture's compression and mip chain; without them, uncompressed without mipmaps. Automation saves never show TGA, DDS, JPEG or TIFF options. Automation opens and saves never fire script events. Use these two rather than `file.open`, `file.save`, `file.saveAs` or `file.saveACopy`, which the control channel refuses (see [Engine commands](#engine-commands)) +- `app.open {path}` / `app.save {path?, tgaBits?, ddsCompression?, ddsMipmaps?, quality?, jpegFormat?, tiffCompression?, tiffTransparency?, tiffPyramid?, tiffLayers?}` (JPEG: `quality` on the 0–12 scale, `jpegFormat` `baseline`, `optimized` or `progressive`; TIFF: `tiffCompression` `none`, `lzw` or `zip`, `tiffTransparency` and `tiffPyramid` bools; automation never shows JPEG or TIFF Options): relative file I/O through the configured automation roots (`app.open` reads under the read root, `app.save` writes under the write root; absolute paths beneath the canonical or configured root are accepted, while `..` and paths escaping the root are refused, and both fail closed when no root was granted). Both reply with `warnings` (import/export notes such as "adjustment layer flattened"; `[]` when none), also shown to the user in the status bar and as a notice (`notices` in `ui.inspect`); `app.open` also returns the `path` and document `name` (directory `.pcraft` — the native on-disk form — loads through the read root's capability; the single-file (zip) form takes the bytes path), `app.save` the `path` written. `app.save` without `path` writes back only to the document's own PSD, PSB or `.pcraft` file, like File › Save. `tgaBits` (24 or 32) sets a TGA's bits per pixel: 32 writes the alpha channel (or else the transparency) as its alpha, 24 none; without it, 32 with an alpha channel or transparency, else 24. `ddsCompression` (`none`, `bc1`, `bc2` or `bc3`) and `ddsMipmaps` (bool) set a DDS texture's compression and mip chain; without them, uncompressed without mipmaps. Automation saves never show TGA, DDS, JPEG or TIFF options. Automation opens and saves never fire script events. Use these two rather than `file.open`, `file.save`, `file.saveAs` or `file.saveACopy`, which the control channel refuses (see [Engine commands](#engine-commands)) - `app.quit` ## Engine commands @@ -449,7 +449,7 @@ stdio, and a value that is not a port is a usage error naming the variable. Impl | `doc.exportLayers` | `{dir, format? (png), prefix?, visibleOnly? (true), quality?, index?}` → `{files, skipped}` (plus `count` for workspace exports): one file per exported top-level layer, named like `file.export.layersToFiles` names them. `count` is present only for workspace exports and counts written files only. `skipped` is always an array (empty when nothing is omitted), in top-to-bottom order, of `{layer, name, reason}`: the session layer id, original unsanitized name, and `"hidden by visibleOnly"` or `"adjustment layer has no pixels of its own"`. Visibility takes precedence for hidden adjustments; `visibleOnly:false` exports hidden non-adjustment layers. Groups export merged; their children are not separate exports or skips. Existing filenames/numbering are unchanged. `dir` resolves beneath the write root, so the ambient command stays refused. The bytes come from the command's own core, so both paths write the same files; the write capability refuses to overwrite an existing file. Encode/write failures are errors, not skips | | `doc.exportDataSets` | `{dir, format? (png), dataSets? (all), naming? ("{name}\|{index}\|{document}"), index?}` → `{files, count}`: the capability-scoped `file.export.dataSetsAsFiles`. The batch is planned first and colliding names are refused before any file is written, as in the command | | `doc.importDataSets` | `{path, delimiter? (,), index?}` → `{imported}`: the capability-scoped `file.import.variableDataSets`; the CSV is read beneath the read root | -| `doc.save` | `{path?, format?, quality?, index?, tiffLayers?, tgaBits?, ddsCompression?, ddsMipmaps?, pcraftFullComposite?}` (`.pcraft` native, else export by extension; set `tiffLayers: true` to preserve TIFF layers rather than exporting a flattened TIFF; `tgaBits` 24 or 32 sets a TGA's bits per pixel, and `ddsCompression`/`ddsMipmaps` a DDS texture's compression and mip chain, as `app.save` does; `pcraftFullComposite: true` also writes the flattened document at its canvas size into a `.pcraft` file as `composite/full.png`; the save may leave it out in the cases listed in [the format guide](../book/src/formats/pcraft.md), and the manifest's `full_composite` member names it when written). Without `path` only a PSD, PSB or `.pcraft` document is written back to its own file, in its own format; anything else is an error and the file is left unchanged. A successful layered save (PSD, PSB or `.pcraft`) records the current revision as saved and makes the file the document's path, so `session.list` reports `dirty: false`; a flat export is a copy and leaves the document dirty | +| `doc.save` | `{path?, format?, quality?, index?, tiffLayers?, tiffCompression?, tiffTransparency?, tiffPyramid?, tgaBits?, ddsCompression?, ddsMipmaps?, pcraftFullComposite?}` (`.pcraft` native, else export by extension; set `tiffLayers: true` to preserve TIFF layers rather than exporting a flattened TIFF; `tiffCompression` (`none`, `lzw`, `zip`), `tiffTransparency` (bool: Save Transparency; unset, kept unless the document has alpha or spot channels) and `tiffPyramid` (bool) are TIFF Options; `tgaBits` 24 or 32 sets a TGA's bits per pixel, and `ddsCompression`/`ddsMipmaps` a DDS texture's compression and mip chain, as `app.save` does; `pcraftFullComposite: true` also writes the flattened document at its canvas size into a `.pcraft` file as `composite/full.png`; the save may leave it out in the cases listed in [the format guide](../book/src/formats/pcraft.md), and the manifest's `full_composite` member names it when written). Without `path` only a PSD, PSB or `.pcraft` document is written back to its own file, in its own format; anything else is an error and the file is left unchanged. A successful layered save (PSD, PSB or `.pcraft`) records the current revision as saved and makes the file the document's path, so `session.list` reports `dirty: false`; a flat export is a copy and leaves the document dirty | | `doc.inspect` | `{index?}`: same JSON as `document.inspect` | | `doc.render` | `{index?, maxSide? (1024; 0 = full), path?}`: PNG to `path`, else `{mime, base64}` | | `doc.select` / `doc.close` | `{index}` / `{index?}` | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index a0821f154e..602597f6e4 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-31 · **Change:** minor (`.atn`: 144 supported events after Actions batch 12 (Groups A–I, command options, version 12 files, reference forms); the supported table and measured reference are in [atn.md](atn.md); TIFF: YCbCr images open as RGB, including JPEG-compressed scans with subsampled chroma, #10316; TIFFs up to 300,000 px a side open, decoded a group of rows at a time into the layer, #8169; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; PSD Filter Gallery: Halftone Pattern dot/circle/line settings survive PSD/PSB saves (#8940); Save for Web writes WebP, lossy or lossless, #6391; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency, #8877; layered TIFF writes the TySh text version and descriptor in PSD big-endian order while the header, matrix, warp and bounds keep container order, #9098; layered TIFF writes Photoshop's 14-byte global layer mask, so Photoshop opens the layers instead of only the composite, #9095; legacy PSD point text placed from the layout ink box after font resolution, #6094; `.atn`: 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; `.asl` layer style libraries read and written, #1051; JPEG Options on File › Save, Save As and Save a Copy, FILE-215-19 partial (no Matte, progressive Scans or size preview); 32-bit Hue/Saturation unclipped and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; JPEG Options on Save As and Save a Copy, FILE-215-19; TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; Cineon write: 10-bit gray/RGB; animated WebP frames load into video layers, #1198; Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 53 filters (39 more: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag, Invert, Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding; Amiga IFF write: 24-plane ILBM, Save As `.iff`; PSD stores spot channels as Photoshop does, ink 0 = solid, and numbers them in 1053, #7415 split; TIFF writes and reads alpha and spot channels as extra samples with Photoshop's channel resources, PSD output unchanged, FILE-215-7, FILE-215-17's TIFF part, #7415; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-11-02 · **Change:** minor (TIFF Options on Save As and Save a Copy: Image Compression None/LZW/ZIP, Save Image Pyramid, Save Transparency and Discard Layers, also `tiffTransparency`/`tiffPyramid` for scripts, the CLI and MCP, #8827; `.atn`: 144 supported events after Actions batch 12 (Groups A–I, command options, version 12 files, reference forms); the supported table and measured reference are in [atn.md](atn.md); TIFF: YCbCr images open as RGB, including JPEG-compressed scans with subsampled chroma, #10316; TIFFs up to 300,000 px a side open, decoded a group of rows at a time into the layer, #8169; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; PSD Filter Gallery: Halftone Pattern dot/circle/line settings survive PSD/PSB saves (#8940); Save for Web writes WebP, lossy or lossless, #6391; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency, #8877; layered TIFF writes the TySh text version and descriptor in PSD big-endian order while the header, matrix, warp and bounds keep container order, #9098; layered TIFF writes Photoshop's 14-byte global layer mask, so Photoshop opens the layers instead of only the composite, #9095; legacy PSD point text placed from the layout ink box after font resolution, #6094; `.atn`: 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; `.asl` layer style libraries read and written, #1051; JPEG Options on File › Save, Save As and Save a Copy, FILE-215-19 partial (no Matte, progressive Scans or size preview); 32-bit Hue/Saturation unclipped and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; JPEG Options on Save As and Save a Copy, FILE-215-19; TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; Cineon write: 10-bit gray/RGB; animated WebP frames load into video layers, #1198; Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 53 filters (39 more: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag, Invert, Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding; Amiga IFF write: 24-plane ILBM, Save As `.iff`; PSD stores spot channels as Photoshop does, ink 0 = solid, and numbers them in 1053, #7415 split; TIFF writes and reads alpha and spot channels as extra samples with Photoshop's channel resources, PSD output unchanged, FILE-215-7, FILE-215-17's TIFF part, #7415; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency) · **Target:** Adobe Photoshop 2026 **PSD Filter Gallery (#8940, 2026-10-23):** Halftone Pattern maps its size, contrast and dot/circle/line pattern as editable settings on import and export, with the reference application's descriptor keys (`HlSz`, `Cntr`, `ScrT`: `ScrD`/`ScrC`/`ScrL`). PSD/PSB round trips pass at 8/16/32 bits. This does not claim pixel equivalence for the existing renderer. Unknown or malformed settings remain verbatim on import. @@ -26,7 +26,7 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity. | PSD | R/W | yes | yes | 1/8/16/32-bit; Bitmap, Gray, Indexed, RGB, CMYK, Multichannel, Lab, Duotone; layers, groups, masks, vector masks, adjustment and fill layers, effects, text, shapes, smart objects (SoLd/lnk2 with their warp: the bend presets, a custom mesh and, since #4112, Cylinder; CS2 PlLd/lnkD read with their warp, kept as is or re-saved as SoLd once edited), smart filters (53 mapped, among them Invert, the Distort filters but Shear, Clouds, Difference Clouds, Fibers and Lens Flare, plus Hue/Saturation, Perspective Warp, Filter Gallery with Chrome and a Camera Raw subset, rest verbatim), slices, comps, Advanced Blending, each layer's XMP packet (`layer.xmpMetadata`); alpha and spot channels as Photoshop stores them (spot ink 0 = solid, channel identifiers in 1053), and PSDs with spot channels from PhotoCraft 0.1.0–0.16.0, which stored the ink the other way round, keep their ink. Oracle floors: Photoshop-authored 190/258, io 148/170, psd-tools 249/309; round trip ≥ 169/170, 308/309, 258/258. Known failures: #1281, #2469 (#2374, text layers, closed 2026-10-10; #2465, CS2 `PlLd`-only smart objects, fixed 2026-10-12) | | PSB | R/W | yes | yes | Auto-switches above 2 GB (REL-212-2); generated 20 MP, 150-layer, 2 GB PSD and 4.4 GB PSB fixtures opened, saved and re-opened by io tests/it/large_files.rs (FILE-216-2, 2026-10-13; the 4.4 GB PSB opens on Windows); > 4 GB from the app's File › Open on Windows: #375 | | Cloud PSD (.psdc) | R/W | no | no | Out of scope (Adobe cloud) | -| TIFF | R/W | yes | yes | 8/16/32f, Gray/RGB/CMYK, YCbCr read as RGB (JPEG-compressed with subsampled chroma, or uncompressed without subsampling; #10316), BigTIFF, every IFD, banded parallel decode; flat pages up to 300,000 px a side open a group of rows at a time into the layer, with no full-size copy, and are refused with an error when the machine lacks the memory (Linux: free RAM and swap, cgroup limits; #8169); layered TIFF both byte orders, including Photoshop mixed-order type blocks on read (#4758/#4771) and TySh text descriptors written in PSD order (#9098) and Photoshop's 14-byte `LMsk` block (#9095); LZW/ZIP for scripted saves by `tiffCompression`; undecodable type keeps saved pixels (FILE-215-7); alpha and spot channels as extra samples (at most 16) with Photoshop's names, ink colours and solidity, spot ink stored 0 = solid (#7415); no Save Transparency choice yet (channels are written as Photoshop does with it off) | +| TIFF | R/W | yes | yes | 8/16/32f, Gray/RGB/CMYK, YCbCr read as RGB (JPEG-compressed with subsampled chroma, or uncompressed without subsampling; #10316), BigTIFF, every IFD, banded parallel decode; flat pages up to 300,000 px a side open a group of rows at a time into the layer, with no full-size copy, and are refused with an error when the machine lacks the memory (Linux: free RAM and swap, cgroup limits; #8169); layered TIFF both byte orders, including Photoshop mixed-order type blocks on read (#4758/#4771) and TySh text descriptors written in PSD order (#9098) and Photoshop's 14-byte `LMsk` block (#9095); TIFF Options on Save As and Save a Copy (Image Compression None/LZW/ZIP, Save Image Pyramid, Save Transparency, Discard Layers and Save a Copy; scripts pass `tiffCompression`, `tiffTransparency`, `tiffPyramid`, `tiffLayers`; #8827), no JPEG compression, Pixel Order or Byte Order (the encoder writes interleaved samples in the machine's byte order); undecodable type keeps saved pixels (FILE-215-7); alpha and spot channels as extra samples (at most 16) with Photoshop's names, ink colours and solidity, spot ink stored 0 = solid (#7415); Save Transparency on keeps the transparency as the first extra sample ahead of the channels (a layered file marks it with `MTrn`, as Photoshop does), off mattes on white; unset, it is kept only without alpha or spot channels | | JPEG | R/W | yes | yes | 8-bit Gray/RGB/CMYK, ICC/EXIF/XMP; Save keeps the opened file's quality (estimated from its quantisation tables, #2399); File › Save, Save As and Save a Copy ask JPEG Options: Quality 0–12, starting at the opened file's quality or else the Export preference, and Baseline ("Standard") / Baseline Optimized / Progressive; scripts pass `quality`/`jpegFormat`, the CLI `--jpeg-format` (FILE-215-19). Baseline Optimized fits the Huffman tables only at 4:4:4 (Gray, CMYK) or progressive: RGB is written 4:2:0, which zune-jpeg 0.5.15 decodes wrongly with fitted tables, as in Save for Web. No Matte, progressive Scans (3–5) or size preview | | PNG | R/W | yes | yes | 8/16-bit, ICC/text; APNG first frame only; alpha channels on open (#2225); palette PNGs open in Indexed Color mode with their transparent entry (#4804); Save keeps a stereo PNG's `sTER` pairing (#6063) | | GIF | R/W | yes | yes | File › Open keeps the first frame; New Video Layer from File and Video Frames to Layers take every frame, with the timeline rate from the delays (#572). Per-frame delays and keeping the frames in `.pcraft` are not done | @@ -151,6 +151,7 @@ The original reference entry retains its source date and historical totals; curr | Date | Change | Summary | |---|---|---| +| 2026-11-02 | minor | TIFF Options on Save As and Save a Copy (#8827): Image Compression (None, LZW, ZIP), Save Image Pyramid (half-size levels marked reduced-resolution, down to 64 px, alpha-weighted; +45 ms on a 30 MP RGB/8 save), Save Transparency (on: transparency kept ahead of alpha and spot channels, `MTrn` in a layered file; off: matted on white) and Discard Layers and Save a Copy. Scripts, `doc.save`, MCP `save` and the CLI (`--tiff-transparency`, `--tiff-pyramid`) take the same answers. JPEG compression, Pixel Order and Byte Order not offered | | 2026-11-01 | minor | `.atn`: event reference [atn.md](atn.md): the 117 supported events, and measured keys, defaults, ranges and enum values for 101 events not mapped yet | | 2026-10-31 | minor | `.atn` Group B: four Render events load, play and save; 120 supported and 97 unmapped. Supported-table columns corrected against the event registry; the original 117/101 reference entry remains historical. | | 2026-10-31 | minor | `.atn`: Pinch (`Pnch`), Polar Coordinates (`Plr `), Spherize (`Sphr`), Twirl (`Twrl`), Wave (`Wave`) and ZigZag (`ZgZg`) map to `filter.distort.*` with every key in recorded order; out-of-range values clamped and missing keys given Photoshop's defaults on import; a Wave scaled below 100 % stays a Photoshop step (the command has no scale); Shear still kept verbatim (#10605) | diff --git a/docs/gaps.md b/docs/gaps.md index 29f9a461d2..0b7ee59a00 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (G11: four Render ATN events mapped, 120 supported/97 unmapped; G9: 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; selected colour sets edit drawing, tone and supported filters; geometry remains open; G11: `.atn` document offsets preserve opening order after tab reordering, #7103; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; Smooth and Feather command masks cropped to selection bounds plus blur reach; G1: Halftone Pattern Filter Gallery settings survive PSD/PSB saves (#8940); cropped selection morphology and command mask conversion performance evidence added; G6: Art History Brush landed as partial, TOOL-213-20, #1610, so no toolbar tool is missing; G4: five hard-round built-in presets use 5% spacing to remove stacked-tip outlines and reduce pressure-opacity bands, #8842; G9: Crop tool canvas menu, #6561; G2: Content-Aware Scale seam carving 1.65–3.6× faster with unchanged output, P28 row 3.3 → 1.6 s on a 4-core VM, #5236; G9: UI scale presets done, 105–120% steps, #3222; G14: lossy DNG, raw sidecars, Camera Raw Defaults live, Calibration and Auto, CR3 assessed as not clean-room feasible; G8: PSD type on a path opens on its path and saves back into the file's `Txt2`, #7962; G11: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; adjustment clipping stays visible in compact Properties, #5231; G9: Color panel modes, ramp and copy commands, the same in every theme, UI-217-17, #6000; G17: Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; G10: `.asl` style libraries landed, #1051; G8: area type (TYPE-218-10); G1: smart objects keep Photoshop's Cylinder warp, #4112; G8: paragraph direction buttons in the Paragraph panel and options bar, TYPE-218-2 partial; G1: 32-bit Hue/Saturation and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; G11: compound layer properties export as separate Photoshop Set events, rename last, #8304; G11: Batch and droplets take the result at the action's Save step, so actions ending in Save As and Close work; G11: `.atn` steps that switch to another open document import, play and save back, #7103; G14: the Nikon D4 white level follows the sample depth, so 12-bit D4 NEFs no longer open 2.6 stops dark, #5978; G14: a Nikon NEF's Adobe RGB preview is read as Adobe RGB (maker-note ColorSpace 0x001E), #5987; Apply Layer Comp action mapping, #7101; G10: JPEG Options dialog landed, FILE-215-19 partial (no Matte, progressive Scans or size preview); G10: Cineon write; G10: DPX read and write, #1049, Cineon and Amiga IFF read; Classical Object Finder improvement; learned segmentation remains open; G4: named, stateful section locks and visible keyboard focus in Brush Settings; G4: independent Brush Pose tilt-axis overrides, #3825 partial; G4: Transfer Mixer-only controls, #3825 partial; G9: Preferences › File Handling › Save in Background takes effect, #6207, 24 of 169 preferences do nothing; G7: Content-Aware Move previews every selection the command takes, coarse then exact, TOOL-213-4 done, #6680; G4: Pattern Stamp and Background Eraser draw live, TOOL-213-7; G9: Animated Zoom pref read: holding the Zoom tool zooms continuously, #6210, prefs that do nothing 25 of 166; G2: a cancelled 24 MP Gaussian Blur job stops in at most 44 ms, P25 under its 200 ms budget in local runs, baseline not re-run, #6205; G1: generated large-file fixtures, a 4.38 GB PSB opens on Windows through io, FILE-216-2 done, #5107; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed; G10: Amiga IFF write; G10: PSD spot channels in Photoshop's polarity, earlier PhotoCraft PSDs keep their ink, #7415 split; G10: TIFF alpha and spot channels written and read as extra samples, FILE-215-7 done, FILE-215-17's TIFF part, #7415) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (G10: TIFF Options dialog on Save As and Save a Copy, compression, Save Image Pyramid, Save Transparency, #8827; G11: four Render ATN events mapped, 120 supported/97 unmapped; G9: 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; selected colour sets edit drawing, tone and supported filters; geometry remains open; G11: `.atn` document offsets preserve opening order after tab reordering, #7103; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; Smooth and Feather command masks cropped to selection bounds plus blur reach; G1: Halftone Pattern Filter Gallery settings survive PSD/PSB saves (#8940); cropped selection morphology and command mask conversion performance evidence added; G6: Art History Brush landed as partial, TOOL-213-20, #1610, so no toolbar tool is missing; G4: five hard-round built-in presets use 5% spacing to remove stacked-tip outlines and reduce pressure-opacity bands, #8842; G9: Crop tool canvas menu, #6561; G2: Content-Aware Scale seam carving 1.65–3.6× faster with unchanged output, P28 row 3.3 → 1.6 s on a 4-core VM, #5236; G9: UI scale presets done, 105–120% steps, #3222; G14: lossy DNG, raw sidecars, Camera Raw Defaults live, Calibration and Auto, CR3 assessed as not clean-room feasible; G8: PSD type on a path opens on its path and saves back into the file's `Txt2`, #7962; G11: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; adjustment clipping stays visible in compact Properties, #5231; G9: Color panel modes, ramp and copy commands, the same in every theme, UI-217-17, #6000; G17: Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; G10: `.asl` style libraries landed, #1051; G8: area type (TYPE-218-10); G1: smart objects keep Photoshop's Cylinder warp, #4112; G8: paragraph direction buttons in the Paragraph panel and options bar, TYPE-218-2 partial; G1: 32-bit Hue/Saturation and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; G11: compound layer properties export as separate Photoshop Set events, rename last, #8304; G11: Batch and droplets take the result at the action's Save step, so actions ending in Save As and Close work; G11: `.atn` steps that switch to another open document import, play and save back, #7103; G14: the Nikon D4 white level follows the sample depth, so 12-bit D4 NEFs no longer open 2.6 stops dark, #5978; G14: a Nikon NEF's Adobe RGB preview is read as Adobe RGB (maker-note ColorSpace 0x001E), #5987; Apply Layer Comp action mapping, #7101; G10: JPEG Options dialog landed, FILE-215-19 partial (no Matte, progressive Scans or size preview); G10: Cineon write; G10: DPX read and write, #1049, Cineon and Amiga IFF read; Classical Object Finder improvement; learned segmentation remains open; G4: named, stateful section locks and visible keyboard focus in Brush Settings; G4: independent Brush Pose tilt-axis overrides, #3825 partial; G4: Transfer Mixer-only controls, #3825 partial; G9: Preferences › File Handling › Save in Background takes effect, #6207, 24 of 169 preferences do nothing; G7: Content-Aware Move previews every selection the command takes, coarse then exact, TOOL-213-4 done, #6680; G4: Pattern Stamp and Background Eraser draw live, TOOL-213-7; G9: Animated Zoom pref read: holding the Zoom tool zooms continuously, #6210, prefs that do nothing 25 of 166; G2: a cancelled 24 MP Gaussian Blur job stops in at most 44 ms, P25 under its 200 ms budget in local runs, baseline not re-run, #6205; G1: generated large-file fixtures, a 4.38 GB PSB opens on Windows through io, FILE-216-2 done, #5107; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed; G10: Amiga IFF write; G10: PSD spot channels in Photoshop's polarity, earlier PhotoCraft PSDs keep their ink, #7415 split; G10: TIFF alpha and spot channels written and read as extra samples, FILE-215-7 done, FILE-215-17's TIFF part, #7415) · **Target:** Adobe Photoshop 2026 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (G11: twelve Stylize/Pixelate ATN events mapped, 128 supported/89 unmapped; G9: 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; selected colour sets edit drawing, tone and supported filters; geometry remains open; G11: `.atn` document offsets preserve opening order after tab reordering, #7103; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; Smooth and Feather command masks cropped to selection bounds plus blur reach; G1: Halftone Pattern Filter Gallery settings survive PSD/PSB saves (#8940); cropped selection morphology and command mask conversion performance evidence added; G6: Art History Brush landed as partial, TOOL-213-20, #1610, so no toolbar tool is missing; G4: five hard-round built-in presets use 5% spacing to remove stacked-tip outlines and reduce pressure-opacity bands, #8842; G9: Crop tool canvas menu, #6561; G2: Content-Aware Scale seam carving 1.65–3.6× faster with unchanged output, P28 row 3.3 → 1.6 s on a 4-core VM, #5236; G9: UI scale presets done, 105–120% steps, #3222; G14: lossy DNG, raw sidecars, Camera Raw Defaults live, Calibration and Auto, CR3 assessed as not clean-room feasible; G8: PSD type on a path opens on its path and saves back into the file's `Txt2`, #7962; G11: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; adjustment clipping stays visible in compact Properties, #5231; G9: Color panel modes, ramp and copy commands, the same in every theme, UI-217-17, #6000; G17: Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; G10: `.asl` style libraries landed, #1051; G8: area type (TYPE-218-10); G1: smart objects keep Photoshop's Cylinder warp, #4112; G8: paragraph direction buttons in the Paragraph panel and options bar, TYPE-218-2 partial; G1: 32-bit Hue/Saturation and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; G11: compound layer properties export as separate Photoshop Set events, rename last, #8304; G11: Batch and droplets take the result at the action's Save step, so actions ending in Save As and Close work; G11: `.atn` steps that switch to another open document import, play and save back, #7103; G14: the Nikon D4 white level follows the sample depth, so 12-bit D4 NEFs no longer open 2.6 stops dark, #5978; G14: a Nikon NEF's Adobe RGB preview is read as Adobe RGB (maker-note ColorSpace 0x001E), #5987; Apply Layer Comp action mapping, #7101; G10: JPEG Options dialog landed, FILE-215-19 partial (no Matte, progressive Scans or size preview); G10: Cineon write; G10: DPX read and write, #1049, Cineon and Amiga IFF read; Classical Object Finder improvement; learned segmentation remains open; G4: named, stateful section locks and visible keyboard focus in Brush Settings; G4: independent Brush Pose tilt-axis overrides, #3825 partial; G4: Transfer Mixer-only controls, #3825 partial; G9: Preferences › File Handling › Save in Background takes effect, #6207, 24 of 169 preferences do nothing; G7: Content-Aware Move previews every selection the command takes, coarse then exact, TOOL-213-4 done, #6680; G4: Pattern Stamp and Background Eraser draw live, TOOL-213-7; G9: Animated Zoom pref read: holding the Zoom tool zooms continuously, #6210, prefs that do nothing 25 of 166; G2: a cancelled 24 MP Gaussian Blur job stops in at most 44 ms, P25 under its 200 ms budget in local runs, baseline not re-run, #6205; G1: generated large-file fixtures, a 4.38 GB PSB opens on Windows through io, FILE-216-2 done, #5107; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed; G10: Amiga IFF write; G10: PSD spot channels in Photoshop's polarity, earlier PhotoCraft PSDs keep their ink, #7415 split; G10: TIFF alpha and spot channels written and read as extra samples, FILE-215-7 done, FILE-215-17's TIFF part, #7415) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact @@ -365,7 +365,7 @@ what an agent can't finish alone. ### G10. Missing and partial file formats - **Progress (2026-10-26):** Embedded SVG/PDF-compatible AI/PDF/EPS smart objects can be edited in VectorCraft, including nested PSD payloads. AI/PDF/EPS working copies retain their format with a separate source-bound SVG display preview; native bytes and previews persist through .pcraft and PSD/PSB saves. This does not add standalone EPS/DCS import or establish lossless Adobe round-trip compatibility. See [workflow and limits](embedded-vector-editing.md); estimates and parity percentages are unchanged. -- **Missing:** JPEG Options' Matte, progressive Scans and size preview (FILE-215-19 partial), Photoshop PDF read and its vector/text/multi-page +- **Missing:** JPEG Options' Matte, progressive Scans and size preview (FILE-215-19 partial), TIFF Options' JPEG compression, Pixel Order and Byte Order (the rest landed 2026-11-02, #8827), Photoshop PDF read and its vector/text/multi-page write (FILE-215-10, #2541; a single flattened page now saves via Save As / Save a Copy / CLI, 2026-10-11, #3401), AVIF read (#463) and default-build AVIF write, JPEG XL, JPEG 2000 (#1047), DICOM, EPS/DCS, Pixar, WBMP, MPO/JPS, PICT, Scitex CT, Photoshop @@ -456,6 +456,7 @@ what an agent can't finish alone. | 2026-10-21 | minor | G11: Batch and droplets take the result at the action's recorded Save / Save As step (Photoshop's "Override Action 'Save As' Commands"); estimate unchanged | | 2026-10-21 | minor | G9: right-click on a pending crop opens the Crop tool menu (presets, Clear, overlays, Cancel, Crop; #6561); Reset still open | | 2026-10-22 | minor | G8: Auto / Left-to-right / Right-to-left paragraph direction buttons in the Paragraph panel, Properties and the Type options bar (#6793); TYPE-218-2 partial (the visual RTL caret is still missing, and PSD doesn't store the direction yet) | +| 2026-11-02 | minor | G10: TIFF Options dialog on Save As and Save a Copy: Image Compression, Save Image Pyramid, Save Transparency, Discard Layers (#8827); JPEG compression, Pixel Order and Byte Order not offered; no readiness percentage or estimate changed | | 2026-10-19 | minor | G10: JPEG Options dialog landed (FILE-215-19 partial: no Matte, progressive Scans or size preview); no readiness percentage or estimate changed | | 2026-10-19 | minor | G11: `.atn` steps that select another open document by offset import as `document.activate {"offset"}`, play and save back (#7103); estimate unchanged | | 2026-10-19 | minor | 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 | diff --git a/docs/scorecard.md b/docs/scorecard.md index db283ba4be..884db18142 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -129,7 +129,7 @@ Corpus floors (`crates/io/tests/it/corpus.rs`): the enforced minimums; the corpu | FILE-215-4 | Adjustments (Hue/Sat + Colorize, Curves, Levels, Shadows/Highlights) apply as Smart Filters | done | [#215](https://github.com/storytold/photocraft/issues/215) | Image › Adjustments on a smart object records image.adjustments. as a smart filter (adjust_cmds.rs adjust_as_smart_filter; the selection becomes the filter mask), re-rendered from the source by adjust_as_filter and re-editable with layer.smartFilter.setParams; PSD maps Levels/Curves/Shadows-Highlights both ways (smart_map.rs). Opening the editor from the smart-filter row is FILE-215-5's row work | | FILE-215-5 | Smart Filter rows: collapse, mask thumb, blend icon, drag reorder, context menu, live preview | partial | [#215](https://github.com/storytold/photocraft/issues/215) | smart_ui.rs:31 rows + visibility + editor; no collapse, drag or context menu | | FILE-215-6 | Smart Object corner skew and perspective (quad or homography transform) | done | [#215](https://github.com/storytold/photocraft/issues/215) | #426: SmartObject.perspective holds the projective map (transform keeps its affine approximation); Distort / Perspective on a smart object set it (transform_cmds.rs, smart_cmds::set_placement), it re-renders from the source through the homography, Warp composes with it, and the PSD placement reads and writes nonAffineTransform / Trnf (io/blocks.rs placed_projective, smart_map.rs); tests distort_survives_edit_contents, warp_on_a_distorted_smart_object_follows_the_corners, placement survives the save | -| FILE-215-7 | Layered TIFF read and write (tag 37724), honouring the ask-before-saving pref | done | [#215](https://github.com/storytold/photocraft/issues/215) | psd tiff.rs (ImageSourceData, both byte orders), io tiff_layers.rs, ui tiff_options_ui.rs (Discard Layers and Save a Copy); extra alpha/spot channels and a Quick Mask are written as TIFF extra samples (at most 16) plus Photoshop resources for name, colour and solidity; Photoshop's MTrn marker is read (#7415). The TIFF Options Save Transparency choice isn't offered: channels are written as Photoshop does with it off | +| FILE-215-7 | Layered TIFF read and write (tag 37724), honouring the ask-before-saving pref | done | [#215](https://github.com/storytold/photocraft/issues/215) | psd tiff.rs (ImageSourceData, both byte orders), io tiff_layers.rs, ui tiff_options_ui.rs (Discard Layers and Save a Copy); extra alpha/spot channels and a Quick Mask are written as TIFF extra samples (at most 16) plus Photoshop resources for name, colour and solidity; Photoshop's MTrn marker is read (#7415). TIFF Options (ui tiff_options_ui.rs) asks on every TIFF Save As and Save a Copy: compression, Save Image Pyramid, Save Transparency (on writes MTrn next to channels, as Photoshop does) and the layers choice (#8827) | | FILE-215-8 | Large and BigTIFF import with banded decode across all IFDs | done | [#215](https://github.com/storytold/photocraft/issues/215) | codecs tiff_ifd.rs (bounds-checked IFD chain + SubIFD walk, classic and BigTIFF, cycle-safe; tiff_info / decode_tiff_page), tiff.rs banded parallel strip/tile decode, planar 1 and 2, BigTIFF write (tiff_bigtiff or > 4 GiB); io import_tiff_page; 30 MP RGB8 Deflate 345 -> 32 ms; flat pages up to 300000 px a side decode a group of rows at a time into the layer (codecs TiffBands, #8169): the 117504 x 22464 px banner from #8456 opens in 3.3 s at 11.45 GB peak; refused with an error past free memory (io memory.rs); layered TIFFs still decode whole; no page picker in the UI yet | | FILE-215-9 | SVG opens and places as shape layers or a vector Smart Object | done | [#215](https://github.com/storytold/photocraft/issues/215) | io svg.rs: paths, groups, text outlines as shape layers (gradients approximated; clip/mask/filter/pattern/image subtrees rasterised); smart_cmds.rs renders an SVG source at its placement scale | | FILE-215-10 | Multi-page PDF save, PDF presentation, layer comps to PDF, vector/text output, PDF import | partial | [#215](https://github.com/storytold/photocraft/issues/215) | io pdf.rs: Save As / Save a Copy / CLI write one flattened RGB/Gray/CMYK page with ICC (#3401); print PDFs in print_cmds. Multi-page save, presentation, comps, vector/text output and PDF import (#2541) remain | @@ -308,7 +308,7 @@ Never-crash attribute on 30 of 30 crate roots. | BUG-203-6 | Failure-injection tests: mid-write, rename and read-only dir errors keep original bytes | done | [#203](https://github.com/storytold/photocraft/issues/203) | format/src/atomic/tests.rs: Faulty AtomicFs injects mid-write StorageFull, rename failure and a read-only dir; locked-target retries, symlink and file-mode preservation covered too | | BUG-204-1 | Interface prefs (UI scale, font size, channels in colour, sliders, tooltips) wired or hidden | done | [#204](https://github.com/storytold/photocraft/issues/204) | prefs_ui.rs tick reads ui_scale (sync_display_scale), ui_font_size (theme::set_ui_font_size) and show_tooltips (sync_tooltips); show_channels_in_color is wired in channel_thumbs and the canvas channel view (#2385); dynamic_color_sliders is in prefs::HIDDEN_UNTIL_IMPLEMENTED | | BUG-204-2 | workspace.large_tabs, animated_zoom, auto_show_home_screen wired or hidden | done | [#204](https://github.com/storytold/photocraft/issues/204) | workspace.large_tabs controls document tab height in canvas.rs; auto_show_home_screen drives the Home screen; general.animated_zoom gates discrete canvas zoom transitions and continuous held Zoom tool presses (zoom_hold.rs, #6210), and is exposed in General preferences | -| BUG-204-3 | RAW prefs and ask_before_saving_layered_tiff read, or hidden | done | [#204](https://github.com/storytold/photocraft/issues/204) | ask_before_saving_layered_tiff drives the TIFF Options prompt (ui-egui tiff_options_ui.rs); raw_defaults.open_in_camera_raw drives the Camera Raw dialog (camera_raw_ui.rs); the other raw_defaults fields drive the raw open and Camera Raw (#5128) | +| BUG-204-3 | RAW prefs and ask_before_saving_layered_tiff read, or hidden | done | [#204](https://github.com/storytold/photocraft/issues/204) | ask_before_saving_layered_tiff drives the layered-file note and its Don't show again in TIFF Options (ui-egui tiff_options_ui.rs); raw_defaults.open_in_camera_raw drives the Camera Raw dialog (camera_raw_ui.rs); the other raw_defaults fields drive the raw open and Camera Raw (#5128) | | BUG-204-4 | Recent files persisted through prefs across restarts | done | [#204](https://github.com/storytold/photocraft/issues/204) | crates/ui-egui/src/lib.rs::push_recent stores the list in preferences; prefs_ui::load restores it and recent_files_survive_a_restart_and_honour_the_count verifies reload | | BUG-204-5 | Recent-files list honours recent_file_count (default 20) | done | [#204](https://github.com/storytold/photocraft/issues/204) | lib.rs push_recent caps the list with recent_cap (recent_file_count); menus.rs covers default and custom limits | | BUG-204-6 | Test: every Prefs field is read somewhere or on a hidden allowlist | done | [#204](https://github.com/storytold/photocraft/issues/204) | crates/engine/tests/it/prefs_usage.rs every_preference_is_read_or_hidden: each field is read outside prefs.rs or listed in prefs::HIDDEN_UNTIL_IMPLEMENTED, never both | diff --git a/scorecard/bugs.toml b/scorecard/bugs.toml index f997437ae6..e9bd060994 100644 --- a/scorecard/bugs.toml +++ b/scorecard/bugs.toml @@ -67,7 +67,7 @@ id = "BUG-204-3" target = "RAW prefs and ask_before_saving_layered_tiff read, or hidden" status = "done" issue = 204 -note = "ask_before_saving_layered_tiff drives the TIFF Options prompt (ui-egui tiff_options_ui.rs); raw_defaults.open_in_camera_raw drives the Camera Raw dialog (camera_raw_ui.rs); the other raw_defaults fields drive the raw open and Camera Raw (#5128)" +note = "ask_before_saving_layered_tiff drives the layered-file note and its Don't show again in TIFF Options (ui-egui tiff_options_ui.rs); raw_defaults.open_in_camera_raw drives the Camera Raw dialog (camera_raw_ui.rs); the other raw_defaults fields drive the raw open and Camera Raw (#5128)" [[item]] id = "BUG-204-4" diff --git a/scorecard/file_compat.toml b/scorecard/file_compat.toml index 49f68d28b0..3a762050a7 100644 --- a/scorecard/file_compat.toml +++ b/scorecard/file_compat.toml @@ -52,7 +52,7 @@ id = "FILE-215-7" target = "Layered TIFF read and write (tag 37724), honouring the ask-before-saving pref" status = "done" issue = 215 -note = "psd tiff.rs (ImageSourceData, both byte orders), io tiff_layers.rs, ui tiff_options_ui.rs (Discard Layers and Save a Copy); extra alpha/spot channels and a Quick Mask are written as TIFF extra samples (at most 16) plus Photoshop resources for name, colour and solidity; Photoshop's MTrn marker is read (#7415). The TIFF Options Save Transparency choice isn't offered: channels are written as Photoshop does with it off" +note = "psd tiff.rs (ImageSourceData, both byte orders), io tiff_layers.rs, ui tiff_options_ui.rs (Discard Layers and Save a Copy); extra alpha/spot channels and a Quick Mask are written as TIFF extra samples (at most 16) plus Photoshop resources for name, colour and solidity; Photoshop's MTrn marker is read (#7415). TIFF Options (ui tiff_options_ui.rs) asks on every TIFF Save As and Save a Copy: compression, Save Image Pyramid, Save Transparency (on writes MTrn next to channels, as Photoshop does) and the layers choice (#8827)" [[item]] id = "FILE-215-8" From 292ce6ab55a3c18636fe599cfbddc20ebc32cea9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= <55043719+hnoerenberg@users.noreply.github.com> Date: Tue, 3 Nov 2026 16:12:16 +0100 Subject: [PATCH 216/216] fix(ui): Minimum and Maximum preview at the scale they apply (#10474) (#10524) * fix(ui): Minimum and Maximum preview at the scale they apply (#10474) Filter > Other > Minimum... and Maximum... were missing from FULL_RESOLUTION_PREVIEW, so on any document large enough to be proxied their live preview was not what OK writes. preview_factor keeps the largest proxy factor at which no pixel-sized parameter falls below its command's own minimum, and for these two commands that minimum is 0.2 (engine/src/filters/validation.rs:90). A typed radius of 1.5 px therefore allows k = 4, and scale_preview_params divides it to 0.375 px. Both kernels then measure that in whole pixels and return the crop untouched when it reads 0: Squareness is `let r = radius.max(0.0).round() as i32;` (algo/src/other.rs:83) and Roundness is `let r = radius.max(0.0); let ri = r.floor() as i32;` (other.rs:23-24). So the dialog showed the untouched document while OK applied a real 1.5 px (Squareness) or 1 px (Roundness) effect - the same failure mode Dust & Scratches already has in that list for (#3265). The repo already asserts this rule for the filters that are listed, in fixed_size_filters_preview_at_the_scale_they_apply; Minimum and Maximum simply were not in it. Fail-first: four rows (1.5 px in both Preserve modes, both filters) added to that test failed with the production change stashed - `assert_eq!(k, 1, "filter.other.minimum")` reported left: 4, right: 1 - and pass with it. filter_dialog group 177 pass / 0 fail; full cargo test -p photocraft-ui-egui --lib 4440 pass / 22 fail, a strict subset of the 24 failures a clean 422ca7be checkout gives in this environment (the i18n, font-picker, type-tool and layer-row tests that need a CRAFT_FONTS_DIR checkout this machine has no access to); nothing new failed. clippy --all-targets -D warnings clean, fmt --check clean, cargo xtask layers OK (31 crates), cargo xtask wasm ok. Not touched, on purpose: the kernel math, the 0.2..500 radius range, and the fractional-radius question in #10474. Min/max over an integer lattice cannot represent a fractional radius at all, and the two Preserve options already snap at different thresholds (0.5 px for Squareness, 1.0 px for Roundness). Picking graded output or one shared rule needs reference captures, and nothing in docs/ records the reference behaviour today; guessing would put the guess in the tests as the oracle. The question stays open on the issue. No doc row either: #2063 and #3265 appear only in code comments, not in any docs table. Co-Authored-By: Claude Opus 5.5 * fix(ui): Minimum and Maximum keep one proxy pixel of radius, not a full-size preview (#10474) Previewing every radius at full size fixed the identity preview but made a large radius (up to 500 px, Roundness is O(radius) per pixel) cost seconds to minutes per tick on an ordinary photo. The bug only appears when radius / k drops under one kernel pixel, so cap k at floor(radius): 1.5 px still previews at k = 1, while 50 px keeps the 1/4 proxy. Co-Authored-By: Claude Opus 5.5 * fix(ui): Squareness previews its radius within 1 px of what OK applies (#10474) Capping k at floor(radius) kept a proxy pixel of radius, but Squareness rounds the proxy radius: 6 px at k = 4 is round(1.5) = 2 proxy px, an 8 px preview for a 6 px result. Take the largest k whose rounded proxy radius, scaled back, is within 1 px of round(radius) (k = 3 for 6 px and 50 px); Roundness reads its disc from the unrounded radius and keeps the floor(radius) cap. The radius is clamped to the command's 0.2-500 range first, as the engine does. Co-Authored-By: Claude Opus 5.5 * fix(ui): the canvas keeps the last preview frame when a drag changes the proxy factor (#10474) Minimum and Maximum's factor now follows the radius, so a radius drag crossing a whole pixel changed FilterPreviewKey::k, and the display gate (same_target, which includes k) dropped the held frame: the canvas showed the unfiltered document until a frame for the new factor landed. The frame is drawn at its own k (textures read preview.key.k), so the gate now forgives the factor alone (same_target_at_any_scale); publishing and freshness keep the strict checks. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com> --- crates/ui-egui/src/canvas/filter_preview.rs | 5 ++-- crates/ui-egui/src/dialogs/filter_dialog.rs | 25 ++++++++++++++++++- .../src/dialogs/filter_dialog/tests.rs | 22 ++++++++++++++++ 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/canvas/filter_preview.rs b/crates/ui-egui/src/canvas/filter_preview.rs index a7d3f8dc94..0144390b46 100644 --- a/crates/ui-egui/src/canvas/filter_preview.rs +++ b/crates/ui-egui/src/canvas/filter_preview.rs @@ -135,8 +135,9 @@ pub(crate) fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize, ctx: &e ctx.request_repaint_after(std::time::Duration::from_millis(16)); } let preview = app.filter_preview.as_ref()?; - // Keep the last preview during a drag, but never one from another source or dialog. - if !preview.key.same_target(&request) { + // Keep the last preview during a drag, but never one from another source or dialog. It is + // drawn at its own factor, so a drag that changes the factor keeps it too (#10474). + if !preview.key.same_target_at_any_scale(&request) { return None; } let result = preview.result.as_ref()?; diff --git a/crates/ui-egui/src/dialogs/filter_dialog.rs b/crates/ui-egui/src/dialogs/filter_dialog.rs index 0f87c26faf..08ce0f6ab1 100644 --- a/crates/ui-egui/src/dialogs/filter_dialog.rs +++ b/crates/ui-egui/src/dialogs/filter_dialog.rs @@ -276,6 +276,22 @@ pub fn preview_factor(command: &str, params: &Value, k: u32) -> u32 { }, _ => k, }); + // Minimum and Maximum measure the radius in whole pixels (Squareness rounds it, Roundness + // floors it, and 0 is the identity), so at k = 4 a typed 1.5 px became 0.375 = 0 proxy px and + // the preview showed the untouched document while OK applied a real effect (#10474). Keep at + // least one proxy pixel of radius, and for Squareness the largest k whose rounded proxy radius, + // scaled back, is within 1 px of the one OK applies (6 px at k = 4 rounded to 2 proxy px, 8 px). + // A large radius still previews on a proxy: at full size Roundness costs O(radius) per pixel. + if matches!(command, "filter.other.minimum" | "filter.other.maximum") { + let Some(r) = params.get("radius").and_then(Value::as_f64).filter(|r| r.is_finite()) else { return k }; + let r = r.clamp(0.2, 500.0); + let square = params.get("preserve").and_then(Value::as_str) != Some("roundness"); + let fits = |d: u32| { + let d = f64::from(d); + d <= r.floor() && (!square || ((r / d).round() * d - r.round()).abs() <= 1.0) + }; + return (1..=k).rev().find(|&d| fits(d)).unwrap_or(1); + } // Mosaic rounds its cells to whole pixels: 10 px cells at k = 4 became 3 proxy px = 12 px. // Divide the cell size evenly instead. match params.get("cellSize").and_then(Value::as_f64) { @@ -1353,13 +1369,20 @@ impl FilterPreviewKey { /// parameters moved on. A reopened dialog has a new id, a different command or a different /// document, and a committed document a new revision, so neither survives this test. pub fn same_target(&self, other: &Self) -> bool { + self.k == other.k && self.same_target_at_any_scale(other) + } + + /// [`Self::same_target`] at any proxy factor: whether the canvas may keep showing this frame, + /// drawn at its own `k`, while the factor moves under a drag. A parameter can set the factor + /// (Minimum and Maximum's radius, Mosaic's cell size, #10474), and the frame for the old one is + /// still the closest thing to the result until the new one lands. + pub fn same_target_at_any_scale(&self, other: &Self) -> bool { self.doc == other.doc && self.revision == other.revision && self.dialog == other.dialog && self.active == other.active && self.target == other.target && self.command == other.command - && self.k == other.k } } diff --git a/crates/ui-egui/src/dialogs/filter_dialog/tests.rs b/crates/ui-egui/src/dialogs/filter_dialog/tests.rs index 10f5a073a1..c5013adc25 100644 --- a/crates/ui-egui/src/dialogs/filter_dialog/tests.rs +++ b/crates/ui-egui/src/dialogs/filter_dialog/tests.rs @@ -133,6 +133,8 @@ fn same_target_ignores_parameters_but_nothing_else() { break_it(&mut other); assert!(!other.same_target(&base), "{other:?} must not pass as {base:?}"); assert!(!base.same_target(&other)); + // The canvas's display gate forgives the factor alone (#10474), nothing else. + assert_eq!(other.same_target_at_any_scale(&base), other.k != base.k, "{other:?}"); } } @@ -966,6 +968,13 @@ fn fixed_size_filters_preview_at_the_scale_they_apply() { ("filter.noise.dustAndScratches", json!({"radius": 4.0, "threshold": 0.0})), ("filter.noise.reduceNoise", json!({})), ("filter.other.custom", json!({"kernel": edge})), + // #10474: Minimum and Maximum work on whole-pixel radii (`round()` / `floor()`), so at + // k = 4 a typed 1.5 px radius previewed as 0.375 px = 0 px: the proxy showed the untouched + // document while OK writes a real 1.5 / 1 px effect. A radius under 2 px previews at k = 1. + ("filter.other.minimum", json!({"radius": 1.5, "preserve": "squareness"})), + ("filter.other.minimum", json!({"radius": 1.5, "preserve": "roundness"})), + ("filter.other.maximum", json!({"radius": 1.5, "preserve": "squareness"})), + ("filter.other.maximum", json!({"radius": 1.5, "preserve": "roundness"})), ("filter.video.deInterlace", json!({})), ("filter.stylize.traceContour", json!({})), ("filter.stylize.wind", json!({"method": "blast"})), @@ -1038,6 +1047,19 @@ fn proxy_previews_scale_only_lengths() { assert_eq!(preview_factor("filter.pixelate.mosaic", &json!({"cellSize": "x"}), 4), 4); let (k, diff) = error("filter.pixelate.mosaic", json!({"cellSize": 10.0})); assert!(k == 2 && diff < 0.01, "Mosaic: {diff}"); + // Minimum and Maximum keep at least one proxy pixel of radius (#10474), and a large radius + // keeps a proxy instead of a full-size preview that costs minutes at radius 500. Squareness + // also keeps its rounded radius within 1 px of OK's: 6 px is 2 proxy px at k = 3 (6 px), not + // at k = 4 (8 px); 50 px is 17 at k = 3 (51 px), not 13 at k = 4 (52 px). + let factors = |cmd: &str, preserve: &str| { + [0.2, 1.5, 2.5, 3.9, 6.0, 50.0, 500.0, 9999.0].map(|radius| preview_factor(cmd, &json!({"radius": radius, "preserve": preserve}), 4)) + }; + for cmd in ["filter.other.minimum", "filter.other.maximum"] { + assert_eq!(factors(cmd, "squareness"), [1, 1, 2, 3, 3, 3, 4, 4], "{cmd} squareness"); + assert_eq!(factors(cmd, "roundness"), [1, 1, 2, 3, 4, 4, 4, 4], "{cmd} roundness"); + assert_eq!(preview_factor(cmd, &json!({"radius": 6.0}), 4), 3, "{cmd}: Squareness is the default"); + assert_eq!(preview_factor(cmd, &json!({"radius": "x"}), 4), 4, "{cmd}: a junk radius keeps the proxy"); + } // Tree's and Picture Frame's sizes are relative, not pixels (and not shown as "px"). assert!(!is_pixel_param_of("filter.render.tree", "size") && !is_pixel_param_of("filter.render.pictureFrame", "size")); assert!(is_pixel_param_of("filter.stylize.extrude", "size"));