diff --git a/ROADMAP.md b/ROADMAP.md index f3e6750ed..740ec6010 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~30 points of "ready for real work" and ~350–620 h away -> **Last reviewed:** 2026-10-22 · **Last updated:** 2026-10-30 · **Change:** minor (localization: 303 more panel/dialog rows in Japanese and the Chinese catalogs; recounted after #1774: Japanese 27% → 46%, Simplified Chinese 29% → 46%, Traditional Chinese 29% → 38%, overall 18% → 24%) · **Target:** Adobe After Effects 2026 (26.5.0.89) +> **Last reviewed:** 2026-10-22 · **Last updated:** 2026-10-30 · **Change:** minor (features of 30 October in the progress log: text Line Join, Timeline drag-reorder and multi-select of effects, viewer edge pan, ScriptUI drag capture and dock size; previously: localization recount after #1774) · **Target:** Adobe After Effects 2026 (26.5.0.89) EffectCraft aims to do what After Effects does, with the same panels, menus and behaviour, written from scratch in Rust. This page is the one-page summary; the evidence is in @@ -117,6 +117,14 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft). ## Progress log +- 2026-10-30: Text strokes take a Line Join (Miter, Round or Bevel) in the Character panel, + Miter for new text as in After Effects (#2077). In the Timeline, effects, masks and shape items + drag to a new place in their stack (#2210) and Shift-click adds them to the selection, so one + animation preset can hold several (#2552). The Composition panel pans while a mask vertex is + held at its edge (#999). Effect buffers that pad a small layer stay within a memory budget + (#2261); an MP4 lasts as long as its edit list shows (#2592); the Render Queue's times tick + between frames (#2593). ScriptUI drags keep their control past the window edge with + `MouseEvent.buttons` (#2584), and docked panels get their dock's size and `onResize` (#2585). - 2026-10-28: A shape layer's Rectangle and Ellipse Size get the Constrain Proportions chain that Scale has, so a circle stays a circle while its size is typed, scrubbed or keyed; it starts off, so existing shapes behave as before (#2489). @@ -169,6 +177,7 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft). | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | Progress log: text Line Join, Timeline drag-reorder and Shift multi-select of effects, viewer edge pan, effect padding budget, MP4 edit-list duration, Render Queue clock, ScriptUI drag capture and docked-panel size | | 2026-10-22 | minor | Localization: 300 more panel/dialog rows in Japanese and the Chinese catalogs, plus the review's three missing rows (Basic, Advanced, Play/Stop) — panel tabs, form and dialog labels, context menus, tracker/graph/text-panel controls, File-panel footers, credits; language shares recounted after #1774 (Japanese 27% → 46% with 297 effect names, Simplified Chinese 29% → 46% with 307 effect names, Traditional Chinese 29% → 38%, overall 18% → 24%) | | 2026-10-12 | minor | Open-issue count (134 to 216), crash list and localization share (12% to 18%) updated to match docs/gaps.md | | 2026-10-12 | patch | Roto Brush span validation after trimming; command regressions for both layer edges | diff --git a/crates/engine/src/cmd/scripts.rs b/crates/engine/src/cmd/scripts.rs index 7ad0d34c3..8c14e03ec 100644 --- a/crates/engine/src/cmd/scripts.rs +++ b/crates/engine/src/cmd/scripts.rs @@ -581,6 +581,15 @@ pub fn specs() -> Vec { always, scriptui::mouse ), + cmd!( + "scriptui.resize", + "Resize Script Window", + [], + None, + "{window?, size: [width, height] (pixels)} → the window takes the size, lays itself out again and its onResizing / onResize run (a docked ScriptUI panel gets its dock's size)", + always, + scriptui::resize + ), cmd!( "scriptui.close", "Close Script Window", diff --git a/crates/engine/src/scriptui.rs b/crates/engine/src/scriptui.rs index 1ad4b9a40..2c211e77a 100644 --- a/crates/engine/src/scriptui.rs +++ b/crates/engine/src/scriptui.rs @@ -769,6 +769,17 @@ pub(crate) fn mouse(s: &mut Session, p: &Value) -> Result { dispatch(s, ScriptUiEvent { window, widget, kind: (*ty).to_string(), value }) } +/// `scriptui.resize`: the host gave the window a new size (a docked panel's dock, #2585): the +/// script's window takes it, lays itself out again and gets `onResizing` / `onResize`. +pub(crate) fn resize(s: &mut Session, p: &Value) -> Result { + let c = "scriptui.resize"; + let window = window_p(s, p, c)?; + let size = p.get("size").and_then(Value::as_array).ok_or_else(|| crate::commands::bad(c, "missing `size` [width, height]"))?; + let side = |i: usize| size.get(i).and_then(Value::as_f64).filter(|v| v.is_finite()).map(|v| v.round().clamp(1.0, 16384.0)); + let (Some(w), Some(h)) = (side(0), side(1)) else { return Err(crate::commands::bad(c, "`size` must be [width, height] in pixels")) }; + dispatch(s, ScriptUiEvent { window, widget: 0, kind: "resize".into(), value: json!([w, h]) }) +} + /// `scriptui.close`: close a window (a dialog's `show()` returns `result`, default 2 = Cancel). pub(crate) fn close(s: &mut Session, p: &Value) -> Result { let window = window_p(s, p, "scriptui.close")?; diff --git a/crates/export/src/pipeline.rs b/crates/export/src/pipeline.rs index c639ebad1..8e8c90561 100644 --- a/crates/export/src/pipeline.rs +++ b/crates/export/src/pipeline.rs @@ -431,12 +431,13 @@ impl<'a> Cx<'a> { fn crop_resize(&self, img: Image) -> Image { let om = self.job.output; let scale = self.comp().map(|c| self.job.settings.effective_scale(c)).unwrap_or_else(|| self.job.settings.resolution.clamp(0.01, 4.0)); + // frame_size applies crop itself: use the original frame and the same scale as edges. + let (w, h) = om.frame_size(img.width, img.height, scale); let (t, l, b, r) = om.crop.edges(img.width, img.height, scale); let img = if (t, l, b, r) == (0, 0, 0, 0) { img } else { crop(&img, t, l, b, r) }; if !om.resize.enabled { return img; } - let (w, h) = om.frame_size(img.width, img.height, 1.0); if (w, h) == (img.width, img.height) { return img; } @@ -1062,6 +1063,91 @@ mod tests { } } + /// Regress the locked-aspect example: cropping 1920x1080 must resize to 1280x1440. + #[test] + fn crop_resize_applies_margins_once() { + use effectcraft_project::render_queue::{Crop, OutputModule, Resize}; + let output = OutputModule { + crop: Crop { enabled: true, left: 480, right: 480, ..Default::default() }, + resize: Resize { enabled: true, width: 1280, lock_aspect: true, quality: ResizeQuality::Low, ..Default::default() }, + ..Default::default() + }; + assert_eq!(output.frame_size(1920, 1080, 1.0), (1280, 1440)); + let img = run_crop_resize(Image::new(1920, 1080), &output, 1.0); + assert_eq!((img.width, img.height), (1280, 1440)); + assert_eq!(img.data.len(), 1280 * 1440); + } + + /// Apply output-module geometry to a synthetic frame without rendering or writing files. + fn run_crop_resize(img: Image, output: &effectcraft_project::render_queue::OutputModule, scale: f64) -> Image { + use effectcraft_project::render_queue::RenderSettings; + let project = Project::default(); + let settings = RenderSettings { resolution: scale, ..Default::default() }; + let job = Job { + project: &project, + footage: &effectcraft_render::NoFootage, + expr: None, + accel: None, + comp: effectcraft_project::ItemId(0), + settings: &settings, + output, + path: "unused.png", + sink: None, + nested_switches: true, + options: Default::default(), + }; + Cx::new(&job).crop_resize(img) + } + + /// Match predicted dimensions across crop, padding, ROI scale, aspect locks and resize qualities. + #[test] + fn crop_resize_dimensions_match_the_output_module() { + use effectcraft_project::render_queue::{Crop, OutputModule, Resize}; + let edges = Crop { enabled: true, left: 2, right: 2, ..Default::default() }; + let padding = Crop { enabled: true, left: -2, bottom: -2, ..Default::default() }; + let roi = |rect| Crop { enabled: true, use_roi: true, roi: Some(rect), ..Default::default() }; + for quality in [ResizeQuality::Low, ResizeQuality::High] { + let resize = |width| Resize { enabled: true, width, height: 1, lock_aspect: true, quality }; + for (crop, resize, scale, expected) in [ + (Crop::default(), Resize::default(), 1.0, (8, 6)), + (edges, Resize::default(), 1.0, (4, 6)), + (Crop::default(), resize(4), 1.0, (4, 3)), + (edges, resize(8), 1.0, (8, 12)), + (padding, resize(5), 1.0, (5, 4)), + (roi([4.0, 2.0, 8.0, 6.0]), resize(8), 0.5, (8, 6)), + (roi([1.0, 0.5, 2.0, 1.5]), resize(8), 2.0, (8, 6)), + (edges, Resize { lock_aspect: false, height: 2, ..resize(5) }, 1.0, (5, 2)), + ] { + let output = OutputModule { crop, resize, ..Default::default() }; + assert_eq!(output.frame_size(8, 6, scale), expected); + let img = run_crop_resize(Image::filled(8, 6, [0.25, 0.0, 0.0, 0.5]), &output, scale); + assert_eq!((img.width, img.height), expected, "{crop:?}, {resize:?}, scale {scale}"); + assert_eq!(img.data.len(), expected.0 as usize * expected.1 as usize); + } + } + } + + /// Exercise zero/empty inputs, collapsed crops and existing caps on extreme single-axis resizes. + #[test] + fn crop_resize_zero_and_extreme_sizes_stay_bounded() { + use effectcraft_project::render_queue::{Crop, OutputModule, Resize}; + for quality in [ResizeQuality::Low, ResizeQuality::High] { + for (size, crop, width, height, expected) in [ + ((8, 6), Crop::default(), 0, 0, (1, 1)), + ((8, 1), Crop::default(), u32::MAX, 1, (30_000, 1)), + ((1, 8), Crop::default(), 1, u32::MAX, (1, 30_000)), + ((8, 6), Crop { enabled: true, left: 20_000, right: 20_000, ..Default::default() }, 2, 3, (2, 3)), + ((0, 0), Crop { enabled: true, left: 1, top: 1, ..Default::default() }, 2, 3, (2, 3)), + ] { + let output = OutputModule { crop, resize: Resize { enabled: true, width, height, lock_aspect: false, quality }, ..Default::default() }; + let img = run_crop_resize(Image::new(size.0, size.1), &output, 1.0); + assert_eq!((img.width, img.height), expected); + assert_eq!(img.data.len(), expected.0 as usize * expected.1 as usize); + assert!(img.data.iter().all(|p| *p == [0.0; 4])); + } + } + } + #[test] fn crop_and_pad() { let mut img = Image::new(4, 3); diff --git a/crates/export/tests/it/render_settings.rs b/crates/export/tests/it/render_settings.rs index 465d8e5fe..0fd6d471b 100644 --- a/crates/export/tests/it/render_settings.rs +++ b/crates/export/tests/it/render_settings.rs @@ -174,6 +174,31 @@ fn crop_roi_and_resize() { assert_eq!((z.width(), z.height()), (32, 24)); } +/// Resizing to the cropped frame's own dimensions must preserve every decoded PNG pixel. +/// Compare against crop-only output for padding and scaled ROIs with both resize qualities. +#[test] +fn crop_resize_preserves_pixels_when_the_cropped_size_is_requested() { + let (p, cid) = project(FrameRate::new(25, 1), |_, _| {}); + for (crop, scale, expected) in [ + (Crop { enabled: true, left: 16, right: 16, ..Default::default() }, 1.0, (32, 48)), + (Crop { enabled: true, left: -4, top: -2, ..Default::default() }, 1.0, (68, 50)), + (Crop { enabled: true, use_roi: true, roi: Some([0.0, 24.0, 32.0, 24.0]), ..Default::default() }, 0.5, (16, 12)), + (Crop { enabled: true, use_roi: true, roi: Some([0.0, 24.0, 32.0, 24.0]), ..Default::default() }, 2.0, (64, 48)), + ] { + let settings = RenderSettings { resolution: scale, ..custom(0.4, 0.44) }; + let mut output = OutputModule { crop, ..OutputModule::for_format(OutputFormat::PngSequence) }; + let reference = png(&run(&p, cid, &settings, &output, "/c/reference_[#####].png"), 0); + assert_eq!(reference.dimensions(), expected); + assert!(reference.pixels().any(|p| p.0[0] > 150), "the reference contains visible content"); + for quality in [ResizeQuality::Low, ResizeQuality::High] { + output.resize = Resize { enabled: true, width: expected.0, height: 1, lock_aspect: true, quality }; + let actual = png(&run(&p, cid, &settings, &output, "/c/resized_[#####].png"), 0); + assert_eq!(actual.dimensions(), expected); + assert!(actual == reference, "crop {crop:?}, scale {scale}, quality {quality:?}: pixels changed"); + } + } +} + #[test] fn alpha_only_and_premultiplied() { let (p, cid) = project(FrameRate::new(25, 1), |p, comp| { diff --git a/crates/script/src/scriptui.js b/crates/script/src/scriptui.js index 178bd8be1..ddfbc1f3e 100644 --- a/crates/script/src/scriptui.js +++ b/crates/script/src/scriptui.js @@ -912,6 +912,18 @@ function __uiDispatch(winId, id, kind, valueJson) { w.close(v === null ? 2 : v); return; } + // The host gave the window a new size (a docked panel's dock): take it, lay out, tell the script. + if (kind === "resize") { + var nw = Number(v && v[0]), nh = Number(v && v[1]); + var b = w.__ub || w.__lb; + if (nw > 0 && nh > 0 && (b[2] - b[0] !== nw || b[3] - b[1] !== nh)) { + w.__ub = [b[0], b[1], b[0] + nw, b[1] + nh]; + __uiLayout(w); + w.__fire("onResizing"); + w.__fire("onResize"); + } + return; + } var c = __uiFind(w, id); if (!c || c.enabled === false) return; var radio = function () { diff --git a/crates/ui-egui/src/panels/scriptui_view.rs b/crates/ui-egui/src/panels/scriptui_view.rs index 14460ed28..753b2884e 100644 --- a/crates/ui-egui/src/panels/scriptui_view.rs +++ b/crates/ui-egui/src/panels/scriptui_view.rs @@ -26,6 +26,8 @@ enum Act { Close(u32), /// A mouse event (`scriptui.mouse` parameters without window and widget). Mouse(u32, u32, Value), + /// A docked panel's new size (`scriptui.resize`). + Resize(u32, [f32; 2]), } fn rect_of(origin: egui::Pos2, b: [f64; 4]) -> Rect { @@ -500,6 +502,7 @@ fn perform(app: &mut EffectcraftApp, ctx: &egui::Context, acts: Vec) { Act::Set(w, id, v) => ("scriptui.set", json!({"window": w, "widget": id, "value": v})), Act::Changing(w, id, v) => ("scriptui.set", json!({"window": w, "widget": id, "value": v, "changing": true})), Act::Close(w) => ("scriptui.close", json!({"window": w})), + Act::Resize(w, [x, y]) => ("scriptui.resize", json!({"window": w, "size": [x, y]})), Act::Mouse(w, id, mut v) => { if let Some(o) = v.as_object_mut() { o.insert("window".into(), json!(w)); @@ -582,6 +585,14 @@ pub fn panel(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect, id: u32) { // A modal script dialog blocks the panels too. let modal = app.session.script_ui.windows.iter().any(|w| w.modal); let mut acts = vec![]; + // The script learns its dock's size when it changes (#2585), as After Effects tells a + // docked panel: its window takes the size and onResize runs. + let dock = [rect.width().round(), rect.height().round()]; + let told = egui::Id::unique(("scriptui-dock-size", id)); + if ui.data(|d| d.get_temp::<[f32; 2]>(told)) != Some(dock) && dock[0] >= 1.0 && dock[1] >= 1.0 { + ui.data_mut(|d| d.insert_temp(told, dock)); + acts.push(Act::Resize(id, dock)); + } let content = rect; egui::ScrollArea::both().id_salt(("scriptui-panel", id)).max_width(content.width()).max_height(content.height()).show(ui, |ui| { let (r, _) = ui.allocate_exact_size(vec2(w.root.bounds[2] as f32, w.root.bounds[3] as f32), Sense::hover()); diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index f4dd89bfc..562a90132 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -117,15 +117,20 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value ui.add_sized(vec2(label_w, 18.0), egui::Label::new(format!("{}:", tr(label))).truncate()); let r = match kind { Kind::Int(lo, hi, unit) => { - let mut x = v.as_i64().unwrap_or(0); - // A typed number applies once entered: typing 85 into UI Scale - // doesn't resize the interface to 75 % at its first digit. + // A dragged value applies on release, and a typed one once + // entered: UI Scale doesn't resize the interface under the + // pointer mid-drag (#2582) or at a typed number's first digit. + let held = egui::Id::unique(("settings-drag", key)); + let mut x = ui.data(|d| d.get_temp::(held)).unwrap_or_else(|| v.as_i64().unwrap_or(0)); let drag = egui::DragValue::new(&mut x).range(lo..=hi).speed(0.25).update_while_editing(false); let r = ui.add(crate::widgets::drag_value(drag)); if !unit.is_empty() { ui.label(RichText::new(tr(unit)).color(t.text_dim)); } - if r.changed() { + if r.dragged() { + ui.data_mut(|d| d.insert_temp(held, x)); + } else if r.changed() || r.drag_stopped() { + ui.data_mut(|d| d.remove::(held)); acts.push(Act::Set(key.into(), json!(x))); } r diff --git a/crates/ui-egui/tests/it/ui_panels.rs b/crates/ui-egui/tests/it/ui_panels.rs index db9ef7373..6d06c6bf7 100644 --- a/crates/ui-egui/tests/it/ui_panels.rs +++ b/crates/ui-egui/tests/it/ui_panels.rs @@ -664,3 +664,33 @@ fn transition_presets_apply_from_effects_and_presets() { assert_eq!(fx.name, "Radial Wipe"); assert_eq!(fx.prop("completion").unwrap().keys.len(), 2); } + +/// #2582: dragging Settings' UI Scale doesn't resize the interface under the pointer; the value +/// applies once the button is released. +#[test] +fn ui_scale_applies_when_the_drag_ends() { + let mut h = harness(); + open_settings(&mut h); + let e = h.state().auto.find("settings.appearance.uiScale").expect("the UI Scale field"); + let from = pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0); + h.input_mut().events.push(Event::PointerMoved(from)); + h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.step(); + for k in 1..=8 { + h.input_mut().events.push(Event::PointerMoved(from + egui::vec2(10.0 * k as f32, 0.0))); + h.step(); + assert_eq!(scale(&h), (100, 1.0), "nothing applies mid-drag"); + } + h.input_mut().events.push(Event::PointerButton { + pos: from + egui::vec2(80.0, 0.0), + button: egui::PointerButton::Primary, + pressed: false, + modifiers: Default::default(), + }); + h.run_steps(4); + let (pct, zoom) = scale(&h); + assert!(pct > 100 && (zoom - pct as f32 / 100.0).abs() < 1e-6, "applied on release: {pct} {zoom}"); + click(&mut h, "settings.cancel"); + h.run_steps(2); + assert_eq!(scale(&h), (100, 1.0)); +} diff --git a/crates/ui-egui/tests/it/ui_scriptui.rs b/crates/ui-egui/tests/it/ui_scriptui.rs index 09986f6d1..0a924bb65 100644 --- a/crates/ui-egui/tests/it/ui_scriptui.rs +++ b/crates/ui-egui/tests/it/ui_scriptui.rs @@ -341,6 +341,38 @@ fn a_drag_keeps_its_control_past_the_window_edge() { h.state_mut().session.execute("scriptui.close", json!({})).unwrap(); } +/// #2585: a window the host resizes takes the size, lays out again and runs onResize; a docked +/// ScriptUI panel gets its dock's size that way. +#[test] +fn script_windows_learn_the_size_they_are_given() { + let mut h = harness(); + let code = r#" + var w = new Window("palette", "Sized"); + var log = w.add("statictext", undefined, "", { name: "log" }); + log.preferredSize = [200, 20]; + w.onResize = function () { log.text = "resized " + w.size.width + "x" + w.size.height; }; + w.show(); + "#; + let r = h.state_mut().session.execute("script.run", json!({"code": code, "name": "sized.jsx"})).unwrap(); + assert_eq!(r["ok"], true, "{r}"); + h.run_steps(2); + h.state_mut().session.execute("scriptui.resize", json!({"window": "Sized", "size": [300, 180]})).unwrap(); + let win = h.state().session.script_ui.windows[0].clone(); + assert_eq!(win.root.lookup(&json!("log")).unwrap().text, "resized 300x180"); + let list = h.state_mut().session.execute("scriptui.list", json!({})).unwrap(); + assert_eq!(list[0]["size"], json!([300.0, 180.0]), "{list}"); + assert!(h.state_mut().session.execute("scriptui.resize", json!({"window": "Sized", "size": ["wide", 1]})).is_err()); + h.state_mut().session.execute("scriptui.close", json!({})).unwrap(); + // A docked panel: its window is its dock's size, not its preferred size. + h.state_mut().session.execute("window.scriptPanel", json!({"name": "Layer Tools.jsx"})).unwrap(); + h.run_steps(4); + let panel = h.state().session.script_ui.windows.iter().find(|w| w.script == "Layer Tools.jsx").unwrap().clone(); + let dock = rect(&h, &format!("scriptui.{}", panel.id)); + assert_eq!([panel.root.bounds[2].round(), panel.root.bounds[3].round()], [dock.width().round() as f64, dock.height().round() as f64]); + h.state_mut().close_panel(PanelKind::ScriptPanel(panel.id)); + h.run_steps(2); +} + fn click_at(h: &mut Harness<'_, EffectcraftApp>, c: egui::Pos2) { h.input_mut().events.push(Event::PointerMoved(c)); h.run_steps(1); diff --git a/docs/scripting-parity.md b/docs/scripting-parity.md index 8d2934518..96e298ece 100644 --- a/docs/scripting-parity.md +++ b/docs/scripting-parity.md @@ -20,7 +20,7 @@ our own scripts; no real-world script library has been run against both apps. | Expression performance | Cached, multi-frame | Per-thread boa contexts | unknown | No benchmark | | Scripting object model | `app`, `Project`, items, layers, properties, keys, eases, render queue, `TextDocument`, `Shape`, markers, `File`/`Folder`, `Socket`, `app.settings` | Same classes, edits run through undoable engine commands; match names table; layers, groups and properties are callable (`layer("Effects")(1)` is `.property(...)`) | good | `crates/script` tests (≈ 10 test files) | | `$.evalFile`, `BridgeTalk` | Evaluate another script file; identify the host application and message other Adobe applications | `$.evalFile` evaluates a file relative to the calling file (nested at most 32 deep, under the scripts' file gate) and returns its last value; `BridgeTalk` names the host honestly (`appName` `effectcraft`, its version); messaging other applications throws "not supported" | partial | `tests_evalfile.rs` (#760); no inter-application messaging | -| ScriptUI | Dialogs, palettes, dockable panels, resource strings, `onDraw` / ScriptUIGraphics, mouse events | Same, dockable panels reopen at launch | good | `ui_scriptui`, `tests_ui*` | +| ScriptUI | Dialogs, palettes, dockable panels, resource strings, `onDraw` / ScriptUIGraphics, mouse events | Same, dockable panels reopen at launch; a docked panel gets its dock's size and `onResize` (`scriptui.resize`, #2585); a drag keeps reaching the control it started on past the window edge, and `MouseEvent.buttons` reports the held buttons (#2584) | good | `ui_scriptui`, `tests_ui*` | | `app.scheduleTask` / `cancelTask` | One-shot and repeating tasks (polling, panel refresh) until `cancelTask` | Same: repeating tasks run every `delay` ms (at least 50 ms, at most 64 pending per script) until `cancelTask`, until they throw (reported once, then cancelled), or until their owner goes: a script's last window closing, or the project closing for scripts without windows (a Startup script's tasks go on until EffectCraft quits); frontends tick them every frame, agents with `script.runTasks` | good | `tests_tasks.rs` (#773); one-shot tasks of a script file still run right after it, not after their delay | | Scripts menu, ScriptUI Panels folder | yes | yes; a subfolder (one level) of either folder is a submenu named after it | same | [plugins.md](plugins.md) | | Startup / Shutdown folders | yes | `Scripts/Startup` runs at launch and `Scripts/Shutdown` runs as the desktop app quits, after the project is closed, each in alphabetical order | partial | `crates/script/src/tests/startup.rs`; each script has its own global scope, so a Startup script's variables and functions are not available to later scripts ([plugins.md](plugins.md)) |