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

This commit is contained in:
bflatastic
2026-10-30 07:02:50 +07:00
24 changed files with 664 additions and 97 deletions
+15
View File
@@ -97,6 +97,21 @@ fn bubbles_rise() {
assert!(y0 >= 0.0 && y1 >= 0.0 && (y1 < y0 || y0 < 8.0), "{y0} -> {y1}");
}
/// #2024: bubbles near the layer's edges show its edge pixels, not the pale colour that stands
/// in for transparent ones.
#[test]
fn bubbles_near_edges_show_the_layer() {
let blue = Image::filled(40, 40, [0.0, 0.0, 1.0, 1.0]);
let pale = [0, 1].map(|reflection| {
let vals = [("bubbleAmount", num(200.0)), ("bubbleSize", num(3.0)), ("reflectionType", Value::Enum(reflection))];
let a = run("ec.sim.ccbubbles", &vals, blue.clone(), 0.3);
assert!(a.data.iter().any(|p| p[3] > 0.5), "bubbles drawn");
// Blue plus the white highlight keeps blue − red = alpha; the pale colour leaves 0.2 · alpha.
a.data.iter().filter(|p| (p[2] - p[0] - p[3]).abs() > 1e-4).count()
});
assert_eq!(pale, [0, 0], "pale bubble pixels (liquid, metal)");
}
#[test]
fn drizzle_ripples() {
let a = run("ec.sim.ccdrizzle", &[], ramp(48, 48), 0.6);
+1 -1
View File
@@ -182,7 +182,7 @@ pub(super) fn bubbles(ctx: &EffectCtx, mut b: Buf) -> Buf {
// Liquid refracts (inverted, magnified), metal reflects (wide-angle mirror).
let k = if metal { 1.6 } else { -0.7 };
let nz = (1.0 - d2).max(0.0).sqrt();
let p = src.sample_bilinear((q.sx + dx * q.r * k) as f64, (q.sy + dy * q.r * k) as f64);
let p = src.sample_bilinear_clamped((q.sx + dx * q.r * k) as f64, (q.sy + dy * q.r * k) as f64);
let (c, sa) = unpremul(p);
let mut c = if sa <= 0.0 { [0.6, 0.7, 0.8] } else { c };
let mut a = cov;
+16 -16
View File
@@ -433,26 +433,26 @@ const GOLDEN: &[(&str, u64)] = &[
("ec.sim.ccstarburst/1/32bpc/t1.5", 0x5c39f8b1bd0e9e43),
("ec.sim.ccstarburst/1/32bpc/t0.8", 0xdf20aa9dcf4b3e22),
("ec.sim.ccstarburst/1/32bpc/t2.2", 0x4fa441737bfcb64d),
("ec.sim.ccbubbles/0/8bpc/t0.5", 0xdcd187e75eeb2fae),
("ec.sim.ccbubbles/0/8bpc/t1.5", 0x835ca088747736ed),
("ec.sim.ccbubbles/0/8bpc/t0.8", 0x5cbb1a6fbb77b41d),
("ec.sim.ccbubbles/0/8bpc/t2.2", 0x3b168068f5bfa533),
("ec.sim.ccbubbles/0/32bpc/t0.5", 0x524b84d14edd77e2),
("ec.sim.ccbubbles/0/32bpc/t1.5", 0xfd7a17f0d40f2a27),
("ec.sim.ccbubbles/0/32bpc/t0.8", 0x70dd2b2b4ff0f7ce),
("ec.sim.ccbubbles/0/32bpc/t2.2", 0xae21dd5cc2c2c07c),
("ec.sim.ccbubbles/0/8bpc/t0.5", 0x6cb63df0c062fdf4),
("ec.sim.ccbubbles/0/8bpc/t1.5", 0x5bb1350114234ac3),
("ec.sim.ccbubbles/0/8bpc/t0.8", 0xaa824d49f12c4156),
("ec.sim.ccbubbles/0/8bpc/t2.2", 0x3528577358c4fc90),
("ec.sim.ccbubbles/0/32bpc/t0.5", 0xdcd8e71222c13a9a),
("ec.sim.ccbubbles/0/32bpc/t1.5", 0xd09565e124d8f5db),
("ec.sim.ccbubbles/0/32bpc/t0.8", 0x91242f8e1fd0ad84),
("ec.sim.ccbubbles/0/32bpc/t2.2", 0x1f9782131e62149d),
("ec.sim.ccbubbles/0/8bpc/half/t0.5", 0x931b4ca707604220),
("ec.sim.ccbubbles/0/8bpc/half/t1.5", 0x21c25d4555abebe9),
("ec.sim.ccbubbles/0/8bpc/half/t0.8", 0x7e05972683359632),
("ec.sim.ccbubbles/0/8bpc/half/t2.2", 0xadc4f7219dab4921),
("ec.sim.ccbubbles/1/8bpc/t0.5", 0x89d27a2fe2011067),
("ec.sim.ccbubbles/1/8bpc/t1.5", 0x2bbfee897bf5bf6e),
("ec.sim.ccbubbles/1/8bpc/t0.8", 0x26cc704e7b06fffc),
("ec.sim.ccbubbles/1/8bpc/t2.2", 0x959d331545d96479),
("ec.sim.ccbubbles/1/32bpc/t0.5", 0xaf486cebfcbf4641),
("ec.sim.ccbubbles/1/32bpc/t1.5", 0xe150e9fdb558036f),
("ec.sim.ccbubbles/1/32bpc/t0.8", 0x6395f3967cb24284),
("ec.sim.ccbubbles/1/32bpc/t2.2", 0x68a55894d86ffb9e),
("ec.sim.ccbubbles/1/8bpc/t0.5", 0x2f5415c679ff0980),
("ec.sim.ccbubbles/1/8bpc/t1.5", 0xfcfc354dcd22e6d0),
("ec.sim.ccbubbles/1/8bpc/t0.8", 0xdf90fd57c217019c),
("ec.sim.ccbubbles/1/8bpc/t2.2", 0xd365c88c12219802),
("ec.sim.ccbubbles/1/32bpc/t0.5", 0x50f20497f40bb46d),
("ec.sim.ccbubbles/1/32bpc/t1.5", 0x74760599ffba220a),
("ec.sim.ccbubbles/1/32bpc/t0.8", 0x028de52900ed8618),
("ec.sim.ccbubbles/1/32bpc/t2.2", 0xaf0e21d9a7f625fe),
("ec.sim.ccdrizzle/0/8bpc/t0.5", 0x2bdfbc17d92b65b5),
("ec.sim.ccdrizzle/0/8bpc/t1.5", 0x8c354c46f99e35d3),
("ec.sim.ccdrizzle/0/8bpc/t0.8", 0x891f331ba47ee557),
+10
View File
@@ -1189,6 +1189,15 @@ fn toggle_hold(s: &mut Session, _: &Value) -> Result<Value> {
})
}
fn set_muted(s: &mut Session, p: &Value) -> Result<Value> {
let muted = b_p(p, "muted").unwrap_or(true);
let label = if muted { "Mute Keyframes" } else { "Unmute Keyframes" };
edit_keys(s, label, None, move |keys, i, _| {
keys[i].muted = muted;
false
})
}
/// A number or a per-dimension array of numbers.
fn nums_p(p: &Value, k: &str, scale: f64) -> Option<Vec<f64>> {
match p.get(k)? {
@@ -1381,6 +1390,7 @@ pub fn specs() -> Vec<CommandSpec> {
&json!({"which": "out"})
)),
cmd!("keys.toggleHold", "Toggle Hold Keyframe", ["Animation"], Some("Cmd+Alt+H"), "{}", has_keys, toggle_hold),
cmd!("keys.setMuted", "Mute Keyframes", ["Animation"], None, "{muted?: bool (default true)}", has_keys, set_muted),
cmd!(
"keys.interpolation",
"Keyframe Interpolation...",
+2
View File
@@ -1036,6 +1036,8 @@ Animation
---
Add Keyframe | anim.addKeyframe
Toggle Hold Keyframe | keys.toggleHold
Mute Keyframes | keys.setMuted
Unmute Keyframes | keys.setMuted {"muted":false}
Keyframe Interpolation... | keys.interpolation
Keyframe Velocity... | keys.velocity
Keyframe Assistant
+62
View File
@@ -1483,6 +1483,68 @@ fn key_selection_toggles_and_skips_locked_layers_and_hold_restores_bezier() {
assert!(s.state.selected_keys.is_empty());
}
#[test]
fn muting_keys_skips_them_in_the_timeline_value_and_undoes() {
let mut s = comp();
let a = solid(&mut s, "#ff0000");
for (t, v) in [(0.0, 0.0), (1.0, 100.0), (2.0, 300.0)] {
s.execute("prop.addKey", json!({"layer": a, "path": "transform/opacity", "time": t, "value": v})).unwrap();
}
let value_at = |s: &Session, t: f64| layer(s, a).props.prop("transform/opacity").unwrap().value_at(Tick::from_seconds_f64(t)).as_f64();
let select = |s: &mut Session| s.execute("keys.select", json!({"keys": [{"layer": a, "path": "transform/opacity", "time": 1.0}]})).unwrap();
select(&mut s);
let before = value_at(&s, 0.5);
s.execute("keys.setMuted", json!({"muted": true})).unwrap();
assert!(layer(&s, a).props.prop("transform/opacity").unwrap().keys[1].muted);
// The muted key no longer bends the 0 → 300 segment.
assert!((value_at(&s, 0.5) - 75.0).abs() < 1e-6);
assert!((value_at(&s, 1.5) - 225.0).abs() < 1e-6);
s.undo();
assert!(!layer(&s, a).props.prop("transform/opacity").unwrap().keys[1].muted);
assert!((value_at(&s, 0.5) - before).abs() < 1e-6);
// The same command unmutes.
select(&mut s);
s.execute("keys.setMuted", json!({"muted": true})).unwrap();
s.execute("keys.setMuted", json!({"muted": false})).unwrap();
assert!(!layer(&s, a).props.prop("transform/opacity").unwrap().keys[1].muted);
}
/// Both Animation-menu rows mute and unmute through `Session::execute`, and undo/redo work.
#[test]
fn mute_and_unmute_keyframes_menu_rows_work_and_undo() {
fn rows(nodes: &[crate::menus::MenuNode], out: &mut Vec<(String, Value)>) {
for n in nodes {
match n {
crate::menus::MenuNode::Item(e) if e.command == "keys.setMuted" => out.push((e.label.clone(), e.params.clone())),
crate::menus::MenuNode::Submenu { children, .. } => rows(children, out),
_ => {}
}
}
}
let mut menu = vec![];
rows(&crate::menus::parse(crate::menus::TREE, false).unwrap(), &mut menu);
assert_eq!(menu.iter().map(|(l, _)| l.as_str()).collect::<Vec<_>>(), ["Mute Keyframes", "Unmute Keyframes"], "{menu:?}");
let mut s = comp();
let a = solid(&mut s, "#ff0000");
for (t, v) in [(0.0, 0.0), (1.0, 100.0)] {
s.execute("prop.addKey", json!({"layer": a, "path": "transform/opacity", "time": t, "value": v})).unwrap();
}
let muted = |s: &Session| layer(s, a).props.prop("transform/opacity").unwrap().keys[1].muted;
s.execute("keys.select", json!({"keys": [{"layer": a, "path": "transform/opacity", "time": 1.0}]})).unwrap();
let run = |s: &mut Session, params: &Value| {
s.execute("keys.setMuted", if params.is_null() { json!({}) } else { params.clone() }).unwrap();
};
run(&mut s, &menu[0].1);
assert!(muted(&s));
s.undo();
assert!(!muted(&s));
s.redo();
assert!(muted(&s));
run(&mut s, &menu[1].1);
assert!(!muted(&s));
}
#[test]
fn transform_drags_apply_their_whole_offset_to_the_keys_they_started_with() {
let mut s = comp();
+83 -14
View File
@@ -31,6 +31,7 @@ function __begin(t, v, c, l, path, uid, idx, fd, ti, tt, sv) {
__seed = 0;
__timeless = false;
__projectInfo = null;
__layersTrim();
}
function __finish(r) {
@@ -44,7 +45,7 @@ function __finish(r) {
function __result(r) {
r = __v(r);
if (r !== null && typeof r === 'object' && r.__isLayer) return r.index;
if (typeof r === 'function' && r.__isLayer) return r.index;
if (r !== null && typeof r === 'object' && r.__isTextStyle) return r.__out();
// `[temp, temp]` where temp is a property (pick-whip 1D → 2D): read the values here, while
// host requests can still be answered and the script re-run.
@@ -488,7 +489,7 @@ function __ancestor(c, l, path, countUp) {
if (typeof n !== 'number' || !(n >= 1) || !isFinite(n)) __err('propertyGroup(' + countUp + '): the count must be a number of 1 or more');
n = Math.floor(n);
var info = __h(__ANCESTOR, c, l, path, n);
if (info === 'layer') return new Layer(c, l);
if (info === 'layer') return __layer(c, l);
if (info === null) __err('propertyGroup(' + n + ') goes above the layer');
return __mkGroup(c, l, info);
}
@@ -518,10 +519,10 @@ Comp.prototype = {
return __err('Composition "' + this.name + '" has no active camera');
},
layer: function (a, rel) {
if (a !== null && typeof a === 'object' && a.__isLayer) return this.layer(a.index + (rel || 0));
if (typeof a === 'function' && a.__isLayer) return this.layer(a.index + (rel || 0));
var id = __h(__LAYER, this.__c, a);
if (id === null) __err(typeof a === 'string' ? 'Layer named "' + a + '" does not exist' : 'Layer index ' + a + ' out of range');
return new Layer(this.__c, id);
return __layer(this.__c, id);
},
toString: function () { return '[object Comp]'; },
};
@@ -596,7 +597,55 @@ var thisProject = {
// Layer -----------------------------------------------------------------------------------------
function Layer(c, l) { this.__c = c; this.__l = l; this.__i = null; }
// A layer is a property group too, as in After Effects: `thisLayer("transform")`, another layer's
// `("effects")("Slider Control")`, `thisLayer(1)` (what the pick whip writes with Compact English
// off). Callable like a group (`__mkGroup`), with the Layer methods.
function Layer(c, l) {
var f = function (k) { return f.__group(k); };
delete f.name;
delete f.length;
Object.setPrototypeOf(f, Layer.prototype);
// Kept across evaluations (`__layer`), so an expression can't change which layer it is, delete
// a field or leave its own on it. Its info is private to `__info`, which reads it again in an
// evaluation whose `__S` isn't the one it was read in (every evaluation has a new `__S`), so
// freezing the layer can't keep its info either.
var info = null, read = null;
Object.defineProperty(f, '__c', { value: c });
Object.defineProperty(f, '__l', { value: l });
Object.defineProperty(f, '__info', {
get: function () {
if (read !== __S) { info = __h(__LINFO, c, l); read = __S; }
return info;
},
});
Object.preventExtensions(f);
return f;
}
// Layer `l` of comp `c`, made once per runtime: a callable Layer is dearer to make than a plain
// object, so it is kept, and its info (`__info`) is read again in each evaluation.
// The kept layers are out of an expression's reach (their maps have no prototype, so a change to
// `Object.prototype` doesn't reach them either), and bounded like the runtime's scripts: once
// `max` are kept, the layers after them aren't, and the next evaluation starts over
// (`__layersTrim`, so a layer stays the same object for a whole evaluation).
var __layer, __layersTrim;
(function () {
var layers = Object.create(null), kept = 0, max = 4096;
__layer = function (c, l) {
// By comp, then layer: a `c + ':' + l` key costs more to build than the lookup saves.
var m = layers[c], f = m && m[l];
if (f) return f;
f = new Layer(c, l);
if (kept < max) {
(m || (layers[c] = Object.create(null)))[l] = f;
kept++;
}
return f;
};
__layersTrim = function () {
if (kept >= max) { layers = Object.create(null); kept = 0; }
};
})();
function __m3(m, p) {
var x = p[0] || 0, y = p[1] || 0;
@@ -620,7 +669,6 @@ function __m4(m, p, vec) {
Layer.prototype = {
__isLayer: true,
get __info() { return this.__i || (this.__i = __h(__LINFO, this.__c, this.__l)); },
get name() { return this.__info[0]; },
get index() { return this.__info[1]; },
get inPoint() { return this.__info[2]; },
@@ -632,7 +680,7 @@ Layer.prototype = {
get parent() {
var p = this.__info[7];
if (p === null) __err('Layer "' + this.name + '" has no parent');
return new Layer(this.__c, p);
return __layer(this.__c, p);
},
get hasVideo() { return this.__info[8]; },
get enabled() { return this.__info[11]; },
@@ -804,7 +852,7 @@ Prop.prototype = {
propertyGroup: function (n) { return __ancestor(this.__c, this.__l, this.__p, n); },
__wrap: function (v) {
var kind = this.__info[2];
if (kind === 'layer') return v === null ? null : new Layer(this.__c, v);
if (kind === 'layer') return v === null ? null : __layer(this.__c, v);
if (kind === 'path' && v !== null) return new __Path(v[0], v[1], v[2], v[3]);
return v;
},
@@ -834,7 +882,7 @@ Prop.prototype = {
for (var i = 1; i < times.length; i++) if (Math.abs(times[i] - t) < Math.abs(times[best] - t)) best = i;
return this.key(best + 1);
},
__seedIndex: function () { return this.__self ? __S.idx : new Layer(this.__c, this.__l).index; },
__seedIndex: function () { return this.__self ? __S.idx : __layer(this.__c, this.__l).index; },
wiggle: function (freq, amp, octaves, ampMult, t) {
if (octaves === undefined) octaves = 1;
if (ampMult === undefined) ampMult = 0.5;
@@ -903,8 +951,9 @@ var __propHandler = {
var v = t.value;
if (v === null || v === undefined) return undefined;
var x = v[k];
// Property groups are callable proxies: hand them over as they are, not bound like methods (#809).
if (x?.__isGroup) return x;
// Property groups and layers are callable: hand them over as they are, not bound like methods
// (#809).
if (typeof x === 'function' && (x.__isGroup || x.__isLayer)) return x;
return typeof x === 'function' ? x.bind(v) : x;
},
};
@@ -1082,18 +1131,18 @@ Markers.prototype = {
// ---------------------------------------------------------------------------------------------
// Unqualified names: thisLayer / thisProperty attributes and the `this*` objects. They're
// accessors on the global object; assigning one (`var width = 10`) shadows it for the current
// evaluation only.
// evaluation only. They can't be deleted or redefined, so an expression can't take one away from
// the evaluations after it.
function __global(name, get) {
Object.defineProperty(globalThis, name, {
configurable: true,
get: function () { return name in __S.vars ? __S.vars[name] : get(); },
set: function (v) { __S.vars[name] = v; },
});
}
__global('thisComp', function () { return __S.comp || (__S.comp = new Comp(__S.c)); });
__global('thisLayer', function () { return __S.layer || (__S.layer = new Layer(__S.c, __S.l)); });
__global('thisLayer', function () { return __S.layer || (__S.layer = __layer(__S.c, __S.l)); });
__global('thisProperty', function () { return __S.prop || (__S.prop = __mkProp(__S.c, __S.l, __S.path)); });
['name', 'index', 'inPoint', 'outPoint', 'startTime', 'width', 'height', 'hasParent', 'parent', 'hasVideo', 'enabled', 'active',
'transform', 'position', 'scale', 'rotation', 'opacity', 'anchorPoint', 'text', 'marker'].forEach(function (k) {
@@ -1226,3 +1275,23 @@ function nearestKey(t) { return thisProperty.nearestKey(t); }
delete globalThis.Proxy;
delete globalThis.Reflect;
})();
// ---------------------------------------------------------------------------------------------
// The runtime runs this file once and keeps it for the thread's later evaluations, so an
// expression can't change the object model or the internals: the prototypes are frozen, and the
// `__` globals, the constructors and `thisProject` can't be reassigned. The `__` state below is
// set again by `__begin` in each evaluation.
[Comp, Footage, Layer, Prop, Markers, __Path, __TextStyle].forEach(function (f) {
Object.freeze(f.prototype);
Object.freeze(f);
});
[__GroupProto, __groupHandler, __propHandler, thisProject].forEach(Object.freeze);
(function () {
var perEvaluation = { __S: true, __rc: true, __seed: true, __timeless: true, __projectInfo: true };
var model = { Comp: true, Footage: true, Layer: true, Prop: true, Markers: true, thisProject: true };
Object.getOwnPropertyNames(globalThis).forEach(function (k) {
if (!(k.slice(0, 2) === '__' && !perEvaluation[k]) && !model[k]) return;
if ('value' in Object.getOwnPropertyDescriptor(globalThis, k)) Object.defineProperty(globalThis, k, { writable: false, configurable: false });
});
})();
+95
View File
@@ -1099,6 +1099,8 @@ fn shadowing_layer_attribute_names_is_local() {
let f = fx();
assert_close!(f.num("var width = 3;\nwidth * 2", 0.0), 6.0);
assert_close!(f.num("width", 0.0), 20.0, 1e-9);
assert_close!(f.num("function width() { return 4; }\nwidth() * 2", 0.0), 8.0);
assert_close!(f.num("width", 0.0), 20.0, 1e-9);
}
#[test]
@@ -1493,4 +1495,97 @@ fn time_to_frames_preserves_coercion_and_small_durations() {
}
}
/// A layer is a property group too, as in After Effects: `thisLayer("transform")`, another
/// layer's `("effects")("Slider Control")` or `(1)`, as the pick whip writes them with Compact
/// English off (they failed: "not a callable function"). It is still a layer.
#[test]
fn layers_are_callable_property_groups() {
let f = fx();
assert_close!(f.num("thisLayer(\"transform\")(\"opacity\")", 0.0), 100.0);
assert_close!(f.num("thisComp.layer(\"A\")(\"effects\")(\"ec.control.slider\")(\"slider\")", 0.0), 42.0);
assert_close!(f.num("thisComp.layer(\"A\")(\"effects\")(\"Slider Control\")(1)", 0.0), 42.0);
assert_close!(f.num("thisComp.layer(1)(\"effects\")(1)(1)", 0.0), 42.0);
// Its own name, index and methods, and a layer as the result still reads as its index.
assert_close!(f.num("thisLayer.name == \"A\" ? thisComp.layer(\"B\").index : 0", 0.0), 2.0);
assert_close!(f.num("thisComp.layer(\"B\")", 0.0), 2.0);
assert_close!(f.num("thisComp.layer(thisComp.layer(\"B\"), -1).index", 0.0), 1.0);
assert!(f.err("thisLayer(\"nope\")").contains("no property"), "{}", f.err("thisLayer(\"nope\")"));
}
/// A layer is kept from one evaluation to the next (it is dear to make) but reads the project
/// afresh in each, and an expression's own fields on it don't reach the next one.
#[test]
fn a_kept_layer_reads_the_project_again() {
let mut f = fx();
let first = "thisComp.layer(\"B\").index * 10 + thisComp.layer(2).name.length";
assert_close!(f.num(first, 0.0), 21.0);
f.layer_mut(f.b).name = "Bee".into();
f.p.comp_mut(f.cid).unwrap().layers.swap(0, 1);
assert_close!(f.num("thisComp.layer(\"Bee\").index * 10 + thisComp.layer(2).name.length", 0.0), 11.0);
assert_close!(f.num("thisLayer.index", 0.0), 2.0);
let _ = f.ev(f.a, ROT, "thisLayer.mine = 5; 0", 0.0);
assert_close!(f.num("thisLayer.mine === undefined ? 1 : 0", 0.0), 1.0);
let _ = f.ev(f.a, ROT, "var L = thisLayer; L.__c = 'x'; L.__l = 99; L.__i = ['Z', 7]; L.__s = __S; 0", 0.0);
assert_close!(f.num("thisLayer.index * 10 + thisLayer.name.length", 0.0), 21.0);
let _ = f.ev(f.a, ROT, "var L = thisLayer; delete L.__c; delete L.__l; delete L.__i; delete L.__s; 0", 0.0);
assert_close!(f.num("thisLayer.index * 10 + thisLayer.name.length", 0.0), 21.0);
let _ = f.ev(f.a, ROT, "Object.freeze(thisLayer); 0", 0.0);
f.layer_mut(f.a).name = "Ay".into();
f.p.comp_mut(f.cid).unwrap().layers.swap(0, 1);
assert_close!(f.num("thisLayer.index * 10 + thisLayer.name.length", 0.0), 12.0);
}
/// The kept layers are bounded: past the bound a layer isn't kept, an evaluation that makes more
/// layers than are kept still sees one object for each layer it had kept, and the evaluations
/// after it, which start over, read the right layers.
#[test]
fn the_kept_layers_are_bounded() {
let f = fx();
let same = "(thisLayer === thisComp.layer(thisLayer.index) ? 1 : 0)";
let many =
format!("var c = thisLayer.__c;\nfor (var i = 0; i < 5000; i++) __layer(c, 100000 + i);\n{same} * 10 + (__layer(c, 1e6) === __layer(c, 1e6) ? 0 : 1)");
assert_close!(f.num(&many, 0.0), 11.0);
assert_close!(f.num(&format!("{same} * 100 + thisLayer.index * 10 + thisLayer.name.length"), 0.0), 111.0);
assert_close!(f.num(&format!("{same} * 100 + thisLayer.index * 10 + thisLayer.name.length"), 0.0), 111.0);
}
/// A change to `Object.prototype` doesn't reach the kept layers: a frozen object put there under
/// the comp's id, before any of its layers is kept, doesn't stop them being kept.
#[test]
fn the_kept_layers_dont_go_through_object_prototype() {
let f = fx();
let _ = f.ev(f.a, ROT, "try { Object.prototype[thisComp.__c] = Object.freeze({}); } catch (e) {} 0", 0.0);
assert_close!(f.num("thisLayer === thisComp.layer(thisLayer.index) ? 1 : 0", 0.0), 1.0);
}
/// The prelude is kept from one evaluation to the next, so an expression can't change its object
/// model or internals for the ones after it.
#[test]
fn an_expression_cant_change_the_prelude_for_the_next() {
let f = fx();
let read = "[thisComp.layer(1).index, thisLayer.index, thisLayer('transform').property('rotation').value, thisLayer.transform.opacity.value, thisProject.bitsPerChannel, comp(thisComp.name).numLayers, width]";
let before = f.ev(f.a, ROT, read, 0.5).unwrap();
let attacks = [
"Comp.prototype.layer = function () { return 0; }",
"Object.getPrototypeOf(thisLayer).index = 0",
"Object.getPrototypeOf(thisLayer('transform')).property = function () { return 0; }",
"Object.getPrototypeOf(thisLayer.transform.opacity).value = 0",
"Comp = function () {}",
"Prop = function () {}",
"thisProject = null",
"__h = function () { return null; }",
"__layer = function () { return null; }",
"__child = function () {}",
"delete __mkProp",
"delete thisLayer",
"delete thisComp",
"delete width",
"Object.defineProperty(globalThis, 'thisComp', { get: function () { return 0; } })",
];
for a in attacks {
let _ = f.ev(f.a, ROT, &format!("try {{ {a} }} catch (e) {{}} 0"), 0.0);
assert_eq!(f.ev(f.a, ROT, read, 0.5).unwrap(), before, "after `{a}`");
}
}
mod property_group;
+3
View File
@@ -72,6 +72,9 @@ fn property_group_reaches_the_layer() {
assert_close!(f.num("thisProperty.propertyGroup(2).name == 'A' ? 1 : 0", 0.0), 1.0);
assert_close!(f.num("thisProperty.propertyGroup(2).index", 0.0), 1.0);
assert_close!(f.num("thisProperty.propertyGroup(1).propertyGroup(1).name == 'A' ? 1 : 0", 0.0), 1.0);
// The layer it reaches is the same object as `thisLayer`.
assert_close!(f.num("thisLayer('transform').propertyGroup(1) === thisLayer ? 1 : 0", 0.0), 1.0);
assert_close!(f.num("thisProperty.propertyGroup(2) === thisLayer ? 1 : 0", 0.0), 1.0);
let s = staggered();
assert_close!(s.shape_num(SCALE_2, "thisProperty.propertyGroup(4).name == 'Shape' ? 1 : 0"), 1.0);
}
+1 -1
View File
@@ -180,7 +180,7 @@ fn fxm_bubble(at: u32, x: f32, y: f32) -> FxmShade {
}
let k = select(-0.7, 1.6, P.u[1].y != 0u);
let nz = sqrt(max(1.0 - d2, 0.0));
let s = fxm_unpremul(sample_bilinear(src, data[at + 4u] + dx * qr * k, data[at + 5u] + dy * qr * k));
let s = fxm_unpremul(sample_bilinear_clamped(src, data[at + 4u] + dx * qr * k, data[at + 5u] + dy * qr * k));
var c = s.xyz;
if (s.w <= 0.0) {
c = vec3<f32>(0.6, 0.7, 0.8);
+15 -11
View File
@@ -155,12 +155,7 @@ fn unsupported(project: &Project, comp: &Comp, l: &Layer, format: TimelineFormat
return Some(format!("blend mode {}", l.blend_mode.label()));
}
// Premiere's Basic Motion in the interchange formats has one (uniform) scale.
if let Some(s) = l.transform().and_then(|t| t.get("scale"))
&& std::iter::once(&s.value).chain(s.keys.iter().map(|k| &k.value)).any(|v| {
let a = v.as_vec3();
(a[0] - a[1]).abs() > 1e-9
})
{
if l.transform().and_then(|t| t.get("scale")).is_some_and(non_uniform_scale) {
return Some("non-uniform scale".into());
}
if l.preserve_transparency {
@@ -581,10 +576,7 @@ impl Exporter<'_> {
}
}
if let Some(p) = tr.get("scale") {
let uniform = std::iter::once(&p.value).chain(p.keys.iter().map(|k| &k.value)).all(|v| {
let a = v.as_vec3();
(a[0] - a[1]).abs() < 1e-9
});
let uniform = !non_uniform_scale(p);
m.params.insert("scale".into(), param_out(p, &to_clip, |v| fp::ParamValue::Float(v.as_vec3()[1])));
if !uniform {
m.params.insert("scale_width".into(), param_out(p, &to_clip, |v| fp::ParamValue::Float(v.as_vec3()[0])));
@@ -636,6 +628,16 @@ fn differs(v: &Value, def: [f64; 2]) -> bool {
(a[0] - def[0]).abs() > 1e-6 || (a[1] - def[1]).abs() > 1e-6
}
/// Whether a scale property is non-uniform at some point: active key values, or the static
/// value when every key is muted (muted keys are not exported, #1894).
fn non_uniform_scale(p: &Property) -> bool {
let non_uniform = |v: &Value| {
let a = v.as_vec3();
(a[0] - a[1]).abs() > 1e-9
};
if p.keys.iter().any(|k| !k.muted) { p.keys.iter().filter(|k| !k.muted).any(|k| non_uniform(&k.value)) } else { non_uniform(&p.value) }
}
/// The clip's `id` effect, or a new instance of it (None if the registry has no such effect).
fn effect_or_new(ti: &fp::TrackItem, id: &str) -> Option<fp::EffectInstance> {
ti.effect(id).cloned().or_else(|| fp::find_effect(id).map(|e| e.instance()))
@@ -651,7 +653,9 @@ fn set_effect(ti: &mut fp::TrackItem, e: fp::EffectInstance) {
/// A property → a FilmCraft parameter (layer-time keys → clip-time keys).
fn param_out(p: &Property, to_clip: &impl Fn(Tick) -> filmcraft_time::Tick, conv: impl Fn(&Value) -> fp::ParamValue) -> fp::Param {
let mut out = fp::Param::new(conv(&p.value));
for k in &p.keys {
// XML timelines have no mute state: leave muted keys out, as the Lottie exporter does, so
// the exported animation matches what the renderer shows (#1894).
for k in p.keys.iter().filter(|k| !k.muted) {
let mut fk = fp::Keyframe::new(to_clip(k.time), conv(&k.value));
fk.interp = match k.out_interp {
Interp::Linear => fp::Interpolation::Linear,
+53
View File
@@ -480,6 +480,59 @@ fn tr_orig(l: &Layer) -> &effectcraft_project::Property {
l.transform().unwrap().get("position").unwrap()
}
/// #1894: XML timelines have no mute state, so a muted key must not be written as a keyframe
/// (the export would otherwise animate through it).
#[test]
fn export_skips_muted_keyframes() {
let mut p = Project::default();
let a = footage_item(&mut p, "/media/a.mov");
let mut c = Comp::new(W, H, FrameRate::new(25, 1), t(4.0));
let mut l = effectcraft_project::build::layer(&mut p, &c, "clip", LayerSource::Footage { item: a }, (W, H), None);
{
let pos = l.transform_mut().unwrap().get_mut("position").unwrap();
pos.keys = vec![
Keyframe::new(t(0.0), Value::Vec3([100.0, 120.0, 0.0])),
Keyframe::new(t(1.0), Value::Vec3([500.0, 240.0, 0.0])),
Keyframe::new(t(2.0), Value::Vec3([300.0, 60.0, 0.0])),
];
pos.keys[1].muted = true;
}
c.layers.push(l);
let cid = comp_item(&mut p, "Main", c);
let out = export(&p, cid, &ExportOptions { format: TimelineFormat::Fcp7Xml, ..Default::default() }, &HashMap::new()).unwrap();
let (q, r) = import_doc(&out.bytes, "Main.xml", &mut probe);
let main = r.comps.iter().copied().find(|c| q.item(*c).unwrap().name == "Main").expect("Main");
let pos = layer(q.comp(main).unwrap(), "clip").transform().unwrap().get("position").unwrap();
assert_eq!(pos.keys.len(), 2, "the muted key was exported");
assert_eq!(pos.keys[0].value.as_vec3()[0], 100.0);
assert_eq!(pos.keys[1].value.as_vec3()[0], 300.0);
}
/// #1894: a muted non-uniform scale key must not force a uniform layer to pre-render; only the
/// active keys matter, and the static value when every key is muted.
#[test]
fn muted_non_uniform_scale_does_not_force_prerendering() {
let mut p = Project::default();
let a = footage_item(&mut p, "/media/a.mov");
let mut c = Comp::new(W, H, FrameRate::new(25, 1), t(2.0));
let mut l = effectcraft_project::build::layer(&mut p, &c, "clip", LayerSource::Footage { item: a }, (W, H), None);
{
let scale = l.transform_mut().unwrap().get_mut("scale").unwrap();
scale.keys = vec![Keyframe::new(t(0.0), Value::Vec3([100.0, 100.0, 100.0])), Keyframe::new(t(1.0), Value::Vec3([50.0, 200.0, 100.0]))];
scale.keys[1].muted = true;
}
let lid = l.id;
c.layers.push(l);
let cid = comp_item(&mut p, "Main", c);
assert_eq!(prerender_reason(&p, cid, lid, &ExportOptions::default()), None);
// With every key muted the static value drives evaluation, so a non-uniform static scale
// still takes the flattened path.
let scale = p.comp_mut(cid).unwrap().layer_mut(lid).unwrap().transform_mut().unwrap().get_mut("scale").unwrap();
scale.keys[0].muted = true;
scale.value = Value::Vec3([50.0, 200.0, 100.0]);
assert_eq!(prerender_reason(&p, cid, lid, &ExportOptions::default()).as_deref(), Some("non-uniform scale"));
}
#[test]
fn export_without_prerender_leaves_text_out() {
let (p, cid) = ae_project();
+51 -20
View File
@@ -15,6 +15,7 @@ pub mod value;
use effectcraft_time::{TICKS_PER_SECOND, Tick};
use serde::{Deserialize, Serialize};
use std::borrow::Borrow;
pub use text_doc::{BaselineOption, CharStyle, Composer, Direction, FigureStyle, FigureWidth, Kerning, LineJoin, OpenType, ParaStyle, StyleRun};
pub use value::{FeatherPoint, Gradient, Justify, ShapePath, TextDoc, Value};
@@ -89,12 +90,19 @@ pub struct Keyframe {
/// Keyframe color label: index into the label list (0 = None).
#[serde(default, skip_serializing_if = "is_zero")]
pub label: u8,
/// Muted keys stay in the list (and the UI) but evaluation skips them.
#[serde(default, skip_serializing_if = "is_false")]
pub muted: bool,
}
fn is_zero(v: &u8) -> bool {
*v == 0
}
fn is_false(v: &bool) -> bool {
!*v
}
fn yes() -> bool {
true
}
@@ -116,6 +124,7 @@ impl Keyframe {
spatial_continuous: false,
roving: false,
label: 0,
muted: false,
}
}
pub fn hold(mut self) -> Keyframe {
@@ -320,16 +329,16 @@ fn v3(v: &Value) -> [f64; 3] {
/// tangent is a sixth of the way from the previous key to the next, the in tangent its mirror, and
/// key times play no part. An end key uses its one neighbour, so the path leaves the first key
/// and enters the last one toward the neighbouring key instead of with no tangent.
fn auto_spatial(keys: &[Keyframe], i: usize) -> ([f64; 3], [f64; 3]) {
fn auto_spatial<K: Borrow<Keyframe>>(keys: &[K], i: usize) -> ([f64; 3], [f64; 3]) {
if i >= keys.len() || keys.len() < 2 {
return ([0.0; 3], [0.0; 3]);
}
let a = v3(&keys[i.saturating_sub(1)].value);
let c = v3(&keys[(i + 1).min(keys.len() - 1)].value);
let a = v3(&keys[i.saturating_sub(1)].borrow().value);
let c = v3(&keys[(i + 1).min(keys.len() - 1)].borrow().value);
let tout = [(c[0] - a[0]) / 6.0, (c[1] - a[1]) / 6.0, (c[2] - a[2]) / 6.0];
// A side facing a key at the same position gets no tangent, so the segment between two
// identical keys has zero length and the layer holds still (#1675).
let p = v3(&keys[i].value);
let p = v3(&keys[i].borrow().value);
let tin = if i > 0 && a == p { [0.0; 3] } else { [-tout[0], -tout[1], -tout[2]] };
let tout = if i + 1 < keys.len() && c == p { [0.0; 3] } else { tout };
(tin, tout)
@@ -337,7 +346,12 @@ fn auto_spatial(keys: &[Keyframe], i: usize) -> ([f64; 3], [f64; 3]) {
/// Effective spatial tangents of key `i` (auto or stored).
pub fn spatial_tangents(keys: &[Keyframe], i: usize) -> ([f64; 3], [f64; 3]) {
let Some(key) = keys.get(i) else { return ([0.0; 3], [0.0; 3]) };
tangents_of(keys, i)
}
/// [`spatial_tangents`] over stored keys or a filtered list of them (see [`evaluate`]).
fn tangents_of<K: Borrow<Keyframe>>(keys: &[K], i: usize) -> ([f64; 3], [f64; 3]) {
let Some(key) = keys.get(i).map(Borrow::borrow) else { return ([0.0; 3], [0.0; 3]) };
if key.spatial_auto { auto_spatial(keys, i) } else { (key.spatial_in, key.spatial_out) }
}
@@ -373,18 +387,18 @@ pub fn spatial_handles(keys: &[Keyframe], i: usize) -> ([f64; 3], [f64; 3]) {
}
/// Effective temporal ease of key `i` for dimension `d` (auto-Bezier keys get the neighbour slope).
fn effective_ease(keys: &[Keyframe], i: usize, d: usize, spatial: bool, out: bool) -> Ease {
let k = &keys[i];
fn effective_ease<K: Borrow<Keyframe>>(keys: &[K], i: usize, d: usize, spatial: bool, out: bool) -> Ease {
let k = keys[i].borrow();
let list = if out { &k.out_ease } else { &k.in_ease };
if k.auto_bezier && i > 0 && i + 1 < keys.len() {
let dt = secs(keys[i + 1].time) - secs(keys[i - 1].time);
let dt = secs(keys[i + 1].borrow().time) - secs(keys[i - 1].borrow().time);
let slope = if spatial {
let a = v3(&keys[i - 1].value);
let c = v3(&keys[i + 1].value);
let a = v3(&keys[i - 1].borrow().value);
let c = v3(&keys[i + 1].borrow().value);
((c[0] - a[0]).powi(2) + (c[1] - a[1]).powi(2) + (c[2] - a[2]).powi(2)).sqrt() / dt.max(1e-12)
} else {
let a = keys[i - 1].value.components();
let c = keys[i + 1].value.components();
let a = keys[i - 1].borrow().value.components();
let c = keys[i + 1].borrow().value.components();
(c.get(d).unwrap_or(&0.0) - a.get(d).unwrap_or(&0.0)) / dt.max(1e-12)
};
return Ease { speed: slope, influence: 1.0 / 6.0 };
@@ -508,23 +522,39 @@ pub fn time_reverse(keys: &mut [Keyframe]) {
}
/// Evaluate a keyframed property at `t`. `spatial` selects motion-path semantics for 2D/3D values.
/// Muted keys are skipped, so the active neighbours interpolate as if they were not there; `None`
/// means every key is muted (the property's static value applies).
pub fn evaluate(keys: &[Keyframe], t: Tick, spatial: bool) -> Option<Value> {
let first = keys.first()?;
if !keys.iter().any(|k| k.muted) {
return evaluate_active(keys, t, spatial);
}
// Muting is rare: skip the muted keys by reference rather than copying every key and value.
let active: Vec<&Keyframe> = keys.iter().filter(|k| !k.muted).collect();
evaluate_active(&active, t, spatial)
}
fn evaluate_active<K: Borrow<Keyframe>>(keys: &[K], t: Tick, spatial: bool) -> Option<Value> {
let first = keys.first()?.borrow();
if keys.len() == 1 || t <= first.time {
return Some(first.value.clone());
}
let last = keys.last()?;
let last = keys.last()?.borrow();
if t >= last.time {
return Some(last.value.clone());
}
let i = keys.partition_point(|k| k.time <= t) - 1;
Some(segment_value(keys, i, t, spatial))
let i = keys.partition_point(|k| k.borrow().time <= t) - 1;
Some(segment_value_of(keys, i, t, spatial))
}
/// Value inside segment `i` → `i+1` at time `t`.
pub fn segment_value(keys: &[Keyframe], i: usize, t: Tick, spatial: bool) -> Value {
let a = &keys[i];
let b = &keys[i + 1];
segment_value_of(keys, i, t, spatial)
}
/// [`segment_value`] over stored keys or a filtered list of them (see [`evaluate`]).
fn segment_value_of<K: Borrow<Keyframe>>(keys: &[K], i: usize, t: Tick, spatial: bool) -> Value {
let a = keys[i].borrow();
let b = keys[i + 1].borrow();
if a.out_interp == Interp::Hold || !a.value.interpolates() {
return a.value.clone();
}
@@ -539,8 +569,8 @@ pub fn segment_value(keys: &[Keyframe], i: usize, t: Tick, spatial: bool) -> Val
if is_spatial {
let p0 = v3(&a.value);
let p3 = v3(&b.value);
let (_, out_t) = spatial_tangents(keys, i);
let (in_t, _) = spatial_tangents(keys, i + 1);
let (_, out_t) = tangents_of(keys, i);
let (in_t, _) = tangents_of(keys, i + 1);
let seg = SpatialSeg::new([p0, [p0[0] + out_t[0], p0[1] + out_t[1], p0[2] + out_t[2]], [p3[0] + in_t[0], p3[1] + in_t[1], p3[2] + in_t[2]], p3]);
let len = seg.length();
let f = if lin_out && lin_in || len <= 1e-12 {
@@ -617,6 +647,7 @@ pub fn set_key(keys: &mut Vec<Keyframe>, key: Keyframe) -> usize {
k.spatial_continuous = old.spatial_continuous;
k.roving = old.roving;
k.label = old.label;
k.muted = old.muted;
keys[i] = k;
i
}
+56
View File
@@ -395,3 +395,59 @@ proptest::proptest! {
proptest::prop_assert!((-1e-9..=10.0 + 1e-9).contains(&v));
}
}
#[test]
fn muted_keys_are_skipped_by_evaluation() {
let mut keys = vec![Keyframe::new(s(0.0), Value::Scalar(0.0)), Keyframe::new(s(1.0), Value::Scalar(100.0)), Keyframe::new(s(2.0), Value::Scalar(300.0))];
let at = |keys: &[Keyframe], t: f64| evaluate(keys, s(t), false).unwrap().as_f64();
assert!((at(&keys, 0.5) - 50.0).abs() < 1e-9);
assert!((at(&keys, 1.5) - 200.0).abs() < 1e-9);
keys[1].muted = true;
// The 0 → 300 segment now spans 0 → 2 s.
assert!((at(&keys, 0.5) - 75.0).abs() < 1e-9);
assert!((at(&keys, 1.0) - 150.0).abs() < 1e-9);
assert!((at(&keys, 1.5) - 225.0).abs() < 1e-9);
for k in &mut keys {
k.muted = true;
}
assert!(evaluate(&keys, s(0.5), false).is_none());
}
#[test]
fn muted_key_automation_uses_the_active_neighbours() {
let mut keys = vec![
Keyframe::new(s(0.0), Value::Vec2([0.0, 0.0])),
Keyframe::new(s(1.0), Value::Vec2([50.0, 100.0])),
Keyframe::new(s(2.0), Value::Vec2([100.0, 0.0])),
];
for k in &mut keys {
k.auto_bezier = true;
k.in_interp = Interp::Bezier;
k.out_interp = Interp::Bezier;
}
let all = keys.clone();
let mut active = keys.clone();
active.remove(1);
keys[1].muted = true;
for t in [0.0, 0.25, 0.5, 1.0, 1.5, 1.75, 2.0] {
assert_eq!(evaluate(&keys, s(t), true), evaluate(&active, s(t), true), "{t}");
}
// The muted auto-Bezier key no longer bulges the motion path.
assert_ne!(evaluate(&keys, s(0.5), true), evaluate(&all, s(0.5), true));
}
#[test]
fn mute_round_trips_and_survives_value_replacement() {
let mut k = Keyframe::new(s(0.0), Value::Scalar(1.0));
let json = serde_json::to_value(&k).unwrap();
assert!(json.get("muted").is_none(), "false is the default and is skipped");
k.muted = true;
let json = serde_json::to_value(&k).unwrap();
assert_eq!(json["muted"], serde_json::json!(true));
let back: Keyframe = serde_json::from_value(json).unwrap();
assert!(back.muted);
let mut keys = vec![k];
set_key(&mut keys, Keyframe::new(s(0.0), Value::Scalar(2.0)));
assert!(keys[0].muted);
assert_eq!(keys[0].value, Value::Scalar(2.0));
}
+18 -7
View File
@@ -73,9 +73,22 @@ fn is_spatial(p: &Property) -> bool {
}
/// Export a property as a Lottie animatable property (`{a, k}` plus `x` for expressions).
/// `label` names the property in warnings.
/// `label` names the property in warnings. Muted keys are omitted: Lottie has no mute state, and
/// the remaining keys carry the animation the renderer shows.
pub(crate) fn export_prop(ex: &mut Ex, p: &Property, conv: &dyn Fn(&Value) -> Json, label: &str) -> Json {
let mut out = if p.keys.is_empty() { json!({"a": 0, "k": conv(&p.value)}) } else { json!({"a": 1, "k": export_keys(ex, p, conv)}) };
let muted = p.keys.iter().any(|k| k.muted);
if muted {
ex.warn(format!("{label}: muted keyframes are not exported"));
}
let active: Vec<Keyframe>;
let keys: &[Keyframe] = if muted {
active = p.keys.iter().filter(|k| !k.muted).cloned().collect();
&active
} else {
&p.keys
};
let mut out =
if keys.is_empty() { json!({"a": 0, "k": conv(&p.value)}) } else { json!({"a": 1, "k": export_keys(ex, keys, is_spatial(p), p.hold_only, conv)}) };
if let Some(e) = p.expr.as_ref().filter(|e| e.enabled && !e.text.trim().is_empty()) {
if ex.include_expressions {
out["x"] = json!(e.text);
@@ -86,20 +99,18 @@ pub(crate) fn export_prop(ex: &mut Ex, p: &Property, conv: &dyn Fn(&Value) -> Js
out
}
fn export_keys(ex: &Ex, p: &Property, conv: &dyn Fn(&Value) -> Json) -> Vec<Json> {
let keys = &p.keys;
let spatial = is_spatial(p);
fn export_keys(ex: &Ex, keys: &[Keyframe], spatial: bool, hold_only: bool, conv: &dyn Fn(&Value) -> Json) -> Vec<Json> {
let mut out = Vec::with_capacity(keys.len());
for (i, k) in keys.iter().enumerate() {
let mut o = json!({"t": r6(ex.tb.frame(k.time)), "s": wrap(conv(&k.value))});
if i + 1 == keys.len() {
if k.out_interp == Interp::Hold || p.hold_only {
if k.out_interp == Interp::Hold || hold_only {
o["h"] = json!(1);
}
out.push(o);
continue;
}
if k.out_interp == Interp::Hold || p.hold_only || !k.value.interpolates() {
if k.out_interp == Interp::Hold || hold_only || !k.value.interpolates() {
o["h"] = json!(1);
out.push(o);
continue;
+18
View File
@@ -878,3 +878,21 @@ fn external_images_are_local_or_skipped() {
assert_eq!(std::path::Path::new(&paths[0]), base.join("images").join("a.png"));
assert_eq!(r.warnings.iter().filter(|w| w.contains("is not imported")).count(), assets.len() - 1, "{:?}", r.warnings);
}
#[test]
fn muted_keys_are_omitted_from_the_export() {
let (mut p, cid) = setup();
let mut l = solid_layer(&mut p, cid, [0.0, 1.0, 0.0], 20, 20);
let mut b = Keyframe::new(s(1.0), Value::Scalar(100.0));
b.muted = true;
l.props.prop_mut("transform/opacity").unwrap().keys = vec![Keyframe::new(s(0.0), Value::Scalar(0.0)), b, Keyframe::new(s(2.0), Value::Scalar(300.0))];
push(&mut p, cid, l);
let (q, nc, warnings, json) = roundtrip(&p, cid, &ExportOptions::default());
assert!(warnings.iter().any(|w| w.contains("muted keyframes")), "{warnings:?}");
// Lottie has no mute state: the two active keys carry the 0 → 300 animation.
assert_eq!(json["layers"][0]["ks"]["o"]["k"].as_array().unwrap().len(), 2);
assert_eq!(json["layers"][0]["ks"]["o"]["k"][1]["s"], json!([300.0]));
let opacity_at = |p: &Project, cid: ItemId, t: f64| p.comp(cid).unwrap().layers[0].props.prop("transform/opacity").unwrap().value_at(s(t)).as_f64();
assert!((opacity_at(&p, cid, 1.0) - 150.0).abs() < 1e-6);
assert!((opacity_at(&q, nc, 1.0) - 150.0).abs() < 1e-6);
}
+48
View File
@@ -23,6 +23,7 @@ pub fn probe(path: impl AsRef<Path>) -> Result<Footage> {
let turn = header.as_deref().map_or(Orientation::NoTransforms, video_turn);
turn_footage(&mut f, turn);
correct_rate(&mut f, header.as_deref().and_then(video_rate));
trim_to_edits(&mut f, header.as_deref().and_then(video_shown));
return Ok(f);
}
let bytes = crate::read_regular_file(path).map_err(|e| MediaError::Io(format!("{}: {e}", path.display())))?;
@@ -107,6 +108,7 @@ pub fn probe_bytes(path: &str, bytes: Arc<[u8]>) -> Result<Footage> {
let name = Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
let turn = video_turn(&bytes);
let rate = video_rate(&bytes);
let shown = video_shown(&bytes);
// A WAV's cue points and XMP markers become the footage's markers.
let markers = crate::wav_markers::read(&bytes);
let src = filmcraft_codecs::open_bytes(&name, bytes)?;
@@ -114,9 +116,37 @@ pub fn probe_bytes(path: &str, bytes: Arc<[u8]>) -> Result<Footage> {
f.markers = markers;
turn_footage(&mut f, turn);
correct_rate(&mut f, rate);
trim_to_edits(&mut f, shown);
Ok(f)
}
/// A movie lasts as long as its video edit list shows (#2592): the reader reports the media
/// duration (`mdhd`), which for H.264 with B-frames runs past the last shown frame by the
/// encoder delay the edit list trims (75 frames at 25 fps read as 3.04 s). Never lengthens.
fn trim_to_edits(f: &mut Footage, shown: Option<Tick>) {
if f.kind == FootageKind::Video
&& let Some(shown) = shown.filter(|s| *s > Tick::ZERO && *s < f.duration)
{
f.duration = shown;
}
}
/// How long the video track's edit list shows its media (`elst` segments, movie timescale):
/// `None` without edits, or with an empty edit (a delayed start keeps the reader's duration).
fn video_shown(bytes: &[u8]) -> Option<Tick> {
let file = filmcraft_isobmff::open(bytes).ok()?;
let t = file.tracks.iter().find(|t| t.kind == filmcraft_isobmff::TrackKind::Video && !t.samples.is_empty())?;
edits_shown(&t.edits, file.timescale)
}
fn edits_shown(edits: &[filmcraft_isobmff::Edit], timescale: u32) -> Option<Tick> {
if edits.is_empty() || timescale == 0 || edits.iter().any(|e| e.media_time < 0) {
return None;
}
let units = edits.iter().try_fold(0u64, |acc, e| acc.checked_add(e.segment_duration))?;
Some(Tick::from_units(i64::try_from(units).ok()?, i64::from(timescale)))
}
/// A movie's frame rate as its frame durations average to ([`video_rate`]), where the reader's
/// own estimate misreads it.
fn correct_rate(f: &mut Footage, rate: Option<FrameRate>) {
@@ -322,6 +352,24 @@ fn averaged_rate(timescale: u32, durations: impl Iterator<Item = u32>) -> Option
mod tests {
use super::*;
/// #2592: an encoder-delayed H.264 track's edit list shows 75 frames at 25 fps (3 s at a
/// 1000 timescale), though its media runs a frame longer; empty edits and no edits leave it.
#[test]
fn the_edit_list_trims_a_movie_to_the_frames_it_shows() {
let edit = |segment_duration, media_time| filmcraft_isobmff::Edit { segment_duration, media_time, media_rate: 0x0001_0000 };
let shown = edits_shown(&[edit(3000, 1024)], 1000);
assert_eq!(shown, Some(Tick::from_units(3, 1)));
let mut f = Footage { kind: FootageKind::Video, duration: Tick::from_units(76, 25), ..Footage::default() };
trim_to_edits(&mut f, shown);
assert_eq!(f.duration, Tick::from_units(75, 25));
// Never longer than the reader's duration, and only for video.
trim_to_edits(&mut f, Some(Tick::from_units(4, 1)));
assert_eq!(f.duration, Tick::from_units(75, 25));
assert_eq!(edits_shown(&[edit(1000, -1), edit(3000, 0)], 1000), None);
assert_eq!(edits_shown(&[], 1000), None);
assert_eq!(edits_shown(&[edit(3000, 0)], 0), None);
}
/// Durations (in 1/`timescale` s) of `n` frames at `rate` whose times are rounded to the timescale.
fn rounded(rate: FrameRate, timescale: i64, n: i64) -> Vec<u32> {
let at = |k: i64| (2 * k * timescale * rate.den + rate.num) / (2 * rate.num);
+2 -1
View File
@@ -1706,7 +1706,8 @@ Object.defineProperty(FootageItem.prototype, "mainSource", {
return this.__mainSource;
},
});
FootageItem.prototype.replace = function (f) { __call("file.replaceFootage", { item: this.__id, path: __str(f) }); };
// replace() swaps in one file, even a numbered still (replaceWithSequence brings a run), as in After Effects.
FootageItem.prototype.replace = function (f) { __call("file.replaceFootage", { item: this.__id, path: __str(f), sequence: false }); };
FootageItem.prototype.replaceWithSequence = function (f, forceAlphabetical) {
__call("file.replaceFootage", { item: this.__id, path: __str(f), sequence: true, alphabetical: !!forceAlphabetical });
};
+5
View File
@@ -1062,6 +1062,11 @@ fn footage_proxies_sequences_and_conform_rate() {
let paths: Vec<String> =
s.project.items.values().filter_map(|i| if let ItemKind::Footage(f) = &i.kind { Some(f.path.replace('\\', "/")) } else { None }).collect();
assert!(paths.iter().any(|f| f.contains("take_")), "replaced with the take sequence: {paths:?}");
// replace() swaps in the one file, even a numbered still, as in After Effects.
ok(&mut s, &format!("app.project.item(1).replace(new File('{}'))", p("shot_0002.png")));
let kinds: Vec<_> =
s.project.items.values().filter_map(|i| if let ItemKind::Footage(f) = &i.kind { Some((f.kind, f.path.replace('\\', "/"))) } else { None }).collect();
assert!(kinds.iter().any(|(k, f)| *k == effectcraft_engine::project::FootageKind::Still && f.ends_with("shot_0002.png")), "{kinds:?}");
let _ = std::fs::remove_dir_all(&dir);
}
+22
View File
@@ -836,6 +836,8 @@ const SIMPLIFIED_CHINESE: &[(&str, &str, &str)] = &[
("layer.applyTextPreset", "Drop In Lines", "行落下"),
("anim.addKeyframe", "Add Keyframe", "添加关键帧"),
("keys.toggleHold", "Toggle Hold Keyframe", "切换定格关键帧"),
("keys.setMuted", "Mute Keyframes", "静音关键帧"),
("keys.setMuted", "Unmute Keyframes", "取消静音关键帧"),
("keys.interpolation", "Keyframe Interpolation...", "关键帧插值..."),
("keys.velocity", "Keyframe Velocity...", "关键帧速度..."),
("", "Keyframe Assistant", "关键帧辅助"),
@@ -1472,6 +1474,8 @@ const TRADITIONAL_CHINESE: &[(&str, &str, &str)] = &[
("layer.applyTextPreset", "Drop In Lines", "行落下"),
("anim.addKeyframe", "Add Keyframe", "新增關鍵影格"),
("keys.toggleHold", "Toggle Hold Keyframe", "切換定格關鍵影格"),
("keys.setMuted", "Mute Keyframes", "靜音關鍵影格"),
("keys.setMuted", "Unmute Keyframes", "取消靜音關鍵影格"),
("keys.interpolation", "Keyframe Interpolation...", "關鍵影格插補..."),
("keys.velocity", "Keyframe Velocity...", "關鍵影格速度..."),
("", "Keyframe Assistant", "關鍵影格輔助"),
@@ -2105,6 +2109,8 @@ const JAPANESE: &[(&str, &str, &str)] = &[
("layer.applyTextPreset", "Drop In Lines", "行をドロップイン"),
("anim.addKeyframe", "Add Keyframe", "キーフレームを追加"),
("keys.toggleHold", "Toggle Hold Keyframe", "停止したキーフレームの切り替え"),
("keys.setMuted", "Mute Keyframes", "キーフレームをミュート"),
("keys.setMuted", "Unmute Keyframes", "キーフレームのミュートを解除"),
("keys.interpolation", "Keyframe Interpolation...", "キーフレーム補間法..."),
("keys.velocity", "Keyframe Velocity...", "キーフレーム速度..."),
("", "Keyframe Assistant", "キーフレーム補助"),
@@ -2741,6 +2747,8 @@ const UKRAINIAN: &[(&str, &str, &str)] = &[
("layer.applyTextPreset", "Drop In Lines", "Поява рядків згори"),
("anim.addKeyframe", "Add Keyframe", "Додати ключовий кадр"),
("keys.toggleHold", "Toggle Hold Keyframe", "Перемкнути утримання ключового кадру"),
("keys.setMuted", "Mute Keyframes", "Заглушити ключові кадри"),
("keys.setMuted", "Unmute Keyframes", "Зняти заглушення ключових кадрів"),
("keys.interpolation", "Keyframe Interpolation...", "Інтерполяція ключових кадрів..."),
("keys.velocity", "Keyframe Velocity...", "Швидкість ключових кадрів..."),
("", "Keyframe Assistant", "Помічник ключових кадрів"),
@@ -3378,6 +3386,8 @@ const SPANISH: &[(&str, &str, &str)] = &[
("layer.applyTextPreset", "Drop In Lines", "Entrada de líneas cayendo"),
("anim.addKeyframe", "Add Keyframe", "Añadir fotograma clave"),
("keys.toggleHold", "Toggle Hold Keyframe", "Conmutar mantener fotograma clave"),
("keys.setMuted", "Mute Keyframes", "Silenciar fotogramas clave"),
("keys.setMuted", "Unmute Keyframes", "Reactivar fotogramas clave"),
("keys.interpolation", "Keyframe Interpolation...", "Interpolación de fotogramas clave..."),
("keys.velocity", "Keyframe Velocity...", "Velocidad de fotogramas clave..."),
("", "Keyframe Assistant", "Asistente de fotogramas clave"),
@@ -4013,6 +4023,8 @@ const PORTUGUESE: &[(&str, &str, &str)] = &[
("layer.applyTextPreset", "Drop In Lines", "Entrar com queda de linhas"),
("anim.addKeyframe", "Add Keyframe", "Adicionar quadro-chave"),
("keys.toggleHold", "Toggle Hold Keyframe", "Alternar quadro-chave de retenção"),
("keys.setMuted", "Mute Keyframes", "Silenciar quadros-chave"),
("keys.setMuted", "Unmute Keyframes", "Reativar quadros-chave"),
("keys.interpolation", "Keyframe Interpolation...", "Interpolação de quadro-chave..."),
("keys.velocity", "Keyframe Velocity...", "Velocidade do quadro-chave..."),
("", "Keyframe Assistant", "Assistente de quadro-chave"),
@@ -4655,6 +4667,8 @@ const RUSSIAN: &[(&str, &str, &str)] = &[
("layer.applyTextPreset", "Drop In Lines", "Появление строк сверху"),
("anim.addKeyframe", "Add Keyframe", "Добавить ключевой кадр"),
("keys.toggleHold", "Toggle Hold Keyframe", "Переключить фиксацию ключевого кадра"),
("keys.setMuted", "Mute Keyframes", "Отключить ключевые кадры"),
("keys.setMuted", "Unmute Keyframes", "Включить ключевые кадры"),
("keys.interpolation", "Keyframe Interpolation...", "Интерполяция ключевых кадров..."),
("keys.velocity", "Keyframe Velocity...", "Скорость ключевых кадров..."),
("", "Keyframe Assistant", "Помощник по ключевым кадрам"),
@@ -5290,6 +5304,8 @@ const FRENCH: &[(&str, &str, &str)] = &[
("layer.applyTextPreset", "Drop In Lines", "Chute des lignes depuis le haut"),
("anim.addKeyframe", "Add Keyframe", "Ajouter une image clé"),
("keys.toggleHold", "Toggle Hold Keyframe", "Activer/Désactiver l'image clé figée"),
("keys.setMuted", "Mute Keyframes", "Désactiver les images clés"),
("keys.setMuted", "Unmute Keyframes", "Réactiver les images clés"),
("keys.interpolation", "Keyframe Interpolation...", "Interpolation des images clés..."),
("keys.velocity", "Keyframe Velocity...", "Vitesse des images clés..."),
("", "Keyframe Assistant", "Assistant d'images clés"),
@@ -5925,6 +5941,8 @@ const GERMAN: &[(&str, &str, &str)] = &[
("layer.applyTextPreset", "Drop In Lines", "Zeilen fallen ein"),
("anim.addKeyframe", "Add Keyframe", "Keyframe hinzufügen"),
("keys.toggleHold", "Toggle Hold Keyframe", "Halte-Keyframe umschalten"),
("keys.setMuted", "Mute Keyframes", "Keyframes stummschalten"),
("keys.setMuted", "Unmute Keyframes", "Keyframes wieder aktivieren"),
("keys.interpolation", "Keyframe Interpolation...", "Keyframe-Interpolation..."),
("keys.velocity", "Keyframe Velocity...", "Keyframe-Geschwindigkeit..."),
("", "Keyframe Assistant", "Keyframe-Assistent"),
@@ -6561,6 +6579,8 @@ const CZECH: &[(&str, &str, &str)] = &[
("layer.applyTextPreset", "Drop In Lines", "Spustit řádky shora"),
("anim.addKeyframe", "Add Keyframe", "Přidat klíčový snímek"),
("keys.toggleHold", "Toggle Hold Keyframe", "Přepnout podržení klíčového snímku"),
("keys.setMuted", "Mute Keyframes", "Ztlumit klíčové snímky"),
("keys.setMuted", "Unmute Keyframes", "Obnovit klíčové snímky"),
("keys.interpolation", "Keyframe Interpolation...", "Interpolace klíčových snímků..."),
("keys.velocity", "Keyframe Velocity...", "Rychlost klíčového snímku..."),
("", "Keyframe Assistant", "Pomocník klíčových snímků"),
@@ -7192,6 +7212,8 @@ const GEORGIAN: &[(&str, &str, &str)] = &[
("layer.applyTextPreset", "Drop In Lines", "სტრიქონების ჩამოვარდნა"),
("anim.addKeyframe", "Add Keyframe", "საკვანძო კადრის დამატება"),
("keys.toggleHold", "Toggle Hold Keyframe", "ფიქსირებული საკვანძო კადრის გადართვა"),
("keys.setMuted", "Mute Keyframes", "საკვანძო კადრების დადუმება"),
("keys.setMuted", "Unmute Keyframes", "საკვანძო კადრების დადუმების მოხსნა"),
("keys.interpolation", "Keyframe Interpolation...", "საკვანძო კადრის ინტერპოლაცია..."),
("keys.velocity", "Keyframe Velocity...", "საკვანძო კადრის სიჩქარე..."),
("", "Keyframe Assistant", "საკვანძო კადრების ასისტენტი"),
+16 -4
View File
@@ -48,9 +48,21 @@ struct KeyPt {
t: f64,
v: f64,
sel: bool,
muted: bool,
handles: Vec<Handle>,
}
/// A graph key's fill and edge colours: muted keys stay visible and selectable, dimmed.
fn key_colors(t: &Tokens, edge: Color32, k: &KeyPt) -> (Color32, Color32) {
let fill = match (k.muted, k.sel) {
(true, true) => t.keyframe_selected.gamma_multiply(0.5),
(true, false) => Color32::from_gray(0x70),
(false, true) => t.keyframe_selected,
(false, false) => Color32::from_gray(0xe0),
};
(fill, if k.muted { edge.gamma_multiply(0.4) } else { edge })
}
struct Curve<'a> {
layer: &'a Layer,
prop: &'a Property,
@@ -153,7 +165,7 @@ fn curves<'a>(app: &EffectcraftApp, comp: &'a Comp, ectx: &EvalCtx, tm: TMap, pl
let dt = e.influence * dur * if out_side { 1.0 } else { -1.0 };
let (hv, show) = if speed { (e.speed.abs(), true) } else { (k.value.components().get(d).copied().unwrap_or(0.0) + e.speed * dt, !spatial) };
let bezier = if out_side { k.out_interp == Interp::Bezier } else { k.in_interp == Interp::Bezier };
if ks && show && (bezier || speed) {
if ks && !k.muted && show && (bezier || speed) {
handles.push(Handle { out: out_side, t: kt + dt, v: hv, speed: e.speed, dur });
}
}
@@ -162,7 +174,7 @@ fn curves<'a>(app: &EffectcraftApp, comp: &'a Comp, ectx: &EvalCtx, tm: TMap, pl
} else {
k.value.components().get(d).copied().unwrap_or(0.0)
};
keys.push(KeyPt { idx: i, time: k.time, t: kt, v, sel: ks, handles });
keys.push(KeyPt { idx: i, time: k.time, t: kt, v, sel: ks, muted: k.muted, handles });
}
out.push(Curve { layer: l, prop: pr, dim: d, color, pts, keys, editable: !spatial });
}
@@ -522,9 +534,9 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
}
}
// The key itself.
let col = if k.sel { t.keyframe_selected } else { Color32::from_gray(0xe0) };
let (col, edge) = key_colors(&t, c.color, k);
pc.rect_filled(Rect::from_center_size(kp, vec2(7.0, 7.0)), 0.0, col);
pc.rect_stroke(Rect::from_center_size(kp, vec2(7.0, 7.0)), 0.0, Stroke::new(1.0, c.color), StrokeKind::Outside);
pc.rect_stroke(Rect::from_center_size(kp, vec2(7.0, 7.0)), 0.0, Stroke::new(1.0, edge), StrokeKind::Outside);
let kr = Rect::from_center_size(kp, vec2(11.0, 11.0));
let kresp = ui.interact(kr, egui::Id::unique(("gkey", uid, d, k.idx)), Sense::click_and_drag());
app.auto.add(&format!("timeline.graph.key.{uid}.{d}.{}", k.idx), kr, &c.prop.name);
+44 -2
View File
@@ -451,6 +451,22 @@ fn queue_drop(app: &EffectcraftApp, ui: &egui::Ui, rect: Rect, p: &egui::Painter
}
}
/// Elapsed and estimated remaining seconds at UI time `now`. The job reports them as frames
/// finish; between reports they count on from the last one, as a clock does, so a slow frame
/// doesn't read as a stalled render (#2593).
fn live_times(ctx: &egui::Context, pr: &effectcraft_engine::render_queue::JobState, now: f64) -> (f64, Option<f64>) {
let report = (pr.current, pr.done, pr.item_elapsed.to_bits());
let since = ctx.data_mut(|d| {
let seen = d.get_temp_mut_or_insert_with(egui::Id::unique("rq-progress-seen"), || (report, now));
if seen.0 != report {
*seen = (report, now);
}
seen.1
});
let dt = if pr.finished { 0.0 } else { (now - since).max(0.0) };
(pr.item_elapsed + dt, pr.remaining.map(|r| (r - dt).max(0.0)))
}
/// The Current Render strip inside `top`: title, elapsed / remaining time, Render, Stop, Notify
/// and the progress bar.
fn current_render_strip(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, top: Rect, rq: &RqView, actions: &mut Vec<Action>) {
@@ -461,11 +477,15 @@ fn current_render_strip(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::P
let cur_item = progress.as_ref().and_then(|p| p.current).and_then(|id| queue.iter().find(|i| i.id == id));
let cur_name = cur_item.and_then(|i| app.session.project.item(i.comp)).map(|i| i.name.clone());
if let Some(pr) = &progress {
let rem = pr.remaining.map(hms).unwrap_or_else(|| "–".into());
let (elapsed, remaining) = live_times(ui.ctx(), pr, ui.input(|i| i.time));
if rendering {
ui.ctx().request_repaint_after(std::time::Duration::from_millis(500));
}
let rem = remaining.map(hms).unwrap_or_else(|| "–".into());
p.text(
pos2(top.max.x - 12.0, top.min.y + 16.0),
Align2::RIGHT_CENTER,
format!("Elapsed: {} Est. Remain: {rem}", hms(pr.item_elapsed)),
format!("Elapsed: {} Est. Remain: {rem}", hms(elapsed)),
Tokens::ui(11.5),
t.text_dim,
);
@@ -903,4 +923,26 @@ mod tests {
assert_eq!(super::utc_stamp(u64::MAX), "584554051223-11-09 07:00:15");
assert_eq!(super::hms(3725.4), "1:02:05");
}
/// #2593: between progress reports the times count on from the last report, and a new
/// report starts the count again.
#[test]
fn render_times_tick_between_reports() {
let ctx = egui::Context::default();
let mut pr = effectcraft_engine::render_queue::JobState {
current: Some(1),
done: 2,
total: 10,
item_elapsed: 20.0,
remaining: Some(80.0),
..Default::default()
};
assert_eq!(super::live_times(&ctx, &pr, 100.0), (20.0, Some(80.0)));
assert_eq!(super::live_times(&ctx, &pr, 103.0), (23.0, Some(77.0)));
assert_eq!(super::live_times(&ctx, &pr, 300.0).1, Some(0.0), "never below zero");
(pr.done, pr.item_elapsed, pr.remaining) = (3, 31.0, Some(72.0));
assert_eq!(super::live_times(&ctx, &pr, 301.0), (31.0, Some(72.0)));
pr.finished = true;
assert_eq!(super::live_times(&ctx, &pr, 400.0), (31.0, Some(72.0)), "a finished job stands still");
}
}
+3
View File
@@ -4111,6 +4111,7 @@ fn key_context_menu(app: &mut EffectcraftApp, g: &mut Rw, rc: &RowCtx<'_>, prop:
("Keyframe Interpolation…", "keys.interpolation", json!({})),
("Keyframe Velocity…", "keys.velocity", json!({})),
("Toggle Hold Keyframe", "keys.toggleHold", json!({})),
(if k.muted { "Unmute Keyframes" } else { "Mute Keyframes" }, "keys.setMuted", json!({"muted": !k.muted})),
("Rove Across Time", "keys.interpolation", json!({"roving": !k.roving})),
("-", "", json!({})),
("Easy Ease", "keys.easyEase", json!({})),
@@ -4203,6 +4204,8 @@ fn key_paint(g: &Rw, rc: &RowCtx<'_>, k: &effectcraft_engine::keyframe::Keyframe
None if ks => t.keyframe_selected,
None => t.keyframe,
};
// Muted keys stay visible and editable but no longer drive the value.
let kcol = if k.muted { kcol.gamma_multiply(0.35) } else { kcol };
if k.roving {
// Roving keys: a small dot (their time follows their neighbours).
gp.circle_filled(c, 2.5, kcol);
+25 -20
View File
@@ -360,31 +360,36 @@ pub(crate) fn motion_path(
}
let v = k.value.as_vec3();
let s = to_scr(v);
// Auto-Bezier keys show handles on a side the path leaves straight too (a two-key path's
// ends), to drag it into a curve; Linear keys (no tangents) show none.
let (tin, tout) = if k.spatial_auto {
effectcraft_engine::keyframe::spatial_handles(&pos.keys, i)
} else {
effectcraft_engine::keyframe::spatial_tangents(&pos.keys, i)
};
for (out, tg) in [(false, tin), (true, tout)] {
let has = (out && i + 1 < pos.keys.len()) || (!out && i > 0);
if !has || (tg[0] == 0.0 && tg[1] == 0.0) {
continue;
let selected = sel.contains(&KeyRef { layer: l.id, prop: pos.uid, time: k.time });
// A muted position key is off the motion path: no tangents, and a dimmed point.
let kc = if k.muted { col.gamma_multiply(if selected { 0.55 } else { 0.35 }) } else { col };
if !k.muted {
// Auto-Bezier keys show handles on a side the path leaves straight too (a two-key path's
// ends), to drag it into a curve; Linear keys (no tangents) show none.
let (tin, tout) = if k.spatial_auto {
effectcraft_engine::keyframe::spatial_handles(&pos.keys, i)
} else {
effectcraft_engine::keyframe::spatial_tangents(&pos.keys, i)
};
for (out, tg) in [(false, tin), (true, tout)] {
let has = (out && i + 1 < pos.keys.len()) || (!out && i > 0);
if !has || (tg[0] == 0.0 && tg[1] == 0.0) {
continue;
}
let h = to_scr([v[0] + tg[0], v[1] + tg[1], v[2] + tg[2]]);
painter.line_segment([s, h], Stroke::new(1.0, kc));
painter.circle_filled(h, 3.0, kc);
let side = if out { "out" } else { "in" };
app.auto.add(&format!("viewer.motionPath.{}.{i}.{side}", l.id.0), Rect::from_center_size(h, vec2(8.0, 8.0)), "Spatial tangent");
hits.push(KeyHit { layer: l.id, prop: pos.uid, time: k.time, pos: h, tangent: Some(out), value: v, p2c });
}
let h = to_scr([v[0] + tg[0], v[1] + tg[1], v[2] + tg[2]]);
painter.line_segment([s, h], Stroke::new(1.0, col));
painter.circle_filled(h, 3.0, col);
let side = if out { "out" } else { "in" };
app.auto.add(&format!("viewer.motionPath.{}.{i}.{side}", l.id.0), Rect::from_center_size(h, vec2(8.0, 8.0)), "Spatial tangent");
hits.push(KeyHit { layer: l.id, prop: pos.uid, time: k.time, pos: h, tangent: Some(out), value: v, p2c });
}
let r = Rect::from_center_size(s, vec2(7.0, 7.0));
if sel.contains(&KeyRef { layer: l.id, prop: pos.uid, time: k.time }) {
painter.rect_filled(r, 0.0, col);
if selected {
painter.rect_filled(r, 0.0, kc);
} else {
painter.rect_filled(r, 0.0, Color32::from_black_alpha(140));
painter.rect_stroke(r, 0.0, Stroke::new(1.0, col), StrokeKind::Inside);
painter.rect_stroke(r, 0.0, Stroke::new(1.0, kc), StrokeKind::Inside);
}
app.auto.add(&format!("viewer.motionPath.{}.{i}", l.id.0), r.expand(2.0), "Position keyframe");
hits.push(KeyHit { layer: l.id, prop: pos.uid, time: k.time, pos: s, tangent: None, value: v, p2c });