diff --git a/AGENTS.md b/AGENTS.md index 46b144dc..2f597e82 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,7 +24,7 @@ crates/ doc L1 document model (layers, masks, adjustments, effects, smart objects: pure data) ops paint algo text vector L2 history, brush engine, imaging algorithms, type engine, paths/shapes compose gpu format L3 CPU compositor (the oracle), wgpu compositor, .pcraft native format - io L4 document <-> PSD / flat formats + io plugins L4 document <-> PSD / flat formats; sandboxed WebAssembly plug-ins engine L5 Session + command registry (every action is a command) ui-egui automation L6 egui shell (thin: all actions go through the engine); MCP server testkit test helpers diff --git a/Cargo.lock b/Cargo.lock index 8e747a40..cd9f7c1f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1554,6 +1554,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foldhash" version = "0.2.0" @@ -1781,7 +1787,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a" dependencies = [ "bytemuck", - "foldhash", + "foldhash 0.2.0", "hashbrown 0.17.1", "log", "peniko", @@ -1915,6 +1921,15 @@ dependencies = [ "smallvec", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -1923,7 +1938,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -1932,7 +1947,7 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -2375,6 +2390,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "lebe" version = "0.5.3" @@ -3393,6 +3414,7 @@ dependencies = [ "photocraft-io", "photocraft-ops", "photocraft-paint", + "photocraft-plugins", "photocraft-raster", "photocraft-raw", "photocraft-text", @@ -3401,6 +3423,7 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.21", + "wat", ] [[package]] @@ -3483,6 +3506,21 @@ dependencies = [ "serde_json", ] +[[package]] +name = "photocraft-plugins" +version = "0.1.1" +dependencies = [ + "photocraft-color", + "photocraft-geom", + "photocraft-raster", + "rayon", + "serde", + "serde_json", + "thiserror 2.0.21", + "wasmi", + "wat", +] + [[package]] name = "photocraft-psd" version = "0.1.1" @@ -3555,6 +3593,7 @@ dependencies = [ "photocraft-geom", "photocraft-gpu", "photocraft-io", + "photocraft-plugins", "photocraft-raster", "photocraft-text", "rayon", @@ -4598,6 +4637,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + [[package]] name = "spirv" version = "0.4.0+sdk-1.4.341.0" @@ -4628,6 +4673,16 @@ dependencies = [ "float-cmp", ] +[[package]] +name = "string-interner" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23de088478b31c349c9ba67816fa55d9355232d63c3afea8bf513e31f0f1d2c0" +dependencies = [ + "hashbrown 0.15.5", + "serde", +] + [[package]] name = "strsim" version = "0.11.1" @@ -5164,6 +5219,99 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.261.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2608e8bb6d67fd68f5a8d0eb1363d6e7bcbc1f8ded5a0bd3a1e382462b876b22" +dependencies = [ + "leb128fmt", + "wasmparser 0.261.0", +] + +[[package]] +name = "wasmi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78693fcdd618e0fc34af59c6b8efa9ac5d58c68df940beff4bedddb6acfe7c27" +dependencies = [ + "spin", + "wasmi_collections", + "wasmi_core", + "wasmi_ir", + "wasmparser 0.228.0", +] + +[[package]] +name = "wasmi_collections" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a8be2aa467cf2d29e96ff759472c36eeb44a3c81c67fc9cb76c9a24c519c557" +dependencies = [ + "string-interner", +] + +[[package]] +name = "wasmi_core" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69372d5fda3ea3d1e0aa6603c7888110e0187e88ea17cd8fc2e2df0a0e1f37fa" +dependencies = [ + "libm", +] + +[[package]] +name = "wasmi_ir" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f17b774caa13c618c7244f1ee51fe23c5e7b8538a471fa46d9949779758aed6" +dependencies = [ + "wasmi_core", +] + +[[package]] +name = "wasmparser" +version = "0.228.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4abf1132c1fdf747d56bbc1bb52152400c70f336870f968b85e89ea422198ae3" +dependencies = [ + "bitflags 2.13.2", + "indexmap", +] + +[[package]] +name = "wasmparser" +version = "0.261.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f20f20e44f7e8aeb6744823ea9d869ede51e51be4fdaedede2852282e54d2d8" +dependencies = [ + "bitflags 2.13.2", + "indexmap", + "semver", +] + +[[package]] +name = "wast" +version = "261.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "776443145731a4062e5b0d392892a2005909b6ab72d9fdc3cad53dd1a714e44a" +dependencies = [ + "bumpalo", + "leb128fmt", + "memchr", + "unicode-width", + "wasm-encoder", +] + +[[package]] +name = "wat" +version = "1.261.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7b4d1a49ea73a8f3326e74e3a05db667001b16bd1035ed3356fc1a0ed05ca7f" +dependencies = [ + "wast", +] + [[package]] name = "wayland-backend" version = "0.3.17" diff --git a/Cargo.toml b/Cargo.toml index 448a1743..9ba60683 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,7 @@ photocraft-text = { path = "crates/text" } photocraft-vector = { path = "crates/vector" } photocraft-format = { path = "crates/format" } photocraft-automation = { path = "crates/automation" } +photocraft-plugins = { path = "crates/plugins" } photocraft-engine = { path = "crates/engine" } photocraft-ui-egui = { path = "crates/ui-egui" } photocraft-testkit = { path = "crates/testkit" } diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 9f4bb571..d47ba6e2 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -25,6 +25,7 @@ photocraft-text = { workspace = true } photocraft-vector = { workspace = true } photocraft-io = { workspace = true } photocraft-format = { workspace = true } +photocraft-plugins = { workspace = true } blake3 = { version = "1.8", default-features = false, features = ["pure"] } photocraft-codecs = { workspace = true } flate2 = { workspace = true } @@ -32,3 +33,5 @@ flate2 = { workspace = true } [dev-dependencies] # Synthetic camera raw files for the open tests. photocraft-raw = { workspace = true, features = ["testgen"] } +# WebAssembly text -> binary for plug-in test modules (Apache-2.0 WITH LLVM-exception). +wat = "1.228" diff --git a/crates/engine/examples/bench_plugin_invert.rs b/crates/engine/examples/bench_plugin_invert.rs new file mode 100644 index 00000000..e654801b --- /dev/null +++ b/crates/engine/examples/bench_plugin_invert.rs @@ -0,0 +1,49 @@ +//! Timing: the example WebAssembly Invert plug-in vs the built-in Image › Adjustments › Invert, +//! through the full command path, on a 6000 × 4000 (24 MP) layer. +//! `cargo run --release -p photocraft-engine --example bench_plugin_invert [width] [height] [depth]` +use std::time::Instant; + +use photocraft_engine::Session; +use photocraft_geom::Rect; +use serde_json::json; + +fn main() { + let mut args = std::env::args().skip(1).map(|v| v.parse::().unwrap_or(0)); + let w = args.next().filter(|v| *v > 0).unwrap_or(6000); + let h = args.next().filter(|v| *v > 0).unwrap_or(4000); + let depth = args.next().filter(|v| *v > 0).unwrap_or(8); + let mut s = Session::new(); + s.execute("file.new", json!({"width": w, "height": h, "depth": depth})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.edit("texture", |doc, active| { + let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap(); + let fmt = surf.format(); + let row: Vec = (0..w).flat_map(|x| photocraft_raster::from_rgba(&fmt, [(x % 256) as f32 / 255.0, 0.5, 0.25, 1.0])).collect(); + for y in 0..h as i32 { + surf.write_region(Rect::new(0, y, w as i32, y + 1), &row); + } + Ok(()) + }) + .unwrap(); + let wasm = include_bytes!("../../plugins/tests/fixtures/invert.wasm"); + let data: String = { + const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + wasm.chunks(3) + .flat_map(|c| { + let n = (u32::from(c[0]) << 16) | (u32::from(*c.get(1).unwrap_or(&0)) << 8) | u32::from(*c.get(2).unwrap_or(&0)); + (0..4).map(move |i| if i <= c.len() { T[(n >> (18 - 6 * i)) as usize & 63] as char } else { '=' }) + }) + .collect() + }; + s.execute("plugin.install", json!({"data": data})).unwrap(); + println!("{w}x{h} ({:.1} MP), {depth}-bit RGBA layer", (w * h) as f64 / 1e6); + for _ in 0..3 { + let t = Instant::now(); + s.execute("image.adjustments.invert", json!({})).unwrap(); + let builtin = t.elapsed().as_secs_f64() * 1e3; + let t = Instant::now(); + s.execute("plugin.run", json!({"id": "org.photocraft.example.invert"})).unwrap(); + let plugin = t.elapsed().as_secs_f64() * 1e3; + println!("built-in invert {builtin:>7.0} ms plug-in invert {plugin:>7.0} ms"); + } +} diff --git a/crates/engine/src/channel_cmds.rs b/crates/engine/src/channel_cmds.rs index 965aca8d..db6ab353 100644 --- a/crates/engine/src/channel_cmds.rs +++ b/crates/engine/src/channel_cmds.rs @@ -514,6 +514,7 @@ pub(crate) fn channel_surface_for_filter<'a>(doc: &'a mut Document, p: &Value) - /// Commands whose target follows the Channels panel. fn routed(id: &str) -> bool { id.starts_with("filter.") + || id == "plugin.run" || id.starts_with("image.adjustments.") || matches!(id, "paint.stroke" | "paint.pencil" | "paint.bucket" | "paint.gradient" | "paint.mixerBrush" | "edit.fill" | "image.applyImage") } diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 4e26560e..7de1c9b0 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -1018,6 +1018,7 @@ fn build() -> Vec { v.extend(crate::video_cmds::specs()); v.extend(crate::wia_cmds::specs()); v.extend(crate::variables_cmds::specs()); + v.extend(crate::plugin_cmds::specs()); v } diff --git a/crates/engine/src/filters.rs b/crates/engine/src/filters.rs index bd77a562..de142432 100644 --- a/crates/engine/src/filters.rs +++ b/crates/engine/src/filters.rs @@ -149,6 +149,10 @@ pub fn apply_filter_to_surface( if let Some(out) = crate::lens_cmds::apply_to_surface(id, params, surf, canvas) { return Some(out); } + // WebAssembly plug-in smart filters (plugin_cmds). + if id == crate::plugin_cmds::RUN { + return crate::plugin_cmds::apply_to_surface(id, params, surf, selection, canvas); + } let fp = params_for(id, params)?; let sel_bounds = selection.map(photocraft_raster::Surface::content_bounds); let content = surf.content_bounds(); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 1604dd28..b39fd31c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -43,6 +43,7 @@ pub mod pattern_cmds; pub mod photo_cmds; pub mod pick_cmds; mod pixels; +pub mod plugin_cmds; pub mod prefs; pub mod presets; pub mod print_cmds; diff --git a/crates/engine/src/plugin_cmds.rs b/crates/engine/src/plugin_cmds.rs new file mode 100644 index 00000000..10025f64 --- /dev/null +++ b/crates/engine/src/plugin_cmds.rs @@ -0,0 +1,286 @@ +//! `plugin.*` commands: sandboxed WebAssembly filter plug-ins (`photocraft-plugins`). +//! +//! Plug-ins are installed per process (`plugin.install`, or the Plug-ins preference folder), +//! listed with `plugin.list` and run with `plugin.run` on the active pixel layer, a targeted +//! channel, or a smart object (recorded as a smart filter, re-run on re-render while the plug-in +//! is installed). Runs respect the selection and are one undo step. + +use std::sync::Mutex; + +use photocraft_doc::{LayerContent, SmartFilter}; +use photocraft_plugins::{Plugin, registry}; +use photocraft_raster::Surface; +use serde_json::{Map, Value, json}; + +use crate::commands::CommandSpec; +use crate::{EngineError, Result, Session}; + +/// The smart-filter command id plug-in runs are recorded under. +pub const RUN: &str = "plugin.run"; + +fn bad(cmd: &str, msg: impl Into) -> EngineError { + EngineError::BadParams { cmd: cmd.into(), msg: msg.into() } +} + +fn err(e: photocraft_plugins::Error) -> EngineError { + EngineError::Other(e.to_string()) +} + +fn always(_: &Session) -> std::result::Result<(), String> { + Ok(()) +} + +/// JSON description of an installed plug-in. +pub fn describe(p: &Plugin) -> Value { + let m = p.manifest(); + json!({ + "id": m.id, "name": m.name, "version": m.version, "kind": m.kind, "author": m.author, "description": m.description, + "params": m.params_notation(), "paramsSchema": serde_json::to_value(m).ok().and_then(|v| v.get("params").cloned()), + "overlap": m.overlap, "area": m.area, "size": p.size(), "source": p.source(), + }) +} + +/// The plug-in's own parameters from command params: `params` if given, else the top-level keys +/// minus the ones the command itself uses. +fn plugin_params(p: &Value) -> Value { + if let Some(v) = p.get("params") { + return v.clone(); + } + match p { + Value::Object(m) => Value::Object( + m.iter() + .filter(|(k, _)| !matches!(k.as_str(), "id" | "layer" | "target" | "channel") && !k.starts_with("__")) + .map(|(k, v)| (k.clone(), v.clone())) + .collect(), + ), + _ => Value::Object(Map::new()), + } +} + +fn plugin_by_id(cmd: &str, p: &Value) -> Result> { + let id = p.get("id").and_then(Value::as_str).ok_or_else(|| bad(cmd, "missing `id` (see plugin.list)"))?; + registry::get(id).ok_or_else(|| err(photocraft_plugins::Error::NotFound(id.into()))) +} + +fn run(s: &mut Session, p: &Value) -> Result { + let plugin = plugin_by_id(RUN, p)?; + let params = plugin_params(p); + // Bad parameters fail before anything is touched. + let resolved = Value::Object(plugin.manifest().resolve_params(¶ms).map_err(|e| bad(RUN, e.to_string()))?); + let layer = crate::commands::layer_param(s, p)?; + let label = plugin.manifest().name.clone(); + let id = plugin.id().to_string(); + s.edit(&label, |doc, _| { + let selection = doc.selection.clone(); + let canvas = doc.bounds(); + if let Some(surf) = crate::channel_cmds::channel_surface_for_filter(doc, p)? { + *surf = plugin.apply(surf, canvas, selection.as_ref(), &resolved).map_err(err)?; + return Ok(()); + } + let l = doc.layer_mut(layer).ok_or(EngineError::NoLayer(layer))?; + match &mut l.content { + LayerContent::Raster(surf) => { + *surf = plugin.apply(surf, canvas, selection.as_ref(), &resolved).map_err(err)?; + Ok(()) + } + LayerContent::Smart(_) => { + let sf = SmartFilter { + command: RUN.into(), + params: json!({"id": id, "params": resolved}), + blend: photocraft_color::BlendMode::Normal, + opacity: 1.0, + visible: true, + }; + crate::smart_cmds::add_smart_filter(doc, layer, sf, selection.as_ref()) + } + other => Err(EngineError::Other(format!("plug-in filters need a pixel layer (active layer is a {} layer)", other.kind_name()))), + } + })?; + Ok(json!({"layer": layer.0, "plugin": id})) +} + +/// Smart-filter re-render of a recorded `plugin.run` (see `filters::apply_filter_to_surface`). +/// `None` when `command` isn't a plug-in run or the plug-in isn't installed (the smart filter is +/// then skipped, as for other unknown filters) or fails. +pub(crate) fn apply_to_surface(command: &str, params: &Value, surf: &Surface, selection: Option<&Surface>, canvas: photocraft_geom::Rect) -> Option { + if command != RUN { + return None; + } + let plugin = registry::get(params.get("id")?.as_str()?)?; + plugin.apply(surf, canvas, selection, params.get("params").unwrap_or(&Value::Null)).ok() +} + +/// Largest base64 payload `plugin.install` accepts (the module limit, encoded). +const MAX_DATA_CHARS: usize = (32 << 20) / 3 * 4 + 4; + +fn b64_decode(s: &str) -> Option> { + let val = |c: u8| -> Option { + Some(u32::from(match c { + b'A'..=b'Z' => c - b'A', + b'a'..=b'z' => c - b'a' + 26, + b'0'..=b'9' => c - b'0' + 52, + b'+' | b'-' => 62, + b'/' | b'_' => 63, + _ => return None, + })) + }; + let clean: Vec = s.bytes().filter(|c| !c.is_ascii_whitespace() && *c != b'=').collect(); + if clean.len() % 4 == 1 { + return None; + } + let mut out = Vec::with_capacity(clean.len() / 4 * 3 + 2); + for chunk in clean.chunks(4) { + let mut n = 0u32; + for (i, &c) in chunk.iter().enumerate() { + n |= val(c)? << (18 - 6 * i); + } + let bytes = [(n >> 16) as u8, (n >> 8) as u8, n as u8]; + out.extend_from_slice(bytes.get(..chunk.len() - 1)?); + } + Some(out) +} + +fn install(_s: &mut Session, p: &Value) -> Result { + const CMD: &str = "plugin.install"; + let plugin = if let Some(data) = p.get("data") { + let data = data.as_str().ok_or_else(|| bad(CMD, "`data` must be a base64 string"))?; + if data.len() > MAX_DATA_CHARS { + return Err(bad(CMD, "`data` is larger than the module limit")); + } + let bytes = b64_decode(data).ok_or_else(|| bad(CMD, "`data` is not valid base64"))?; + Plugin::load(&bytes, photocraft_plugins::Limits::default()).map_err(err)? + } else if let Some(path) = p.get("path") { + let path = path.as_str().filter(|s| !s.is_empty()).ok_or_else(|| bad(CMD, "`path` must be a file path"))?; + load_path(path)? + } else { + return Err(bad(CMD, "give `path` (a .wasm file) or `data` (the module as base64)")); + }; + let replace = p.get("replace").and_then(Value::as_bool).unwrap_or(true); + if !replace && registry::get(plugin.id()).is_some() { + return Err(bad(CMD, format!("a plug-in with id {:?} is already installed", plugin.id()))); + } + Ok(describe(®istry::install(plugin))) +} + +#[cfg(not(target_arch = "wasm32"))] +fn load_path(path: &str) -> Result { + registry::load_file(std::path::Path::new(path), photocraft_plugins::Limits::default()).map_err(err) +} + +#[cfg(target_arch = "wasm32")] +fn load_path(_: &str) -> Result { + Err(EngineError::Other("installing from a path is not available on the web; pass the module as base64 `data`".into())) +} + +/// The plug-in folder last loaded from the preferences, and what happened. +static FOLDER: Mutex> = Mutex::new(None); + +/// Loads the Plug-ins preference folder (`*.wasm`) when it is enabled and has changed since the +/// last load. Called whenever preferences are applied (startup, `prefs.set`). Native only. +pub(crate) fn sync_prefs(s: &Session) { + let pi = &s.prefs().plug_ins; + let folder = pi.additional_plugins_folder.trim(); + if !pi.use_additional_plugins_folder || folder.is_empty() { + return; + } + let mut last = FOLDER.lock().unwrap_or_else(|e| e.into_inner()); + if last.as_ref().is_some_and(|(f, _)| f == folder) { + return; + } + *last = Some((folder.to_string(), load_folder(folder))); +} + +#[cfg(not(target_arch = "wasm32"))] +fn load_folder(folder: &str) -> Value { + match registry::load_folder(std::path::Path::new(folder)) { + Ok(r) => json!({"folder": folder, "loaded": r.loaded, "failed": r.failed.iter().map(|(f, e)| json!({"file": f, "error": e})).collect::>()}), + Err(e) => json!({"folder": folder, "error": e.to_string()}), + } +} + +#[cfg(target_arch = "wasm32")] +fn load_folder(folder: &str) -> Value { + json!({"folder": folder, "error": "plug-in folders are not available on the web"}) +} + +fn reload(s: &mut Session, p: &Value) -> Result { + let folder = match p.get("path") { + Some(v) => v.as_str().filter(|f| !f.trim().is_empty()).ok_or_else(|| bad("plugin.reload", "`path` must be a folder"))?.trim().to_string(), + None => s.prefs().plug_ins.additional_plugins_folder.trim().to_string(), + }; + if folder.is_empty() { + return Err(bad("plugin.reload", "no folder: pass `path` or set Preferences › Plug-ins › Additional Plug-ins Folder")); + } + let report = load_folder(&folder); + if let Some(e) = report.get("error").and_then(Value::as_str) { + return Err(EngineError::Other(e.to_string())); + } + *FOLDER.lock().unwrap_or_else(|e| e.into_inner()) = Some((folder, report.clone())); + Ok(report) +} + +/// The plug-in command specs. +pub fn specs() -> Vec { + vec![ + CommandSpec { + id: "plugin.list", + label: "List Plug-ins", + menu: &[], + shortcut: None, + params: "{}", + enabled: always, + run: |_, _| { + let folder = FOLDER.lock().unwrap_or_else(|e| e.into_inner()).as_ref().map(|(_, r)| r.clone()); + Ok(json!({"plugins": registry::list().iter().map(|p| describe(p)).collect::>(), "folder": folder})) + }, + journal: false, + }, + CommandSpec { + id: "plugin.install", + label: "Install Plug-in…", + menu: &["Filter", "Plug-ins"], + shortcut: None, + params: r##"{"path":text,"data":json,"replace":bool=true} (path: a .wasm file, native only; data: the module as base64)"##, + enabled: always, + run: install, + journal: false, + }, + CommandSpec { + id: "plugin.remove", + label: "Remove Plug-in", + menu: &[], + shortcut: None, + params: r##"{"id":text}"##, + enabled: always, + run: |_, p| { + let id = p.get("id").and_then(Value::as_str).ok_or_else(|| bad("plugin.remove", "missing `id`"))?; + if registry::remove(id) { Ok(json!({"removed": id})) } else { Err(err(photocraft_plugins::Error::NotFound(id.into()))) } + }, + journal: false, + }, + CommandSpec { + id: "plugin.reload", + label: "Reload Plug-ins", + menu: &[], + shortcut: None, + params: r##"{"path":text} (a folder of .wasm plug-ins; default: the Plug-ins preference folder)"##, + enabled: always, + run: reload, + journal: false, + }, + CommandSpec { + id: RUN, + label: "Run Plug-in", + menu: &[], + shortcut: None, + params: r##"{"id":text,"params":json} (plug-in parameters under "params" or at the top level; see plugin.list)"##, + enabled: crate::filters::has_filterable_layer, + run, + journal: true, + }, + ] +} + +#[cfg(test)] +#[path = "plugin_cmds/tests.rs"] +mod tests; diff --git a/crates/engine/src/plugin_cmds/tests.rs b/crates/engine/src/plugin_cmds/tests.rs new file mode 100644 index 00000000..3d178fad --- /dev/null +++ b/crates/engine/src/plugin_cmds/tests.rs @@ -0,0 +1,228 @@ +use super::*; +use photocraft_geom::Rect; + +/// The example Invert plug-in, built from `examples/plugins/invert-rs`. +const INVERT: &[u8] = include_bytes!("../../../plugins/tests/fixtures/invert.wasm"); +const INVERT_ID: &str = "org.photocraft.example.invert"; + +fn b64(b: &[u8]) -> String { + const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::new(); + for c in b.chunks(3) { + let n = (u32::from(c[0]) << 16) | (u32::from(*c.get(1).unwrap_or(&0)) << 8) | u32::from(*c.get(2).unwrap_or(&0)); + for i in 0..4 { + out.push(if i <= c.len() { T[(n >> (18 - 6 * i)) as usize & 63] as char } else { '=' }); + } + } + out +} + +/// A WAT plug-in with its own `id` whose `pc_filter` body is `filter`. +fn wat_plugin(id: &str, params: &str, filter: &str) -> Vec { + let manifest = format!(r#"{{"id":"{id}","name":"Test {id}","version":"1","kind":"filter","params":{params}}}"#); + let src = format!( + r#"(module + (memory (export "memory") 1) + (data (i32.const 16) "{data}") + (func (export "pc_abi_version") (result i32) (i32.const 1)) + (func (export "pc_manifest") (result i64) (i64.or (i64.shl (i64.const {len}) (i64.const 32)) (i64.const 16))) + (func (export "pc_alloc") (param $n i32) (result i32) (local $old i32) + (local.set $old (memory.grow (i32.shr_u (i32.add (local.get $n) (i32.const 65535)) (i32.const 16)))) + (if (result i32) (i32.eq (local.get $old) (i32.const -1)) (then (i32.const 0)) (else (i32.mul (local.get $old) (i32.const 65536))))) + (func (export "pc_filter") (param $buf i32) (param $len i32) (param $w i32) (param $h i32) + (param $ch i32) (param $fmt i32) (param $pp i32) (param $pl i32) (result i32) {filter}))"#, + data = manifest.replace('"', "\\\""), + len = manifest.len() + ); + wat::parse_str(src).unwrap() +} + +/// Sets every sample to 0.5. +const GREY: &str = r#"(local $i i32) + (block $done (loop $l + (br_if $done (i32.ge_u (local.get $i) (local.get $len))) + (f32.store (i32.add (local.get $buf) (local.get $i)) (f32.const 0.5)) + (local.set $i (i32.add (local.get $i) (i32.const 4))) + (br $l))) + (i32.const 0)"#; + +fn doc(depth: u32) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 28, "depth": depth})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.edit("pattern", |doc, active| { + let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap(); + for y in 0..28 { + for x in 0..40 { + let v = ((x * 5 + y * 3) % 17) as f32 / 16.0; + let a = if (x * y) % 11 == 3 { 0.0 } else { 0.3 + (x % 3) as f32 * 0.35 }; + surf.write_pixel(x, y, &[v, 1.0 - v, (x % 4) as f32 / 3.0, a]); + } + } + Ok(()) + }) + .unwrap(); + s +} + +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, 40, 28)) +} + +fn select_partial(s: &mut Session) { + s.edit("sel", |doc, _| { + let mut sel = Surface::new(photocraft_color::PixelFormat::GRAY8); + sel.fill_rect(Rect::new(4, 4, 30, 20), &[1.0]); + sel.fill_rect(Rect::new(4, 4, 30, 9), &[0.4]); + doc.selection = Some(sel); + Ok(()) + }) + .unwrap(); +} + +#[test] +fn example_invert_matches_builtin_invert_and_round_trips_the_registry() { + // One test owns the example plug-in's id (the registry is process-wide). + let r = Session::new().execute("plugin.install", json!({"data": b64(INVERT)})).unwrap(); + assert_eq!(r["id"], INVERT_ID); + assert_eq!(r["name"], "Invert (WebAssembly)"); + let list = Session::new().execute("plugin.list", json!({})).unwrap(); + assert!(list["plugins"].as_array().unwrap().iter().any(|p| p["id"] == INVERT_ID)); + + for depth in [8, 16, 32] { + for with_selection in [false, true] { + let mut a = doc(depth); + let mut b = doc(depth); + if with_selection { + select_partial(&mut a); + select_partial(&mut b); + } + let before = pixels(&a); + a.execute("image.adjustments.invert", json!({})).unwrap(); + let r = b.execute("plugin.run", json!({"id": INVERT_ID})).unwrap(); + assert_eq!(r["plugin"], INVERT_ID); + assert_eq!(pixels(&a), pixels(&b), "depth {depth}, selection {with_selection}"); + assert_ne!(pixels(&b), before); + // One undo step. + b.execute("edit.undo", json!({})).unwrap(); + assert_eq!(pixels(&b), before, "depth {depth} undo"); + } + } + + // Smart objects record a smart filter. + let mut s = doc(8); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + s.execute("plugin.run", json!({"id": INVERT_ID})).unwrap(); + let d = s.active().unwrap(); + let LayerContent::Smart(sm) = &d.doc.layer(d.active_layer.unwrap()).unwrap().content else { panic!("not a smart object") }; + assert_eq!(sm.smart_filters.len(), 1); + assert_eq!(sm.smart_filters[0].command, RUN); + assert_eq!(sm.smart_filters[0].params["id"], INVERT_ID); + + // Install from a path (native), then remove. + let dir = std::env::temp_dir().join(format!("pc-plugin-cmds-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("invert.wasm"); + std::fs::write(&path, INVERT).unwrap(); + let r = Session::new().execute("plugin.install", json!({"path": path.to_str().unwrap()})).unwrap(); + assert_eq!(r["source"], path.to_str().unwrap()); + assert!(Session::new().execute("plugin.install", json!({"path": path.to_str().unwrap(), "replace": false})).is_err()); + Session::new().execute("plugin.remove", json!({"id": INVERT_ID})).unwrap(); + assert!(doc(8).execute("plugin.run", json!({"id": INVERT_ID})).is_err(), "removed"); + std::fs::remove_dir_all(&dir).ok(); +} + +#[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); + Session::new().execute("plugin.install", json!({"data": b64(&bytes)})).unwrap(); + let list = Session::new().execute("plugin.list", json!({})).unwrap(); + let me = list["plugins"].as_array().unwrap().iter().find(|p| p["id"] == "test.cmds.grey").unwrap().clone(); + assert_eq!(me["params"], r#"{"amount":0..1=1}"#); + let mut s = doc(16); + let before = pixels(&s); + let undo_before = s.active().unwrap().history.can_undo(); + // Wrong type: refused before any edit (no undo step). + assert!(matches!(s.execute("plugin.run", json!({"id": "test.cmds.grey", "amount": "x"})), Err(EngineError::BadParams { .. }))); + assert!(s.execute("plugin.run", json!({"id": "test.cmds.grey", "params": {"amount": true}})).is_err()); + assert_eq!(pixels(&s), before); + assert_eq!(s.active().unwrap().history.can_undo(), undo_before); + // Flat and nested params both work. + s.execute("plugin.run", json!({"id": "test.cmds.grey", "amount": 0.5})).unwrap(); + s.execute("plugin.run", json!({"id": "test.cmds.grey", "params": {"amount": 0.25}})).unwrap(); + assert!((pixels(&s)[0] - 0.5).abs() < 1e-4); + Session::new().execute("plugin.remove", json!({"id": "test.cmds.grey"})).unwrap(); +} + +#[test] +fn hostile_plugins_fail_without_touching_the_document() { + for (id, body) in [ + ("test.cmds.trap", "unreachable"), + ("test.cmds.spin", "(loop $l (br $l)) (i32.const 0)"), + ("test.cmds.oob", "(i32.store (i32.const -4) (i32.const 1)) (i32.const 0)"), + ] { + Session::new().execute("plugin.install", json!({"data": b64(&wat_plugin(id, "{}", body))})).unwrap(); + let mut s = doc(8); + let before = pixels(&s); + let undo_before = s.active().unwrap().history.can_undo(); + let t = std::time::Instant::now(); + assert!(s.execute("plugin.run", json!({"id": id})).is_err(), "{id}"); + assert!(t.elapsed() < std::time::Duration::from_secs(10), "{id} took {:?}", t.elapsed()); + assert_eq!(pixels(&s), before, "{id}"); + assert_eq!(s.active().unwrap().history.can_undo(), undo_before, "{id}: no undo step"); + Session::new().execute("plugin.remove", json!({"id": id})).unwrap(); + } +} + +#[test] +fn bad_params_are_errors_not_panics() { + let mut s = doc(8); + for (cmd, p) in [ + ("plugin.install", json!({})), + ("plugin.install", json!({"data": 5})), + ("plugin.install", json!({"data": "!!!not base64"})), + ("plugin.install", json!({"data": "QUJD"})), + ("plugin.install", json!({"data": b64(b"\0asm\x01\0\0\0junk")})), + ("plugin.install", json!({"path": ""})), + ("plugin.install", json!({"path": 3})), + ("plugin.install", json!({"path": "/definitely/not/here.wasm"})), + ("plugin.install", json!({"path": "/"})), + ("plugin.remove", json!({})), + ("plugin.remove", json!({"id": "no.such.plugin"})), + ("plugin.run", json!({})), + ("plugin.run", json!({"id": 7})), + ("plugin.run", json!({"id": "no.such.plugin"})), + ("plugin.reload", json!({"path": 1})), + ("plugin.reload", json!({"path": ""})), + ("plugin.reload", json!({"path": "/definitely/not/here"})), + ] { + assert!(s.execute(cmd, p.clone()).is_err(), "{cmd} {p}"); + } + // No document: running is disabled. + assert!(Session::new().execute("plugin.run", json!({"id": "x"})).is_err()); + assert_eq!(b64_decode("aGk="), Some(b"hi".to_vec())); + assert_eq!(b64_decode("aGk"), Some(b"hi".to_vec())); + assert_eq!(b64_decode("a"), None); +} + +#[test] +fn preference_folder_is_loaded() { + let dir = std::env::temp_dir().join(format!("pc-plugin-prefs-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("grey.wasm"), wat_plugin("test.cmds.folder", "{}", GREY)).unwrap(); + std::fs::write(dir.join("broken.wasm"), b"nope").unwrap(); + let mut s = Session::new(); + // Off by default: setting the folder alone loads nothing. + s.execute("prefs.set", json!({"path": "plugIns.additionalPluginsFolder", "value": dir.to_str().unwrap()})).unwrap(); + assert!(registry::get("test.cmds.folder").is_none()); + s.execute("prefs.set", json!({"path": "plugIns.useAdditionalPluginsFolder", "value": true})).unwrap(); + assert!(registry::get("test.cmds.folder").is_some()); + let list = s.execute("plugin.list", json!({})).unwrap(); + assert_eq!(list["folder"]["failed"].as_array().map(Vec::len), Some(1)); + registry::remove("test.cmds.folder"); + let r = s.execute("plugin.reload", json!({})).unwrap(); + assert_eq!(r["loaded"], json!(["test.cmds.folder"])); + registry::remove("test.cmds.folder"); + std::fs::remove_dir_all(&dir).ok(); +} diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index a7fe3ebe..0c82117c 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -552,6 +552,8 @@ impl GuidesGridAndSlices { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct PlugIns { + /// Load the WebAssembly plug-ins (`*.wasm`) in `additional_plugins_folder` (native only). + pub use_additional_plugins_folder: bool, pub additional_plugins_folder: String, pub show_extension_panels: bool, pub allow_scripts_to_connect: bool, @@ -560,7 +562,13 @@ pub struct PlugIns { impl Default for PlugIns { fn default() -> Self { - Self { additional_plugins_folder: String::new(), show_extension_panels: true, allow_scripts_to_connect: false, generator_enabled: false } + Self { + use_additional_plugins_folder: false, + additional_plugins_folder: String::new(), + show_extension_panels: true, + allow_scripts_to_connect: false, + generator_enabled: false, + } } } @@ -1067,6 +1075,7 @@ impl Session { st.history.max_states = n; } photocraft_compose::set_effect_cache_budget(budget << 20); + crate::plugin_cmds::sync_prefs(self); } /// Everything persisted as one JSON document: the preferences plus `colorSettings`. diff --git a/crates/plugins/Cargo.toml b/crates/plugins/Cargo.toml new file mode 100644 index 00000000..09a69ca8 --- /dev/null +++ b/crates/plugins/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "photocraft-plugins" +description = "Sandboxed WebAssembly filter plug-ins: a resource-limited wasmi host for the PhotoCraft plug-in ABI" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[lints] +workspace = true + +[dependencies] +photocraft-geom = { workspace = true } +photocraft-color = { workspace = true } +photocraft-raster = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +# Pure-Rust WebAssembly interpreter (MIT OR Apache-2.0). Without `wat` (text parsing is test-only) +# and memory64; `validate` keeps full module validation on. +wasmi = { version = "2.0.0", default-features = false, features = ["std", "validate", "stable", "portable-dispatch"] } + +# Bands run in parallel on native; wasm builds stay single-threaded. +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +rayon = { workspace = true } + +[dev-dependencies] +# WebAssembly text -> binary for the hostile-module fixtures (Apache-2.0 WITH LLVM-exception). +wat = "1.228" diff --git a/crates/plugins/examples/bench_invert.rs b/crates/plugins/examples/bench_invert.rs new file mode 100644 index 00000000..6936a48a --- /dev/null +++ b/crates/plugins/examples/bench_invert.rs @@ -0,0 +1,44 @@ +//! Times the example Invert plug-in on a large image: +//! `cargo run -p photocraft-plugins --release --example bench_invert -- [width] [height]` +//! (default 6000 × 4000 = 24 MP, RGBA 8-bit). + +use std::time::Instant; + +use photocraft_color::PixelFormat; +use photocraft_geom::Rect; +use photocraft_plugins::{Limits, Plugin}; +use photocraft_raster::Surface; + +fn main() { + let mut args = std::env::args().skip(1).map(|a| a.parse::().unwrap_or(0)); + let w = args.next().filter(|v| *v > 0).unwrap_or(6000); + let h = args.next().filter(|v| *v > 0).unwrap_or(4000); + let mut s = Surface::new(PixelFormat::RGBA8); + let row: Vec = (0..w).flat_map(|x| [(x % 256) as f32 / 255.0, 0.5, 0.25, 1.0]).collect(); + for y in 0..h { + s.write_region(Rect::new(0, y, w, y + 1), &row); + } + let p = Plugin::load(include_bytes!("../tests/fixtures/invert.wasm"), Limits::default()).expect("load"); + if std::env::var("NOOP").is_ok() { + let noop = wat::parse_str(NOOP).unwrap(); + let p = Plugin::load(&noop, Limits::default()).unwrap(); + let t = Instant::now(); + p.apply(&s, Rect::new(0, 0, w, h), None, &serde_json::json!({})).unwrap(); + println!("noop: {:.0} ms", t.elapsed().as_secs_f64() * 1e3); + } + let canvas = Rect::new(0, 0, w, h); + for run in 0..3 { + let t = Instant::now(); + let out = p.apply(&s, canvas, None, &serde_json::json!({})).expect("apply"); + println!("run {run}: {w}x{h} ({:.1} MP) in {:.0} ms", (w as f64 * h as f64) / 1e6, t.elapsed().as_secs_f64() * 1e3); + assert!((out.pixel(1, 1)[0] - (1.0 - 1.0 / 255.0)).abs() < 1e-6); + } +} + +const NOOP: &str = r#"(module (memory (export "memory") 1) (data (i32.const 16) "{\"id\":\"n\",\"name\":\"n\",\"kind\":\"filter\"}") + (func (export "pc_abi_version") (result i32) (i32.const 1)) + (func (export "pc_manifest") (result i64) (i64.const 0x0000002500000010)) + (func (export "pc_alloc") (param $n i32) (result i32) (local $old i32) + (local.set $old (memory.grow (i32.shr_u (i32.add (local.get $n) (i32.const 65535)) (i32.const 16)))) + (if (result i32) (i32.eq (local.get $old) (i32.const -1)) (then (i32.const 0)) (else (i32.mul (local.get $old) (i32.const 65536))))) + (func (export "pc_filter") (param i32 i32 i32 i32 i32 i32 i32 i32) (result i32) (i32.const 0)))"#; diff --git a/crates/plugins/examples/wat2wasm.rs b/crates/plugins/examples/wat2wasm.rs new file mode 100644 index 00000000..e72ee1d1 --- /dev/null +++ b/crates/plugins/examples/wat2wasm.rs @@ -0,0 +1,30 @@ +//! Compiles a WebAssembly text plug-in to a `.wasm` module and checks that it loads: +//! `cargo run -p photocraft-plugins --example wat2wasm -- plugin.wat plugin.wasm` + +use photocraft_plugins::{Limits, Plugin}; + +fn main() { + let args: Vec = std::env::args().skip(1).collect(); + let (Some(input), Some(output)) = (args.first(), args.get(1)) else { + eprintln!("usage: wat2wasm "); + std::process::exit(2); + }; + let bytes = match wat::parse_file(input) { + Ok(b) => b, + Err(e) => { + eprintln!("{e}"); + std::process::exit(1); + } + }; + match Plugin::load(&bytes, Limits::default()) { + Ok(p) => println!("{} ({}): {} bytes, params {}", p.manifest().name, p.id(), bytes.len(), p.manifest().params_notation()), + Err(e) => { + eprintln!("not a valid plug-in: {e}"); + std::process::exit(1); + } + } + if let Err(e) = std::fs::write(output, &bytes) { + eprintln!("{output}: {e}"); + std::process::exit(1); + } +} diff --git a/crates/plugins/src/apply.rs b/crates/plugins/src/apply.rs new file mode 100644 index 00000000..a809228c --- /dev/null +++ b/crates/plugins/src/apply.rs @@ -0,0 +1,235 @@ +//! Running a filter plug-in over a surface, band by band. + +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_geom::{Rect, TILE_SIZE, TileCoord}; +use photocraft_raster::Surface; +use serde_json::{Value, json}; + +use crate::manifest::Area; +use crate::runtime::{Deadline, Plugin}; +use crate::{Error, Result}; + +/// The `format` argument of `pc_filter`: bits 0–7 the source bit depth (8, 16 or 32), bit 8 set +/// when the last channel is (straight) alpha, bits 16–23 the colour mode as numbered in the PSD +/// file format (1 Grayscale, 2 Indexed, 3 RGB, 4 CMYK, 7 Multichannel, 8 Duotone, 9 Lab; +/// 0 Bitmap). +pub fn format_code(f: PixelFormat) -> u32 { + let depth = match f.sample { + SampleType::U8 => 8, + SampleType::U16 => 16, + SampleType::F32 => 32, + }; + let mode = match f.mode { + ColorMode::Bitmap => 0, + ColorMode::Grayscale => 1, + ColorMode::Indexed => 2, + ColorMode::Rgb => 3, + ColorMode::Cmyk => 4, + ColorMode::Multichannel => 7, + ColorMode::Duotone => 8, + ColorMode::Lab => 9, + }; + depth | (u32::from(f.alpha) << 8) | (mode << 16) +} + +fn mode_name(m: ColorMode) -> &'static str { + match m { + ColorMode::Bitmap => "bitmap", + ColorMode::Grayscale => "grayscale", + ColorMode::Indexed => "indexed", + ColorMode::Rgb => "rgb", + ColorMode::Cmyk => "cmyk", + ColorMode::Lab => "lab", + ColorMode::Multichannel => "multichannel", + ColorMode::Duotone => "duotone", + } +} + +/// Reads `rect`, repeating the edge pixels of `extent` outside it (like built-in neighbourhood +/// filters at the canvas edge). +fn read_clamped(s: &Surface, rect: Rect, extent: Rect) -> Vec { + let inner = rect.intersect(&extent); + if inner.is_empty() || inner == rect { + return s.read_region(rect); + } + let src = s.read_region(inner); + let n = s.channels(); + let (w, iw) = (rect.width() as usize, inner.width() as usize); + let mut out = vec![0.0f32; w * rect.height() as usize * n]; + for (row, y) in (rect.y0..rect.y1).enumerate() { + let sy = (y.clamp(inner.y0, inner.y1 - 1) - inner.y0) as usize; + for (col, x) in (rect.x0..rect.x1).enumerate() { + let sx = (x.clamp(inner.x0, inner.x1 - 1) - inner.x0) as usize; + let s = (sy * iw + sx) * n; + let d = (row * w + col) * n; + if let (Some(dst), Some(srcpx)) = (out.get_mut(d..d + n), src.get(s..s + n)) { + dst.copy_from_slice(srcpx); + } + } + } + out +} + +impl Plugin { + /// The rectangle a run writes: the layer's content (grown by the plug-in's overlap) or the + /// canvas, limited to the selection's bounds. + pub fn output_area(&self, surface: &Surface, canvas: Rect, selection: Option<&Surface>) -> Rect { + let content = surface.content_bounds(); + let extent = canvas.union(&content); + let mut area = match self.manifest().area { + Area::Content if content.is_empty() => Rect::EMPTY, + Area::Content => content.inflate(self.manifest().overlap as i32).intersect(&extent), + Area::Canvas => canvas, + }; + if let Some(sel) = selection { + area = area.intersect(&sel.content_bounds()); + } + area + } + + /// Runs the filter on `surface` with user `params` (validated against the manifest) and + /// returns the new surface. `canvas` is the document bounds; `selection` (coverage in + /// channel 0) limits and feathers the result, as for built-in filters. + pub fn apply(&self, surface: &Surface, canvas: Rect, selection: Option<&Surface>, params: &Value) -> Result { + let resolved = self.manifest().resolve_params(params)?; + let area = self.output_area(surface, canvas, selection); + let mut out = surface.clone(); + if area.is_empty() { + return Ok(out); + } + let fmt = surface.format(); + let n = fmt.channels(); + let ov = self.manifest().overlap as i32; + let extent = canvas.union(&surface.content_bounds()); + // Bands are tile-aligned blocks, one tile row tall and as many tiles wide as fit in + // `band_bytes`, so each worker can encode its own tiles and the merge is a tile copy. + let tile_bytes = (TILE_SIZE as usize).pow(2) * n * 4; + // Half the plug-in's memory cap is for the band (the module needs room of its own). + let room = self.limits().max_memory_bytes / 2; + let across = (self.limits().band_bytes.min(room) / tile_bytes).clamp(1, 1024) as i32; + let in_bytes = ((across * TILE_SIZE + 2 * ov) as usize) * ((TILE_SIZE + 2 * ov) as usize) * n * 4; + if in_bytes > room { + return Err(Error::Limit("memory budget (a band does not fit in the plug-in's memory)".into())); + } + let mut bands = Vec::new(); + let (t0, t1) = (TileCoord::containing(area.x0, area.y0), TileCoord::containing(area.x1 - 1, area.y1 - 1)); + for ty in t0.ty..=t1.ty { + let mut tx = t0.tx; + while tx <= t1.tx { + let r = Rect::new(tx * TILE_SIZE, ty * TILE_SIZE, (tx + across).min(t1.tx + 1) * TILE_SIZE, (ty + 1) * TILE_SIZE); + bands.push(r.intersect(&area)); + tx += across; + } + } + let deadline = Deadline::new(self.limits()); + let ctx = BandCtx { surface, extent, canvas, selection, fmt, ov, params: Value::Object(resolved) }; + let run = |b: &Rect| { + let r = self.run_band(&ctx, *b, &deadline).map(|data| encode_band(surface, *b, &data)); + if r.is_err() { + deadline.abort(); + } + r + }; + // A few bands at a time (one per worker), so peak memory stays near one band per thread. + #[cfg(not(target_arch = "wasm32"))] + let group = rayon::current_num_threads().max(1); + #[cfg(target_arch = "wasm32")] + let group = 1; + for chunk in bands.chunks(group) { + #[cfg(not(target_arch = "wasm32"))] + let results: Vec> = { + use rayon::prelude::*; + chunk.par_iter().map(run).collect() + }; + #[cfg(target_arch = "wasm32")] + 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.prune(); + Ok(out) + } + + fn run_band(&self, c: &BandCtx, band: Rect, deadline: &Deadline) -> Result> { + let n = c.fmt.channels(); + let input = band.inflate(c.ov); + let mut data = read_clamped(c.surface, input, c.extent); + let (iw, ih) = (input.width(), input.height()); + let mut params = c.params.clone(); + if let Value::Object(m) = &mut params { + m.insert( + "_image".into(), + json!({ + "x": input.x0, "y": input.y0, "width": iw, "height": ih, "overlap": c.ov, + "canvasWidth": c.canvas.width(), "canvasHeight": c.canvas.height(), + "mode": mode_name(c.fmt.mode), "depth": format_code(c.fmt) & 0xff, "alpha": c.fmt.alpha, "channels": n, + }), + ); + } + let pbytes = serde_json::to_vec(¶ms).map_err(|e| Error::Params(e.to_string()))?; + let orig_full = data.clone(); + let run = + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.filter_buffer(&mut data, iw, ih, n as u32, format_code(c.fmt), &pbytes, deadline))); + run.map_err(|_| Error::Trap("the runtime panicked".into()))??; + // Keep the band (drop the overlap margins), sanitise and blend by the selection. + let (bw, bh) = (band.width() as usize, band.height() as usize); + let ov = c.ov as usize; + let mut out = vec![0.0f32; bw * bh * n]; + let sel_row = c.selection.map(|s| s.format().channels()); + let mut sel = Vec::new(); + let int_depth = c.fmt.sample != SampleType::F32; + for row in 0..bh { + if let (Some(s), Some(sn)) = (c.selection, sel_row) { + let r = Rect::new(band.x0, band.y0 + row as i32, band.x1, band.y0 + row as i32 + 1); + sel = s.read_region(r).chunks_exact(sn.max(1)).map(|p| p.first().copied().unwrap_or(0.0).clamp(0.0, 1.0)).collect(); + } + for col in 0..bw { + let si = ((row + ov) * (bw + 2 * ov) + col + ov) * n; + let di = (row * bw + col) * n; + let k = if c.selection.is_some() { sel.get(col).copied().unwrap_or(0.0) } else { 1.0 }; + for ch in 0..n { + let o = orig_full.get(si + ch).copied().unwrap_or(0.0); + let mut v = data.get(si + ch).copied().unwrap_or(o); + if !v.is_finite() { + v = o; + } + if int_depth || (c.fmt.alpha && ch == n - 1) { + v = v.clamp(0.0, 1.0); + } + if let Some(d) = out.get_mut(di + ch) { + *d = o + (v - o) * k; + } + } + } + } + Ok(out) + } +} + +/// The tiles under `band` with the band's new pixels written in (the rest of each tile is the +/// original), encoded in the surface's format: built on the worker thread. +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()); + } else { + s.tile_mut(c); + } + } + s.write_region(band, data); + s +} + +struct BandCtx<'a> { + surface: &'a Surface, + extent: Rect, + canvas: Rect, + selection: Option<&'a Surface>, + fmt: PixelFormat, + ov: i32, + params: Value, +} diff --git a/crates/plugins/src/lib.rs b/crates/plugins/src/lib.rs new file mode 100644 index 00000000..57ee0004 --- /dev/null +++ b/crates/plugins/src/lib.rs @@ -0,0 +1,55 @@ +//! Sandboxed WebAssembly plug-ins for PhotoCraft. +//! +//! A plug-in is a WebAssembly module that implements the PhotoCraft plug-in ABI (v1, documented +//! in `docs/plugins.md`). It runs in [`wasmi`], a pure-Rust interpreter, with: +//! +//! - **no host imports**: no WASI, file system, network, clock or randomness. A module that +//! imports anything is rejected; +//! - an **instruction budget** (fuel) per call, metered in slices so a wall-clock deadline is +//! also enforced (native builds), so infinite loops end with an error; +//! - a **linear-memory cap**, a **recursion-depth cap** and wasmi's strict module limits; +//! - a **fresh instance per band**, so no state leaks between calls. +//! +//! Every failure (malformed module, trap, exhausted budget, bad output) is an [`Error`]; nothing +//! a module does can panic or hang the host. +//! +//! Pixels are handed over as interleaved, normalised `f32` samples in the document's own colour +//! model (bit-depth and colour-mode agnostic), in horizontal bands so the copy stays bounded. +//! [`Plugin::apply`] runs a filter on a [`photocraft_raster::Surface`], blending by the selection. +//! The process-wide [`registry`] holds the installed plug-ins. +#![forbid(unsafe_code)] +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +mod apply; +pub mod manifest; +pub mod registry; +mod runtime; + +pub use apply::format_code; +pub use manifest::{Area, Kind, Manifest, ParamSpec}; +pub use runtime::{ABI_VERSION, Limits, Plugin}; + +/// A plug-in error. Every way a module can misbehave ends here, never in a panic. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum Error { + #[error("not a valid plug-in module: {0}")] + Module(String), + #[error("plug-in ABI: {0}")] + Abi(String), + #[error("plug-in manifest: {0}")] + Manifest(String), + #[error("plug-in trapped: {0}")] + Trap(String), + #[error("plug-in exceeded its {0}")] + Limit(String), + #[error("plug-in parameters: {0}")] + Params(String), + #[error("plug-in failed: {0}")] + Failed(String), + #[error("no plug-in {0:?} is installed")] + NotFound(String), + #[error("{0}")] + Io(String), +} + +pub type Result = std::result::Result; diff --git a/crates/plugins/src/manifest.rs b/crates/plugins/src/manifest.rs new file mode 100644 index 00000000..460d0270 --- /dev/null +++ b/crates/plugins/src/manifest.rs @@ -0,0 +1,312 @@ +//! The plug-in manifest (`pc_manifest`) and parameter schema. + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value, json}; + +use crate::{Error, Result}; + +/// Longest manifest the host reads. +pub const MAX_MANIFEST_BYTES: usize = 64 * 1024; +/// Most parameters a plug-in may declare. +pub const MAX_PARAMS: usize = 64; +/// Largest neighbourhood (`overlap`) a plug-in may ask for, in pixels. +pub const MAX_OVERLAP: u32 = 256; + +/// What a plug-in is. ABI v1 has filters only. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Kind { + Filter, +} + +/// Which pixels a filter is given. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Area { + /// The layer's non-empty pixels (like Invert or Gaussian Blur). + #[default] + Content, + /// The whole canvas, including transparent pixels (like Clouds). + Canvas, +} + +/// One declared parameter. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum ParamSpec { + /// A real number in `min..=max` (a slider in the dialog). + Number { min: f64, max: f64, default: f64 }, + /// A whole number in `min..=max`. + Int { + #[serde(default = "int_min")] + min: i64, + #[serde(default = "int_max")] + max: i64, + #[serde(default)] + default: i64, + }, + /// A checkbox. + Bool { + #[serde(default)] + default: bool, + }, + /// One of `options` (a dropdown). + Choice { + options: Vec, + #[serde(default)] + default: Option, + }, +} + +fn int_min() -> i64 { + -1_000_000 +} +fn int_max() -> i64 { + 1_000_000 +} + +/// The manifest a plug-in returns from `pc_manifest` (UTF-8 JSON). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Manifest { + /// Stable id, `[A-Za-z0-9._-]{1,64}` (reverse-DNS style is recommended: `org.example.invert`). + pub id: String, + /// Menu label. + pub name: String, + #[serde(default)] + pub version: String, + pub kind: Kind, + #[serde(default)] + pub description: String, + #[serde(default)] + pub author: String, + /// Parameters in declaration (dialog) order; a JSON object `{"name": spec, …}` in the manifest. + #[serde(default, deserialize_with = "ordered_params", serialize_with = "params_as_object")] + pub params: Vec<(String, ParamSpec)>, + /// Pixels of context the filter needs around each output pixel (0 = point filter). + #[serde(default)] + pub overlap: u32, + #[serde(default)] + pub area: Area, +} + +impl Manifest { + /// Parses and validates manifest JSON. + pub fn parse(bytes: &[u8]) -> Result { + if bytes.len() > MAX_MANIFEST_BYTES { + return Err(Error::Manifest(format!("manifest is {} bytes (limit {MAX_MANIFEST_BYTES})", bytes.len()))); + } + let text = std::str::from_utf8(bytes).map_err(|_| Error::Manifest("manifest is not UTF-8".into()))?; + let man: Manifest = serde_json::from_str(text).map_err(|e| Error::Manifest(format!("manifest: {e}")))?; + man.validate()?; + Ok(man) + } + + fn validate(&self) -> Result<()> { + if !valid_id(&self.id) { + return Err(Error::Manifest(format!("invalid id {:?}: use 1-64 characters from A-Z a-z 0-9 . _ -", self.id))); + } + let name = self.name.trim(); + if name.is_empty() || name.chars().count() > 64 || name.chars().any(char::is_control) { + return Err(Error::Manifest("`name` must be 1-64 printable characters".into())); + } + if self.version.len() > 32 || self.description.len() > 1024 || self.author.len() > 128 { + return Err(Error::Manifest("`version`, `description` or `author` is too long".into())); + } + if self.overlap > MAX_OVERLAP { + return Err(Error::Manifest(format!("`overlap` {} exceeds {MAX_OVERLAP}", self.overlap))); + } + for (k, spec) in &self.params { + if !valid_key(k) { + return Err(Error::Manifest(format!("invalid parameter name {k:?}"))); + } + match spec { + ParamSpec::Number { min, max, default } => { + if !(min.is_finite() && max.is_finite() && default.is_finite()) || min > max { + return Err(Error::Manifest(format!("parameter `{k}`: bad range"))); + } + } + ParamSpec::Int { min, max, .. } if min > max => return Err(Error::Manifest(format!("parameter `{k}`: bad range"))), + ParamSpec::Choice { options, default } => { + if options.is_empty() || options.len() > 64 || options.iter().any(|o| o.is_empty() || o.contains(['|', '"'])) { + return Err(Error::Manifest(format!("parameter `{k}`: choices must be 1-64 non-empty strings without | or \""))); + } + if default.as_ref().is_some_and(|d| !options.contains(d)) { + return Err(Error::Manifest(format!("parameter `{k}`: default is not one of the options"))); + } + } + _ => {} + } + } + Ok(()) + } + + /// The parameter notation the command registry and the generated dialogs use + /// (`{"amount":0..100=50,"mode":"a|b","mono":bool=false}`). + pub fn params_notation(&self) -> String { + let parts: Vec = self + .params + .iter() + .map(|(k, spec)| match spec { + ParamSpec::Number { min, max, default } => format!("\"{k}\":{min}..{max}={default}"), + ParamSpec::Int { default, .. } => format!("\"{k}\":int={default}"), + ParamSpec::Bool { default } => format!("\"{k}\":bool={default}"), + ParamSpec::Choice { options, default } => { + // The dialog's default is the first option, so list the default first. + let mut opts = options.clone(); + if let Some(d) = default + && let Some(i) = opts.iter().position(|o| o == d) + { + let d = opts.remove(i); + opts.insert(0, d); + } + format!("\"{k}\":\"{}\"", opts.join("|")) + } + }) + .collect(); + format!("{{{}}}", parts.join(",")) + } + + /// Validates user `params` against the schema: missing keys take their defaults, numbers are + /// clamped into range, unknown keys are dropped; a value of the wrong type is an error. + pub fn resolve_params(&self, params: &Value) -> Result> { + let given = match params { + Value::Null => &Map::new(), + Value::Object(m) => m, + _ => return Err(Error::Params("plug-in parameters must be a JSON object".into())), + }; + let mut out = Map::new(); + for (k, spec) in &self.params { + let v = given.get(k).filter(|v| !v.is_null()); + let wrong = |what: &str| Error::Params(format!("`{k}` must be {what}")); + let resolved = match spec { + ParamSpec::Number { min, max, default } => match v { + None => json!(default), + Some(v) => json!(v.as_f64().filter(|f| f.is_finite()).ok_or_else(|| wrong("a number"))?.clamp(*min, *max)), + }, + ParamSpec::Int { min, max, default } => match v { + None => json!(default), + Some(v) => { + let f = v.as_f64().filter(|f| f.is_finite()).ok_or_else(|| wrong("a number"))?; + json!((f.round().clamp(*min as f64, *max as f64)) as i64) + } + }, + ParamSpec::Bool { default } => match v { + None => json!(default), + Some(v) => json!(v.as_bool().ok_or_else(|| wrong("true or false"))?), + }, + ParamSpec::Choice { options, default } => match v { + None => json!(default.clone().or_else(|| options.first().cloned()).unwrap_or_default()), + Some(v) => { + let s = v.as_str().ok_or_else(|| wrong("a string"))?; + if !options.iter().any(|o| o == s) { + return Err(Error::Params(format!("`{k}` must be one of {}", options.join(", ")))); + } + json!(s) + } + }, + }; + out.insert(k.clone(), resolved); + } + Ok(out) + } +} + +/// Reads the `params` object keeping its key order (serde_json's `Map` would sort it). +fn ordered_params<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result, D::Error> { + struct V; + impl<'de> serde::de::Visitor<'de> for V { + type Value = Vec<(String, ParamSpec)>; + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("an object of parameter specs") + } + fn visit_unit(self) -> std::result::Result { + Ok(Vec::new()) + } + fn visit_map>(self, mut map: A) -> std::result::Result { + let mut out = Vec::new(); + while let Some((k, v)) = map.next_entry::()? { + if out.len() >= MAX_PARAMS { + return Err(serde::de::Error::custom(format!("too many parameters (limit {MAX_PARAMS})"))); + } + out.push((k, v)); + } + Ok(out) + } + } + d.deserialize_any(V) +} + +fn params_as_object(p: &[(String, ParamSpec)], s: S) -> std::result::Result { + use serde::ser::SerializeMap; + let mut m = s.serialize_map(Some(p.len()))?; + for (k, v) in p { + m.serialize_entry(k, v)?; + } + m.end() +} + +/// `[A-Za-z0-9._-]{1,64}`. +pub fn valid_id(id: &str) -> bool { + (1..=64).contains(&id.len()) && id.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')) +} + +fn valid_key(k: &str) -> bool { + (1..=64).contains(&k.len()) && k.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') && !k.starts_with('_') && k != "id" && k != "layer" +} + +#[cfg(test)] +mod tests { + use super::*; + + const FULL: &str = r#"{"id":"org.example.tone","name":"Tone","version":"1.2","kind":"filter", + "params":{"amount":{"type":"number","min":0,"max":100,"default":50}, + "mode":{"type":"choice","options":["soft","hard"],"default":"hard"}, + "mono":{"type":"bool"},"seed":{"type":"int","min":0,"max":9}}, + "overlap":2,"area":"canvas"}"#; + + #[test] + fn parses_and_keeps_param_order() { + let m = Manifest::parse(FULL.as_bytes()).unwrap(); + assert_eq!(m.id, "org.example.tone"); + assert_eq!(m.params.iter().map(|p| p.0.as_str()).collect::>(), ["amount", "mode", "mono", "seed"]); + assert_eq!(m.overlap, 2); + assert_eq!(m.area, Area::Canvas); + assert_eq!(m.params_notation(), r#"{"amount":0..100=50,"mode":"hard|soft","mono":bool=false,"seed":int=0}"#); + } + + #[test] + fn resolves_defaults_clamps_and_rejects_wrong_types() { + let m = Manifest::parse(FULL.as_bytes()).unwrap(); + let p = m.resolve_params(&json!({"amount": 500, "seed": 3.6, "junk": 1})).unwrap(); + assert_eq!(Value::Object(p), json!({"amount": 100.0, "mode": "hard", "mono": false, "seed": 4})); + assert!(m.resolve_params(&json!({"amount": "lots"})).is_err()); + assert!(m.resolve_params(&json!({"mode": "medium"})).is_err()); + assert!(m.resolve_params(&json!({"mono": 1})).is_err()); + assert!(m.resolve_params(&json!([1, 2])).is_err()); + assert!(m.resolve_params(&Value::Null).is_ok()); + } + + #[test] + fn rejects_bad_manifests() { + for bad in [ + "", + "[]", + "not json", + r#"{"id":"","name":"x","kind":"filter"}"#, + r#"{"id":"a b","name":"x","kind":"filter"}"#, + r#"{"id":"a","name":"","kind":"filter"}"#, + r#"{"id":"a","name":"x","kind":"panel"}"#, + r#"{"id":"a","name":"x"}"#, + r#"{"id":"a","name":"x","kind":"filter","overlap":100000}"#, + r#"{"id":"a","name":"x","kind":"filter","params":{"r":{"type":"number","min":5,"max":1,"default":1}}}"#, + r#"{"id":"a","name":"x","kind":"filter","params":{"c":{"type":"choice","options":[]}}}"#, + r#"{"id":"a","name":"x","kind":"filter","params":{"__x":{"type":"bool"}}}"#, + r#"{"id":"a","name":"x","kind":"filter","params":[1]}"#, + ] { + assert!(Manifest::parse(bad.as_bytes()).is_err(), "{bad}"); + } + assert!(Manifest::parse(&[0xff, 0xfe]).is_err()); + assert!(Manifest::parse(&vec![b' '; MAX_MANIFEST_BYTES + 1]).is_err()); + } +} diff --git a/crates/plugins/src/registry.rs b/crates/plugins/src/registry.rs new file mode 100644 index 00000000..92eb5f3f --- /dev/null +++ b/crates/plugins/src/registry.rs @@ -0,0 +1,101 @@ +//! The process-wide set of installed plug-ins. +//! +//! Plug-ins are installed per process (as in Photoshop), not per document: smart filters and +//! filter previews (which run on scratch sessions) must find the same plug-ins. + +use std::collections::BTreeMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, RwLock}; + +#[cfg(not(target_arch = "wasm32"))] +use crate::Error; +use crate::{Limits, Plugin, Result}; + +static PLUGINS: RwLock>> = RwLock::new(BTreeMap::new()); +static REVISION: AtomicU64 = AtomicU64::new(0); + +fn bump() { + REVISION.fetch_add(1, Ordering::Relaxed); +} + +/// Installs `plugin`, replacing one with the same id. +pub fn install(plugin: Plugin) -> Arc { + let p = Arc::new(plugin); + PLUGINS.write().unwrap_or_else(|e| e.into_inner()).insert(p.id().to_string(), p.clone()); + bump(); + p +} + +/// Loads `bytes` with the default limits and installs the plug-in. +pub fn install_bytes(bytes: &[u8]) -> Result> { + Ok(install(Plugin::load(bytes, Limits::default())?)) +} + +pub fn get(id: &str) -> Option> { + PLUGINS.read().unwrap_or_else(|e| e.into_inner()).get(id).cloned() +} + +/// Installed plug-ins, sorted by id. +pub fn list() -> Vec> { + PLUGINS.read().unwrap_or_else(|e| e.into_inner()).values().cloned().collect() +} + +/// Uninstalls `id`; false if it wasn't installed. +pub fn remove(id: &str) -> bool { + let gone = PLUGINS.write().unwrap_or_else(|e| e.into_inner()).remove(id).is_some(); + if gone { + bump(); + } + gone +} + +/// Changes whenever a plug-in is installed or removed (for caches such as menus). +pub fn revision() -> u64 { + REVISION.load(Ordering::Relaxed) +} + +/// Reads a `.wasm` file (refusing anything that isn't a regular file or exceeds the module limit) +/// and loads it. +#[cfg(not(target_arch = "wasm32"))] +pub fn load_file(path: &std::path::Path, limits: Limits) -> Result { + use std::io::Read; + let meta = std::fs::metadata(path).map_err(|e| Error::Io(format!("{}: {e}", path.display())))?; + if !meta.is_file() { + return Err(Error::Io(format!("{} is not a file", path.display()))); + } + if meta.len() > limits.max_module_bytes as u64 { + return Err(Error::Module(format!("{} is {} bytes (limit {})", path.display(), meta.len(), limits.max_module_bytes))); + } + let f = std::fs::File::open(path).map_err(|e| Error::Io(format!("{}: {e}", path.display())))?; + let mut bytes = Vec::new(); + f.take(limits.max_module_bytes as u64 + 1).read_to_end(&mut bytes).map_err(|e| Error::Io(format!("{}: {e}", path.display())))?; + Ok(Plugin::load(&bytes, limits)?.with_source(path.display().to_string())) +} + +/// What [`load_folder`] did. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct FolderReport { + /// Ids of the plug-ins installed. + pub loaded: Vec, + /// `(file, error)` for each `.wasm` that failed to load. + pub failed: Vec<(String, String)>, +} + +/// Installs every `*.wasm` directly in `dir` (not recursive), in file-name order. A bad module is +/// reported and skipped; the others still load. +#[cfg(not(target_arch = "wasm32"))] +pub fn load_folder(dir: &std::path::Path) -> Result { + let rd = std::fs::read_dir(dir).map_err(|e| Error::Io(format!("{}: {e}", dir.display())))?; + let mut files: Vec = + rd.filter_map(|e| e.ok().map(|e| e.path())).filter(|p| p.extension().is_some_and(|x| x.eq_ignore_ascii_case("wasm"))).collect(); + files.sort(); + files.truncate(1000); + let mut report = FolderReport::default(); + for f in files { + match load_file(&f, Limits::default()) { + Ok(p) => report.loaded.push(install(p).id().to_string()), + Err(e) => report.failed.push((f.display().to_string(), e.to_string())), + } + } + Ok(report) +} diff --git a/crates/plugins/src/runtime.rs b/crates/plugins/src/runtime.rs new file mode 100644 index 00000000..c978c607 --- /dev/null +++ b/crates/plugins/src/runtime.rs @@ -0,0 +1,280 @@ +//! The wasmi host: loading, instantiating and calling a plug-in under resource limits. + +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +use wasmi::{ + Config, EnforcedLimits, Engine, Instance, Linker, Memory, Module, Store, StoreLimits, StoreLimitsBuilder, TypedFunc, TypedResumableCall, WasmParams, + WasmResults, +}; + +use crate::manifest::Manifest; +use crate::{Error, Result}; + +/// The plug-in ABI version this host implements (`pc_abi_version` must return it). +pub const ABI_VERSION: i32 = 1; + +/// Fuel handed out per slice; between slices the host checks the deadline and the abort flag. +const FUEL_SLICE: u64 = 20_000_000; + +/// Resource limits for running plug-ins. +#[derive(Clone, Debug, PartialEq)] +pub struct Limits { + /// Largest module accepted. + pub max_module_bytes: usize, + /// Cap on a plug-in's linear memory. + pub max_memory_bytes: usize, + /// Target size of one band of `f32` pixels handed to the plug-in. + pub band_bytes: usize, + /// Instructions (fuel) any call may use, plus [`Limits::fuel_per_sample`] per sample for + /// `pc_filter`. + pub fuel_base: u64, + pub fuel_per_sample: u64, + /// Deepest call nesting. + pub max_recursion_depth: usize, + /// Wall-clock budget for one filter run (all bands), in milliseconds. Native builds only: + /// the web build has no monotonic clock in `std`, so the fuel budget alone bounds it there. + pub wall_time_ms: u64, +} + +impl Default for Limits { + fn default() -> Self { + Self { + max_module_bytes: 32 << 20, + max_memory_bytes: 512 << 20, + band_bytes: 4 << 20, + fuel_base: 50_000_000, + fuel_per_sample: 4_000, + max_recursion_depth: 1024, + wall_time_ms: 60_000, + } + } +} + +/// A loaded, validated plug-in. Cheap to share (`Arc`) and safe to run from several threads: +/// every run gets its own store and instance. +pub struct Plugin { + manifest: Manifest, + engine: Engine, + module: Module, + limits: Limits, + size: usize, + source: Option, +} + +impl std::fmt::Debug for Plugin { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Plugin").field("manifest", &self.manifest).field("size", &self.size).field("source", &self.source).finish() + } +} + +/// When a run must stop: a wall-clock deadline (native) and a flag shared by parallel bands. +#[derive(Clone)] +pub(crate) struct Deadline { + #[cfg(not(target_arch = "wasm32"))] + at: std::time::Instant, + abort: Arc, +} + +impl Deadline { + pub(crate) fn new(limits: &Limits) -> Self { + #[cfg(target_arch = "wasm32")] + let _ = limits; + Self { + #[cfg(not(target_arch = "wasm32"))] + at: std::time::Instant::now() + std::time::Duration::from_millis(limits.wall_time_ms), + abort: Arc::new(AtomicBool::new(false)), + } + } + pub(crate) fn abort(&self) { + self.abort.store(true, Ordering::Relaxed); + } + fn check(&self) -> Result<()> { + if self.abort.load(Ordering::Relaxed) { + return Err(Error::Failed("stopped because another band failed".into())); + } + #[cfg(not(target_arch = "wasm32"))] + if std::time::Instant::now() >= self.at { + self.abort(); + return Err(Error::Limit("time budget".into())); + } + Ok(()) + } +} + +/// One live instance: a store with its own memory limit, and the module's exports. +pub(crate) struct Live { + store: Store, + instance: Instance, + memory: Memory, +} + +fn wasm_err(e: wasmi::Error) -> Error { + if e.as_trap_code() == Some(wasmi::TrapCode::OutOfFuel) { Error::Limit("instruction budget".into()) } else { Error::Trap(e.to_string()) } +} + +impl Plugin { + /// Validates and loads a module: checks the ABI version and reads the manifest. + pub fn load(bytes: &[u8], limits: Limits) -> Result { + if bytes.len() > limits.max_module_bytes { + 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 mut config = Config::default(); + config.consume_fuel(true).enforced_limits(EnforcedLimits::strict()).set_max_recursion_depth(limits.max_recursion_depth.max(16)); + let engine = Engine::new(&config); + let module = Module::new(&engine, bytes).map_err(|e| Error::Module(e.to_string()))?; + if let Some(imp) = module.imports().next() { + return Err(Error::Abi(format!( + "the module imports `{}.{}`, but plug-ins get no host imports (no WASI, files or network)", + imp.module(), + imp.name() + ))); + } + let mut p = Plugin { manifest: placeholder_manifest(), engine, module, limits, size: bytes.len(), source: None }; + let deadline = Deadline::new(&p.limits); + let mut live = p.instantiate(&deadline)?; + let version: TypedFunc<(), i32> = live.func("pc_abi_version")?; + let v = p.call(&mut live, &version, (), p.limits.fuel_base, &deadline)?; + if v != ABI_VERSION { + return Err(Error::Abi(format!("pc_abi_version returned {v}; this host implements version {ABI_VERSION}"))); + } + let manifest: TypedFunc<(), i64> = live.func("pc_manifest")?; + let packed = p.call(&mut live, &manifest, (), p.limits.fuel_base, &deadline)? as u64; + let (ptr, len) = ((packed & 0xffff_ffff) as usize, (packed >> 32) as usize); + if len > crate::manifest::MAX_MANIFEST_BYTES { + return Err(Error::Manifest(format!("manifest is {len} bytes (limit {})", crate::manifest::MAX_MANIFEST_BYTES))); + } + let text = live.memory.data(&live.store).get(ptr..ptr.saturating_add(len)).ok_or_else(|| Error::Abi("pc_manifest points outside memory".into()))?; + p.manifest = Manifest::parse(text)?; + // The other exports must exist with the right types. + live.func::("pc_alloc")?; + live.func::("pc_filter")?; + Ok(p) + } + + pub fn manifest(&self) -> &Manifest { + &self.manifest + } + pub fn id(&self) -> &str { + &self.manifest.id + } + pub fn limits(&self) -> &Limits { + &self.limits + } + /// Module size in bytes. + pub fn size(&self) -> usize { + self.size + } + /// Where the module was loaded from, if it came from a file. + pub fn source(&self) -> Option<&str> { + self.source.as_deref() + } + pub fn with_source(mut self, source: impl Into) -> Self { + self.source = Some(source.into()); + self + } + + pub(crate) fn instantiate(&self, deadline: &Deadline) -> Result { + deadline.check()?; + let limits = StoreLimitsBuilder::new().memory_size(self.limits.max_memory_bytes).memories(1).tables(4).table_elements(100_000).instances(1).build(); + let mut store = Store::new(&self.engine, limits); + store.limiter(|l| l); + // The start function (if any) runs on this fuel. + store.set_fuel(self.limits.fuel_base).map_err(wasm_err)?; + let linker = Linker::::new(&self.engine); + let instance = linker.instantiate_and_start(&mut store, &self.module).map_err(wasm_err)?; + let memory = instance.get_memory(&store, "memory").ok_or_else(|| Error::Abi("the module must export its linear memory as `memory`".into()))?; + Ok(Live { store, instance, memory }) + } + + /// Calls `f` with at most `fuel` instructions, in slices, checking the deadline between them. + pub(crate) fn call(&self, live: &mut Live, f: &TypedFunc, params: P, fuel: u64, deadline: &Deadline) -> Result { + let mut left = fuel; + let give = left.min(FUEL_SLICE); + left -= give; + live.store.set_fuel(give).map_err(wasm_err)?; + let mut call = f.call_resumable(&mut live.store, params).map_err(wasm_err)?; + loop { + match call { + TypedResumableCall::Finished(r) => return Ok(r), + TypedResumableCall::HostTrap(_) => return Err(Error::Trap("unexpected host trap".into())), + TypedResumableCall::OutOfFuel(inv) => { + let need = inv.required_fuel(); + if left == 0 || need > left { + return Err(Error::Limit("instruction budget".into())); + } + deadline.check()?; + let give = left.min(FUEL_SLICE.max(need)); + left -= give; + live.store.set_fuel(give).map_err(wasm_err)?; + call = inv.resume(&mut live.store).map_err(wasm_err)?; + } + } + } + } + + /// Runs `pc_filter` on `data` (interleaved `f32`, `w × h × channels`) in a fresh instance. + #[allow(clippy::too_many_arguments)] + pub(crate) fn filter_buffer(&self, data: &mut [f32], w: u32, h: u32, channels: u32, format: u32, params: &[u8], deadline: &Deadline) -> Result<()> { + let bytes = data.len().checked_mul(4).filter(|b| *b <= i32::MAX as usize && *b + params.len() <= self.limits.max_memory_bytes); + let bytes = bytes.ok_or_else(|| Error::Limit("memory budget (band too large)".into()))?; + let mut live = self.instantiate(deadline)?; + let alloc: TypedFunc = live.func("pc_alloc")?; + let filter: TypedFunc = live.func("pc_filter")?; + let ptr = self.call(&mut live, &alloc, bytes as i32, self.limits.fuel_base, deadline)? as u32 as usize; + let pptr = self.call(&mut live, &alloc, params.len().max(1) as i32, self.limits.fuel_base, deadline)? as u32 as usize; + if ptr == 0 || pptr == 0 { + return Err(Error::Failed("pc_alloc returned 0 (out of memory)".into())); + } + let outside = || Error::Abi("pc_alloc returned a block outside memory".into()); + let end = ptr.checked_add(bytes).ok_or_else(outside)?; + let pend = pptr.checked_add(params.len()).ok_or_else(outside)?; + { + let mem = live.memory.data_mut(&mut live.store); + let dst = mem.get_mut(ptr..end).ok_or_else(outside)?; + for (d, v) in dst.as_chunks_mut::<4>().0.iter_mut().zip(data.iter()) { + *d = v.to_le_bytes(); + } + let pdst = mem.get_mut(pptr..pend).ok_or_else(outside)?; + pdst.copy_from_slice(params); + } + let fuel = self.limits.fuel_base.saturating_add((data.len() as u64).saturating_mul(self.limits.fuel_per_sample)); + let args = (ptr as i32, bytes as i32, w as i32, h as i32, channels as i32, format as i32, pptr as i32, params.len() as i32); + let rc = self.call(&mut live, &filter, args, fuel, deadline)?; + if rc != 0 { + return Err(Error::Failed(format!("pc_filter returned error code {rc}"))); + } + let mem = live.memory.data(&live.store); + let src = mem.get(ptr..end).ok_or_else(|| Error::Abi("pixel buffer no longer in memory".into()))?; + for (v, s) in data.iter_mut().zip(src.as_chunks::<4>().0) { + *v = f32::from_le_bytes(*s); + } + Ok(()) + } +} + +/// `pc_filter(buf_ptr, buf_len, width, height, channels, format, params_ptr, params_len)`. +type FilterArgs = (i32, i32, i32, i32, i32, i32, i32, i32); + +impl Live { + fn func(&self, name: &str) -> Result> { + self.instance.get_typed_func::(&self.store, name).map_err(|e| Error::Abi(format!("export `{name}`: {e}"))) + } +} + +fn placeholder_manifest() -> Manifest { + Manifest { + id: String::new(), + name: String::new(), + version: String::new(), + kind: crate::Kind::Filter, + description: String::new(), + author: String::new(), + params: Vec::new(), + overlap: 0, + area: crate::Area::Content, + } +} diff --git a/crates/plugins/tests/abi.rs b/crates/plugins/tests/abi.rs new file mode 100644 index 00000000..f5c9e593 --- /dev/null +++ b/crates/plugins/tests/abi.rs @@ -0,0 +1,278 @@ +//! The plug-in ABI end to end: the example Invert plug-in, and hostile modules (infinite loops, +//! memory bombs, out-of-bounds access, bad manifests…), which must all fail with an error, quickly. + +use std::time::{Duration, Instant}; + +use photocraft_color::{PixelFormat, SampleType}; +use photocraft_geom::Rect; +use photocraft_plugins::{Error, Limits, Plugin}; +use photocraft_raster::Surface; +use serde_json::json; + +const INVERT: &[u8] = include_bytes!("fixtures/invert.wasm"); + +/// A module implementing the ABI whose pieces can be swapped: `manifest` JSON, `version`, +/// `filter` body (params `$buf $len $w $h $ch $fmt $pp $pl`, result i32) and extra items. +fn module(manifest: &str, version: i32, filter: &str, extra: &str) -> Vec { + let escaped = manifest.replace('\\', "\\\\").replace('"', "\\\""); + let src = format!( + r#"(module + {extra} + (memory (export "memory") 1) + (data (i32.const 16) "{escaped}") + (func (export "pc_abi_version") (result i32) (i32.const {version})) + (func (export "pc_manifest") (result i64) + (i64.or (i64.shl (i64.const {len}) (i64.const 32)) (i64.const 16))) + (func (export "pc_alloc") (param $n i32) (result i32) (local $old i32) + (local.set $old (memory.grow (i32.shr_u (i32.add (local.get $n) (i32.const 65535)) (i32.const 16)))) + (if (result i32) (i32.eq (local.get $old) (i32.const -1)) + (then (i32.const 0)) (else (i32.mul (local.get $old) (i32.const 65536))))) + (func (export "pc_filter") (param $buf i32) (param $len i32) (param $w i32) (param $h i32) + (param $ch i32) (param $fmt i32) (param $pp i32) (param $pl i32) (result i32) + {filter}) +)"#, + len = manifest.len() + ); + wat::parse_str(&src).unwrap_or_else(|e| panic!("bad test WAT: {e}\n{src}")) +} + +const MANIFEST: &str = r#"{"id":"test.plugin","name":"Test","version":"1","kind":"filter"}"#; + +fn ok_module(filter: &str) -> Vec { + module(MANIFEST, 1, filter, "") +} + +fn surface(fmt: PixelFormat, w: i32, h: i32) -> Surface { + let mut s = Surface::new(fmt); + for y in 0..h { + for x in 0..w { + let v = ((x * 7 + y * 13) % 31) as f32 / 30.0; + let a = if (x + y) % 9 == 0 { 0.0 } else { 0.25 + 0.75 * ((x % 4) as f32 / 3.0) }; + s.write_pixel(x, y, &[v, 1.0 - v, (x % 5) as f32 / 4.0, a]); + } + } + s +} + +/// Runs `bytes` as a filter on a small image; returns the error and how long it took. +fn run_err(bytes: &[u8], limits: Limits) -> (Error, Duration) { + let t = Instant::now(); + let r = Plugin::load(bytes, limits).and_then(|p| { + let s = surface(PixelFormat::RGBA8, 32, 24); + p.apply(&s, Rect::new(0, 0, 32, 24), None, &json!({})) + }); + (r.expect_err("hostile module must fail"), t.elapsed()) +} + +#[test] +fn example_invert_loads_and_reports_its_manifest() { + let p = Plugin::load(INVERT, Limits::default()).unwrap(); + let m = p.manifest(); + assert_eq!(m.id, "org.photocraft.example.invert"); + assert_eq!(m.name, "Invert (WebAssembly)"); + assert_eq!(m.kind, photocraft_plugins::Kind::Filter); + assert!(m.params.is_empty()); + assert!(p.size() < 4096, "the example stays tiny: {} bytes", p.size()); +} + +#[test] +fn example_invert_inverts_colour_at_every_depth() { + let p = Plugin::load(INVERT, Limits::default()).unwrap(); + for sample in SampleType::ALL { + let fmt = PixelFormat { sample, ..PixelFormat::RGBA8 }; + let s = surface(fmt, 40, 30); + let out = p.apply(&s, Rect::new(0, 0, 40, 30), None, &json!({})).unwrap(); + for (x, y) in [(1, 1), (0, 0), (17, 3), (39, 29)] { + let (a, b) = (s.pixel(x, y), out.pixel(x, y)); + if a[3] <= 0.0 { + assert_eq!(a, b, "transparent pixels are left alone"); + continue; + } + for c in 0..3 { + assert!((b[c] - (1.0 - a[c])).abs() < 1e-6, "{sample:?} ({x},{y}) c{c}: {a:?} -> {b:?}"); + } + assert_eq!(a[3], b[3], "alpha is kept"); + } + } +} + +#[test] +fn bands_are_seamless_and_selection_blends() { + // Tiny bands: many instances, same result as one band. + let small = Limits { band_bytes: 40 * 4 * 4 * 3, ..Limits::default() }; + let p1 = Plugin::load(INVERT, Limits::default()).unwrap(); + let p2 = Plugin::load(INVERT, small).unwrap(); + let s = surface(PixelFormat::RGBA16, 40, 30); + let canvas = Rect::new(0, 0, 40, 30); + assert_eq!(p1.apply(&s, canvas, None, &json!({})).unwrap(), p2.apply(&s, canvas, None, &json!({})).unwrap()); + + // A half-strength selection over the left half. + let mut sel = Surface::new(PixelFormat::GRAY8); + sel.fill_rect(Rect::new(0, 0, 20, 30), &[1.0]); + sel.fill_rect(Rect::new(0, 0, 20, 10), &[0.5]); + let out = p1.apply(&s, canvas, Some(&sel), &json!({})).unwrap(); + assert_eq!(out.pixel(30, 20), s.pixel(30, 20), "outside the selection"); + let (a, b) = (s.pixel(4, 20), out.pixel(4, 20)); + assert!((b[0] - (1.0 - a[0])).abs() < 1e-4, "fully selected"); + let (a, b) = (s.pixel(5, 5), out.pixel(5, 5)); + let k = sel.pixel(5, 5)[0]; + let half = a[0] + (1.0 - a[0] - a[0]) * k; + assert!((b[0] - half).abs() < 2.0 / 65535.0, "half selected: {a:?} {b:?}"); +} + +#[test] +fn params_and_image_context_reach_the_plugin() { + // Writes params_len / 1000 into every sample, so the host must pass the JSON through. + let fill = r#"(local $i i32) + (block $done (loop $l + (br_if $done (i32.ge_u (local.get $i) (local.get $len))) + (f32.store (i32.add (local.get $buf) (local.get $i)) + (f32.div (f32.convert_i32_u (local.get $pl)) (f32.const 1000))) + (local.set $i (i32.add (local.get $i) (i32.const 4))) + (br $l))) + (i32.const 0)"#; + let man = r#"{"id":"test.params","name":"P","kind":"filter","params":{"amount":{"type":"number","min":0,"max":10,"default":5}}}"#; + let p = Plugin::load(&module(man, 1, fill, ""), Limits::default()).unwrap(); + assert_eq!(p.manifest().params_notation(), r#"{"amount":0..10=5}"#); + let s = surface(PixelFormat::RGBA32F, 8, 8); + let out = p.apply(&s, Rect::new(0, 0, 8, 8), None, &json!({"amount": 3})).unwrap(); + let v = out.pixel(1, 1)[0] * 1000.0; + assert!(v > 100.0 && v < 600.0, "params JSON length {v}"); + // Wrong-typed params are refused before the plug-in runs. + assert!(matches!(p.apply(&s, Rect::new(0, 0, 8, 8), None, &json!({"amount": "x"})), Err(Error::Params(_)))); +} + +#[test] +fn nan_and_out_of_range_output_is_sanitised() { + // Fills the buffer with NaN: every pixel keeps its value. + let nan = r#"(local $i i32) + (block $done (loop $l + (br_if $done (i32.ge_u (local.get $i) (local.get $len))) + (f32.store (i32.add (local.get $buf) (local.get $i)) (f32.const nan)) + (local.set $i (i32.add (local.get $i) (i32.const 4))) + (br $l))) + (i32.const 0)"#; + let p = Plugin::load(&ok_module(nan), Limits::default()).unwrap(); + let s = surface(PixelFormat::RGBA32F, 8, 8); + assert_eq!(p.apply(&s, Rect::new(0, 0, 8, 8), None, &json!({})).unwrap(), s); +} + +#[test] +fn hostile_modules_fail_quickly() { + let quick = Duration::from_secs(5); + let cases: Vec<(&str, Vec)> = vec![ + ("infinite loop", ok_module("(loop $l (br $l)) (i32.const 0)")), + ("memory.grow bomb, then trap", ok_module("(loop $l (br_if $l (i32.ne (memory.grow (i32.const 512)) (i32.const -1)))) unreachable")), + ("memory.grow forever", ok_module("(loop $l (drop (memory.grow (i32.const 1))) (br $l)) (i32.const 0)")), + ("out-of-bounds write", ok_module("(i32.store (i32.const -4) (i32.const 0)) (i32.const 0)")), + ("trap", ok_module("unreachable")), + ("division by zero", ok_module("(i32.div_u (i32.const 1) (local.get $ch)) (drop) (i32.div_u (i32.const 1) (i32.const 0))")), + ("deep recursion", module(MANIFEST, 1, "(call $r) (i32.const 0)", "(func $r (call $r))")), + ("error code", ok_module("(i32.const 7)")), + ("wrong ABI version", module(MANIFEST, 2, "(i32.const 0)", "")), + ("bad manifest", module("not json", 1, "(i32.const 0)", "")), + ("manifest with a bad id", module(r#"{"id":"a b","name":"x","kind":"filter"}"#, 1, "(i32.const 0)", "")), + ("manifest of the wrong kind", module(r#"{"id":"a","name":"x","kind":"panel"}"#, 1, "(i32.const 0)", "")), + ("start function loops", module(MANIFEST, 1, "(i32.const 0)", "(func $spin (loop $l (br $l))) (start $spin)")), + ("WASI import", module(MANIFEST, 1, "(i32.const 0)", r#"(import "wasi_snapshot_preview1" "fd_write" (func (param i32 i32 i32 i32) (result i32)))"#)), + ]; + for (name, bytes) in cases { + let (e, t) = run_err(&bytes, Limits::default()); + assert!(t < quick, "{name} took {t:?}"); + eprintln!("{name}: {e} ({t:?})"); + } +} + +#[test] +fn malformed_and_oversized_modules_are_refused() { + let half = &INVERT[..INVERT.len() / 2]; + for (name, bytes) in [("empty", &b""[..]), ("garbage", &b"\0asm\x01\0\0\0\xff\xff\xff"[..]), ("not wasm", &b"MZ\x90\0"[..]), ("truncated", half)] { + assert!(matches!(Plugin::load(bytes, Limits::default()), Err(Error::Module(_))), "{name}"); + } + let tiny = Limits { max_module_bytes: 100, ..Limits::default() }; + assert!(matches!(Plugin::load(INVERT, tiny), Err(Error::Module(_)))); + // Huge initial memory, manifest outside memory, missing exports, bogus allocations. + let huge = wat::parse_str(r#"(module (memory (export "memory") 60000))"#).unwrap(); + assert!(Plugin::load(&huge, Limits::default()).is_err()); + let no_filter = wat::parse_str( + r#"(module (memory (export "memory") 1) (func (export "pc_abi_version") (result i32) (i32.const 1)) + (func (export "pc_manifest") (result i64) (i64.const 0)))"#, + ) + .unwrap(); + assert!(Plugin::load(&no_filter, Limits::default()).is_err()); + let src = r#"(module (memory (export "memory") 1) (func (export "pc_abi_version") (result i32) (i32.const 1)) + (func (export "pc_manifest") (result i64) (i64.const 0x00000064ffffff00)) + (func (export "pc_alloc") (param i32) (result i32) (i32.const 16)) + (func (export "pc_filter") (param i32 i32 i32 i32 i32 i32 i32 i32) (result i32) (i32.const 0)))"#; + assert!(matches!(Plugin::load(&wat::parse_str(src).unwrap(), Limits::default()), Err(Error::Abi(_)))); + let bogus_alloc = wat::parse_str(format!( + r#"(module (memory (export "memory") 1) (data (i32.const 16) "{}") + (func (export "pc_abi_version") (result i32) (i32.const 1)) + (func (export "pc_manifest") (result i64) (i64.const {})) + (func (export "pc_alloc") (param i32) (result i32) (i32.const -16)) + (func (export "pc_filter") (param i32 i32 i32 i32 i32 i32 i32 i32) (result i32) (i32.const 0)))"#, + MANIFEST.replace('"', "\\\""), + ((MANIFEST.len() as i64) << 32) | 16 + )) + .unwrap(); + let (e, _) = run_err(&bogus_alloc, Limits::default()); + assert!(matches!(e, Error::Abi(_)), "{e}"); +} + +#[test] +fn budgets_stop_runaway_plugins() { + let spin = ok_module("(loop $l (br $l)) (i32.const 0)"); + // Instruction budget. + let (e, _) = run_err(&spin, Limits { fuel_base: 1_000_000, fuel_per_sample: 10, ..Limits::default() }); + assert_eq!(e, Error::Limit("instruction budget".into())); +} + +#[test] +fn wall_clock_budget_stops_runaway_plugins() { + let spin = ok_module("(loop $l (br $l)) (i32.const 0)"); + // Wall-clock budget, with an effectively unlimited instruction budget. + let (e, t) = run_err(&spin, Limits { fuel_base: u64::MAX / 4, wall_time_ms: 300, ..Limits::default() }); + assert_eq!(e, Error::Limit("time budget".into())); + assert!(t < Duration::from_secs(3), "{t:?}"); +} + +#[test] +fn memory_cap_limits_bands() { + // Memory cap: a band that can't fit is refused up front. + let s = surface(PixelFormat::RGBA8, 64, 8); + let p = Plugin::load(INVERT, Limits { max_memory_bytes: 4 << 20, band_bytes: 64 << 20, ..Limits::default() }).unwrap(); + assert!(p.apply(&s, Rect::new(0, 0, 64, 8), None, &json!({})).is_ok(), "small bands still fit"); + let p = Plugin::load(INVERT, Limits { max_memory_bytes: 1024, ..Limits::default() }); + assert!(p.is_err() || p.unwrap().apply(&s, Rect::new(0, 0, 64, 8), None, &json!({})).is_err()); +} + +#[test] +fn registry_install_list_remove() { + // Its own id: the registry is process-wide and tests run in parallel. + let id = "test.registry"; + let p = photocraft_plugins::registry::install_bytes(&module(r#"{"id":"test.registry","name":"R","kind":"filter"}"#, 1, "(i32.const 0)", "")).unwrap(); + assert_eq!(p.id(), id); + let rev = photocraft_plugins::registry::revision(); + assert!(photocraft_plugins::registry::list().iter().any(|p| p.id() == id)); + assert!(photocraft_plugins::registry::get(id).is_some()); + assert!(photocraft_plugins::registry::remove(id)); + assert!(!photocraft_plugins::registry::remove(id)); + assert!(photocraft_plugins::registry::get(id).is_none()); + assert!(photocraft_plugins::registry::revision() > rev); +} + +#[test] +fn folder_loading_skips_bad_files() { + let dir = std::env::temp_dir().join(format!("photocraft-plugins-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("a-invert.wasm"), INVERT).unwrap(); + std::fs::write(dir.join("b-broken.wasm"), b"\0asm junk").unwrap(); + std::fs::write(dir.join("notes.txt"), b"ignored").unwrap(); + let r = photocraft_plugins::registry::load_folder(&dir).unwrap(); + assert_eq!(r.loaded, ["org.photocraft.example.invert"]); + assert_eq!(r.failed.len(), 1); + assert!(r.failed[0].0.ends_with("b-broken.wasm")); + assert!(photocraft_plugins::registry::load_folder(&dir.join("missing")).is_err()); + assert!(photocraft_plugins::registry::load_file(&dir, Limits::default()).is_err(), "a directory is not a module"); + std::fs::remove_dir_all(&dir).ok(); +} diff --git a/crates/plugins/tests/fixtures/invert.wasm b/crates/plugins/tests/fixtures/invert.wasm new file mode 100755 index 00000000..377028a0 Binary files /dev/null and b/crates/plugins/tests/fixtures/invert.wasm differ diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 78f2d3aa..d6b6f22a 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -15,6 +15,7 @@ photocraft-doc = { workspace = true } photocraft-compose = { workspace = true } photocraft-gpu = { workspace = true } photocraft-engine = { workspace = true } +photocraft-plugins = { workspace = true } photocraft-algo = { workspace = true } photocraft-text = { workspace = true } egui = { workspace = true } diff --git a/crates/ui-egui/src/filter_dialog.rs b/crates/ui-egui/src/filter_dialog.rs index 6ee72f7c..ba5b68a0 100644 --- a/crates/ui-egui/src/filter_dialog.rs +++ b/crates/ui-egui/src/filter_dialog.rs @@ -201,6 +201,28 @@ pub fn open(app: &mut PhotocraftApp, command: &str) -> Option { Some(id) } +/// A filter dialog with live preview for `command` whose parameters follow `spec` (registry +/// notation) instead of the command's own; `fixed` params (e.g. a plug-in id) are passed through. +pub fn open_with_spec(app: &mut PhotocraftApp, command: &str, label: &str, spec: &str, fixed: Map) -> u64 { + let mut fields = fixed; + fields.insert("__command".into(), json!(command)); + fields.insert("__label".into(), json!(label)); + fields.insert("__filter".into(), json!(true)); + fields.insert("__preview".into(), json!(true)); + fields.insert("__spec".into(), json!(spec)); + for p in parse_spec(spec) { + let v = match &p.kind { + Kind::Range { default, .. } => json!(default), + Kind::Choice(c) => json!(c.first().cloned().unwrap_or_default()), + Kind::Bool(default) => json!(default), + Kind::Int { default } => json!(default), + _ => continue, + }; + fields.insert(p.key, v); + } + app.ui.open_dialog(crate::state::DialogKind::Command, fields) +} + pub(crate) fn label(key: &str) -> String { // camelCase → "Camel Case" let mut s = String::new(); @@ -225,8 +247,15 @@ fn choice_label(v: &str) -> String { pub fn body(ui: &mut egui::Ui, f: &mut Map) { let t = Tokens::get(ui.ctx()); let cmd = f.get("__command").and_then(Value::as_str).unwrap_or_default().to_string(); - let Some(spec) = photocraft_engine::commands::find(&cmd) else { return }; - for p in parse_spec(spec.params) { + // Plug-in dialogs carry their own spec (from the plug-in's manifest). + let spec = match f.get("__spec").and_then(Value::as_str) { + Some(s) => s.to_string(), + None => match photocraft_engine::commands::find(&cmd) { + Some(c) => c.params.to_string(), + None => return, + }, + }; + for p in parse_spec(&spec) { match p.kind { Kind::Range { min, max, default } => { let mut v = f.get(&p.key).and_then(Value::as_f64).unwrap_or(default as f64) as f32; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 50d61283..2d4b17c4 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -47,6 +47,7 @@ pub mod palette; pub mod panels; pub mod parity; pub mod perspective_ui; +pub mod plugin_ui; pub mod prefs_ui; pub mod preset_panels; pub mod proxy; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index c7d35d7d..ba31bfb0 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -137,6 +137,10 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: if let Some(r) = crate::timeline_ui::menu(app, id, ¶ms) { return r; } + // Filter › Plug-ins (installed WebAssembly plug-ins, Install Plug-in…). + if let Some(r) = crate::plugin_ui::menu(app, id, ¶ms) { + return r; + } if id == "window.panel.brushes" { // Window › Brushes opens the Brush Settings window on its presets tab. app.ui.panels.brush_settings = true; @@ -417,6 +421,9 @@ pub fn is_enabled(app: &PhotocraftApp, id: &str) -> bool { if let Some(e) = crate::view_cmds::is_enabled(app, id) { return e; } + if let Some(e) = crate::plugin_ui::is_enabled(app, id) { + return e; + } match id { "file.open" | "file.exit" | "file.clearRecent" | "help.about" | "edit.search" => true, i if i.starts_with("file.openRecent.") => true, @@ -598,6 +605,7 @@ pub fn menu_items(app: &PhotocraftApp) -> Vec { items.insert(at, e); } } + crate::plugin_ui::insert_menu_items(app, &mut items); // File › Open Recent: a dynamic submenu of recently opened files (inserted after "Open As…"). if let Some(after) = items.iter().position(|i| i.id == "file.openAs") { let rp: Vec = vec!["File".into(), "Open Recent".into()]; diff --git a/crates/ui-egui/src/plugin_ui.rs b/crates/ui-egui/src/plugin_ui.rs new file mode 100644 index 00000000..38de85ac --- /dev/null +++ b/crates/ui-egui/src/plugin_ui.rs @@ -0,0 +1,96 @@ +//! Filter › Plug-ins: one menu item per installed WebAssembly filter plug-in (`plugin.*` engine +//! commands), with a generated dialog and live preview for plug-ins that take parameters. + +use serde_json::{Map, Value, json}; + +use crate::PhotocraftApp; +use crate::menus::MenuItem; + +/// Menu ids of installed plug-ins: `plugin.filter.`. +const PREFIX: &str = "plugin.filter."; + +/// Inserts the installed plug-ins into Filter › Plug-ins, above "Install Plug-in…". +pub fn insert_menu_items(app: &PhotocraftApp, items: &mut Vec) { + let plugins = photocraft_plugins::registry::list(); + let Some(at) = items.iter().position(|i| i.id == "plugin.install") else { return }; + let path: Vec = vec!["Filter".into(), "Plug-ins".into()]; + let enabled = app.session.is_enabled("plugin.run"); + let mut extra: Vec = plugins + .iter() + .map(|p| { + let m = p.manifest(); + let dots = if m.params.is_empty() || m.name.ends_with('…') { "" } else { "…" }; + MenuItem { + id: format!("{PREFIX}{}", m.id), + label: format!("{}{dots}", m.name), + path: path.clone(), + shortcut: None, + enabled, + checked: None, + color: None, + } + }) + .collect(); + if !extra.is_empty() { + extra.push(MenuItem { id: "---".into(), label: "---".into(), path: path.clone(), shortcut: None, enabled: false, checked: None, color: None }); + } + for (k, it) in extra.into_iter().enumerate() { + items.insert(at + k, it); + } +} + +/// Enabled state of a plug-in menu item (`None` for other ids). +pub fn is_enabled(app: &PhotocraftApp, id: &str) -> Option { + let pid = id.strip_prefix(PREFIX)?; + Some(photocraft_plugins::registry::get(pid).is_some() && app.session.is_enabled("plugin.run")) +} + +/// Runs a plug-in menu item: straight away when it has no parameters (or params were given), +/// else through its dialog. "Install Plug-in…" without params opens its path dialog. +pub fn menu(app: &mut PhotocraftApp, id: &str, params: &Value) -> Option> { + let given = params.as_object().is_some_and(|o| !o.is_empty()); + if id == "plugin.install" && !given { + return Some(Ok(json!({"dialog": crate::filter_dialog::open(app, "plugin.install")}))); + } + let pid = id.strip_prefix(PREFIX)?; + let Some(plugin) = photocraft_plugins::registry::get(pid) else { return Some(Err(format!("no plug-in {pid:?} is installed"))) }; + let m = plugin.manifest(); + if m.params.is_empty() || given { + let mut p = params.as_object().cloned().unwrap_or_default(); + p.insert("id".into(), json!(pid)); + return Some(app.run("plugin.run", Value::Object(p))); + } + let mut fixed = Map::new(); + fixed.insert("id".into(), json!(pid)); + let d = crate::filter_dialog::open_with_spec(app, "plugin.run", &m.name, &m.params_notation(), fixed); + Some(Ok(json!({"dialog": d}))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn installed_plugins_appear_under_filter_plugins_and_run() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../plugins/tests/fixtures/invert.wasm"); + app.run("plugin.install", json!({"path": path})).unwrap(); + let items = crate::menus::menu_items(&app); + let pi: Vec<&MenuItem> = items.iter().filter(|i| i.path == ["Filter", "Plug-ins"]).collect(); + let me = pi.iter().find(|i| i.id == "plugin.filter.org.photocraft.example.invert").expect("plug-in listed"); + assert_eq!(me.label, "Invert (WebAssembly)"); + assert!(!me.enabled, "no document yet"); + assert!(pi.iter().any(|i| i.id == "plugin.install"), "Install Plug-in… is in the same submenu"); + app.run("file.new", json!({"width": 16, "height": 16})).unwrap(); + assert!(crate::menus::is_enabled(&app, "plugin.filter.org.photocraft.example.invert")); + let px = |app: &PhotocraftApp| app.session.active().unwrap().doc.layers[0].surface().unwrap().pixel(1, 1); + let before = px(&app); + crate::menus::invoke(&mut app, &ctx, "plugin.filter.org.photocraft.example.invert", json!({})).unwrap(); + assert!((px(&app)[0] - (1.0 - before[0])).abs() < 1e-6); + assert!(crate::menus::invoke(&mut app, &ctx, "plugin.filter.not.installed", json!({})).is_err()); + // Install Plug-in… without params opens its dialog instead of failing. + let r = crate::menus::invoke(&mut app, &ctx, "plugin.install", json!({})).unwrap(); + assert!(r.get("dialog").is_some()); + } +} diff --git a/docs/architecture.md b/docs/architecture.md index 4d24b389..1bb88dbf 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -103,6 +103,7 @@ photocraft/ │ ├─ io/ photocraft-io import/export orchestration; doc ⇄ PSD mapping; PDF/SVG import (features) │ │ ── intelligence ── │ ├─ ml/ photocraft-ml model registry + InferenceBackend trait (ort native / burn|candle web); SAM2, matting, depth, sky +│ ├─ plugins/ photocraft-plugins sandboxed WebAssembly filter plug-ins (wasmi host, limits, ABI v1, registry) │ │ ── the façade every frontend talks to ── │ ├─ tools/ photocraft-tools tool state machines (move, marquee, lasso, brush, clone, gradient, crop, transform, pen, text…) │ ├─ engine/ photocraft-engine Session: open docs, command registry + dispatch, jobs, events, view-models, preferences @@ -122,7 +123,7 @@ photocraft/ └─ plan/ this directory ``` -**What exists today.** This layout is the target design. Built so far: `geom`, `cms`, `color`, `raster`, `psd`, `codecs`, `doc`, `ops`, `algo`, `paint`, `text`, `vector`, `compose`, `gpu`, `format`, `raw`, `io`, `engine`, `ui-egui`, `automation`, `testkit`, and the three apps. Not yet split out: `viewport` and `tools` live inside `ui-egui` and `engine`; `platform` services are function hooks injected by each app (`ui_egui::Services`); `adobe-assets` and `ml` are not started. +**What exists today.** This layout is the target design. Built so far: `geom`, `cms`, `color`, `raster`, `psd`, `codecs`, `doc`, `ops`, `algo`, `paint`, `text`, `vector`, `compose`, `gpu`, `format`, `raw`, `io`, `plugins`, `engine`, `ui-egui`, `automation`, `testkit`, and the three apps. Not yet split out: `viewport` and `tools` live inside `ui-egui` and `engine`; `platform` services are function hooks injected by each app (`ui_egui::Services`); `adobe-assets` and `ml` are not started. **Crate granularity:** start with the crates above. Split `algo` into `-adjust`, `-filters`, `-select`, `-inpaint` and `-warp` once any module passes about 10k lines, or once compile times hurt. Its internal module boundaries should already follow those lines. @@ -136,7 +137,7 @@ photocraft/ L7 apps/* (binaries: wire everything together) L6 ui-egui · automation · platform L5 engine - L4 tools · viewport · io · ml + L4 tools · viewport · io · ml · plugins L3 compose · gpu · format L2 ops · algo · paint · text · vector L1 doc @@ -430,7 +431,7 @@ psd/src/ - Stdio for agent CLIs, and optionally loopback TCP with a token so it can attach to a running GUI. - **Actions:** recorded `Vec`, replayable in batch (File → Automate → Batch). - **Scripting (later):** embed a scripting language over the same registry. Options are Rhai, or Lua via mlua (C). JS via QuickJS is possible if we want Photoshop-script familiarity. -- **Plugins (later):** a WASM component-model plugin API (wasmtime) for filters and panels. It is sandboxed and portable, and fits the command/schema model. Photoshop CEP/UXP/8BF compatibility is explicitly out of scope for v1. +- **Plugins:** sandboxed WebAssembly filter plug-ins (`photocraft-plugins`, L4) run by `wasmi`, a pure-Rust interpreter, with fuel, memory, stack and wall-time limits and no host imports. They are driven by the `plugin.*` commands and listed under Filter › Plug-ins; the ABI is in [`plugins.md`](plugins.md). Native Photoshop `.8BF`/CEP/UXP hosting is out of scope (it needs unsafe FFI and can't run on the web). Panel plug-ins are later. --- @@ -535,4 +536,4 @@ Each phase ends with a demoable build and green CI on all targets. Phases 8–10 3. **Project name.** `photocraft` (the repo name) is assumed. 4. **PSD crate.** Build our own, or adopt or fork `ag-psd`/`psd`. Decide after the week-1 spike. 5. **Default working depth and blending.** 8-bit + gamma-space blending matches Photoshop. 16-bit/float + linear is the modern default. The proposal is to follow Photoshop by default and make the other a per-document option. -6. **Scope of Photoshop plugin compatibility.** Proposed: none in v1. +6. **Scope of Photoshop plugin compatibility.** Decided: no native `.8BF` hosting; a sandboxed WebAssembly plug-in API instead (see [`plugins.md`](plugins.md)). diff --git a/docs/parity.md b/docs/parity.md index 34ba6e48..da7cfa87 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -2,11 +2,11 @@ Generated by `cargo xtask parity` from `crates/ui-egui/src/menu_catalog.rs` (Photoshop's menu tree) and the live command registry. Do not edit by hand. -**625 of 625 menu items live (100.0%).** +**626 of 626 menu items live (100.0%).** | Menu | Live | Total | % | |---|---:|---:|---:| -| File | 50 | 50 | 100% | +| File | 51 | 51 | 100% | | Edit | 75 | 75 | 100% | | Image | 61 | 61 | 100% | | Layer | 160 | 160 | 100% | diff --git a/docs/plugins.md b/docs/plugins.md new file mode 100644 index 00000000..bc75ff1e --- /dev/null +++ b/docs/plugins.md @@ -0,0 +1,171 @@ +# Plug-ins: the WebAssembly plug-in API (ABI v1) + +PhotoCraft does not host native Photoshop plug-ins (`.8BF`, CEP, UXP): that needs unsafe FFI into +arbitrary machine code and cannot work in the browser build. Instead, plug-ins are **WebAssembly +modules** run in a sandbox. One `.wasm` file works on macOS, Windows, Linux and the web. + +This page is the contract for plug-in authors and the reference for the host +(`crates/plugins`, the `plugin.*` commands in `crates/engine/src/plugin_cmds.rs`). + +## Using plug-ins + +| Command | Params | What it does | +|---|---|---| +| `plugin.install` | `{"path": "/x/y.wasm"}` (native) or `{"data": ""}`, `"replace": bool = true` | Validates and installs a module; returns its description. | +| `plugin.list` | `{}` | `{"plugins": [{id, name, version, kind, author, description, params, paramsSchema, overlap, area, size, source}], "folder": report}` | +| `plugin.run` | `{"id": "…", "params": {…}}` (plug-in params may also be top-level keys) | Runs a filter plug-in on the active pixel layer, targeted alpha channel / Quick Mask, or smart object. One undo step. | +| `plugin.remove` | `{"id": "…"}` | Uninstalls. | +| `plugin.reload` | `{"path": "folder"}` (default: the preference folder) | Loads every `*.wasm` in a folder; a bad module is reported and skipped. | + +In the app, installed filters appear under **Filter › Plug-ins** (with a generated dialog and live +preview when they have parameters), next to **Install Plug-in…**. + +**Preference folder.** Edit › Preferences › Plug-ins › *Use Additional Plug-ins Folder* plus +*Additional Plug-ins Folder*: when both are set, every `*.wasm` in that folder (not recursive) is +installed at startup and whenever the preference changes (native builds only; the web build installs +from bytes). + +Plug-ins are installed **per process**, not per document. A smart filter recorded from a plug-in +(`{"command": "plugin.run", "params": {"id", "params"}}`) re-runs on re-render while that plug-in is +installed; if it is missing, the filter is skipped (the cached pixels stay as they were), like other +filters PhotoCraft does not implement. + +## The sandbox + +The host is [`wasmi`](https://github.com/wasmi-labs/wasmi), a pure-Rust WebAssembly interpreter +(MIT OR Apache-2.0). It builds for `wasm32-unknown-unknown` too, so plug-ins also run in the web +build. Limits (`photocraft_plugins::Limits`, defaults): + +| Limit | Default | On violation | +|---|---|---| +| Host imports | none: no WASI, file system, network, clock or randomness | module rejected at install | +| Module size | 32 MiB | rejected | +| Linear memory | 512 MiB per instance | `memory.grow` returns -1; oversized initial memory fails | +| Instructions (fuel) | 50 M per call + 4,000 per sample for `pc_filter` | `Limit("instruction budget")` | +| Wall clock | 60 s per run (native; checked every 20 M instructions) | `Limit("time budget")` | +| Call depth | 1,024 frames | trap | +| Module shape | wasmi's strict limits (≤ 10,000 functions, 1 memory, …) | rejected | + +Every band runs in a **fresh instance**, so no state survives between calls, and parallel bands +never share memory. Traps, exhausted budgets, malformed modules, bad manifests, out-of-range +pointers and non-zero return codes all become errors; the document is left untouched (the edit is +transactional). Output samples that are NaN or infinite keep their original value; integer-depth +documents and alpha are clamped to 0–1. + +wasmi is built with its *portable* (loop) dispatcher: the default tail-call dispatcher relies on LLVM +sibling-call optimisation, and when that doesn't happen a long-running module overflows the host +stack, which aborts the process instead of returning an error. + +## ABI v1 + +A plug-in is a core WebAssembly module (MVP + the usual post-MVP features: bulk memory, sign +extension, multi-value, saturating conversions; no SIMD, no memory64) with **no imports** and these +exports. All integers are `i32` unless noted; pointers are offsets into the module's memory. + +| Export | Signature | Meaning | +|---|---|---| +| `memory` | memory | The module's linear memory. | +| `pc_abi_version` | `() -> i32` | Must return `1`. | +| `pc_manifest` | `() -> i64` | `(len << 32) \| ptr` of the UTF-8 manifest JSON (≤ 64 KiB). | +| `pc_alloc` | `(size) -> ptr` | A block of `size` bytes the host may write, or `0` on failure. Never freed: each band gets a fresh instance. | +| `pc_filter` | `(buf, buf_len, width, height, channels, format, params, params_len) -> i32` | Filters the pixels in place; `0` = success, anything else is an error code. | + +### Manifest + +```json +{ + "id": "org.example.tone", + "name": "Tone…", + "version": "1.0.0", + "kind": "filter", + "author": "Example", + "description": "What it does.", + "params": { + "amount": {"type": "number", "min": 0, "max": 100, "default": 50}, + "steps": {"type": "int", "min": 1, "max": 16, "default": 4}, + "mono": {"type": "bool", "default": false}, + "mode": {"type": "choice", "options": ["soft", "hard"], "default": "soft"} + }, + "overlap": 0, + "area": "content" +} +``` + +- `id`: 1–64 characters from `A-Z a-z 0-9 . _ -` (reverse-DNS recommended). Installing a module + with the same id replaces the old one. +- `name`: the menu label (1–64 printable characters). `kind`: `"filter"` (the only kind in v1). +- `params`: in dialog order. Names are `[A-Za-z0-9_]`, not starting with `_`, not `id`/`layer`. + The host validates the user's values before calling: missing values take the default, numbers are + clamped to `min..max` (ints rounded), a wrong type or an unknown choice is an error. +- `overlap` (0–256): pixels of context the filter needs around each output pixel (a blur radius). + The host pads each band by `overlap` on every side, repeating the edge pixels of the canvas (or of + off-canvas layer content), and keeps only the inner rectangle of the result. +- `area`: `"content"` (default) filters the layer's non-transparent bounds grown by `overlap`; + `"canvas"` filters the whole canvas, including transparent pixels (for generators). + +### Pixels + +`buf` holds `width × height × channels` **little-endian `f32` samples**, interleaved, row-major, +normalised to 0–1 at every bit depth (32-bit documents may hold values above 1). The buffer is +`width × height` including the overlap margins. Channels are the document's own colour model, with no +conversion: + +`format` = `depth | alpha << 8 | mode << 16` + +- `depth` (bits 0–7): the document's bit depth, 8, 16 or 32 (informational: samples are always `f32`). +- `alpha` (bit 8): 1 when the last channel is (straight, not premultiplied) alpha. +- `mode` (bits 16–23): the colour mode, numbered as in the PSD file format: 0 Bitmap, 1 Grayscale, + 2 Indexed (stored as RGB), 3 RGB, 4 CMYK, 7 Multichannel, 8 Duotone (stored as gray), 9 Lab. + +Alpha channels and Quick Mask targets arrive as one grayscale channel. + +The host converts the layer into bands (tile-aligned blocks of at most ~4 MiB of `f32`), runs them in +parallel on native builds, writes the results back in the layer's format, then blends with the +selection: `out = original + (filtered − original) × coverage`, exactly as built-in filters do. A +plug-in never sees the selection and needs nothing special to support it. + +### Parameters + +`params` points at `params_len` bytes of UTF-8 JSON: the validated parameters, plus `_image` with +the band's geometry: + +```json +{"amount": 50, "mode": "soft", + "_image": {"x": 0, "y": 256, "width": 1024, "height": 256, "overlap": 0, + "canvasWidth": 6000, "canvasHeight": 4000, + "mode": "rgb", "depth": 8, "alpha": true, "channels": 4}} +``` + +`x, y` is the document position of the buffer's top-left sample (including the margins), so +position-dependent filters (gradients, vignettes) are seamless across bands. + +## Writing a plug-in in Rust + +[`examples/plugins/invert-rs`](../examples/plugins/invert-rs) is a complete `no_std` plug-in +(no allocator, under 1 KiB built). Build it with: + +```sh +rustup target add wasm32-unknown-unknown +examples/plugins/invert-rs/build.sh # cargo build --release --target wasm32-unknown-unknown +``` + +The script also refreshes `crates/plugins/tests/fixtures/invert.wasm`, the test fixture built from +that source. Any language that targets core WebAssembly works (C, Zig, AssemblyScript, WAT…), as +long as the module has no imports: in Rust, that means `no_std` or a `std` build that never touches +WASI. + +## Performance + +The interpreter costs roughly 10–100× native speed per instruction, so plug-ins suit per-pixel and +small-neighbourhood filters. Because bands run in parallel, the example Invert on a 6000 × 4000 +RGBA 8-bit layer (24 MP, release, 14-core Apple Silicon under heavy load) took 1.1–1.6 s through +`plugin.run`, versus 3.6–5.1 s for the (single-threaded) built-in Image › Adjustments › Invert +(`cargo run --release -p photocraft-engine --example bench_plugin_invert`; plug-in alone: +`cargo run --release -p photocraft-plugins --example bench_invert`). Peak extra memory is about three bands per worker thread (input, +original for the selection blend, and the instance's memory), i.e. ~12 MiB per thread. + +## Not in v1 + +- Panel / UI plug-ins, plug-ins that read other layers or documents, host callbacks (progress, + cancel), plug-in-provided file formats. +- Filter › Last Filter does not repeat plug-in runs yet. diff --git a/examples/plugins/invert-rs/.gitignore b/examples/plugins/invert-rs/.gitignore new file mode 100644 index 00000000..2f7896d1 --- /dev/null +++ b/examples/plugins/invert-rs/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/examples/plugins/invert-rs/Cargo.lock b/examples/plugins/invert-rs/Cargo.lock new file mode 100644 index 00000000..5497167d --- /dev/null +++ b/examples/plugins/invert-rs/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "photocraft-plugin-invert" +version = "1.0.0" diff --git a/examples/plugins/invert-rs/Cargo.toml b/examples/plugins/invert-rs/Cargo.toml new file mode 100644 index 00000000..ecd68668 --- /dev/null +++ b/examples/plugins/invert-rs/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "photocraft-plugin-invert" +description = "Example PhotoCraft WebAssembly filter plug-in: Invert" +version = "1.0.0" +edition = "2024" +license = "MIT OR Apache-2.0" +publish = false + +# Not a member of the PhotoCraft workspace: it builds for wasm32-unknown-unknown only. +[workspace] + +[lib] +crate-type = ["cdylib"] + +[profile.release] +opt-level = "s" +lto = true +codegen-units = 1 +panic = "abort" +strip = true diff --git a/examples/plugins/invert-rs/README.md b/examples/plugins/invert-rs/README.md new file mode 100644 index 00000000..7fd5de9f --- /dev/null +++ b/examples/plugins/invert-rs/README.md @@ -0,0 +1,27 @@ +# Invert: an example PhotoCraft plug-in + +A complete WebAssembly filter plug-in in about 70 lines of `no_std` Rust: no allocator, no imports, +under 1 KiB built. It inverts the colour channels and leaves alpha alone, matching +Image › Adjustments › Invert. The ABI is documented in [`docs/plugins.md`](../../../docs/plugins.md). + +## Build + +```sh +rustup target add wasm32-unknown-unknown +./build.sh +``` + +`build.sh` runs `cargo build --release --target wasm32-unknown-unknown` and copies the module to +`crates/plugins/tests/fixtures/invert.wasm`, the test fixture the host's tests use. This crate is not +a member of the PhotoCraft workspace. + +## Install + +```sh +# Into a running app (control channel) or the CLI, as a command: +plugin.install {"path": "examples/plugins/invert-rs/target/wasm32-unknown-unknown/release/photocraft_plugin_invert.wasm"} +plugin.run {"id": "org.photocraft.example.invert"} +``` + +Or copy the `.wasm` into the folder set in Edit › Preferences › Plug-ins (with *Use Additional +Plug-ins Folder* on); it then appears under Filter › Plug-ins. diff --git a/examples/plugins/invert-rs/build.sh b/examples/plugins/invert-rs/build.sh new file mode 100755 index 00000000..83e6103f --- /dev/null +++ b/examples/plugins/invert-rs/build.sh @@ -0,0 +1,9 @@ +#!/bin/sh +# Builds the Invert example plug-in and refreshes the test fixture built from it. +# Needs the wasm32-unknown-unknown target: `rustup target add wasm32-unknown-unknown`. +set -eu +cd "$(dirname "$0")" +cargo build --release --target wasm32-unknown-unknown +out=target/wasm32-unknown-unknown/release/photocraft_plugin_invert.wasm +cp "$out" ../../../crates/plugins/tests/fixtures/invert.wasm +echo "built $out ($(wc -c < "$out") bytes) -> crates/plugins/tests/fixtures/invert.wasm" diff --git a/examples/plugins/invert-rs/src/lib.rs b/examples/plugins/invert-rs/src/lib.rs new file mode 100644 index 00000000..ed0d2edc --- /dev/null +++ b/examples/plugins/invert-rs/src/lib.rs @@ -0,0 +1,81 @@ +//! Invert: a minimal PhotoCraft filter plug-in (plug-in ABI v1, see `docs/plugins.md`). +//! +//! `no_std`, no allocator, no imports: the host hands us an interleaved `f32` buffer and we +//! invert the colour channels in place, leaving alpha (and fully transparent pixels) alone, +//! exactly like Image › Adjustments › Invert. +#![no_std] + +#[panic_handler] +fn panic(_: &core::panic::PanicInfo) -> ! { + core::arch::wasm32::unreachable() +} + +const MANIFEST: &str = r#"{ + "id": "org.photocraft.example.invert", + "name": "Invert (WebAssembly)", + "version": "1.0.0", + "kind": "filter", + "author": "PhotoCraft contributors", + "description": "Inverts the colour channels; an example of the PhotoCraft plug-in ABI.", + "params": {} +}"#; + +/// Bit 8 of the `format` argument: the last channel is alpha. +const HAS_ALPHA: u32 = 1 << 8; + +#[unsafe(no_mangle)] +pub extern "C" fn pc_abi_version() -> u32 { + 1 +} + +/// `len << 32 | ptr` of the UTF-8 manifest JSON. +#[unsafe(no_mangle)] +pub extern "C" fn pc_manifest() -> u64 { + ((MANIFEST.len() as u64) << 32) | MANIFEST.as_ptr() as u64 +} + +/// A block of `size` bytes: fresh pages from `memory.grow` (each run gets a fresh instance, so +/// nothing is ever freed). Returns 0 when the host's memory cap is reached. +#[unsafe(no_mangle)] +pub extern "C" fn pc_alloc(size: u32) -> u32 { + let pages = (size as usize).div_ceil(65536); + let old = core::arch::wasm32::memory_grow(0, pages); + if old == usize::MAX { 0 } else { (old * 65536) as u32 } +} + +/// Inverts `width × height` pixels of `channels` interleaved `f32` samples at `buf`. +/// +/// # Safety +/// The host guarantees `buf..buf + len` is a block it got from `pc_alloc`, holding `len / 4` +/// samples. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn pc_filter(buf: *mut f32, len: u32, _width: u32, _height: u32, channels: u32, format: u32, _params: *const u8, _params_len: u32) -> i32 { + let n = channels as usize; + if n == 0 { + return 1; + } + // SAFETY: see the function's contract. + let px = unsafe { core::slice::from_raw_parts_mut(buf, len as usize / 4) }; + let alpha = format & HAS_ALPHA != 0; + // Fast path for the common RGBA layout (the interpreter rewards fewer instructions). + if alpha && n == 4 { + for p in px.chunks_exact_mut(4) { + if p[3] > 0.0 { + p[0] = 1.0 - p[0]; + p[1] = 1.0 - p[1]; + p[2] = 1.0 - p[2]; + } + } + return 0; + } + let colour = if alpha { n - 1 } else { n }; + for p in px.chunks_exact_mut(n) { + if alpha && p[n - 1] <= 0.0 { + continue; + } + for v in &mut p[..colour] { + *v = 1.0 - *v; + } + } + 0 +} diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index 6dd7e0be..a11cd2ce 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -54,6 +54,7 @@ pub const TABLE: &[(&str, Class)] = &[ ("tools", Class::Layer(4)), ("viewport", Class::Layer(4)), ("ml", Class::Layer(4)), + ("plugins", Class::Layer(4)), ("engine", Class::Layer(5)), ("ui-egui", Class::Layer(6)), ("automation", Class::Layer(6)),