From 96bdb813a6628290696bf80dad40430f27c06572 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 04:42:17 -0800 Subject: [PATCH] Plug-ins: read-only inputs count toward the input's memory share A filter's input is now encoded within the half of the plug-in's memory cap that the input and the parameters share. Every object counts toward it in paint order, with its id, bounds, transform and name. Type runs and image pixels that don't fit what is left come with truncated or pixelsOmitted, and a type, image or symbol instance whose other fields alone don't fit fails the run before the plug-in starts with "memory budget (object N needs S bytes of input, L left)". --- crates/engine/src/cmd/plugin.rs | 6 +- crates/plugins/src/objects.rs | 153 ++++++++++++++++++---------- crates/plugins/src/objects/tests.rs | 53 ++++++++-- docs/mcp.md | 5 +- docs/plugins.md | 10 +- docs/target-app-parity.md | 2 +- 6 files changed, 164 insertions(+), 65 deletions(-) diff --git a/crates/engine/src/cmd/plugin.rs b/crates/engine/src/cmd/plugin.rs index f352bdac1..e5ecc9992 100644 --- a/crates/engine/src/cmd/plugin.rs +++ b/crates/engine/src/cmd/plugin.rs @@ -162,8 +162,10 @@ impl FilterRun { // What the plug-in keeps with the document, as it last wrote it. let data = st.doc.plugin_data(&m.id).filter(|_| may.contains(&Permission::State)).cloned().unwrap_or(Value::Null); params["_context"] = json!({"mode": "filter", "bounds": bounds.map(rect), "artboard": board.map(|a| rect(a.rect)), "documentData": data}); - let (input, read_only) = objects::encode_input_with(&st.doc, &inputs, &kinds); let params = serde_json::to_vec(¶ms).map_err(|e| bad(C, e.to_string()))?; + // The input and the parameters may use half the plug-in's memory together. + let capacity = (plugin.limits().max_memory_bytes / 2).min(i32::MAX as usize).saturating_sub(params.len()); + let (input, read_only) = objects::encode_input_with(&st.doc, &inputs, &kinds, capacity).map_err(err)?; let document = (st.uid, st.revision); Ok(FilterRun { plugin, input, params, inputs, read_only, parent, document }) } @@ -531,7 +533,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): 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 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}", has_doc, run ), diff --git a/crates/plugins/src/objects.rs b/crates/plugins/src/objects.rs index e8db87319..fa309ae62 100644 --- a/crates/plugins/src/objects.rs +++ b/crates/plugins/src/objects.rs @@ -178,60 +178,80 @@ pub fn encode(n: &Node) -> Option { /// The input document of a filter run: `{"objects": [...]}` for `ids` (paths and compound paths; /// others are left out). pub fn encode_input(doc: &Document, ids: &[NodeId]) -> Vec { - encode_input_with(doc, ids, &[]).0 + encode_input_with(doc, ids, &[], usize::MAX).map(|(bytes, _)| bytes).unwrap_or_default() } /// [`encode_input`] for a filter that asks for `kinds` too: objects of those kinds among `ids` are /// in it as [`encode_read_only`] gives them, within [`MAX_INPUT_PIXELS_TOTAL`] and -/// [`MAX_INPUT_TEXT_TOTAL`] over all of them in order. 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]) -> (Vec, HashMap) { - encode_input_within(doc, ids, kinds, Allowance::FULL) +/// [`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 +/// 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 }) } -/// What an input's images and type may still be given of its totals, as its objects are encoded in -/// order: characters of base64 for pixels, bytes of JSON for runs and paragraphs. +/// The input with no objects; each object adds its JSON and a comma. +const EMPTY_INPUT: &[u8] = b"{\"objects\":[]}"; + +/// What an input's objects may still be given of its totals, as they are encoded in order: +/// characters of base64 for pixels, bytes of JSON for runs and paragraphs, and bytes of the whole +/// input. #[derive(Clone, Copy, Debug)] struct Allowance { pixels: usize, text: usize, + input: usize, } impl Allowance { - const FULL: Allowance = Allowance { pixels: MAX_INPUT_PIXELS_TOTAL, text: MAX_INPUT_TEXT_TOTAL }; + const FULL: Allowance = Allowance { pixels: MAX_INPUT_PIXELS_TOTAL, text: MAX_INPUT_TEXT_TOTAL, input: usize::MAX }; } /// [`encode_input_with`] within `left` of the totals. Each read-only object is kept once, in the map /// it returns, and written into the input from there. -fn encode_input_within(doc: &Document, ids: &[NodeId], kinds: &[InputKind], mut left: Allowance) -> (Vec, HashMap) { +fn encode_input_within(doc: &Document, ids: &[NodeId], kinds: &[InputKind], mut left: Allowance) -> Result<(Vec, HashMap)> { let mut read_only = HashMap::new(); let symbols = doc.symbol_boxes(); let mut out = b"{\"objects\":[".to_vec(); let mut first = true; for n in ids.iter().filter_map(|id| doc.node(*id)) { let path; - let o: &Value = match input_kind(n).filter(|k| kinds.contains(k)) { - Some(_) => match encode_read_only_within(doc, n, &symbols, &mut left) { - Some(o) => read_only.entry(n.id).insert_entry(o).into_mut(), + let (o, checked): (&Value, bool) = match input_kind(n).filter(|k| kinds.contains(k)) { + Some(_) => match encode_read_only_within(doc, n, &symbols, &mut left)? { + Some(o) => (read_only.entry(n.id).insert_entry(o).into_mut(), true), None => continue, }, None => match encode(n) { Some(o) => { path = o; - &path + (&path, false) } None => continue, }, }; + let start = out.len(); if !std::mem::take(&mut first) { out.push(b','); } if serde_json::to_writer(&mut out, o).is_err() { - return (Vec::new(), HashMap::new()); + return Ok((Vec::new(), HashMap::new())); } + let size = out.len() - start; + if checked && size > left.input { + return Err(too_large(n, size, left.input)); + } + left.input = left.input.saturating_sub(size); } out.extend_from_slice(b"]}"); - (out, read_only) + Ok((out, read_only)) +} + +/// The error for read-only object `n`, which needs `size` bytes of input where `left` are left. +fn too_large(n: &Node, size: usize, left: usize) -> Error { + Error::Limit(format!("memory budget (object {} needs {size} bytes of input, {left} left)", n.id.0)) } /// The kind of read-only object `n` is: type, an image or a symbol instance (`None` for others). @@ -263,37 +283,34 @@ fn affine_json(xf: Affine) -> Value { /// the document has no such symbol, and `overrides: {}`. pub fn encode_read_only(doc: &Document, n: &Node) -> Option { let mut left = Allowance::FULL; - encode_read_only_within(doc, n, &doc.symbol_boxes(), &mut left) + encode_read_only_within(doc, n, &doc.symbol_boxes(), &mut left).ok().flatten() } +/// The bytes the `pixelsBase64` key adds to an image's JSON besides the base64 itself. +const PIXELS_KEY: usize = ",\"pixelsBase64\":\"\"".len(); + /// [`encode_read_only`] measuring symbol instances from `symbols`, a cache shared across the /// objects of one input, and taking the pixels and text it gives from `left`: pixels or text that -/// don't fit what is left are omitted (`pixelsOmitted`, `truncated`), and an image's size is -/// checked against it before anything is decoded. -fn encode_read_only_within(doc: &Document, n: &Node, symbols: &SymbolBoxes, left: &mut Allowance) -> Option { - let mut o = json!({"id": n.id.0, "type": input_kind(n)?.name(), "bounds": rect_json(doc.bounds_of_with(&[n.id], false, symbols))}); +/// don't fit what is left of their totals or of the input are omitted (`pixelsOmitted`, +/// `truncated`), and an image's size is checked against it before anything is decoded. The object's +/// other fields are measured first, with a comma before it: when they alone don't fit what is left +/// of the input, it is an [`Error::Limit`] naming the object and that size. +fn encode_read_only_within(doc: &Document, n: &Node, symbols: &SymbolBoxes, left: &mut Allowance) -> Result> { + let Some(kind) = input_kind(n) else { return Ok(None) }; + let mut o = json!({"id": n.id.0, "type": kind.name(), "bounds": rect_json(doc.bounds_of_with(&[n.id], false, symbols))}); + // Each flag is measured as `false`, its longer value. match &n.kind { NodeKind::Text(t) => { o["transform"] = affine_json(t.xf); - let (text, truncated) = text_json(t, &mut left.text); - o["text"] = text; - o["truncated"] = json!(truncated); + o["text"] = text_json(t, None).0; + o["truncated"] = json!(false); } NodeKind::Image(im) => { o["transform"] = affine_json(im.xf); - let linked = im.link.is_some(); - let blob = doc.image_blob(im); - // A linked image's pixels are its file's: never given (nor its path). - let pixels = blob.filter(|_| !linked).and_then(|b| rgba_pixels(b, im.width, im.height, MAX_INPUT_PIXELS.min(left.pixels))); o["pixelSize"] = json!([im.width, im.height]); - o["colorMode"] = json!(blob.map_or("unknown", color_mode)); - o["linked"] = json!(linked); - o["pixelsOmitted"] = json!(pixels.is_none()); - if let Some(p) = pixels { - let b64 = craft_vectorcraft_format::base64::encode(&p); - left.pixels = left.pixels.saturating_sub(b64.len()); - o["pixelsBase64"] = json!(b64); - } + o["colorMode"] = json!(doc.image_blob(im).map_or("unknown", color_mode)); + o["linked"] = json!(im.link.is_some()); + o["pixelsOmitted"] = json!(false); } NodeKind::SymbolInstance { symbol, xf } => { o["transform"] = affine_json(*xf); @@ -303,37 +320,69 @@ fn encode_read_only_within(doc: &Document, n: &Node, symbols: &SymbolBoxes, left } o["overrides"] = json!({}); } - _ => return None, + _ => return Ok(None), } if let Some(name) = &n.name { o["name"] = json!(name); } - Some(o) + let fields = serde_json::to_vec(&o).map_or(usize::MAX, |b| b.len()).saturating_add(1); + if fields > left.input { + return Err(too_large(n, fields, left.input)); + } + let room = left.input - fields; + match &n.kind { + NodeKind::Text(t) => { + let mut budget = left.text.min(room); + let (text, truncated) = text_json(t, Some(&mut budget)); + left.text -= left.text.min(room) - budget; + o["text"] = text; + o["truncated"] = json!(truncated); + } + NodeKind::Image(im) => { + let blob = doc.image_blob(im); + let max = MAX_INPUT_PIXELS.min(left.pixels).min(room.saturating_sub(PIXELS_KEY)); + // A linked image's pixels are its file's: never given (nor its path). + let pixels = blob.filter(|_| im.link.is_none()).and_then(|b| rgba_pixels(b, im.width, im.height, max)); + o["pixelsOmitted"] = json!(pixels.is_none()); + if let Some(p) = pixels { + let b64 = craft_vectorcraft_format::base64::encode(&p); + left.pixels = left.pixels.saturating_sub(b64.len()); + o["pixelsBase64"] = json!(b64); + } + } + _ => {} + } + Ok(Some(o)) } /// The text of type `t` as a filter reads it, and whether it was too long to give /// ([`MAX_INPUT_TEXT`], [`MAX_INPUT_RUNS`], or more bytes of JSON than `left`, which what it gives -/// is taken from). -fn text_json(t: &TextObject, left: &mut usize) -> (Value, bool) { +/// is taken from, with the commas between them). With `left` `None`, its kind and direction with no +/// runs or paragraphs. +fn text_json(t: &TextObject, left: Option<&mut usize>) -> (Value, bool) { let kind = match t.kind { TextKind::Point => "point", TextKind::Area { .. } => "area", TextKind::OnPath { .. } => "path", }; - let bytes = t.runs.iter().fold(0usize, |n, r| n.saturating_add(r.text.len())); - let paragraphs = t.paragraph_count(); - // The text alone is never more bytes than its JSON, so type that can't fit isn't built. - let mut truncated = bytes > MAX_INPUT_TEXT.min(*left) || t.runs.len() > MAX_INPUT_RUNS || paragraphs > MAX_INPUT_RUNS; let (mut runs, mut paras): (Vec, Vec) = (Vec::new(), Vec::new()); - if !truncated { - let value = |v: std::result::Result| v.unwrap_or(Value::Null); - runs = t.runs.iter().map(|r| value(serde_json::to_value(r))).collect(); - paras = (0..paragraphs).map(|i| value(serde_json::to_value(t.para_at(i)))).collect(); - let size = runs.iter().chain(¶s).fold(0usize, |n, v| n.saturating_add(serde_json::to_vec(v).map_or(usize::MAX, |b| b.len()))); - match left.checked_sub(size) { - Some(rest) => *left = rest, - None => { - (runs, paras, truncated) = (Vec::new(), Vec::new(), true); + let mut truncated = false; + if let Some(left) = left { + let bytes = t.runs.iter().fold(0usize, |n, r| n.saturating_add(r.text.len())); + let paragraphs = t.paragraph_count(); + // The text alone is never more bytes than its JSON, so type that can't fit isn't built. + truncated = bytes > MAX_INPUT_TEXT.min(*left) || t.runs.len() > MAX_INPUT_RUNS || paragraphs > MAX_INPUT_RUNS; + if !truncated { + let value = |v: std::result::Result| v.unwrap_or(Value::Null); + runs = t.runs.iter().map(|r| value(serde_json::to_value(r))).collect(); + paras = (0..paragraphs).map(|i| value(serde_json::to_value(t.para_at(i)))).collect(); + let commas = runs.len().saturating_sub(1) + paras.len().saturating_sub(1); + let size = runs.iter().chain(¶s).fold(commas, |n, v| n.saturating_add(serde_json::to_vec(v).map_or(usize::MAX, |b| b.len()))); + match left.checked_sub(size) { + Some(rest) => *left = rest, + None => { + (runs, paras, truncated) = (Vec::new(), Vec::new(), true); + } } } } diff --git a/crates/plugins/src/objects/tests.rs b/crates/plugins/src/objects/tests.rs index dddf62fbf..9277ce708 100644 --- a/crates/plugins/src/objects/tests.rs +++ b/crates/plugins/src/objects/tests.rs @@ -366,7 +366,7 @@ fn legacy_filter_input_bytes_are_unchanged() { assert_eq!(filter_targets(&d, &[layer]), ids); assert_eq!(filter_targets_with(&d, &[layer], &[]), ids); let every: Vec = ids.iter().copied().chain([text, embedded, linked, star, ghost]).collect(); - let (bytes, read_only) = encode_input_with(&d, &every, &[]); + let (bytes, read_only) = encode_input_with(&d, &every, &[], usize::MAX).unwrap(); assert_eq!(bytes, encode_input(&d, &ids), "the other objects are left out"); assert!(read_only.is_empty()); let all = filter_targets_with(&d, &[layer], &InputKind::ALL); @@ -377,7 +377,7 @@ fn legacy_filter_input_bytes_are_unchanged() { d.node_mut(text).unwrap().visible = false; d.node_mut(star).unwrap().locked = true; assert_eq!(filter_targets_with(&d, &[layer], &InputKind::ALL), [&ids[..], &[embedded, linked, ghost]].concat()); - let (bytes, read_only) = encode_input_with(&d, &all, &[InputKind::SymbolInstance]); + let (bytes, read_only) = encode_input_with(&d, &all, &[InputKind::SymbolInstance], usize::MAX).unwrap(); let v: Value = serde_json::from_slice(&bytes).unwrap(); let types: Vec<&str> = v["objects"].as_array().unwrap().iter().map(|o| o["type"].as_str().unwrap()).collect(); assert_eq!(types, ["path", "path", "path", "symbolInstance", "symbolInstance"]); @@ -485,10 +485,11 @@ fn input_totals_cover_images_and_type_in_order() { }; let (text2, image2, image3) = (copy(&mut d, text), copy(&mut d, embedded), copy(&mut d, embedded)); let one = encode_read_only(&d, d.node(text).unwrap()).unwrap(); - let size: usize = ["runs", "paragraphs"].iter().flat_map(|k| one["text"][k].as_array().unwrap()).map(|v| v.to_string().len()).sum(); + // The runs and paragraphs with the commas between them, without their brackets. + let size: usize = ["runs", "paragraphs"].iter().map(|k| one["text"][k].to_string().len() - 2).sum(); // A 2 × 1 image is 12 characters of base64: two fit 30, the third doesn't. let ids = [text, text2, embedded, image2, image3]; - let (bytes, read_only) = encode_input_within(&d, &ids, &InputKind::ALL, Allowance { pixels: 30, text: size }); + let (bytes, read_only) = encode_input_within(&d, &ids, &InputKind::ALL, Allowance { pixels: 30, text: size, input: usize::MAX }).unwrap(); let v: Value = serde_json::from_slice(&bytes).unwrap(); let objects = v["objects"].as_array().unwrap(); assert_eq!(objects.len(), 5); @@ -500,18 +501,56 @@ fn input_totals_cover_images_and_type_in_order() { assert_eq!(Some(o), read_only.get(&NodeId(o["id"].as_u64().unwrap()))); } // Within the full totals every one of them is given. - let (bytes, _) = encode_input_with(&d, &ids, &InputKind::ALL); + let (bytes, _) = encode_input_with(&d, &ids, &InputKind::ALL, usize::MAX).unwrap(); let all = String::from_utf8(bytes).unwrap(); assert!(!all.contains("\"pixelsOmitted\":true") && !all.contains("\"truncated\":true"), "{all}"); } +/// Every object counts toward the input's capacity in order, its id, bounds, transform and name +/// included: a symbol instance before type leaves the type less room, so text that fits the exact +/// size of the whole input comes with `truncated` one byte under it, and an image's pixels are +/// omitted the same way. +#[test] +fn object_fields_count_toward_the_input_capacity() { + let (d, ids, _, [text, embedded, _, star, _]) = mixed(); + for (order, flag) in [([ids[0], star, text], "truncated"), ([ids[0], star, embedded], "pixelsOmitted")] { + let (bytes, _) = encode_input_with(&d, &order, &InputKind::ALL, usize::MAX).unwrap(); + let (exact, _) = encode_input_with(&d, &order, &InputKind::ALL, bytes.len()).unwrap(); + assert_eq!(exact, bytes, "{flag}: the exact size fits"); + let (under, _) = encode_input_with(&d, &order, &InputKind::ALL, bytes.len() - 1).unwrap(); + let v: Value = serde_json::from_slice(&under).unwrap(); + assert_eq!(v["objects"][2][flag], json!(true), "{flag}: one byte under"); + assert!(under.len() < bytes.len() - 1, "{flag}"); + } +} + +/// A read-only object whose fields other than its pixels and text alone don't fit what is left of +/// the input fails the run with a limit naming it and its size, rather than being left out. +#[test] +fn an_object_too_large_to_give_is_a_limit_naming_it() { + let (mut d, ids, _, [_, _, linked, star, _]) = mixed(); + d.node_mut(linked).unwrap().name = Some("n".repeat(4096)); + let (fits, _) = encode_input_with(&d, &[ids[0], star], &InputKind::ALL, usize::MAX).unwrap(); + let order = [ids[0], star, linked]; + let e = encode_input_with(&d, &order, &InputKind::ALL, fits.len() + 4096).unwrap_err(); + assert!(matches!(e, Error::Limit(_)), "{e}"); + // Measured with `pixelsOmitted` false and a comma before it. + let mut o = encode_read_only(&d, d.node(linked).unwrap()).unwrap(); + o["pixelsOmitted"] = json!(false); + let size = o.to_string().len() + 1; + assert_eq!(e.to_string(), format!("plug-in exceeded its memory budget (object {} needs {size} bytes of input, 4096 left)", linked.0)); + // A filter that doesn't list its kind is given the rest. + let (bytes, read_only) = encode_input_with(&d, &order, &[InputKind::SymbolInstance], fits.len() + 4096).unwrap(); + assert_eq!((bytes, read_only.len()), (fits, 1)); +} + /// 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] fn a_changed_field_is_named_escaped_and_short() { let (d, _, layer, [text, ..]) = mixed(); let targets = filter_targets_with(&d, &[layer], &[InputKind::Type]); - let (bytes, read_only) = encode_input_with(&d, &targets, &[InputKind::Type]); + let (bytes, read_only) = encode_input_with(&d, &targets, &[InputKind::Type], usize::MAX).unwrap(); let mut v: Value = serde_json::from_slice(&bytes).unwrap(); let at = v["objects"].as_array().unwrap().iter().position(|o| o["id"] == json!(text.0)).unwrap(); let key = format!("\u{202E}{}", "k".repeat(500)); @@ -530,7 +569,7 @@ fn a_changed_field_is_named_escaped_and_short() { fn read_only_kind_field_and_omission_refuse() { let (mut d, ids, layer, [text, embedded, linked, star, ghost]) = mixed(); let targets = filter_targets_with(&d, &[layer], &InputKind::ALL); - let (bytes, read_only) = encode_input_with(&d, &targets, &InputKind::ALL); + let (bytes, read_only) = encode_input_with(&d, &targets, &InputKind::ALL, usize::MAX).unwrap(); assert_eq!(read_only.len(), 5); let input: Value = serde_json::from_slice(&bytes).unwrap(); let before = d.clone(); diff --git a/docs/mcp.md b/docs/mcp.md index af66deab4..53f6ff89b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6089,7 +6089,10 @@ schema, defaults). An object filter runs on the selected paths and compound path undo step; it may change, delete or add objects, and the result is selected). A filter whose manifest lists `inputs` (`type`, `image`, `symbolInstance`; `inputs` in its `plugin.list` entry) also lists `doc.read` in its `permissions`, so `plugin.install` needs `grant`. It is also given the selected objects of those kinds to read, with at most 16 MiB of -pixels and 16 MiB of type runs over one run (later ones come with `pixelsOmitted` or `truncated`): its output returns +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 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 05132fc02..dd39fe512 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -377,8 +377,14 @@ was given (numbers compare by value, so `1` and `1.0` are the same). A changed o listed twice or left out, and a new object with one of these types are refused, and the document and undo history stay as they were: `objects[1] (id 17): read-only field 'text.runs[0].text' changed`, `objects (id 17): a read-only object was left out`. The field is named with control and bidirectional characters escaped (`\u{202E}`), cut to 64 -characters. New objects listed after one of them go just above it. Their text and pixels count toward the input's -half of the memory cap, and an echo of them toward the 64 MiB output cap. +characters. New objects listed after one of them go just above it. + +Every object in the input counts toward the half of the memory cap that the input and the parameters share, in paint +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 +toward the 64 MiB output cap. ### Parameters diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 812e777ed..fa4a8f0d6 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1488,7 +1488,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **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. - **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. 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. +- **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 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.