Compare commits

...
Author SHA1 Message Date
skylar taylor-barrick 93837ee578 wasm size: compose and algo join cms at opt-level "z" to clear the 24 MiB gate
Main's browser build is 171 KB over the 24 MiB wasm gate in CI
(25,337,343 bytes), so every branch built on current main fails the
web size check. Demote three pixel crates in the wasm-release profile:

- cms: transforms are built once and cached (transform::cached),
  so "z" costs little runtime (measured -42 KB).
- compose: the browser app composites per frame on the GPU (crates/gpu,
  WebGPU/WebGL2), so the CPU compositor is not the browser's hot path
  there (measured -122 KB).
- algo: filters run per pixel on every interactive filter application.
  "z" there is the lever of last resort (measured -331 KB), taken
  because the gate is enforced in CI; if browser filters feel too slow,
  restore 3 here and reclaim the bytes from a feature-gated decoder.

Final size 24,811,949 bytes locally, 354 KB under the gate; the native
profiles are untouched. If a maintainer prefers keeping algo at 3, the
alternative is raising the self-imposed 24 MiB early-warning limit
(Cloudflare's hard cap is 25 MiB) - a policy call in packaging/web/
package.sh, not a code fix.
2026-10-15 17:20:22 -06:00
skylar taylor-barrick b3e6de91f5 ui_set description: name the layerFilter field main added
The Layers-panel filter dropdown (#5048) added layerFilter to the
control method's UI_SET_FIELDS without extending the ui_set tool's
description, so the drift guard (mcp.rs, ui_set_description_names_every_
accepted_field) fails on Windows CI for any branch built on main. Name
the field with its patch shape: by (kind, name or mode), kinds (a list
of layer kinds), name (matches part of the layer name), mode (a blend
mode name). The 40 MCP tests pass; strict Clippy clean.
2026-10-15 17:16:12 -06:00
skylar taylor-barrick 7cd3fac330 Review: carry render authority through Session::edit and surface denials
Session::edit is the engine's universal mutation entry (linked-file
refresh, transforms, place); renders reached through it ran with no
authorization scope installed, so stored smart-filter params read
ambiently there even after the execute-path fix. Install the scope in
Session::edit as well, keyed on the session's authorize gate.

apply_filter_to_surface also conflated a denied filter with an unknown
one: authorize_render_command's Result was discarded with .ok()?, so a
denial silently rendered unfiltered pixels and the stage entered the
process-wide stack cache. The signature becomes
Result<Option<Surface>>: Err for denials (callers propagate), None for
ids that aren't pixel filters (pass-through semantics unchanged), and an
unreadable map keeps its deliberate pass-through. apply_smart_filter and
apply_smart_filters propagate instead of cur.clone().

Two tests: a refresh driven outside command execution (Session::edit)
is denied and leaves the document unchanged; a denied render caches no
stage. Nine authorization tests pass, engine suite and strict Clippy
clean, panic hunt green.
2026-10-15 17:16:12 -06:00
skylar taylor-barrick dda6ba10a2 Security: enforce the automation read boundary on stored smart-filter params
Smart filters store a command id and params inside the document, so a
crafted .pcraft could resolve stored ambient paths (displace mapPath,
color-lookup LUT files) at render time without the authorize gate:
synchronous smart-object rendering has no Session argument, spawned job
threads drop the requesting session's authority, and a trusted UI
render could warm the process-wide stack cache with a stage the gate
never saw.

Thread the requesting session's gate through a thread-local in jobs.rs
(entered around command execution, reinstalled on spawned workers and on
job apply, restored on drop), re-check stored filter commands in
render_in before source resolution and any cache lookup and in
apply_filter_to_surface, and re-authorize layer.smartFilter.setParam
with the complete merged params after merging stored document params —
including edits whose request contains no path and actions.play steps.
Denial is conservative: the document is unchanged and no worker starts.

Regression tests in the automation crate cover rerender, cached-stage
reuse, merged-param edits, action playback, background jobs, and filter
transfer, for both the displace map and color-lookup LUT vectors.
2026-10-15 17:15:45 -06:00
8 changed files with 370 additions and 32 deletions

No files matched your search

+11 -4
View File
@@ -127,17 +127,24 @@ opt-level = "z"
[profile.wasm-release.package.naga]
opt-level = "z"
# Pixel processing keeps its speed-oriented optimization level.
# Pixel processing keeps its speed-oriented optimization level, with two exceptions.
# Colour-management transforms are built once and cached (`transform::cached`), so "z" costs
# little runtime. The browser app composites per frame on the GPU (`crates/gpu`, WebGPU/WebGL2),
# so the CPU compositor is not the browser's hot path there; "z" for compose keeps the wasm
# under the 24 MiB gate (measured −122 KB vs opt-level 3). algo's filters run per pixel on
# every interactive filter application; "z" there is the size lever of last resort (−331 KB),
# taken because the gate is enforced in CI: if browser filters feel too slow, restore 3 here
# and reclaim the bytes from a feature-gated decoder instead.
[profile.wasm-release.package.photocraft-raster]
opt-level = 3
[profile.wasm-release.package.photocraft-color]
opt-level = 3
[profile.wasm-release.package.photocraft-cms]
opt-level = 3
opt-level = "z"
[profile.wasm-release.package.photocraft-compose]
opt-level = 3
opt-level = "z"
[profile.wasm-release.package.photocraft-algo]
opt-level = 3
opt-level = "z"
[profile.wasm-release.package.photocraft-paint]
opt-level = 3
+5 -4
View File
@@ -325,10 +325,11 @@ pub struct UiSetParams {
/// dashes, dashOffset and opacity; null disables the stroke). Shape stroke fields use
/// `shape.edit` names and units; colour comes from `tools.setColors`. camTransformOnDrop (bool,
/// the Content-Aware Move tool's Transform On Drop). tileGrid (bool, a developer overlay of the
/// active layer's storage tiles). layerFilter (an object patching the Layers panel filter:
/// by: kind/name/mode; kinds: a list of pixel/adjustment/type/shape/smart; name: a string;
/// mode: a blend mode name such as Multiply or Pass Through). Omitted filter fields keep
/// their current values. Other fields are an error.
/// active layer's storage tiles). layerFilter (an object patching by ("kind", "name" or
/// "mode"), kinds (a list of layer kinds), name (matches part of the layer name) and mode (a
/// blend mode name) for the Layers panel filter). strokeTrace (true, false, "stderr" or "off":
/// the stroke latency trace at runtime; a CSV file is set at launch with --stroke-trace, not
/// over this channel). Other fields are an error.
pub fields: Value,
}
@@ -0,0 +1,261 @@
//! Stored smart-filter parameters must not grant automation ambient filesystem access.
use std::path::{Path, PathBuf};
use std::sync::{
Arc,
atomic::{AtomicU64, Ordering},
};
use photocraft_automation::{AuthorizedWorkspace, Headless, workspace::authorize_desktop_engine_step};
use photocraft_doc::{Document, LayerContent, LayerId};
use photocraft_engine::{Session, actions_cmds::Action, jobs::Started, smart_cmds};
use serde_json::{Value, json};
struct Fixture(PathBuf);
impl Fixture {
fn new() -> Self {
static NEXT: AtomicU64 = AtomicU64::new(0);
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let base = std::env::temp_dir().join(format!("pc-filter-auth-{}-{stamp}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
std::fs::create_dir(&base).unwrap();
std::fs::create_dir(base.join("workspace")).unwrap();
std::fs::create_dir(base.join("outside")).unwrap();
let f = Self(base);
std::fs::write(f.root().join("stripes.png"), png(|x| if (x / 2) % 2 == 0 { 0 } else { 255 })).unwrap();
std::fs::write(f.outside("black.png"), png(|_| 0)).unwrap();
std::fs::write(f.outside("white.png"), png(|_| 255)).unwrap();
std::fs::write(f.outside("black.cube"), cube(0)).unwrap();
std::fs::write(f.outside("white.cube"), cube(1)).unwrap();
f
}
fn root(&self) -> PathBuf {
self.0.join("workspace")
}
fn outside(&self, name: &str) -> PathBuf {
self.0.join("outside").join(name)
}
fn headless(&self) -> Headless {
Headless::with_workspace(AuthorizedWorkspace::new(Some(&self.root()), Some(&self.root())).unwrap())
}
fn setup(&self, lookup: bool) -> (Headless, LayerId) {
let mut h = self.headless();
h.handle("doc.new", json!({"width": 32, "height": 32, "background": "#808080"})).unwrap();
h.handle("doc.new", json!({"width": 32, "height": 32})).unwrap();
let layer = LayerId(h.handle("doc.place", json!({"path": "stripes.png"})).unwrap()["layer"].as_u64().unwrap());
let (id, params) = if lookup {
("image.adjustments.colorLookup", json!({"layer": layer.0, "lut": "none"}))
} else {
("filter.distort.displace", json!({"layer": layer.0, "mapDocument": 0, "horizontal": 50, "vertical": 0}))
};
h.session.execute(id, params).unwrap();
(h, layer)
}
fn saved_filter(&self, lookup: bool) -> (Document, LayerId) {
let (h, layer) = self.setup(lookup);
let mut doc = (*h.session.active().unwrap().doc).clone();
let LayerContent::Smart(sm) = &mut doc.layer_mut(layer).unwrap().content else { panic!("smart object") };
sm.smart_filters[0].params = path_params(lookup, &self.outside(if lookup { "white.cube" } else { "white.png" }));
let bytes = photocraft_format::save_to_bytes(&doc, &Default::default()).unwrap();
std::fs::write(self.root().join("stored.pcraft"), bytes).unwrap();
(doc, layer)
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn png(value: impl Fn(u32) -> u8) -> Vec<u8> {
let mut pixels = Vec::new();
for _ in 0..32 {
for x in 0..32 {
pixels.extend([value(x); 3]);
}
}
let image = photocraft_codecs::Image::from_u8(32, 32, photocraft_codecs::ChannelLayout::Rgb, pixels).unwrap();
photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).unwrap()
}
fn cube(value: u8) -> String {
format!("LUT_3D_SIZE 2\n{}", format!("{value} {value} {value}\n").repeat(8))
}
fn path_params(lookup: bool, path: &Path) -> Value {
if lookup { json!({"file": path}) } else { json!({"mapPath": path, "mapDocument": null}) }
}
fn assert_denied<T: std::fmt::Debug>(result: Result<T, impl std::fmt::Display>) {
match result {
Err(error) => assert!(error.to_string().contains("ambient filesystem"), "{error}"),
Ok(value) => panic!("ambient file access was accepted: {value:?}"),
}
}
#[test]
fn parameter_edits_reject_outside_images_and_luts_without_changing_the_document() {
for lookup in [false, true] {
let f = Fixture::new();
let (mut h, layer) = f.setup(lookup);
let before = h.render_png(None, 64).unwrap();
let doc = h.session.active().unwrap().doc.clone();
let journal = h.session.journal.len();
for name in if lookup { ["black.cube", "white.cube"] } else { ["black.png", "white.png"] } {
let params = path_params(lookup, &f.outside(name));
let id = if lookup { "image.adjustments.colorLookup" } else { "filter.distort.displace" };
assert_denied(h.session.execute(id, params.clone()));
assert_denied(h.handle("engine.execute", json!({"command": "layer.smartFilter.setParams", "params": {"layer": layer.0, "params": params}})));
assert!(Arc::ptr_eq(&doc, &h.session.active().unwrap().doc));
assert_eq!(h.session.journal.len(), journal);
assert_eq!(h.render_png(None, 64).unwrap(), before);
}
// Safe numeric and embedded-data edits remain usable.
let safe = if lookup { json!({"data": cube(0), "fileName": "embedded.cube"}) } else { json!({"horizontal": 10}) };
h.session.execute("layer.smartFilter.setParams", json!({"layer": layer.0, "params": safe})).unwrap();
}
}
#[test]
fn action_playback_cannot_hide_a_smart_filter_path_edit() {
let f = Fixture::new();
let (mut h, layer) = f.setup(false);
let doc = h.session.active().unwrap().doc.clone();
h.session.actions.list.push(Action::new(
"edit filter",
vec![("layer.smartFilter.setParams".into(), json!({"layer": layer.0, "params": path_params(false, &f.outside("white.png"))}))],
));
let result = h.session.execute("actions.play", json!({"action": "edit filter"})).unwrap();
assert!(result["failed"]["error"].as_str().unwrap().contains("ambient filesystem"), "{result}");
assert!(Arc::ptr_eq(&doc, &h.session.active().unwrap().doc));
}
#[test]
fn loaded_filters_are_checked_after_merging_even_when_the_edit_contains_no_path() {
for lookup in [false, true] {
let f = Fixture::new();
let (_, layer) = f.saved_filter(lookup);
let mut h = f.headless();
h.open(Path::new("stored.pcraft")).unwrap();
let doc = h.session.active().unwrap().doc.clone();
assert_denied(h.session.execute("layer.smartFilter.setParams", json!({"layer": layer.0, "params": {"horizontal": 10, "dither": false}})));
assert!(Arc::ptr_eq(&doc, &h.session.active().unwrap().doc));
// Removing an ambient input makes the filter usable again.
let safe = if lookup { json!({"file": "", "lut": "none"}) } else { json!({"mapPath": ""}) };
h.session.execute("layer.smartFilter.setParams", json!({"layer": layer.0, "params": safe})).unwrap();
}
}
#[test]
fn loaded_filters_cannot_read_on_rerender_or_reuse_a_trusted_cached_stage() {
for lookup in [false, true] {
let f = Fixture::new();
let (doc, layer) = f.saved_filter(lookup);
for warm_cache in [false, true] {
if warm_cache {
let mut trusted = doc.clone();
assert!(smart_cmds::refresh(&mut trusted, layer).unwrap());
}
let mut h = f.headless();
h.open(Path::new("stored.pcraft")).unwrap();
let before = h.session.active().unwrap().doc.clone();
assert_denied(h.session.execute("layer.smartFilter.setVisible", json!({"layer": layer.0, "visible": true})));
assert!(Arc::ptr_eq(&before, &h.session.active().unwrap().doc));
}
}
}
#[test]
fn background_rerender_keeps_the_desktop_request_authority() {
for lookup in [false, true] {
let f = Fixture::new();
let (doc, layer) = f.saved_filter(lookup);
let mut s = Session::new();
s.add_document(doc, None);
s.authorize = Some(authorize_desktop_engine_step);
let before = s.active().unwrap().doc.clone();
let started = s.start("layer.smartFilter.setVisible", json!({"layer": layer.0, "visible": true})).unwrap();
// The desktop restores UI authority as soon as the automation request returns.
s.authorize = None;
let Started::Job(job) = started else { panic!("expected a background render") };
assert_denied(s.wait_job(job));
assert!(Arc::ptr_eq(&before, &s.active().unwrap().doc));
// The worker's restriction must not leak into subsequent trusted UI work.
s.execute("layer.smartFilter.setVisible", json!({"layer": layer.0, "visible": true})).unwrap();
}
}
#[test]
fn transferring_stored_filters_cannot_read_outside_files() {
for lookup in [false, true] {
let f = Fixture::new();
let (_, from) = f.saved_filter(lookup);
let mut h = f.headless();
h.open(Path::new("stored.pcraft")).unwrap();
let to = h.handle("doc.place", json!({"path": "stripes.png"})).unwrap()["layer"].as_u64().unwrap();
let before = h.session.active().unwrap().doc.clone();
for copy in [false, true] {
assert_denied(h.session.execute("layer.smartFilter.transfer", json!({"from": from.0, "to": to, "copy": copy})));
assert!(Arc::ptr_eq(&before, &h.session.active().unwrap().doc));
}
}
}
#[test]
fn trusted_parameter_edits_still_read_maps_and_luts_after_a_denied_request() {
for lookup in [false, true] {
let f = Fixture::new();
let (mut h, layer) = f.setup(lookup);
let edit = |path: &Path| json!({"layer": layer.0, "params": path_params(lookup, path)});
assert_denied(h.session.execute("layer.smartFilter.setParams", edit(&f.outside("white.png"))));
let mut trusted = Headless::trusted_local();
trusted.session.add_document((*h.session.active().unwrap().doc).clone(), None);
let mut renders = Vec::new();
for name in if lookup { ["black.cube", "white.cube", "black.cube"] } else { ["black.png", "white.png", "black.png"] } {
trusted.session.execute("layer.smartFilter.setParams", edit(&f.outside(name))).unwrap();
renders.push(trusted.render_png(None, 64).unwrap());
}
assert_ne!(renders[0], renders[1], "trusted path-based filter still works");
assert_eq!(renders[0], renders[2], "trusted rendering remains deterministic");
}
}
#[test]
fn a_refresh_outside_command_execution_surfaces_the_denial_instead_of_rendering_unfiltered() {
// link-watch and transform paths re-render through Session::edit, not Session::execute or
// jobs::run; the gate must reach them and the denial must not silently pass pixels through
// into the process-wide stack cache.
for lookup in [false, true] {
let f = Fixture::new();
let (doc, layer) = f.saved_filter(lookup);
let mut h = f.headless();
h.open(Path::new("stored.pcraft")).unwrap();
let before = h.session.active().unwrap().doc.clone();
// Session::edit is the outside-execute mutation path (an edit closure the UI or a
// linked-file change runs); the stored filter must be denied there too.
assert_denied(h.session.edit("Re-render", |d, _| {
match smart_cmds::refresh(d, layer) {
Ok(_) => Ok(()),
Err(e) => Err(e),
}
}));
assert!(Arc::ptr_eq(&before, &h.session.active().unwrap().doc));
// A trusted session (no gate) still renders the same document.
let mut trusted = Session::new();
trusted.add_document(doc, None);
let mut d = (*trusted.active().unwrap().doc).clone();
assert!(smart_cmds::refresh(&mut d, layer).is_ok());
}
}
#[test]
fn an_ambient_map_or_lut_never_renders_unfiltered_pixels_into_the_stack_cache() {
// The denial path must fail the render before any stage is cached process-wide.
for lookup in [false, true] {
let f = Fixture::new();
let (doc, layer) = f.saved_filter(lookup);
let mut s = Session::new();
s.add_document(doc, None);
s.authorize = Some(authorize_desktop_engine_step);
let bytes = smart_cmds::stack_cache_bytes();
assert_denied(s.execute("layer.smartFilter.setVisible", json!({"layer": layer.0, "visible": true})));
assert_eq!(bytes, smart_cmds::stack_cache_bytes(), "a denied render cached no stage");
}
}
+16 -10
View File
@@ -174,8 +174,9 @@ pub fn params_for(id: &str, p: &Value) -> Option<FilterParams> {
/// Runs filter command `id` with JSON `params` on `surf` (the pixel function behind every
/// `filter.*` command; smart filters re-run it on re-render). `bounds` frames distortions;
/// `selection` limits the result. Like layer filters, neighbourhood filters repeat the edge pixels
/// of `canvas` ∪ the surface's content instead of fading in transparency, and the output is
/// clipped to it. `None` for ids that aren't pixel filters.
/// `None` for ids that aren't pixel filters. `Err` when the requesting session's authority denies
/// the filter's params (an ambient path the session may not read): the caller must surface the
/// error rather than render unfiltered pixels.
pub fn apply_filter_to_surface(
id: &str,
params: &Value,
@@ -183,31 +184,36 @@ pub fn apply_filter_to_surface(
bounds: photocraft_geom::Rect,
selection: Option<&photocraft_raster::Surface>,
canvas: photocraft_geom::Rect,
) -> Option<photocraft_raster::Surface> {
) -> Result<Option<photocraft_raster::Surface>> {
// Replays bypass command dispatch; nested paths must still obey the requesting session's
// authority, including adjustment LUTs and plug-ins, before any resolver can read them.
crate::jobs::authorize_render_command(id, params)?;
// Liquify / Puppet Warp / Perspective Warp smart filters (distort_cmds).
if let Some(out) = crate::distort_cmds::apply_to_surface(id, params, surf, canvas) {
return Some(out);
return Ok(Some(out));
}
// Lens Correction / Adaptive Wide Angle / Camera Raw smart filters (lens_cmds).
if let Some(out) = crate::lens_cmds::apply_to_surface(id, params, surf, canvas) {
return Some(out);
return Ok(Some(out));
}
// Image › Adjustments recorded as smart filters (Levels, Curves, Shadows/Highlights… from PSD).
if let Some(kind) = id.strip_prefix("image.adjustments.") {
return crate::adjust_cmds::adjust_as_filter(kind, params, surf);
return Ok(crate::adjust_cmds::adjust_as_filter(kind, params, surf));
}
// 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);
return Ok(crate::plugin_cmds::apply_to_surface(id, params, surf, selection, canvas));
}
let mut fp = params_for(id, params)?;
let Some(mut fp) = params_for(id, params) else { return Ok(None) };
// A map that can no longer be read (moved or deleted since) leaves the pixels alone, like an
// unknown filter; the command itself already refused an unreadable map when it was added.
crate::filters_ext::resolve_map_path(&mut fp, params).ok()?;
if crate::filters_ext::resolve_map_path(&mut fp, params).is_err() {
return Ok(None);
}
let sel_bounds = selection.map(photocraft_raster::Surface::content_bounds);
let content = surf.content_bounds();
let area = algo::output_area(&fp, content, bounds, sel_bounds);
Some(algo::apply_in(surf, &fp, area, bounds, selection, canvas.union(&content)))
Ok(Some(algo::apply_in(surf, &fp, area, bounds, selection, canvas.union(&content))))
}
pub(crate) fn has_filterable_layer(s: &Session) -> std::result::Result<(), String> {
+42
View File
@@ -29,6 +29,43 @@ use serde_json::{Value, json};
use crate::{EngineError, Result, Session};
type Authorization = fn(&str, &Value) -> Result<()>;
thread_local! {
// Smart-filter rendering has no Session argument. Carry the requesting session's gate
// through synchronous rendering and explicitly reinstall it on background workers.
static RENDER_AUTHORIZATION: std::cell::Cell<Option<Authorization>> = const { std::cell::Cell::new(None) };
}
pub(crate) struct AuthorizationScope(Option<Authorization>);
impl AuthorizationScope {
fn enter(gate: Option<Authorization>) -> Self {
Self(RENDER_AUTHORIZATION.with(|current| current.replace(gate.or(current.get()))))
}
}
impl Drop for AuthorizationScope {
fn drop(&mut self) {
RENDER_AUTHORIZATION.with(|current| current.set(self.0));
}
}
/// [`AuthorizationScope`] entered with `gate` (or the one already installed, so trusted work on
/// this thread keeps ambient paths usable). [`Session::edit`] and the job paths use this to carry
/// the requesting session's authority into every smart-object re-render.
pub(crate) fn render_authorization_scope(gate: Option<Authorization>) -> AuthorizationScope {
AuthorizationScope::enter(gate)
}
/// Recheck a stored filter's command and parameters before evaluating it or using its cache.
pub(crate) fn authorize_render_command(id: &str, params: &Value) -> Result<()> {
match RENDER_AUTHORIZATION.with(std::cell::Cell::get) {
Some(gate) => gate(id, params),
None => Ok(()),
}
}
/// Identifies one background job for the session's lifetime.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, serde::Deserialize)]
pub struct JobId(pub u64);
@@ -284,6 +321,8 @@ pub fn run<T: Send + 'static>(
work: impl FnOnce(&JobCtx) -> Result<T> + Send + 'static,
apply: impl FnOnce(&mut Session, T) -> Result<Value> + Send + 'static,
) -> Result<Value> {
let authorization = s.authorize.or_else(|| RENDER_AUTHORIZATION.with(std::cell::Cell::get));
let _authorization = AuthorizationScope::enter(authorization);
if !s.jobs.spawn || cfg!(target_arch = "wasm32") {
let t = work(&s.jobs.inline.clone().unwrap_or_default())?;
return apply(s, t);
@@ -299,6 +338,7 @@ pub fn run<T: Send + 'static>(
let ctx = JobCtx::new();
let (tx, rx) = std::sync::mpsc::channel::<Result<Payload>>();
let erased: Apply = Box::new(move |s, any| {
let _authorization = AuthorizationScope::enter(authorization);
let t = any.downcast::<T>().map_err(|_| EngineError::Other("internal error: job result type mismatch".into()))?;
apply(s, *t)
});
@@ -311,6 +351,7 @@ pub fn run<T: Send + 'static>(
// The work closure is moved into the thread. If spawning fails (thread limits), there is
// no way to get it back, so the error is reported instead.
let spawned = std::thread::Builder::new().name(format!("photocraft-job-{}", id.0)).spawn(move || {
let _authorization = AuthorizationScope::enter(authorization);
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| work(&wctx).map(|t| Box::new(t) as Payload)))
.unwrap_or_else(|_| Err(EngineError::Other(format!("`{label_w}` failed with an internal error (logged); the document is unchanged"))));
// The receiver is gone when the job was cancelled: nothing to report.
@@ -652,6 +693,7 @@ impl Session {
if let Some(gate) = self.authorize {
gate(id, &params)?;
}
let _authorization = AuthorizationScope::enter(self.authorize);
// Last Filter runs as the filter it repeats: journaled, recorded in an action, faded and
// run as a job like that filter, so a recorded step replays the same filter anywhere
// (#3089), as Transform Again records its transform (#1440). With no filter yet it falls
+3
View File
@@ -779,6 +779,9 @@ impl Session {
/// Apply an undoable edit to the active document.
pub fn edit<R>(&mut self, label: &str, f: impl FnOnce(&mut Document, &mut Option<LayerId>) -> Result<R>) -> Result<R> {
// Every mutation path (linked-file refresh, transform, place) re-renders smart objects
// through this entry; the requesting session's authority must reach those renders.
let _authorization = crate::jobs::render_authorization_scope(self.authorize);
let restrict = self.color_restrict;
let sampler_map = self.sampler_map.take();
let st = self.active_mut().ok_or(EngineError::NoDocument)?;
+31 -13
View File
@@ -298,28 +298,35 @@ fn blend_surfaces(base: &Surface, top: &Surface, mode: BlendMode, opacity: f32)
out
}
/// Runs the smart filter stack on the placed (unfiltered) content.
pub fn apply_smart_filters(placed: &Surface, sm: &SmartObject, canvas: Rect) -> Surface {
/// Runs the smart filter stack on the placed (unfiltered) content. A filter the session's
/// authority denies surfaces its error (the trusted compositor path has no gate installed, so
/// this reads ambiently only when the caller established one and it refuses).
pub fn apply_smart_filters(placed: &Surface, sm: &SmartObject, canvas: Rect) -> Result<Surface> {
if !sm.filters_enabled || !sm.smart_filters.iter().any(|f| f.visible) {
return placed.clone();
return Ok(placed.clone());
}
let mut cur = placed.clone();
// Edge pixels repeat at the canvas ∪ placed-content edge, as for layer filters.
let extent = canvas.union(&placed.content_bounds());
for f in sm.smart_filters.iter().filter(|f| f.visible) {
cur = apply_smart_filter(f, &cur, canvas, extent);
cur = apply_smart_filter(f, &cur, canvas, extent)?;
}
match sm.filter_mask.as_ref().filter(|m| m.enabled) {
Ok(match sm.filter_mask.as_ref().filter(|m| m.enabled) {
Some(m) => photocraft_compose::filter_mask::mix(placed, &cur, m),
None => cur,
}
})
}
/// One visible smart filter over `cur`, blended with its blend mode and opacity.
fn apply_smart_filter(f: &SmartFilter, cur: &Surface, canvas: Rect, extent: Rect) -> Surface {
// Unknown ids (e.g. Photoshop filters we don't implement) leave the pixels alone.
let Some(out) = crate::filters::apply_filter_to_surface(&f.command, &f.params, cur, canvas, None, extent) else { return cur.clone() };
if f.blend == BlendMode::Normal && f.opacity >= 1.0 { out } else { blend_surfaces(cur, &out, f.blend, f.opacity.clamp(0.0, 1.0)) }
/// One visible smart filter over `cur`, blended with its blend mode and opacity. `Err` when the
/// requesting session's authority denies the filter's params: callers surface the error instead of
/// rendering (and caching) unfiltered pixels.
fn apply_smart_filter(f: &SmartFilter, cur: &Surface, canvas: Rect, extent: Rect) -> Result<Surface> {
let out = match crate::filters::apply_filter_to_surface(&f.command, &f.params, cur, canvas, None, extent)? {
// Unknown ids (e.g. Photoshop filters we don't implement) leave the pixels alone.
Some(out) => out,
None => cur.clone(),
};
if f.blend == BlendMode::Normal && f.opacity >= 1.0 { Ok(out) } else { Ok(blend_surfaces(cur, &out, f.blend, f.opacity.clamp(0.0, 1.0))) }
}
/// The source placed in document pixels, before any smart filter.
@@ -377,6 +384,11 @@ pub fn render(doc: &Document, sm: &SmartObject) -> Result<Option<Surface>> {
/// is cancelled. Gives the same pixels as [`apply_smart_filters`] on the placed source, but
/// starts from the deepest stage of the stack already rendered (see [`stack_cache`]).
fn render_in(doc: &Document, sm: &SmartObject, ctx: &JobCtx) -> Result<Option<Surface>> {
// Stored filters can come from a file or another layer. Check before looking up cached
// stages too: a trusted UI render may have populated this process-wide cache earlier.
for filter in sm.smart_filters.iter().filter(|f| sm.filters_enabled && f.visible) {
crate::jobs::authorize_render_command(&filter.command, &filter.params)?;
}
let Some((name, bytes)) = source_bytes(&doc.metadata, &sm.source) else { return Ok(None) };
let (fmt, canvas) = (doc.pixel_format(), doc.bounds());
let placed_key = stack_cache::placed_key(&bytes, fmt, sm, canvas);
@@ -410,7 +422,7 @@ fn render_in(doc: &Document, sm: &SmartObject, ctx: &JobCtx) -> Result<Option<Su
for (i, (f, key)) in filters.iter().zip(&keys).enumerate().skip(done) {
ctx.check()?;
ctx.progress(i as f32 / n, "");
cur = Stage { surface: apply_smart_filter(f, &cur.surface, canvas, cur.extent), extent: cur.extent };
cur = Stage { surface: apply_smart_filter(f, &cur.surface, canvas, cur.extent)?, extent: cur.extent };
stack_cache::put(*key, &cur);
}
ctx.progress(1.0, "");
@@ -522,7 +534,7 @@ pub(crate) fn add_smart_filter(doc: &mut Document, id: LayerId, sf: photocraft_d
}
let bounds = selection.map(Surface::content_bounds).filter(|b| !b.is_empty()).unwrap_or(canvas);
let cache = sm.cache.as_ref().ok_or_else(|| other("smart object has no pixels"))?;
let out = crate::filters::apply_filter_to_surface(&sf.command, &sf.params, cache, bounds, selection, canvas)
let out = crate::filters::apply_filter_to_surface(&sf.command, &sf.params, cache, bounds, selection, canvas)?
.ok_or_else(|| other(format!("unknown filter {}", sf.command)))?;
sm.cache = Some(out);
sm.smart_filters.push(sf);
@@ -1000,6 +1012,7 @@ fn set_filter_visible(s: &mut Session, p: &Value) -> Result<Value> {
fn set_filter_params(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "layer.smartFilter.setParams";
let new = p.get("params").cloned().ok_or_else(|| bad(CMD, "pass `params` (merged into the filter's parameters)"))?;
let authorize = s.authorize;
edit_filters(s, p, "Edit Smart Filter", |sm| {
let i = filter_index(CMD, p, sm)?;
let f = &mut sm.smart_filters[i];
@@ -1010,6 +1023,11 @@ fn set_filter_params(s: &mut Session, p: &Value) -> Result<Value> {
(Value::Object(old), Value::Object(n)) => old.extend(n),
(slot, n) => *slot = n,
}
// Authorize the underlying command with the complete merged params, including paths
// already stored in the document, before committing the edit or starting a worker.
if let Some(gate) = authorize {
gate(&f.command, &f.params)?;
}
Ok(())
})
}
@@ -17,7 +17,7 @@ fn uncached(s: &Session) -> Surface {
let doc = &s.active().unwrap().doc;
let sm = active_smart(s);
let (name, bytes) = source_bytes(&doc.metadata, &sm.source).unwrap();
apply_smart_filters(&place(&name, &bytes, doc.pixel_format(), &sm).unwrap(), &sm, doc.bounds())
apply_smart_filters(&place(&name, &bytes, doc.pixel_format(), &sm).unwrap(), &sm, doc.bounds()).unwrap()
}
#[test]