Merge remote-tracking branch 'origin/main' into HEAD

This commit is contained in:
bflatastic
2026-10-30 08:59:21 +07:00
11 changed files with 237 additions and 7 deletions
+10 -1
View File
@@ -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 |
+9
View File
@@ -581,6 +581,15 @@ pub fn specs() -> Vec<CommandSpec> {
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",
+11
View File
@@ -769,6 +769,17 @@ pub(crate) fn mouse(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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<Value> {
let window = window_p(s, p, "scriptui.close")?;
+87 -1
View File
@@ -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);
+25
View File
@@ -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| {
+12
View File
@@ -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 () {
@@ -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>) {
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());
+9 -4
View File
@@ -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::<i64>(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::<i64>(held));
acts.push(Act::Set(key.into(), json!(x)));
}
r
+30
View File
@@ -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));
}
+32
View File
@@ -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);
+1 -1
View File
@@ -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)) |