From 499f9ce455fcf6b9d9a69ec99c076936f1c8b7b7 Mon Sep 17 00:00:00 2001 From: JonRC Date: Tue, 10 Nov 2026 02:24:29 -0300 Subject: [PATCH 01/15] #3395: setValue stores string effect parameters, such as Apply Color LUT's LUT --- crates/script/src/prelude.js | 2 ++ crates/script/src/tests.rs | 27 +++++++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/crates/script/src/prelude.js b/crates/script/src/prelude.js index b9108b2a1..360913289 100644 --- a/crates/script/src/prelude.js +++ b/crates/script/src/prelude.js @@ -867,6 +867,8 @@ Property.prototype.property = function () { return null; }; if (!idx) return null; return this.__layer.containingComp.layer(idx).__id; } + // A string parameter (a LUT or look file, a text field) takes the text, or a File's path (#3395). + if (info.type === "string") return __str(v); if (info.type === "enum") return __num(v) - 1; if (info.type === "bool") return typeof v === "boolean" ? v : __num(v) !== 0; if (info.type === "color") { diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs index 78a16e5d3..d04018e67 100644 --- a/crates/script/src/tests.rs +++ b/crates/script/src/tests.rs @@ -1949,3 +1949,30 @@ fn setting_comp_width_and_height_leaves_the_layers_where_they_are() { let (_, c) = comp_named(&s, "w"); assert_eq!(layer(c, "s").props.prop("transform/position").unwrap().value, KV::Vec3([100.0, 100.0, 0.0])); } + +/// #3395: setValue on a string effect parameter stores the text, or a File's path, as `prop.set` +/// does. Before, every string fell through to the number check ("value must be a number"). +/// Hidden string parameters (Mesh Warp's Distortion Mesh) stay refused. +#[test] +fn set_value_stores_a_string_parameter() { + let mut s = session(); + let o = ok( + &mut s, + r#" + var c = app.project.items.addComp("Str", 64, 36, 1, 1, 24); + var l = c.layers.addSolid([1, 0, 0], "s", 64, 36, 1); + var fx = l.property("ADBE Effect Parade"); + var lut = fx.addProperty("Apply Color LUT").property("LUT"); + lut.setValue("/luts/look.cube"); + var fromString = lut.value; + lut.setValue(new File("/luts/other.cube")); + var mesh = fx.addProperty("Mesh Warp").property("Distortion Mesh"); + var hidden; + try { mesh.setValue("x"); hidden = "no error"; } catch (e) { hidden = e.message; } + [lut.propertyValueType === PropertyValueType.CUSTOM_VALUE, fromString, lut.value, hidden] + "#, + ); + assert_eq!(o.result, json!([true, "/luts/look.cube", "/luts/other.cube", "\"Distortion Mesh\" can not be set"])); + let (_, c) = comp_named(&s, "Str"); + assert_eq!(layer(c, "s").props.prop("effects/#1/lut").unwrap().value, KV::Str("/luts/other.cube".into())); +} From 29db04ed62e57abc302e74701766229c79f5f09a Mon Sep 17 00:00:00 2001 From: JonRC Date: Tue, 10 Nov 2026 03:35:33 -0300 Subject: [PATCH 02/15] #3395: hidden string parameters stay refused by setValueAtTime and the other key setters --- crates/script/src/prelude.js | 6 +++++- crates/script/src/tests.rs | 12 +++++++----- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/crates/script/src/prelude.js b/crates/script/src/prelude.js index 360913289..f2a7a8230 100644 --- a/crates/script/src/prelude.js +++ b/crates/script/src/prelude.js @@ -868,7 +868,11 @@ Property.prototype.property = function () { return null; }; return this.__layer.containingComp.layer(idx).__id; } // A string parameter (a LUT or look file, a text field) takes the text, or a File's path (#3395). - if (info.type === "string") return __str(v); + // A hidden one (Mesh Warp's mesh) stays refused by the key setters too, as by setValue. + if (info.type === "string") { + if (info.hidden) throw __err("\"" + info.name + "\" can not be set"); + return __str(v); + } if (info.type === "enum") return __num(v) - 1; if (info.type === "bool") return typeof v === "boolean" ? v : __num(v) !== 0; if (info.type === "color") { diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs index d04018e67..d0884dbcb 100644 --- a/crates/script/src/tests.rs +++ b/crates/script/src/tests.rs @@ -1952,7 +1952,7 @@ fn setting_comp_width_and_height_leaves_the_layers_where_they_are() { /// #3395: setValue on a string effect parameter stores the text, or a File's path, as `prop.set` /// does. Before, every string fell through to the number check ("value must be a number"). -/// Hidden string parameters (Mesh Warp's Distortion Mesh) stay refused. +/// Hidden string parameters (Mesh Warp's Distortion Mesh) stay refused, keys included. #[test] fn set_value_stores_a_string_parameter() { let mut s = session(); @@ -1967,12 +1967,14 @@ fn set_value_stores_a_string_parameter() { var fromString = lut.value; lut.setValue(new File("/luts/other.cube")); var mesh = fx.addProperty("Mesh Warp").property("Distortion Mesh"); - var hidden; - try { mesh.setValue("x"); hidden = "no error"; } catch (e) { hidden = e.message; } - [lut.propertyValueType === PropertyValueType.CUSTOM_VALUE, fromString, lut.value, hidden] + function attempt(f) { try { f(); return "no error"; } catch (e) { return e.message; } } + var hidden = attempt(function () { mesh.setValue("x"); }); + var keyed = attempt(function () { mesh.setValueAtTime(0, "x"); }); + [lut.propertyValueType === PropertyValueType.CUSTOM_VALUE, fromString, lut.value, hidden, keyed, mesh.numKeys] "#, ); - assert_eq!(o.result, json!([true, "/luts/look.cube", "/luts/other.cube", "\"Distortion Mesh\" can not be set"])); + let refused = "\"Distortion Mesh\" can not be set"; + assert_eq!(o.result, json!([true, "/luts/look.cube", "/luts/other.cube", refused, refused, 0])); let (_, c) = comp_named(&s, "Str"); assert_eq!(layer(c, "s").props.prop("effects/#1/lut").unwrap().value, KV::Str("/luts/other.cube".into())); } From c96a9b1f6e2435f2b6446f8ee3c20ef0726fa9f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 14:36:12 +0000 Subject: [PATCH 03/15] Cylinder Wrap: Radius goes up to 30000 % (#3368) The Radius parameter was limited to 400 %, so a larger value was clamped and drew a smaller cylinder than After Effects' CC Cylinder, whose range the report gives as 0 to 30000 %. Raise the hard maximum; the slider's visible range is unchanged. --- crates/effects/src/perspective.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/effects/src/perspective.rs b/crates/effects/src/perspective.rs index 69c63caf9..2aa9fe69d 100644 --- a/crates/effects/src/perspective.rs +++ b/crates/effects/src/perspective.rs @@ -394,7 +394,7 @@ pub fn specs() -> Vec { }; let render = || popup(&["Full", "Outside", "Inside"]); let mut cyl = vec![ - p("radius", "Radius", num(100.0), slider(0.0, 400.0, 0.0, 200.0, 1)), + p("radius", "Radius", num(100.0), slider(0.0, 30000.0, 0.0, 200.0, 1)), p("position", "Position", pt(0.5, 0.5), ParamUi::Point), p("rotation", "Rotation", num(0.0), ParamUi::Angle), p("rotationX", "Rotation X", num(0.0), ParamUi::Angle), @@ -565,6 +565,19 @@ mod tests { assert_eq!(out.get(50 + 18, 10)[3], 0.0); } + /// #3368: Radius takes the range After Effects' CC Cylinder has, up to 30000 %, and a cylinder + /// that large still renders (nearly flat, fully opaque) instead of clamping to 400 %. + #[test] + fn cylinder_radius_goes_up_to_thirty_thousand_percent() { + let spec = find("ec.perspective.cccylinder").unwrap(); + let radius = spec.params.iter().find(|p| p.id == "radius").unwrap(); + assert!(matches!(radius.ui, ParamUi::Slider { min, max, .. } if min == 0.0 && max == 30000.0), "{:?}", radius.ui); + let img = Image::filled(100, 20, [0.5, 0.5, 0.5, 1.0]); + let out = run("ec.perspective.cccylinder", &img, &[("radius", num(30000.0))]).img; + let px = out.get(50 + 40, 10); + assert!(px.iter().all(|v| v.is_finite()) && px[3] > 0.99, "{px:?}"); + } + #[test] fn cylinder_axis_rotations_affect_geometry_and_texture() { let img = Image::filled(100, 100, [0.5, 0.5, 0.5, 1.0]); From 2226af65f2672586bc6db3313bbc03c62c883893 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 15:51:52 +0000 Subject: [PATCH 04/15] Cylinder Wrap test: make the 30000 % render check fail on a 400 % clamp (#3368) The pixel it read was inside the cylinder at 400 % too. Put the axis on the left edge and read a pixel past the 400 % radius, with a 400 % run showing it is empty. --- crates/effects/src/perspective.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/effects/src/perspective.rs b/crates/effects/src/perspective.rs index 2aa9fe69d..8f55bc408 100644 --- a/crates/effects/src/perspective.rs +++ b/crates/effects/src/perspective.rs @@ -572,9 +572,13 @@ mod tests { let spec = find("ec.perspective.cccylinder").unwrap(); let radius = spec.params.iter().find(|p| p.id == "radius").unwrap(); assert!(matches!(radius.ui, ParamUi::Slider { min, max, .. } if min == 0.0 && max == 30000.0), "{:?}", radius.ui); + // The cylinder's axis on the left edge: at 400 % its radius is 400 / 100 * 100 / 2π ≈ 63.7 px, + // so a pixel 70 px along is past it there and inside the 30000 % one. let img = Image::filled(100, 20, [0.5, 0.5, 0.5, 1.0]); - let out = run("ec.perspective.cccylinder", &img, &[("radius", num(30000.0))]).img; - let px = out.get(50 + 40, 10); + let at_edge = |radius: f64| run("ec.perspective.cccylinder", &img, &[("radius", num(radius)), ("position", Value::Vec2([0.0, 10.0]))]).img; + let clamped = at_edge(400.0).get(70, 10); + assert_eq!(clamped[3], 0.0, "beyond a 400 % cylinder: {clamped:?}"); + let px = at_edge(30000.0).get(70, 10); assert!(px.iter().all(|v| v.is_finite()) && px[3] > 0.99, "{px:?}"); } From 3694f0760097857f3410a7474600976ea22b490e Mon Sep 17 00:00:00 2001 From: lisn0 <33394991+lisn0@users.noreply.github.com> Date: Wed, 11 Nov 2026 00:37:34 +0000 Subject: [PATCH 05/15] Preserve frozen Object Mattes with pre-0.23 footage keys (#3430) --- crates/engine/src/analysis/roto.rs | 14 ++++++++-- crates/engine/src/analysis/warp.rs | 14 ++++++++++ crates/engine/src/tests/roto.rs | 41 ++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/analysis/roto.rs b/crates/engine/src/analysis/roto.rs index 3fe049e20..16cbb08cd 100644 --- a/crates/engine/src/analysis/roto.rs +++ b/crates/engine/src/analysis/roto.rs @@ -219,7 +219,15 @@ fn fnv(s: &str) -> u64 { /// What the effect's input frames are made of: the layer's source and timing, its masks and the /// effects above the Roto Brush. pub fn input_key(p: &Project, comp: ItemId, layer: &Layer, uid: Uid) -> String { - let mut s = crate::warp::signature(p, comp, layer); + input_key_with_signature(crate::warp::signature(p, comp, layer), layer, uid) +} + +pub(crate) fn input_key_before_placed_page(p: &Project, comp: ItemId, layer: &Layer, uid: Uid) -> Option { + let signature = crate::warp::signature_before_placed_page(p, comp, layer)?; + Some(input_key_with_signature(signature, layer, uid)) +} + +fn input_key_with_signature(mut s: String, layer: &Layer, uid: Uid) -> String { if let Some(m) = layer.masks() { s.push_str(&format!("{m:?}")); } @@ -247,7 +255,9 @@ pub(crate) fn sync(before: &Project, after: &mut Project) -> Vec<(ItemId, LayerI for g in fxg.groups().filter(|g| is_roto(g) || is_object_matte(g)) { let key = input_key(after, *cid, l, g.uid); let old = before.comp(*cid).and_then(|bc| bc.layer(l.id)).and_then(|ol| ol.props.find_group(g.uid)); - if str_of(g, INPUT_KEY) != key { + let saved_key = str_of(g, INPUT_KEY); + // Keep a matching old key: the frozen cache itself also hashes that key. + if saved_key != key && input_key_before_placed_page(after, *cid, l, g.uid).as_deref() != Some(saved_key.as_str()) { set.push((*cid, l.id, g.uid, key)); todo.push((*cid, l.id, g.uid)); } else if old.is_none_or(|o| guard_of(o) != guard_of(g)) { diff --git a/crates/engine/src/analysis/warp.rs b/crates/engine/src/analysis/warp.rs index 71ee45ee6..0ab576d2c 100644 --- a/crates/engine/src/analysis/warp.rs +++ b/crates/engine/src/analysis/warp.rs @@ -119,6 +119,20 @@ pub fn signature(p: &Project, comp: ItemId, layer: &Layer) -> String { }, _ => String::new(), }; + signature_with_source(p, comp, layer, &src) +} + +/// Before `Footage::placed_page` was added in 0.23, the persisted signature hashed the +/// same Debug representation without that last field. Only ordinary footage can match it. +pub(crate) fn signature_before_placed_page(p: &Project, comp: ItemId, layer: &Layer) -> Option { + let LayerSource::Footage { item } = &layer.source else { return None }; + let ItemKind::Footage(footage) = &p.item(*item)?.kind else { return None }; + let src = format!("{footage:?}"); + let old = src.strip_suffix(", placed_page: false }")?; + Some(signature_with_source(p, comp, layer, &format!("{old} }}"))) +} + +fn signature_with_source(p: &Project, comp: ItemId, layer: &Layer, src: &str) -> String { let rate = p.comp(comp).map(|c| format!("{:?}", c.frame_rate)).unwrap_or_default(); let s = format!( "{:?}|{src}|{:?}|{:?}|{:?}|{}|{:?}|{rate}", diff --git a/crates/engine/src/tests/roto.rs b/crates/engine/src/tests/roto.rs index 99c155265..8fc8fb479 100644 --- a/crates/engine/src/tests/roto.rs +++ b/crates/engine/src/tests/roto.rs @@ -603,6 +603,47 @@ fn object_matte_background_job_cancel_and_freeze() { assert_eq!(status(&mut s)["frozen"], true); } +/// A queue edit preserves a frozen matte made before the footage signature gained placed_page. +#[test] +fn render_queue_keeps_frozen_object_matte_with_legacy_footage_key() { + use effectcraft_effects::{object_matte, roto::{FROZEN, FrozenCache, INPUT_KEY}}; + use effectcraft_keyframe::Value::Str; + use std::sync::Arc; + + hold_roto_cache_test_lock(); + let (mut s, clip, _) = setup(|f| draw(center(f))); + let cid = s.active_comp_id().unwrap(); + let r = s.execute_checked("objectMatte.apply", json!({"layer": clip.0, "point": center(0), "before": 0, "after": 2})).unwrap(); + let uid = r["effect"].as_u64().unwrap(); + s.execute_checked("objectMatte.freeze", json!({"layer": clip.0, "wait": true})).unwrap(); + + let layer = s.project.comp(cid).unwrap().layer(clip).unwrap(); + let legacy = crate::roto::input_key_before_placed_page(&s.project, cid, layer, uid).unwrap(); + assert_ne!(legacy, crate::roto::input_key(&s.project, cid, layer, uid)); + let fps = s.project.comp(cid).unwrap().frame_rate.as_f64(); + // Recreate the two keys serialized by 0.22, leaving the real frozen alpha data alone. + let p = Arc::make_mut(&mut s.project); + let g = p.comp_mut(cid).unwrap().layer_mut(clip).unwrap().props.find_group_mut(uid).unwrap(); + g.get_mut(INPUT_KEY).unwrap().value = Str(legacy.clone()); + let params = crate::roto::params_static(g); + let Some(Str(value)) = params.get(FROZEN) else { panic!("missing frozen matte") }; + let mut frozen: FrozenCache = serde_json::from_str(value).unwrap(); + assert_eq!(frozen.frames.len(), 3); + frozen.key = object_matte::frozen_key(¶ms, [f64::from(W), f64::from(H)], fps, None); + let saved = Str(serde_json::to_string(&frozen).unwrap()); + g.get_mut(FROZEN).unwrap().value = saved.clone(); + + s.execute_checked("renderQueue.add", json!({"comp": cid.0, "format": "png", "output": "matte.png"})).unwrap(); + let params = |s: &Session| { + crate::roto::params_static(s.project.comp(cid).unwrap().layer(clip).unwrap().props.find_group(uid).unwrap()) + }; + assert_eq!(params(&s).get(INPUT_KEY), Some(&Str(legacy))); + assert_eq!(params(&s).get(FROZEN), Some(&saved)); + + s.execute_checked("layer.timing", json!({"layer": clip.0, "out": 0.5})).unwrap(); + assert_eq!(params(&s).get(FROZEN), Some(&Str(String::new()))); +} + /// Computed Object Matte segmentations go to the persistent disk cache: a second session on the /// same folder and project gets them back without segmenting (its frames cannot even be read /// here), and another prompt (other keys) finds nothing. From 1f04ea571aa90e6eccee6e63c8435c645e1f6ff2 Mon Sep 17 00:00:00 2001 From: lisn0 <33394991+lisn0@users.noreply.github.com> Date: Wed, 11 Nov 2026 00:39:12 +0000 Subject: [PATCH 06/15] Format frozen matte regression test (#3430) --- crates/engine/src/tests/roto.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/tests/roto.rs b/crates/engine/src/tests/roto.rs index 8fc8fb479..167c157ba 100644 --- a/crates/engine/src/tests/roto.rs +++ b/crates/engine/src/tests/roto.rs @@ -606,7 +606,10 @@ fn object_matte_background_job_cancel_and_freeze() { /// A queue edit preserves a frozen matte made before the footage signature gained placed_page. #[test] fn render_queue_keeps_frozen_object_matte_with_legacy_footage_key() { - use effectcraft_effects::{object_matte, roto::{FROZEN, FrozenCache, INPUT_KEY}}; + use effectcraft_effects::{ + object_matte, + roto::{FROZEN, FrozenCache, INPUT_KEY}, + }; use effectcraft_keyframe::Value::Str; use std::sync::Arc; @@ -634,9 +637,7 @@ fn render_queue_keeps_frozen_object_matte_with_legacy_footage_key() { g.get_mut(FROZEN).unwrap().value = saved.clone(); s.execute_checked("renderQueue.add", json!({"comp": cid.0, "format": "png", "output": "matte.png"})).unwrap(); - let params = |s: &Session| { - crate::roto::params_static(s.project.comp(cid).unwrap().layer(clip).unwrap().props.find_group(uid).unwrap()) - }; + let params = |s: &Session| crate::roto::params_static(s.project.comp(cid).unwrap().layer(clip).unwrap().props.find_group(uid).unwrap()); assert_eq!(params(&s).get(INPUT_KEY), Some(&Str(legacy))); assert_eq!(params(&s).get(FROZEN), Some(&saved)); From b8be50ea4b9ccae84d23614ed0d0ede30edc4190 Mon Sep 17 00:00:00 2001 From: lisn0 <33394991+lisn0@users.noreply.github.com> Date: Wed, 11 Nov 2026 01:25:00 +0000 Subject: [PATCH 07/15] Use a released 0.22 project in the frozen matte regression (#3430) --- ATTRIBUTION.md | 6 + crates/engine/src/analysis/roto.rs | 3 +- .../tests/fixtures/object-matte-0.22.ecproj | 947 ++++++++++++++++++ .../object-matte-0.22.ecproj.attribution | 8 + crates/engine/src/tests/roto.rs | 43 +- 5 files changed, 978 insertions(+), 29 deletions(-) create mode 100644 crates/engine/src/tests/fixtures/object-matte-0.22.ecproj create mode 100644 crates/engine/src/tests/fixtures/object-matte-0.22.ecproj.attribution diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 5bff5cdb0..b463e79ea 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -129,6 +129,12 @@ configuration from these Apache-2.0 projects: - `crates/segment/src/mobilesam.rs` follows the MobileSAM, TinyViT and Segment Anything architectures and their Apache-2.0 reference code. +## Frozen matte regression fixture + +| Asset | Author | Source | Licence | +|---|---|---|---| +| `crates/engine/src/tests/fixtures/object-matte-0.22.ecproj` | lisn0 | Original work, synthetic project saved by EffectCraft 0.22.0 | MIT OR Apache-2.0 | + ## First-party brand marks The ArtCraft name, wordmark and logos are trademarks of the ArtCraft Team. They are not open source and are not diff --git a/crates/engine/src/analysis/roto.rs b/crates/engine/src/analysis/roto.rs index 16cbb08cd..246c8899c 100644 --- a/crates/engine/src/analysis/roto.rs +++ b/crates/engine/src/analysis/roto.rs @@ -222,7 +222,8 @@ pub fn input_key(p: &Project, comp: ItemId, layer: &Layer, uid: Uid) -> String { input_key_with_signature(crate::warp::signature(p, comp, layer), layer, uid) } -pub(crate) fn input_key_before_placed_page(p: &Project, comp: ItemId, layer: &Layer, uid: Uid) -> Option { +/// Recognize the input key serialized before 0.23 without re-keying its frozen matte. +fn input_key_before_placed_page(p: &Project, comp: ItemId, layer: &Layer, uid: Uid) -> Option { let signature = crate::warp::signature_before_placed_page(p, comp, layer)?; Some(input_key_with_signature(signature, layer, uid)) } diff --git a/crates/engine/src/tests/fixtures/object-matte-0.22.ecproj b/crates/engine/src/tests/fixtures/object-matte-0.22.ecproj new file mode 100644 index 000000000..9a85aaa65 --- /dev/null +++ b/crates/engine/src/tests/fixtures/object-matte-0.22.ecproj @@ -0,0 +1,947 @@ +{ + "savedBy": "0.22.0", + "activeComp": 1, + "openComps": [ + 1 + ], + "schema": 2, + "settings": { + "bit_depth": "Bpc8", + "linearize": false, + "working_space": null, + "blend_linear": false, + "time_display": "Timecode", + "frame_start": 0, + "audio_sample_rate": 48000, + "gpu_acceleration": true, + "color_engine": "Adobe", + "hdr": "Clip", + "output_space": null, + "working_gamma": 2.2 + }, + "items": { + "1": { + "id": 1, + "name": "Main", + "label": "Sandstone", + "comment": "", + "parent": null, + "kind": { + "type": "Comp", + "width": 64, + "height": 48, + "pixel_aspect": 1.0, + "frame_rate": { + "num": 30, + "den": 1 + }, + "duration": 25401600000, + "display_start": 0, + "background": [ + 0.0, + 0.0, + 0.0 + ], + "work_area": [ + 0, + 25401600000 + ], + "layers": [ + { + "id": 3, + "name": "effectcraft-3430-subject.mp4", + "source": { + "type": "Footage", + "item": 2 + }, + "label": "Aqua", + "comment": "", + "start_time": 0, + "in_point": 0, + "out_point": 25401600000, + "stretch": 100.0, + "switches": { + "video": true, + "audio": true, + "solo": false, + "locked": false, + "shy": false, + "collapse": false, + "quality": "Best", + "sampling": "Bilinear", + "effects": true, + "frame_blend": "Off", + "motion_blur": false, + "adjustment": false, + "three_d": false, + "guide": false + }, + "blend_mode": "Normal", + "preserve_transparency": false, + "track_matte": null, + "parent": null, + "markers": [], + "auto_orient": "Off", + "props": { + "uid": 4, + "match": "layer", + "name": "effectcraft-3430-subject.mp4", + "kind": { + "kind": "Plain" + }, + "enabled": true, + "children": [ + { + "node": "Group", + "uid": 5, + "match": "masks", + "name": "Masks", + "kind": { + "kind": "Plain" + }, + "enabled": true, + "children": [] + }, + { + "node": "Group", + "uid": 6, + "match": "effects", + "name": "Effects", + "kind": { + "kind": "Plain" + }, + "enabled": true, + "children": [ + { + "node": "Group", + "uid": 26, + "match": "ec.matte.objectmatte", + "name": "Object Matte", + "kind": { + "kind": "Effect", + "effect": "ec.matte.objectmatte" + }, + "enabled": true, + "children": [ + { + "node": "Prop", + "uid": 27, + "match": "objectPoint", + "name": "Object Point", + "value": { + "t": "Vec2", + "v": [ + 32.0, + 24.0 + ] + }, + "ui": { + "kind": "Point" + }, + "spatial": true, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 28, + "match": "useBox", + "name": "Use Box", + "value": { + "t": "Bool", + "v": true + }, + "ui": { + "kind": "Checkbox" + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 29, + "match": "boxTopLeft", + "name": "Box Top Left", + "value": { + "t": "Vec2", + "v": [ + 14.0, + 8.0 + ] + }, + "ui": { + "kind": "Point" + }, + "spatial": true, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 30, + "match": "boxBottomRight", + "name": "Box Bottom Right", + "value": { + "t": "Vec2", + "v": [ + 50.0, + 40.0 + ] + }, + "ui": { + "kind": "Point" + }, + "spatial": true, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Group", + "uid": 32, + "match": "objectTracking", + "name": "Tracking", + "kind": { + "kind": "Plain" + }, + "enabled": true, + "children": [ + { + "node": "Prop", + "uid": 31, + "match": "trackObject", + "name": "Track Object", + "value": { + "t": "Bool", + "v": true + }, + "ui": { + "kind": "Checkbox" + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 33, + "match": "referenceFrame", + "name": "Reference Frame", + "value": { + "t": "Scalar", + "v": 0.0 + }, + "ui": { + "kind": "Number" + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 34, + "match": "framesBefore", + "name": "Frames Before", + "value": { + "t": "Scalar", + "v": 0.0 + }, + "ui": { + "kind": "Slider", + "min": 0.0, + "max": 600.0, + "slider_min": 0.0, + "slider_max": 120.0, + "decimals": 0 + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 35, + "match": "framesAfter", + "name": "Frames After", + "value": { + "t": "Scalar", + "v": 0.0 + }, + "ui": { + "kind": "Slider", + "min": 0.0, + "max": 600.0, + "slider_min": 0.0, + "slider_max": 120.0, + "decimals": 0 + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + } + ] + }, + { + "node": "Group", + "uid": 37, + "match": "objectMatte", + "name": "Matte", + "kind": { + "kind": "Plain" + }, + "enabled": true, + "children": [ + { + "node": "Prop", + "uid": 36, + "match": "feather", + "name": "Feather", + "value": { + "t": "Scalar", + "v": 0.0 + }, + "ui": { + "kind": "Slider", + "min": 0.0, + "max": 100.0, + "slider_min": 0.0, + "slider_max": 20.0, + "decimals": 1 + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 38, + "match": "contrast", + "name": "Contrast", + "value": { + "t": "Scalar", + "v": 0.0 + }, + "ui": { + "kind": "Slider", + "min": 0.0, + "max": 100.0, + "slider_min": 0.0, + "slider_max": 100.0, + "decimals": 0 + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 39, + "match": "shiftEdge", + "name": "Shift Edge", + "value": { + "t": "Scalar", + "v": 0.0 + }, + "ui": { + "kind": "Slider", + "min": -100.0, + "max": 100.0, + "slider_min": -100.0, + "slider_max": 100.0, + "decimals": 0 + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + } + ] + }, + { + "node": "Prop", + "uid": 40, + "match": "invert", + "name": "Invert", + "value": { + "t": "Bool", + "v": false + }, + "ui": { + "kind": "Checkbox" + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 41, + "match": "view", + "name": "View", + "value": { + "t": "Enum", + "v": 0 + }, + "ui": { + "kind": "Popup", + "options": [ + "Final Output", + "Matte" + ] + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 42, + "match": "inputKey", + "name": "Input Key", + "value": { + "t": "Str", + "v": "cbefe0c85977c4aa" + }, + "ui": { + "kind": "Hidden" + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 43, + "match": "frozen", + "name": "Frozen", + "value": { + "t": "Str", + "v": "{\"key\":\"1a097b69366afd7f\",\"size\":[64,48],\"frames\":{\"0\":{\"a\":\"gBgAkAX/IAAg/yAAIP8gACD/IAAg/yAAIP8gACD/IAAg/yAAIP8gACD/IAAg/yAAIP8gACD/IAAg/yAAIP8gACD/IAAg/yAAIP8gACD/IAAg/yAAIP8gACD/IAAg/yAAIP8gACD/IAAg/yAAIP8gACD/IACQBQ==\",\"b\":\"AA==\"}}}" + }, + "ui": { + "kind": "Hidden" + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + } + ] + } + ] + }, + { + "node": "Group", + "uid": 15, + "match": "transform", + "name": "Transform", + "kind": { + "kind": "Plain" + }, + "enabled": true, + "children": [ + { + "node": "Prop", + "uid": 7, + "match": "anchor", + "name": "Anchor Point", + "value": { + "t": "Vec3", + "v": [ + 32.0, + 24.0, + 0.0 + ] + }, + "ui": { + "kind": "Point" + }, + "spatial": true, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 2, + "static_only": false + }, + { + "node": "Prop", + "uid": 8, + "match": "position", + "name": "Position", + "value": { + "t": "Vec3", + "v": [ + 32.0, + 24.0, + 0.0 + ] + }, + "ui": { + "kind": "Point" + }, + "spatial": true, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 2, + "static_only": false + }, + { + "node": "Prop", + "uid": 9, + "match": "scale", + "name": "Scale", + "value": { + "t": "Vec3", + "v": [ + 100.0, + 100.0, + 100.0 + ] + }, + "ui": { + "kind": "Percent" + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 2, + "static_only": false + }, + { + "node": "Prop", + "uid": 10, + "match": "orientation", + "name": "Orientation", + "value": { + "t": "Vec3", + "v": [ + 0.0, + 0.0, + 0.0 + ] + }, + "ui": { + "kind": "Angle" + }, + "spatial": false, + "hold_only": false, + "three_d_only": true, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 11, + "match": "rotationX", + "name": "X Rotation", + "value": { + "t": "Scalar", + "v": 0.0 + }, + "ui": { + "kind": "Angle" + }, + "spatial": false, + "hold_only": false, + "three_d_only": true, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 12, + "match": "rotationY", + "name": "Y Rotation", + "value": { + "t": "Scalar", + "v": 0.0 + }, + "ui": { + "kind": "Angle" + }, + "spatial": false, + "hold_only": false, + "three_d_only": true, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 13, + "match": "rotation", + "name": "Rotation", + "value": { + "t": "Scalar", + "v": 0.0 + }, + "ui": { + "kind": "Angle" + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 14, + "match": "opacity", + "name": "Opacity", + "value": { + "t": "Scalar", + "v": 100.0 + }, + "ui": { + "kind": "Slider", + "min": 0.0, + "max": 100.0, + "slider_min": 0.0, + "slider_max": 100.0, + "decimals": 0 + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + } + ] + }, + { + "node": "Group", + "uid": 16, + "match": "materialOptions", + "name": "Material Options", + "kind": { + "kind": "Plain" + }, + "enabled": true, + "children": [ + { + "node": "Prop", + "uid": 17, + "match": "castsShadows", + "name": "Casts Shadows", + "value": { + "t": "Enum", + "v": 0 + }, + "ui": { + "kind": "Popup", + "options": [ + "Off", + "On", + "Only" + ] + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 18, + "match": "lightTransmission", + "name": "Light Transmission", + "value": { + "t": "Scalar", + "v": 0.0 + }, + "ui": { + "kind": "Percent" + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 19, + "match": "acceptsShadows", + "name": "Accepts Shadows", + "value": { + "t": "Enum", + "v": 1 + }, + "ui": { + "kind": "Popup", + "options": [ + "Off", + "On", + "Only" + ] + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 20, + "match": "acceptsLights", + "name": "Accepts Lights", + "value": { + "t": "Bool", + "v": true + }, + "ui": { + "kind": "Checkbox" + }, + "spatial": false, + "hold_only": true, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 21, + "match": "ambient", + "name": "Ambient", + "value": { + "t": "Scalar", + "v": 100.0 + }, + "ui": { + "kind": "Percent" + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 22, + "match": "diffuse", + "name": "Diffuse", + "value": { + "t": "Scalar", + "v": 50.0 + }, + "ui": { + "kind": "Percent" + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 23, + "match": "specularIntensity", + "name": "Specular Intensity", + "value": { + "t": "Scalar", + "v": 50.0 + }, + "ui": { + "kind": "Percent" + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 24, + "match": "specularShininess", + "name": "Specular Shininess", + "value": { + "t": "Scalar", + "v": 5.0 + }, + "ui": { + "kind": "Percent" + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + { + "node": "Prop", + "uid": 25, + "match": "metal", + "name": "Metal", + "value": { + "t": "Scalar", + "v": 100.0 + }, + "ui": { + "kind": "Percent" + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + } + ] + } + ] + } + } + ], + "markers": [], + "shutter_angle": 180.0, + "shutter_phase": -90.0, + "motion_blur_samples": 16, + "motion_blur_adaptive_limit": 128, + "renderer": "Classic3D", + "hide_shy": false, + "enable_motion_blur": true, + "enable_frame_blending": true, + "draft_3d": false, + "preserve_frame_rate": false, + "preserve_resolution": false, + "poster_time": 0, + "global_light": { + "angle": { + "uid": 0, + "match": "globalLightAngle", + "name": "Global Light Angle", + "value": { + "t": "Scalar", + "v": 120.0 + }, + "ui": { + "kind": "Angle" + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + }, + "altitude": { + "uid": 0, + "match": "globalLightAltitude", + "name": "Global Light Altitude", + "value": { + "t": "Scalar", + "v": 30.0 + }, + "ui": { + "kind": "Angle" + }, + "spatial": false, + "hold_only": false, + "three_d_only": false, + "two_d_only": false, + "shown_dims": 0, + "static_only": false + } + } + } + }, + "2": { + "id": 2, + "name": "effectcraft-3430-subject.mp4", + "label": "Aqua", + "comment": "", + "parent": null, + "kind": { + "type": "Footage", + "path": "/tmp/effectcraft-3430-subject.mp4", + "kind": "Video", + "width": 64, + "height": 48, + "pixel_aspect": 1.0, + "frame_rate": { + "num": 30, + "den": 1 + }, + "native_rate": null, + "duration": 25401600000, + "has_video": true, + "has_audio": false, + "alpha": "Ignore", + "premul_color": [ + 0.0, + 0.0, + 0.0 + ], + "loop_count": 1, + "codec": "H.264 (High)", + "missing": false, + "color_profile": "Rec709" + } + } + }, + "next_id": 44 +} \ No newline at end of file diff --git a/crates/engine/src/tests/fixtures/object-matte-0.22.ecproj.attribution b/crates/engine/src/tests/fixtures/object-matte-0.22.ecproj.attribution new file mode 100644 index 000000000..c20c8b02c --- /dev/null +++ b/crates/engine/src/tests/fixtures/object-matte-0.22.ecproj.attribution @@ -0,0 +1,8 @@ +asset: object-matte-0.22.ecproj +title: One frozen Object Matte from EffectCraft 0.22.0 +author: lisn0 +source: original work +license: MIT OR Apache-2.0 +license-file: ../../../../../LICENSE-MIT +added: 2026-11-11 by lisn0 +notes: Saved by the released 0.22.0 CLI using a generated 64x48 orange rectangle on a blue background. Contains one frozen alpha plane. The source path is a generic test path, and the source video is unnecessary for the state-preservation regression test. diff --git a/crates/engine/src/tests/roto.rs b/crates/engine/src/tests/roto.rs index 167c157ba..e751681c6 100644 --- a/crates/engine/src/tests/roto.rs +++ b/crates/engine/src/tests/roto.rs @@ -606,42 +606,29 @@ fn object_matte_background_job_cancel_and_freeze() { /// A queue edit preserves a frozen matte made before the footage signature gained placed_page. #[test] fn render_queue_keeps_frozen_object_matte_with_legacy_footage_key() { - use effectcraft_effects::{ - object_matte, - roto::{FROZEN, FrozenCache, INPUT_KEY}, - }; + use effectcraft_effects::roto::{FROZEN, INPUT_KEY}; use effectcraft_keyframe::Value::Str; - use std::sync::Arc; hold_roto_cache_test_lock(); - let (mut s, clip, _) = setup(|f| draw(center(f))); - let cid = s.active_comp_id().unwrap(); - let r = s.execute_checked("objectMatte.apply", json!({"layer": clip.0, "point": center(0), "before": 0, "after": 2})).unwrap(); - let uid = r["effect"].as_u64().unwrap(); - s.execute_checked("objectMatte.freeze", json!({"layer": clip.0, "wait": true})).unwrap(); - + // Saved by the released 0.22.0 CLI, independently of the compatibility helper. + let project = effectcraft_project::Project::from_json(include_str!("fixtures/object-matte-0.22.ecproj")).unwrap(); + let mut s = Session::default(); + s.replace_project(project, None); + let cid = effectcraft_project::ItemId(1); + let clip = LayerId(3); + let uid = 26; + s.open_comp(cid); + let params = |s: &Session| crate::roto::params_static(s.project.comp(cid).unwrap().layer(clip).unwrap().props.find_group(uid).unwrap()); + let legacy = params(&s).get(INPUT_KEY).unwrap().clone(); + let saved = params(&s).get(FROZEN).unwrap().clone(); let layer = s.project.comp(cid).unwrap().layer(clip).unwrap(); - let legacy = crate::roto::input_key_before_placed_page(&s.project, cid, layer, uid).unwrap(); - assert_ne!(legacy, crate::roto::input_key(&s.project, cid, layer, uid)); - let fps = s.project.comp(cid).unwrap().frame_rate.as_f64(); - // Recreate the two keys serialized by 0.22, leaving the real frozen alpha data alone. - let p = Arc::make_mut(&mut s.project); - let g = p.comp_mut(cid).unwrap().layer_mut(clip).unwrap().props.find_group_mut(uid).unwrap(); - g.get_mut(INPUT_KEY).unwrap().value = Str(legacy.clone()); - let params = crate::roto::params_static(g); - let Some(Str(value)) = params.get(FROZEN) else { panic!("missing frozen matte") }; - let mut frozen: FrozenCache = serde_json::from_str(value).unwrap(); - assert_eq!(frozen.frames.len(), 3); - frozen.key = object_matte::frozen_key(¶ms, [f64::from(W), f64::from(H)], fps, None); - let saved = Str(serde_json::to_string(&frozen).unwrap()); - g.get_mut(FROZEN).unwrap().value = saved.clone(); + assert_ne!(legacy, Str(crate::roto::input_key(&s.project, cid, layer, uid))); s.execute_checked("renderQueue.add", json!({"comp": cid.0, "format": "png", "output": "matte.png"})).unwrap(); - let params = |s: &Session| crate::roto::params_static(s.project.comp(cid).unwrap().layer(clip).unwrap().props.find_group(uid).unwrap()); - assert_eq!(params(&s).get(INPUT_KEY), Some(&Str(legacy))); + assert_eq!(params(&s).get(INPUT_KEY), Some(&legacy)); assert_eq!(params(&s).get(FROZEN), Some(&saved)); - s.execute_checked("layer.timing", json!({"layer": clip.0, "out": 0.5})).unwrap(); + s.execute_checked("layer.timing", json!({"layer": clip.0, "out": 0.05})).unwrap(); assert_eq!(params(&s).get(FROZEN), Some(&Str(String::new()))); } From 6b7731d05a9a1161bef809f56b164887c46dcc4c Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Wed, 11 Nov 2026 16:15:54 +0530 Subject: [PATCH 08/15] #3365: the losses prompt shows every detail line and how many have none MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prompt showed 3 detail lines per category under a bare count, which read as the project's total. The headline now says how many occurrences are left out or changed, every line the report keeps shows, and "… and N more" counts the rest. The import report records that number as `unlisted` (an unmapped effect's line covers all its instances); unmapped effects are listed most used first. Co-Authored-By: Claude Opus 5.5 --- crates/aep/src/import/report.rs | 45 ++++++++++++++++++++++--- crates/ui-egui/src/i18n/ui.rs | 22 ++++++++++++ crates/ui-egui/src/panels/aep_losses.rs | 39 ++++++++++++++++----- docs/aep-import.md | 10 ++++-- 4 files changed, 99 insertions(+), 17 deletions(-) diff --git a/crates/aep/src/import/report.rs b/crates/aep/src/import/report.rs index 2825b61b3..aa50f4dff 100644 --- a/crates/aep/src/import/report.rs +++ b/crates/aep/src/import/report.rs @@ -98,8 +98,12 @@ pub struct Loss { pub category: LossCategory, /// How many occurrences (exact). pub count: usize, - /// Up to [`MAX_DETAILS`] example lines. + /// Example lines: up to [`MAX_DETAILS`], and for effects with no counterpart one more line per + /// effect (with its number of instances). pub details: Vec, + /// How many of the `count` occurrences no line of `details` describes. + #[serde(default)] + pub unlisted: usize, } /// What arrived in the project. @@ -281,7 +285,7 @@ const MAX_UNMAPPED_EFFECTS: usize = 100; impl Collector { /// Records one loss. pub fn lose(&mut self, category: LossCategory, detail: impl Into) { - let loss = self.losses.entry(category).or_insert_with(|| Loss { category, count: 0, details: Vec::new() }); + let loss = self.losses.entry(category).or_insert_with(|| Loss { category, count: 0, details: Vec::new(), unlisted: 0 }); loss.count = loss.count.saturating_add(1); if loss.details.len() < MAX_DETAILS { loss.details.push(detail.into()); @@ -292,7 +296,8 @@ impl Collector { /// made in [`Collector::finish`], one per effect with the number of instances, so that many /// other losses cannot hide which effects were dropped. pub fn lose_unmapped_effect(&mut self, match_name: &str, detail: String) { - let loss = self.losses.entry(LossCategory::Effects).or_insert_with(|| Loss { category: LossCategory::Effects, count: 0, details: Vec::new() }); + let loss = + self.losses.entry(LossCategory::Effects).or_insert_with(|| Loss { category: LossCategory::Effects, count: 0, details: Vec::new(), unlisted: 0 }); loss.count = loss.count.saturating_add(1); if let Some(seen) = self.unmapped_effects.get_mut(match_name) { seen.1 = seen.1.saturating_add(1); @@ -303,12 +308,22 @@ impl Collector { } pub fn finish(mut self) -> ImportReport { + // Every line describes one occurrence, except an unmapped effect's, which describes all + // its instances. + for loss in self.losses.values_mut() { + loss.unlisted = loss.count.saturating_sub(loss.details.len()); + } if let Some(loss) = self.losses.get_mut(&LossCategory::Effects) { - let named = std::mem::take(&mut self.unmapped_effects).into_values().map(|(first, n)| match n { + // The most used effects first. + let mut named: Vec<(String, usize)> = std::mem::take(&mut self.unmapped_effects).into_values().collect(); + named.sort_by(|a, b| b.1.cmp(&a.1)); + let described = named.iter().fold(0usize, |sum, (_, n)| sum.saturating_add(*n)); + loss.unlisted = loss.unlisted.saturating_sub(described); + let lines = named.into_iter().map(|(first, n)| match n { 1 => first, _ => format!("{first}, and {} more like it", n - 1), }); - loss.details.splice(0..0, named); + loss.details.splice(0..0, lines); } ImportReport { imported: self.imported, @@ -355,6 +370,25 @@ mod tests { // One instance stays the single line it was. assert_eq!(named("(Pseudo/)"), ["C: 'D' has the effect 'Dropdown' (Pseudo/)"]); assert!(effects.details.len() <= MAX_DETAILS + 2, "the examples stay bounded"); + // 20 parameter lines, the cylinders' line and the dropdown's describe 46; 10 parameters + // have no line. + assert_eq!(effects.unlisted, 10); + // The most used effect comes first. + assert!(effects.details[0].contains("(CC Cylinder)"), "{:?}", effects.details); + } + + /// #3365: each category says how many of its occurrences have no example line. + #[test] + fn a_loss_counts_the_occurrences_without_a_line() { + let mut col = Collector::default(); + for i in 0..27 { + col.lose(LossCategory::Masks, format!("mask {i}")); + } + col.lose(LossCategory::ShapeLayers, "one"); + let report = col.finish(); + let unlisted = |c| report.losses.iter().find(|l| l.category == c).map(|l| (l.details.len(), l.unlisted)); + assert_eq!(unlisted(LossCategory::Masks), Some((MAX_DETAILS, 7))); + assert_eq!(unlisted(LossCategory::ShapeLayers), Some((1, 0))); } /// A file with more than [`MAX_UNMAPPED_EFFECTS`] different unmapped effects names that many @@ -369,5 +403,6 @@ mod tests { let effects = report.losses.iter().find(|l| l.category == LossCategory::Effects).unwrap(); assert_eq!(effects.count, MAX_UNMAPPED_EFFECTS + 1, "every instance is counted"); assert_eq!(effects.details.len(), MAX_UNMAPPED_EFFECTS, "{} lines", effects.details.len()); + assert_eq!(effects.unlisted, 1, "the effect past the limit has no line"); } } diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index 1a162dab3..b2e0b3631 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -245,6 +245,8 @@ const JAPANESE: &[(&str, &str)] = &[ ("Import Adobe After Effects Project", "Adobe After Effectsプロジェクトを読み込み"), ("Some parts of this project cannot be imported exactly (left out or changed):", "このプロジェクトの一部は正確に読み込めません(省略または変更されます):"), ("Import Anyway", "このまま読み込み"), + ("{}: {} left out or changed", "{}:{} 件が省略または変更"), + ("… and {} more", "…ほか {} 件"), ("Casts Shadows", "シャドウを落とす"), ("Change Renderer…", "レンダラーを変更…"), ( @@ -1386,6 +1388,8 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Import Adobe After Effects Project", "导入 Adobe After Effects 项目"), ("Some parts of this project cannot be imported exactly (left out or changed):", "此项目的某些部分无法准确导入(将被省略或更改):"), ("Import Anyway", "仍然导入"), + ("{}: {} left out or changed", "{}:{} 项被省略或更改"), + ("… and {} more", "…以及另外 {} 项"), ("Casts Shadows", "投射阴影"), ("Change Renderer…", "更改渲染器…"), ("Changing a shortcut creates a custom preset; the default stays as it is.", "修改快捷键会创建自定义预设;默认预设保持不变。"), @@ -2505,6 +2509,8 @@ const SWEDISH: &[(&str, &str)] = &[ "Vissa delar av projektet kan inte importeras exakt (utelämnas eller ändras):", ), ("Import Anyway", "Importera ändå"), + ("{}: {} left out or changed", "{}: {} utelämnas eller ändras"), + ("… and {} more", "… och {} till"), ("Casts Shadows", "Kastar skuggor"), ("Change Renderer…", "Byt renderare…"), ( @@ -3778,6 +3784,8 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Import Adobe After Effects Project", "匯入 Adobe After Effects 專案"), ("Some parts of this project cannot be imported exactly (left out or changed):", "此專案的部分內容無法準確匯入(將被省略或變更):"), ("Import Anyway", "仍要匯入"), + ("{}: {} left out or changed", "{}:{} 項被省略或變更"), + ("… and {} more", "…以及另外 {} 項"), ("Casts Shadows", "投射陰影"), ("Change Renderer…", "更改算圖引擎…"), ("Changing a shortcut creates a custom preset; the default stays as it is.", "修改快速鍵會創建自定義預設集;預設預設集保持不變。"), @@ -4847,6 +4855,8 @@ const UKRAINIAN: &[(&str, &str)] = &[ "Деякі частини цього проєкту неможливо імпортувати точно (їх пропущено або змінено):", ), ("Import Anyway", "Усе одно імпортувати"), + ("{}: {} left out or changed", "{}: {} пропущено або змінено"), + ("… and {} more", "… і ще {}"), ("Casts Shadows", "Відкидає тіні"), ("Change Renderer…", "Змінити рушій рендерингу…"), ( @@ -5428,6 +5438,8 @@ const PORTUGUESE: &[(&str, &str)] = &[ "Algumas partes deste projeto não podem ser importadas exatamente (omitidas ou alteradas):", ), ("Import Anyway", "Importar mesmo assim"), + ("{}: {} left out or changed", "{}: {} omitidos ou alterados"), + ("… and {} more", "… e mais {}"), ("Casts Shadows", "Projeta sombras"), ("Change Renderer…", "Alterar renderizador…"), ( @@ -6201,6 +6213,8 @@ const POLISH: &[(&str, &str)] = &[ "Niektórych części tego projektu nie można zaimportować dokładnie (zostaną pominięte lub zmienione):", ), ("Import Anyway", "Importuj mimo to"), + ("{}: {} left out or changed", "{}: {} pominięto lub zmieniono"), + ("… and {} more", "… i {} więcej"), ("Casts Shadows", "Rzuca cienie"), ("Change Renderer…", "Zmień renderer…"), ( @@ -6957,6 +6971,8 @@ const CZECH: &[(&str, &str)] = &[ "Některé části tohoto projektu nelze importovat přesně (budou vynechány nebo změněny):", ), ("Import Anyway", "Přesto importovat"), + ("{}: {} left out or changed", "{}: {} vynecháno nebo změněno"), + ("… and {} more", "… a {} dalších"), ("Casts Shadows", "Vrhat stíny"), ("Change Renderer…", "Změnit vykreslovací modul…"), ( @@ -7532,6 +7548,8 @@ const SPANISH: &[(&str, &str)] = &[ "Algunas partes de este proyecto no se pueden importar con exactitud (se omiten o cambian):", ), ("Import Anyway", "Importar de todos modos"), + ("{}: {} left out or changed", "{}: {} omitidos o cambiados"), + ("… and {} more", "… y {} más"), ("Casts Shadows", "Proyecta sombras"), ("Change Renderer…", "Cambiar motor de procesamiento…"), ( @@ -8098,6 +8116,8 @@ const GEORGIAN: &[(&str, &str)] = &[ "ამ პროექტის ზოგიერთი ნაწილის ზუსტად იმპორტი შეუძლებელია (გამოტოვებული ან შეცვლილი):", ), ("Import Anyway", "მაინც იმპორტი"), + ("{}: {} left out or changed", "{}: {} გამოტოვებული ან შეცვლილი"), + ("… and {} more", "… და კიდევ {}"), ("Casts Shadows", "ჩრდილს აგდებს"), ("Change Renderer…", "რენდერერის შეცვლა…"), ("Changing a shortcut creates a custom preset; the default stays as it is.", "მალსახმობის შეცვლა ქმნის მორგებულ პრესეტს; ნაგულისხმევი უცვლელი რჩება."), @@ -9070,6 +9090,8 @@ const FRENCH: &[(&str, &str)] = &[ "Certaines parties de ce projet ne peuvent pas être importées à l’identique (omises ou modifiées) :", ), ("Import Anyway", "Importer quand même"), + ("{}: {} left out or changed", "{} : {} omis ou modifiés"), + ("… and {} more", "… et {} de plus"), ("Casts Shadows", "Projette des ombres"), ("Change Renderer…", "Changer de moteur de rendu…"), ( diff --git a/crates/ui-egui/src/panels/aep_losses.rs b/crates/ui-egui/src/panels/aep_losses.rs index 4eb71104d..4674c369a 100644 --- a/crates/ui-egui/src/panels/aep_losses.rs +++ b/crates/ui-egui/src/panels/aep_losses.rs @@ -8,7 +8,7 @@ //! //! Automation ids: `dialog.aepLosses.continue`, `dialog.aepLosses.cancel`. -use crate::i18n::tr; +use crate::i18n::{tr, tr_args}; use egui::vec2; use serde_json::{Value, json}; @@ -23,9 +23,6 @@ pub struct Pending { pub queue: Vec, } -/// How many detail lines of a loss category the prompt shows. -const DETAILS: usize = 3; - /// Whether the prompt is showing with a conversion waiting on it. pub fn asking(app: &EffectcraftApp) -> bool { app.dialog == Some(Dialog::AepLosses) && app.dialog_state.aep_losses.command.is_some() @@ -77,14 +74,24 @@ fn category(c: &str) -> String { chars.next().map(|f| f.to_uppercase().chain(chars).collect()).unwrap_or_default() } -/// The report's losses as lines: one per category with its count, and a few of its details. +/// The report's losses as lines: one per category with how many occurrences are left out or +/// changed, then every detail line the report keeps and "… and N more" for the occurrences none +/// describes (#3365). pub fn lines(report: &Value) -> Vec<(String, Vec)> { let Some(losses) = report["losses"].as_array() else { return vec![] }; losses .iter() .map(|l| { - let head = format!("{} ({})", category(l["category"].as_str().unwrap_or("other")), l["count"].as_u64().unwrap_or(0)); - let details = l["details"].as_array().map(|d| d.iter().filter_map(Value::as_str).take(DETAILS).map(str::to_string).collect()).unwrap_or_default(); + let count = l["count"].as_u64().unwrap_or(0); + let head = tr_args("{}: {} left out or changed", &[&category(l["category"].as_str().unwrap_or("other")), &count]); + let mut details: Vec = + l["details"].as_array().map(|d| d.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default(); + // An export report has no `unlisted`: each of its lines is one occurrence. + let listed = u64::try_from(details.len()).unwrap_or(u64::MAX); + let unlisted = l["unlisted"].as_u64().unwrap_or_else(|| count.saturating_sub(listed)); + if unlisted > 0 { + details.push(tr_args("… and {} more", &[&unlisted])); + } (head, details) }) .collect() @@ -179,11 +186,25 @@ mod tests { fn the_report_reads_as_categories_with_counts_and_details() { let report = json!({"losses": [{"category": "layer_styles", "count": 4, "details": ["a", "b", "c", "d"]}, {"category": "effects", "count": 1}]}); let l = lines(&report); - assert_eq!(l[0], ("Layer styles (4)".to_string(), vec!["a".to_string(), "b".into(), "c".into()])); - assert_eq!(l[1], ("Effects (1)".to_string(), vec![])); + assert_eq!(l[0], ("Layer styles: 4 left out or changed".to_string(), vec!["a".to_string(), "b".into(), "c".into(), "d".into()])); + assert_eq!(l[1], ("Effects: 1 left out or changed".to_string(), vec!["… and 1 more".to_string()])); assert!(lines(&json!({})).is_empty()); } + /// #3365: every detail line shows, then how many occurrences have none: the report's + /// `unlisted` (an effect line can describe many instances), else the lines past the count. + #[test] + fn every_detail_shows_and_the_rest_is_counted() { + let details: Vec = (0..20).map(|i| format!("d{i}")).collect(); + let l = lines(&json!({"losses": [{"category": "masks", "count": 27, "details": details}]})); + assert_eq!(l[0].1.len(), 21); + assert_eq!(l[0].1[20], "… and 7 more"); + let l = lines(&json!({"losses": [{"category": "effects", "count": 50, "details": ["X, and 44 more like it"], "unlisted": 5}]})); + assert_eq!(l[0].1, ["X, and 44 more like it", "… and 5 more"]); + let l = lines(&json!({"losses": [{"category": "effects", "count": 45, "details": ["X, and 44 more like it"], "unlisted": 0}]})); + assert_eq!(l[0].1, ["X, and 44 more like it"]); + } + /// Projects dropped or picked together wait only behind a prompt that shows; with none /// showing they are tried at once (a missing file is an error naming it). #[test] diff --git a/docs/aep-import.md b/docs/aep-import.md index c704e0a89..3713ca0fe 100644 --- a/docs/aep-import.md +++ b/docs/aep-import.md @@ -1,6 +1,6 @@ # After Effects project import -> **Last reviewed:** 2026-11-05 · **Last updated:** 2026-11-10 · **Change:** dropped projects and projects picked in File ▸ Import ▸ File… import as projects, several asked about one at a time; `file.import` hands them to `file.importAep`; the menu entries are named Adobe After Effects Project… (#3103); before that: After Effects' Puppet arrives as EffectCraft's Puppet without its meshes and pins, which are reported mesh by mesh (#3093; the storage is not sampled yet, aep-format 3.7); before that: Lumetri Color's master RGB curve, Hue vs Sat curve and colour wheels are imported (1200 presets made by hand); before that: Lumetri Color is imported (1200: every slider, checkbox and popup with a counterpart, by match name, also from records in the default state; switched-off sections, LUTs, looks, undecoded curves and wheels are reported); before that: the report names the After Effects version a file is written for and the one that wrote it (`aeVersion`); copies saved as 25.x and 24.x import like the 2026 save; before that: Curves and Mesh Warp are imported (961-965, 985; FORMAT S of the corpus); before that: a shape item's eye switch is imported (bit 0 of its group flags, 986 / 987: a switched-off Fill under Trim Paths is no longer drawn, render equal to After Effects), and a layer style's (switch group `tdsb` 0, 988); before that: a negative time stretch (Time Reverse Layer) is imported: in and out point in order, the layer plays backwards (981-983: render equal to After Effects at 7 points); before that: After Effects' None on Compound Blur, Displacement Map and Set Matte arrives as an explicit None (`NO_LAYER`), not as the owning layer; before that: layer styles are imported (the nine styles and Blending Options, 951-979; Global Light becomes the composition's); before that: the 27 effects of round 3 are in the table (`effectcraft_aep::effect_map`); before that: a layer of a clip, sequence or sound ends at its source's end (After Effects ends it there when it opens a project, read-back test f1), unless it is remapped or looped or runs less than a frame past; before that: keys of shape colours are read (the reader took their 152-byte spatial records for plain ones: `tdb4` byte 7 is 0 there, 854); before that: the effect table moved to `effectcraft_aep::effect_map`; point defaults in percent and a popup default slot of 0 (After Effects' save of a project it opened, e1 / e2 re-saves) are read; before that: the justification of right-to-left paragraphs is read relative to the direction (t7); before that: 24 more layer blend modes (906, 907); before that: 27 more effects are imported (batch 6: 903-905), with layer parameters; before that: a Rec. 709 working space also sets the output space (After Effects renders in the working space, 902); before that: a mask whose path was never set arrives as the source rectangle (900, 901); before that: Offset Paths' Copy Offset arrives as EffectCraft's (After Effects' value minus 1, 828); before that: project settings, image sequences, conformed clips and effect instance names are imported, next to the mattes, shapes, text settings and animators, effects, markers, time remapping, cameras and lights); before that: parallel and ambient lights (701, 702) and keyed Orientation (703) are imported; before that: masks on text and shape layers (705, 706: AE vs EffectCraft render 12 and 0 of 5184 points, glyph edges) and Merge Paths, Offset Paths, Repeater (707-710, 731; merge renders equal but for edges; Repeater copy opacity and Offset Paths' outline differ in EffectCraft's renderer) are imported; before that: keyframes: the ease of Anchor Point, effect point, effect colour and mask path keys is imported (597-599, 700, 730: within 0.5 px of After Effects' read-backs); before that: effects: Directional Blur, Radial Blur, CC Radial Blur, Venetian Blinds, Channel Blur, Black & White, Tritone, Luma Key, Simple Choker, Matte Choker and Transform are imported (711-723; render checks: CC Radial Blur and Channel Blur equal, Directional Blur within 5/255; Venetian Blinds, Radial Blur, Black & White (8/255), Tritone (19/255), Luma Key, Matte Choker and Transform differ in EffectCraft's renderer, see STATUS); before that: text: character and paragraph settings (stroke, scales, baseline shift, kerning, ligatures, tsume, indents, spacing, justified text, composer, direction, vertical text; 538-578) and text animators with range selectors (579-594, 114) are imported; render checks 594 (keyed range selector at 0 / 0.5 / 1 / 1.5 / 2.5 s), 595, 596 equal but for glyph edges; before that: cameras, spot and point lights, X / Y Rotation and Orientation, Timewarp and expressions on effect parameters are imported (render checks 486, 535-537 equal but for edges; 488 differs by 5.25 %, cause in EffectCraft's renderer not isolated); before that: text: superscript and subscript are imported (526-528); before that: effects: Invert, Fast Box Blur, Gaussian Blur, Drop Shadow, Glow, Fill, Hue/Saturation, Levels and five expression controls are imported, popups are mapped by item name, checkbox defaults come from the file; before that: shape layers are imported (groups, paths, paints, trim paths, round corners; 451-484, 519 added); before that: markers and Time Remapping are imported (509, 510 added); before that: track mattes are imported (402-408); before that: text: automatic leading is verified (400); effects: Exposure is imported (Channels, all twelve sliders, Bypass, keys), next to Tint and Gradient Ramp; before that: text: U+0003 is a paragraph break (386, 387); before that: a stored text Anchor Point is pixels (385, now V); before that: effects: Tint and Gradient Ramp are imported (all parameters and keys), other effects stay reported; `.aepx` is accepted; text layers are imported (Source Text with style runs, paragraphs and box text; fonts by PostScript name, missing fonts listed); layer styles are reported; before that: layer masks · **Target:** Adobe After Effects 2026 +> **Last reviewed:** 2026-11-05 · **Last updated:** 2026-11-11 · **Change:** the losses prompt shows every detail line and how many have none (`unlisted`); unmapped effects are listed most used first (#3365); before that: dropped projects and projects picked in File ▸ Import ▸ File… import as projects, several asked about one at a time; `file.import` hands them to `file.importAep`; the menu entries are named Adobe After Effects Project… (#3103); before that: After Effects' Puppet arrives as EffectCraft's Puppet without its meshes and pins, which are reported mesh by mesh (#3093; the storage is not sampled yet, aep-format 3.7); before that: Lumetri Color's master RGB curve, Hue vs Sat curve and colour wheels are imported (1200 presets made by hand); before that: Lumetri Color is imported (1200: every slider, checkbox and popup with a counterpart, by match name, also from records in the default state; switched-off sections, LUTs, looks, undecoded curves and wheels are reported); before that: the report names the After Effects version a file is written for and the one that wrote it (`aeVersion`); copies saved as 25.x and 24.x import like the 2026 save; before that: Curves and Mesh Warp are imported (961-965, 985; FORMAT S of the corpus); before that: a shape item's eye switch is imported (bit 0 of its group flags, 986 / 987: a switched-off Fill under Trim Paths is no longer drawn, render equal to After Effects), and a layer style's (switch group `tdsb` 0, 988); before that: a negative time stretch (Time Reverse Layer) is imported: in and out point in order, the layer plays backwards (981-983: render equal to After Effects at 7 points); before that: After Effects' None on Compound Blur, Displacement Map and Set Matte arrives as an explicit None (`NO_LAYER`), not as the owning layer; before that: layer styles are imported (the nine styles and Blending Options, 951-979; Global Light becomes the composition's); before that: the 27 effects of round 3 are in the table (`effectcraft_aep::effect_map`); before that: a layer of a clip, sequence or sound ends at its source's end (After Effects ends it there when it opens a project, read-back test f1), unless it is remapped or looped or runs less than a frame past; before that: keys of shape colours are read (the reader took their 152-byte spatial records for plain ones: `tdb4` byte 7 is 0 there, 854); before that: the effect table moved to `effectcraft_aep::effect_map`; point defaults in percent and a popup default slot of 0 (After Effects' save of a project it opened, e1 / e2 re-saves) are read; before that: the justification of right-to-left paragraphs is read relative to the direction (t7); before that: 24 more layer blend modes (906, 907); before that: 27 more effects are imported (batch 6: 903-905), with layer parameters; before that: a Rec. 709 working space also sets the output space (After Effects renders in the working space, 902); before that: a mask whose path was never set arrives as the source rectangle (900, 901); before that: Offset Paths' Copy Offset arrives as EffectCraft's (After Effects' value minus 1, 828); before that: project settings, image sequences, conformed clips and effect instance names are imported, next to the mattes, shapes, text settings and animators, effects, markers, time remapping, cameras and lights); before that: parallel and ambient lights (701, 702) and keyed Orientation (703) are imported; before that: masks on text and shape layers (705, 706: AE vs EffectCraft render 12 and 0 of 5184 points, glyph edges) and Merge Paths, Offset Paths, Repeater (707-710, 731; merge renders equal but for edges; Repeater copy opacity and Offset Paths' outline differ in EffectCraft's renderer) are imported; before that: keyframes: the ease of Anchor Point, effect point, effect colour and mask path keys is imported (597-599, 700, 730: within 0.5 px of After Effects' read-backs); before that: effects: Directional Blur, Radial Blur, CC Radial Blur, Venetian Blinds, Channel Blur, Black & White, Tritone, Luma Key, Simple Choker, Matte Choker and Transform are imported (711-723; render checks: CC Radial Blur and Channel Blur equal, Directional Blur within 5/255; Venetian Blinds, Radial Blur, Black & White (8/255), Tritone (19/255), Luma Key, Matte Choker and Transform differ in EffectCraft's renderer, see STATUS); before that: text: character and paragraph settings (stroke, scales, baseline shift, kerning, ligatures, tsume, indents, spacing, justified text, composer, direction, vertical text; 538-578) and text animators with range selectors (579-594, 114) are imported; render checks 594 (keyed range selector at 0 / 0.5 / 1 / 1.5 / 2.5 s), 595, 596 equal but for glyph edges; before that: cameras, spot and point lights, X / Y Rotation and Orientation, Timewarp and expressions on effect parameters are imported (render checks 486, 535-537 equal but for edges; 488 differs by 5.25 %, cause in EffectCraft's renderer not isolated); before that: text: superscript and subscript are imported (526-528); before that: effects: Invert, Fast Box Blur, Gaussian Blur, Drop Shadow, Glow, Fill, Hue/Saturation, Levels and five expression controls are imported, popups are mapped by item name, checkbox defaults come from the file; before that: shape layers are imported (groups, paths, paints, trim paths, round corners; 451-484, 519 added); before that: markers and Time Remapping are imported (509, 510 added); before that: track mattes are imported (402-408); before that: text: automatic leading is verified (400); effects: Exposure is imported (Channels, all twelve sliders, Bypass, keys), next to Tint and Gradient Ramp; before that: text: U+0003 is a paragraph break (386, 387); before that: a stored text Anchor Point is pixels (385, now V); before that: effects: Tint and Gradient Ramp are imported (all parameters and keys), other effects stay reported; `.aepx` is accepted; text layers are imported (Source Text with style runs, paragraphs and box text; fonts by PostScript name, missing fonts listed); layer styles are reported; before that: layer masks · **Target:** Adobe After Effects 2026 File ▸ Import ▸ Adobe After Effects Project… (`file.importAep`) reads an `.aep` or its XML form `.aepx` and adds its items to the open project, in a folder named after the file (as After Effects does when it imports a project). @@ -322,8 +322,12 @@ parameter gets the file's value: ## What is reported `ImportReport` (`serde`, returned as JSON) has `imported` (counts, including `textLayers`), -`losses` (per category: `count` exact, up to 20 `details`), `missingFootage`, `missingFonts` -(PostScript names; not losses), `limitations` and `aeVersion`. The categories: +`losses` (per category: `count` exact; up to 20 `details`, plus one line per unmapped effect with its +number of instances, the most used first; `unlisted`, how many of the `count` no line describes), +`missingFootage`, `missingFonts` +(PostScript names; not losses), `limitations` and `aeVersion`. The losses prompt shows each +category with how many occurrences are left out or changed, every detail line, and "… and N more" +for the `unlisted` ones. The categories: | Category | Counted | |---|---| From b55226320b1aa6890be83e89d436482cb66ddf80 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Wed, 11 Nov 2026 16:17:03 +0530 Subject: [PATCH 09/15] #3087: a shape property in the default state is no loss Shape Direction, blend mode, composite order and the gradient highlight values were reported when their record was in the default state (tdsb 3), which holds After Effects' default (docs/aep-format.md 3.4), so a plain guide rectangle reported "shape direction not imported". They are now read only from written records, keys or an expression. Co-Authored-By: Claude Opus 5.5 --- crates/aep/src/import/shapes.rs | 46 +++++++++++++++++++++++++++++++-- docs/aep-import.md | 4 +-- 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/crates/aep/src/import/shapes.rs b/crates/aep/src/import/shapes.rs index 345346a6f..b86ee7492 100644 --- a/crates/aep/src/import/shapes.rs +++ b/crates/aep/src/import/shapes.rs @@ -457,18 +457,22 @@ fn shift_copy_offset(p: &mut effectcraft_project::Property, cx: &mut Cx<'_, '_>) } } -/// Reports a property whose only sampled value is the default 1. +/// Reports a property whose only sampled value is the default 1. A record in the default state +/// holds After Effects' default (docs/aep-format.md 3.4, state), so it holds nothing to lose. fn not_default(g: &AepGroup<'_, '_>, match_name: &str, what: &str, cx: &mut Cx<'_, '_>) { if let Some(PropNode::Property(p)) = g.get(match_name) + && stored(&p) && (p.is_animated() || p.expression().is_some() || p.static_value().and_then(|v| v.first().copied()).is_some_and(|v| v != 1.0)) { cx.col.lose(LossCategory::ShapeLayers, format!("{}: {what} not imported", cx.what)); } } -/// Reports a property whose only sampled value is 0. +/// Reports a property whose only sampled value is 0 (a record in the default state, as above, +/// holds nothing to lose). fn not_zero(g: &AepGroup<'_, '_>, match_name: &str, what: &str, cx: &mut Cx<'_, '_>) { if let Some(PropNode::Property(p)) = g.get(match_name) + && stored(&p) && (p.is_animated() || p.expression().is_some() || p.static_value().and_then(|v| v.first().copied()).is_some_and(|v| v != 0.0)) { cx.col.lose(LossCategory::ShapeLayers, format!("{}: {what} not imported", cx.what)); @@ -490,3 +494,41 @@ fn unknown(g: &AepGroup<'_, '_>, known: &[&str], what: &str, cx: &mut Cx<'_, '_> } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::Chunk; + + /// A group holding `match_name` as a one-component property in state `tdsb` with value `v`. + fn group_with(match_name: &str, tdsb: u32, v: f64) -> Chunk<'static> { + let mut name = match_name.as_bytes().to_vec(); + name.resize(40, 0); + let mut tdb4 = vec![0u8; 124]; + tdb4[3] = 1; + let tdbs = Chunk::list( + *b"tdbs", + vec![Chunk::data(*b"tdsb", tdsb.to_be_bytes().to_vec()), Chunk::data(*b"tdb4", tdb4), Chunk::data(*b"cdat", v.to_be_bytes().to_vec())], + ); + Chunk::list(*b"tdgp", vec![Chunk::data(*b"tdmn", name), tdbs]) + } + + fn reported(match_name: &str, tdsb: u32, v: f64, check: fn(&AepGroup<'_, '_>, &str, &str, &mut Cx<'_, '_>)) -> usize { + let chunk = group_with(match_name, tdsb, v); + let g = AepGroup::from_chunk(&chunk).unwrap(); + let tm = TimeMap { timescale: 30, frame_duration: 1, rate: crate::import::time::frame_rate(30, 1).unwrap() }; + let (mut next, mut col) = (1, Collector::default()); + check(&g, match_name, "it", &mut Cx { what: "L", tm: &tm, ids: &mut Ids(&mut next), col: &mut col }); + col.finish().losses.iter().map(|l| l.count).sum() + } + + /// #3087: a guide rectangle's Shape Direction recorded in the default state (`tdsb` 3, holding + /// 2) is After Effects' default and is no loss; written (`tdsb` 1) with 2, it is one. + #[test] + fn a_default_state_record_is_no_loss() { + assert_eq!(reported("ADBE Vector Shape Direction", 3, 2.0, not_default), 0); + assert_eq!(reported("ADBE Vector Shape Direction", 1, 2.0, not_default), 1); + assert_eq!(reported("ADBE Vector Grad HiLite Length", 3, 5.0, not_zero), 0); + assert_eq!(reported("ADBE Vector Grad HiLite Length", 1, 5.0, not_zero), 1); + } +} diff --git a/docs/aep-import.md b/docs/aep-import.md index 3713ca0fe..379b81b67 100644 --- a/docs/aep-import.md +++ b/docs/aep-import.md @@ -1,6 +1,6 @@ # After Effects project import -> **Last reviewed:** 2026-11-05 · **Last updated:** 2026-11-11 · **Change:** the losses prompt shows every detail line and how many have none (`unlisted`); unmapped effects are listed most used first (#3365); before that: dropped projects and projects picked in File ▸ Import ▸ File… import as projects, several asked about one at a time; `file.import` hands them to `file.importAep`; the menu entries are named Adobe After Effects Project… (#3103); before that: After Effects' Puppet arrives as EffectCraft's Puppet without its meshes and pins, which are reported mesh by mesh (#3093; the storage is not sampled yet, aep-format 3.7); before that: Lumetri Color's master RGB curve, Hue vs Sat curve and colour wheels are imported (1200 presets made by hand); before that: Lumetri Color is imported (1200: every slider, checkbox and popup with a counterpart, by match name, also from records in the default state; switched-off sections, LUTs, looks, undecoded curves and wheels are reported); before that: the report names the After Effects version a file is written for and the one that wrote it (`aeVersion`); copies saved as 25.x and 24.x import like the 2026 save; before that: Curves and Mesh Warp are imported (961-965, 985; FORMAT S of the corpus); before that: a shape item's eye switch is imported (bit 0 of its group flags, 986 / 987: a switched-off Fill under Trim Paths is no longer drawn, render equal to After Effects), and a layer style's (switch group `tdsb` 0, 988); before that: a negative time stretch (Time Reverse Layer) is imported: in and out point in order, the layer plays backwards (981-983: render equal to After Effects at 7 points); before that: After Effects' None on Compound Blur, Displacement Map and Set Matte arrives as an explicit None (`NO_LAYER`), not as the owning layer; before that: layer styles are imported (the nine styles and Blending Options, 951-979; Global Light becomes the composition's); before that: the 27 effects of round 3 are in the table (`effectcraft_aep::effect_map`); before that: a layer of a clip, sequence or sound ends at its source's end (After Effects ends it there when it opens a project, read-back test f1), unless it is remapped or looped or runs less than a frame past; before that: keys of shape colours are read (the reader took their 152-byte spatial records for plain ones: `tdb4` byte 7 is 0 there, 854); before that: the effect table moved to `effectcraft_aep::effect_map`; point defaults in percent and a popup default slot of 0 (After Effects' save of a project it opened, e1 / e2 re-saves) are read; before that: the justification of right-to-left paragraphs is read relative to the direction (t7); before that: 24 more layer blend modes (906, 907); before that: 27 more effects are imported (batch 6: 903-905), with layer parameters; before that: a Rec. 709 working space also sets the output space (After Effects renders in the working space, 902); before that: a mask whose path was never set arrives as the source rectangle (900, 901); before that: Offset Paths' Copy Offset arrives as EffectCraft's (After Effects' value minus 1, 828); before that: project settings, image sequences, conformed clips and effect instance names are imported, next to the mattes, shapes, text settings and animators, effects, markers, time remapping, cameras and lights); before that: parallel and ambient lights (701, 702) and keyed Orientation (703) are imported; before that: masks on text and shape layers (705, 706: AE vs EffectCraft render 12 and 0 of 5184 points, glyph edges) and Merge Paths, Offset Paths, Repeater (707-710, 731; merge renders equal but for edges; Repeater copy opacity and Offset Paths' outline differ in EffectCraft's renderer) are imported; before that: keyframes: the ease of Anchor Point, effect point, effect colour and mask path keys is imported (597-599, 700, 730: within 0.5 px of After Effects' read-backs); before that: effects: Directional Blur, Radial Blur, CC Radial Blur, Venetian Blinds, Channel Blur, Black & White, Tritone, Luma Key, Simple Choker, Matte Choker and Transform are imported (711-723; render checks: CC Radial Blur and Channel Blur equal, Directional Blur within 5/255; Venetian Blinds, Radial Blur, Black & White (8/255), Tritone (19/255), Luma Key, Matte Choker and Transform differ in EffectCraft's renderer, see STATUS); before that: text: character and paragraph settings (stroke, scales, baseline shift, kerning, ligatures, tsume, indents, spacing, justified text, composer, direction, vertical text; 538-578) and text animators with range selectors (579-594, 114) are imported; render checks 594 (keyed range selector at 0 / 0.5 / 1 / 1.5 / 2.5 s), 595, 596 equal but for glyph edges; before that: cameras, spot and point lights, X / Y Rotation and Orientation, Timewarp and expressions on effect parameters are imported (render checks 486, 535-537 equal but for edges; 488 differs by 5.25 %, cause in EffectCraft's renderer not isolated); before that: text: superscript and subscript are imported (526-528); before that: effects: Invert, Fast Box Blur, Gaussian Blur, Drop Shadow, Glow, Fill, Hue/Saturation, Levels and five expression controls are imported, popups are mapped by item name, checkbox defaults come from the file; before that: shape layers are imported (groups, paths, paints, trim paths, round corners; 451-484, 519 added); before that: markers and Time Remapping are imported (509, 510 added); before that: track mattes are imported (402-408); before that: text: automatic leading is verified (400); effects: Exposure is imported (Channels, all twelve sliders, Bypass, keys), next to Tint and Gradient Ramp; before that: text: U+0003 is a paragraph break (386, 387); before that: a stored text Anchor Point is pixels (385, now V); before that: effects: Tint and Gradient Ramp are imported (all parameters and keys), other effects stay reported; `.aepx` is accepted; text layers are imported (Source Text with style runs, paragraphs and box text; fonts by PostScript name, missing fonts listed); layer styles are reported; before that: layer masks · **Target:** Adobe After Effects 2026 +> **Last reviewed:** 2026-11-05 · **Last updated:** 2026-11-11 · **Change:** the losses prompt shows every detail line and how many have none (`unlisted`); unmapped effects are listed most used first (#3365); a shape property recorded in the default state is no loss (#3087); before that: dropped projects and projects picked in File ▸ Import ▸ File… import as projects, several asked about one at a time; `file.import` hands them to `file.importAep`; the menu entries are named Adobe After Effects Project… (#3103); before that: After Effects' Puppet arrives as EffectCraft's Puppet without its meshes and pins, which are reported mesh by mesh (#3093; the storage is not sampled yet, aep-format 3.7); before that: Lumetri Color's master RGB curve, Hue vs Sat curve and colour wheels are imported (1200 presets made by hand); before that: Lumetri Color is imported (1200: every slider, checkbox and popup with a counterpart, by match name, also from records in the default state; switched-off sections, LUTs, looks, undecoded curves and wheels are reported); before that: the report names the After Effects version a file is written for and the one that wrote it (`aeVersion`); copies saved as 25.x and 24.x import like the 2026 save; before that: Curves and Mesh Warp are imported (961-965, 985; FORMAT S of the corpus); before that: a shape item's eye switch is imported (bit 0 of its group flags, 986 / 987: a switched-off Fill under Trim Paths is no longer drawn, render equal to After Effects), and a layer style's (switch group `tdsb` 0, 988); before that: a negative time stretch (Time Reverse Layer) is imported: in and out point in order, the layer plays backwards (981-983: render equal to After Effects at 7 points); before that: After Effects' None on Compound Blur, Displacement Map and Set Matte arrives as an explicit None (`NO_LAYER`), not as the owning layer; before that: layer styles are imported (the nine styles and Blending Options, 951-979; Global Light becomes the composition's); before that: the 27 effects of round 3 are in the table (`effectcraft_aep::effect_map`); before that: a layer of a clip, sequence or sound ends at its source's end (After Effects ends it there when it opens a project, read-back test f1), unless it is remapped or looped or runs less than a frame past; before that: keys of shape colours are read (the reader took their 152-byte spatial records for plain ones: `tdb4` byte 7 is 0 there, 854); before that: the effect table moved to `effectcraft_aep::effect_map`; point defaults in percent and a popup default slot of 0 (After Effects' save of a project it opened, e1 / e2 re-saves) are read; before that: the justification of right-to-left paragraphs is read relative to the direction (t7); before that: 24 more layer blend modes (906, 907); before that: 27 more effects are imported (batch 6: 903-905), with layer parameters; before that: a Rec. 709 working space also sets the output space (After Effects renders in the working space, 902); before that: a mask whose path was never set arrives as the source rectangle (900, 901); before that: Offset Paths' Copy Offset arrives as EffectCraft's (After Effects' value minus 1, 828); before that: project settings, image sequences, conformed clips and effect instance names are imported, next to the mattes, shapes, text settings and animators, effects, markers, time remapping, cameras and lights); before that: parallel and ambient lights (701, 702) and keyed Orientation (703) are imported; before that: masks on text and shape layers (705, 706: AE vs EffectCraft render 12 and 0 of 5184 points, glyph edges) and Merge Paths, Offset Paths, Repeater (707-710, 731; merge renders equal but for edges; Repeater copy opacity and Offset Paths' outline differ in EffectCraft's renderer) are imported; before that: keyframes: the ease of Anchor Point, effect point, effect colour and mask path keys is imported (597-599, 700, 730: within 0.5 px of After Effects' read-backs); before that: effects: Directional Blur, Radial Blur, CC Radial Blur, Venetian Blinds, Channel Blur, Black & White, Tritone, Luma Key, Simple Choker, Matte Choker and Transform are imported (711-723; render checks: CC Radial Blur and Channel Blur equal, Directional Blur within 5/255; Venetian Blinds, Radial Blur, Black & White (8/255), Tritone (19/255), Luma Key, Matte Choker and Transform differ in EffectCraft's renderer, see STATUS); before that: text: character and paragraph settings (stroke, scales, baseline shift, kerning, ligatures, tsume, indents, spacing, justified text, composer, direction, vertical text; 538-578) and text animators with range selectors (579-594, 114) are imported; render checks 594 (keyed range selector at 0 / 0.5 / 1 / 1.5 / 2.5 s), 595, 596 equal but for glyph edges; before that: cameras, spot and point lights, X / Y Rotation and Orientation, Timewarp and expressions on effect parameters are imported (render checks 486, 535-537 equal but for edges; 488 differs by 5.25 %, cause in EffectCraft's renderer not isolated); before that: text: superscript and subscript are imported (526-528); before that: effects: Invert, Fast Box Blur, Gaussian Blur, Drop Shadow, Glow, Fill, Hue/Saturation, Levels and five expression controls are imported, popups are mapped by item name, checkbox defaults come from the file; before that: shape layers are imported (groups, paths, paints, trim paths, round corners; 451-484, 519 added); before that: markers and Time Remapping are imported (509, 510 added); before that: track mattes are imported (402-408); before that: text: automatic leading is verified (400); effects: Exposure is imported (Channels, all twelve sliders, Bypass, keys), next to Tint and Gradient Ramp; before that: text: U+0003 is a paragraph break (386, 387); before that: a stored text Anchor Point is pixels (385, now V); before that: effects: Tint and Gradient Ramp are imported (all parameters and keys), other effects stay reported; `.aepx` is accepted; text layers are imported (Source Text with style runs, paragraphs and box text; fonts by PostScript name, missing fonts listed); layer styles are reported; before that: layer masks · **Target:** Adobe After Effects 2026 File ▸ Import ▸ Adobe After Effects Project… (`file.importAep`) reads an `.aep` or its XML form `.aepx` and adds its items to the open project, in a folder named after the file (as After Effects does when it imports a project). @@ -336,7 +336,7 @@ for the `unlisted` ones. The categories: | `layer_styles` | per layer: a style in switch state 3 (not sampled; state 0 is imported as a style switched off, 988), Pattern Overlay, an unknown style or parameter, a gradient (`GCst`, not decoded: EffectCraft's default gradient stays), keys on a popup or checkbox, a value that does not fit, styles on a layer that takes none here (adjustment, camera, light) | | `markers` | a marker with an unknown cue type (imported as an event cue) | | `text_layers` ("text layer features") | per text layer, what of it is not imported: wiggly and expression selectors and unmapped animator properties, Path Options or More Options with set values, Source Text keyframes or an expression, character style or paragraph fields that are not decoded and differ from the default (named by their slot, e.g. `/68`), an unknown capitalisation or justification code, manual kerning, No Break, the stroke line join, the Japanese leading model, a digit set other than the default and Arabic, control characters of unknown meaning (they are kept in the text; U+0003 is not one of them, see Text above). The text layer itself is imported | -| `shape_layers` ("shape layers") | what of a shape layer is not imported: shape items other than groups, rectangle, ellipse, polystar, path, fill, stroke, gradient fill and stroke, trim paths, round corners, merge paths, repeater and offset paths (for example wiggle or twist), stroke dashes, taper and wave with values, item blend modes and composite order, shape direction, gradient colour stops other than the default, gradient highlight values other than 0, material options, groups nested deeper than 32, a line cap or join of the unsampled third kind (imported as it), a path without a readable path. The layer itself is imported | +| `shape_layers` ("shape layers") | what of a shape layer is not imported: shape items other than groups, rectangle, ellipse, polystar, path, fill, stroke, gradient fill and stroke, trim paths, round corners, merge paths, repeater and offset paths (for example wiggle or twist), stroke dashes, taper and wave with values, item blend modes and composite order, shape direction, gradient colour stops other than the default, gradient highlight values other than 0 (only from written records: one in the default state holds the default), material options, groups nested deeper than 32, a line cap or join of the unsampled third kind (imported as it), a path without a readable path. The layer itself is imported | | `lights` | each environment light (the layer is skipped; the other layers of the composition are imported) | | `blend_modes` | a blend mode code that is not mapped (1, 3 = Dissolve or Dancing Dissolve, 21 / 22 = Alpha Add or Luminescent Premul, codes above 34; the layer stays Normal) | | `transform_properties` | a stored transform property that is not imported (keyed or expression-driven orientation, separated position dimensions), and the stored camera and light options that are not mapped (Blur Level, Iris, Falloff, ...) | From 1f38431fafb82f3fec10282782915a4851d76aa8 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Wed, 11 Nov 2026 16:18:15 +0530 Subject: [PATCH 10/15] #3087: an expression on Orientation is imported with its value or keys An Orientation with an expression was reported as "not decoded" and lost its value and keys too. The expression is stored in the otst's tdbs like on any property, so it now arrives with the stored value or keys. Co-Authored-By: Claude Opus 5.5 --- crates/aep/src/import/props.rs | 18 +++++++---- crates/aep/tests/it/import/synthetic.rs | 42 +++++++++++++++++++++++++ docs/aep-import.md | 6 ++-- 3 files changed, 57 insertions(+), 9 deletions(-) diff --git a/crates/aep/src/import/props.rs b/crates/aep/src/import/props.rs index fd0a4f9fc..8c331f1cb 100644 --- a/crates/aep/src/import/props.rs +++ b/crates/aep/src/import/props.rs @@ -231,15 +231,21 @@ pub(crate) fn import_transform( } import_expression(&node, prop, col); } - if let Some(PropNode::Other(_)) = group.get("ADBE Orientation") { + if let Some(PropNode::Other(otst)) = group.get("ADBE Orientation") { match (layer.orientation(), target.get_mut("orientation")) { - (Some(o), Some(prop)) if !o.animated && !o.has_expression && sane(&Value::Vec3(o.degrees)) => prop.value = Value::Vec3(o.degrees), + (Some(o), Some(prop)) if o.animated || sane(&Value::Vec3(o.degrees)) => { + if o.animated { + import_orientation_keys(layer, layer_name, tm, prop, col); + } else { + prop.value = Value::Vec3(o.degrees); + } + // The expression is stored in the `otst`'s `tdbs` as on any property (#3087). + if let Some(node) = otst.child_list(b"tdbs").and_then(AepProp::from_chunk) { + import_expression(&node, prop, col); + } + } // A layer without an orientation (an ambient light) loses nothing with the default. (Some(o), None) if !o.animated && !o.has_expression && o.degrees == [0.0; 3] => {} - (Some(o), Some(prop)) if o.animated && !o.has_expression => import_orientation_keys(layer, layer_name, tm, prop, col), - (Some(o), Some(_)) if o.has_expression => { - col.lose(LossCategory::TransformProperties, format!("{layer_name}: ADBE Orientation expression (not decoded)")); - } _ => col.lose(LossCategory::TransformProperties, format!("{layer_name}: ADBE Orientation")), } } diff --git a/crates/aep/tests/it/import/synthetic.rs b/crates/aep/tests/it/import/synthetic.rs index 8e4c28f16..f2bc36a70 100644 --- a/crates/aep/tests/it/import/synthetic.rs +++ b/crates/aep/tests/it/import/synthetic.rs @@ -842,3 +842,45 @@ fn a_default_state_colour_record_holds_the_value() { assert_eq!(colour(&default_state), colour(&written)); assert_ne!(colour(&default_state), colour(&unchanged), "the recorded colour, not the default"); } + +/// #3087: an expression on Orientation arrives with the stored value, as on any property; it was +/// dropped together with the value and reported as "not decoded". +#[test] +fn an_orientation_expression_is_imported_with_its_value() { + let bytes = mutate(&mut |c| { + if !c.is_list(b"otst") { + return; + } + for child in c.children_mut() { + if child.is_list(b"tdbs") { + let Body::List { children, .. } = &mut child.body else { continue }; + for k in children.iter_mut() { + if k.is(b"tdsb") { + set_data(k, 0, &1u32.to_be_bytes()); + } else if k.is(b"tdb4") { + set_data(k, 120, &[1]); + } + } + children.push(Chunk::data(*b"Utf8", b"[0, time * 90, 0]".to_vec())); + } else if child.is_list(b"otky") { + for k in child.children_mut() { + let degrees: Vec = [10.0f64, 20.0, 30.0].iter().flat_map(|d| d.to_be_bytes()).collect(); + set_data(k, 0, °rees); + } + } + } + }); + let (p, r) = run(&bytes, true); + assert_eq!(losses(&r.unwrap(), LossCategory::TransformProperties), Vec::::new()); + let mut seen = 0; + for item in p.items.values() { + let ItemKind::Comp(c) = &item.kind else { continue }; + for layer in &c.layers { + let Some(o) = layer.transform().and_then(|t| t.prop("orientation")).filter(|o| o.expr.is_some()) else { continue }; + assert_eq!(o.value, effectcraft_keyframe::Value::Vec3([10.0, 20.0, 30.0]), "{}", layer.name); + assert_eq!(o.expr.as_ref().map(|e| (e.text.as_str(), e.enabled)), Some(("[0, time * 90, 0]", true)), "{}", layer.name); + seen += 1; + } + } + assert!(seen > 0); +} diff --git a/docs/aep-import.md b/docs/aep-import.md index 379b81b67..ee7469a8c 100644 --- a/docs/aep-import.md +++ b/docs/aep-import.md @@ -1,6 +1,6 @@ # After Effects project import -> **Last reviewed:** 2026-11-05 · **Last updated:** 2026-11-11 · **Change:** the losses prompt shows every detail line and how many have none (`unlisted`); unmapped effects are listed most used first (#3365); a shape property recorded in the default state is no loss (#3087); before that: dropped projects and projects picked in File ▸ Import ▸ File… import as projects, several asked about one at a time; `file.import` hands them to `file.importAep`; the menu entries are named Adobe After Effects Project… (#3103); before that: After Effects' Puppet arrives as EffectCraft's Puppet without its meshes and pins, which are reported mesh by mesh (#3093; the storage is not sampled yet, aep-format 3.7); before that: Lumetri Color's master RGB curve, Hue vs Sat curve and colour wheels are imported (1200 presets made by hand); before that: Lumetri Color is imported (1200: every slider, checkbox and popup with a counterpart, by match name, also from records in the default state; switched-off sections, LUTs, looks, undecoded curves and wheels are reported); before that: the report names the After Effects version a file is written for and the one that wrote it (`aeVersion`); copies saved as 25.x and 24.x import like the 2026 save; before that: Curves and Mesh Warp are imported (961-965, 985; FORMAT S of the corpus); before that: a shape item's eye switch is imported (bit 0 of its group flags, 986 / 987: a switched-off Fill under Trim Paths is no longer drawn, render equal to After Effects), and a layer style's (switch group `tdsb` 0, 988); before that: a negative time stretch (Time Reverse Layer) is imported: in and out point in order, the layer plays backwards (981-983: render equal to After Effects at 7 points); before that: After Effects' None on Compound Blur, Displacement Map and Set Matte arrives as an explicit None (`NO_LAYER`), not as the owning layer; before that: layer styles are imported (the nine styles and Blending Options, 951-979; Global Light becomes the composition's); before that: the 27 effects of round 3 are in the table (`effectcraft_aep::effect_map`); before that: a layer of a clip, sequence or sound ends at its source's end (After Effects ends it there when it opens a project, read-back test f1), unless it is remapped or looped or runs less than a frame past; before that: keys of shape colours are read (the reader took their 152-byte spatial records for plain ones: `tdb4` byte 7 is 0 there, 854); before that: the effect table moved to `effectcraft_aep::effect_map`; point defaults in percent and a popup default slot of 0 (After Effects' save of a project it opened, e1 / e2 re-saves) are read; before that: the justification of right-to-left paragraphs is read relative to the direction (t7); before that: 24 more layer blend modes (906, 907); before that: 27 more effects are imported (batch 6: 903-905), with layer parameters; before that: a Rec. 709 working space also sets the output space (After Effects renders in the working space, 902); before that: a mask whose path was never set arrives as the source rectangle (900, 901); before that: Offset Paths' Copy Offset arrives as EffectCraft's (After Effects' value minus 1, 828); before that: project settings, image sequences, conformed clips and effect instance names are imported, next to the mattes, shapes, text settings and animators, effects, markers, time remapping, cameras and lights); before that: parallel and ambient lights (701, 702) and keyed Orientation (703) are imported; before that: masks on text and shape layers (705, 706: AE vs EffectCraft render 12 and 0 of 5184 points, glyph edges) and Merge Paths, Offset Paths, Repeater (707-710, 731; merge renders equal but for edges; Repeater copy opacity and Offset Paths' outline differ in EffectCraft's renderer) are imported; before that: keyframes: the ease of Anchor Point, effect point, effect colour and mask path keys is imported (597-599, 700, 730: within 0.5 px of After Effects' read-backs); before that: effects: Directional Blur, Radial Blur, CC Radial Blur, Venetian Blinds, Channel Blur, Black & White, Tritone, Luma Key, Simple Choker, Matte Choker and Transform are imported (711-723; render checks: CC Radial Blur and Channel Blur equal, Directional Blur within 5/255; Venetian Blinds, Radial Blur, Black & White (8/255), Tritone (19/255), Luma Key, Matte Choker and Transform differ in EffectCraft's renderer, see STATUS); before that: text: character and paragraph settings (stroke, scales, baseline shift, kerning, ligatures, tsume, indents, spacing, justified text, composer, direction, vertical text; 538-578) and text animators with range selectors (579-594, 114) are imported; render checks 594 (keyed range selector at 0 / 0.5 / 1 / 1.5 / 2.5 s), 595, 596 equal but for glyph edges; before that: cameras, spot and point lights, X / Y Rotation and Orientation, Timewarp and expressions on effect parameters are imported (render checks 486, 535-537 equal but for edges; 488 differs by 5.25 %, cause in EffectCraft's renderer not isolated); before that: text: superscript and subscript are imported (526-528); before that: effects: Invert, Fast Box Blur, Gaussian Blur, Drop Shadow, Glow, Fill, Hue/Saturation, Levels and five expression controls are imported, popups are mapped by item name, checkbox defaults come from the file; before that: shape layers are imported (groups, paths, paints, trim paths, round corners; 451-484, 519 added); before that: markers and Time Remapping are imported (509, 510 added); before that: track mattes are imported (402-408); before that: text: automatic leading is verified (400); effects: Exposure is imported (Channels, all twelve sliders, Bypass, keys), next to Tint and Gradient Ramp; before that: text: U+0003 is a paragraph break (386, 387); before that: a stored text Anchor Point is pixels (385, now V); before that: effects: Tint and Gradient Ramp are imported (all parameters and keys), other effects stay reported; `.aepx` is accepted; text layers are imported (Source Text with style runs, paragraphs and box text; fonts by PostScript name, missing fonts listed); layer styles are reported; before that: layer masks · **Target:** Adobe After Effects 2026 +> **Last reviewed:** 2026-11-05 · **Last updated:** 2026-11-11 · **Change:** the losses prompt shows every detail line and how many have none (`unlisted`); unmapped effects are listed most used first (#3365); a shape property recorded in the default state is no loss; an expression on Orientation is imported with its value or keys (#3087); before that: dropped projects and projects picked in File ▸ Import ▸ File… import as projects, several asked about one at a time; `file.import` hands them to `file.importAep`; the menu entries are named Adobe After Effects Project… (#3103); before that: After Effects' Puppet arrives as EffectCraft's Puppet without its meshes and pins, which are reported mesh by mesh (#3093; the storage is not sampled yet, aep-format 3.7); before that: Lumetri Color's master RGB curve, Hue vs Sat curve and colour wheels are imported (1200 presets made by hand); before that: Lumetri Color is imported (1200: every slider, checkbox and popup with a counterpart, by match name, also from records in the default state; switched-off sections, LUTs, looks, undecoded curves and wheels are reported); before that: the report names the After Effects version a file is written for and the one that wrote it (`aeVersion`); copies saved as 25.x and 24.x import like the 2026 save; before that: Curves and Mesh Warp are imported (961-965, 985; FORMAT S of the corpus); before that: a shape item's eye switch is imported (bit 0 of its group flags, 986 / 987: a switched-off Fill under Trim Paths is no longer drawn, render equal to After Effects), and a layer style's (switch group `tdsb` 0, 988); before that: a negative time stretch (Time Reverse Layer) is imported: in and out point in order, the layer plays backwards (981-983: render equal to After Effects at 7 points); before that: After Effects' None on Compound Blur, Displacement Map and Set Matte arrives as an explicit None (`NO_LAYER`), not as the owning layer; before that: layer styles are imported (the nine styles and Blending Options, 951-979; Global Light becomes the composition's); before that: the 27 effects of round 3 are in the table (`effectcraft_aep::effect_map`); before that: a layer of a clip, sequence or sound ends at its source's end (After Effects ends it there when it opens a project, read-back test f1), unless it is remapped or looped or runs less than a frame past; before that: keys of shape colours are read (the reader took their 152-byte spatial records for plain ones: `tdb4` byte 7 is 0 there, 854); before that: the effect table moved to `effectcraft_aep::effect_map`; point defaults in percent and a popup default slot of 0 (After Effects' save of a project it opened, e1 / e2 re-saves) are read; before that: the justification of right-to-left paragraphs is read relative to the direction (t7); before that: 24 more layer blend modes (906, 907); before that: 27 more effects are imported (batch 6: 903-905), with layer parameters; before that: a Rec. 709 working space also sets the output space (After Effects renders in the working space, 902); before that: a mask whose path was never set arrives as the source rectangle (900, 901); before that: Offset Paths' Copy Offset arrives as EffectCraft's (After Effects' value minus 1, 828); before that: project settings, image sequences, conformed clips and effect instance names are imported, next to the mattes, shapes, text settings and animators, effects, markers, time remapping, cameras and lights); before that: parallel and ambient lights (701, 702) and keyed Orientation (703) are imported; before that: masks on text and shape layers (705, 706: AE vs EffectCraft render 12 and 0 of 5184 points, glyph edges) and Merge Paths, Offset Paths, Repeater (707-710, 731; merge renders equal but for edges; Repeater copy opacity and Offset Paths' outline differ in EffectCraft's renderer) are imported; before that: keyframes: the ease of Anchor Point, effect point, effect colour and mask path keys is imported (597-599, 700, 730: within 0.5 px of After Effects' read-backs); before that: effects: Directional Blur, Radial Blur, CC Radial Blur, Venetian Blinds, Channel Blur, Black & White, Tritone, Luma Key, Simple Choker, Matte Choker and Transform are imported (711-723; render checks: CC Radial Blur and Channel Blur equal, Directional Blur within 5/255; Venetian Blinds, Radial Blur, Black & White (8/255), Tritone (19/255), Luma Key, Matte Choker and Transform differ in EffectCraft's renderer, see STATUS); before that: text: character and paragraph settings (stroke, scales, baseline shift, kerning, ligatures, tsume, indents, spacing, justified text, composer, direction, vertical text; 538-578) and text animators with range selectors (579-594, 114) are imported; render checks 594 (keyed range selector at 0 / 0.5 / 1 / 1.5 / 2.5 s), 595, 596 equal but for glyph edges; before that: cameras, spot and point lights, X / Y Rotation and Orientation, Timewarp and expressions on effect parameters are imported (render checks 486, 535-537 equal but for edges; 488 differs by 5.25 %, cause in EffectCraft's renderer not isolated); before that: text: superscript and subscript are imported (526-528); before that: effects: Invert, Fast Box Blur, Gaussian Blur, Drop Shadow, Glow, Fill, Hue/Saturation, Levels and five expression controls are imported, popups are mapped by item name, checkbox defaults come from the file; before that: shape layers are imported (groups, paths, paints, trim paths, round corners; 451-484, 519 added); before that: markers and Time Remapping are imported (509, 510 added); before that: track mattes are imported (402-408); before that: text: automatic leading is verified (400); effects: Exposure is imported (Channels, all twelve sliders, Bypass, keys), next to Tint and Gradient Ramp; before that: text: U+0003 is a paragraph break (386, 387); before that: a stored text Anchor Point is pixels (385, now V); before that: effects: Tint and Gradient Ramp are imported (all parameters and keys), other effects stay reported; `.aepx` is accepted; text layers are imported (Source Text with style runs, paragraphs and box text; fonts by PostScript name, missing fonts listed); layer styles are reported; before that: layer masks · **Target:** Adobe After Effects 2026 File ▸ Import ▸ Adobe After Effects Project… (`file.importAep`) reads an `.aep` or its XML form `.aepx` and adds its items to the open project, in a folder named after the file (as After Effects does when it imports a project). @@ -59,7 +59,7 @@ refused losses) leaves the project unchanged. outside the sampled ones keeps the current value and is reported as `project settings`. After Effects renders in the working space (902: in a Rec. 709 Gamma 2.4 project an sRGB PNG step of 128 comes out as 135 and a grey 0.5 solid as 128, in the Render Queue output and in the captures), while EffectCraft converts its output to `output_space` (sRGB when unset): a Rec. 709 working space therefore also sets the output space to Rec. 709 (902 within 1 level of After Effects, was up to 13). They come in `ImportResult::settings`; the library never changes the project's own settings, `file.importAep` applies them when the project has no compositions yet (or with `applySettings: true`) | | Composition | name, size, exact frame rate, duration, work area, background colour (8 bit), pixel aspect, composition motion blur switch, shutter angle and phase (as stored), display start time, Classic/Advanced 3D renderer | | Layers | solid, null, adjustment, footage, precomposition and **text** layers: name (the source's name when the layer has none; for a text layer the first 40 characters of its first line, as EffectCraft names a text layer after its text), in/out/start and time stretch (exact ratios, e.g. 50 % and 150 %); a layer of a clip, sequence or sound ends at its source's end as After Effects ends it (unless remapped, looped or less than a frame past; f1), enabled/audio/solo/shy/locked/effects/motion blur/collapse/3D/guide switches, draft quality, label, blend mode (Normal, Multiply, Add, Screen, Overlay, Difference; Soft Light, Hard Light, Darken, Lighten, Exclusion, Color Dodge, Color Burn by name, 906; Classic Difference, Hue, Saturation, Color, Luminosity, the Stencil and Silhouette modes, Classic Color Dodge / Burn, Linear Dodge / Burn, Linear, Vivid and Pin Light, Hard Mix by render, 907: each code rendered by After Effects matched exactly one EffectCraft mode; 906 renders within 1/255 of After Effects), parent, **track matte** (alpha, alpha inverted, luma, luma inverted; the matte layer anywhere in the composition, also below its target; 402-408) | -| Transform | Anchor Point, Position, Scale, Rotation, Opacity, **X and Y Rotation** (486, 487) and **Orientation**, static (488) or keyed (703: key values, times, linear and hold sides; a bezier side becomes linear and is reported; After Effects slerps between the keys, EffectCraft interpolates each angle, so in-between values differ, e.g. 703 at 1 s `[6.26, 9.35, 15.22]` against `[5, 10, 15]`; an expression-driven orientation is reported): the stored value, or the default when the file stores none; keyframes with time, linear/bezier/hold on each side, temporal ease (speed and influence, per dimension for Scale), automatic or manual spatial tangents, roving and key labels | +| Transform | Anchor Point, Position, Scale, Rotation, Opacity, **X and Y Rotation** (486, 487) and **Orientation**, static (488) or keyed (703: key values, times, linear and hold sides; a bezier side becomes linear and is reported; After Effects slerps between the keys, EffectCraft interpolates each angle, so in-between values differ, e.g. 703 at 1 s `[6.26, 9.35, 15.22]` against `[5, 10, 15]`), with its expression as on any property: the stored value, or the default when the file stores none; keyframes with time, linear/bezier/hold on each side, temporal ease (speed and influence, per dimension for Scale), automatic or manual spatial tangents, roving and key labels | | Expressions | on the transform and mask properties and on effect, camera and light parameters (534): source text and enable switch | | Cameras and lights | **cameras** (one-node and two-node, from `ldta` byte 38 bit `0x40`): point of interest, Position as stored (also the auto Position `[0, 0, -zoom]`, where After Effects renders it, 537), orientation and rotations, Zoom (absent: `-z` of the auto Position, else the 50 mm default), Depth of Field, Focus Distance (absent: the zoom), Aperture (absent: 25.309 at 1920 px, read back from 256; scaled with the width, which is not sampled, E); **spot, point, parallel and ambient lights** (light type 1, 2, 0, 3: 701, 702): point of interest (not for point and ambient lights), Position, Intensity, Color, Cone Angle, Cone Feather, Casts Shadows; keys and expressions as for the transform; 251-256, 341-345, 535-537, 701, 702. Environment lights are reported | | Text | the Source Text of a text layer: the text (paragraphs separated by CR; a hard line break is a CR too; U+0003 is laid out by After Effects exactly like a CR (386 against 387) and becomes a CR), per style run the font (PostScript name), size, fill and stroke colour, whether fill and stroke are drawn, stroke width, stroke over fill (one switch for the text), tracking, leading (automatic or explicit), faux bold, faux italic, all caps, small caps, superscript and subscript (526-528), horizontal and vertical scale, baseline shift, automatic kerning (metrics, optical, none), ligatures, tsume (538-578); per paragraph the justification (left, centre, right and the four justified variants), first-line / start / end indent, space before / after, direction, composer, hanging punctuation (in a right-to-left paragraph the stored justification is relative to the direction: After Effects lays 0 and 3 out flush right and 1 and 4 flush left, read-back test t7, so they arrive as right / left); point text or box text (box size and top-left in layer pixels); vertical right-to-left text (575) | @@ -339,7 +339,7 @@ for the `unlisted` ones. The categories: | `shape_layers` ("shape layers") | what of a shape layer is not imported: shape items other than groups, rectangle, ellipse, polystar, path, fill, stroke, gradient fill and stroke, trim paths, round corners, merge paths, repeater and offset paths (for example wiggle or twist), stroke dashes, taper and wave with values, item blend modes and composite order, shape direction, gradient colour stops other than the default, gradient highlight values other than 0 (only from written records: one in the default state holds the default), material options, groups nested deeper than 32, a line cap or join of the unsampled third kind (imported as it), a path without a readable path. The layer itself is imported | | `lights` | each environment light (the layer is skipped; the other layers of the composition are imported) | | `blend_modes` | a blend mode code that is not mapped (1, 3 = Dissolve or Dancing Dissolve, 21 / 22 = Alpha Add or Luminescent Premul, codes above 34; the layer stays Normal) | -| `transform_properties` | a stored transform property that is not imported (keyed or expression-driven orientation, separated position dimensions), and the stored camera and light options that are not mapped (Blur Level, Iris, Falloff, ...) | +| `transform_properties` | a stored transform property that is not imported (an orientation that cannot be read, or set on a layer without one, separated position dimensions), and the stored camera and light options that are not mapped (Blur Level, Iris, Falloff, ...) | | `time_remapping` | an enabled Time Remap without a readable keyframe | | `approximated_keyframes` | a key imported in a simpler form (unknown interpolation code, bezier ease that cannot be decoded: a colour key with a non-zero ease, non-finite ease data; a mask path key with a speed other than 0, imported unverified) | | `parent_links` | a parent that was not imported (a skipped camera, a missing layer) or that forms a loop | From 670896404695ed80e9cf86930cee11de5c01ab23 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Wed, 11 Nov 2026 17:15:43 +0530 Subject: [PATCH 11/15] #3365: sort the unmapped effects with sort_by_key (clippy) Co-Authored-By: Claude Opus 5.5 --- crates/aep/src/import/report.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/aep/src/import/report.rs b/crates/aep/src/import/report.rs index aa50f4dff..4732592d9 100644 --- a/crates/aep/src/import/report.rs +++ b/crates/aep/src/import/report.rs @@ -316,7 +316,7 @@ impl Collector { if let Some(loss) = self.losses.get_mut(&LossCategory::Effects) { // The most used effects first. let mut named: Vec<(String, usize)> = std::mem::take(&mut self.unmapped_effects).into_values().collect(); - named.sort_by(|a, b| b.1.cmp(&a.1)); + named.sort_by_key(|n| std::cmp::Reverse(n.1)); let described = named.iter().fold(0usize, |sum, (_, n)| sum.saturating_add(*n)); loss.unlisted = loss.unlisted.saturating_sub(described); let lines = named.into_iter().map(|(first, n)| match n { From 6540c9ab447131724647e7838e1090ea82cf449a Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Wed, 11 Nov 2026 17:44:01 +0530 Subject: [PATCH 12/15] #3454: Apply Color LUT loads files whose path contains LUT_ load_lut took any source containing "LUT_" for inline .cube text, so a path like /luts/LUT_Rec709.cube was parsed as text, failed, and the effect passed the image through. A .cube can only parse with a size header and data rows on separate lines, so a newline (after trimming, as the path already is) is what tells text from a path. --- crates/effects/src/utility.rs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/crates/effects/src/utility.rs b/crates/effects/src/utility.rs index fca9d607e..f0275eab1 100644 --- a/crates/effects/src/utility.rs +++ b/crates/effects/src/utility.rs @@ -344,7 +344,9 @@ pub fn load_lut(src: &str) -> Option> { { return v.clone(); } - let inline = src.contains('\n') || src.contains("LUT_"); + // `.cube` text always spans lines (a size header, then data rows); a path never does, even one + // whose file name contains `LUT_` (#3454). Trimmed, as the path is, so a trailing newline is a path. + let inline = src.trim().contains('\n'); let parsed = if inline { parse_cube(src) } else { std::fs::read_to_string(src.trim()).ok().and_then(|t| parse_cube(&t)) }.map(Arc::new); if let Ok(mut m) = cache.lock() { m.insert(src.to_string(), parsed.clone()); @@ -484,6 +486,20 @@ mod tests { assert_eq!(out.img, img); } + /// #3454: a path containing `LUT_` was taken for `.cube` text and the file never loaded. + #[test] + fn a_path_whose_name_contains_lut_loads_the_file() { + let dir = std::env::temp_dir().join(format!("ec-fx-3454-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("LUT_x.cube"); + std::fs::write(&path, identity_cube(2)).unwrap(); + let path = path.to_string_lossy().into_owned(); + assert!(load_lut(&path).is_some(), "{path}"); + assert!(load_lut(&format!("{path}\r\n")).is_some(), "{path} with a trailing newline"); + assert!(load_lut(&identity_cube(2)).is_some(), "inline text"); + std::fs::remove_dir_all(&dir).unwrap(); + } + #[test] fn one_d_lut_with_domain() { let lut = parse_cube("LUT_1D_SIZE 2\nDOMAIN_MIN 0 0 0\nDOMAIN_MAX 2 2 2\n1 1 1\n0 0 0\n").unwrap(); From 33a15e1d8691ffcd5a6301ac7eade7855d97d846 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 08:24:28 -0500 Subject: [PATCH 13/15] Scripting: export commands a script runs directly respect Allow Scripts to Write Files (#2921) * Scripting: export commands a script runs directly respect Allow Scripts to Write Files CompItem.saveFrameToPng and exportAsMotionGraphicsTemplate checked the file-write preference only in their JavaScript wrappers. A script could call comp.saveFrameAs or essential.exportTemplate itself (app.run, __exec) and the native handlers wrote the file with the preference off. Session::refuse_export and write_output_with now refuse when the running command was issued by a script and the preference is off, so every command that writes a path it is given is gated the same way. The wrappers check the gate with a new side-effect-free __file("checkWrite") instead of writing an empty file at the destination. * address review: clear stale export test dir before use * test: allow script writes in script_imports_footage_and_saves --- crates/engine/src/output_guard.rs | 16 ++++++++++ crates/host/src/tests.rs | 1 + crates/script/src/prelude.js | 6 ++-- crates/script/src/runtime.rs | 2 ++ crates/script/src/tests.rs | 50 +++++++++++++++++++++++++++++++ 5 files changed, 72 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/output_guard.rs b/crates/engine/src/output_guard.rs index 46c4356ed..73d938a32 100644 --- a/crates/engine/src/output_guard.rs +++ b/crates/engine/src/output_guard.rs @@ -509,12 +509,27 @@ impl Session { /// is not for an export to replace. For callers that write the file themselves; call it /// before the work, not after it. pub fn refuse_export(&self, path: &str) -> crate::Result<()> { + self.refuse_script_write(path)?; match self.blocking(path) { Some(source) => Err(crate::EngineError::Other(source.refusal(path, Use::Export))), None => Ok(()), } } + /// `Err` when a script issued the running command ([`Session::running_for_script`]) and + /// Settings ▸ Scripting & Expressions ▸ Allow Scripts to Write Files is off. The scripting + /// wrappers check this too, but a script can run any command directly (`app.run`, `__exec`), + /// so the commands that write a path they are given check here, through + /// [`Session::refuse_export`] and [`Session::write_output`]. + pub fn refuse_script_write(&self, path: &str) -> crate::Result<()> { + if self.running_for_script() && !self.prefs.scripting.allow_scripts_write_files { + return Err(crate::EngineError::Other(format!( + "Unable to write {path}: scripts can't write files unless Settings ▸ Scripting & Expressions ▸ Allow Scripts to Write Files and Access Network is on" + ))); + } + Ok(()) + } + /// Write `bytes` to `path` through the host's services, unless the project reads the file /// there ([`Session::refuse_export`]). Every command that writes to a path it is given /// writes through this; Save and Save As (the project's own file) do not. @@ -525,6 +540,7 @@ impl Session { /// [`Session::write_output`] against files already found ([`Session::protected_files`]), /// for a command that writes many. pub(crate) fn write_output_with(&self, protected: &Protected, path: &str, bytes: &[u8]) -> crate::Result<()> { + self.refuse_script_write(path)?; if let Some(source) = protected.find(Path::new(path)) { return Err(crate::EngineError::Other(source.refusal(path, Use::Export))); } diff --git a/crates/host/src/tests.rs b/crates/host/src/tests.rs index 55240698f..687aafe77 100644 --- a/crates/host/src/tests.rs +++ b/crates/host/src/tests.rs @@ -547,6 +547,7 @@ fn script_imports_footage_and_saves() { std::fs::create_dir_all(&dir).unwrap(); let q = |n: &str| serde_json::to_string(&dir.join(n).to_string_lossy()).unwrap(); let mut s = super::session(); + s.execute("prefs.set", json!({"key": "scripting.allowScriptsWriteFiles", "value": true})).unwrap(); let code = format!( r#" var c = app.project.items.addComp("Src", 32, 32, 1, 1, 10); diff --git a/crates/script/src/prelude.js b/crates/script/src/prelude.js index d4962d1b9..37910bf9e 100644 --- a/crates/script/src/prelude.js +++ b/crates/script/src/prelude.js @@ -1658,7 +1658,7 @@ CompItem.prototype.constructor = CompItem; P.saveFrameToPng = function (time, file) { var f = __str(file); if (!f) throw __err("saveFrameToPng: missing file"); - __file("write", f, ""); + __file("checkWrite", f); __call("comp.saveFrameAs", { comp: this.__id, path: f, time: __num(time, "time") }); }; P.openInEssentialGraphics = function () { __call("comp.openInEssentialGraphics", { comp: this.__id }); }; @@ -1668,8 +1668,8 @@ CompItem.prototype.constructor = CompItem; if (!/\.[A-Za-z0-9]+$/.test(f)) f += ".ectemplate"; if (!overwrite && JSON.parse(__file("exists", f)) === true) return false; if (this.__controllers().length === 0) return false; - // The file gate applies to templates too. - __file("write", f, ""); + // The file gate applies to templates too (checked without touching the file). + __file("checkWrite", f); __call("essential.exportTemplate", { comp: this.__id, path: f }); return true; }; diff --git a/crates/script/src/runtime.rs b/crates/script/src/runtime.rs index c23eaefb0..830e250e9 100644 --- a/crates/script/src/runtime.rs +++ b/crates/script/src/runtime.rs @@ -427,6 +427,8 @@ fn native_file(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult check_access(s, &path, true).map(|_| json!(true)), "read" | "readBinary" => { let p = check_access(s, &path, false)?; let bytes = s.services.read_file(&p.to_string_lossy()).map_err(|e| format!("Unable to read {path}: {e}"))?; diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs index d0884dbcb..c7cd8730c 100644 --- a/crates/script/src/tests.rs +++ b/crates/script/src/tests.rs @@ -700,6 +700,56 @@ fn save_frame_to_png_writes_behind_the_file_gate() { let _ = std::fs::remove_dir_all(&dir); } +/// A script that runs the export commands itself (`app.run`, `__exec`) meets the same file-write +/// preference as the wrappers; with the preference off nothing is created or replaced. +#[test] +fn export_commands_run_directly_by_a_script_respect_the_file_gate() { + let dir = std::env::temp_dir().join(format!("ec-direct-export-{}", std::process::id())); + // A leftover dir from an interrupted run (same pid) must not feed stale absence checks. + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let png = dir.join("frame.png"); + let tpl = dir.join("card.ectemplate"); + let kept = dir.join("kept.png"); + std::fs::write(&kept, b"original").unwrap(); + let mut s = session(); + s.execute("comp.new", json!({"name": "Card", "width": 64, "height": 64, "duration": 1})).unwrap(); + s.execute("layer.newText", json!({"text": "Hi"})).unwrap(); + ok(&mut s, "var c = app.project.activeItem; c.layer(1).property('ADBE Transform Group').property('ADBE Opacity').addToMotionGraphicsTemplate(c);"); + let q = |p: &std::path::Path| serde_json::to_string(&p.to_string_lossy()).unwrap(); + let comp = s.active_comp_id().unwrap().0; + for (code, target) in [ + (format!("app.run('comp.saveFrameAs', {{comp: {comp}, path: {}}})", q(&png)), &png), + (format!("__exec('comp.saveFrameAs', JSON.stringify({{comp: {comp}, path: {}}}))", q(&png)), &png), + (format!("app.run('essential.exportTemplate', {{comp: {comp}, path: {}}})", q(&tpl)), &tpl), + (format!("app.run('comp.saveFrameAs', {{comp: {comp}, path: {}}})", q(&kept)), &kept), + ] { + let o = run_code(&mut s, &code, "direct.jsx"); + let e = o.error.unwrap_or_else(|| panic!("`{code}` was not refused")); + assert!(e.message.contains("Allow Scripts to Write Files"), "{e:?}"); + if target == &kept { + assert_eq!(std::fs::read(&kept).unwrap(), b"original"); + } else { + assert!(!target.exists(), "{} was written", target.display()); + } + } + // The wrappers check the gate without touching the file: an existing one is left as it was. + let o = run_code(&mut s, &format!("app.project.activeItem.saveFrameToPng(0, new File({}))", q(&kept)), "wrap.jsx"); + assert!(o.error.is_some()); + assert_eq!(std::fs::read(&kept).unwrap(), b"original"); + let o = run_code(&mut s, &format!("app.project.activeItem.exportAsMotionGraphicsTemplate(true, {})", q(&tpl)), "wrap.jsx"); + assert!(o.error.is_some()); + assert!(!tpl.exists()); + // The user, from the UI, is not a script: the same command writes. + s.execute("comp.saveFrameAs", json!({"comp": comp, "path": png.to_string_lossy()})).unwrap(); + assert!(png.exists()); + // With the preference on, the script's direct call writes too. + s.prefs.scripting.allow_scripts_write_files = true; + ok(&mut s, &format!("app.run('essential.exportTemplate', {{comp: {comp}, path: {}}})", q(&tpl))); + assert!(std::fs::metadata(&tpl).unwrap().len() > 100); + let _ = std::fs::remove_dir_all(&dir); +} + /// #1246: with Warn User When Executing Files on, a script that asserts `confirmed` itself still /// gets the question; only the user's answer opens the file. #[test] From 006dcc962debf258dc661c7b923076b5e705e694 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Wed, 11 Nov 2026 14:31:36 +0100 Subject: [PATCH 14/15] #1615: Auto caps CPU frames in flight from free memory after a job switches to the CPU (#3458) A GPU project whose Render Queue job measured the CPU faster (#3335) rendered every frame of a batch at once on the CPU, one per thread, with no memory cap: on a 16-thread machine at about 1.4 GB a frame the app ran out of RAM and exited without an error (0.23.1, Windows). While a job renders on the CPU after a GPU frame measured its working memory, CPU frames in flight now fit half the memory the system has free at that size per frame, 1 to one per thread, settled once per job and logged. The probe's timed CPU frames use the same cap. EFFECTCRAFT_CPU_INFLIGHT still overrides it; Software Rendering projects, with no measurement, are unchanged. The host reads the free memory through the new JobOptions::free_memory. --- crates/export/src/lib.rs | 5 ++ crates/export/src/pipeline.rs | 131 +++++++++++++++++++++++++++++++++- crates/host/src/lib.rs | 6 ++ docs/preferences.md | 11 +-- 4 files changed, 147 insertions(+), 6 deletions(-) diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 21b7501fa..48cb07ac4 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -122,6 +122,11 @@ pub struct JobOptions<'a> { /// Settings ▸ Memory & CPU ▸ GPU Frames Rendered at Once: 1 to 3, or `None` for Auto (from /// the device memory a frame measured, #3335). `EFFECTCRAFT_GPU_INFLIGHT` overrides it. pub gpu_frames: Option, + /// Reads the bytes of memory the system can hand out now (`None`: unknown), for Auto's CPU + /// frames in flight (#1615): read at most once per job, when a job renders on the CPU after + /// a GPU frame measured a frame's working memory. `None`: CPU frames are capped only by + /// `EFFECTCRAFT_CPU_INFLIGHT`. + pub free_memory: Option<&'a (dyn Fn() -> Option + Sync)>, } /// The layer caches of a Render Queue run (see [`JobOptions::caches`]): one per compositor, diff --git a/crates/export/src/pipeline.rs b/crates/export/src/pipeline.rs index 922b1b4ea..d447e0980 100644 --- a/crates/export/src/pipeline.rs +++ b/crates/export/src/pipeline.rs @@ -137,6 +137,17 @@ pub(crate) fn cpu_inflight(var: Option<&str>) -> Option { } } +/// Auto's CPU frames in flight (#1615): as many frames as half of the `free` bytes the system +/// can hand out hold at `frame` bytes each, 1 to `threads`. `frame` is a GPU frame's measured +/// working memory: both compositors keep 16-byte RGBA pixels (`[f32; 4]`, `Rgba32Float`), and +/// the other half is headroom for the layer caches, decoded footage, the encoder and the +/// estimate being low. `None` (not capped) when either is unknown or `frame` is 0. +pub(crate) fn auto_cpu_frames(frame: Option, free: Option, threads: usize) -> Option { + let (frame, free) = (frame.filter(|&f| f > 0)?, free?); + let n = free / 2 / frame; + Some(usize::try_from(n).unwrap_or(usize::MAX).clamp(1, threads.max(1))) +} + /// The frames-in-flight caps of a batch: `gpu` (#879) for the GPU's memory, `cpu` (#1615) for /// the process's. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] @@ -253,6 +264,9 @@ pub(crate) struct Cx<'a> { gpu_frames: Mutex>, /// A frame has rendered on the GPU compositor: its memory can be measured. gpu_rendered: std::sync::atomic::AtomicBool, + /// Auto's CPU frames in flight once settled ([`Cx::auto_cpu_frames`]; `Some(None)`: not + /// capped). + cpu_frames: Mutex>>, /// (output frame, seconds) of every rendered frame (Plus Per Frame Info logs). pub frame_times: Option>>, /// Files written to an overflow folder. @@ -337,6 +351,7 @@ impl<'a> Cx<'a> { choice: Mutex::new(None), gpu_frames: Mutex::new(None), gpu_rendered: Default::default(), + cpu_frames: Mutex::new(None), frame_times, overflowed: Mutex::new(vec![]), written: Mutex::new(vec![]), @@ -555,9 +570,11 @@ impl<'a> Cx<'a> { let timed = |on: crate::JobRenderer, frames: &[u64]| { let warm = render(first, on); // The GPU's frames in flight settle with its warm-up frame (Auto measures it). + // The CPU's frames are held to what the job would render at once on it, from the + // GPU frames just measured (#1615): uncapped, they could exhaust RAM here already. let caps = match on { crate::JobRenderer::Gpu => Inflight { gpu: Some(self.gpu_inflight()), cpu: cpu_cap }, - crate::JobRenderer::Cpu => Inflight { gpu: None, cpu: cpu_cap }, + crate::JobRenderer::Cpu => Inflight { gpu: None, cpu: cpu_cap.or_else(|| self.auto_cpu_frames()) }, }; let t0 = Instant::now(); let gpu_live = || on == crate::JobRenderer::Cpu || self.job.accel.is_some_and(|a| a.available()); @@ -660,7 +677,48 @@ impl<'a> Cx<'a> { /// The GPU and CPU frames-in-flight caps every frame loop shares (#1615). fn inflight(&self) -> Inflight { - Inflight { gpu: self.gpu_cap(), cpu: cpu_inflight(std::env::var(CPU_INFLIGHT_ENV).ok().as_deref()) } + Inflight { gpu: self.gpu_cap(), cpu: self.cpu_cap() } + } + + /// CPU frames in flight (#1615): `EFFECTCRAFT_CPU_INFLIGHT`, else Auto's + /// ([`Cx::auto_cpu_frames`]) while the job renders on the CPU (it chose the CPU's + /// compositor, or the GPU stopped for good). `None`: not capped. + fn cpu_cap(&self) -> Option { + if let Some(n) = cpu_inflight(std::env::var(CPU_INFLIGHT_ENV).ok().as_deref()) { + return Some(n); + } + let on_cpu = self.backend() == crate::JobRenderer::Cpu || self.job.accel.is_some_and(|a| !a.available()); + if on_cpu { self.auto_cpu_frames() } else { None } + } + + /// Auto's CPU frames in flight ([`auto_cpu_frames`]), settled once per job from the working + /// memory a GPU frame measured and the memory the system has free + /// ([`crate::JobOptions::free_memory`]). `None` (not capped, as before #1615) until a GPU + /// frame has rendered, and in a job that never renders one (a Software Rendering project): + /// there is no per-frame estimate then. + fn auto_cpu_frames(&self) -> Option { + if let Some(n) = *self.cpu_frames.lock().unwrap_or_else(std::sync::PoisonError::into_inner) { + return n; + } + if !self.gpu_rendered.load(std::sync::atomic::Ordering::Relaxed) { + return None; + } + // Read outside the lock: the allocator report walks every allocation, and reading the + // system's memory can take a second (PowerShell on Windows). + let frame = self.job.accel.and_then(|a| a.frame_memory()).map(|m| m.frame); + let free = self.job.options.free_memory.and_then(|read| read()); + let n = auto_cpu_frames(frame, free, rayon::current_num_threads()); + let mut slot = self.cpu_frames.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + *slot.get_or_insert_with(|| { + let mib = |b: Option| b.map_or_else(|| "unknown".to_string(), |b| format!("{} MiB", b >> 20)); + let (frame, free) = (mib(frame), mib(free)); + let label = &self.job.options.label; + match n { + Some(n) => log::info!(target: "effectcraft_export", "{label}: {n} CPU frame(s) at once (Auto: {frame} per frame on the GPU, {free} free)"), + None => log::info!(target: "effectcraft_export", "{label}: CPU frames not capped (Auto: {frame} per frame on the GPU, {free} free)"), + } + n + }) } /// Output frame `i`: sampled (fields, pulldown), at the colour depth, cropped and resized. @@ -1285,6 +1343,75 @@ mod tests { assert_eq!(describe_choice(&cx.choice().unwrap()), "Software Rendering (measured on the first frames: GPU 117.0 ms/frame, CPU 68.0 ms/frame)"); } + #[test] + fn auto_cpu_frames_fit_half_the_free_memory() { + // #1615: 16 threads at 1378 MiB a frame (a GTX 1050 Ti user's comp) with 10 GiB free + // render 3 frames at once, not 16. + assert_eq!(auto_cpu_frames(Some(1378 << 20), Some(10 << 30), 16), Some(3)); + assert_eq!(auto_cpu_frames(Some(1 << 28), Some(64 << 30), 16), Some(16), "at most one per thread"); + assert_eq!(auto_cpu_frames(Some(4 << 30), Some(2 << 30), 16), Some(1), "at least one"); + assert_eq!(auto_cpu_frames(Some(1 << 30), Some(0), 8), Some(1)); + assert_eq!(auto_cpu_frames(Some(1 << 20), Some(u64::MAX), 0), Some(1)); + // Unknown: not capped, as before. + assert_eq!(auto_cpu_frames(None, Some(8 << 30), 16), None); + assert_eq!(auto_cpu_frames(Some(1 << 30), None, 16), None); + assert_eq!(auto_cpu_frames(Some(0), Some(8 << 30), 16), None); + } + + #[test] + fn a_job_that_chose_the_cpu_after_measuring_the_gpu_caps_its_frames() { + // #1615: a GPU project that measured the CPU faster rendered one frame per thread at + // once, so a heavy comp ran out of RAM (an exit without an error, 0.23.1 on Windows). + // Its frames now fit half the free memory at the GPU's measured size per frame. + use effectcraft_project::render_queue::{OutputFormat, OutputModule, RenderSettings}; + if std::env::var_os(CPU_INFLIGHT_ENV).is_some() || std::env::var_os(RENDERER_ENV).is_some() { + return; + } + let (settings, output) = (RenderSettings::default(), OutputModule::for_format(OutputFormat::PngSequence)); + let mut p = Project::default(); + p.settings.gpu_acceleration = true; + let comp = Comp::new(16, 8, effectcraft_time::FrameRate::FPS_25, Tick::from_seconds_f64(1.0)); + let cid = p.add_item("Comp", effectcraft_color::Label::Sandstone, None, effectcraft_project::ItemKind::Comp(comp.into())); + let accel = MemAccel(effectcraft_render::FrameMemory { budget: 3 << 30, peak: 2 << 30, frame: 1 << 30, reserve: 0 }); + let reads = AtomicUsize::new(0); + let free = || { + reads.fetch_add(1, Ordering::SeqCst); + Some(6u64 << 30) + }; + let job = Job { + project: &p, + footage: &effectcraft_render::NoFootage, + expr: None, + accel: Some(&accel), + comp: cid, + settings: &settings, + output: &output, + path: "/c/f_[#####].png", + sink: None, + nested_switches: true, + options: crate::JobOptions { free_memory: Some(&free), ..Default::default() }, + }; + let pool = rayon::ThreadPoolBuilder::new().num_threads(16).build().unwrap(); + pool.install(|| { + let cx = Cx::new(&job); + assert_eq!(cx.inflight().cpu, None, "nothing measured yet"); + let c = cx.comp().unwrap(); + cx.frame_on(c, 0, crate::JobRenderer::Gpu); + assert_eq!(cx.inflight().cpu, None, "the GPU renders: its own cap holds"); + assert_eq!(reads.load(Ordering::SeqCst), 0, "the free memory is read only for CPU frames"); + cx.decide(Choice { renderer: crate::JobRenderer::Cpu, gpu_ms: Some(1417.3), cpu_ms: Some(655.8), why: "measured on the first frames" }); + assert_eq!(cx.inflight(), Inflight { gpu: None, cpu: Some(3) }, "3 GiB of 6 GiB free at 1 GiB a frame"); + assert_eq!(cx.inflight().cpu, Some(3)); + assert_eq!(reads.load(Ordering::SeqCst), 1, "settled once per job"); + }); + // Without a free-memory reading the job renders uncapped, as before. + let job = Job { options: Default::default(), ..job }; + let cx = Cx::new(&job); + cx.frame_on(cx.comp().unwrap(), 0, crate::JobRenderer::Gpu); + cx.decide(Choice { renderer: crate::JobRenderer::Cpu, gpu_ms: Some(2.0), cpu_ms: Some(1.0), why: "measured on the first frames" }); + assert_eq!(cx.inflight().cpu, None); + } + #[test] fn cpu_inflight_is_off_unless_a_positive_number() { assert_eq!(cpu_inflight(None), None); diff --git a/crates/host/src/lib.rs b/crates/host/src/lib.rs index 3d1384a90..0233f834c 100644 --- a/crates/host/src/lib.rs +++ b/crates/host/src/lib.rs @@ -65,6 +65,7 @@ impl Exporter for FileExporter { caches: job.caches.map(|c| effectcraft_export::JobCaches { gpu: &c.gpu, cpu: &c.cpu }), renderer: job.renderer, gpu_frames: job.gpu_frames, + free_memory: Some(&free_memory), }, nested_switches: job.nested_switches, }; @@ -87,6 +88,11 @@ impl Exporter for FileExporter { } } +/// The memory the system can hand out now, for an export's Auto CPU frames in flight (#1615). +fn free_memory() -> Option { + effectcraft_engine::sysinfo::memory().map(|m| m.available) +} + /// A new session with media, import, expressions, scripting and export enabled. pub fn session() -> Session { let mut s = Session { diff --git a/docs/preferences.md b/docs/preferences.md index 86c4599b3..c35d2c793 100644 --- a/docs/preferences.md +++ b/docs/preferences.md @@ -100,10 +100,13 @@ Commands (CLI, MCP, control channel): one; `EFFECTCRAFT_GPU_MEMCAP_MB` sets it), keeping room for the upload cache to fill. It renders one frame at a time where the memory can't be measured (Metal, WebGPU, GL, or the cap turned off) and once frames have had to wait for memory. The render log names the count. - `EFFECTCRAFT_GPU_INFLIGHT` overrides it. CPU renders have no such setting: they keep one - frame per thread in flight, so their memory grows with the core count. Set - `EFFECTCRAFT_CPU_INFLIGHT` to a whole number of frames (at least 1) to cap the frames in - flight of every Render Queue render and export, `effectcraft-cli render` included + `EFFECTCRAFT_GPU_INFLIGHT` overrides it. CPU renders have no such setting. A job on GPU + Acceleration that renders on the CPU (it measured the CPU faster, or the GPU stopped) fits + its CPU frames in flight into half the memory the system has free, at the size a GPU frame + measured, from 1 to one per thread; the app log names the count. Other CPU renders (Software + Rendering projects) keep one frame per thread in flight, so their memory grows with the core + count. Set `EFFECTCRAFT_CPU_INFLIGHT` to a whole number of frames (at least 1) to cap the + frames in flight of every Render Queue render and export, `effectcraft-cli render` included ([#1615](https://github.com/storytold/effectcraft/issues/1615)); previews don't read it. One exception: while a job on GPU Acceleration times both compositors on its first frames, it renders them under the cap but keeps them all (up to 12) until it has chosen. That timing From 230b3920de18d6da6cbeadfcfc1646ac0ca67724 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Wed, 11 Nov 2026 16:57:18 +0100 Subject: [PATCH 15/15] Release: EffectCraft v0.24.0 (#3494) --- Cargo.lock | 78 +++++++++++++++++++++++++++--------------------------- Cargo.toml | 2 +- 2 files changed, 40 insertions(+), 40 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 37233ef58..a69346b9f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1420,7 +1420,7 @@ dependencies = [ [[package]] name = "effectcraft" -version = "0.23.1" +version = "0.24.0" dependencies = [ "chrono", "cpal", @@ -1448,7 +1448,7 @@ dependencies = [ [[package]] name = "effectcraft-aep" -version = "0.23.1" +version = "0.24.0" dependencies = [ "effectcraft-color", "effectcraft-effects", @@ -1462,7 +1462,7 @@ dependencies = [ [[package]] name = "effectcraft-automation" -version = "0.23.1" +version = "0.24.0" dependencies = [ "effectcraft-engine", "effectcraft-host", @@ -1476,14 +1476,14 @@ dependencies = [ [[package]] name = "effectcraft-av1enc" -version = "0.23.1" +version = "0.24.0" dependencies = [ "filmcraft-av1", ] [[package]] name = "effectcraft-cli" -version = "0.23.1" +version = "0.24.0" dependencies = [ "cpu-time", "effectcraft-automation", @@ -1502,7 +1502,7 @@ dependencies = [ [[package]] name = "effectcraft-color" -version = "0.23.1" +version = "0.24.0" dependencies = [ "proptest", "serde", @@ -1510,7 +1510,7 @@ dependencies = [ [[package]] name = "effectcraft-effects" -version = "0.23.1" +version = "0.24.0" dependencies = [ "chrono", "effectcraft-color", @@ -1528,7 +1528,7 @@ dependencies = [ [[package]] name = "effectcraft-engine" -version = "0.23.1" +version = "0.24.0" dependencies = [ "chrono", "craft-docpath", @@ -1567,7 +1567,7 @@ dependencies = [ [[package]] name = "effectcraft-export" -version = "0.23.1" +version = "0.24.0" dependencies = [ "effectcraft-av1enc", "effectcraft-color", @@ -1598,7 +1598,7 @@ dependencies = [ [[package]] name = "effectcraft-expr" -version = "0.23.1" +version = "0.24.0" dependencies = [ "boa_engine", "effectcraft-color", @@ -1615,7 +1615,7 @@ dependencies = [ [[package]] name = "effectcraft-geom" -version = "0.23.1" +version = "0.24.0" dependencies = [ "proptest", "serde", @@ -1623,7 +1623,7 @@ dependencies = [ [[package]] name = "effectcraft-gpu" -version = "0.23.1" +version = "0.24.0" dependencies = [ "bytemuck", "effectcraft-color", @@ -1646,7 +1646,7 @@ dependencies = [ [[package]] name = "effectcraft-helper-host" -version = "0.23.1" +version = "0.24.0" dependencies = [ "bytemuck", "effectcraft-effects", @@ -1657,14 +1657,14 @@ dependencies = [ [[package]] name = "effectcraft-hevcenc" -version = "0.23.1" +version = "0.24.0" dependencies = [ "filmcraft-hevc", ] [[package]] name = "effectcraft-host" -version = "0.23.1" +version = "0.24.0" dependencies = [ "effectcraft-engine", "effectcraft-export", @@ -1691,7 +1691,7 @@ dependencies = [ [[package]] name = "effectcraft-interchange" -version = "0.23.1" +version = "0.24.0" dependencies = [ "effectcraft-color", "effectcraft-keyframe", @@ -1710,7 +1710,7 @@ dependencies = [ [[package]] name = "effectcraft-keyframe" -version = "0.23.1" +version = "0.24.0" dependencies = [ "effectcraft-color", "effectcraft-geom", @@ -1722,7 +1722,7 @@ dependencies = [ [[package]] name = "effectcraft-lottie" -version = "0.23.1" +version = "0.24.0" dependencies = [ "craft-docpath", "effectcraft-color", @@ -1740,7 +1740,7 @@ dependencies = [ [[package]] name = "effectcraft-media" -version = "0.23.1" +version = "0.24.0" dependencies = [ "craft-docpath", "effectcraft-color", @@ -1773,7 +1773,7 @@ dependencies = [ [[package]] name = "effectcraft-model" -version = "0.23.1" +version = "0.24.0" dependencies = [ "effectcraft-geom", "image", @@ -1786,14 +1786,14 @@ dependencies = [ [[package]] name = "effectcraft-opusenc" -version = "0.23.1" +version = "0.24.0" dependencies = [ "filmcraft-opus", ] [[package]] name = "effectcraft-path" -version = "0.23.1" +version = "0.24.0" dependencies = [ "effectcraft-geom", "effectcraft-keyframe", @@ -1805,7 +1805,7 @@ dependencies = [ [[package]] name = "effectcraft-pdf" -version = "0.23.1" +version = "0.24.0" dependencies = [ "craft-pdf", "effectcraft-raster", @@ -1821,7 +1821,7 @@ dependencies = [ [[package]] name = "effectcraft-plugin" -version = "0.23.1" +version = "0.24.0" dependencies = [ "effectcraft-effects", "effectcraft-keyframe", @@ -1833,7 +1833,7 @@ dependencies = [ [[package]] name = "effectcraft-project" -version = "0.23.1" +version = "0.24.0" dependencies = [ "craft-ecproj-format", "effectcraft-color", @@ -1847,14 +1847,14 @@ dependencies = [ [[package]] name = "effectcraft-psd" -version = "0.23.1" +version = "0.24.0" dependencies = [ "miniz_oxide 0.9.1", ] [[package]] name = "effectcraft-raster" -version = "0.23.1" +version = "0.24.0" dependencies = [ "effectcraft-color", "effectcraft-geom", @@ -1864,7 +1864,7 @@ dependencies = [ [[package]] name = "effectcraft-render" -version = "0.23.1" +version = "0.24.0" dependencies = [ "effectcraft-color", "effectcraft-effects", @@ -1887,7 +1887,7 @@ dependencies = [ [[package]] name = "effectcraft-sam2" -version = "0.23.1" +version = "0.24.0" dependencies = [ "bytemuck", "candle-core", @@ -1902,7 +1902,7 @@ dependencies = [ [[package]] name = "effectcraft-script" -version = "0.23.1" +version = "0.24.0" dependencies = [ "boa_engine", "effectcraft-engine", @@ -1920,7 +1920,7 @@ dependencies = [ [[package]] name = "effectcraft-segment" -version = "0.23.1" +version = "0.24.0" dependencies = [ "miniz_oxide 0.9.1", "ndarray", @@ -1929,7 +1929,7 @@ dependencies = [ [[package]] name = "effectcraft-svg" -version = "0.23.1" +version = "0.24.0" dependencies = [ "effectcraft-geom", "effectcraft-path", @@ -1941,7 +1941,7 @@ dependencies = [ [[package]] name = "effectcraft-text" -version = "0.23.1" +version = "0.24.0" dependencies = [ "effectcraft-keyframe", "harfrust", @@ -1953,7 +1953,7 @@ dependencies = [ [[package]] name = "effectcraft-time" -version = "0.23.1" +version = "0.24.0" dependencies = [ "proptest", "serde", @@ -1961,7 +1961,7 @@ dependencies = [ [[package]] name = "effectcraft-track" -version = "0.23.1" +version = "0.24.0" dependencies = [ "effectcraft-raster", "effectcraft-segment", @@ -1973,7 +1973,7 @@ dependencies = [ [[package]] name = "effectcraft-ui-egui" -version = "0.23.1" +version = "0.24.0" dependencies = [ "bytemuck", "chrono", @@ -2014,11 +2014,11 @@ dependencies = [ [[package]] name = "effectcraft-vp9enc" -version = "0.23.1" +version = "0.24.0" [[package]] name = "effectcraft-web" -version = "0.23.1" +version = "0.24.0" dependencies = [ "effectcraft-engine", "effectcraft-export", @@ -7835,7 +7835,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.23.1" +version = "0.24.0" dependencies = [ "chrono", "pkg-config", diff --git a/Cargo.toml b/Cargo.toml index 6f96cb012..18a92e409 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/*"] [workspace.package] -version = "0.23.1" +version = "0.24.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95"