diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 94f30b1be3..700bccb19f 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -145,7 +145,7 @@ Files copied into `corpus/` by hand (tiff, exr, raw) must be MIT, BSD or CC0. | Path | Title | Author | Source | License | |---|---|---|---|---| -| `crates/psd/tests/fixtures/atn/*.atn` | Independently encoded ATN version-16 parser fixtures | PhotoCraft contributors | Original synthetic test data; wire layout documented in `crates/psd/src/atn.rs`; provenance in the adjacent README | MIT OR Apache-2.0 | +| `crates/psd/tests/fixtures/atn/*.atn` | Independently encoded ATN version-16 and version-12 parser fixtures | PhotoCraft contributors (version-12 files: @yoriswind) | Original synthetic test data; wire layout documented in `crates/psd/src/atn.rs`; provenance in the adjacent README | MIT OR Apache-2.0 | ## Embedded-vector editor source diff --git a/Cargo.lock b/Cargo.lock index b22b872d1d..ab0417b79b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -650,7 +650,7 @@ dependencies = [ "bitflags 2.13.2", "cexpr", "clang-sys", - "itertools 0.10.5", + "itertools 0.13.0", "proc-macro2", "quote", "regex", @@ -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" @@ -3345,15 +3355,6 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" -[[package]] -name = "itertools" -version = "0.10.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" -dependencies = [ - "either", -] - [[package]] name = "itertools" version = "0.13.0" @@ -3889,6 +3890,12 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" +[[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" @@ -4141,6 +4148,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 +4569,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" @@ -4963,7 +4989,7 @@ dependencies = [ [[package]] name = "photocraft" -version = "0.19.0" +version = "0.20.1" dependencies = [ "arboard", "async-io", @@ -5018,7 +5044,7 @@ dependencies = [ [[package]] name = "photocraft-affinity" -version = "0.19.0" +version = "0.20.1" dependencies = [ "crc32fast", "flate2", @@ -5028,7 +5054,7 @@ dependencies = [ [[package]] name = "photocraft-algo" -version = "0.19.0" +version = "0.20.1" dependencies = [ "criterion", "photocraft-color", @@ -5043,7 +5069,7 @@ dependencies = [ [[package]] name = "photocraft-automation" -version = "0.19.0" +version = "0.20.1" dependencies = [ "base64 0.23.1", "cap-std", @@ -5054,7 +5080,9 @@ dependencies = [ "photocraft-doc", "photocraft-engine", "photocraft-format", + "photocraft-geom", "photocraft-io", + "photocraft-psd", "photocraft-raster", "photocraft-raw", "rmcp", @@ -5068,7 +5096,7 @@ dependencies = [ [[package]] name = "photocraft-cli" -version = "0.19.0" +version = "0.20.1" dependencies = [ "clap", "clap_complete", @@ -5087,7 +5115,7 @@ dependencies = [ [[package]] name = "photocraft-cms" -version = "0.19.0" +version = "0.20.1" dependencies = [ "moxcms", "rayon", @@ -5095,7 +5123,7 @@ dependencies = [ [[package]] name = "photocraft-codecs" -version = "0.19.0" +version = "0.20.1" dependencies = [ "craft-jpeg", "exr", @@ -5119,7 +5147,7 @@ dependencies = [ [[package]] name = "photocraft-color" -version = "0.19.0" +version = "0.20.1" dependencies = [ "photocraft-cms", "serde", @@ -5127,7 +5155,7 @@ dependencies = [ [[package]] name = "photocraft-compose" -version = "0.19.0" +version = "0.20.1" dependencies = [ "criterion", "photocraft-cms", @@ -5143,7 +5171,7 @@ dependencies = [ [[package]] name = "photocraft-doc" -version = "0.19.0" +version = "0.20.1" dependencies = [ "photocraft-color", "photocraft-geom", @@ -5154,7 +5182,7 @@ dependencies = [ [[package]] name = "photocraft-engine" -version = "0.19.0" +version = "0.20.1" dependencies = [ "base64 0.22.1", "blake3", @@ -5196,9 +5224,10 @@ dependencies = [ [[package]] name = "photocraft-format" -version = "0.19.0" +version = "0.20.1" dependencies = [ "blake3", + "craft-pcraft-format", "crc32fast", "exacl", "flate2", @@ -5219,14 +5248,14 @@ dependencies = [ [[package]] name = "photocraft-geom" -version = "0.19.0" +version = "0.20.1" dependencies = [ "serde", ] [[package]] name = "photocraft-gpu" -version = "0.19.0" +version = "0.20.1" dependencies = [ "bytemuck", "half", @@ -5246,14 +5275,14 @@ dependencies = [ [[package]] name = "photocraft-heif" -version = "0.19.0" +version = "0.20.1" dependencies = [ "heic-rs", ] [[package]] name = "photocraft-io" -version = "0.19.0" +version = "0.20.1" dependencies = [ "blake3", "craft-docpath", @@ -5289,18 +5318,18 @@ dependencies = [ [[package]] name = "photocraft-ipp" -version = "0.19.0" +version = "0.20.1" [[package]] name = "photocraft-ops" -version = "0.19.0" +version = "0.20.1" dependencies = [ "photocraft-doc", ] [[package]] name = "photocraft-paint" -version = "0.19.0" +version = "0.20.1" dependencies = [ "photocraft-cms", "photocraft-color", @@ -5314,7 +5343,7 @@ dependencies = [ [[package]] name = "photocraft-plugins" -version = "0.19.0" +version = "0.20.1" dependencies = [ "photocraft-color", "photocraft-geom", @@ -5330,7 +5359,7 @@ dependencies = [ [[package]] name = "photocraft-psd" -version = "0.19.0" +version = "0.20.1" dependencies = [ "encoding_rs", "flate2", @@ -5342,18 +5371,22 @@ dependencies = [ [[package]] name = "photocraft-raster" -version = "0.19.0" +version = "0.20.1" dependencies = [ + "crc32fast", + "lz4_flex", "photocraft-color", "photocraft-geom", "proptest", "rayon", "serde", + "sysinfo", + "winsafe", ] [[package]] name = "photocraft-raw" -version = "0.19.0" +version = "0.20.1" dependencies = [ "flate2", "image", @@ -5368,7 +5401,7 @@ dependencies = [ [[package]] name = "photocraft-tablet" -version = "0.19.0" +version = "0.20.1" dependencies = [ "block2 0.6.2", "objc2 0.6.5", @@ -5380,7 +5413,7 @@ dependencies = [ [[package]] name = "photocraft-testkit" -version = "0.19.0" +version = "0.20.1" dependencies = [ "memory-stats", "serde_json", @@ -5388,7 +5421,7 @@ dependencies = [ [[package]] name = "photocraft-text" -version = "0.19.0" +version = "0.20.1" dependencies = [ "flate2", "parley", @@ -5403,7 +5436,7 @@ dependencies = [ [[package]] name = "photocraft-ui-egui" -version = "0.19.0" +version = "0.20.1" dependencies = [ "base64 0.23.1", "craft-print", @@ -5448,7 +5481,7 @@ dependencies = [ [[package]] name = "photocraft-vector" -version = "0.19.0" +version = "0.20.1" dependencies = [ "photocraft-color", "photocraft-doc", @@ -5459,7 +5492,7 @@ dependencies = [ [[package]] name = "photocraft-web" -version = "0.19.0" +version = "0.20.1" dependencies = [ "eframe", "egui", @@ -5481,7 +5514,7 @@ dependencies = [ [[package]] name = "photocraft-web-boot" -version = "0.19.0" +version = "0.20.1" dependencies = [ "flate2", "js-sys", @@ -6651,6 +6684,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" @@ -7337,6 +7381,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" @@ -8920,6 +8978,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", @@ -9197,7 +9256,7 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" [[package]] name = "xtask" -version = "0.19.0" +version = "0.20.1" dependencies = [ "chrono", "serde", diff --git a/Cargo.toml b/Cargo.toml index ef1f6d5fdb..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.19.0" +version = "0.20.1" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" @@ -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/NOTICE b/NOTICE index 6250865673..9ab6b0a1cf 100644 --- a/NOTICE +++ b/NOTICE @@ -23,6 +23,37 @@ https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (today BIZ UDP 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. + The egui-icons font subset is embedded from epaint_default_fonts by crates/ui-egui/build.rs and registered in the native and web fallback stacks by crates/ui-egui/src/egui_fonts.rs. It is a subset of emoji-icon-font by John Slegers; the same upstream MIT license applies. diff --git a/ROADMAP.md b/ROADMAP.md index 357be3920a..2aace61b67 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 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 (`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 | 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/src/args.rs b/apps/photocraft-cli/src/args.rs index 91b99ef33b..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)] @@ -74,6 +82,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, + /// 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, } /// Convert between formats (.pcraft, .psd, .png, .jpg, .tif, .webp, .exr, …) @@ -91,6 +103,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 +117,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 +148,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 +190,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, } @@ -252,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, @@ -306,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/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..4c6678bfb1 100644 --- a/apps/photocraft-cli/src/document.rs +++ b/apps/photocraft-cli/src/document.rs @@ -30,7 +30,8 @@ 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 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); warn_all(err, &o.warnings); @@ -43,7 +44,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 +87,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/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/src/output.rs b/apps/photocraft-cli/src/output.rs index 34101c9ec0..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, ..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 e592d552e2..5c5ffc7870 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_full_composite(&std::fs::read(path).unwrap()).unwrap().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. @@ -1090,6 +1110,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, @@ -1496,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-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/Cargo.toml b/apps/photocraft/Cargo.toml index e19edc6b6a..b064f00376 100644 --- a/apps/photocraft/Cargo.toml +++ b/apps/photocraft/Cargo.toml @@ -100,7 +100,7 @@ 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"] } +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/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); +} diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs index e19a9d45e8..75eb0ea83a 100644 --- a/apps/photocraft/src/main.rs +++ b/apps/photocraft/src/main.rs @@ -80,9 +80,9 @@ use photocraft_ui_egui::PhotocraftApp; use photocraft_ui_egui::os_window::APP_ID; -/// 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 › @@ -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"))] @@ -943,9 +946,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; + 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/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index 675996c5d0..d9ddcf64d8 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -361,9 +361,20 @@ 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(_) => { + // Only the progress bar's scale: a model whose length can't be worked out fails the + // write the same way, through the atomic write's "not changed" error. + let len = encoded.encoded_len().unwrap_or(0); + photocraft_format::atomic_write_stream_progress(path, len, progress, &mut |w| encoded.write_to(w)) + } + } + .map_err(|e| e.to_string()) } /// Whether `a` and `b` reach the same file: the same device and inode on Unix, the same final path @@ -413,26 +424,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) } @@ -443,7 +454,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>>; @@ -497,6 +508,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 +632,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. @@ -657,7 +685,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; @@ -748,6 +776,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..fb2188cdab 100644 --- a/apps/photocraft/src/services/tests.rs +++ b/apps/photocraft/src/services/tests.rs @@ -4,6 +4,41 @@ use photocraft_format::list_recovery; 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", photocraft_geom::Size::new(64, 48), photocraft_doc::ColorMode::Rgb, photocraft_color::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(photocraft_doc::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); +} + /// #6601: a chosen path whose name isn't valid Unicode is reported, never handed on lossily /// (Open would fail with "No such file", Save would write a second, renamed file). #[test] @@ -709,6 +744,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() @@ -886,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/book/src/formats/pcraft.md b/book/src/formats/pcraft.md index 40ecbdd127..8a0e40a5a2 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^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. 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/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/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(); 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..60a3b488e4 100644 --- a/crates/algo/src/lib.rs +++ b/crates/algo/src/lib.rs @@ -1023,6 +1023,31 @@ 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); + // 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) => { + 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). /// Nonpositive tile sizes are treated as one pixel. pub fn apply_tiled( @@ -1269,7 +1294,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 +1352,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/selection.rs b/crates/algo/src/selection.rs index a86fc52966..1e0c1dd149 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/combine_mask.rs b/crates/algo/src/selection/combine_mask.rs index bbe4c65738..5c45ed8ff0 100644 --- a/crates/algo/src/selection/combine_mask.rs +++ b/crates/algo/src/selection/combine_mask.rs @@ -69,8 +69,10 @@ fn build(old: Option<&Surface>, area: Rect, mode: SelectionMode, keep_outside: b for y in cut.y0..cut.y1 { let offset = y.abs_diff(rect.y0) as usize * ts + cut.x0.abs_diff(rect.x0) as usize; let len = cut.width() as usize; - let Some(dst) = tile.bytes_mut().get_mut(offset..offset + len) else { continue }; - let src = previous.and_then(|t| t.bytes().get(offset..offset + len)); + let mut data = tile.bytes_mut(); + let Some(dst) = data.get_mut(offset..offset + len) else { continue }; + let previous_bytes = previous.map(|t| t.bytes()); + let src = previous_bytes.as_ref().and_then(|t| t.get(offset..offset + len)); let start = (y.abs_diff(area.y0) as usize).checked_mul(width).and_then(|i| i.checked_add(cut.x0.abs_diff(area.x0) as usize)); for (i, d) in dst.iter_mut().enumerate() { let new = start.and_then(|j| j.checked_add(i)).map(&sample).unwrap_or(0); @@ -86,7 +88,7 @@ fn build(old: Option<&Surface>, area: Rect, mode: SelectionMode, keep_outside: b let (lo, hi) = tile.bytes().iter().fold((u8::MAX, 0u8), |(lo, hi), b| (lo.min(*b), hi.max(*b))); let tile = if lo == out_default && hi == out_default { None - } else if previous.is_some_and(|p| p.bytes() == tile.bytes()) { + } else if previous.is_some_and(|p| *p.bytes() == *tile.bytes()) { previous.cloned() } else if lo == u8::MAX { Some(Arc::clone(&full)) diff --git a/crates/algo/src/selection/inverse.rs b/crates/algo/src/selection/inverse.rs index e92cc918d2..8616ac3111 100644 --- a/crates/algo/src/selection/inverse.rs +++ b/crates/algo/src/selection/inverse.rs @@ -53,14 +53,14 @@ pub fn inverse_surface(source: &Surface, area: Rect) -> Surface { let cell = cache.get(&(Arc::as_ptr(input) as usize))?; let tile = cell.get_or_init(|| { if format == PixelFormat::GRAY8 { - if input.bytes() == full.bytes() { + if *input.bytes() == *full.bytes() { return None; } - if input.bytes() == blank.bytes() { + if *input.bytes() == *blank.bytes() { return Some(Arc::clone(&full)); } let mut tile = Tile::clone(input); - for b in tile.bytes_mut() { + for b in tile.bytes_mut().iter_mut() { *b = 255 - *b; } return Some(Arc::new(tile)); @@ -78,7 +78,8 @@ pub fn inverse_surface(source: &Surface, area: Rect) -> Surface { let (x0, x1) = ((cut.x0 - rect.x0) as usize, (cut.x1 - rect.x0) as usize); for y in (cut.y0 - rect.y0) as usize..(cut.y1 - rect.y0) as usize { let start = y * TILE_SIZE as usize + x0; - if let Some(row) = tile.bytes_mut().get_mut(start..start + x1 - x0) { + let mut data = tile.bytes_mut(); + if let Some(row) = data.get_mut(start..start + x1 - x0) { for (i, b) in (start..).zip(row) { *b = input.map_or(default, |t| value(t, i)); } @@ -91,9 +92,9 @@ pub fn inverse_surface(source: &Surface, area: Rect) -> Surface { } fn canonical(tile: Tile, blank: &Arc, full: &Arc) -> Option> { - if tile.bytes() == blank.bytes() { + if *tile.bytes() == *blank.bytes() { None - } else if tile.bytes() == full.bytes() { + } else if *tile.bytes() == *full.bytes() { Some(Arc::clone(full)) } else { Some(Arc::new(tile)) diff --git a/crates/algo/src/selection/lasso.rs b/crates/algo/src/selection/lasso.rs index 2471a379b5..a13e4de1df 100644 --- a/crates/algo/src/selection/lasso.rs +++ b/crates/algo/src/selection/lasso.rs @@ -202,7 +202,8 @@ pub fn polygon_surface(points: &[(f32, f32)], clip: Rect) -> Option { } for ((r, tile), row) in spans.iter().zip(&mut tiles).zip(acc.as_chunks::().0) { let base = y.rem_euclid(TILE_SIZE) as usize * TS + r.x0.rem_euclid(TILE_SIZE) as usize; - let Some(dst) = tile.bytes_mut().get_mut(base..base + r.width() as usize) else { continue }; + let mut data = tile.bytes_mut(); + let Some(dst) = data.get_mut(base..base + r.width() as usize) else { continue }; for (d, v) in dst.iter_mut().zip(row) { *d = (v.min(1.0).clamp(0.0, 1.0) * 255.0).round() as u8; } diff --git a/crates/algo/src/selection/marquee.rs b/crates/algo/src/selection/marquee.rs index c4830ae134..7afd788fdc 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()); @@ -258,15 +264,17 @@ pub fn combine_sparse(a: &Surface, b: &Surface, area: Rect, f: impl Fn(f32, f32) let tr = rect.intersect(&area); let tb = b.tile(tc)?; let mut tile = Tile::clone(a.tile(tc).unwrap_or(&untouched)); - let data = tile.bytes_mut(); + let mut data = tile.bytes_mut(); + let source = tb.bytes(); 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 (Some(dst), Some(src)) = (data.get_mut(s..e), tb.bytes().get(s..e)) else { continue }; + let (Some(dst), Some(src)) = (data.get_mut(s..e), source.get(s..e)) else { continue }; for (d, y) in dst.iter_mut().zip(src) { *d = pair(*d, *y); } } + drop((data, source)); let (lo, hi) = tile.bytes().iter().fold((u8::MAX, 0u8), |(lo, hi), v| (lo.min(*v), hi.max(*v))); Some(( tc, 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/tests.rs b/crates/algo/src/tests.rs index 57b7428330..8a2e6a272d 100644 --- a/crates/algo/src/tests.rs +++ b/crates/algo/src/tests.rs @@ -4,6 +4,18 @@ mod ext; mod mosaic; mod selection_mix; +#[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); + // 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 { PixelFormat::new(ColorMode::Rgb, s, true) } 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/Cargo.toml b/crates/automation/Cargo.toml index ba073af2ff..320a1f50f4 100644 --- a/crates/automation/Cargo.toml +++ b/crates/automation/Cargo.toml @@ -29,8 +29,13 @@ cap-std = "4" clap = { workspace = true } [dev-dependencies] +photocraft-geom = { workspace = true } # 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/files.rs b/crates/automation/src/files.rs index 0b8ff103aa..0ecc057c59 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,23 +48,39 @@ 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) } } /// 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 and it can be rendered +/// ([`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)); + } + 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 @@ -86,7 +112,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 +130,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/headless.rs b/crates/automation/src/headless.rs index 6722c5c00b..e623e69f2c 100644 --- a/crates/automation/src/headless.rs +++ b/crates/automation/src/headless.rs @@ -90,14 +90,25 @@ 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 opts = photocraft_io::ImportOptions { psd_high_memory_approved: approve_high_memory, ..Default::default() }; + 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. @@ -129,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(); @@ -358,6 +370,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/automation/src/rpc.rs b/crates/automation/src/rpc.rs index 92ece2b943..6ac8d96247 100644 --- a/crates/automation/src/rpc.rs +++ b/crates/automation/src/rpc.rs @@ -61,6 +61,36 @@ 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 }) + | 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 }) + | 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)); + m.insert("hardLimit".into(), json!(limit)); + } + } + _ => {} + } +} + fn bad(msg: impl Into) -> AutomationError { AutomationError::BadRequest(msg.into()) } @@ -107,10 +137,18 @@ 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 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"); opts.remove("index"); @@ -126,10 +164,22 @@ 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: 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()))?; 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) => { @@ -224,7 +274,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 +316,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..0a20075267 100644 --- a/crates/automation/src/rpc/tests.rs +++ b/crates/automation/src/rpc/tests.rs @@ -1028,3 +1028,88 @@ 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); + + // 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 544dbc90f1..e5929f2864 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: bool, } #[derive(Debug, Default, Deserialize, JsonSchema)] @@ -69,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)] @@ -177,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")] @@ -191,6 +208,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: 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)] pub index: Option, } @@ -480,13 +501,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) => fail(e), + Err(e) => { + let mut obj = json!({"error": e.to_string()}); + 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 \ @@ -631,30 +703,25 @@ 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; + 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( - 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() { @@ -792,7 +859,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 } @@ -939,7 +1006,11 @@ 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()}); + attach_memory_error_details(&mut obj, &error); + obj + } }; if let Err(error) = reply_budget.charge(&result) { failed += 1; @@ -1062,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}"))); } @@ -1073,12 +1148,14 @@ 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"), p.tiff_layers.then_some("tiffLayers"), + p.pcraft_full_composite.is_some().then_some("pcraftFullComposite"), dpx_bits.is_some().then_some("dpxBits"), p.index.is_some().then_some("index"), ] @@ -1101,12 +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, tga_bits, ..Default::default() }; + 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; @@ -1255,6 +1352,66 @@ 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 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, + 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); + } + + /// #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/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" 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/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/mcp.rs b/crates/automation/tests/it/mcp.rs index e18df63c4a..2320bf7adc 100644 --- a/crates/automation/tests/it/mcp.rs +++ b/crates/automation/tests/it/mcp.rs @@ -360,6 +360,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; @@ -464,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/automation/tests/it/native_full_composite.rs b/crates/automation/tests/it/native_full_composite.rs new file mode 100644 index 0000000000..f699087cc6 --- /dev/null +++ b/crates/automation/tests/it/native_full_composite.rs @@ -0,0 +1,49 @@ +//! 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 png = photocraft_format::read_full_composite(bytes).unwrap().expect("the manifest names the full-size composite"); + let image = photocraft_codecs::decode(&png).unwrap(); + (image.dimensions(), image.to_rgba8()) +} + +fn has_full_composite(bytes: &[u8]) -> bool { + photocraft_format::read_full_composite(bytes).unwrap().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)); + 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/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); +} 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/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/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/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..e2359e6828 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, @@ -164,6 +165,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`; @@ -185,6 +198,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); } @@ -215,6 +231,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 { @@ -520,6 +546,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 +571,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). @@ -539,6 +581,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 { @@ -567,6 +612,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 +690,17 @@ fn prepare(f: &File<'_>, dir: &Ifd, limits: &Limits, whole: bool) -> Result 3, + 2 | 6 | 8 | 9 => 3, 5 => 4, _ => 1, }; @@ -686,6 +736,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 +780,9 @@ fn prepare(f: &File<'_>, dir: &Ifd, limits: &Limits, whole: bool) -> Result 4, 32773 => 130, 4 => cw.clamp(4096, 65_535), @@ -776,6 +828,9 @@ fn prepare(f: &File<'_>, dir: &Ifd, limits: &Limits, whole: bool) -> Result, dir: &Ifd, limits: &Limits, whole: bool) -> Result { 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,23 +595,62 @@ 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> { - 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) { + // Best effort: without the room the buffer grows as before. + 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)?; + 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)?; + } } - 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/src/codecs/tiff/ycbcr.rs b/crates/codecs/src/codecs/tiff/ycbcr.rs new file mode 100644 index 0000000000..2983a8050e --- /dev/null +++ b/crates/codecs/src/codecs/tiff/ycbcr.rs @@ -0,0 +1,222 @@ +//! YCbCr (PhotometricInterpretation 6) to RGB, after the `tiff` crate has decoded the samples. +//! +//! The crate decompresses JPEG data and upsamples subsampled chroma, but hands back the stored +//! Y, Cb and Cr values. This module turns them into RGB in place: ReferenceBlackWhite scales the +//! codes and YCbCrCoefficients gives the luma weights. JPEG-compressed data (compression 7) uses +//! the JPEG (JFIF) conversion, full range with the BT.601 weights, and ignores both tags, as +//! libtiff does when it asks the JPEG library for RGB. +//! +//! # Sources +//! +//! - TIFF Revision 6.0 (Final, June 3, 1992), Section 21 "YCbCr Images", pp. 89–92; the +//! YCbCrCoefficients field and its inverse equations are on pp. 90–91: +//! `R = Cr·(2 - 2·LumaRed) + Y`, `B = Cb·(2 - 2·LumaBlue) + Y`, +//! `G = (Y - LumaBlue·B - LumaRed·R) / LumaGreen`. +//! +//! - 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/src/exr_stream.rs b/crates/codecs/src/exr_stream.rs new file mode 100644 index 0000000000..1ed8ba1276 --- /dev/null +++ b/crates/codecs/src/exr_stream.rs @@ -0,0 +1,294 @@ +//! 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")); + } + // 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 (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() { + 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 4c081ba074..ec9f0995de 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)] @@ -17,6 +18,7 @@ pub mod cicp; mod codecs; mod error; pub mod exif_color; +pub mod exr_stream; mod fidelity; mod format; mod image; @@ -43,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}; @@ -273,6 +276,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) 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/exr_stream.rs b/crates/codecs/tests/it/exr_stream.rs new file mode 100644 index 0000000000..65be2387b9 --- /dev/null +++ b/crates/codecs/tests/it/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.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]); + } + } + 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/codecs/tests/it/main.rs b/crates/codecs/tests/it/main.rs index a2ed94616a..f465a0cbbd 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; @@ -30,7 +31,9 @@ mod tiff_exif; mod tiff_lab; mod tiff_pages; mod tiff_photoshop; +mod tiff_pyramid; mod tiff_regressions; +mod tiff_ycbcr; mod webp_frames; mod webp_lossy; mod xpm; diff --git a/crates/codecs/tests/it/roundtrip.rs b/crates/codecs/tests/it/roundtrip.rs index acbd16c5db..5a0175bbc2 100644 --- a/crates/codecs/tests/it/roundtrip.rs +++ b/crates/codecs/tests/it/roundtrip.rs @@ -213,6 +213,23 @@ fn tiff_all_compressions_lossless() { } } +#[test] +fn compressed_tiff_does_not_keep_the_uncompressed_reservation() { + // The encoder reserves the uncompressed size up front for an uncompressed file (#6607). A + // smooth image compresses to a fraction of it, and no returned file holds much spare room. + 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::None, TiffCompression::Lzw, TiffCompression::Deflate] { + let v = encode(&img, Format::Tiff, &EncodeOptions { tiff_compression: comp, ..Default::default() }).unwrap(); + if !matches!(comp, TiffCompression::None) { + 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()); + assert_eq!(decode(&v).unwrap().data(), img.data(), "{comp:?}"); + } +} + #[test] fn png_all_compressions_lossless() { let img = test_image(ChannelLayout::Rgba, SampleType::U16); 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/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/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/bounds.rs b/crates/compose/src/bounds.rs index 985a400e08..d6a3a88ef1 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() && 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/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/fx_cache.rs b/crates/compose/src/fx_cache.rs index 43e524d37f..b24aabc29b 100644 --- a/crates/compose/src/fx_cache.rs +++ b/crates/compose/src/fx_cache.rs @@ -122,6 +122,21 @@ pub fn purge_effect_cache() -> usize { freed } +/// 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() { + 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(); + super::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 })) @@ -218,13 +233,22 @@ pub(crate) fn effect_maps(layer: &Layer, rect: Rect, cx: &Ctx) -> std::sync::Arc } fn build_fx_slot_maps(layer: &Layer, region: Rect, key: u64, cx: &Ctx, slot: &FxSlot) -> std::sync::Arc { + let generation = photocraft_raster::spill::read_error_generation(); 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 !readable || photocraft_raster::spill::check_since(generation).is_err() { + // Built from a scratch placeholder: use it for this frame, never cache it. + return maps; + } if slot.set(FxEntry { maps: maps.clone(), _pin: pin, 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/lib.rs b/crates/compose/src/lib.rs index e593290571..2af1c2e8fb 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -33,6 +33,7 @@ pub mod proxy; pub mod psblend; pub mod shape_split; mod uniform; +pub mod viewport; use photocraft_color::blend::BlendMode; use photocraft_doc::{Document, Fill, Layer, LayerContent, Pattern}; @@ -68,7 +69,9 @@ pub use extents::{ }; #[cfg(test)] use extents::{mask_k, shapeless_stroke_bounds, trim_to_painted}; -pub use fx_cache::{DEFAULT_FX_CACHE_BUDGET, 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}; @@ -84,18 +87,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/masks.rs b/crates/compose/src/masks.rs index 851c9bc509..faf45a19d0 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() || pixel_input.as_ref().is_some_and(|p| p.scratch_health().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/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/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/shape_split.rs b/crates/compose/src/shape_split.rs index c6e76fb0b2..53875d5922 100644 --- a/crates/compose/src/shape_split.rs +++ b/crates/compose/src/shape_split.rs @@ -21,6 +21,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. @@ -32,7 +36,7 @@ const BUDGET: usize = 8 << 20; /// Pixel bytes of one entry's (fill, stroke) parts (the pinned shape pixels are the document's). fn entry_bytes(parts: &(Surface, Surface)) -> usize { - [&parts.0, &parts.1].iter().map(|s| s.tiles().map(|(_, t)| t.bytes().len()).sum::()).sum() + [&parts.0, &parts.1].iter().map(|s| s.tiles().map(|(_, t)| t.len()).sum::()).sum() } /// Drop every cached split (Edit › Purge › All), returning the parts' pixel bytes. Callers may @@ -49,6 +53,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); @@ -78,6 +83,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() || 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()); // 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/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/src/uniform.rs b/crates/compose/src/uniform.rs index 02bc4a4111..35ddd5b4cc 100644 --- a/crates/compose/src/uniform.rs +++ b/crates/compose/src/uniform.rs @@ -85,7 +85,8 @@ impl<'a> UniformAreas<'a> { Some(v) => v, None => { let bpp = s.default_bytes().len().max(1); - let mut px = t.bytes().chunks_exact(bpp); + let bytes = t.bytes(); + let mut px = bytes.chunks_exact(bpp); let v = px.next().filter(|p0| px.all(|p| p == *p0)).map(Box::from); self.tile_values.lock().unwrap_or_else(PoisonError::into_inner).insert(id, v.clone()); v diff --git a/crates/compose/src/viewport.rs b/crates/compose/src/viewport.rs new file mode 100644 index 0000000000..c7a5cc3c10 --- /dev/null +++ b/crates/compose/src/viewport.rs @@ -0,0 +1,260 @@ +//! 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)); + } + 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())?; + 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] + ); + } + } + } + } + /// 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); + } +} 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/compose/tests/scratch_retry.rs b/crates/compose/tests/scratch_retry.rs new file mode 100644 index 0000000000..70fdc41225 --- /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.clone(); + // 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 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(); + 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/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/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/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/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 19e1e94afc..e2e9c3bd76 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; @@ -152,6 +153,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; @@ -166,6 +168,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}; @@ -239,6 +242,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 @@ -305,11 +309,30 @@ 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"|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)?; + 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")); + } + } + // `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)?; + 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()))), @@ -318,7 +341,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,"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 ), @@ -586,7 +609,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 +933,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 +1068,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. @@ -1053,13 +1076,22 @@ 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)? } (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"))? } @@ -1184,7 +1216,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/actions_cmds.rs b/crates/engine/src/cmd/actions_cmds.rs index ea68d86b50..d0be63bf5e 100644 --- a/crates/engine/src/cmd/actions_cmds.rs +++ b/crates/engine/src/cmd/actions_cmds.rs @@ -137,9 +137,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/actions_cmds/tests.rs b/crates/engine/src/cmd/actions_cmds/tests.rs index 1eef6d4b34..02bc22ae35 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/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/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.rs b/crates/engine/src/cmd/atn_cmds.rs index 2daa9f5ce5..47f4b688fb 100644 --- a/crates/engine/src/cmd/atn_cmds.rs +++ b/crates/engine/src/cmd/atn_cmds.rs @@ -32,10 +32,20 @@ mod descriptors; mod events; mod fields; mod files; + +mod steps_channels; +mod steps_distort; mod steps_doc; +mod steps_gallery; mod steps_image; mod steps_layer; mod steps_make; + +mod steps_stylize; + +mod steps_other; +mod steps_render; +mod steps_selection; mod tables; #[cfg(test)] @@ -56,6 +66,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)] @@ -145,3 +156,12 @@ mod tests; #[cfg(test)] mod layer_props_tests; + +#[cfg(test)] +mod stylize_tests; + +#[cfg(test)] +mod selection_tests; + +#[cfg(test)] +mod render_tests; diff --git a/crates/engine/src/cmd/atn_cmds/adjustments.rs b/crates/engine/src/cmd/atn_cmds/adjustments.rs index 4b66e58658..ece241f3cf 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}; @@ -432,7 +434,7 @@ const ADJUSTMENTS: &[Adjustment] = &[ Adjustment { class: "ClrB", suffix: "colorBalance", from: color_balance_from, to: color_balance_to }, Adjustment { class: "vibrance", suffix: "vibrance", from: vibrance_from, to: |p| fields_to(p, VIBRANCE, desc("vibrance")) }, 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 }, @@ -456,6 +458,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/convert.rs b/crates/engine/src/cmd/atn_cmds/convert.rs index ccdbcf7348..4b22515c09 100644 --- a/crates/engine/src/cmd/atn_cmds/convert.rs +++ b/crates/engine/src/cmd/atn_cmds/convert.rs @@ -75,7 +75,10 @@ fn convert_action(file: &AtnFile, a: &AtnAction, set: &str, unsupported: &mut Ve (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 } => { let label = file.item_label(item); unsupported.push(Unsupported { action: name.clone(), step: i, event: item.event.id(), name: label.clone() }); @@ -155,6 +158,12 @@ pub(super) fn export_item(id: &str, params: &Value, set: &str) -> Option 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), @@ -109,13 +117,29 @@ 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, } 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 `), @@ -177,10 +201,32 @@ 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, } } +/// 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) } @@ -201,6 +247,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 e84e60b351..a29b4a1957 100644 --- a/crates/engine/src/cmd/atn_cmds/events.rs +++ b/crates/engine/src/cmd/atn_cmds/events.rs @@ -2,6 +2,9 @@ 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_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, @@ -9,13 +12,16 @@ use super::steps_doc::{ }; 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, radial_blur_from, radial_blur_to, refine_edge_to, transform_from, transform_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, - 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::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}; @@ -52,6 +58,12 @@ 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: super::steps_stylize::to } + }; +} + /// 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). @@ -182,9 +194,131 @@ macro_rules! clipping_event { }; } +/// 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 } + }; +} + /// Every Photoshop event PhotoCraft maps. The order matters only for the panel label. pub(super) const EVENTS: &[Event] = &[ + 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", + "intersectWith", + "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", + "distribute", + "Distribute", + &[ + "layer.distribute.leftEdges", + "layer.distribute.horizontalCenters", + "layer.distribute.rightEdges", + "layer.distribute.topEdges", + "layer.distribute.verticalCenters", + "layer.distribute.bottomEdges", + ], + 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", + "mergeLayers", + "Merge Layers", + &["layer.mergeLayers"], + |d| super::steps_selection::empty_from(d, "layer.mergeLayers", Some("MrgL")), + super::steps_selection::empty_to + ), // 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, + }, + 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, + }, + 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"), + 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", @@ -247,6 +381,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)]), @@ -254,6 +430,62 @@ pub(super) const EVENTS: &[Event] = &[ // What Photoshop needs in a written step (process version, the full settings set) is // unverified, so only an imported step saves back, as its recorded descriptor while unedited. Event { code: "", name: "Adobe Camera Raw Filter", label: "Camera Raw Filter", ids: &[crate::lens_cmds::RAW], from: camera_raw_from, to: camera_raw_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, + }, + // 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| 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| empty_from(d, "layer.removeBackground"), + to: empty_to, + }, // Adjustments (Image › Adjustments). Event { code: "", @@ -278,17 +510,24 @@ 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"), + // 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"), 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"), + 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 }, @@ -476,12 +715,26 @@ 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", 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, }, @@ -504,7 +757,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", @@ -615,20 +875,53 @@ 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")]; +const LEGACY_CODES: &[(&str, &str)] = &[ + ("BoxB", "boxblur"), + ("SrfB", "surfaceBlur"), + ("Fltn", "flattenImage"), + ("BlnW", "blackAndWhite"), + ("PhtF", "photoFilter"), + ("Vbrn", "vibrance"), + ("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 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 { - 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); + 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) => 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)), }) } /// 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; + } + // 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; + } + 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/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..21f87c286e --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/render_tests.rs @@ -0,0 +1,291 @@ +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)) +} + +/// 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() { + 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}"); + } + // 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}"); + } +} + +/// 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()); +} + +/// 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() { + 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"})), + // 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] { + 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/selection_tests.rs b/crates/engine/src/cmd/atn_cmds/selection_tests.rs new file mode 100644 index 0000000000..66c99248c6 --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/selection_tests.rs @@ -0,0 +1,541 @@ +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() +} + +/// 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)), + ("SbtF", Some("subtractFrom"), rectangle([30.0, 30.0, 60.0, 60.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("distribute"), desc("null").with("null", target_ref("Lyr ")).with("Usng", enm("ADSt", "AdLf"))), + ("RmvL", Some("removeLayerMask"), desc("null")), + ("DfnP", Some("definePattern"), 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"] { + 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)); + 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("distribute"), desc("null").with("null", target_ref("Lyr ")).with("Usng", enm("ADSt", edge)))); + } + 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())); + } + 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 }); + } + } +} + +/// A kept step retains its descriptor bytes, event identity and playback flags. +fn assert_kept(id: &str, name: Option<&str>, descriptor: Descriptor) { + 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; + 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()); +} + +/// 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_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 { + 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 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")), + // 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(); + 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() { + 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() { + 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() { + 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()); + 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. +#[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 { panic!("rectangle fixture must contain a shape") }; + 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_channels.rs b/crates/engine/src/cmd/atn_cmds/steps_channels.rs new file mode 100644 index 0000000000..bfd3e07904 --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/steps_channels.rs @@ -0,0 +1,160 @@ +//! 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, 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 { + 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. 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 { + 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, + } +} + +/// 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'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 { + match v { + D::UnitFloat { unit, value } if *unit == PX && value.is_finite() => Some(*value), + _ => None, + } +} + +/// 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!({"width": MIN_TRAP})), + _ => Mapped::Unsupported { harmless: false }, + } +} + +/// `Wdth` is always written: the command traps 1 px when the step leaves the width out. A +/// 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| (MIN_TRAP..=MAX_TRAP).contains(n))?, + }; + Some(desc("null").with("Wdth", px(width))) +} + +/// 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. 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)), + 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, range) in MATCH_COLOR { + let integer = |v: &D| match v { + D::Integer(n) if range.contains(n) => Some(Value::from(*n)), + _ => None, + }; + p.insert((*param).into(), setting(d, key, integer, Value::from(*default))?); + } + Some(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 or a +/// 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)); + 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; + } + let mut d = desc("null"); + 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(*range.start())..=f64::from(*range.end())).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/steps_distort.rs b/crates/engine/src/cmd/atn_cmds/steps_distort.rs new file mode 100644 index 0000000000..86ffa239da --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/steps_distort.rs @@ -0,0 +1,221 @@ +//! 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, 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())) +} + +/// 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. 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 { + 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, + } +} + +/// Whether `p` sets nothing but `allowed` (a `null` counts as unset). +fn sets_only(p: &Value, allowed: &[&str]) -> bool { + 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 +/// 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, 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 }, +} + +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, 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))) +} + +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(setting(d, key, |v| num(v).map(|n| n.round().clamp(f64::from(*min), f64::from(*max)) as i32), *default)?) + } + 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); + } + 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, +/// and one outside its `min..=max`, which would load back clamped. +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, min, max, .. } => { + let n = v.as_f64()?; + if n.fract() != 0.0 || !(f64::from(*min)..=f64::from(*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")]; + +/// `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 { + 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"]).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; + } + 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)) => { + m.insert("seed".into(), Value::from(seed)); + } + Some(None) => {} + None => return unsupported, + } + 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")), + ("generators", json!(5)), + ("wavelengthMin", json!(10)), + ("wavelengthMax", json!(120)), + ("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", + 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/steps_doc.rs b/crates/engine/src/cmd/atn_cmds/steps_doc.rs index 12852536ca..9b23dd1c10 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}; @@ -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,12 @@ 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(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 "))), + } } const WEB_FORMATS: &[(&str, &str)] = &[("PN24", "png24"), ("PN8", "png8"), ("JPEG", "jpeg"), ("GIFf", "gif"), ("WBMP", "wbmp")]; @@ -192,7 +202,14 @@ 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") { return match depth as i64 { 8 => none("image.mode.bits8"), @@ -210,8 +227,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 }; @@ -248,27 +271,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()); } @@ -287,23 +330,42 @@ 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 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 " @@ -324,32 +386,50 @@ 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") { 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) } +/// Keep recorded physical dimensions until playback can use the current document's dpi. +/// Import pixel or percentage dimensions and their recorded style-scaling option. 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), }, @@ -357,6 +437,18 @@ 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("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")) + { + return Mapped::Unsupported { harmless: false }; + } + p.insert("units".into(), "pt".into()); + } if let Some(pc) = percent { p.insert("percent".into(), pc.into()); } @@ -380,10 +472,25 @@ pub(super) fn image_size_from(d: &Descriptor) -> Mapped { if p.is_empty() { return Mapped::Unsupported { harmless: false }; } + // An omitted recorded flag preserves style sizes; native calls still default 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)) } +/// Export the recorded unit, avoiding sizes the native no-resample operation ignores. +/// Export effective style scaling with the existing Image Size dimensions. 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() @@ -399,6 +506,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 && ["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() { // Nothing left to record: the step changes nothing. return None; @@ -407,17 +527,33 @@ 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 }); } - 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)?)); } + // 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) } @@ -475,11 +611,51 @@ 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") => 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 }, + }; + 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 }; + } + 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" }) } -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)); } @@ -528,12 +704,28 @@ 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. +/// 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" { - if let Some(i) = get_enum(d, "Inte").and_then(|i| lookup(INTENT, &i)) { + // 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")] { + let value = match d.get(key) { + None => false, + Some(D::Boolean(value)) => *value, + Some(_) => return Mapped::Unsupported { harmless: false }, + }; + p[param] = json!(value); + } + 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") { @@ -543,14 +735,28 @@ pub(super) fn profile_from(d: &Descriptor, id: &str) -> Mapped { step(id, p) } +/// Export the supported profile and canonical rendering-intent identifier. +/// 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")); 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(-1)); + 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/steps_gallery.rs b/crates/engine/src/cmd/atn_cmds/steps_gallery.rs new file mode 100644 index 0000000000..215fab4a67 --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/steps_gallery.rs @@ -0,0 +1,775 @@ +//! 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, jstr, trimmed}; +use super::events::pixel_target; +use super::fields::{Mapped, step}; +use super::tables::{lookup, lookup_back}; +use super::{AtnEvent, AtnItem, D, Descriptor, Map, Value, json}; +use photocraft_algo::{GalleryEffect, GalleryFilter}; + +/// 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` 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, + } + } + + fn param(&self) -> &'static str { + match self { + G::Int(_, p, ..) | G::Enum(_, p, ..) | G::Bool(_, p, _) | G::Color(_, p, _) => p, + } + } +} + +/// 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). +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", "#0000ff")], + ), + // 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", +]; + +/// 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", + }; + // `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 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")? { + "code" => AtnEvent::Code(event.as_bytes().try_into().ok()?), + "name" => AtnEvent::name(event), + _ => return None, + }; + 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 +/// 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; + } + // 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 + // 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(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); + } + 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 +/// 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", 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; + } + 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(_, _, min, max, default) => { + let n = v.map_or(Some(f64::from(*default)), Value::as_f64)?; + // 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) + } + 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)?), + G::Color(..) => D::Descriptor(match v { + Some(v) => color_desc(v.as_str()?)?, + None => default_color(id)?, + }), + }; + d = d.with(k.key(), out); + } + 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()), + } + } + } + } +} diff --git a/crates/engine/src/cmd/atn_cmds/steps_image.rs b/crates/engine/src/cmd/atn_cmds/steps_image.rs index 897544b109..149f1939fb 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_image.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_image.rs @@ -1,12 +1,27 @@ use super::descriptors::{ - PX, Target, ang, class, code, desc, enm, 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, + 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}; use super::tables::{CHANNELS, INTERPOLATION, RADIAL_METHOD, RADIAL_QUALITY, channel_of, lookup, lookup_back}; use super::{D, Descriptor, Map, ReferenceItem, UnicodeString, Value, json}; +/// 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"), + ("Qcs4", "top"), + ("Qcs1", "topRight"), + ("Qcs7", "left"), + ("Qcs5", "right"), + ("Qcs3", "bottomLeft"), + ("Qcs6", "bottom"), + ("Qcs2", "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 +55,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 +96,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 +115,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) { @@ -477,24 +501,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. @@ -640,3 +647,239 @@ 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())) +} + +/// 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. 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 { + 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, + } +} + +/// Whether `p` sets nothing but `allowed` (a `null` counts as unset). +fn sets_only(p: &Value, allowed: &[&str]) -> bool { + 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 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 empty_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 { + match setting(d, "sampleAllLayers", boolean, false) { + Some(all) if only_keys(d, &["sampleAllLayers"]) => step("select.sky", json!({"sampleAllLayers": all})), + _ => Mapped::Unsupported { harmless: 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))) +} + +/// 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), + ("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), +]; + +/// `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 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)), + 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 None; + } + 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(), oil_paint_clamped(param, setting(d, key, double, *ps)?).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) +} + +/// 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, 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() && in_oil_paint_limits(k, *n)), + }; + 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)?))) +} + +/// 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, + } +} + +/// `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`) 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)), + None => Mapped::Unsupported { harmless: false }, + } +} + +/// 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", "noiseReduction", "blur"]; + let gaussian = only_keys(d, &keys) + && setting(d, "presetKind", enumerated, "presetKindCustom".into())? == "presetKindCustom" + && !setting(d, "useLegacy", boolean, true)? + && setting(d, "blur", blur_type, "lensBlur".into())? == "GsnB"; + if !gaussian { + return None; + } + let mut p = Map::new(); + 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()); + } + Some(p) +} + +/// The form `smart_sharpen_params` reads, with the keys in the order Photoshop records them (as +/// 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; + } + let num = |k: &str, default: f64| match p.get(k) { + None | Some(Value::Null) => Some(default), + Some(v) => v.as_f64().filter(|n| SMART_SHARPEN_LIMITS.iter().any(|(param, min, max)| *param == k && (*min..=*max).contains(n))), + }; + 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)?)) + .with("noiseReduction", pct(num("reduceNoise", 10.0)?)) + .with("blur", enm("blurType", "GsnB")), + ) +} diff --git a/crates/engine/src/cmd/atn_cmds/steps_layer.rs b/crates/engine/src/cmd/atn_cmds/steps_layer.rs index 5e116beb3c..ed1d9caabb 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_layer.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_layer.rs @@ -1,27 +1,41 @@ 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}; 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, 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. +/// Resolve a recorded selection while retaining layer names, Background properties and modifiers. +/// 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(n)) => { - let mut p = json!({"layer": n}); - 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) + (Some("Lyr"), Target::Name(_) | Target::Index(_) | Target::Ordinal(_)) => { + let Some(params) = recorded_layer_target(r) else { return Mapped::Unsupported { harmless: false } }; + 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 } }; + 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() { @@ -29,24 +43,20 @@ 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() + // 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")) => + { + // 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 }, }, // 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) { @@ -55,11 +65,15 @@ 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})), + (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. +/// Write the supported layer, channel, document and history selection references. +/// 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) }]); @@ -68,8 +82,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).or_else(|| 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")), @@ -80,10 +99,27 @@ 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("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") { + None => "RGB ", + Some(Value::String(channel)) => match channel.as_str() { + "mask" => "Msk ", + "composite" => "RGB ", + _ => return None, + }, + 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)) } @@ -95,9 +131,21 @@ 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()?; + // The reference application 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 }])) } _ => { @@ -172,13 +220,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,10 +247,16 @@ 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) } +/// 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 +272,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 +297,19 @@ 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) { + 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 +318,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 +347,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 +405,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 { @@ -360,6 +473,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 @@ -398,6 +512,23 @@ 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" { + 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")] + { + 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), @@ -419,6 +550,7 @@ pub(super) fn fill_from(d: &Descriptor) -> Mapped { step("edit.fill", p) } +/// 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")?)))); @@ -466,9 +598,52 @@ 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") { + 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")] + { + match p.get(param) { + None | Some(Value::Null) => {} + Some(Value::Bool(value)) => d = d.with(key, D::Boolean(*value)), + Some(_) => return None, + } + } + } 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/steps_make.rs b/crates/engine/src/cmd/atn_cmds/steps_make.rs index 4a0fad4ef0..07c71040a5 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, levels_complete}; 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; @@ -103,13 +103,35 @@ 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 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 ") { + 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)); } @@ -150,6 +172,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; @@ -176,13 +199,46 @@ 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 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 } }; + mapped.push(("layer.setLabelColor".into(), json!({"color": color}))); + } + Mapped::Steps(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})), @@ -196,7 +252,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") { @@ -234,6 +290,17 @@ 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| { let mut u = desc(cls); @@ -244,12 +311,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")) @@ -257,6 +324,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)); } @@ -360,16 +430,30 @@ 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(recorded_percent(opacity))); + } + desc("null").with("null", class_ref("AdjL")).with("Usng", D::Descriptor(using)) } }) } +/// 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 ") { @@ -380,7 +464,24 @@ 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(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)) + } // ⌘-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})), @@ -659,6 +760,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); @@ -675,7 +777,22 @@ 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 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)) + } "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/steps_other.rs b/crates/engine/src/cmd/atn_cmds/steps_other.rs new file mode 100644 index 0000000000..599ed7cab7 --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/steps_other.rs @@ -0,0 +1,152 @@ +//! 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, enumerated, get_list, 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}; +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 { + 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. 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 { + 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, + } +} + +/// 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.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 +/// 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, 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(); + if !only_keys(d, &names) { + return unsupported; + } + let mut p = Map::new(); + for (key, param, _, table, default) in keys { + 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)) +} + +/// 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) +} + +/// `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 whole number within `range`. +fn within(range: &RangeInclusive) -> impl Fn(&D) -> Option + '_ { + 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, 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"]) { + 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()); + p.insert("offset".into(), offset.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; 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 = |range: &RangeInclusive, v: &Value| { + let n = v.as_f64()?; + (n.fract() == 0.0 && range.contains(&n)).then_some(n as i32) + }; + let field = |k: &str, range: &RangeInclusive, default: i32| match p.get(k) { + None | Some(Value::Null) => Some(default), + 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) => { + let a = v.as_array().filter(|a| a.len() == 25)?; + a.iter().map(|c| int(&CUSTOM_CELL, c)).collect::>()? + } + }; + Some( + desc("null") + .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/steps_render.rs b/crates/engine/src/cmd/atn_cmds/steps_render.rs new file mode 100644 index 0000000000..b4911b6207 --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/steps_render.rs @@ -0,0 +1,138 @@ +//! Recorded descriptors for the Render filters. + +use std::hash::BuildHasher; + +use super::descriptors::{desc, enm}; +use super::fields::{Mapped, step}; +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")]; + +/// 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. +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 { + if d.items.is_empty() { step(id, json!({})) } else { Mapped::Unsupported { harmless: false } } +} + +/// 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 { + 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. +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/steps_selection.rs b/crates/engine/src/cmd/atn_cmds/steps_selection.rs new file mode 100644 index 0000000000..1c30dc93ab --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/steps_selection.rs @@ -0,0 +1,233 @@ +//! 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}; + +/// 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 { + convert(d) +} + +/// Keys must retain the encoding and order used by export, with no repeats or unknowns. +fn keys(d: &Descriptor, allowed: &[&str]) -> bool { + 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. +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, + } +} + +/// Enum identifiers must retain their encoded form, including short-code padding. +fn enumeration(d: &Descriptor, key: &str, expected: &str) -> Option { + 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. +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; + } + 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)) +} + +/// 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/steps_stylize.rs b/crates/engine/src/cmd/atn_cmds/steps_stylize.rs new file mode 100644 index 0000000000..611fb9e694 --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/steps_stylize.rs @@ -0,0 +1,202 @@ +//! Descriptor adapters for the measured Stylize and Pixelate action events. + +use super::descriptors::{desc, enm, px}; +use super::fields::{Mapped, step}; +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] = &[ + // 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] = &[ + 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 { + match params_from(id, d) { + Some(params) => step(id, Value::Object(params)), + None => Mapped::Unsupported { harmless: false }, + } +} + +/// 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)?; + // 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" + && !(colours && matches!(key.as_str(), "foreground" | "background")) + && !(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(), + }; + 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 new file mode 100644 index 0000000000..976940ef43 --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/stylize_tests.rs @@ -0,0 +1,483 @@ +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 [ + (*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 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() { + 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), + ("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 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)))); + } +} + +/// Trace levels are whole numbers 0..255: imports clamp to that range and play like the clamped direct command. +#[test] +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); + 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"], clamped); + let saved = saved_items(&mut imported); + 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); + 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 [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}"); + } +} + +/// 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)), + ] { + // 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(); + 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)).into_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}"); + } +} + +/// 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}"); + } +} 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 5de5928052..f153ee83af 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"); @@ -103,13 +103,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() @@ -186,10 +186,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") }, @@ -376,11 +375,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"); } @@ -481,9 +480,43 @@ 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![ + ("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"})), + ("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.stylize.findEdges", json!({})), + ("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})), @@ -493,6 +526,35 @@ 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"})), + ("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!({})), + ("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!({})), + ("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}}), @@ -535,7 +597,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"})), @@ -570,8 +632,14 @@ 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.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!({})), @@ -598,7 +666,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!({})), @@ -628,7 +699,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"})), @@ -643,16 +714,72 @@ 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"})), (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})), ("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"}), @@ -669,7 +796,7 @@ fn every_mapped_command_exports_and_imports_back() { ("layer.fill.pattern.set", json!({"pattern": "1 -R2.jpg"})), ("layer.newFillLayer.pattern", json!({"pattern": "b7a3c1d2-0000-4000-8000-000000000001", "patternName": "Twin", "scale": 50})), ("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})), @@ -681,6 +808,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")); @@ -693,6 +823,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); @@ -748,8 +895,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", @@ -1081,7 +1226,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 ")))), @@ -1466,8 +1611,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")))), ]; @@ -1521,14 +1666,15 @@ 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"})); - 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"})); @@ -1623,9 +1769,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 { @@ -2121,7 +2266,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() { @@ -2133,7 +2278,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}), @@ -2177,15 +2321,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"}), @@ -3035,8 +3177,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())); } @@ -3072,7 +3214,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(); @@ -3081,22 +3223,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"); } @@ -3193,12 +3336,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 @@ -3553,6 +3699,1870 @@ 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 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, 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.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()); +} + +/// 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"); + } +} + +/// 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] +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"); +} + +/// 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)); +} + +/// 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); +} + +/// 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()); +} + +/// 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); +} + +/// 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"); + } +} + +/// 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"); + } +} + +/// 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(_))); +} + +/// 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"); + } +} + +/// 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(_))); +} + +/// 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()); +} + +/// 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); + } + } +} + +/// 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"); + } +} + +/// 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 (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": 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); + 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 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. +#[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"); +} + +/// 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()); +} + +/// 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"), + ("Qcs4", "top"), + ("Qcs1", "topRight"), + ("Qcs7", "left"), + ("Qcs5", "right"), + ("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") + .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)); + } + } + } + // 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. +#[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()); +} + +/// `"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() { + 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)); + } +} + +/// 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"); + // 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)) + .with("noiseReduction", pct(20.0)) + .with("blur", enm("blurType", "GsnB")); + assert_eq!( + mapped(newer("smartSharpen", sharpen.clone())), + ("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!({}))); + 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": 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(); + 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", gaussian_sharpen(&[]))).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 }); + // 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))))); + 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, "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})), + ("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()); +} + +/// 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)]) { + 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"))]); +} + +#[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)), + ], + ); +} + +#[test] +fn smart_sharpen_with_a_wrong_typed_setting_is_kept() { + 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 = 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, +/// 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(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)), + ("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)| { + 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:?}"); +} + +/// 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 }), + ], + ); +} + +/// 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() { + 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 = 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})), + "{rds:?}" + ); + } +} + +/// `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_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": noise})), + "{amount} {radius}" + ); + } + for params in [ + 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"); + } +} + +/// 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, 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() { + let (w, h) = (160u32, 100u32); + 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 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); + } + } + } + bg.write_region(photocraft_geom::Rect::new(0, 0, w as i32, h as i32), &data); + Ok(()) + }) + .unwrap(); + 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"); + 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"); + 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, 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_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)) + .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}), + json!({"angle": 181}), + json!({"angle": -181}), + json!({"angle": 1000}), + ] { + 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)))); @@ -3805,3 +5815,70 @@ 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))))); +} + +/// 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)); +} + +/// 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); + } +} diff --git a/crates/engine/src/cmd/channel_cmds.rs b/crates/engine/src/cmd/channel_cmds.rs index 49a45cba6a..e890a492f2 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, @@ -148,7 +154,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 +164,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 ), @@ -206,7 +212,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..5cedd21e31 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)) @@ -102,19 +107,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), or `"merged"` (default). An unknown name is an error. +/// 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)), - 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), - } + 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}) => { + return doc.background().map(|l| Some(l.id)).ok_or_else(|| bad(cmd, "the source document has no Background layer")); + } + 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/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); +} diff --git a/crates/engine/src/cmd/color_cmds.rs b/crates/engine/src/cmd/color_cmds.rs index b443441a18..5fb2d7d0f2 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. @@ -657,24 +659,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 @@ -845,7 +852,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) { @@ -856,7 +863,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); } @@ -950,6 +957,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 +1038,18 @@ 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")?; + 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. + flatten_for_profile(doc, active)?; + } else if rasterize { + prepare_profile_smart_layers(doc, true)?; + } + 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 })) } @@ -1168,7 +1258,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,"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..4bc5854ac0 --- /dev/null +++ b/crates/engine/src/cmd/color_cmds/profile_dither.rs @@ -0,0 +1,205 @@ +//! 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, + 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)) +} + +/// 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 = if sparse(s) { 0 } else { u128::from(bounds.tile_count()) }; + for (c, _) in s.tiles() { + tile_origin(*c)?; + if sparse(s) || !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. +/// 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 ds = to.channels(); + let mut converted_default = [0.0; 16]; + 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 (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(dest) +} + +/// 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/comps_cmds.rs b/crates/engine/src/cmd/comps_cmds.rs index db239c328f..ee6bc6d196 100644 --- a/crates/engine/src/cmd/comps_cmds.rs +++ b/crates/engine/src/cmd/comps_cmds.rs @@ -202,8 +202,39 @@ 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() { + 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 && i32::try_from(index).is_ok()) { + return Ok(None); + } + 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)?; + 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; 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") { + 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 +244,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 +495,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/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/edit_menu_cmds/purge.rs b/crates/engine/src/cmd/edit_menu_cmds/purge.rs index aee1ecc7e0..a74edb818b 100644 --- a/crates/engine/src/cmd/edit_menu_cmds/purge.rs +++ b/crates/engine/src/cmd/edit_menu_cmds/purge.rs @@ -14,7 +14,7 @@ fn history_bytes(s: &Session, all: bool) -> usize { } fn clip_bytes(s: &Session) -> usize { - s.clipboard.as_ref().map_or(0, |c| c.surface.tiles().map(|(_, t)| t.bytes().len()).sum()) + s.clipboard.as_ref().map_or(0, |c| c.surface.tiles().map(|(_, t)| t.len()).sum()) } pub(super) fn purge(s: &mut Session, what: &str) -> Result { diff --git a/crates/engine/src/cmd/extra_cmds.rs b/crates/engine/src/cmd/extra_cmds.rs index 45fec91db1..e523b4b5e6 100644 --- a/crates/engine/src/cmd/extra_cmds.rs +++ b/crates/engine/src/cmd/extra_cmds.rs @@ -440,7 +440,7 @@ fn visible_bounds(s: &photocraft_raster::Surface, canvas: Rect) -> Rect { } out }; - tiles.par_iter().map(|(c, t)| tile_bounds(c.rect(), t.bytes())).reduce(|| Rect::EMPTY, |a, b| a.union(&b)) + tiles.par_iter().map(|(c, t)| tile_bounds(c.rect(), &t.bytes())).reduce(|| Rect::EMPTY, |a, b| a.union(&b)) } // ---------- layers ---------- @@ -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/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/file_cmds.rs b/crates/engine/src/cmd/file_cmds.rs index 82a5421445..5d78a8f72a 100644 --- a/crates/engine/src/cmd/file_cmds.rs +++ b/crates/engine/src/cmd/file_cmds.rs @@ -39,9 +39,9 @@ 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(crate) use open::{import, tag_exr_colorspace}; +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, import_with_options, tag_exr_colorspace}; use paths::is_file; #[cfg(test)] use paths::openable_name; @@ -53,7 +53,10 @@ 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_bool_param, tiff_compression_param, + tiff_would_write_layers, +}; mod batch_naming; @@ -68,6 +71,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) @@ -117,6 +124,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()) } @@ -244,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)}"##, + 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 ), @@ -253,16 +264,30 @@ 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 ), + // 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…", &["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) ), @@ -271,7 +296,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) ), @@ -307,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 ), @@ -316,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 ), @@ -344,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/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/open.rs b/crates/engine/src/cmd/file_cmds/open.rs index efc44e880f..1397702318 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| EngineError::Other(format!("{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 { @@ -109,12 +122,30 @@ 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) +} + +/// [`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, + 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; + opts.psd_approved_estimate = None; + 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); @@ -139,9 +170,69 @@ pub fn open_bytes_as(s: &mut Session, name: &str, bytes: &[u8], as_ext: Option<& 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 = 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() }); + } + }; + let bytes = read_file(&path)?; + 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") { + 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) + .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})); + } + // 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. 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 }); + } + 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})), + } } // ---------- place ---------- @@ -159,8 +250,17 @@ 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; + opts.psd_approved_estimate = None; + 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" @@ -214,6 +314,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/save.rs b/crates/engine/src/cmd/file_cmds/save.rs index fff62dc7f4..0f784671c8 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. @@ -25,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, @@ -33,6 +39,10 @@ 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`: 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, } impl Default for SaveOpts { @@ -44,9 +54,12 @@ 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, + pcraft_full_composite: false, } } } @@ -56,7 +69,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 { @@ -66,9 +80,12 @@ 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, + pcraft_full_composite: pcraft_full_composite_param(p, cmd)?.unwrap_or(file_handling.pcraft_full_composite), }) } @@ -98,6 +115,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> { @@ -113,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`]. @@ -175,9 +215,11 @@ 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, + 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. @@ -191,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 d93c15fd82..ff66258820 100644 --- a/crates/engine/src/cmd/file_cmds/tests.rs +++ b/crates/engine/src/cmd/file_cmds/tests.rs @@ -456,6 +456,37 @@ 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_full_composite(&std::fs::read(path).unwrap()).unwrap().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); + } + 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] fn open_as_forces_the_decoder() { let dir = tmp("openas"); @@ -1828,6 +1859,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. @@ -1973,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_"); @@ -1989,3 +2072,323 @@ fn sanitize_keeps_windows_device_names_off_the_disk() { assert_eq!(sanitize(name), name); } } + +#[test] +fn approve_memory_validates_parameters_and_unknown_ids() { + 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 { .. }))); + 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, + }) + .unwrap(); + let res = s.execute("file.approveMemory", json!({"id": id, "approve": false})).unwrap(); + assert_eq!(res["approved"], false); + assert_eq!(res["cancelled"], true); + assert!(s.take_pending_preflight(id).is_none(), "preflight removed on cancel"); +} + +#[test] +fn approve_memory_refuses_when_source_changes_before_confirmation() { + let dir = tmp("fingerprint_test"); + let path = join(&dir, "change.psd"); + std::fs::write(&path, b"initial content").unwrap(); + + 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, + }) + .unwrap(); + + // Modify file on disk + std::fs::write(&path, b"changed content longer").unwrap(); + + let res = s.execute("file.approveMemory", json!({"id": id, "approve": true, "background": false})); + assert!(matches!(res, Err(EngineError::SourceChanged { .. })), "changed file must fail identity verification: {res:?}"); +} + +#[test] +fn approve_memory_refuses_when_available_ram_decreases() { + let gb = 1u64 << 30; + 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, + }) + .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 }; + 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"); + }); +} + +#[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::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() + ); +} + +/// 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/fill_cmds.rs b/crates/engine/src/cmd/fill_cmds.rs index 35d25119a9..88bbafe878 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,16 @@ 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 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); - content_aware(surf, sel, doc_bounds, 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}`"))), }; @@ -225,13 +232,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 +258,24 @@ 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) +} + +/// 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, + 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 +291,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` @@ -398,7 +415,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(()) })?; @@ -409,7 +426,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, } @@ -417,13 +435,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/cmd/float_cmds.rs b/crates/engine/src/cmd/float_cmds.rs index 19ac191900..bb93f233cb 100644 --- a/crates/engine/src/cmd/float_cmds.rs +++ b/crates/engine/src/cmd/float_cmds.rs @@ -174,9 +174,10 @@ fn cut_piece(base: &Surface, sel: &Surface, b: photocraft_geom::Rect, with_alpha { return Ok(Some((tc, t.clone()))); } - let src_bytes = src_tile.map_or(base.default_bytes(), |t| t.bytes()); + let src_guard = src_tile.map(|t| t.bytes()); + let src_bytes: &[u8] = src_guard.as_deref().unwrap_or(base.default_bytes()); let mut out = piece.try_default_tile().map_err(oom)?; - let out_bytes = out.bytes_mut(); + let mut out_bytes = out.bytes_mut(); let mut px = [0.0f32; 8]; let mut has_content = false; for y in tr.y0..tr.y1 { @@ -224,6 +225,7 @@ fn cut_piece(base: &Surface, sel: &Surface, b: photocraft_geom::Rect, with_alpha has_content |= non_zero; } } + drop(out_bytes); Ok(has_content.then(|| (tc, Arc::new(out)))) }) .collect::>>()?; diff --git a/crates/engine/src/cmd/history_cmds.rs b/crates/engine/src/cmd/history_cmds.rs index ba639876a3..d7b3fc8207 100644 --- a/crates/engine/src/cmd/history_cmds.rs +++ b/crates/engine/src/cmd/history_cmds.rs @@ -509,7 +509,7 @@ mod tests { } fn tile_bytes(doc: &Document) -> usize { - doc.layers.iter().filter_map(|l| l.surface()).flat_map(|s| s.tiles().map(|(_, t)| t.bytes().len())).sum() + doc.layers.iter().filter_map(|l| l.surface()).flat_map(|s| s.tiles().map(|(_, t)| t.len())).sum() } /// A Merged Layers snapshot is a flattened copy only it holds (#4530): it counts in the diff --git a/crates/engine/src/cmd/image_cmds.rs b/crates/engine/src/cmd/image_cmds.rs index c8c3e0b7d2..1553d0564f 100644 --- a/crates/engine/src/cmd/image_cmds.rs +++ b/crates/engine/src/cmd/image_cmds.rs @@ -191,7 +191,24 @@ 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 }; + +/// 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 { @@ -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"))?; @@ -238,6 +261,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 +278,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 +316,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 +334,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 @@ -526,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"))?; @@ -983,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),"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 ), @@ -991,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/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_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/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..49f9bdae29 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 } @@ -222,6 +245,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..3d94bcc888 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,16 @@ 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)))) } + // 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())) + } 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}"))), } } 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/cmd/lens_cmds.rs b/crates/engine/src/cmd/lens_cmds.rs index b0061cf89f..1192281744 100644 --- a/crates/engine/src/cmd/lens_cmds.rs +++ b/crates/engine/src/cmd/lens_cmds.rs @@ -283,6 +283,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, p: &Value, 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, p)?; let colors = crate::color_channels::target(s, p)?.is_some(); let mut stored = stored; 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/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/engine/src/cmd/select_extra_cmds.rs b/crates/engine/src/cmd/select_extra_cmds.rs index 8ca012266d..55e2ec10d9 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() } } @@ -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/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/stack_cache.rs b/crates/engine/src/cmd/smart_cmds/stack_cache.rs index 2822f01027..372d30eaab 100644 --- a/crates/engine/src/cmd/smart_cmds/stack_cache.rs +++ b/crates/engine/src/cmd/smart_cmds/stack_cache.rs @@ -95,5 +95,5 @@ pub fn purge() -> usize { pub(super) fn surface_bytes(s: &Surface) -> usize { // Tiles shared with the document (or another stage) are charged anyway: the cache is what // keeps them alive once the document moves on. - s.tiles().fold(s.default_bytes().len(), |n, (_, t)| n.saturating_add(t.bytes().len())) + s.tiles().fold(s.default_bytes().len(), |n, (_, t)| n.saturating_add(t.len())) } 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/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/cmd/transform_cmds.rs b/crates/engine/src/cmd/transform_cmds.rs index 520f61e506..c322da8e26 100644 --- a/crates/engine/src/cmd/transform_cmds.rs +++ b/crates/engine/src/cmd/transform_cmds.rs @@ -641,6 +641,7 @@ pub(crate) fn composite_over(dst: &mut Surface, top: &Surface) { SampleType::F32 => tb.chunks_exact(bpp).all(|px| read_sample(px, SampleType::F32, a) == 1.0), }; if all_opaque { + drop(tb); return Some((tc, top_tile)); } let all_transparent = match fmt.sample { @@ -658,6 +659,7 @@ pub(crate) fn composite_over(dst: &mut Surface, top: &Surface) { SampleType::F32 => tb.chunks_exact(bpp).all(|px| read_sample(px, SampleType::F32, a) > 0.0 || px.iter().all(|&b| b == 0)), }; if clean_zero { + drop(tb); return Some((tc, top_tile)); } } @@ -740,7 +742,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 +758,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 +952,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/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/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/src/cmd/video_cmds.rs b/crates/engine/src/cmd/video_cmds.rs index edc6c7e59d..d5540c1f79 100644 --- a/crates/engine/src/cmd/video_cmds.rs +++ b/crates/engine/src/cmd/video_cmds.rs @@ -263,10 +263,90 @@ 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. +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. -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!( @@ -292,6 +372,7 @@ fn load_frames(path: &str, fmt: photocraft_color::PixelFormat) -> Result, 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 { @@ -299,8 +380,8 @@ fn load_frames(path: &str, fmt: photocraft_color::PixelFormat) -> Result 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(()) @@ -345,15 +426,20 @@ 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, &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(); @@ -410,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)?.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; @@ -446,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)?.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; @@ -471,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)?.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 66566bdb7d..f2acb0bd0f 100644 --- a/crates/engine/src/cmd/video_cmds/tests.rs +++ b/crates/engine/src/cmd/video_cmds/tests.rs @@ -99,6 +99,46 @@ fn write_tiff(path: &str, pages: &[(u32, u32, u8, u32)]) { std::fs::write(path, f).unwrap(); } +/// 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"] { + 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"); + 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]]); + 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] @@ -145,6 +185,121 @@ 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}"); +} + +/// #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"); +} + +/// #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"); @@ -229,7 +384,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"); } @@ -382,8 +537,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/color_channels.rs b/crates/engine/src/color_channels.rs index c0ec66fc6c..c19c7e389c 100644 --- a/crates/engine/src/color_channels.rs +++ b/crates/engine/src/color_channels.rs @@ -163,6 +163,7 @@ fn restrict_surface_impl(before: &Surface, after: &mut Surface, colors: ColorCha let mut merged = before.clone(); for coord in coords { let source = after.tile(coord); + let source_bytes = source.map(|t| t.bytes()); let dest = merged.try_tile_mut(coord).map_err(|e| EngineError::Other(e.to_string()))?; let rect = coord.rect(); let width = rect.width() as usize; @@ -174,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.and_then(|t| t.bytes().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); @@ -229,9 +231,10 @@ fn restrict_coords(before: &Surface, after: &mut Surface, colors: ColorChannels, } for c in coords { let old = before.tile(c); + let old_bytes = old.map(|t| t.bytes()); let tile = after.try_tile_mut(c).map_err(|e| EngineError::Other(e.to_string()))?; for (i, pixel) in tile.bytes_mut().chunks_exact_mut(stride).enumerate() { - let original = old.and_then(|t| t.bytes().get(i * stride..(i + 1) * stride)).unwrap_or(before.default_bytes()); + let original = old_bytes.as_deref().and_then(|t| t.get(i * stride..(i + 1) * stride)).unwrap_or(before.default_bytes()); for (ch, (out, src)) in pixel.chunks_exact_mut(bytes).zip(original.chunks_exact(bytes)).enumerate() { if !colors.contains(ch) { out.copy_from_slice(src); @@ -329,6 +332,7 @@ pub(crate) fn scatter_selected( } .map_err(|e| EngineError::Other(e.to_string()))?; let src = plane.tile(coord); + let src_bytes = src.map(|t| t.bytes()); let default = plane.default_bytes(); let stride = fmt.bytes_per_pixel(); let size = photocraft_geom::TILE_SIZE as usize; @@ -339,7 +343,7 @@ pub(crate) fn scatter_selected( continue; } let offset = i * fmt.sample.bytes(); - let bytes = src.and_then(|t| t.bytes().get(offset..offset + fmt.sample.bytes())).unwrap_or(default); + let bytes = src_bytes.as_deref().and_then(|t| t.get(offset..offset + fmt.sample.bytes())).unwrap_or(default); let v = read_sample(bytes, fmt.sample, 0); let v = if fmt.mode == ColorMode::Cmyk { 1.0 - v } else { v }; write_sample(pixel, fmt.sample, component, v); 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/error.rs b/crates/engine/src/error.rs index 323d3f5203..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))] @@ -18,6 +18,17 @@ 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 }, + /// 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. @@ -27,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/filters.rs b/crates/engine/src/filters.rs index aebee03caf..9e73d10811 100644 --- a/crates/engine/src/filters.rs +++ b/crates/engine/src/filters.rs @@ -259,6 +259,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(); @@ -397,6 +399,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/filters/tests.rs b/crates/engine/src/filters/tests.rs index d88123df52..4d21d2dfdf 100644 --- a/crates/engine/src/filters/tests.rs +++ b/crates/engine/src/filters/tests.rs @@ -79,6 +79,28 @@ const ALL: [&str; 24] = [ "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/filters_ext.rs b/crates/engine/src/filters_ext.rs index 1afc11220e..963e04e729 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":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/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/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index ac9e38a723..ae1b880a64 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,10 +282,21 @@ impl Running { elapsed_ms: now_ms() - self.started_ms, result: None, error: None, + error_kind: None, } } } +/// 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 +312,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, } @@ -330,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")] @@ -347,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}"))) } @@ -367,6 +402,18 @@ 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(); + 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)); let _authorization = AuthorizationScope::enter(authorization); // The job's document: stored paths it follows are resolved against it on any thread. @@ -518,6 +565,76 @@ 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, 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) => { + Ok(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, + } + } + + /// 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), + Err(_) => 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 @@ -533,6 +650,137 @@ impl Session { self.dispatch(id, params, true) } + pub const MAX_PENDING_PREFLIGHTS: usize = 32; + + /// 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; + + /// 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) + } + + /// [`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) { + break; + } + } + preflight.id = self.next_pending_preflight; + self.pending_preflights.push(preflight); + 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) + } + + /// 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; + 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) + } + + /// 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, + 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) + } + + /// [`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, + 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) + } + /// 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. @@ -543,76 +791,175 @@ impl Session { /// 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) + 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) + 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) } /// 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 { + // 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 opts = import_options(self.prefs()); - self.start_job( + let mut opts = import_options(self.prefs()); + opts.psd_high_memory_approved = approve_high_memory; + opts.psd_approved_estimate = approved_estimate; + let test_memory_scope = photocraft_io::memory::TestMemoryScope::capture(); + enum WorkResult { + 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( OPEN_JOB, json!({"name": name}), &label, false, + background, move |ctx| { + let _test_memory = test_memory_scope.enter(); ctx.progress(0.0, "Reading"); - let imported = match source { + + // 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 { 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(Box::new(imported)), + Err(photocraft_io::IoError::PsdApprovalRequired { required, soft_limit, hard_limit, available_ram }) if confirm_damage => { + // 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(crate::error::io_open_error(&name_w, e)), + } + } + OpenSource::Path(p) => { + 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 => { + // 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(crate::error::io_open_error(&name_w, e)), + } + } + }; + + 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 } => { + // 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(), + 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, + "capacityUnknown": capacity_unknown, + "uncertainCapacityWarning": uncertain_capacity, + })) } - 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(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)); + 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})) }, ) } @@ -631,9 +978,27 @@ 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. + /// [`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, + 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)) => { @@ -673,7 +1038,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) } @@ -688,7 +1063,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))), } } @@ -706,8 +1083,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 +1131,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 +1158,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. @@ -781,6 +1170,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". @@ -857,6 +1247,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 +1267,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 +1284,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 +1301,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 @@ -974,17 +1373,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 { @@ -1070,6 +1471,12 @@ impl Session { } fn end_job(&mut self, job: &Running, outcome: JobOutcome) { + 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. self.live_commit = None; @@ -1082,6 +1489,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; } @@ -1090,7 +1498,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..38cca3b206 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] @@ -782,3 +814,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/engine/src/lib.rs b/crates/engine/src/lib.rs index 5e6cc66ab0..65d3eb04ff 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; @@ -36,6 +37,7 @@ pub mod prefs; pub mod preset_store; pub mod presets; pub mod proof_sim; +mod scratch; pub mod select_mask_edits; pub mod shell_commands; pub mod shortcut_sets; @@ -281,6 +283,13 @@ 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, + /// 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/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")]))); + } +} diff --git a/crates/engine/src/pixels.rs b/crates/engine/src/pixels.rs index 906f0b23c6..3f2274cef6 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]; @@ -472,6 +479,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) }; @@ -479,7 +487,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); } @@ -491,10 +499,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 86c4a1f426..a463418ef6 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -295,7 +295,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). 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). @@ -492,6 +493,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 +528,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(), @@ -578,6 +583,9 @@ pub struct Performance { /// Memory PhotoCraft may use, in MB: bounds each document's pixels plus its History (the /// oldest states are dropped beyond it), and the result of one transform. 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, /// Undo steps kept per document (History panel states). pub history_states: u32, /// Cache Levels, 1–8: the highest cache level the Histogram panel counts a large document at @@ -638,6 +646,25 @@ impl Performance { pub fn history_budget_bytes(&self) -> usize { (self.effective_memory_usage_mb() as usize).saturating_mul(1 << 20) } + + /// The byte limit History enforces now: none (only History States) while the scratch disk + /// is spilling, since tiles only History holds go to disk instead; otherwise + /// [`Self::history_budget_bytes`]. + pub fn history_cap_bytes(&self) -> usize { + if photocraft_raster::spill::enabled() { 0 } else { self.history_budget_bytes() } + } + + /// 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.effective_memory_usage_mb()).saturating_mul(1 << 20) / 2); + usize::try_from(bytes).unwrap_or(usize::MAX) + } } impl Default for Performance { @@ -645,6 +672,7 @@ impl Default for Performance { fn default() -> Self { Self { memory_usage_mb: DEFAULT_MEMORY_USAGE_MB, + memory_usage_percent: Some(70), history_states: 50, cache_levels: 4, cache_tile_size: 8192, @@ -674,11 +702,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 } } } @@ -1325,8 +1357,11 @@ impl Session { /// linearity, the transform and layer-effect cache budgets 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()); + self.scratch_epoch = photocraft_raster::spill::epoch(); let n = self.prefs().performance.history_states.max(1) as usize; - let bytes = self.prefs().performance.history_budget_bytes(); + let bytes = self.prefs().performance.history_cap_bytes(); let budget = self.prefs().performance.effect_cache_mb as usize; let non_linear = self.prefs().history_options.allow_non_linear_history; for st in &mut self.docs { @@ -1342,6 +1377,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/prefs/tests.rs b/crates/engine/src/prefs/tests.rs index fc96369ee4..2e4e7ee098 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_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(); + 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/engine/src/prefs/validate.rs b/crates/engine/src/prefs/validate.rs index 37d0fdf022..e474f30dbe 100644 --- a/crates/engine/src/prefs/validate.rs +++ b/crates/engine/src/prefs/validate.rs @@ -40,7 +40,6 @@ pub const HIDDEN_UNTIL_IMPLEMENTED: &[&str] = &[ "workspace.enableFloatingDocumentWindowDocking", "performance.effectCacheMb", "performance.legacyCompositing", - "scratchDisks.disks", "unitsAndRulers.columnWidth", "unitsAndRulers.gutter", "plugIns.showExtensionPanels", @@ -119,6 +118,7 @@ pub fn range(path: &str) -> Option<(f64, f64)> { "export.jpegQuality" | "export.webpQuality" => (1.0, 100.0), "performance.memoryUsageMb" => (256.0, 1_048_576.0), "performance.gpuMemoryMb" => (0.0, 1_048_576.0), + "performance.memoryUsagePercent" => (1.0, 95.0), "performance.historyStates" => (1.0, 1000.0), "performance.cacheLevels" => (1.0, 8.0), "performance.cacheTileSize" => (256.0, 16384.0), @@ -231,7 +231,7 @@ pub(super) fn check_value(path: &str, v: &Value) -> std::result::Result<(), Stri 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) { diff --git a/crates/engine/src/scratch.rs b/crates/engine/src/scratch.rs new file mode 100644 index 0000000000..6e21d9c1d7 --- /dev/null +++ b/crates/engine/src/scratch.rs @@ -0,0 +1,146 @@ +//! 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, + // 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"))] + { + 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 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() + } 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")] + let _ = (prefs, budget); +} 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/session/documents.rs b/crates/engine/src/session/documents.rs index a82f6c18e6..8b23a7110b 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -102,15 +102,23 @@ 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.get(target).ok_or(EngineError::NoDocument)?; + 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) } @@ -125,7 +133,7 @@ impl Session { self.open_document_order.push(doc.id); let mut st = DocState::new(doc, path); st.history.max_states = self.prefs.get().performance.history_states.max(1) as usize; - st.history.max_bytes = self.prefs.get().performance.history_budget_bytes(); + st.history.max_bytes = self.prefs.get().performance.history_cap_bytes(); st.history.non_linear = self.prefs.get().history_options.allow_non_linear_history; self.docs.push(st); let i = self.docs.len() - 1; diff --git a/crates/engine/src/session/execute.rs b/crates/engine/src/session/execute.rs index 2c7ef1ba1c..d7916fe548 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,11 +181,21 @@ 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); - 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; } @@ -207,15 +227,21 @@ impl Session { // through this entry; the requesting session's authority must reach those renders. 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(); + let scratch_generation = photocraft_raster::spill::read_error_generation(); let restrict = self.color_restrict; let filter_mask_layer = self.filter_mask_layer; let sampler_map = self.sampler_map.take(); 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(); + 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)?; @@ -225,6 +251,11 @@ 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).and_then(|()| before.scratch_health()).and_then(|()| doc.scratch_health()) + { + 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/tests.rs b/crates/engine/src/tests.rs index bae9cf4c92..599d0ce2d6 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)); @@ -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})); @@ -1757,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"); } diff --git a/crates/engine/tests/atn_mapped_identifiers.rs b/crates/engine/tests/atn_mapped_identifiers.rs new file mode 100644 index 0000000000..df643b2608 --- /dev/null +++ b/crates/engine/tests/atn_mapped_identifiers.rs @@ -0,0 +1,789 @@ +//! 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; + +/// 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.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)); + } + } +} + +/// 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})); + } +} + +/// 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() { + 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), ("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)) + .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(); + 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| { + 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(); + 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() { + 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); +} + +/// 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)); + } +} + +/// 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]); + 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]); + } + 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'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_load_the_canvas() { + 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 (id, params) = &action.steps[0]; + let result = s.execute(id, params.clone()); + 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(); + assert_eq!(s.active().unwrap().doc.selection.as_ref().unwrap().read_region(Rect::from_xywh(0, 0, 2, 1)), vec![1.0, 0.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() { + 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) } +} + +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![], + encoding: None, + }) + .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 +} + +/// 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, 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}))]); + let written = saved(&action); + assert_eq!(written.event, AtnEvent::name("vibrance"), "Vibrance must save by its string event"); + // 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(); + 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") }; + // 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(); + 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.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(); + } +} + +/// 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","dither":false,"flatten":false,"rasterizePlaced":false}))] + ); + 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()); +} 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..796982207f --- /dev/null +++ b/crates/engine/tests/content_aware_fill_atn.rs @@ -0,0 +1,189 @@ +//! #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); 4] = [ + ("contentAwareColorAdaptationFill", "colorAdaptation"), + ("contentAwareScaleFill", "scaleAdaptation"), + ("contentAwareMirrorFill", "mirror"), + ("contentAwareRotateFill", "rotationAdaptation"), +]; +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![], + encoding: None, + }) + .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()) +} + +/// 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")] { + 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)); + 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"); + } + 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"); + 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(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); + 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"); + } + } +} + +/// 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(); + 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", "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); + 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 { + if key != ROTATION { + assert!(d.get(key).is_none()); + } + } + 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")] { + 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()); + } + } +} + +/// Malformed flags remain intact as unsupported Photoshop steps. +#[test] +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 { + for value in [D::Integer(0), D::Text(UnicodeString::new_nul("false"))] { + bad.push(descriptor().with(key, value)); + } + } + 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); + } + } +} 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(); +} 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/convert_profile_atn.rs b/crates/engine/tests/convert_profile_atn.rs new file mode 100644 index 0000000000..cf1f91b6de --- /dev/null +++ b/crates/engine/tests/convert_profile_atn.rs @@ -0,0 +1,287 @@ +//! 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![], + encoding: None, + }) + .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() +} + +/// Preserve absolute intent and keep unrepresentable intent settings as complete steps. +#[test] +fn absolute_and_unknown_intents_remain_distinct_with_conversion_flags() { + for code in ["AClr", "AbsC", "futureIntent"] { + 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 = 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(); + if code == "futureIntent" { + assert_eq!(result["unsupported"].as_array().unwrap().len(), 1); + assert_eq!(saved, &d); + } else { + 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)); + // 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:?}"); + } + } +} + +/// 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 +} + +/// 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).into_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(-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)); + 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(-1))); + 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(-1))); + } + 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(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(-1)).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()); +} + +/// 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 new file mode 100644 index 0000000000..a8c92e2e5b --- /dev/null +++ b/crates/engine/tests/convert_profile_dither.rs @@ -0,0 +1,229 @@ +//! 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().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]); + 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().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())); + 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()); + } +} + +/// 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/convert_profile_options.rs b/crates/engine/tests/convert_profile_options.rs new file mode 100644 index 0000000000..a64d9c88f3 --- /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).into_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})); + } + } +} 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); + } +} 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..4f65bcc34e --- /dev/null +++ b/crates/engine/tests/image_size_scale_styles_atn.rs @@ -0,0 +1,172 @@ +//! #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, Id, 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![], + encoding: None, + }) + .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 }); +} + +/// 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"] { + 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(); + // 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); + 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); + } + } +} + +/// 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"))] { + 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"), 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); + 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); + } +} + +/// 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"] { + 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); + } + } +} + +/// 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/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_reference_forms.rs b/crates/engine/tests/it/atn_reference_forms.rs new file mode 100644 index 0000000000..0e35280eb7 --- /dev/null +++ b/crates/engine/tests/it/atn_reference_forms.rs @@ -0,0 +1,511 @@ +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"); + 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. +#[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(); + 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); + 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); + } +} + +/// 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()); +} + +/// 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() { + 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()); + } +} + +/// 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); +} 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/atn_v12.rs b/crates/engine/tests/it/atn_v12.rs new file mode 100644 index 0000000000..d5bc6d8263 --- /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/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})), + ("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..467b04720d 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -3,9 +3,11 @@ 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; +mod atn_v12; mod background_and_mask_depth; mod background_layer_consistency; mod brush_smoothing; 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/memory_prefs.rs b/crates/engine/tests/memory_prefs.rs new file mode 100644 index 0000000000..a21affa91d --- /dev/null +++ b/crates/engine/tests/memory_prefs.rs @@ -0,0 +1,27 @@ +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); + // Main's GPU budget: 0 selects the automatic limit. + p.set("performance.gpuMemoryMb", json!(0)).unwrap(); + assert_eq!(p.performance.gpu_memory_mb, 0); +} diff --git a/crates/engine/tests/paste_style_options.rs b/crates/engine/tests/paste_style_options.rs new file mode 100644 index 0000000000..af0f131dd8 --- /dev/null +++ b/crates/engine/tests/paste_style_options.rs @@ -0,0 +1,266 @@ +//! 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![], + encoding: None, + 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()); + } +} diff --git a/crates/engine/tests/scratch_disk.rs b/crates/engine/tests/scratch_disk.rs new file mode 100644 index 0000000000..55a361133e --- /dev/null +++ b/crates/engine/tests/scratch_disk.rs @@ -0,0 +1,119 @@ +//! 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_cap_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 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 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()); + // 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(); + 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, healthy, loaded, writer, file)); + spill::configure(None, 0).unwrap(); + let _ = std::fs::remove_dir_all(dir); +} diff --git a/crates/engine/tests/scratch_pause.rs b/crates/engine/tests/scratch_pause.rs new file mode 100644 index 0000000000..3b694f2da2 --- /dev/null +++ b/crates/engine/tests/scratch_pause.rs @@ -0,0 +1,60 @@ +//! 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()); + 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; + 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/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/engine/tests/vector_paste_options.rs b/crates/engine/tests/vector_paste_options.rs new file mode 100644 index 0000000000..0482a5ab99 --- /dev/null +++ b/crates/engine/tests/vector_paste_options.rs @@ -0,0 +1,372 @@ +//! 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![], + encoding: None, + 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!([])); + // `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(); + 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; + 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()); + } +} + +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/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/atomic.rs b/crates/format/src/atomic.rs index 8cd349efff..de28655f08 100644 --- a/crates/format/src/atomic.rs +++ b/crates/format/src/atomic.rs @@ -35,7 +35,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}; @@ -56,6 +56,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. @@ -74,6 +83,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; @@ -95,6 +111,16 @@ 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); + // `AlreadyExists` means a temporary name collided, which makes the caller retry with + // another name and leave this one alone: from the writer, once the file is ours, it is + // a plain failure (the partial file is removed, the writer is not run again). + write(&mut out).map_err(|e| if e.kind() == io::ErrorKind::AlreadyExists { io::Error::other(e) } else { e })?; + 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) } @@ -207,6 +233,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() { @@ -223,7 +318,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 c768044e06..2baba46fac 100644 --- a/crates/format/src/atomic/tests.rs +++ b/crates/format/src/atomic/tests.rs @@ -148,6 +148,119 @@ 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 writer failing with `AlreadyExists` (from whatever it reads) is a failed save, not a +/// temporary name collision: it runs once and its partial file is removed. +#[test] +fn streamed_write_failing_with_already_exists_runs_once() { + let d = TempDir::new("stream-exists"); + let p = d.0.join("doc.psd"); + std::fs::write(&p, ORIGINAL).unwrap(); + let runs = Cell::new(0); + let e = atomic_write_stream(&p, &mut |w| { + runs.set(runs.get() + 1); + w.write_all(&[1; 70_000])?; + Err(io::Error::new(io::ErrorKind::AlreadyExists, "injected")) + }) + .unwrap_err(); + assert_eq!(runs.get(), 1); + assert!(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/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..8ea8c5cc3e 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 { @@ -369,7 +367,7 @@ impl Loader<'_> { if let Some(tile) = self.tiles.get(&f.sample).and_then(|m| m.get(&t.hash)) { // One hash names one byte string, so a cached tile of another length means // the same hash was referenced from formats of different pixel sizes. - if tile.bytes().len() != len { + if tile.len() != len { return Err(FormatError::corrupt(format!("tile {} is referenced with different sizes", t.hash))); } s.put_tiles([(c, tile.clone())]); diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 992e925ae9..696b52cb72 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) //! ``` //! @@ -40,7 +41,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}; @@ -50,7 +51,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 +77,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 @@ -84,6 +99,12 @@ 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, 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>, } /// Limits applied while loading untrusted bundles. @@ -119,10 +140,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, @@ -197,3 +219,14 @@ pub fn read_thumbnail(bytes: &[u8]) -> Result>> { None => Ok(None), } } + +/// The embedded full-size composite (PNG bytes): `None` when the manifest has no +/// `full_composite` member. A member that names any entry other than `composite/full.png` is +/// refused. +pub fn read_full_composite(bytes: &[u8]) -> Result>> { + let src = store::ZipSource::new(bytes)?; + match store::read_full_composite_entry(&src, &LoadOptions::default())? { + Some(p) => Ok(Some(src.get(p, bytes.len())?)), + None => Ok(None), + } +} diff --git a/crates/format/src/manifest.rs b/crates/format/src/manifest.rs index 1164bc0a68..5d725b6bf2 100644 --- a/crates/format/src/manifest.rs +++ b/crates/format/src/manifest.rs @@ -14,24 +14,13 @@ 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`]. A save with the full-size +/// composite adds a `full_composite` member after the envelope; [`crate::read_full_composite`] +/// reads it. +pub type Manifest = craft_pcraft_format::Manifest; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct DocM { @@ -154,13 +143,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 af6678088b..309151d52d 100644 --- a/crates/format/src/store.rs +++ b/crates/format/src/store.rs @@ -15,23 +15,108 @@ 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}; +/// The flattened document at its canvas size, a PNG in [`COMPOSITE_DIR`], named by the manifest's +/// `full_composite` member. +pub(crate) const FULL_COMPOSITE: &str = "composite/full.png"; +/// The manifest member that names [`FULL_COMPOSITE`]. +const FULL_COMPOSITE_MEMBER: &str = "full_composite"; +/// 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); /// 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 { + Tile(Arc), + Blob(Arc>), + /// A little-endian copy of a tile (big-endian hosts only). + Owned(Vec), } -fn compress(data: &[u8]) -> Vec { - ruzstd::encoding::compress_to_vec(data, ruzstd::encoding::CompressionLevel::Fastest) +struct SourceReader { + source: Source, + pos: usize, +} + +impl SourceReader { + fn len(&self) -> usize { + match &self.source { + Source::Tile(t) => t.len(), + Source::Blob(b) => b.len(), + Source::Owned(v) => v.len(), + } + } +} + +/// Copies the source bytes from `pos` into `buf`, returning how many were copied. +fn read_from(all: &[u8], pos: usize, buf: &mut [u8]) -> usize { + let rest = all.get(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); + } + n +} + +impl Read for SourceReader { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + // A tile's bytes are borrowed only for this call, so its scratch-disk backing may + // page out between reads (a later read reloads it). + let n = match &self.source { + Source::Tile(t) => read_from(&t.bytes(), self.pos, buf), + Source::Blob(b) => read_from(b, self.pos, buf), + Source::Owned(v) => read_from(v, self.pos, buf), + }; + 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).to_vec()), + 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 @@ -59,16 +144,31 @@ fn hash_bytes(b: &[u8]) -> Hash { } fn png(img: &Rgba8Image) -> 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}")))?; + png_tagged(img, None) +} + +/// [`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 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}")))? + .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 +177,10 @@ fn previews(opts: &SaveOptions) -> Result)>> { if let Some(t) = composite? { out.push((COMPOSITE, t)); } + // 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) } @@ -149,64 +253,64 @@ 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; +/// 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 +} -/// 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; +/// The manifest as a save writes it: `manifest`, followed by a `full_composite` member naming +/// [`FULL_COMPOSITE`] when `full_composite` is set. Without it the bytes are `manifest`'s own. +fn manifest_json(manifest: &Manifest, full_composite: bool) -> Result> { + #[derive(serde::Serialize)] + struct WithFullComposite<'a> { + #[serde(flatten)] + manifest: &'a Manifest, + full_composite: &'static str, + } + // 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 = if full_composite { + serde_json::to_vec_pretty(&WithFullComposite { manifest, full_composite: FULL_COMPOSITE })? + } else { + serde_json::to_vec_pretty(manifest)? + }; + // The nesting and the items only: a save checks no manifest size. + craft_pcraft_format::check_manifest(&json, &manifest_limits(usize::MAX))?; + 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. -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"))); - } - } +/// The entry the manifest's `full_composite` member names: `None` when the member is missing or +/// `null`. A member that names any entry other than [`FULL_COMPOSITE`] is refused. +pub(crate) fn read_full_composite_entry(src: &dyn ByteSource, opts: &LoadOptions) -> Result> { + let raw = src.get(MANIFEST, opts.max_manifest_bytes)?; + let manifest = craft_pcraft_format::parse_manifest(&raw, &manifest_limits(opts.max_manifest_bytes))?; + match manifest.get(FULL_COMPOSITE_MEMBER) { + None | Some(serde_json::Value::Null) => Ok(None), + Some(serde_json::Value::String(entry)) if entry == FULL_COMPOSITE => Ok(Some(FULL_COMPOSITE)), + Some(other) => Err(FormatError::corrupt(format!("`{FULL_COMPOSITE_MEMBER}` names {other}, not {FULL_COMPOSITE:?}"))), } - Ok(()) } 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) { @@ -263,11 +367,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 { @@ -288,10 +391,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))?; @@ -410,13 +512,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()) } } @@ -518,25 +639,49 @@ struct FileSignature { struct Prepared { manifest: Vec, + /// What `manifest` encodes, for a ZIP save that has to leave the full-size composite out. + model: Manifest, + /// Whether `manifest` names the full-size composite. + full_composite: bool, objects: BTreeMap, 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) +} + +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.full_composite && !previews.iter().any(|(name, _)| *name == FULL_COMPOSITE) { + self.full_composite = false; + self.manifest = manifest_json(&self.model, false)?; + self.stats.manifest_bytes = self.manifest.len(); + } + Ok(()) + } +} + enum Object { Tile(Arc, SampleType), Blob(Arc>), } 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). - 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()), } } } @@ -607,7 +752,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() { @@ -627,7 +772,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)?; @@ -784,9 +929,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 }) } @@ -795,9 +940,10 @@ 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), + 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}"))), }, }; @@ -860,7 +1006,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 +1015,11 @@ fn par_compress<'a>(objects: &[(&'a String, &'a Object)]) -> Result Result { + 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) { + // 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); self.blob_hash_cache.retain(|_, (w, _)| w.strong_count() > 0); 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()), - }; - // 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 mut model = Manifest::new(format!("photocraft-format {}", env!("CARGO_PKG_VERSION")), document); + model.thumbnail = opts.thumbnail.as_ref().map(|_| THUMB.to_owned()); + model.composite = opts.composite.as_ref().map(|_| COMPOSITE.to_owned()); + let full_composite = opts.full_composite.is_some(); + let manifest = manifest_json(&model, full_composite)?; 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 }) + doc.scratch_health().map_err(FormatError::corrupt)?; + Ok(Prepared { manifest, model, full_composite, 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)> { + 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( - 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)?; @@ -959,23 +1119,12 @@ impl PcraftWriter { Ok((p, fresh)) }, ); - let (p, mut fresh) = prepared?; - let previews = previews?; + let (mut p, mut fresh) = prepared?; + let mut previews = previews?; + p.match_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()); + let mut compressor: Option = None; for (path, obj) in &p.objects { let (key, data) = match self.compressed.remove_entry(path) { Some((key, data)) => { @@ -989,15 +1138,59 @@ 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))))) } }; + entries.push((path, key, data)); + } + // 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; + if (total > limit.0 || 1 + previews.len() + entries.len() > limit.1) + && let Some(at) = previews.iter().position(|(name, _)| *name == FULL_COMPOSITE) + { + previews.remove(at); + manifest = manifest_json(&p.model, false)?; + 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 = 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); + manifest = manifest_json(&p.model, false)?; + 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)?; + } + 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(); next.insert(key.unwrap_or_else(|| path.clone()), data); } } + scratch_ok(doc, generation)?; self.compressed = next; Ok((z.finish()?, stats)) } @@ -1012,6 +1205,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). @@ -1019,11 +1213,12 @@ 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)?; + 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() }); @@ -1036,6 +1231,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 +1266,9 @@ impl PcraftWriter { Object::Tile(..) => stats.tiles_written += 1, Object::Blob(_) => stats.blobs_written += 1, } - write_bundle_file(dir, &object_path, &obj.compressed(), fs)?; + let compressed = obj.compressed(compressor.get_or_insert_with(Compressor::new)); + 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 { match written { @@ -1095,21 +1293,22 @@ 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)?; } } + 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. - 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); } @@ -1256,35 +1455,102 @@ 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_full_composite(&bytes).unwrap(), opts.full_composite.as_ref().map(|i| png(i).unwrap())); } - 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"))); + // 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_full_composite(&bytes).unwrap(), 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); - 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 whole archive, central directory included, and how many entries it holds: what the + // limit compares. + 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)] { + 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() { @@ -1421,6 +1687,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,10 +1740,10 @@ 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); + 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..062a755bf0 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); @@ -490,7 +497,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 +509,45 @@ 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(); + assert!(read_full_composite(&bytes).unwrap().is_some()); + assert_eq!(read_manifest(&bytes).unwrap().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_full_composite(&older).unwrap(), None); + // A member that names any other entry is refused, and the document still opens. + manifest["full_composite"] = serde_json::Value::from("thumb.png"); + 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") { + z.add(&e.name, &r.read(e, usize::MAX).unwrap()).unwrap(); + } + let other = z.finish().unwrap(); + assert!(read_full_composite(&other).is_err()); + assert_eq!(load_from_bytes(&other).unwrap(), doc); + 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); @@ -524,6 +570,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()), ..Default::default() }] { + 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"")); @@ -911,7 +973,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 +993,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 +1024,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/format/tests/scratch_retry.rs b/crates/format/tests/scratch_retry.rs new file mode 100644 index 0000000000..3d8a32f540 --- /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 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 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/convert.rs b/crates/gpu/src/convert.rs index 9dbfc81814..1c04f03e28 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,11 +160,13 @@ 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.len()); + out.extend_from_slice(&t.bytes()); + return out; } let fmt = surface.format(); 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; } @@ -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/src/tests.rs b/crates/gpu/src/tests.rs index 281c67421e..397dcb0124 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -303,7 +303,7 @@ fn sixteen_and_thirty_two_bit_fast_paths_match_the_general_one() { 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/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/Cargo.toml b/crates/io/Cargo.toml index e58875b959..1c6a7324d6 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, estimate charges and verification across crates. +testgen = [] [dependencies] photocraft-geom = { workspace = true } 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/exr_native.rs b/crates/io/src/exr_native.rs new file mode 100644 index 0000000000..af76690056 --- /dev/null +++ b/crates/io/src/exr_native.rs @@ -0,0 +1,98 @@ +//! 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}; + +/// 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::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(_) => return Ok(None), + }; + 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)); + ram_tile_capacity( + 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::OutOfMemory)?; + let mut surface = Surface::new(fmt); + let mut done = 0; + loop { + ctl.check().map_err(|_| IoError::Cancelled)?; + 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; + ram_tile_capacity( + 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); + 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(); + } + 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/flat.rs b/crates/io/src/flat.rs index 8e169bd556..3735514514 100644 --- a/crates/io/src/flat.rs +++ b/crates/io/src/flat.rs @@ -13,18 +13,62 @@ 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() +} + +/// 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()) { + hook(); + } +} + +/// No-op outside tests: production decodes have no pre-composite 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) { - return import_tiff_page_with_limit(name, bytes, None, pixel_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 @@ -234,47 +278,145 @@ 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, plan.tiles, 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, `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 +/// 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, + 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, 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 + // `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); @@ -284,11 +426,13 @@ 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 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); @@ -297,6 +441,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); @@ -326,6 +471,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. @@ -452,9 +631,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) @@ -813,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) } @@ -1388,7 +1574,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)); } @@ -1404,7 +1590,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") }; @@ -1650,4 +1836,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, 0, &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, 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, 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, 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"); + } + + /// 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, 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 b3135df1ad..d133832ce2 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -45,6 +45,8 @@ mod channel_map; pub mod comps_map; pub mod doc_path; pub mod effects_map; +#[cfg(any(unix, windows))] +mod exr_native; pub mod exr_parts; mod flat; mod gradient_bake; @@ -64,10 +66,11 @@ pub mod pdf; mod pdn; pub use pcraft_cache::PcraftExportCache; pub mod pixels; -mod psd_budget; +pub mod psd_budget; mod psd_export; +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; @@ -88,8 +91,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}; @@ -115,6 +118,16 @@ 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 }, + /// 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}")] @@ -159,6 +172,72 @@ 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)?) + } + + /// 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()?; + #[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. @@ -204,6 +283,68 @@ 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 exact length [`Self::write_to`] writes. Fails, as the write would, for a PSD/PSB + /// model its writer refuses. + pub fn encoded_len(&self) -> Result { + match self { + Encoded::Bytes(b) => Ok(b.len() as u64), + Encoded::Psd(file) => Ok(file.encoded_len()?), + } + } + + /// 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 @@ -229,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. @@ -251,6 +399,11 @@ 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`: 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 ([`photocraft_format::read_full_composite`] + /// reads it). Off by default. + pub pcraft_full_composite: bool, } impl Default for ExportOptions { @@ -259,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, @@ -266,6 +420,7 @@ impl Default for ExportOptions { document_path: None, pcraft_cache: None, read_linked_files: true, + pcraft_full_composite: false, } } } @@ -348,9 +503,16 @@ 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; 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, + /// 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 { @@ -358,7 +520,9 @@ 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, + psd_approved_estimate: None, } } } @@ -387,11 +551,16 @@ 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(); + 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)) } @@ -401,29 +570,83 @@ fn import_psd( name: &str, ctl: &photocraft_raster::Interrupt, damaged: &mut bool, - pixel_limit: u64, + 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(|()| psd_budget::check(file, pixel_limit)); - if let Err(error) = checked { - *preflight_error.borrow_mut() = Some(error); - return Err(PsdError::LimitExceeded("PSD import preflight refused the file")); + let preflight_check: Result = (|| { + depth?; + let est = psd_budget::estimate(file)?; + + if psd_gate::is_holding_admission() { + // 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)); + } + + // 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, + est.tile_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, + est.tile_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")) + } } - Ok(psd_import::merged_needed(file)) }, &psd_import::can_recover_layers, ); + if let Some(error) = preflight_error.into_inner() { return Err(error); } @@ -434,6 +657,7 @@ fn import_psd( })?; ctl.check().map_err(|_| IoError::Cancelled)?; ctl.progress(0.05); + // 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)?; @@ -451,44 +675,135 @@ fn import_psd( /// ([`PsdFile::from_source`]) rather than read whole: its channel data is read layer by layer as /// it is decoded, so opening it needs memory for the document, not for the document plus the /// file. A read that fails meanwhile (the file is truncated or rewritten, its drive goes away) -/// fails the open. Other formats are read whole. +/// fails the open. A single-part flat scanline EXR is decoded one compression block at a time +/// into tiles, so its size is bounded by the tile budget (or the scratch disk), not by a +/// whole-file buffer and a decoded image. Other formats are read whole. #[cfg(any(unix, windows))] pub fn import_file_for_open_with( name: &str, 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 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)?; + // A flat scanline EXR is decoded block by block straight into tiles; other EXR + // layouts, and EXR headers the band decoder refuses, are read whole below. + let mut streamed = None; + if !has_extension(name, photocraft_format::EXTENSION) && photocraft_codecs::detect(&magic) == Some(photocraft_codecs::Format::OpenExr) { + let before = photocraft_raster::spill::read_error_generation(); + if let Some(mut result) = exr_native::import(&mut file, ctl)? { + // A rewrite in place while the blocks were read must not become the document. + if FileIdentity::of_handle(&watch).map_err(read_failed)? != identity { + return Err(IoError::Read(format!("{}: the file changed while it was being opened; open it again", path.display()))); + } + photocraft_raster::spill::check_since(before).map_err(IoError::Read)?; + ctl.check().map_err(|_| IoError::Cancelled)?; + result.document.name = name.to_string(); + streamed = Some(result); + } else { + std::io::Seek::rewind(&mut file).map_err(read_failed)?; + } + } + match streamed { + Some(result) => Ok((result, false)), + None => { + let bytes = 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_limit(name, photocraft_psd::Source::new(source), ctl, opts.psd_max_pixel_bytes.min(DEFAULT_PSD_IMPORT_PIXEL_BYTES)) + result } /// [`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_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)) +} + +/// [`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, source: photocraft_psd::Source, ctl: &photocraft_raster::Interrupt, pixel_limit: u64, ) -> 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))?; - 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. @@ -508,9 +823,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| { - 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); @@ -543,11 +856,9 @@ 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| { - 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.min(DEFAULT_PSD_IMPORT_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`). @@ -597,10 +908,36 @@ 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 r = checked_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. Every pixel is read from the document (scratch included, and checked as +/// [`export_with`] checks it) before this returns: the model holds the encoded channels, never a +/// reference to the document's tiles. 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 { + let r = checked_stages(doc, name_or_ext, opts, ctl)?; + ctl.check().map_err(|_| IoError::Cancelled)?; + ctl.progress(1.0); + Ok(r) +} + +/// [`export_stages`] refused when the document's scratch was, or became, unreadable while its +/// pixels were read (previews included): their placeholders must never be saved. +fn checked_stages(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(); + 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)?; Ok(r) } @@ -612,14 +949,18 @@ 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; +/// 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; -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())); @@ -636,10 +977,10 @@ fn export_stages(doc: &Document, name_or_ext: &str, opts: &ExportOptions, ctl: & )); } 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); @@ -649,12 +990,19 @@ 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)?, }; - return Ok(ExportResult { bytes, warnings: Vec::new() }); + return Ok(ExportResult { bytes, warnings: Vec::new() }.into()); } if ext == "psd" || ext == "psb" { let o = PsdExportOptions { @@ -664,6 +1012,9 @@ fn export_stages(doc: &Document, name_or_ext: &str, opts: &ExportOptions, ctl: & read_linked_files: opts.read_linked_files, ..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)?; @@ -671,8 +1022,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()); @@ -683,14 +1034,62 @@ 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 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 +/// 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() +} + +/// 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()) > 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) }; + 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 @@ -765,14 +1164,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)] @@ -816,6 +1215,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"; @@ -841,6 +1287,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 94d34aa902..c9b8c54378 100644 --- a/crates/io/src/memory.rs +++ b/crates/io/src/memory.rs @@ -8,20 +8,461 @@ 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; +/// 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; + +/// 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 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 { + /// 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, +} + +#[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) }; + /// 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 }; + + /// 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"))] + { + 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; + } + } + + 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 } + } +} + +/// 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, + /// 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. +#[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, 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 { + BudgetDecision::RequiresApproval + } else { + 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, +} + +/// 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 } +} + +/// 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: +/// - 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. +/// - 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 { + 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) { + hard = hard.min(caller); + } + return MemoryBudget { soft_limit: WASM_BUDGET.min(hard), hard_limit: hard, available_ram: None, warning_threshold: None }; + } + + 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); + 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(platform_cap); + + let s_pct = a / 2; + let s = s_pct.min(MAX_SOFT_BUDGET).min(h); + (h, s, None) + } + None => { + let mut h = platform_cap; + if let Some(c) = snapshot.commit_headroom { + let c_room = c.saturating_sub(COMMIT_RESERVE); + h = h.min(c_room); + } + let s = NATIVE_FALLBACK_BUDGET.min(h); + (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); + soft = soft.min(hard); + } + + MemoryBudget { soft_limit: soft, hard_limit: hard, available_ram: snapshot.available_ram, warning_threshold } +} + +/// 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, +} + +/// 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). +/// +/// `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, 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_credited(platform, snapshot, limit_opt, scratch_credit_for(platform, spillable.min(required))); + + 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, + }); + } + 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 }) +} + +/// 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. 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 @@ -39,51 +480,167 @@ pub(crate) fn check_fits(w: u32, h: u32, needed: u64, available: Option) -> ))) } -#[cfg(target_os = "linux")] -mod linux { - use std::path::{Path, PathBuf}; +/// 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); +} - 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)))) +/// 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 { + 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) { + MOCK_SNAPSHOT.with(|m| *m.borrow_mut() = self.prev); + } } - /// 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) + let _reset = Reset { prev }; + f() +} + +/// 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()); + } } - /// 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('/'))) + let _reset = Reset { prev }; + 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. +/// +/// 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, + /// 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. +pub struct TestMemoryGuard { + #[cfg(any(test, feature = "testgen"))] + prev: Option, + #[cfg(any(test, feature = "testgen"))] + 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.0, self.prev_charge.1); + MOCK_SCRATCH.with(|m| m.set(self.prev_scratch)); + } +} + +impl TestMemoryScope { + /// 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. + pub fn capture() -> Self { + #[cfg(any(test, feature = "testgen"))] + { + let mock = MOCK_SNAPSHOT.with(|m| *m.borrow()); + 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")))] + { + Self {} + } } - /// 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))) - }; - 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) + /// Enters this captured mock memory scope, returning a RAII guard that + /// restores the thread's previous mock state and estimate charge 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 + }); + 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")))] + { + TestMemoryGuard {} + } } - /// 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() + /// Executes a closure within this captured mock memory scope. + pub fn run(self, f: impl FnOnce() -> R) -> R { + let _guard = self.enter(); + f() } } @@ -103,47 +660,425 @@ mod tests { assert!(check_fits(10, 10, u64::MAX, Some(u64::MAX)).is_err()); } - #[cfg(target_os = "linux")] #[test] - fn linux_memory_is_read_from_meminfo_and_the_cgroup() { - 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); + 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; - 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(); - assert!(available().is_some()); + // 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); + 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; + 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); + 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) }; + 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); } - #[cfg(target_os = "linux")] - use std::path::Path; + #[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.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); + 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, 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, 0, u64::MAX, true, Some(u64::MAX), MemorySnapshot::UNKNOWN), + Err(IoError::PsdImportBudget { required: u64::MAX, limit: u64::MAX }) + )); + } + + #[test] + 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()); + + 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, 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, 0, false, None, snap).unwrap_err(); + assert!(matches!(err, IoError::PsdApprovalRequired { available_ram: None, .. })); + + 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, 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"); + 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); + + 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); + 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, 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, 0, false, None, snap).unwrap(); + assert_eq!(out.authorized_total, WASM_BUDGET); + + 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, 0, true, Some(approval), snap).unwrap(); + assert_eq!(out.authorized_total, approval); + + 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, 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 = expected_hard.min(native_platform_cap()); + assert_eq!(b.hard_limit, expected_hard); + assert_eq!(b.soft_limit, MAX_SOFT_BUDGET.min(expected_hard)); + } + + #[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, 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, 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, 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, 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, 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, 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; + 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 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)); + } } 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_budget.rs b/crates/io/src/psd_budget.rs index b3c00ed4ef..a80230322d 100644 --- a/crates/io/src/psd_budget.rs +++ b/crates/io/src/psd_budget.rs @@ -16,10 +16,102 @@ 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, + /// 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. +/// +/// 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() +} + +/// [`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 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() +} + +/// The calling thread's current (plain, tile) estimate charges, for an explicit scope to capture. +#[cfg(any(test, feature = "testgen"))] +pub(crate) fn test_estimate_charge() -> (u64, u64) { + (TEST_ESTIMATE_CHARGE.with(|c| c.get()), TEST_TILE_CHARGE.with(|c| c.get())) +} + +/// 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) + } } fn overflow() -> IoError { @@ -49,14 +141,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(()); @@ -66,15 +150,24 @@ 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(()) } } +/// 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 +238,20 @@ 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 (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, tile_bytes: budget.tiles }) +} + +/// 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..9e24676716 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()); @@ -55,9 +54,16 @@ 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, || { + // 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::PsdApprovalRequired { .. } | IoError::PsdImportBudget { .. }) + )); + }); } #[test] @@ -119,9 +125,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 { @@ -192,3 +201,380 @@ 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() { + 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: + // 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() { + 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(); + + 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 { .. }))); + }); +} + +#[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, 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, 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, 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 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; + 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(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 == gb)); + + // Allowance is still 1 GB (failed attempt does not reset or corrupt parent) + assert!(crate::psd_gate::consume_attempt_allowance(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(gb).is_ok()); + 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"); +} + +/// 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_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_export/merged.rs b/crates/io/src/psd_export/merged.rs index 94b7bc4014..c70965dda9 100644 --- a/crates/io/src/psd_export/merged.rs +++ b/crates/io/src/psd_export/merged.rs @@ -12,6 +12,17 @@ pub(super) fn merged_planes(doc: &Document, fmt: &PixelFormat, cmyk: bool, matte if cmyk && let Some(planes) = merged_ink_planes(doc, fmt.sample, matte, ctl) { return planes; } + merged_planes_in_bands(doc, fmt, cmyk, matte, MERGED_BAND_PIXELS, ctl) +} + +/// 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; + +/// [`merged_planes`] (its composite path) 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(); @@ -25,7 +36,8 @@ pub(super) fn merged_planes(doc: &Document, fmt: &PixelFormat, cmyk: bool, matte 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 = i32::try_from((band_pixels / u64::from(canvas.width().max(1))).max(1)).unwrap_or(i32::MAX); + let _ = photocraft_compose::render_bands(doc, canvas, band_rows, |band| -> Result<(), ()> { if ctl.cancelled() { return Err(()); } @@ -270,4 +282,33 @@ mod tests { } } } + + /// #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) 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::new("bands", photocraft_geom::Size::new(300, 1100), ColorMode::Rgb, SampleType::U8); + 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!(photocraft_compose::band_rows_for(300, (photocraft_compose::BAND_PIXELS / 300) as i32) >= 1100, "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"); + } } diff --git a/crates/io/src/psd_gate.rs b/crates/io/src/psd_gate.rs new file mode 100644 index 0000000000..d661166018 --- /dev/null +++ b/crates/io/src/psd_gate.rs @@ -0,0 +1,265 @@ +//! 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. +//! +//! 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; +use std::sync::{Condvar, Mutex, OnceLock}; +use std::thread::ThreadId; +#[cfg(any(test, not(target_arch = "wasm32")))] +use std::time::Duration; + +use crate::IoError; + +struct GateState { + active: bool, + owner: Option, +} + +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())) +} + +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. +/// 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) { + 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(); + } + } +} + +/// 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. +/// +/// `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 == u64::MAX || 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 }) + } + }) +} + +/// 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 +/// 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(crate) fn acquire_decode_admission(ctl: &photocraft_raster::Interrupt) -> Result { + let current_thread = std::thread::current().id(); + 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); + state = next_state; + } + + ctl.check().map_err(|_| IoError::Cancelled)?; + state.active = true; + state.owner = Some(current_thread); + NESTED_DEPTH.with(|d| d.set(1)); + + Ok(AdmissionGuard { _marker: PhantomData }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, Ordering}; + + #[test] + fn reentrant_admission_does_not_deadlock() { + let ctl = photocraft_raster::Interrupt::NONE; + let guard1 = acquire_decode_admission(&ctl).unwrap(); + assert!(is_holding_admission()); + + // Nested admission on the same thread + let guard2 = acquire_decode_admission(&ctl).unwrap(); + assert!(is_holding_admission()); + + drop(guard2); + assert!(is_holding_admission()); + drop(guard1); + assert!(!is_holding_admission()); + + // Can acquire again after release + let _guard3 = acquire_decode_admission(&ctl).unwrap(); + assert!(is_holding_admission()); + } + + #[test] + fn unknown_allowance_during_reentrant_gate_fails_closed() { + let ctl = photocraft_raster::Interrupt::NONE; + let _guard = acquire_decode_admission(&ctl).unwrap(); + // Allowance is None (unknown) + let res = consume_attempt_allowance(100); + assert!(matches!(res, Err(IoError::PsdImportBudget { limit: 0, .. }))); + } + + #[test] + fn nested_allowance_consumption_deducts_and_caps() { + let ctl = photocraft_raster::Interrupt::NONE; + let _guard = acquire_decode_admission(&ctl).unwrap(); + set_active_attempt_budget(5000); + + assert!(consume_attempt_allowance(2000).is_ok()); + assert_eq!(ATTEMPT_ALLOWANCE.with(|a| a.get()), Some(3000)); + + assert!(consume_attempt_allowance(3001).is_err()); + assert!(consume_attempt_allowance(1000).is_ok()); + 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; + let guard = acquire_decode_admission(&ctl).unwrap(); + + let thread_started = Arc::new(AtomicBool::new(false)); + let thread_acquired = Arc::new(AtomicBool::new(false)); + + let t_started = Arc::clone(&thread_started); + let t_acquired = Arc::clone(&thread_acquired); + let handle = std::thread::spawn(move || { + let ctl = photocraft_raster::Interrupt::NONE; + t_started.store(true, Ordering::SeqCst); + let g = acquire_decode_admission(&ctl).unwrap(); + t_acquired.store(true, Ordering::SeqCst); + drop(g); + }); + + while !thread_started.load(Ordering::SeqCst) { + std::thread::yield_now(); + } + // Give background thread time to reach condvar wait + std::thread::sleep(Duration::from_millis(60)); + assert!(!thread_acquired.load(Ordering::SeqCst), "second thread must block while guard held"); + + drop(guard); + handle.join().unwrap(); + assert!(thread_acquired.load(Ordering::SeqCst), "second thread must acquire after release"); + } + + #[test] + fn blocked_wait_aborts_on_cancellation() { + let ctl = photocraft_raster::Interrupt::NONE; + let guard = acquire_decode_admission(&ctl).unwrap(); + + let cancelled = Arc::new(AtomicBool::new(false)); + let c_flag = Arc::clone(&cancelled); + + let flag_for_thread = Arc::clone(&cancelled); + let handle = std::thread::spawn(move || { + let cancelled = || flag_for_thread.load(Ordering::SeqCst); + let progress = |_: f32| {}; + let interrupt = photocraft_raster::Interrupt::new(&cancelled, &progress); + acquire_decode_admission(&interrupt).map(drop) + }); + + // Let thread block on condvar + std::thread::sleep(Duration::from_millis(60)); + // Cancel waiting thread + c_flag.store(true, Ordering::SeqCst); + + let res = handle.join().unwrap(); + assert!(matches!(res, Err(IoError::Cancelled)), "waiting thread must abort with Cancelled"); + + // First thread releases guard + drop(guard); + + // Subsequent acquire succeeds cleanly + let next_guard = acquire_decode_admission(&ctl); + assert!(next_guard.is_ok(), "gate must remain functional after cancelled wait"); + } +} diff --git a/crates/io/src/psd_import.rs b/crates/io/src/psd_import.rs index 6989941d31..12aabf6e8d 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. @@ -1222,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, 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, est.tile_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, @@ -1232,11 +1262,26 @@ 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. - if !preflighted && let Err(error) = crate::psd_budget::check(file, crate::DEFAULT_PSD_IMPORT_PIXEL_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))); - } + let _guard = if !preflighted || !crate::psd_gate::is_holding_admission() { + 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))); + } + }; + 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))); + } + } + } else { + None + }; let (mode, layered, multichannel, depth) = import_format(file); let h = &file.header; let mut warnings = Vec::new(); @@ -1408,6 +1453,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 +1593,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/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/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/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/src/tiff_layers.rs b/crates/io/src/tiff_layers.rs index 664355ef5b..fdb31c93b5 100644 --- a/crates/io/src/tiff_layers.rs +++ b/crates/io/src/tiff_layers.rs @@ -48,19 +48,64 @@ 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, + /// 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 { + /// 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, tiles: 0 }); } }; - 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 +113,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,13 +157,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()); - } - crate::psd_budget::check(&file, pixel_limit - composite)?; + // 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, tiles: est.tile_bytes }) +} + +/// 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: _, tiles: _ } = 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)) @@ -149,11 +248,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); @@ -256,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(), @@ -282,7 +383,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 tiff_image_source(doc, &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); the model keeps its header, resources and composite for the image below. + 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)); @@ -290,7 +394,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; - tiff_image_source(doc, &file).to_bytes(order)? + has_alpha = file.layer_info.as_ref().is_some_and(|l| l.merged_alpha); + tiff_image_source(doc, &mut file, save_transparency && has_alpha).into_bytes(order)? } r => r?, }; @@ -304,26 +409,28 @@ 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_n = usize::from(file.header.channels).saturating_sub(channels); let kept = extra_n.min(codecs::MAX_EXTRA_SAMPLES); 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 @@ -341,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); @@ -351,6 +460,8 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr let cmyk = fmt.mode == ColorMode::Cmyk; let data = interleave(&file.image_data.data, w, h, ifd_channels, cc, sample, cmyk)?; let extras = native_extra_planes(&file.image_data.data, w, h, channels, kept, sample)?; + // 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, write_alpha); @@ -373,26 +484,35 @@ 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. -fn tiff_image_source(doc: &Document, file: &photocraft_psd::PsdFile) -> ImageSourceData { +/// 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. `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. - let mut isd = ImageSourceData::from_psd(file); + // 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) }; - // 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 { + let mut isd = ImageSourceData::take_from_psd(file); + 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 { @@ -418,6 +538,10 @@ fn tiff_image_source(doc: &Document, file: &photocraft_psd::PsdFile) -> ImageSou 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 } @@ -691,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 }; @@ -800,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(); @@ -848,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}")), } @@ -868,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(), @@ -876,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); @@ -990,7 +1146,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 @@ -1009,6 +1165,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(); @@ -1033,4 +1213,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/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/export_progress.rs b/crates/io/tests/it/export_progress.rs index 8f372db4b2..d7c3b48856 100644 --- a/crates/io/tests/it/export_progress.rs +++ b/crates/io/tests/it/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_eq!(r.encoded.encoded_len().unwrap(), want.len() as u64, "{name} {depth:?}: the encoded length"); + 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/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/main.rs b/crates/io/tests/it/main.rs index 49262bc9b1..d9e597297e 100644 --- a/crates/io/tests/it/main.rs +++ b/crates/io/tests/it/main.rs @@ -41,6 +41,7 @@ mod multichannel; mod native_vector_preview; mod orientation; mod pattern_fill_psd; +mod pcraft_full_composite; mod pcraft_manifest_name; mod pdn_corpus; mod png_text_metadata; @@ -72,6 +73,7 @@ mod resolution_axes; mod roundtrip; mod smart_contents_identity; mod smart_corpus; +mod streaming_exr; mod svg; mod text_corpus; mod text_styles; @@ -79,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/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/pcraft_full_composite.rs b/crates/io/tests/it/pcraft_full_composite.rs new file mode 100644 index 0000000000..65acccdcd5 --- /dev/null +++ b/crates/io/tests/it/pcraft_full_composite.rs @@ -0,0 +1,140 @@ +//! 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 png = photocraft_format::read_full_composite(bytes).expect("manifest").expect("the manifest names the full-size composite"); + assert_eq!(png, ZipReader::new(bytes).expect("zip").read_by_name("composite/full.png", 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_full_composite(&bytes).expect("manifest"), 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_full_composite(&bytes).expect("manifest"), 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_full_composite(&bytes).expect("manifest"), None); + assert!(ZipReader::new(&bytes).expect("zip").find("composite/full.png").is_none()); +} 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/streaming_exr.rs b/crates/io/tests/it/streaming_exr.rs new file mode 100644 index 0000000000..3c395bec3f --- /dev/null +++ b/crates/io/tests/it/streaming_exr.rs @@ -0,0 +1,92 @@ +use photocraft_codecs::{ChannelLayout, EncodeOptions, ExrCompression, Format, Image, SampleType}; +use photocraft_doc::LayerContent; +use photocraft_raster::Interrupt; +use std::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() +} + +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() { + 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 actual = open(&path, &Interrupt::NONE).unwrap(); + { + 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!(open(&path, &Interrupt::new(&cancel, &progress)), Err(photocraft_io::IoError::Cancelled))); + std::fs::write(&path, &bytes[..bytes.len() / 2]).unwrap(); + 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()); + 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]); + 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/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/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/ops/src/tiles.rs b/crates/ops/src/tiles.rs index 50243cc8d8..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)`: @@ -53,7 +23,7 @@ pub(crate) fn surfaces(doc: &Document, mut visit: impl FnMut(&Surface)) { pub(crate) fn blocks(doc: &Document, mut visit: impl FnMut(usize, usize)) { surfaces(doc, |surface| { for (_, tile) in surface.tiles() { - visit(Arc::as_ptr(tile) as usize, tile.bytes().len()); + visit(Arc::as_ptr(tile) as usize, tile.len()); } }); for (_, _, layer) in doc.walk() { diff --git a/crates/plugins/src/apply.rs b/crates/plugins/src/apply.rs index 52e3b85826..9e4ddc2266 100644 --- a/crates/plugins/src/apply.rs +++ b/crates/plugins/src/apply.rs @@ -156,7 +156,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()); } } } @@ -227,7 +227,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/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 diff --git a/crates/psd/src/atn.rs b/crates/psd/src/atn.rs index 797845e2fe..0e5e08d699 100644 --- a/crates/psd/src/atn.rs +++ b/crates/psd/src/atn.rs @@ -6,7 +6,7 @@ //! code-unit count + UTF-16BE, usually NUL-terminated) unless noted. //! //! ```text -//! file := version u32 (16), set name UnicodeString, expanded u8, count u32, count × action +//! file := version u32 (12 or 16), set name UnicodeString, expanded u8, count u32, count × action //! action := function key u16 (0 = none, n = Fn), shift u8, command u8, colour u16, //! name UnicodeString, expanded u8, count u32, count × item //! item := expanded u8, enabled u8, with dialog u8, dialog options u8, @@ -20,7 +20,7 @@ //! carry them. The event name and the panel name are byte strings (ASCII, or the platform's //! legacy encoding for a localised panel name: Mac Roman "Définir"), kept verbatim; //! [`AtnItem::label`] gives a readable name. Photoshop 5-era files (version 12) use a -//! different, undocumented layout and are rejected as unsupported. +//! legacy descriptor layout: bare four-byte identifiers and no top-level class or name. //! //! Which legacy encoding is not recorded: Windows Photoshop uses the system code page (GBK in //! Chinese, Shift_JIS in Japanese…), Mac Photoshop the Mac one. [`parse`] works it out once per @@ -30,16 +30,18 @@ //! they are. //! //! [`parse`] never panics: every count is bounds-checked against the bytes left, descriptor -//! nesting is bounded, and allocations are capped. [`write`] produces the same layout, so -//! `parse(write(f)) == f` for files [`parse`] read, and `write(parse(b)) == b` for files Photoshop -//! wrote. +//! nesting is bounded, and allocations are capped. [`write`] always produces version 16, so +//! `parse(write(f)) == f` for files [`parse`] read, and `write(parse(b)) == b` for version 16 files +//! Photoshop wrote; a version 12 file's legacy descriptors keep their meaning, not their layout. use crate::codepage; use crate::descriptor::{Descriptor, UnicodeString}; use crate::error::{PsdError, Result}; use crate::io::{Reader, WriteExt, decode_legacy_name}; -/// The only version this module reads and writes (Photoshop 6 and every later release). +mod legacy; + +/// The version written by this module (Photoshop 6 and every later release). pub const VERSION: u32 = 16; /// Most actions read from one set (Photoshop's own sets hold a few dozen). pub const MAX_ACTIONS: usize = 10_000; @@ -214,7 +216,8 @@ fn write_ascii(out: &mut Vec, s: &[u8]) -> Result<()> { Ok(()) } -fn read_item(r: &mut Reader<'_>) -> Result { +/// 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; let with_dialog = r.u8()? != 0; @@ -227,13 +230,14 @@ 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 { +/// 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; let command = r.u8()? != 0; @@ -248,12 +252,13 @@ 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, working out the encoding of its 8-bit names (see the module docs). +/// Parses a version 12 or 16 `.atn` file, working out the encoding of its 8-bit names (see the +/// module docs). pub fn parse(data: &[u8]) -> Result { parse_with_encoding(data, None) } @@ -274,8 +279,8 @@ pub fn parse_with_encoding(data: &[u8], encoding: Option<&str>) -> Result 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; @@ -287,7 +292,7 @@ fn parse_layout(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() { @@ -544,7 +549,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/src/compression.rs b/crates/psd/src/compression.rs index 08ff28b881..5a49284c44 100644 --- a/crates/psd/src/compression.rs +++ b/crates/psd/src/compression.rs @@ -235,18 +235,6 @@ pub(crate) fn rle_widening(data: &[u8], rows: usize) -> Result { Ok(table as u64) } -/// Writes a PSD RLE stream (two-byte row counts) as the PSB one (four-byte row counts): the packed -/// rows are the same in both versions, only the count table widens. -pub(crate) fn put_widened_rle(out: &mut Vec, data: &[u8], rows: usize) -> Result<()> { - let table_len = usize::try_from(rle_widening(data, rows)?).map_err(|_| PsdError::LimitExceeded("row count overflow"))?; - let (table, packed) = data.split_at_checked(table_len).ok_or_else(|| PsdError::invalid("RLE row-count table out of range"))?; - for count in table.as_chunks::<2>().0 { - out.extend_from_slice(&[0, 0, count[0], count[1]]); - } - out.extend_from_slice(packed); - Ok(()) -} - /// Encodes planar data (excluding the 2-byte compression field). pub fn encode_planes(compression: Compression, decoded: &[u8], layout: &PlaneLayout) -> Result> { let total = layout.decoded_len()?; @@ -379,62 +367,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()); @@ -612,9 +607,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 6009def21f..2e4f87d849 100644 --- a/crates/psd/src/error.rs +++ b/crates/psd/src/error.rs @@ -53,6 +53,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 f2aeb902c0..1e2c60f827 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 crate::compression::{Compression, MAX_DECODED_BYTES, decode_planes, put_widened_rle, rle_widening, validate_planes}; +use std::io::{Seek, Write}; + +use crate::compression::{Compression, MAX_DECODED_BYTES, decode_planes, rle_widening, 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)] @@ -288,27 +290,48 @@ impl PsdFile { /// Serializes the file. pub fn to_bytes(&self) -> Result> { + // Reserve the whole file up front, at its exact length: 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 (#6607). + let len = usize::try_from(self.encoded_len()?).map_err(|_| PsdError::OutOfMemory("not enough memory to serialize the file"))?; + let mut out = Vec::new(); + out.try_reserve_exact(len).map_err(|_| PsdError::OutOfMemory("not enough memory to serialize the file"))?; + 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, widen) = self.write_form(); 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 widened = if widen { self.rle_widening()? } else { 0 }; - let hint = self - .layer_info - .as_ref() - .map_or(0, LayerInfo::channel_data_len) - .saturating_add(usize::try_from(self.image_data.encoded_len()).unwrap_or(usize::MAX)) - .saturating_add(usize::try_from(widened).unwrap_or(usize::MAX)); - let mut out = Vec::new(); - out.try_reserve(hint).map_err(|_| PsdError::OutOfMemory("not enough memory to serialize the file"))?; - if widen { - Header { version: v, ..self.header.clone() }.write(&mut out); - } else { - 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)?; + out.small(|o| { + if widen { + Header { version: v, ..self.header.clone() }.write(o); + } else { + 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 @@ -316,51 +339,54 @@ 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_as(&mut out, v, widen)?; + info.write_body_to(out, v, widen)?; } 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_as(&mut out, v, widen)?; - let len = out.len() - start; + let at = out.begin_len(long)?; + let start = out.pos(); + info.write_body_to(out, v, widen)?; + 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.small(|o| { + o.put_u16(self.image_data.compression.as_u16()); + Ok(()) + })?; + let merged = self.image_data.encoded()?; match self.merged_widened_rows(widen)? { - 0 => out.put(&self.image_data.encoded()?), - rows => put_widened_rle(&mut out, &self.image_data.encoded()?, rows)?, + 0 => out.put(&merged), + rows => out.put_widened_rle(&merged, rows), } - Ok(out) } /// The version the file is written as, and whether its RLE count tables widen on the way @@ -609,6 +635,129 @@ impl PsdFile { mod write_tests { use super::*; use crate::{Compression, testgen}; + use std::io::{Seek, Write}; + + /// #6607: the output is allocated once, at its exact length. A reserve a few bytes short of + /// the file made the last append double the buffer, copying the whole file once more. + #[test] + fn serializing_allocates_the_output_once() { + for case in testgen::all_cases() { + let bytes = case.file.to_bytes().unwrap(); + assert_eq!(bytes.capacity(), bytes.len(), "{}: 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) + } + } + + /// The bytes `file` streams with `stage`-byte staging, after a 3-byte prefix that must stay. + fn streamed(file: &PsdFile, stage: usize) -> Vec { + let mut w = std::io::Cursor::new(b"pre".to_vec()); + w.set_position(3); + file.write_to_staging(&mut w, stage).unwrap(); + let end = w.position(); + let mut bytes = w.into_inner(); + assert_eq!(&bytes[..3], b"pre"); + assert_eq!(end, bytes.len() as u64, "the stream must end at the file's end"); + bytes.drain(..3); + bytes + } + + /// #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] { + assert!(streamed(&case.file, stage) == want, "{} stage {stage}: streamed bytes differ", 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); + } + } + + /// A PSD model written as PSB (its RLE count tables widened on the way out) and a model read + /// lazily from a source (its pixel data not in memory) stream the bytes `to_bytes` writes. + #[test] + fn promoted_and_source_backed_files_stream_the_same_bytes() { + for case in testgen::all_cases() { + let mut promoted = case.file.clone(); + if promoted.header.version == Version::Psd { + promoted.promote_to_psb().unwrap(); + let want = promoted.to_bytes().unwrap(); + for stage in [0, 1, 7, 4096] { + assert!(streamed(&promoted, stage) == want, "{} promoted, stage {stage}", case.name); + } + } + let bytes = case.file.to_bytes().unwrap(); + let lazy = PsdFile::from_source(crate::Source::new(bytes.clone())).unwrap(); + for stage in [0, 1, 4096] { + assert!(streamed(&lazy, stage) == bytes, "{} source-backed, stage {stage}", 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 = ImageData::new(Compression::Raw, vec![5; big]); + let info = file.layer_info.as_mut().unwrap(); + info.layers[0].channels[0] = crate::layer::ChannelData::new(0, Some(Compression::Raw), 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); + // A damaged count table of a promoted file fails the stream as it fails `to_bytes`. + let mut file = testgen::small(Version::Psd, Compression::Rle); + file.promote_to_psb().unwrap(); + file.image_data.data.truncate(3); + let want = file.to_bytes().unwrap_err(); + let e = file.write_to(&mut std::io::Cursor::new(Vec::new())).unwrap_err(); + assert_eq!(PsdError::from_io(e), want); + } /// `encoded_len` is exactly what `to_bytes` writes, for every generated file and both layer /// info placements, with and without padding (#5285). diff --git a/crates/psd/src/io.rs b/crates/psd/src/io.rs index a7c7e3d8ee..eb7185bde2 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,138 @@ 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(()) + } + + /// Appends RLE channel data whose 2-byte row-count table widens to 4 bytes (a PSD model + /// written as PSB): the table is widened in staged pieces, the packed rows go straight + /// through: each two-byte count becomes `00 00` and the count, the packed rows are unchanged. + pub(crate) fn put_widened_rle(&mut self, data: &[u8], rows: usize) -> std::io::Result<()> { + let table_len = usize::try_from(crate::compression::rle_widening(data, rows)?).map_err(|_| PsdError::LimitExceeded("row count overflow"))?; + let (table, packed) = data.split_at_checked(table_len).ok_or_else(|| PsdError::invalid("RLE row-count table out of range"))?; + for part in table.chunks(STAGE / 2) { + self.small(|o| { + for count in part.as_chunks::<2>().0 { + o.extend_from_slice(&[0, 0, count[0], count[1]]); + } + Ok(()) + })?; + } + self.put(packed) + } + + /// `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 cbc4ccb424..fc1a3ff571 100644 --- a/crates/psd/src/layer.rs +++ b/crates/psd/src/layer.rs @@ -3,10 +3,10 @@ use std::borrow::Cow; use crate::blend::BlendMode; -use crate::compression::{Compression, PlaneLayout, decode_planes, encode_planes, put_widened_rle, rle_widening}; +use crate::compression::{Compression, PlaneLayout, decode_planes, encode_planes, rle_widening}; 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::source::Stored; use crate::tagged::{BlockData, SectionDivider, SectionType, TaggedBlock, read_blocks, uses_long_length, write_blocks}; @@ -813,6 +813,8 @@ impl LayerInfo { /// Bytes of channel data and their compression fields: the part of the body worth reserving up /// front (the records are small next to the pixels). Saturates instead of overflowing. + /// (Saves reserve their exact length now, #6607; the tests still check it as a lower bound.) + #[cfg(test)] pub(crate) fn channel_data_len(&self) -> usize { self.layers .iter() @@ -823,24 +825,43 @@ impl LayerInfo { /// Serializes the body without any trailing padding (the padding field is ignored). pub(crate) fn write_unpadded(&self, out: &mut Vec, version: Version) -> Result<()> { - self.write_unpadded_as(out, version, false) + append_with(out, |s| self.write_unpadded_to(s, version, false)) } - /// [`Self::write_unpadded`], widening the RLE count tables of a PSD model written as PSB - /// (`widen`) on the way out. - fn write_unpadded_as(&self, out: &mut Vec, version: Version, widen: bool) -> Result<()> { + /// 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, false)) + } + + /// [`Self::write_unpadded`] to a streaming [`Sink`], widening the RLE count tables of a PSD + /// model written as PSB (`widen`) on the way out: the records are staged, the channel data + /// goes straight through (#6607). + pub(crate) fn write_unpadded_to( + &self, + out: &mut Sink<'_, W>, + version: Version, + widen: bool, + ) -> 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, widen)?; + out.small(|o| l.write(o, version, widen))?; } for l in &self.layers { for c in &l.channels { if let Some(comp) = c.compression { - out.put_u16(comp.as_u16()); + out.small(|o| { + o.put_u16(comp.as_u16()); + Ok(()) + })?; + let data = c.encoded()?; match l.widened_rows(c, widen)? { - 0 => out.put(&c.encoded()?), - rows => put_widened_rle(out, &c.encoded()?, rows)?, + 0 => out.put(&data)?, + rows => out.put_widened_rle(&data, rows)?, } } } @@ -848,25 +869,20 @@ impl LayerInfo { Ok(()) } - /// Serializes the body (without a length field). - #[cfg(test)] - pub(crate) fn write_body(&self, out: &mut Vec, version: Version) -> Result<()> { - self.write_body_as(out, version, false) - } - - /// [`Self::write_body`], widening RLE count tables when `widen` (see [`Self::write_unpadded_as`]). - pub(crate) fn write_body_as(&self, out: &mut Vec, version: Version, widen: bool) -> Result<()> { - let start = out.len(); - self.write_unpadded_as(out, version, widen)?; + /// The body (without a length field) to a streaming [`Sink`], widening RLE count tables when + /// `widen` (see [`Self::write_unpadded_to`]). + pub(crate) fn write_body_to( + &self, + out: &mut Sink<'_, W>, + version: Version, + widen: bool, + ) -> std::io::Result<()> { + let start = out.pos(); + self.write_unpadded_to(out, version, widen)?; 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(()) } } diff --git a/crates/psd/src/tagged.rs b/crates/psd/src/tagged.rs index 6fce19bbe9..e54f4daf0b 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 @@ -508,6 +508,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 c725c7d3dc..9fadf9a00c 100644 --- a/crates/psd/src/tiff.rs +++ b/crates/psd/src/tiff.rs @@ -152,16 +152,54 @@ 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 signature, then the blocks in big-endian order. + fn body(&self) -> Result> { let version = self.version; + let signature = if version.is_psb() { SIGNATURE_PSB } else { SIGNATURE }; + let len_field = |key: &[u8; 4]| if uses_long_length(version, key) { 8u64 } else { 4 }; + // Reserved at its exact length, as the buffer holds all the layers' pixels: one that is a + // few bytes short doubles at its last append, copying them once more (#6607). + let mut len = signature.len() as u64; + let info_pad = match &self.layer_info { + Some(info) => { + let pad = info.pad_len(version, 4)?; + len = len.saturating_add(8 + len_field(&self.layer_info_key)).saturating_add(info.unpadded_len(version)?).saturating_add(pad as u64); + pad + } + None => 0, + }; + if let Some(m) = &self.global_layer_mask { + let pad = TaggedBlock::default_padding(m.data.len(), 4); + len = len.saturating_add(8 + len_field(b"LMsk")).saturating_add(m.data.len() as u64).saturating_add(pad as u64); + } + len = self.global_blocks.iter().fold(len, |n, b| n.saturating_add(b.encoded_len_aligned(version, 4) as u64)); + len = len.saturating_add(self.trailing.len() as u64); + let no_memory = || PsdError::OutOfMemory("not enough memory for the layer data"); let mut body = Vec::new(); + body.try_reserve_exact(usize::try_from(len).map_err(|_| no_memory())?).map_err(|_| no_memory())?; + body.put(signature); if let Some(info) = &self.layer_info { - // Pad to 4 without cloning the layers (and all their pixel data) just to set `padding`. - 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::OutOfMemory("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)?; + // 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 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() + info_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 +211,18 @@ 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` (signature included) converted to `order`. + fn finish(body: Vec, version: Version, order: ByteOrder) -> Result<(Vec, Vec)> { + if !order.is_little() { + return Ok((body, Vec::new())); + } + let signature = if version.is_psb() { SIGNATURE_PSB } else { SIGNATURE }; + let blocks = body.get(signature.len()..).ok_or_else(|| PsdError::invalid("image source data without its signature"))?; + // Converted behind the signature, into one buffer sized for both. + transcode::transcode_blocks_after(signature, blocks, version, ByteOrder::Big, ByteOrder::Little) } /// 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). @@ -223,6 +286,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]) } diff --git a/crates/psd/src/tiff/tests.rs b/crates/psd/src/tiff/tests.rs index 3836822bf4..533e534430 100644 --- a/crates/psd/src/tiff/tests.rs +++ b/crates/psd/src/tiff/tests.rs @@ -8,6 +8,32 @@ use crate::testgen; /// 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 a PSD writes the bytes copying them does, and leaves the +/// rest of the file (its merged image) as it was. The big-endian body is reserved at its exact +/// length, so the buffer is never grown (a copy of every layer's pixels). +#[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); + if !order.is_little() { + assert_eq!(b.0.capacity(), b.0.len(), "{}: the body grew", 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() && file.global_layer_mask.is_none(), "{}", case.name); + assert_eq!(file.image_data, case.file.image_data, "{}", case.name); + assert_eq!(file.header, case.file.header, "{}", 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 { @@ -973,6 +999,11 @@ fn global_blocks_written_without_copies_keep_their_bytes() { 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_blocks_after(signature, &body, version, ByteOrder::Big, ByteOrder::Little).unwrap(); + assert_eq!(after, [signature, &little[..]].concat()); + assert_eq!(after.capacity(), signature.len() + body.len()); } } diff --git a/crates/psd/src/tiff/transcode.rs b/crates/psd/src/tiff/transcode.rs index 31d12b702f..15c33d4a9d 100644 --- a/crates/psd/src/tiff/transcode.rs +++ b/crates/psd/src/tiff/transcode.rs @@ -7,6 +7,16 @@ pub fn transcode_blocks(data: &[u8], version: Version, from: ByteOrder, to: Byte 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::new(); + out.try_reserve_exact(prefix.len().saturating_add(data.len())).map_err(|_| PsdError::OutOfMemory("not enough memory for the layer data"))?; + 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); @@ -61,8 +71,13 @@ macro_rules! copy_int { 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) { diff --git a/crates/psd/tests/fixtures/atn/README.md b/crates/psd/tests/fixtures/atn/README.md index 119f098c3e..e0912ea462 100644 --- a/crates/psd/tests/fixtures/atn/README.md +++ b/crates/psd/tests/fixtures/atn/README.md @@ -1,10 +1,11 @@ # Synthetic ATN parser fixtures -These four tiny files were independently encoded field by field from the version-16 layout -documented in `src/atn.rs`, without calling PhotoCraft's 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. +These seven tiny files are original synthetic test data. The four version-16 files were independently +encoded field by field from the version-16 layout documented in `src/atn.rs`, without calling +PhotoCraft's writer; the three version-12 files (`v12-*.atn`) encode the version-12 layout documented +there (#10743). They are 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 | |---|---:|---|---| @@ -12,8 +13,19 @@ oracles. | `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 bytes and expected public structures. -A fixture change should explain -the changed wire fields and update this table; do not regenerate expectations merely to make a -failing parser pass. +`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/tests/fixtures/atn/v12-commands.atn b/crates/psd/tests/fixtures/atn/v12-commands.atn new file mode 100644 index 0000000000..6b02e7ae71 Binary files /dev/null and b/crates/psd/tests/fixtures/atn/v12-commands.atn differ diff --git a/crates/psd/tests/fixtures/atn/v12-stop.atn b/crates/psd/tests/fixtures/atn/v12-stop.atn new file mode 100644 index 0000000000..826bdabe55 Binary files /dev/null and b/crates/psd/tests/fixtures/atn/v12-stop.atn differ diff --git a/crates/psd/tests/fixtures/atn/v12-values.atn b/crates/psd/tests/fixtures/atn/v12-values.atn new file mode 100644 index 0000000000..0fc46dde8e Binary files /dev/null and b/crates/psd/tests/fixtures/atn/v12-values.atn differ diff --git a/crates/psd/tests/it/atn_contract.rs b/crates/psd/tests/it/atn_contract.rs index 522d4ad2d4..f6c38bf81c 100644 --- a/crates/psd/tests/it/atn_contract.rs +++ b/crates/psd/tests/it/atn_contract.rs @@ -145,11 +145,15 @@ fn trailing_bytes_only_add_a_diagnostic() { #[test] fn unsupported_versions_and_invalid_item_tags_are_refused() { - for version in [0_u32, 12, 15, 17, u32::MAX] { + for version in [0_u32, 7, 11, 15, 17, u32::MAX] { let mut input = FIXTURES[0].1.to_vec(); input[..4].copy_from_slice(&version.to_be_bytes()); assert!(matches!(atn::parse(&input), Err(PsdError::Unsupported(_)))); } + // Version 12 (Photoshop 5) is read too (#10743): the smallest set parses to an empty set. + let mut v12 = FIXTURES[0].1.to_vec(); + v12[..4].copy_from_slice(&12_u32.to_be_bytes()); + assert!(atn::parse(&v12).unwrap().actions.is_empty()); let file = file_with(Descriptor::new("null")); let valid = atn::write(&file).unwrap(); // The empty set/action names put the first step at offset 28. diff --git a/crates/psd/tests/it/atn_v12.rs b/crates/psd/tests/it/atn_v12.rs new file mode 100644 index 0000000000..28f3921d1f --- /dev/null +++ b/crates/psd/tests/it/atn_v12.rs @@ -0,0 +1,145 @@ +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!("../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) +} + +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 +} + +/// 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] { + 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()); + } +} + +/// 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()); + 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 8d83a12c17..db445f849c 100644 --- a/crates/psd/tests/it/main.rs +++ b/crates/psd/tests/it/main.rs @@ -1,6 +1,7 @@ //! The crate's integration tests, linked into one test binary. mod atn_contract; +mod atn_v12; mod builder; mod corpus; mod corpus_atn; diff --git a/crates/raster/Cargo.toml b/crates/raster/Cargo.toml index 41b188a94f..058e275b60 100644 --- a/crates/raster/Cargo.toml +++ b/crates/raster/Cargo.toml @@ -11,10 +11,19 @@ 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"] } -# Native only: converting a region's tiles is independent per tile, so it runs on all cores. +# 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 and the scratch volume's free space. [target.'cfg(not(target_arch = "wasm32"))'.dependencies] rayon = { workspace = true } +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/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 5d0f6fe257..994c02c57c 100644 --- a/crates/raster/src/lib.rs +++ b/crates/raster/src/lib.rs @@ -9,17 +9,20 @@ mod interrupt; pub use interrupt::{Cancelled, Interrupt}; +pub mod scratch; 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, read_samples, 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, @@ -34,6 +37,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), Self::LengthMismatch { expected, got } => write!(f, "{got} bytes of pixels given for a region that takes {expected}"), @@ -42,6 +46,9 @@ impl fmt::Display for AllocationError { } impl std::error::Error for AllocationError {} +pub mod memory; +mod scratch_file; +pub mod spill; /// One shared tile per pixel value (and tile size), for [`Surface::share_uniform_tiles`]. #[derive(Debug, Default)] @@ -62,38 +69,39 @@ impl TileInterner { } /// 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 { 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); } } - 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 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() }) + let source = self.try_bytes().map_err(AllocationError::ScratchRead)?; + let bytes = source.len(); + let mut data = scratch::try_take_exact(bytes, 0u8).ok_or(AllocationError::NotEnoughMemory { bytes })?; + data.copy_from_slice(&source); + Ok(Tile::from_vec(data)) } 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. @@ -105,15 +113,38 @@ impl Tile { out.copy_from_slice(&row); } } - 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) + } + /// `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(); + 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). @@ -129,7 +160,7 @@ impl Tile { // last differing pixels. let blank = dp.repeat(ts); 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) = first_difference(row, &blank) else { continue }; let Some(last) = last_difference(row, &blank) else { continue }; x0 = x0.min(first / bpp); @@ -139,6 +170,67 @@ 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) => { + let mut data = scratch::take_exact(bytes.len(), 0u8); + data.copy_from_slice(&bytes); + Tile::from_vec(data) + } + // 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) + } } /// Bytes compared at once while looking for a difference in a tile row. @@ -203,12 +295,15 @@ impl UniformTiles { if let Some(k) = self.of.get(&key) { 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)) { - Some(k) => k, - None => { - self.canonical.push(t.clone()); - self.canonical.len() - 1 + 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 + } } }); self.of.insert(key, k); @@ -228,12 +323,13 @@ fn write_tile(t: &mut Tile, tc: TileCoord, r: Rect, data: &[f32], w: usize, n: u // 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; + let mut target = t.bytes_mut(); for y in tr.y0..tr.y1 { let o = (((y - r.y0) as usize) * w + (tr.x0 - r.x0) as usize) * n; 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) { @@ -278,12 +374,13 @@ fn copy_tile(tc: TileCoord, t: &mut Arc, r: Rect, bytes: &[u8], w: usize, *t = Arc::new(t.try_clone()?); } let t = Arc::get_mut(t).ok_or(AllocationError::SizeOverflow)?; + let mut target = t.bytes_mut(); let tr = tc.rect().intersect(&r); 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; - let (Some(d), Some(s)) = (t.data.get_mut(dst..dst + span), bytes.get(src..src + span)) else { return Err(AllocationError::SizeOverflow) }; + let (Some(d), Some(s)) = (target.get_mut(dst..dst + span), bytes.get(src..src + span)) else { return Err(AllocationError::SizeOverflow) }; d.copy_from_slice(s); } Ok(()) @@ -302,17 +399,17 @@ fn copy_tiles(tiles: &mut [(TileCoord, Arc)], r: Rect, bytes: &[u8], w: us tiles.iter_mut().try_for_each(|(tc, t)| copy_tile(*tc, t, r, bytes, w, bpp)) } -fn decode_tile_row_rgba(fmt: &PixelFormat, t: &Tile, base: usize, dst: &mut [[f32; 4]], row: &mut Vec) { +fn decode_tile_row_rgba(fmt: &PixelFormat, t: &[u8], base: usize, dst: &mut [[f32; 4]], row: &mut Vec) { let n = fmt.channels(); 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]; @@ -321,7 +418,7 @@ fn decode_tile_row_rgba(fmt: &PixelFormat, t: &Tile, base: usize, dst: &mut [[f3 (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 { @@ -340,7 +437,7 @@ fn decode_tile_row_rgba(fmt: &PixelFormat, t: &Tile, base: usize, dst: &mut [[f3 // RGB and grey take their channels as `to_rgba` does, other models go // through it. row.resize(dst.len() * n, 0.0); - read_samples(&t.data, fmt.sample, base, row); + read_samples(t, fmt.sample, base, row); let a = |s: &[f32], i: usize| if fmt.alpha { s[i] } else { 1.0 }; let pixels = dst.iter_mut().zip(row.chunks_exact(n)); match fmt.mode { @@ -358,13 +455,31 @@ 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 })?; + 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(); @@ -381,7 +496,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) { @@ -445,6 +560,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); @@ -478,18 +596,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())) @@ -530,14 +671,17 @@ impl Surface { let bpp = self.format.bytes_per_pixel(); let mut saved = 0; for t in self.tiles.values_mut() { - let mut px = t.data.chunks_exact(bpp); + let Ok(bytes) = t.try_bytes() else { continue }; + let mut px = bytes.chunks_exact(bpp); let Some(p0) = px.next() else { continue }; if !px.all(|p| p == p0) { continue; } - let shared = interner.tiles.entry(t.data.len()).or_default().entry(p0.to_vec()).or_insert_with(|| t.clone()); + let p0 = p0.to_vec(); + drop(bytes); + let shared = interner.tiles.entry(t.len()).or_default().entry(p0).or_insert_with(|| t.clone()); if !Arc::ptr_eq(shared, t) { - saved += t.data.len(); + saved += t.len(); *t = shared.clone(); } } @@ -548,7 +692,7 @@ impl Surface { pub fn prune(&mut self) { let dp = self.default_pixel.as_ref(); 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] @@ -571,8 +715,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 => { @@ -587,9 +732,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); } } @@ -598,7 +743,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), } } @@ -628,9 +773,10 @@ impl Surface { match self.tiles.get(&TileCoord { tx: tx as i32, 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 i64 * step - tx * i64::from(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); } } } @@ -658,10 +804,11 @@ impl Surface { let zero = self.default_pixel.iter().all(|&b| b == 0); for tc in r.tiles() { let tr = tc.rect().intersect(&r); - let tile = self.tiles.get(&tc); - if tile.is_none() && zero { + let guard = self.tiles.get(&tc).map(|t| t.bytes()); + if guard.is_none() && zero { continue; } + 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; @@ -669,7 +816,7 @@ impl Surface { match tile { Some(t) => { let base = (((y - tc.ty * TILE_SIZE) as usize) * TILE_SIZE as usize + (tr.x0 - tc.tx * TILE_SIZE) as usize) * n; - read_samples(&t.data, self.format.sample, base, dst); + read_samples(t, self.format.sample, base, dst); } None if n == 1 => dst.fill(dp[0]), None => { @@ -697,7 +844,8 @@ impl Surface { let mut row = Vec::new(); 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]; @@ -712,7 +860,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); @@ -723,10 +871,11 @@ impl Surface { let mut heap = Vec::new(); let dp = to_rgba(&fmt, self.default_samples(&mut stack, &mut heap)); 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]; @@ -735,14 +884,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])]; } } @@ -761,7 +915,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 } @@ -875,12 +1029,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); } @@ -902,12 +1056,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]); } } } @@ -935,16 +1089,16 @@ 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; 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) { @@ -1011,7 +1165,8 @@ 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. - return (shared.data.get(..bpp) != Some(&*self.default_pixel)).then_some((dc, shared)); + let keep = shared.bytes().get(..bpp) != Some(&*self.default_pixel); + return keep.then_some((dc, shared)); } // The identities of the source tiles under a covered window (the offset is the same // for every target of this call). @@ -1022,7 +1177,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 data = scratch::take_exact((TILE_SIZE * TILE_SIZE) as usize * bpp, 0u8); 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 @@ -1030,7 +1185,7 @@ impl Surface { // row — no whole-tile pre-fill, which rewrote nearly every byte twice. for local_y in 0..i64::from(TILE_SIZE) { let y = ty0 + local_y; - let Some(row) = tile.data.get_mut(local_y as usize * row_len..(local_y as usize + 1) * row_len) else { continue }; + let Some(row) = data.get_mut(local_y as usize * row_len..(local_y as usize + 1) * row_len) else { continue }; let in_content_y = y - i64::from(dy) >= i64::from(content.y0) && y - i64::from(dy) < i64::from(content.y1); // The row's target columns whose SOURCE lies inside the content are copied // (the copy loop crosses source tiles as needed); the rest stays default — @@ -1056,7 +1211,7 @@ impl Surface { let mut x = cx0.max(tx0); let x_end = cx1.min(tx0 + i64::from(TILE_SIZE)); // Rows run over the same one or two source tiles: cache the last lookup. - let mut cached: Option<(i64, i64, &std::sync::Arc)> = None; + let mut cached: Option<(i64, i64, TileBytes<'_>)> = None; while x < x_end { // The source document position this target column reads, and the source // tile it lives in (the shift crosses source tiles as x runs). @@ -1065,16 +1220,9 @@ impl Surface { // A missing source tile inside the content holds the default pixel, not // zeros: fill the span from the default row instead of skipping it // (echelon's white-mask-with-sparse-tiles case). - let st = match cached.take() { - Some((ctx, cty, t)) if ctx == tx && cty == ty => { - cached = Some((ctx, cty, t)); - t - } - _ => match self.tiles.get(&TileCoord::new(tx as i32, ty as i32)) { - Some(t) => { - cached = Some((tx, ty, t)); - t - } + if !matches!(&cached, Some((ctx, cty, _)) if *ctx == tx && *cty == ty) { + match self.tiles.get(&TileCoord::new(tx as i32, ty as i32)) { + Some(t) => cached = Some((tx, ty, t.bytes())), None => { let span_end = ((tx + 1) * i64::from(TILE_SIZE) + i64::from(dx)).min(x_end).max(x + 1); if !fill_row.is_empty() { @@ -1087,13 +1235,14 @@ impl Surface { x = span_end; continue; } - }, - }; + } + } + let Some((_, _, st)) = &cached else { break }; let span_end = (((tx + 1) * i64::from(TILE_SIZE) + i64::from(dx)).min(x_end)).max(x + 1); let span = (span_end - x) as usize * bpp; let src = ((sy - ty * i64::from(TILE_SIZE)) * row_len as i64 + (sx - tx * i64::from(TILE_SIZE)) * bpp as i64) as usize; let dst = (x - tx0) as usize * bpp; - if let (Some(d), Some(sr)) = (row.get_mut(dst..dst + span), st.data.get(src..src + span)) { + if let (Some(d), Some(sr)) = (row.get_mut(dst..dst + span), st.get(src..src + span)) { d.copy_from_slice(sr); } x = span_end; @@ -1101,7 +1250,12 @@ impl Surface { } // 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. - let finished = tile.data.chunks(default_row.len()).any(|row| row != default_row).then(|| Arc::new(tile)); + let finished = if data.chunks(default_row.len()).any(|row| row != default_row) { + Some(Arc::new(Tile::from_vec(data))) + } else { + scratch::give(data); + None + }; // Workers that missed the memo at the same time each built a copy: all of them take // the one stored first, so the targets share it however many threads raced. let finished = match memo_key { @@ -1146,13 +1300,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()); } @@ -1171,17 +1327,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()); } @@ -1203,16 +1361,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()); } diff --git a/crates/raster/src/memory.rs b/crates/raster/src/memory.rs new file mode 100644 index 0000000000..b996bd3152 --- /dev/null +++ b/crates/raster/src/memory.rs @@ -0,0 +1,240 @@ +//! 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}; + +pub mod system; + +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 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: 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()), + } +} + +#[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 + .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)) +} + +/// 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(); + // 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(); + } + } +} + +#[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 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(); + 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/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.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/src/scratch_file.rs b/crates/raster/src/scratch_file.rs new file mode 100644 index 0000000000..188c0cbcd1 --- /dev/null +++ b/crates/raster/src/scratch_file.rs @@ -0,0 +1,493 @@ +//! The scratch file: where cold tiles go when pixel memory is over budget. +//! +//! - 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. +//! - 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, 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, + #[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, +} + +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); + // 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()), + }) + } + + /// 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 + } + + /// 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) + } + + /// 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 + // 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 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() { + 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) { + // 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); + } + } +} + +/// 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! { + 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()); + // 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(); + // 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(); + } + + #[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()); + 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(); + 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..ac37c85686 --- /dev/null +++ b/crates/raster/src/spill.rs @@ -0,0 +1,828 @@ +//! 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 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. + +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}; + +/// 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); + } + if let Some(bytes) = self.data.get_mut().unwrap_or_else(PoisonError::into_inner).take() { + M.resident.fetch_sub(self.len, Ordering::Relaxed); + // The next tile of this size reuses the buffer, as main's pool expects. + crate::scratch::give(bytes); + } + 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 mut bytes = crate::scratch::take_exact(len, 0u8); + match lz4_flex::block::decompress_into(packed, &mut bytes) { + Ok(n) if n == len => Ok(bytes), + Ok(n) => { + crate::scratch::give(bytes); + Err(format!("tile decoded to {n} bytes, expected {len}")) + } + Err(e) => { + crate::scratch::give(bytes); + Err(e.to_string()) + } + } +} + +/// 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 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, + 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, + /// 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, budget: 0, paused: None, backoff: RETRY_FIRST, size_cap: 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); + } + + /// 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 self.budget.load(Ordering::Relaxed) == 0 { + return 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. + /// 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 { + if !self.has_room(&file) { + return freed; + } + match cell.evict(&file) { + Ok(n) => freed += n, + Err(e) => { + // 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()).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")] +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); + 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, 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; + } + M.sweep(low_water(budget), 4096) +} + +/// 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) { + 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(); + 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 { + 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 dir != c.dir { + c.file = None; + } + 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.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")] +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, + /// 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 { + 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(), + paused: c.paused.is_some(), + size_cap_bytes: c.size_cap, + } +} + +/// Last storage error, without scanning the scratch allocator for UI status updates. +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 +} + +/// 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 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 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> { + if generation == read_error_generation() { + Ok(()) + } else { + Err(last_error().unwrap_or_else(|| "scratch read failed during this operation; the result was discarded".into())) + } +} + +/// 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); + // 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()); + 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"); + 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!(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); + 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/src/tests.rs b/crates/raster/src/tests.rs index 0cba6f9343..472849c977 100644 --- a/crates/raster/src/tests.rs +++ b/crates/raster/src/tests.rs @@ -41,7 +41,7 @@ fn previous_read_region(surface: &Surface, r: Rect) -> Vec { 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, surface.format.sample, base + i); + out[o + i] = read_sample(&t.bytes(), surface.format.sample, base + i); } } None => out[o..o + n].copy_from_slice(&dp), @@ -137,7 +137,7 @@ fn write_region_reference(surface: &mut Surface, r: Rect, data: &[f32]) { 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 t.bytes_mut(), sample, base + i, data[o + i]); } } } @@ -571,13 +571,13 @@ fn tile_content_bounds_match_a_per_pixel_scan() { for &s in &spots { data[s - s % bpp + byte] ^= 0x5a; } - let t = Tile { data: data.into_boxed_slice() }; + let t = Tile::from_vec(data); assert_eq!(t.content_bounds(&dp), per_pixel(&t, &dp), "bpp {bpp}, spots {spots:?}, byte {byte}"); } } let mut data = dp.repeat(TILE_SIZE as usize * TILE_SIZE as usize); data[row * 3 + 9 * bpp..row * 3 + 10 * bpp].iter_mut().for_each(|b| *b = !*b); - let t = Tile { data: data.into_boxed_slice() }; + let t = Tile::from_vec(data); assert_eq!(t.content_bounds(&dp), Some((9, 3, 10, 4)), "bpp {bpp}"); } } 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/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/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/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/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/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/examples/huge_doc_bench.rs b/crates/ui-egui/examples/huge_doc_bench.rs index 180cdfa8fc..35e6174199 100644 --- a/crates/ui-egui/examples/huge_doc_bench.rs +++ b/crates/ui-egui/examples/huge_doc_bench.rs @@ -199,7 +199,7 @@ fn main() { .walk() .iter() .flat_map(|(_, _, l)| l.surface().into_iter().chain(l.mask.as_ref().map(|m| &m.surface))) - .map(|s| s.tiles().map(|(_, t)| t.bytes().len() as u64).sum::()) + .map(|s| s.tiles().map(|(_, t)| t.len() as u64).sum::()) .sum(); let ram = photocraft_ui_egui::gpu_canvas::physical_memory(); let vram = g.video_memory(); 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/app.rs b/crates/ui-egui/src/app.rs index 14038b10de..1e6e82dfb5 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, @@ -143,6 +144,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, @@ -186,12 +188,15 @@ 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, jpeg_options: None, 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(), @@ -270,6 +275,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; + } self.ui_frame(ui, frame); self.finish_type_reset_pass(ui.ctx()); } diff --git a/crates/ui-egui/src/app/clipboard.rs b/crates/ui-egui/src/app/clipboard.rs index a553cc932c..54d4de90b9 100644 --- a/crates/ui-egui/src/app/clipboard.rs +++ b/crates/ui-egui/src/app/clipboard.rs @@ -8,7 +8,76 @@ 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); + }; + // 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| { + 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) + } + + /// 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; @@ -24,6 +93,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 +129,19 @@ 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 { + 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; + 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 +156,10 @@ 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 }); + // 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 65318b174f..956776a50e 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}; @@ -458,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 39d17bf493..9a5621c5be 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -85,10 +85,42 @@ 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 { + // 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 + && 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()?; } - 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); @@ -314,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/app/files.rs b/crates/ui-egui/src/app/files.rs index 2fee62f41e..5bfd070fcf 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -58,6 +58,19 @@ impl PhotocraftApp { /// [`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,15 +78,28 @@ 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. - let (r, damaged) = photocraft_io::import_for_open(name, bytes, &opts).map_err(|e| e.to_string())?; + // 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), slot, order)?; + return Ok(Vec::new()); + } + Err(error) => return Err(error.to_string()), + }; (r.document, r.warnings, damaged) } else { let (doc, warnings) = import(name, bytes, &opts)?; @@ -99,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, }, @@ -112,13 +139,18 @@ 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) + } + + /// [`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 { 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 +160,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. @@ -248,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 @@ -287,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"; @@ -306,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"); @@ -355,14 +395,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)); @@ -552,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/canvas.rs b/crates/ui-egui/src/canvas.rs index 12b4a8a766..5c20d57f83 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; @@ -65,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}; @@ -418,6 +418,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..a5397c77dd 100644 --- a/crates/ui-egui/src/canvas/background_redraw/tests.rs +++ b/crates/ui-egui/src/canvas/background_redraw/tests.rs @@ -612,6 +612,90 @@ 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 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); + 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/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/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/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..b5f9fe7e7d --- /dev/null +++ b/crates/ui-egui/src/canvas/gpu_canvas/pages.rs @@ -0,0 +1,183 @@ +//! 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). + /// 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()); + 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))); + } + } + + /// 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(); + 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..72483f9c0e --- /dev/null +++ b/crates/ui-egui/src/canvas/viewport_pages.rs @@ -0,0 +1,942 @@ +//! 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) 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. + +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 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 { + 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, + /// 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, + 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, + /// Rendered for [`Page::partial`]: written into the page held, not a page of its own. + pub region: Option, + 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, + /// Pages updated in part, by re-rendering only what an edit reached. + pub partial: 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) { + // 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 &= partial.is_some(); + page.partial = partial; + } + 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, 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() { + 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; + } + // 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; + 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 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 || region.is_some(), cancel.clone(), region) { + 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); + 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 + /// 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, region): (PageId, u64, bool, Option), + cancel: Arc, +) -> bool { + if ACTIVE.fetch_add(1, Ordering::AcqRel) >= MAX_JOBS { + ACTIVE.fetch_sub(1, Ordering::AcqRel); + 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, region, display.as_deref(), &cancel))) + .unwrap_or_else(|_| Err("viewport page render panicked".into())); + let _ = tx.send(Done { page, epoch, exact, region, 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, + region: Option, + display: Option<&CanvasDisplay>, + cancel: &AtomicBool, +) -> Result { + let size = [doc.size.width, doc.size.height]; + 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(); + 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); + 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 + // 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, 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, + 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, region: None, 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, Option) -> 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, 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); + 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 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(&[]); + 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, 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)); + 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/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/control/dispatch.rs b/crates/ui-egui/src/control/dispatch.rs index 3c7b22dda0..7d9c6dd5cd 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,9 @@ fn app_methods(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlReques }) } -fn app_open(app: &mut PhotocraftApp, path: &str) -> Outcome { +/// 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; } @@ -1398,7 +1404,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/control/inspect.rs b/crates/ui-egui/src/control/inspect.rs index 4e09d79a1b..53df5d373f 100644 --- a/crates/ui-egui/src/control/inspect.rs +++ b/crates/ui-egui/src/control/inspect.rs @@ -31,6 +31,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/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 88a6e96dbe..a280cafd48 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) { @@ -943,6 +946,10 @@ fn confirm_generic_command(app: &mut PhotocraftApp, d: Dialog) -> Result 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 +1069,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/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/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/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 0b5ef3cf58..fe9941f9e7 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), @@ -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. 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 92daea2528..c5013adc25 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 @@ -121,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:?}"); } } @@ -628,7 +642,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, @@ -954,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"})), @@ -1026,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")); diff --git a/crates/ui-egui/src/dialogs/open_prompt.rs b/crates/ui-egui/src/dialogs/open_prompt.rs index ef04f0b576..2da15f9fb4 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, @@ -41,35 +45,95 @@ pub struct Held { pub policy_applied: bool, } +/// 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, + pub path: Option, + 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. 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. pub fn arrive(app: &mut PhotocraftApp, held: Held) { app.held_opens.push_back(held); + app.prompt_order.push_back(PromptKind::Open); 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)) +} + /// 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 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) { + 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); } } } @@ -129,19 +193,31 @@ 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); + crate::jobs_ui::end_batch(app); r } @@ -164,14 +240,19 @@ pub fn answer_remembering(app: &mut PhotocraftApp, action: &str) -> Result Result) -> Result) -> Result app .run("document.move", json!({"document": index, "to": slot})) @@ -267,3 +353,134 @@ fn open(app: &mut PhotocraftApp, held: Held, action: Option<&str>) -> Result, + slot: Option, + 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, capacity_unknown, uncertain_capacity_warning }); + app.prompt_order.push_back(PromptKind::Memory); + advance_memory(app); +} + +/// Whether a dialog's fields mark it as the high-memory confirmation. +pub fn owns_memory(fields: &serde_json::Map) -> 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 + // 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))], + ), + 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)), + ("__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!(available)), + ("capacityUnknown".into(), json!(prompt.capacity_unknown)), + ("uncertainCapacityWarning".into(), json!(prompt.uncertain_capacity_warning)), + ] + .into_iter() + .collect(); + + 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); + 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 path = front.path.clone(); + let slot = front.slot; + let order = front.order; + + 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(app); + } + advance(app); + crate::jobs_ui::end_batch(app); + Ok(r) + } + Err(err) => { + app.ui.status = err.clone(); + app.ui.status_error = true; + crate::jobs_ui::end_batch(app); + advance(app); + crate::jobs_ui::end_batch(app); + Err(err) + } + } +} 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/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/file_open.rs b/crates/ui-egui/src/file_open.rs index 65c1fb5758..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); + 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 c0323da9a1..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); + 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/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/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 921c6c6e39..0c67a91ec6 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 الرقم التسلسلي الأولي @@ -3421,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 fbbe8d1de0..8cd95ed30d 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í… @@ -3904,6 +3907,7 @@ navigator Zoom Zvětšení Sent to {printer} Odesláno do {printer} set in Print Settings… nastavuje se v nastavení tisku… ICO is limited to 256 × 256 pixels ICO je omezen na 256 × 256 pixelů + Memory usage percent Využití paměti (%) Show Favorite Fonts Zobrazit oblíbená písma No favorite fonts yet: click the star beside a font to add it. Zatím žádná oblíbená písma: přidejte písmo kliknutím na hvězdičku vedle něj. Favorite fonts Oblíbená písma @@ -3966,6 +3970,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 Edit Selected Guides Upravit vybraná vodítka Clear Selected Guides Vymazat vybraná vodítka Bicubic Automatic Bikubická automatická @@ -4019,6 +4024,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 2bb8fcbb59..46e71ed2cf 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 @@ -3358,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 8cd5a6b02d..45fb326bea 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… @@ -3303,6 +3306,7 @@ curves Smooth Glätten Sent to {printer} An {printer} gesendet set in Print Settings… in den Druckeinstellungen festlegen… ICO is limited to 256 × 256 pixels ICO ist auf 256 × 256 Pixel begrenzt + Memory usage percent Speichernutzung (%) Show Favorite Fonts Favorisierte Schriften anzeigen No favorite fonts yet: click the star beside a font to add it. Noch keine favorisierten Schriften: Klicken Sie auf den Stern neben einer Schrift, um sie hinzuzufügen. Favorite fonts Favorisierte Schriften @@ -3365,6 +3369,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 Color Profile Farbprofil EXIF EXIF Edit Selected Guides Ausgewählte Hilfslinien bearbeiten @@ -3420,6 +3425,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 fc778e15ae..8f20dd6705 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… Εισαγωγή διακοπής… @@ -3987,6 +3990,7 @@ curves Smooth Εξομάλυνση Sent to {printer} Στάλθηκε στον {printer} set in Print Settings… ορίζεται στις ρυθμίσεις εκτύπωσης… ICO is limited to 256 × 256 pixels Το ICO περιορίζεται σε 256 × 256 εικονοστοιχεία + Memory usage percent Χρήση μνήμης (%) Show Favorite Fonts Εμφάνιση αγαπημένων γραμματοσειρών No favorite fonts yet: click the star beside a font to add it. Δεν υπάρχουν ακόμη αγαπημένες γραμματοσειρές: κάντε κλικ στο αστέρι δίπλα σε μια γραμματοσειρά για να την προσθέσετε. Favorite fonts Αγαπημένες γραμματοσειρές @@ -4049,6 +4053,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 EXIF EXIF Edit Selected Guides Επεξεργασία επιλεγμένων οδηγών Clear Selected Guides Απαλοιφή επιλεγμένων οδηγών @@ -4102,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 (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 70c79fd3b4..6b365ce2be 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… @@ -4200,6 +4203,7 @@ navigator Zoom Zoom Sent to {printer} Enviado a {printer} set in Print Settings… se establece en Ajustes de impresión… ICO is limited to 256 × 256 pixels ICO está limitado a 256 × 256 píxeles + Memory usage percent Uso de memoria (%) Show Favorite Fonts Mostrar fuentes favoritas No favorite fonts yet: click the star beside a font to add it. Aún no hay fuentes favoritas: haz clic en la estrella junto a una fuente para añadirla. Favorite fonts Fuentes favoritas @@ -4262,6 +4266,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 Edit Selected Guides Editar guías seleccionadas Clear Selected Guides Borrar guías seleccionadas Bicubic Automatic Bicúbica automática @@ -4314,6 +4319,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 4eed74673c..b08a224b27 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 @@ -3359,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 923f6f6fbb..89bdec45d4 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… @@ -3294,6 +3297,7 @@ curves Smooth Lisser Sent to {printer} Envoyé à {printer} set in Print Settings… défini dans les paramètres d’impression… ICO is limited to 256 × 256 pixels Le format ICO est limité à 256 × 256 pixels + Memory usage percent Utilisation de la mémoire (%) Show Favorite Fonts Afficher les polices favorites No favorite fonts yet: click the star beside a font to add it. Aucune police favorite pour l’instant : cliquez sur l’étoile à côté d’une police pour l’ajouter. Favorite fonts Polices favorites @@ -3356,6 +3360,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 Edit Selected Guides Modifier les repères sélectionnés Clear Selected Guides Effacer les repères sélectionnés Bicubic Automatic Bicubique automatique @@ -3410,6 +3415,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/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..c5a61baed8 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… @@ -4192,6 +4195,7 @@ cameraRaw Yellow Sárga Kept {name}: written by another application {name} megtartva: egy másik alkalmazás írta Shadow Tint Árnyék színezet Redrawing Újrarajzolás + Memory usage percent Memóriahasználat százaléka Show Favorite Fonts Kedvenc betűtípusok megjelenítése No favorite fonts yet: click the star beside a font to add it. Még nincsenek kedvenc betűtípusok: kattintson a betűtípus melletti csillagra a hozzáadáshoz. Favorite fonts Kedvenc betűtípusok @@ -4241,6 +4245,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 Edit Selected Guides Kijelölt segédvonalak szerkesztése Clear Selected Guides Kijelölt segédvonalak törlése Bicubic Automatic Automatikus bikubikus @@ -4316,6 +4321,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 601dca3b8d..e5058af2c9 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… @@ -3299,6 +3302,7 @@ curves Smooth Haluskan Sent to {printer} Dikirim ke {printer} set in Print Settings… diatur di Pengaturan Cetak… ICO is limited to 256 × 256 pixels ICO dibatasi hingga 256 × 256 piksel + Memory usage percent Pemakaian memori (%) Show Favorite Fonts Tampilkan Font Favorit No favorite fonts yet: click the star beside a font to add it. Belum ada font favorit: klik bintang di samping font untuk menambahkannya. Favorite fonts Font favorit @@ -3361,6 +3365,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 Edit Selected Guides Edit Panduan Terpilih Clear Selected Guides Hapus Panduan Terpilih Bicubic Automatic Bicubic otomatis @@ -3415,6 +3420,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 a6ca7389ce..367c7267f5 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… @@ -4250,6 +4253,7 @@ navigator Zoom Zoom Sent to {printer} Inviato a {printer} set in Print Settings… impostato nelle impostazioni di stampa… ICO is limited to 256 × 256 pixels ICO è limitato a 256 × 256 pixel + Memory usage percent Uso memoria (%) Show Favorite Fonts Mostra font preferiti No favorite fonts yet: click the star beside a font to add it. Ancora nessun font preferito: fai clic sulla stella accanto a un font per aggiungerlo. Favorite fonts Font preferiti @@ -4312,6 +4316,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 Adobe-rgb-compat Adobe RGB (compatibile) All shortcuts reset to Photoshop defaults. Tutte le scorciatoie ripristinate ai predefiniti di Photoshop. Arc Lower Arco inferiore @@ -4525,6 +4530,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 062137ac41..2eee5145a2 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… ストップを挿入… @@ -3962,6 +3965,7 @@ curves Smooth スムーズ Reset All Locked Settings ロックされた設定をすべて初期化 Copy Texture to Other Tools テクスチャを他のツールにコピー ICO is limited to 256 × 256 pixels ICO は 256 × 256 ピクセルまでです + Memory usage percent メモリ使用率 (%) Show Favorite Fonts お気に入りのフォントを表示 No favorite fonts yet: click the star beside a font to add it. お気に入りのフォントはまだありません。フォントの横の星をクリックして追加してください。 Favorite fonts お気に入りのフォント @@ -4024,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ファイルにフルサイズの合成画像を保存 Edit Selected Guides 選択したガイドを編集 Clear Selected Guides 選択したガイドを消去 Bicubic Automatic バイキュービック法 (自動) @@ -4076,6 +4081,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 7117a2c747..43f7dea83e 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… 정지 삽입… @@ -3493,6 +3496,7 @@ curves Smooth 매끄럽게 Sent to {printer} {printer}(으)로 전송됨 set in Print Settings… 인쇄 설정에서 지정… ICO is limited to 256 × 256 pixels ICO는 256 × 256픽셀로 제한됩니다 + Memory usage percent 메모리 사용 비율(%) Show Favorite Fonts 즐겨찾는 글꼴 표시 No favorite fonts yet: click the star beside a font to add it. 아직 즐겨찾는 글꼴이 없습니다. 글꼴 옆의 별을 클릭하여 추가하세요. Favorite fonts 즐겨찾는 글꼴 @@ -3555,6 +3559,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 파일에 전체 크기 합성 이미지 저장 Color Profile 색상 프로필 EXIF EXIF Edit Selected Guides 선택한 안내선 편집 @@ -3611,6 +3616,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 14e7d9824f..d7232004b6 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 @@ -3358,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 4ae734f302..7ad2900a28 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… @@ -4046,6 +4049,7 @@ curves Smooth Vloeiend maken Sent to {printer} Verzonden naar {printer} set in Print Settings… instellen in de afdrukinstellingen… ICO is limited to 256 × 256 pixels ICO is beperkt tot 256 × 256 pixels + Memory usage percent Geheugengebruik (%) Show Favorite Fonts Favoriete lettertypen tonen No favorite fonts yet: click the star beside a font to add it. Nog geen favoriete lettertypen: klik op de ster naast een lettertype om het toe te voegen. Favorite fonts Favoriete lettertypen @@ -4108,6 +4112,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 Edit Selected Guides Geselecteerde hulplijnen bewerken Clear Selected Guides Geselecteerde hulplijnen wissen Bicubic Automatic Bicubisch automatisch @@ -4160,6 +4165,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 561d12b1fa..fe13d8f177 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… @@ -3903,6 +3906,7 @@ curves Smooth Wygładź Sent to {printer} Wysłano do {printer} set in Print Settings… ustawiane w ustawieniach druku… ICO is limited to 256 × 256 pixels ICO jest ograniczony do 256 × 256 pikseli + Memory usage percent Użycie pamięci (%) Show Favorite Fonts Pokaż ulubione czcionki No favorite fonts yet: click the star beside a font to add it. Brak ulubionych czcionek: kliknij gwiazdkę obok czcionki, aby ją dodać. Favorite fonts Ulubione czcionki @@ -3965,6 +3969,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 Edit Selected Guides Edytuj zaznaczone prowadnice Clear Selected Guides Usuń zaznaczone prowadnice Bicubic Automatic Dwusześcienna automatyczna @@ -4017,6 +4022,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 0ccb6ffe8e..f12a596d1a 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… @@ -4086,6 +4089,7 @@ layerLabel No Color Sem cor Sent to {printer} Enviado para {printer} set in Print Settings… definido nas configurações de impressão… ICO is limited to 256 × 256 pixels ICO é limitado a 256 × 256 pixels + Memory usage percent Uso de memória (%) Show Favorite Fonts Mostrar fontes favoritas No favorite fonts yet: click the star beside a font to add it. Ainda não há fontes favoritas: clique na estrela ao lado de uma fonte para adicioná-la. Favorite fonts Fontes favoritas @@ -4148,6 +4152,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 Edit Selected Guides Editar guias selecionadas Clear Selected Guides Limpar guias selecionadas Bicubic Automatic Bicúbica automática @@ -4200,6 +4205,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 bb117dee33..60f3929e5f 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… Вставить остановку… @@ -3280,6 +3283,7 @@ curves Smooth Сгладить Gray Value (Median) Уровень серого (медиана) Integrated Density Интегральная плотность Length Длина + Memory usage percent Использование памяти (%) Show Favorite Fonts Показать избранные шрифты No favorite fonts yet: click the star beside a font to add it. Избранных шрифтов пока нет: нажмите на звёздочку рядом со шрифтом, чтобы добавить его. Favorite fonts Избранные шрифты @@ -3329,6 +3333,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 Edit Selected Guides Изменить выбранные направляющие Clear Selected Guides Удалить выбранные направляющие Bicubic Automatic Бикубическая, автоматически @@ -3384,6 +3389,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 3fc1d8b861..2ebc6c2e70 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: @@ -4331,6 +4334,7 @@ shape Appearance Izgled Kept {name}: written by another application {name} je zadržan: upisala ga je druga aplikacija Shadow Tint Nijansa senki Redrawing Ponovno iscrtavanje + Memory usage percent Проценат употребе меморије Show Favorite Fonts Прикажи омиљене фонтове No favorite fonts yet: click the star beside a font to add it. Још нема омиљених фонтова: кликните на звездицу поред фонта да бисте га додали. Favorite fonts Омиљени фонтови @@ -4393,6 +4397,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 Edit Selected Guides Uredi izabrane vodilje Clear Selected Guides Ukloni izabrane vodilje @@ -4469,6 +4474,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 10b5805352..b9c3721765 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 @@ -3356,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 8770968a57..007d756f89 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… @@ -3999,6 +4002,7 @@ cameraData Lens Lens Kept {name}: written by another application {name} korundu: başka bir uygulama tarafından yazılmış Shadow Tint Gölge tonu Redrawing Yeniden çiziliyor + Memory usage percent Bellek kullanım yüzdesi Show Favorite Fonts Favori yazı tiplerini göster No favorite fonts yet: click the star beside a font to add it. Henüz favori yazı tipi yok: eklemek için bir yazı tipinin yanındaki yıldıza tıklayın. Favorite fonts Favori yazı tipleri @@ -4048,6 +4052,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 Edit Selected Guides Seçili Kılavuzları Düzenle Clear Selected Guides Seçili Kılavuzları Temizle Bicubic Automatic Bikübik Otomatik @@ -4123,6 +4128,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 c3241495bc..68128b8584 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… Вставити зупинку… @@ -3989,6 +3992,7 @@ curves Smooth Згладити Sent to {printer} Надіслано на {printer} set in Print Settings… задається в параметрах друку… ICO is limited to 256 × 256 pixels ICO обмежено розміром 256 × 256 пікселів + Memory usage percent Використання пам’яті (%) Show Favorite Fonts Показати вибрані шрифти No favorite fonts yet: click the star beside a font to add it. Вибраних шрифтів ще немає: натисніть зірочку біля шрифту, щоб додати його. Favorite fonts Вибрані шрифти @@ -4051,6 +4055,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 Edit Selected Guides Редагувати вибрані напрямні Clear Selected Guides Очистити вибрані напрямні Bicubic Automatic Бікубічна, автоматично @@ -4103,6 +4108,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 fcf6f41568..d868ab099a 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… @@ -3755,6 +3758,7 @@ curves Smooth Làm mượt Sent to {printer} Đã gửi tới {printer} set in Print Settings… đặt trong thiết lập in… ICO is limited to 256 × 256 pixels ICO giới hạn ở 256 × 256 pixel + Memory usage percent Tỷ lệ sử dụng bộ nhớ Show Favorite Fonts Hiển thị phông chữ yêu thích No favorite fonts yet: click the star beside a font to add it. Chưa có phông chữ yêu thích: nhấp vào ngôi sao bên cạnh phông chữ để thêm. Favorite fonts Phông chữ yêu thích @@ -3817,6 +3821,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 Edit Selected Guides Sửa đường căn đã chọn Clear Selected Guides Xóa đường căn đã chọn Bicubic Automatic Bicubic tự động @@ -3894,6 +3899,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 3313412efa..d9b554acfa 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… 插入停止… @@ -2944,6 +2947,7 @@ curves Smooth 平滑 Sent to {printer} 已发送到 {printer} set in Print Settings… 在打印设置中设置… ICO is limited to 256 × 256 pixels ICO 最大为 256 × 256 像素 + Memory usage percent 内存使用比例 (%) Show Favorite Fonts 显示收藏的字体 No favorite fonts yet: click the star beside a font to add it. 还没有收藏的字体:点击字体旁边的星标即可添加。 Favorite fonts 收藏的字体 @@ -3006,6 +3010,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 文件中存储全尺寸合成图像 Color Profile 颜色配置文件 EXIF EXIF Edit Selected Guides 编辑所选参考线 @@ -3065,6 +3070,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 e95d706e86..9379418b9e 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… 插入停止… @@ -2905,6 +2908,7 @@ curves Smooth 平滑 Sent to {printer} 已傳送至 {printer} set in Print Settings… 在列印設定中設定… ICO is limited to 256 × 256 pixels ICO 最大為 256 × 256 像素 + Memory usage percent 記憶體使用比例 (%) Show Favorite Fonts 顯示我的最愛字體 No favorite fonts yet: click the star beside a font to add it. 尚無我的最愛字體:按一下字體旁的星號即可加入。 Favorite fonts 我的最愛字體 @@ -2967,6 +2971,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 檔案中儲存全尺寸合成影像 Color Profile 色彩描述檔 EXIF EXIF Edit Selected Guides 編輯所選參考線 @@ -3026,6 +3031,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/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/crates/ui-egui/src/jobs_ui.rs b/crates/ui-egui/src/jobs_ui.rs index 00fce3da53..760a7b6b48 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(name, source).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), + match app.session.start_open_for_user_with_background(name, source, app.background_jobs).map_err(|e| e.to_string())? { + // 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,16 +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) { - let opens = &jobs.opens; - jobs.batches.retain(|b| opens.iter().any(|o| o.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 @@ -247,12 +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> { - 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); + let r = finish_open(app, &tab.name, tab.path.as_deref(), v, tab.slot, tab.order); + end_batch(app); r } @@ -447,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. @@ -507,7 +530,7 @@ fn on_event(app: &mut PhotocraftApp, ctx: &egui::Context, e: JobEvent) { app.ui.status_error = false; } } - end_batch(&mut app.jobs); + end_batch(app); return; } match e.outcome { @@ -546,8 +569,29 @@ 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); + 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 = 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 { @@ -559,6 +603,7 @@ fn finish_open(app: &mut PhotocraftApp, name: &str, path: Option<&str>, v: &Valu warnings: warnings.clone(), bytes: None, slot, + order, question, policy_applied, }; @@ -581,7 +626,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/lib.rs b/crates/ui-egui/src/lib.rs index e17b2bc80d..b9a1380e9a 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; @@ -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). @@ -593,6 +595,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, @@ -670,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, @@ -687,6 +693,9 @@ 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, + 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/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/panels.rs b/crates/ui-egui/src/panels.rs index be80f832bb..9a5266684c 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -145,7 +145,7 @@ 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. + // 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)); } diff --git a/crates/ui-egui/src/panels/actions.rs b/crates/ui-egui/src/panels/actions.rs index 88034ab2c8..d84d6d96e5 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; @@ -254,6 +255,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()) }; @@ -465,7 +469,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); @@ -499,8 +508,82 @@ 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 = 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 { + // 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; + 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))), + } + } + if count == 1 { + return match errors.pop() { + Some(e) => Err(e), + None => Ok(last), + }; + } + 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 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; + Ok(json!({"files": loaded, "actions": actions, "unsupported": unsupported})) + }) } /// Save Actions…: the selected action's set (every action of it), else the selected set folder, diff --git a/crates/ui-egui/src/panels/actions/tests.rs b/crates/ui-egui/src/panels/actions/tests.rs index 06a5cd5fb3..e35999dedc 100644 --- a/crates/ui-egui/src/panels/actions/tests.rs +++ b/crates/ui-egui/src/panels/actions/tests.rs @@ -1,5 +1,133 @@ 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"); + // 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] +/// 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}; @@ -1828,3 +1956,20 @@ 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}"); + // 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/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}"); + } +} diff --git a/crates/ui-egui/src/prefs/prefs_ui/fields.rs b/crates/ui-egui/src/prefs/prefs_ui/fields.rs index 2258a3f7be..2accc50371 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(); @@ -71,6 +74,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(); } @@ -371,7 +378,7 @@ pub(super) fn field_visible(section: &str, obj: &Map, k: &str, qu (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, @@ -410,6 +417,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().and_then(|p| u32::try_from(p).ok()).unwrap_or(70); + 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(""); diff --git a/crates/ui-egui/src/prefs/prefs_ui/startup.rs b/crates/ui-egui/src/prefs/prefs_ui/startup.rs index e1789138ee..16b945b49e 100644 --- a/crates/ui-egui/src/prefs/prefs_ui/startup.rs +++ b/crates/ui-egui/src/prefs/prefs_ui/startup.rs @@ -40,6 +40,8 @@ pub fn load(app: &mut PhotocraftApp) { } } } + // Applies Memory Usage and the scratch disks even when the stored preferences were reset. + app.session.apply_prefs(); crate::dock::restore(app); crate::color_panel_ui::restore(app); crate::brush_picker::restore(app); diff --git a/crates/ui-egui/src/prefs/prefs_ui/tests.rs b/crates/ui-egui/src/prefs/prefs_ui/tests.rs index 23f1f76e92..21994b7352 100644 --- a/crates/ui-egui/src/prefs/prefs_ui/tests.rs +++ b/crates/ui-egui/src/prefs/prefs_ui/tests.rs @@ -1444,8 +1444,8 @@ fn unimplemented_preferences_are_hidden_from_the_dialog() { let values = prefs::Preferences::default().to_json(); assert!(has_visible_fields(&values, "general")); assert!(has_visible_fields(&values, "fileHandling")); - // Every setting of this section is still unimplemented. - assert!(!has_visible_fields(&values, "scratchDisks"), "scratchDisks"); + assert!(has_visible_fields(&values, "scratchDisks")); + assert!(!prefs::is_hidden("scratchDisks.disks")); // Allow Agent Control and Control Port are read at launch (apps/photocraft main.rs). assert!(has_visible_fields(&values, "integrations")); assert!(!prefs::is_hidden("integrations.allowAgentControl")); diff --git a/crates/ui-egui/src/psd_open_tests.rs b/crates/ui-egui/src/psd_open_tests.rs index 1c40f898bd..719c760f35 100644 --- a/crates/ui-egui/src/psd_open_tests.rs +++ b/crates/ui-egui/src/psd_open_tests.rs @@ -197,3 +197,759 @@ 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; + + 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), 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; + + 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()); +} + +/// #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); + // 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("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 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 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("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 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"); + + 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); + }); + }); +} + +#[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), + false, + false, + ); + 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"); + 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()); +} + +#[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), + false, + false, + ); + 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"); + assert!(app.ui.status.contains("changed since it was checked"), "the background refusal names the changed identity: {}", app.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), false, false); + 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), 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; + 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), false, false); + + 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, + order: 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, + order: 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), false, false); + + // 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()); +} + +/// 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}"); + 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)); + 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}"); + 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()); +} + +/// #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/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 1bcaf89e32..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). @@ -92,6 +110,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, @@ -116,11 +137,15 @@ 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(), image_previews: true, psd_maximize_compatibility: true, + pcraft_full_composite: false, embed_icc: true, embed_metadata: true, document_path: None, @@ -146,11 +171,15 @@ 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; 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; @@ -247,6 +276,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 +465,9 @@ 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: 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. pub clipboard_unreadable: Option, 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/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))], 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); } 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..5036eb42d5 --- /dev/null +++ b/crates/ui-egui/tests/it/clipboard_svg.rs @@ -0,0 +1,24 @@ +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(); + 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/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; diff --git a/docs/atn.md b/docs/atn.md new file mode 100644 index 0000000000..99f883fc98 --- /dev/null +++ b/docs/atn.md @@ -0,0 +1,1929 @@ +# 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`), 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 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 (144) + +| 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` | +| `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` | +| `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` | +| (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` | 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` | +| (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` | 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.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` | +| (string ID only) | focusMask | Focus Area | `select.focusArea` | +| `GsnB` | gaussianBlur | Gaussian Blur | `filter.blur.gaussianBlur` | +| (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` | +| `Invs` | inverse | Inverse | `select.inverse` | +| `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.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` | +| `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` | 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` | +| `ShrE` | sharpenEdges | Sharpen Edges | `filter.sharpen.sharpenEdges` | +| `ShrM` | sharpenMore | Sharpen More | `filter.sharpen.sharpenMore` | +| `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` | +| `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` | +| (string ID only) | vibrance | Vibrance | `image.adjustments.vibrance` | +| `Wave` | wave | Wave | `filter.distort.wave` | +| `ZgZg` | zigZag | ZigZag | `filter.distort.zigZag` | + +### Settings retained by mapped events + +- 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. + +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. + +The Vibrance event and adjustment-opacity handling incorporate [@bommerts' contribution in #8659](https://github.com/storytold/photocraft/pull/8659). + +### Command options retained by mapped events + +- 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. + +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. + +### 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 +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 | + +## 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. 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 + +- **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. 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. +- **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 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 + +- 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`) 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 | 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): +``` + [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 | 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 + +- 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). + +#### 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` +- 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. + +#### Custom + +- 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 | Example value | Allowed | +|---|---|---|---|---|---| +| `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 + +- 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 | Example value | 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 (example values): +``` + '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. +- 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). + +#### Split Channels + +- 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 | Example value | Allowed | +|---|---|---|---|---|---| +| `` | documentIDs | list | | list[3] | | + +Nested values as recorded (example values): +``` + [0] 182 + [1] 185 + [2] 188 +``` + +#### Merge Spot Channel + +- 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. + +#### Trap + +- 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 | 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) | + +#### 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 | +|---|---| +| 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/control-protocol.md b/docs/control-protocol.md index 7c5f4f2c24..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 @@ -443,13 +443,13 @@ 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 | | `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?, 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/development.md b/docs/development.md index 277d20b954..25578943fc 100644 --- a/docs/development.md +++ b/docs/development.md @@ -36,6 +36,8 @@ building locally; the downloaded app is still subject to Windows application-con ## Build and run +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 cargo run --release -p photocraft -- --control 7878 --control-token-file .private/control.token \ @@ -153,6 +155,10 @@ The window itself draws with the same device (or, with the CPU canvas, one it wa ## 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. A launch while another PhotoCraft is still running leaves that instance's log where it is and writes its own `photocraft-.log` next to it (the newest two of those are kept once their instances have exited; the `PhotoCraft , log file ` line at startup names the file in use). The file stops growing at 16 MiB. `--version` and command-line errors write no file. A desktop start that fails before any window exists logs the error and, when no terminal, automation client or headless session can report it, shows it in a dialog naming this file (#7643). 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. @@ -271,6 +277,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), 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). @@ -612,8 +619,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: @@ -631,9 +638,17 @@ 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. -`crates/psd/tests/it/atn_contract.rs` checks the parser's public API against four small, -independently encoded fixtures in `crates/psd/tests/fixtures/atn/` (provenance and checksums in +`crates/psd/tests/it/atn_contract.rs` checks the parser's public API against small, independently +encoded version-16 fixtures (the version-12 ones are read by `atn_v12::`) in `crates/psd/tests/fixtures/atn/` (provenance and checksums in its README): `cargo test -p photocraft-psd --test it atn_contract::`. +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 @@ -663,12 +678,122 @@ 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, 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` 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. +- 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 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` +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. @@ -690,6 +815,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 diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 5ef09a2e7e..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-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-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, 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 | @@ -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` 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 | | @@ -146,8 +146,25 @@ 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. +The original reference entry retains its source date and historical totals; current totals are in the status above. + | 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) | +| 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-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-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-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-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-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-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-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-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) | diff --git a/docs/gaps.md b/docs/gaps.md index 56867ba41a..0b7ee59a00 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,7 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (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 on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -111,6 +112,9 @@ 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 120 supported and 97 unmapped events. Other Actions/scripting gaps remain; no overall readiness percentage or estimate changed. +- **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. +- **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); @@ -361,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 @@ -424,6 +428,9 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 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-31 | minor | G11: Group F action steps map to existing commands; 127 supported event IDs; action-file support remains partial; 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-11-01 | minor | G9: 11 of 183 preferences do nothing (was 12): Graphics Adapter (`performance.gpuAdapter`) always worked, but startup read it by raw JSON key, which the scorecard prefs audit can't see; it now reads the typed Performance section; no estimate changed | | 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 | @@ -449,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/roadmap.md b/docs/roadmap.md index 670083ccde..d2ca4a8435 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -122,3 +122,7 @@ Each milestone has a **definition of done (DoD)** and must leave `main` green on | 2026-10-10 | major | Progress docs restructured: 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. + +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 32cc47bfd5..884db18142 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: 11 of 183 (target 0) | +| [Workspace and UI](#workspace-and-ui) | 15 | 6 | 2 | settings that do nothing: 10 of 186 (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 | @@ -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 | @@ -203,9 +203,9 @@ Corpus floors (`crates/io/tests/it/corpus.rs`): the enforced minimums; the corpu ## Workspace and UI -**Settings that do nothing: 11 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. +**Settings that do nothing: 10 of 186** (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`, `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`. +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`. | Id | Target | Status | Issue | Note | |---|---|---|---|---| @@ -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; 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. 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 | @@ -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/docs/scratch-disks.md b/docs/scratch-disks.md new file mode 100644 index 0000000000..acc784d242 --- /dev/null +++ b/docs/scratch-disks.md @@ -0,0 +1,70 @@ +# 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 (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 +`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. 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. 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 +operations; standalone callers work without a background thread. `sysinfo` is native-only +with its default features disabled and only the `system` (RAM) and `disk` (free space) +features enabled. + +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. 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 +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. See +[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. + +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. diff --git a/docs/streaming-gpu.md b/docs/streaming-gpu.md new file mode 100644 index 0000000000..7770d1de0f --- /dev/null +++ b/docs/streaming-gpu.md @@ -0,0 +1,35 @@ +# Viewport pages (GPU canvas for over-budget documents) + +A document's display texture set (every tile, every mip level) is held to the per-document +canvas budget (`gpu_canvas::budget::canvas_budget`: a quarter of physical memory, capped by a +discrete GPU's own memory, from the detected RAM and VRAM, so also with GPU Memory on Auto). +A document whose texture set fits keeps it, with everything around it unchanged: the layer +budget (LayerFit), deferred refreshes (`DEFER_PX`), background redraw and the compositor's +output admission. There is no pixel-count cutoff. + +A document whose texture set doesn't fit used to fall back to the CPU canvas +(`canvasTooLarge`). It is now drawn on the GPU canvas from viewport pages +(`ui-egui/src/canvas/viewport_pages.rs`, `gpu_canvas/pages.rs`): + +- Pages are 512 texels square at a power-of-two reduction chosen so a texel covers at most one + screen pixel; a view asks for at most 256 pages and coarsens beyond that. Each page is rendered + from its core plus a one-texel apron (`photocraft_compose::viewport`), so independently rendered + pages filter their shared edges identically; the shader discards the apron. +- Two background workers render pages (over all documents), centre of the view first. At a + reduction, a faithful document shows a quick nearest-sample preview of every page in view first, + then the exact (area-averaged) page. Work for a page that left the view, changed zoom or was + edited is cancelled, and a late result of older work is dropped. +- Uploads are bounded per frame (2 pages, 32 MB of texels), so staging stays bounded however + many pages finish at once. +- Pages are held to half the per-document canvas budget (the rest is headroom for the + compositor); the least recently wanted pages are evicted first and wanted pages never are. + The global wgpu resource budget and the split of the GPU memory budget between documents are + not changed. +- An edit dirties only the pages its damage (plus the layers' effect reach) touches; a dirty page + stays on screen until its replacement is uploaded. Invalidation costs the pages held, not the + document's layers or tiles. +- When no page fits the budget, or a page fails to render or upload, the document is drawn on the + CPU canvas as before (`canvasTooLarge`). A failed page is retried when the scratch storage or + memory configuration changes. Closing the document, a GPU fault or dropping the GPU canvas + cancels its work and frees its pages. Pattern Preview and size-changing previews keep main's + path. diff --git a/docs/streaming-io.md b/docs/streaming-io.md new file mode 100644 index 0000000000..b7a84de6a2 --- /dev/null +++ b/docs/streaming-io.md @@ -0,0 +1,27 @@ +# EXR streaming + +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. + +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. + +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. + +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. + +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/docs/ui-parity.md b/docs/ui-parity.md index abf9431ef4..7cdd233c95 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-02 · **Change:** minor (a gradient stop dragged off the bar is removed in every stop editor, Gradient Map and Layer Style included, #10742; 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-03 · **Change:** minor (a gradient stop dragged off the bar is removed in every stop editor, Gradient Map and Layer Style included, #10742; 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 | @@ -352,3 +352,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 | diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index 8bfcc164aa..519bc850c6 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -137,6 +137,8 @@ fi if has appimage; then APPDIR="$WORK/PhotoCraft.AppDir" cp -R "$STAGE" "$APPDIR" + # 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 @@ -206,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" 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" diff --git a/scorecard/reliability.toml b/scorecard/reliability.toml index 081af4461f..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; 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. 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"