mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-16 12:16:50 -04:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5bd5b0aea | ||
|
|
708e8f6cba | ||
|
|
e9fd4a6c72 | ||
|
|
b3ce1bb47d | ||
|
|
09b51c98f1 |
No files matched your search
@@ -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
|
||||
|
||||
Generated
+151
-3
@@ -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"
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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"
|
||||
@@ -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::<u64>().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<f32> = (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");
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -1018,6 +1018,7 @@ fn build() -> Vec<CommandSpec> {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<String>) -> 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<std::sync::Arc<Plugin>> {
|
||||
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<Value> {
|
||||
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<Surface> {
|
||||
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<Vec<u8>> {
|
||||
let val = |c: u8| -> Option<u32> {
|
||||
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<u8> = 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<Value> {
|
||||
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<Plugin> {
|
||||
registry::load_file(std::path::Path::new(path), photocraft_plugins::Limits::default()).map_err(err)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn load_path(_: &str) -> Result<Plugin> {
|
||||
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<Option<(String, Value)>> = 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::<Vec<_>>()}),
|
||||
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<Value> {
|
||||
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<CommandSpec> {
|
||||
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::<Vec<_>>(), "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;
|
||||
@@ -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<u8> {
|
||||
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<f32> {
|
||||
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();
|
||||
}
|
||||
@@ -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`.
|
||||
|
||||
@@ -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"
|
||||
@@ -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::<i32>().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<f32> = (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)))"#;
|
||||
@@ -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<String> = std::env::args().skip(1).collect();
|
||||
let (Some(input), Some(output)) = (args.first(), args.get(1)) else {
|
||||
eprintln!("usage: wat2wasm <in.wat> <out.wasm>");
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<f32> {
|
||||
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<Surface> {
|
||||
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<Result<Surface>> = {
|
||||
use rayon::prelude::*;
|
||||
chunk.par_iter().map(run).collect()
|
||||
};
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let results: Vec<Result<Surface>> = 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<Vec<f32>> {
|
||||
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,
|
||||
}
|
||||
@@ -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<T> = std::result::Result<T, Error>;
|
||||
@@ -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<String>,
|
||||
#[serde(default)]
|
||||
default: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
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<Manifest> {
|
||||
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<String> = 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<Map<String, Value>> {
|
||||
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<Vec<(String, ParamSpec)>, 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<E: serde::de::Error>(self) -> std::result::Result<Self::Value, E> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
fn visit_map<A: serde::de::MapAccess<'de>>(self, mut map: A) -> std::result::Result<Self::Value, A::Error> {
|
||||
let mut out = Vec::new();
|
||||
while let Some((k, v)) = map.next_entry::<String, ParamSpec>()? {
|
||||
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<S: serde::Serializer>(p: &[(String, ParamSpec)], s: S) -> std::result::Result<S::Ok, S::Error> {
|
||||
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::<Vec<_>>(), ["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());
|
||||
}
|
||||
}
|
||||
@@ -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<BTreeMap<String, Arc<Plugin>>> = 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<Plugin> {
|
||||
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<Arc<Plugin>> {
|
||||
Ok(install(Plugin::load(bytes, Limits::default())?))
|
||||
}
|
||||
|
||||
pub fn get(id: &str) -> Option<Arc<Plugin>> {
|
||||
PLUGINS.read().unwrap_or_else(|e| e.into_inner()).get(id).cloned()
|
||||
}
|
||||
|
||||
/// Installed plug-ins, sorted by id.
|
||||
pub fn list() -> Vec<Arc<Plugin>> {
|
||||
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<Plugin> {
|
||||
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<String>,
|
||||
/// `(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<FolderReport> {
|
||||
let rd = std::fs::read_dir(dir).map_err(|e| Error::Io(format!("{}: {e}", dir.display())))?;
|
||||
let mut files: Vec<std::path::PathBuf> =
|
||||
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)
|
||||
}
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
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<AtomicBool>,
|
||||
}
|
||||
|
||||
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<StoreLimits>,
|
||||
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<Plugin> {
|
||||
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::<i32, i32>("pc_alloc")?;
|
||||
live.func::<FilterArgs, i32>("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<String>) -> Self {
|
||||
self.source = Some(source.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn instantiate(&self, deadline: &Deadline) -> Result<Live> {
|
||||
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::<StoreLimits>::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<P: WasmParams, R: WasmResults>(&self, live: &mut Live, f: &TypedFunc<P, R>, params: P, fuel: u64, deadline: &Deadline) -> Result<R> {
|
||||
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<i32, i32> = live.func("pc_alloc")?;
|
||||
let filter: TypedFunc<FilterArgs, i32> = 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<P: WasmParams, R: WasmResults>(&self, name: &str) -> Result<TypedFunc<P, R>> {
|
||||
self.instance.get_typed_func::<P, R>(&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,
|
||||
}
|
||||
}
|
||||
@@ -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<u8> {
|
||||
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<u8> {
|
||||
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<u8>)> = 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();
|
||||
}
|
||||
BIN
Binary file not shown.
@@ -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 }
|
||||
|
||||
@@ -201,6 +201,28 @@ pub fn open(app: &mut PhotocraftApp, command: &str) -> Option<u64> {
|
||||
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<String, Value>) -> 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<String, Value>) {
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<MenuItem> {
|
||||
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<String> = vec!["File".into(), "Open Recent".into()];
|
||||
|
||||
@@ -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.<plug-in id>`.
|
||||
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<MenuItem>) {
|
||||
let plugins = photocraft_plugins::registry::list();
|
||||
let Some(at) = items.iter().position(|i| i.id == "plugin.install") else { return };
|
||||
let path: Vec<String> = vec!["Filter".into(), "Plug-ins".into()];
|
||||
let enabled = app.session.is_enabled("plugin.run");
|
||||
let mut extra: Vec<MenuItem> = 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<bool> {
|
||||
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<Result<Value, String>> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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<CommandInvocation>`, 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)).
|
||||
+2
-2
@@ -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% |
|
||||
|
||||
+171
@@ -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": "<base64>"}`, `"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.
|
||||
@@ -0,0 +1 @@
|
||||
target/
|
||||
Generated
+7
@@ -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"
|
||||
@@ -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
|
||||
@@ -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.
|
||||
Executable
+9
@@ -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"
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)),
|
||||
|
||||
Reference in new issue
Block a user