From 5540a5e79710ffb267830d3b1a12e0cb7df2f132 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 14:27:11 -0800 Subject: [PATCH] Plug-ins: an oversized path fails a filter run, and objects that clip stay out of its input A path or compound path that doesn't fit what is left of the input now fails the run with the runtime's own "selection is too large to hand over" limit before the rest of the selection is written, instead of later read-only objects reporting a size error. Objects that clip a clip group are left out for every filter: compound paths and paths inside a group that clips one reached filters before, as type, images and symbol instances did. --- crates/engine/src/cmd/plugin.rs | 2 +- crates/plugins/src/objects.rs | 37 +++++++++++++-------- crates/plugins/src/objects/tests.rs | 50 +++++++++++++++++++++++++++++ crates/plugins/src/runtime.rs | 5 ++- docs/mcp.md | 3 +- docs/plugins.md | 13 +++++--- docs/target-app-parity.md | 2 +- 7 files changed, 89 insertions(+), 23 deletions(-) diff --git a/crates/engine/src/cmd/plugin.rs b/crates/engine/src/cmd/plugin.rs index 1a6ff4189..61ac3ff6d 100644 --- a/crates/engine/src/cmd/plugin.rs +++ b/crates/engine/src/cmd/plugin.rs @@ -703,7 +703,7 @@ pub fn specs() -> Vec { "Run Plug-in", [], None, - "{id, params?: {…} (the plug-in's parameters; or give them as top-level keys; missing ones take their defaults, see plugin.info), ids?: [..] (default: the selection), …the plug-in's parameters as top-level keys} run an object filter on the selected paths and compound paths (inside selected groups and layers too; hidden, locked, guide and clipping paths are left out): it may change, delete or add objects, as one undo step; the result is selected, except the objects it locked or made guides. A filter whose manifest lists inputs (type, image, symbolInstance; it lists doc.read in permissions, so plugin.install needs grant) is also given the selected objects of those kinds to read, with at most 16 MiB of pixels and 16 MiB of type runs per run (later ones come with pixelsOmitted or truncated); every object counts toward the input's half of the plug-in's memory cap, ids, bounds, transforms and names included, and an object whose fields besides its pixels and runs don't fit fails the run with a memory-budget error naming it and its size: its output returns each as {id} or exactly as given, or the run is refused and nothing changes. With nothing selected it gets no objects (generators add theirs to the current layer) → {plugin, ids}", + "{id, params?: {…} (the plug-in's parameters; or give them as top-level keys; missing ones take their defaults, see plugin.info), ids?: [..] (default: the selection), …the plug-in's parameters as top-level keys} run an object filter on the selected paths and compound paths (inside selected groups and layers too; hidden, locked, guide and clipping paths, compound paths that clip a clip group and paths inside a group that clips one are left out): it may change, delete or add objects, as one undo step; the result is selected, except the objects it locked or made guides. A filter whose manifest lists inputs (type, image, symbolInstance; it lists doc.read in permissions, so plugin.install needs grant) is also given the selected objects of those kinds to read, with at most 16 MiB of pixels and 16 MiB of type runs per run (later ones come with pixelsOmitted or truncated); every object counts toward the input's half of the plug-in's memory cap, ids, bounds, transforms and names included, and an object whose fields besides its pixels and runs don't fit fails the run with a memory-budget error naming it and its size (a path or compound path fails it with the memory-budget error for a selection too large to hand over): its output returns each as {id} or exactly as given, or the run is refused and nothing changes. With nothing selected it gets no objects (generators add theirs to the current layer) → {plugin, ids}", has_doc, run ), diff --git a/crates/plugins/src/objects.rs b/crates/plugins/src/objects.rs index fa309ae62..f5d746bcf 100644 --- a/crates/plugins/src/objects.rs +++ b/crates/plugins/src/objects.rs @@ -186,8 +186,9 @@ pub fn encode_input(doc: &Document, ids: &[NodeId]) -> Vec { /// [`MAX_INPUT_TEXT_TOTAL`] over all of them in order, and within `capacity`, the most bytes the /// input may be. Every object counts toward `capacity` in order, ids, bounds, transforms and names /// included: pixels or text that don't fit what is left are omitted (`pixelsOmitted`, -/// `truncated`), and a read-only object whose other fields alone don't fit is an -/// [`Error::Limit`] naming it and its size. Also returns those objects by id, which the filter +/// `truncated`), a read-only object whose other fields alone don't fit is an [`Error::Limit`] +/// naming it and its size, and a path or compound path that doesn't fit is the limit the runtime +/// gives an input past its share. Also returns those objects by id, which the filter /// must return as they were ([`decode_output_with`]). pub fn encode_input_with(doc: &Document, ids: &[NodeId], kinds: &[InputKind], capacity: usize) -> Result<(Vec, HashMap)> { encode_input_within(doc, ids, kinds, Allowance { input: capacity.saturating_sub(EMPTY_INPUT.len()), ..Allowance::FULL }) @@ -240,10 +241,12 @@ fn encode_input_within(doc: &Document, ids: &[NodeId], kinds: &[InputKind], mut return Ok((Vec::new(), HashMap::new())); } let size = out.len() - start; - if checked && size > left.input { - return Err(too_large(n, size, left.input)); + // A path or compound path past what is left fails the run as the runtime would fail it, + // before the rest of the selection is written. + if size > left.input { + return Err(if checked { too_large(n, size, left.input) } else { Error::Limit(crate::runtime::SELECTION_TOO_LARGE.into()) }); } - left.input = left.input.saturating_sub(size); + left.input -= size; } out.extend_from_slice(b"]}"); Ok((out, read_only)) @@ -976,33 +979,39 @@ pub fn selectable(n: &Node) -> bool { /// The paths and compound paths a filter reads from the selection `selected`, in paint order: /// selected ones, and those inside selected groups and layers; hidden, locked, guide (compound -/// guides too) and clipping paths are left out (so are paths inside compound paths, blends and -/// other live objects). +/// guides too) and clipping paths are left out, and so are compound paths that clip a clip group +/// and paths inside a group that clips one (and paths inside compound paths, blends and other +/// live objects). pub fn filter_targets(doc: &Document, selected: &[NodeId]) -> Vec { filter_targets_with(doc, selected, &[]) } /// [`filter_targets`] for a filter that asks for `kinds` too: type, images and symbol instances of -/// those kinds join the paths in paint order, under the same rules (type that clips a clip group is -/// left out, and so is anything inside a symbol, a placed document or a live object). +/// those kinds join the paths in paint order, under the same rules (objects that clip a clip +/// group are left out, inside a group that clips one too, and so is anything inside a symbol, a +/// placed document or a live object). pub fn filter_targets_with(doc: &Document, selected: &[NodeId], kinds: &[InputKind]) -> Vec { - fn visit(list: &[Arc], sel: &HashSet, inside: bool, clips: bool, kinds: &[InputKind], out: &mut Vec) { + /// `source`: `list` is inside the clipping object of a clip group. + fn visit(list: &[Arc], sel: &HashSet, inside: bool, clips: bool, source: bool, kinds: &[InputKind], out: &mut Vec) { for (i, n) in list.iter().enumerate() { if !n.visible || n.locked { continue; } let picked = inside || sel.contains(&n.id); + let clipping = source || (clips && i == 0); match &n.kind { - NodeKind::Path { guide: false, clipping: false, .. } | NodeKind::Compound { .. } if picked && !is_guide(n) => out.push(n.id), - NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => visit(children, sel, picked, n.clips(), kinds, out), - _ if picked && !(clips && i == 0) && input_kind(n).is_some_and(|k| kinds.contains(&k)) => out.push(n.id), + NodeKind::Path { guide: false, clipping: false, .. } | NodeKind::Compound { .. } if picked && !clipping && !is_guide(n) => { + out.push(n.id) + } + NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => visit(children, sel, picked, n.clips(), clipping, kinds, out), + _ if picked && !clipping && input_kind(n).is_some_and(|k| kinds.contains(&k)) => out.push(n.id), _ => {} } } } let sel: HashSet = selected.iter().copied().collect(); let mut out = vec![]; - visit(&doc.layers, &sel, false, false, kinds, &mut out); + visit(&doc.layers, &sel, false, false, false, kinds, &mut out); out } diff --git a/crates/plugins/src/objects/tests.rs b/crates/plugins/src/objects/tests.rs index 9277ce708..7ff87f6e9 100644 --- a/crates/plugins/src/objects/tests.rs +++ b/crates/plugins/src/objects/tests.rs @@ -544,6 +544,56 @@ fn an_object_too_large_to_give_is_a_limit_naming_it() { assert_eq!((bytes, read_only.len()), (fits, 1)); } +/// A path or compound path that doesn't fit what is left of the input fails the run with the limit +/// the runtime gives an input past its share, before anything after it is written; the exact size +/// still fits. +#[test] +fn a_path_past_the_input_capacity_is_the_runtime_limit() { + let (d, ids, _, [text, ..]) = mixed(); + let (bytes, _) = encode_input_with(&d, &ids, &[], usize::MAX).unwrap(); + assert_eq!(encode_input_with(&d, &ids, &[], bytes.len()).unwrap().0, bytes, "the exact size fits"); + let e = encode_input_with(&d, &[ids[0], ids[1], ids[2], text], &[InputKind::Type], bytes.len() - 1).unwrap_err(); + assert_eq!(e.to_string(), "plug-in exceeded its memory budget (the selection is too large to hand over)"); +} + +/// Objects that clip a clip group are left out, and so are those inside a group that clips one; +/// those it clips are given. This covers a filter that lists no `inputs` too. +#[test] +fn objects_that_clip_a_clip_group_are_not_inputs() { + let (mut d, _, layer, [text, embedded, ..]) = mixed(); + let (t, e) = (Arc::new(d.node(text).unwrap().clone()), Arc::new(d.node(embedded).unwrap().clone())); + let mut copy = |n: &Arc| { + let mut c = (**n).clone(); + c.id = d.alloc_id(); + Arc::new(c) + }; + let (t_source, e_source, t_inner, e_inner, t_clipped) = (copy(&t), copy(&e), copy(&t), copy(&e), copy(&t)); + let inner = Node::group(d.alloc_id(), vec![t_inner.clone(), e_inner.clone()]); + let mut by_group = Node::group(d.alloc_id(), vec![Arc::new(inner), t_clipped.clone()]); + by_group.set_clips(true); + let mut by_type = Node::group(d.alloc_id(), vec![t_source.clone(), e_source.clone()]); + by_type.set_clips(true); + let by_group = d.insert(Some(layer), usize::MAX, by_group).unwrap(); + let by_type = d.insert(Some(layer), usize::MAX, by_type).unwrap(); + let path = |d: &mut Document, x: f64| Arc::new(Node::path(d.alloc_id(), square(x), Appearance::default())); + let (part, clipped_by_compound, in_source, clipped_by_paths) = (path(&mut d, 0.0), path(&mut d, 10.0), path(&mut d, 20.0), path(&mut d, 30.0)); + let compound = Node::new(d.alloc_id(), NodeKind::Compound { children: vec![part], rule: FillRule::NonZero }); + let mut by_compound = Node::group(d.alloc_id(), vec![Arc::new(compound), clipped_by_compound.clone()]); + by_compound.set_clips(true); + let source = Node::group(d.alloc_id(), vec![in_source]); + let mut by_paths = Node::group(d.alloc_id(), vec![Arc::new(source), clipped_by_paths.clone()]); + by_paths.set_clips(true); + let by_compound = d.insert(Some(layer), usize::MAX, by_compound).unwrap(); + let by_paths = d.insert(Some(layer), usize::MAX, by_paths).unwrap(); + let ids = |list: &[NodeId]| filter_targets_with(&d, list, &InputKind::ALL); + assert_eq!(ids(&[by_type]), [e_source.id], "the type clips; the image is clipped"); + assert_eq!(ids(&[by_group]), [t_clipped.id], "the group's type and image clip"); + assert_eq!(ids(&[by_compound]), [clipped_by_compound.id], "the compound path clips"); + assert_eq!(ids(&[by_paths]), [clipped_by_paths.id], "the group's path clips"); + assert_eq!(filter_targets(&d, &[by_compound]), [clipped_by_compound.id], "a filter without inputs"); + assert_eq!(filter_targets(&d, &[by_paths]), [clipped_by_paths.id], "a filter without inputs"); +} + /// A field a plug-in adds to a read-only object is named in the refusal with its control and /// direction characters escaped, and cut short. #[test] diff --git a/crates/plugins/src/runtime.rs b/crates/plugins/src/runtime.rs index d37a9159d..77b6d8732 100644 --- a/crates/plugins/src/runtime.rs +++ b/crates/plugins/src/runtime.rs @@ -32,6 +32,9 @@ pub const HEAVY_FUEL: u64 = 1_000_000_000; /// Wall-clock budget of a heavy filter's run, in milliseconds (native builds). pub const HEAVY_WALL_TIME_MS: u64 = 300_000; +/// The limit a run reports when its input and parameters together are past half its memory. +pub(crate) const SELECTION_TOO_LARGE: &str = "memory budget (the selection is too large to hand over)"; + /// A heavy run's budget as people read it: (minutes it may take, times the instructions a light /// run gets). pub fn heavy_terms() -> (u64, u64) { @@ -305,7 +308,7 @@ impl Plugin { fn run_inner(&self, input: &[u8], params: &[u8], limits: &Limits, report: &mut Report) -> Result> { let total = input.len().checked_add(params.len()).filter(|b| *b <= i32::MAX as usize && *b <= self.limits.max_memory_bytes / 2); - total.ok_or_else(|| Error::Limit("memory budget (the selection is too large to hand over)".into()))?; + total.ok_or_else(|| Error::Limit(SELECTION_TOO_LARGE.into()))?; let deadline = Deadline::new(limits.wall_time_ms); let m = &self.manifest; let run = Run::new(&m.id, m.kind != Kind::Effect, true, deadline).with_storage(crate::registry::storage(&m.id)); diff --git a/docs/mcp.md b/docs/mcp.md index d08fa834d..36586303a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6479,7 +6479,8 @@ undo step; it may change, delete or add objects, and the result is selected). A pixels and 16 MiB of type runs over one run (later ones come with `pixelsOmitted` or `truncated`). Every object counts toward the input's half of the plug-in's memory cap, ids, bounds, transforms and names included: pixels and runs that don't fit come the same way, and an object whose other fields don't fit makes `plugin.run` return a memory-budget error -naming the object and its size, with nothing changed. Its output returns +naming the object and its size, with nothing changed. A path or compound path that doesn't fit makes it return the +memory-budget error `the selection is too large to hand over`. Its output returns each of them as `{"id": …}` or exactly as given, otherwise `plugin.run` returns an error naming the object and the field, and the document and undo history are unchanged. A live-effect plug-in is an effect with id `plugin.`: `effect.apply`, `effect.setParams` and `effect.expandAppearance` work as for built-in effects, and diff --git a/docs/plugins.md b/docs/plugins.md index 8325a5737..f13e28e5c 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -398,9 +398,10 @@ type and `blob` but without their data, which follows the JSON in a saved file; ### Reading type, images and symbols A filter whose manifest lists `inputs` also gets the selected objects of those kinds, in paint order among the paths -and under the same rules (hidden and locked ones, type that clips a clip group and anything inside a symbol, a placed -document or a live object are left out). They are never turned into outlines, rectangles or the symbol's art. Each -has `id`, `type`, `name` (when it has one), `bounds` (`null` until the object has been laid out or drawn) and +and under the same rules (hidden and locked ones, objects that clip a clip group, alone or inside a group that clips +it, and anything inside a symbol, a placed document or a live object are left out). They are never turned into +outlines, rectangles or the symbol's art. Each has `id`, `type`, `name` (when it has one), `bounds` (`null` until the +object has been laid out or drawn) and `transform`, `[a, b, c, d, e, f]` mapping its own space (text space, pixel space, the symbol's art) into the document. In this example the character and paragraph styles are cut short; a filter gets every attribute: @@ -446,7 +447,9 @@ Every object in the input counts toward the half of the memory cap that the inpu order, with its id, bounds, transform and name. Type whose runs and paragraphs don't fit what is left comes with `truncated: true`, and an image whose pixels don't fit comes with `pixelsOmitted: true`. When the other fields of one of these objects don't fit what is left, the run fails before the plug-in starts, naming the object and the bytes it -needs: `plug-in exceeded its memory budget (object 17 needs 4200 bytes of input, 1000 left)`. An echo of them counts +needs: `plug-in exceeded its memory budget (object 17 needs 4200 bytes of input, 1000 left)`. A path or compound path +that doesn't fit fails it with `plug-in exceeded its memory budget (the selection is too large to hand over)` before +the objects after it are written. An echo of them counts toward the 64 MiB output cap. ### Parameters @@ -546,6 +549,6 @@ per change of their input, parameters or paints (results are cached), not per fr - Tool plug-ins (pointer events on the canvas, a guide line or rectangle, geometry back), panel plug-ins, importers (plug-in formats that open files). -- Live effects that change paint (they reshape geometry; an object filter recolours), and filters that change type, +- Live effects that change paint (they reshape geometry; an object filter recolors), and filters that change type, images or symbol instances (a filter at `api` 2 reads them, see [`inputs`](#reading-type-images-and-symbols)). - Apply Last Effect and Last Effect work for plug-in effects; there is no "last filter" for object filters yet. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 49e26abb4..ee3fae0b8 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1542,8 +1542,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Safe Mode for plug-ins (G17):** `vectorcraft --safe-mode` and `vectorcraft-cli --safe-mode ` start without plug-ins. When a launch stopped while a plug-in was loading or drawing its first live effect, the next launch names that plug-in, without saying it caused the stop, and offers Disable and Continue, Start Normally or Start in Safe Mode. Help › Safe Mode lists the disabled plug-ins with Enable on Re-launch, which takes effect at the next launch, and the app bar and the window title show Safe Mode. The help page (https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf, pages 65 to 69) describes the same choice between diagnosing and starting normally, a Safe Mode with the problem item off and the rest of the app working, and turning the item back on at the next launch. In Safe Mode no plug-in loads or runs, and a live-effect plug-in draws its object's geometry unchanged while the object keeps the effect, which the Appearance panel marks "(Safe Mode)" with the plug-in's id in its tooltip and `effect.list` marks `skipped`; Expand Appearance refuses such an object. The web build keeps no plug-ins between page loads and has no marker. Each desktop launch with preferences keeps a marker in the `Launch` folder next to them; `vectorcraft-cli` and a desktop launch without preferences keep none, so a stop there leads to no choice at the next launch. Disabled plug-ins are listed in `Plug-ins/disabled.json`. `plugin.safeMode` reports Safe Mode and the disabled plug-ins, and `plugin.setEnabled` changes the list. Regressions: `crates/plugins/src/safe_mode.rs`, `crates/plugins/tests/safe_mode.rs`, `crates/engine/tests/plugin_safe_mode.rs`, `crates/ui-egui/tests/safe_mode.rs`. Scores and hours unchanged. - **Plug-in instruction budget at a bulk memory instruction:** a run that pauses where one slice of its instruction budget ends keeps the instructions the slice still held. A `memory.fill` or `memory.copy` that the slice can't pay for in full runs on the next slice, so a run whose budget covers its work finishes; a run ends with `Limit("instruction budget")` only when the instructions it has left can't pay for the next instruction. Regressions: `crates/plugins/tests/abi.rs`. Scores and hours unchanged. - **Plug-in permissions (G17):** a manifest may list `permissions` (`doc.read`, `doc.write`, `state`, `effect.render`, `export.write`; the first three follow storytold/photocraft#662) and an `api` level; without the list a plug-in gets what its kind implies and installs as before. Install Plug-in… and File › Open show the name, id, version, file SHA-256 and each permission in plain words, with control and bidirectional characters escaped, and install nothing on Cancel; `plugin.install` needs `grant`, and folders install only granted plug-ins. The grant is kept with the module's hash, a module asking for more asks again, and a filter run refuses output its permissions don't allow. Regressions: `crates/plugins/src/permissions.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. +- **Filter plug-ins read type, images and symbol instances (G17, #4333):** a filter at plug-in API level 2 lists `inputs` (`type`, `image`, `symbolInstance`) with `doc.read` in its `permissions`, so installing it asks for approval, and is also given the selected objects of those kinds, read only: type with its runs, character and paragraph attributes (no text past 256 KiB or 4,096 runs or paragraphs, or 16 MiB of runs over the input), images with their size, color mode and, when embedded and at most 1 MiB in base64 (16 MiB over the input), their RGBA pixels (never a linked file's), and symbol instances with their symbol and transform. Every object counts toward the input's half of the memory cap; one whose fields besides its pixels and runs don't fit fails the run naming it and its size, and a path or compound path that doesn't fit fails it too. Its output returns each as `{id}` or exactly as given; any change or omission is refused before the document changes. Filters without `inputs` get the same input as before, except that compound paths that clip a clip group and paths inside a group that clips one are left out for every filter. Regressions: `crates/plugins/src/objects/tests.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged. - **Plug-in progress, Cancel and log lines (G17):** at plug-in API level 2 a plug-in with the `progress` permission imports `env.vc_progress`, `env.vc_cancelled` and `env.vc_log`. A heavy filter's and an export's progress windows show its progress as a percentage bar, their Cancel reaches it through `vc_cancelled`, and its log lines go to the app's log with its id, capped per line and per run. Installing checks every import and names the one it refuses. Regressions: `crates/plugins/src/host.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged. -- **Filter plug-ins read type, images and symbol instances (G17, #4333):** a filter at plug-in API level 2 lists `inputs` (`type`, `image`, `symbolInstance`) with `doc.read` in its `permissions`, so installing it asks for approval, and is also given the selected objects of those kinds, read only: type with its runs, character and paragraph attributes (no text past 256 KiB or 4,096 runs or paragraphs, or 16 MiB of runs over the input), images with their size, color mode and, when embedded and at most 1 MiB in base64 (16 MiB over the input), their RGBA pixels (never a linked file's), and symbol instances with their symbol and transform. Every object counts toward the input's half of the memory cap; one whose fields besides its pixels and runs don't fit fails the run naming it and its size. Its output returns each as `{id}` or exactly as given; any change or omission is refused before the document changes. Filters without `inputs` get the same input as before. Regressions: `crates/plugins/src/objects/tests.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged. - **Plug-in settings and cache (G17, #2191):** at plug-in API level 2 a plug-in with the `settings` permission keeps settings of its own (`env.vc_settings_get`, `_set`, `_delete`) and one with `cache` a cache the user can clear (`env.vc_cache_read`, `_write`, `_clear`), under its id in the `Plug-ins` folder. Keys are plain names, never paths; VectorCraft's own caps refuse a write past 256 settings, 1 MiB of settings or 512 MiB of cache; a damaged settings file is moved aside and the settings start empty. `plugin.clearCache` empties a cache, `plugin.list` and `plugin.info` give `cacheBytes`, replacing a plug-in keeps its storage and `plugin.remove` deletes it. Another module under an id that is installed or has storage installs only with `replace: true`, which the app asks the user for; each storage call counts the whole settings file or cache entry it reads and writes toward the run's 1 GiB. Regressions: `crates/plugins/src/storage.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged. - **Plug-in effects after a plug-in changes:** when a plug-in is installed for the first time, installed again or removed (`plugin.install`, `plugin.reload`, `plugin.remove`, File › Open of a `.wasm` file, the Additional Plug-ins Folder preference), the next frame the canvas draws uses the plug-ins then installed for objects with a plug-in effect and their raster effects, for the blends, envelopes and repeats that show such objects, and for pattern tiles and symbol instances whose art holds one.