Merge pull request #1951 from ki2pixel/fix/1747-collapse-essential-overrides

Fix #1747: keep collapsed precomp instance overrides unclipped
This commit is contained in:
BFlat
2026-10-30 05:08:28 +07:00
committed by GitHub
45 changed files with 873 additions and 116 deletions
+1 -1
View File
@@ -207,7 +207,7 @@ pub(crate) fn source_frame(
t: Tick,
expr: Option<&dyn ExprHost>,
) -> Option<(Arc<Image>, [f64; 2])> {
let ctx = EvalCtx { project, comp_id: cid, comp, time: t, expr, footage: None };
let ctx = EvalCtx { project, comp_id: cid, comp, time: t, expr, footage: None, property_overrides: &[], media_overrides: &[] };
if let effectcraft_project::LayerSource::Footage { item } = &layer.source
&& let Some(effectcraft_project::ItemKind::Footage(f)) = project.item(*item).map(|i| &i.kind)
&& f.has_video
+50 -5
View File
@@ -136,7 +136,16 @@ fn warning(s: &mut Session, p: &Value) -> Result<Value> {
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?;
let g = layer.effects().and_then(|fx| fx.groups().find(|g| g.uid == uid)).ok_or_else(|| bad(cmd, "no such effect"))?;
let t = super::time_p(s, p, Some(comp));
let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None };
let ctx = effectcraft_render::EvalCtx {
project: &s.project,
comp_id: cid,
comp,
time: t,
expr: s.expr.as_deref(),
footage: None,
property_overrides: &[],
media_overrides: &[],
};
Ok(json!({"effect": uid, "warning": effectcraft_effects::warning(g, &mut |pr| ctx.value(layer, pr))}))
}
@@ -160,7 +169,16 @@ fn pick_color(s: &mut Session, p: &Value) -> Result<Value> {
.filter(|pr| pr.ui == effectcraft_project::ParamUi::Color)
.ok_or_else(|| bad(cmd, "not a colour parameter of this effect"))?;
let t = super::time_p(s, p, Some(comp));
let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None };
let ctx = effectcraft_render::EvalCtx {
project: &s.project,
comp_id: cid,
comp,
time: t,
expr: s.expr.as_deref(),
footage: None,
property_overrides: &[],
media_overrides: &[],
};
let mut r = effectcraft_render::Renderer::new(&s.project, &*s.footage, effectcraft_render::RenderOpts::default());
r.expr = s.expr.as_deref();
r.cache = Some(&s.layer_cache);
@@ -288,7 +306,16 @@ impl CryptoProbe {
let comp = r.project.comp(cid).ok_or("no composition")?;
let layer = comp.layer(lid).ok_or("no such layer")?;
let g = layer.effects().and_then(|fx| fx.groups().nth(pos)).ok_or("no such effect")?;
let ctx = effectcraft_render::EvalCtx { project: r.project, comp_id: cid, comp, time, expr: r.expr, footage: None };
let ctx = effectcraft_render::EvalCtx {
project: r.project,
comp_id: cid,
comp,
time,
expr: r.expr,
footage: None,
property_overrides: &[],
media_overrides: &[],
};
let params = effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(layer, pr));
let want = effectcraft_effects::CRYPTO_LAYERS.get(params.e("layer") as usize).copied().unwrap_or("CryptoAsset");
let aux = r.layer_aux(&ctx, layer).ok_or(NO_CRYPTO)?;
@@ -351,7 +378,16 @@ fn probe_key<'a>(s: &'a Session, p: &Value, cmd: &str) -> Result<(ProbeKey, Tick
let g = fx.groups().nth(pos).ok_or_else(|| bad(cmd, "no such effect"))?;
let prop = g.prop("selection").filter(|_| g.match_id == "ec.3d.cryptomatte").ok_or_else(|| bad(cmd, "not a Cryptomatte effect"))?;
let time = super::time_p(s, p, Some(comp));
let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time, expr: s.expr.as_deref(), footage: None };
let ctx = effectcraft_render::EvalCtx {
project: &s.project,
comp_id: cid,
comp,
time,
expr: s.expr.as_deref(),
footage: None,
property_overrides: &[],
media_overrides: &[],
};
let frame = probe_frame(&ctx, layer, pos);
let o = s.render_opts(Default::default());
let prefs = (o.nested_switches, o.draft_shadows);
@@ -503,7 +539,16 @@ fn crypto_hit(s: &Session, p: &Value, cmd: &str) -> Result<Hit> {
let Some(hash) = b.aux.index_at(ax, ay).and_then(|i| b.aux.crypto_id_at(&b.layer, i)) else { return Ok(Hit::Nothing) };
let (cid, lid, ..) = key.at;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time, expr: s.expr.as_deref(), footage: None };
let ctx = effectcraft_render::EvalCtx {
project: &s.project,
comp_id: cid,
comp,
time,
expr: s.expr.as_deref(),
footage: None,
property_overrides: &[],
media_overrides: &[],
};
let selection = effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(layer, pr)).s("selection").to_string();
let driven = prop.expr.as_ref().is_some_and(|e| e.enabled);
let (aux, layer_name) = (b.aux.clone(), b.layer.clone());
+10 -2
View File
@@ -362,8 +362,16 @@ fn aim_correction(rotation: effectcraft_geom::Mat4, aim: [f64; 3]) -> Option<eff
/// time.
pub(crate) fn parent_fixes(s: &Session, cid: ItemId, ids: &[LayerId], parent: Option<LayerId>) -> Vec<ParentFix> {
let Some(comp) = s.project.comp(cid) else { return vec![] };
let ectx =
effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: s.time(), expr: s.expr.as_deref(), footage: Some(s.footage.as_ref()) };
let ectx = effectcraft_render::EvalCtx {
project: &s.project,
comp_id: cid,
comp,
time: s.time(),
expr: s.expr.as_deref(),
footage: Some(s.footage.as_ref()),
property_overrides: &[],
media_overrides: &[],
};
let space = |id: Option<LayerId>| -> effectcraft_geom::Mat3 {
id.and_then(|i| comp.layer(i)).filter(|l| !l.is_3d()).map(|l| ectx.layer_to_comp(l).0).unwrap_or(effectcraft_geom::Mat3::IDENTITY)
};
+10 -1
View File
@@ -185,7 +185,16 @@ fn freeze(s: &mut Session, p: &Value, last: bool) -> Result<Value> {
return Err(bad("layer.freezeFrame", "freezing needs a footage or composition layer"));
}
// What each layer shows now (honouring an existing remap) — evaluated before the edit.
let ectx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: cti, expr: s.expr.as_deref(), footage: Some(s.footage.as_ref()) };
let ectx = effectcraft_render::EvalCtx {
project: &s.project,
comp_id: cid,
comp,
time: cti,
expr: s.expr.as_deref(),
footage: Some(s.footage.as_ref()),
property_overrides: &[],
media_overrides: &[],
};
let now: Vec<(effectcraft_project::LayerId, f64)> = comp
.layers
.iter()
+20 -3
View File
@@ -78,7 +78,16 @@ fn info(s: &Session, cid: ItemId, lid: LayerId, uid: Uid, cmd: &str) -> Result<P
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?;
let pr = layer.props.find(uid).ok_or_else(|| bad(cmd, "no such property"))?;
let t = s.time();
let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: Some(s.footage.as_ref()) };
let ctx = effectcraft_render::EvalCtx {
project: &s.project,
comp_id: cid,
comp,
time: t,
expr: s.expr.as_deref(),
footage: Some(s.footage.as_ref()),
property_overrides: &[],
media_overrides: &[],
};
let KV::Path(path) = ctx.value(layer, pr) else { return Err(bad(cmd, "that property is not a path")) };
if path.vertices.is_empty() {
return Err(bad(cmd, "the path has no vertices"));
@@ -147,8 +156,16 @@ fn pin_targets(s: &Session, p: &Value, cmd: &str) -> Result<Option<(ItemId, Vec<
};
let cid = pins.first().map(|x| x.0).ok_or_else(|| bad(cmd, "no puppet pins"))?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let ctx =
effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: s.time(), expr: s.expr.as_deref(), footage: Some(s.footage.as_ref()) };
let ctx = effectcraft_render::EvalCtx {
project: &s.project,
comp_id: cid,
comp,
time: s.time(),
expr: s.expr.as_deref(),
footage: Some(s.footage.as_ref()),
property_overrides: &[],
media_overrides: &[],
};
let mut out = vec![];
for (_, lid, uid) in pins {
let Some(layer) = comp.layer(lid) else { continue };
+10 -2
View File
@@ -1285,8 +1285,16 @@ fn convert_one_expr(s: &mut Session, cid: ItemId, lid: LayerId, uid: Uid) -> Res
let fd = comp.frame_duration();
let mut t = layer.in_point;
while t < layer.out_point {
let ctx =
effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp: &comp, time: t, expr: s.expr.as_deref(), footage: Some(s.footage.as_ref()) };
let ctx = effectcraft_render::EvalCtx {
project: &s.project,
comp_id: cid,
comp: &comp,
time: t,
expr: s.expr.as_deref(),
footage: Some(s.footage.as_ref()),
property_overrides: &[],
media_overrides: &[],
};
keys.push(Keyframe::new(layer.layer_time(t), ctx.value(&layer, &pr)));
t += fd;
}
+2 -1
View File
@@ -38,7 +38,8 @@ pub fn puppet_eval(s: &Session, cid: ItemId, lid: LayerId, fx_uid: Uid, t: Tick)
let fx = layer.effects()?;
let index = fx.groups().position(|g| g.uid == fx_uid)?;
let g = fx.groups().nth(index)?;
let ctx = EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None };
let ctx =
EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None, property_overrides: &[], media_overrides: &[] };
let mut r = Renderer::new(&s.project, &*s.footage, RenderOpts::default());
r.expr = s.expr.as_deref();
r.cache = Some(&s.layer_cache);
+10 -1
View File
@@ -48,7 +48,16 @@ impl Env {
Env { time: s.time_of(cid), expr: s.expr.clone(), footage: s.footage.clone() }
}
fn ctx<'a>(&'a self, proj: &'a Project, cid: ItemId, comp: &'a Comp) -> EvalCtx<'a> {
EvalCtx { project: proj, comp_id: cid, comp, time: self.time, expr: self.expr.as_deref(), footage: Some(self.footage.as_ref()) }
EvalCtx {
project: proj,
comp_id: cid,
comp,
time: self.time,
expr: self.expr.as_deref(),
footage: Some(self.footage.as_ref()),
property_overrides: &[],
media_overrides: &[],
}
}
}
+4 -2
View File
@@ -413,7 +413,8 @@ fn get_3d(s: &mut Session, p: &Value) -> Result<Value> {
let (w, h) = (comp.width as f64, comp.height as f64);
let vs = s.state.views3d.get(&cid).cloned().unwrap_or_default();
let t = s.state.times.get(&cid).copied().unwrap_or(Tick::ZERO);
let ctx = EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None };
let ctx =
EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None, property_overrides: &[], media_overrides: &[] };
let cam = vs.override_camera(w, h).unwrap_or_else(|| camera::active_camera(&ctx));
let f = cam.forward();
let lights: Vec<Value> = comp
@@ -555,7 +556,8 @@ fn auto_orient(s: &mut Session, p: &Value) -> Result<Value> {
/// through each layer's world matrix; cameras and lights contribute their position).
fn layer_points(s: &Session, cid: ItemId, ids: &[LayerId], t: Tick) -> Vec<Vec3> {
let Some(comp) = s.project.comp(cid) else { return vec![] };
let ctx = EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None };
let ctx =
EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None, property_overrides: &[], media_overrides: &[] };
let mut out = vec![];
for l in comp.layers.iter().filter(|l| ids.contains(&l.id)) {
let m = ctx.world_matrix(l);
+10 -1
View File
@@ -190,7 +190,16 @@ fn rendered_matte_of_pick(s: &mut Session, x: f64, y: f64, scale: f64) -> (Strin
s.execute_checked("prop.set", json!({"path": format!("effects/#{fx}/matteOnly"), "value": true})).unwrap();
let (cid, comp) = (s.active_comp_id().unwrap(), s.active_comp().unwrap());
let layer = &comp.layers[0];
let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: Tick::ZERO, expr: None, footage: None };
let ctx = effectcraft_render::EvalCtx {
project: &s.project,
comp_id: cid,
comp,
time: Tick::ZERO,
expr: None,
footage: None,
property_overrides: &[],
media_overrides: &[],
};
let buf = s.renderer(effectcraft_render::RenderOpts { scale, ..Default::default() }).content_buf(&ctx, layer).unwrap();
let (px, py) = buf.to_px([x, y]);
let m = buf.img.get(px.floor() as i64, py.floor() as i64)[0];
+122
View File
@@ -367,6 +367,128 @@ fn media_replacement_swaps_footage_per_instance() {
assert!(center(&s, main)[0] > 0.99);
}
/// #1747: a collapsed precomp with an Essential Property override must keep drawing its nested
/// layers past the precomp's frame instead of falling back to a flattened, clipped frame.
#[test]
fn collapsed_precomp_applies_property_overrides_without_clipping() {
struct AddY;
impl effectcraft_render::ExprHost for AddY {
fn eval(
&self,
_: &effectcraft_render::EvalCtx,
_: &effectcraft_project::Layer,
_: &effectcraft_project::Property,
value: &effectcraft_keyframe::Value,
) -> Result<effectcraft_keyframe::Value, String> {
Ok(match value {
effectcraft_keyframe::Value::Vec3([x, y, z]) => effectcraft_keyframe::Value::Vec3([*x, y + 100.0, *z]),
_ => value.clone(),
})
}
}
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Inner", "width": 400, "height": 300, "frameRate": 30, "duration": 2})).unwrap();
let inner = s.active_comp_id().unwrap();
let box_ = s.execute("layer.newSolid", json!({"name": "Box", "color": "#ffffff", "width": 100, "height": 100})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.set", json!({"layer": box_, "path": "transform/position", "value": [200, 50, 0]})).unwrap();
// The source property's expression still runs after the instance override.
s.execute("prop.setExpression", json!({"layer": box_, "path": "transform/position", "expression": "value + [0, 100, 0]"})).unwrap();
let position = s.execute("essential.addProperty", json!({"layer": box_, "path": "transform/position"})).unwrap()["controls"][0].as_u64().unwrap();
s.execute("comp.new", json!({"name": "Outer", "width": 400, "height": 600, "frameRate": 30, "duration": 2})).unwrap();
let outer = s.active_comp_id().unwrap();
let instance = s.execute("layer.addItem", json!({"item": inner.0})).unwrap()["layer"].as_u64().unwrap();
let other = s.execute("layer.addItem", json!({"item": inner.0})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setSwitch", json!({"layers": [instance, other], "switch": "collapse", "value": true})).unwrap();
s.expr = Some(std::sync::Arc::new(AddY));
s.execute("layer.select", json!({"layers": [instance]})).unwrap();
s.execute("essential.set", json!({"layer": instance, "control": position, "value": [200, -120, 0]})).unwrap();
s.execute("layer.select", json!({"layers": [other]})).unwrap();
s.execute("essential.set", json!({"layer": other, "control": position, "value": [200, 220, 0]})).unwrap();
let img = s.render(outer, Tick::ZERO, RenderOpts::default());
// The expression adds 100 px: the overrides give inner y = -20 and 320. The first maps to
// outer y = 130 (the precomp is centred at 300) and its 100 px box spans 80..179, 70 px above
// the instance's frame (150..449), where a flattened precomp would stop. The second maps to
// 470 and spans 420..519, past the bottom.
assert!(img.get(200, 100)[3] > 0.99, "the override draws above the nested frame: {:?}", img.get(200, 100));
assert!(img.get(200, 90)[3] > 0.99, "the box reaches the top of its overridden bounds");
assert!(img.get(200, 500)[3] > 0.99, "the other instance keeps its own override: {:?}", img.get(200, 500));
assert!(img.get(200, 200)[3] < 0.01, "nothing is left between the instances");
}
/// #1747: two collapsed instances of the same comp keep their own source-affecting property
/// overrides in the layer cache (the nested layers' ids and static values are identical).
#[test]
fn collapsed_instances_keep_distinct_property_overrides_in_the_cache() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Card", "width": 64, "height": 64, "frameRate": 30, "duration": 2})).unwrap();
let card = s.active_comp_id().unwrap();
let solid = s.execute("layer.newSolid", json!({"name": "BG", "color": "#ffffff", "width": 64, "height": 64})).unwrap()["layer"].as_u64().unwrap();
s.execute("effect.apply", json!({"layer": solid, "effect": "Fill"})).unwrap();
let color = s.execute("essential.addProperty", json!({"layer": solid, "path": "effects/#1/color"})).unwrap()["controls"][0].as_u64().unwrap();
s.execute("comp.new", json!({"name": "Main", "width": 200, "height": 100, "frameRate": 30, "duration": 2})).unwrap();
let main = s.active_comp_id().unwrap();
let a = s.execute("layer.addItem", json!({"item": card.0})).unwrap()["layer"].as_u64().unwrap();
let b = s.execute("layer.addItem", json!({"item": card.0})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setSwitch", json!({"layers": [a, b], "switch": "collapse", "value": true})).unwrap();
s.execute("prop.set", json!({"layer": a, "path": "transform/position", "value": [60, 50, 0]})).unwrap();
s.execute("prop.set", json!({"layer": b, "path": "transform/position", "value": [140, 50, 0]})).unwrap();
s.execute("layer.select", json!({"layers": [a]})).unwrap();
s.execute("essential.set", json!({"layer": a, "control": color, "value": [1.0, 0.0, 0.0, 1.0]})).unwrap();
s.execute("layer.select", json!({"layers": [b]})).unwrap();
s.execute("essential.set", json!({"layer": b, "control": color, "value": [0.0, 0.0, 1.0, 1.0]})).unwrap();
let img = s.render(main, Tick::ZERO, RenderOpts::default());
let left = img.get(60, 50);
let right = img.get(140, 50);
assert!(left[0] > 0.99 && left[2] < 0.01, "left instance is red: {left:?}");
assert!(right[2] > 0.99 && right[0] < 0.01, "right instance is blue: {right:?}");
}
/// #1747 with Media Replacement: the replacement footage and the position override both apply
/// while the precomp stays collapsed, so the box is blue and still draws above the frame.
#[test]
fn collapsed_precomp_applies_media_replacement_without_clipping() {
struct Colors;
impl crate::render::FootageSource for Colors {
fn frame(&self, _: ItemId, f: &effectcraft_project::Footage, _: Tick) -> Option<std::sync::Arc<effectcraft_raster::Image>> {
let c = if f.path.contains("blue") { [0.0, 0.0, 1.0, 1.0] } else { [1.0, 0.0, 0.0, 1.0] };
Some(std::sync::Arc::new(effectcraft_raster::Image::filled(100, 100, c)))
}
}
let mut s = Session { footage: std::sync::Arc::new(Colors), ..Default::default() };
let footage = |path: &str| effectcraft_project::Footage {
path: path.into(),
kind: effectcraft_project::FootageKind::Still,
width: 100,
height: 100,
has_video: true,
..Default::default()
};
let (red, blue) = s
.edit("test", None, |proj, _| {
let red = proj.add_item("red.png", Default::default(), None, effectcraft_project::ItemKind::Footage(footage("red.png")));
let blue = proj.add_item("blue.png", Default::default(), None, effectcraft_project::ItemKind::Footage(footage("blue.png")));
Ok((red, blue))
})
.unwrap();
s.execute("comp.new", json!({"name": "Inner", "width": 400, "height": 300, "frameRate": 30, "duration": 2})).unwrap();
let inner = s.active_comp_id().unwrap();
let clip = s.execute("layer.addItem", json!({"item": red.0})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.set", json!({"layer": clip, "path": "transform/position", "value": [200, 50, 0]})).unwrap();
let media = s.execute("essential.addMedia", json!({"layer": clip})).unwrap()["control"].as_u64().unwrap();
let position = s.execute("essential.addProperty", json!({"layer": clip, "path": "transform/position"})).unwrap()["controls"][0].as_u64().unwrap();
s.execute("comp.new", json!({"name": "Outer", "width": 400, "height": 600, "frameRate": 30, "duration": 2})).unwrap();
let outer = s.active_comp_id().unwrap();
let instance = s.execute("layer.addItem", json!({"item": inner.0})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setSwitch", json!({"layers": [instance], "switch": "collapse", "value": true})).unwrap();
s.execute("layer.select", json!({"layers": [instance]})).unwrap();
s.execute("essential.set", json!({"layer": instance, "control": position, "value": [200, -20, 0]})).unwrap();
s.execute("essential.set", json!({"layer": instance, "control": media, "item": blue.0})).unwrap();
let img = s.render(outer, Tick::ZERO, RenderOpts::default());
let above = img.get(200, 100);
assert!(above[2] > 0.99 && above[0] < 0.01, "the replacement media is blue above the frame: {above:?}");
assert!(img.get(200, 200)[3] < 0.01, "the collapsed box is not clipped at the nested frame");
}
/// Responsive Design — Time: stretching a precomp keeps its protected regions at their
/// original speed; the unprotected parts absorb the stretch.
#[test]
+31
View File
@@ -2,6 +2,7 @@
use effectcraft_keyframe::Value as KV;
use effectcraft_project::essential::{self, ControlType};
use effectcraft_time::Tick;
use serde_json::json;
use crate::{Event, Session};
@@ -102,3 +103,33 @@ fn open_in_essential_graphics_sets_the_primary_comp() {
);
assert_eq!(s.execute("essential.list", json!({})).unwrap()["name"], "A");
}
/// #1747: through a collapsed precomp, a Text override and a Font override of the same Source
/// Text both apply: the Font merges into the overridden text, as `essential::with_overrides`
/// does on the flattened path, instead of one entry replacing the other.
#[test]
fn collapsed_instance_merges_text_and_font_overrides() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Title", "width": 64, "height": 64, "frameRate": 30, "duration": 1})).unwrap();
let title = s.active_comp_id().unwrap();
let text = s.execute("layer.newText", json!({"text": "Hello"})).unwrap()["layer"].as_u64().unwrap();
let body = s.execute("essential.addProperty", json!({"layer": text, "path": "text/sourceText"})).unwrap()["controls"][0].as_u64().unwrap();
let font = s.execute("essential.addProperty", json!({"layer": text, "path": "text/sourceText", "as": "font"})).unwrap()["controls"][0].as_u64().unwrap();
s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 64, "frameRate": 30, "duration": 1})).unwrap();
let main = s.active_comp_id().unwrap();
let inst = s.execute("layer.addItem", json!({"item": title.0})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setSwitch", json!({"layers": [inst], "switch": "collapse", "value": true})).unwrap();
s.execute("essential.set", json!({"layer": inst, "control": body, "value": "Bye"})).unwrap();
s.execute("essential.set", json!({"layer": inst, "control": font, "value": {"font": "Serif Test", "size": 40}})).unwrap();
// Evaluate the nested text in the collapsed instance's context, as the renderer does.
let r = effectcraft_render::Renderer::new(&s.project, &*s.footage, crate::render::RenderOpts::default());
let ctx = effectcraft_render::EvalCtx::new(&s.project, main, s.project.comp(main).unwrap(), Tick::ZERO);
let layer = ctx.comp.layer(effectcraft_project::LayerId(inst)).unwrap();
let collapsed = r.collapsed(&ctx, layer).expect("the instance collapses");
let (_, nctx) = r.collapse_into(&ctx, layer, &collapsed, 1.0).expect("a nested context");
let src = nctx.comp.layer(effectcraft_project::LayerId(text)).unwrap();
match nctx.value(src, src.props.prop("text/sourceText").unwrap()) {
KV::Text(d) => assert_eq!((d.text.as_str(), d.font.as_str(), d.size), ("Bye", "Serif Test", 40.0)),
o => panic!("{o:?}"),
}
}
+2 -2
View File
@@ -40,7 +40,7 @@ fn close3(a: [f64; 3], b: [f64; 3], tol: f64) -> bool {
fn frame(s: &Session, id: u64) -> (Vec3, Vec3) {
let c = cid(s);
let comp = s.project.comp(c).unwrap();
let ctx = EvalCtx { project: &s.project, comp_id: c, comp, time: Tick::ZERO, expr: None, footage: None };
let ctx = EvalCtx { project: &s.project, comp_id: c, comp, time: Tick::ZERO, expr: None, footage: None, property_overrides: &[], media_overrides: &[] };
let (e, f, _) = layer_frame(&ctx, comp.layer(LayerId(id)).unwrap());
(e, f)
}
@@ -244,7 +244,7 @@ fn parented_camera_keeps_its_frame_under_an_offset_null() {
fn frame_at(s: &Session, id: u64, t: Tick) -> (Vec3, Vec3, Vec3) {
let c = cid(s);
let comp = s.project.comp(c).unwrap();
let ctx = EvalCtx { project: &s.project, comp_id: c, comp, time: t, expr: None, footage: None };
let ctx = EvalCtx { project: &s.project, comp_id: c, comp, time: t, expr: None, footage: None, property_overrides: &[], media_overrides: &[] };
layer_frame(&ctx, comp.layer(LayerId(id)).unwrap())
}
+20 -5
View File
@@ -10,7 +10,7 @@
use effectcraft_geom::{Mat3, Mat4};
use effectcraft_keyframe::Value;
use effectcraft_project::{Comp, ItemId, ItemKind, Layer, LayerId, LayerSource, Node, PropGroup, Property};
use effectcraft_render::{EvalCtx, source_size};
use effectcraft_render::EvalCtx;
use effectcraft_time::Tick;
/// A name-or-index argument (`layer("A")`, `layer(2)`, `effect(1)`).
@@ -299,7 +299,17 @@ impl<'a> Resolver<'a> {
}
fn ctx_at(&self, c: u64, t: f64) -> Option<EvalCtx<'a>> {
let comp = self.comp(c)?;
Some(EvalCtx { project: self.ctx.project, comp_id: ItemId(c), comp, time: Tick::from_seconds_f64(t), expr: self.ctx.expr, footage: self.ctx.footage })
let same_comp = c == self.ctx.comp_id.0;
Some(EvalCtx {
project: self.ctx.project,
comp_id: ItemId(c),
comp,
time: Tick::from_seconds_f64(t),
expr: self.ctx.expr,
footage: self.ctx.footage,
property_overrides: if same_comp { self.ctx.property_overrides } else { &[] },
media_overrides: if same_comp { self.ctx.media_overrides } else { &[] },
})
}
fn prop(&self, c: u64, l: u64, path: &str) -> Option<(&'a Layer, &'a Property)> {
let layer = self.layer(c, l)?;
@@ -372,7 +382,7 @@ impl<'a> Resolver<'a> {
Req::LayerInfo { comp, layer } => {
let c = self.comp(*comp)?;
let l = self.layer(*comp, *layer)?;
let (mut w, mut h) = source_size(project, l);
let (mut w, mut h) = self.ctx_at(*comp, self.ctx.time.seconds())?.source_size(l);
if w == 0 || h == 0 {
(w, h) = (c.width, c.height);
}
@@ -467,7 +477,7 @@ impl<'a> Resolver<'a> {
}
}
_ => {
let (w, h) = source_size(project, l);
let (w, h) = ctx.source_size(l);
[0.0, 0.0, w as f64, h as f64]
}
};
@@ -501,7 +511,12 @@ impl<'a> Resolver<'a> {
Req::SourceInfo { comp, layer } => {
let c = self.comp(*comp)?;
let l = self.layer(*comp, *layer)?;
let it = project.item(l.source.item()?)?;
let ctx = self.ctx_at(*comp, self.ctx.time.seconds())?;
let item = match &l.source {
LayerSource::Footage { item } => ctx.media_item(l, *item),
_ => l.source.item()?,
};
let it = project.item(item)?;
let (kind, aspect, frame, duration) = match &it.kind {
ItemKind::Comp(nc) => ("comp", nc.pixel_aspect, nc.frame_duration().seconds(), nc.duration.seconds()),
ItemKind::Footage(f) => ("footage", f.pixel_aspect, f.frame_rate.frame_duration().seconds(), f.duration.seconds()),
+10 -1
View File
@@ -163,7 +163,16 @@ fn evaluate_out(
return Err("Error: expressions reference each other too deeply (circular reference?)".into());
}
budget_left()?;
let ctx = EvalCtx { project: ctx.project, comp_id: ctx.comp_id, comp: ctx.comp, time: ctx.time, expr: Some(host), footage: ctx.footage };
let ctx = EvalCtx {
project: ctx.project,
comp_id: ctx.comp_id,
comp: ctx.comp,
time: ctx.time,
expr: Some(host),
footage: ctx.footage,
property_overrides: ctx.property_overrides,
media_overrides: ctx.media_overrides,
};
let comp = ctx.comp_id.0;
let path = layer.props.path_of(prop.uid).unwrap_or_else(|| format!("@{}", prop.uid));
let resolver = Resolver { ctx, own: Own { comp, layer, prop, path: path.clone() } };
+53 -1
View File
@@ -978,6 +978,49 @@ fn source_rect_at_time() {
assert!((s[0] + 31.0).abs() < 2.0 && (s[1] + 16.0).abs() < 2.0 && (s[2] - 62.0).abs() < 3.0, "{s:?}");
}
#[test]
fn source_dimensions_follow_media_replacement() {
let mut p = Project::default();
let comp = Comp::new(200, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
let cid = p.add_item("Main", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
let footage = |p: &mut Project, name: &str, width, height| {
p.add_item(
name,
Label::SeaFoam,
None,
ItemKind::Footage(effectcraft_project::Footage {
path: name.into(),
kind: effectcraft_project::FootageKind::Still,
width,
height,
has_video: true,
..Default::default()
}),
)
};
let original = footage(&mut p, "original.png", 100, 80);
let replacement = footage(&mut p, "replacement.png", 30, 40);
let mut l = build::layer(&mut p, &comp, "Clip", LayerSource::Footage { item: original }, (100, 80), None);
l.props.prop_mut(ROT).unwrap().expr =
Some(Expression { text: "thisLayer.width + thisLayer.height + thisLayer.source.width + sourceRectAtTime().width".into(), enabled: true });
let lid = l.id;
p.comp_mut(cid).unwrap().layers.push(l);
let c = p.comp(cid).unwrap();
let l = c.layer(lid).unwrap();
let media = effectcraft_render::MediaOverride::new(lid, replacement);
let ctx = EvalCtx {
project: &p,
comp_id: cid,
comp: c,
time: Tick::ZERO,
expr: Some(&Expressions),
footage: None,
property_overrides: &[],
media_overrides: &[media],
};
assert_eq!(ctx.value(l, l.props.prop(ROT).unwrap()), Value::Scalar(130.0));
}
// ---- colour -----------------------------------------------------------------------------------
#[test]
@@ -1185,7 +1228,16 @@ fn text_expression_selector_values() {
let sel_of = |p: &Project| {
let c = p.comp(cid).unwrap();
let l = c.layer(lid).unwrap();
let ctx = EvalCtx { project: p, comp_id: cid, comp: c, time: Tick::ZERO, expr: Some(&Expressions), footage: None };
let ctx = EvalCtx {
project: p,
comp_id: cid,
comp: c,
time: Tick::ZERO,
expr: Some(&Expressions),
footage: None,
property_overrides: &[],
media_overrides: &[],
};
let doc = effectcraft_render::text::source_text(&ctx, l).unwrap();
let lay = effectcraft_text::layout_doc(&doc);
let anim = l.props.group("text/animators/#1").unwrap();
+20 -2
View File
@@ -96,7 +96,16 @@ pub(crate) fn scene_project() -> (Project, ItemId) {
pub(crate) fn scene(p: &Project, cid: ItemId) -> Scene {
let r = Renderer::new(p, &NoFootage, RenderOpts::default());
let ctx = EvalCtx { project: p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None, footage: None };
let ctx = EvalCtx {
project: p,
comp_id: cid,
comp: p.comp(cid).unwrap(),
time: Tick::ZERO,
expr: None,
footage: None,
property_overrides: &[],
media_overrides: &[],
};
let run: Vec<&effectcraft_project::Layer> = ctx.comp.layers.iter().rev().filter(|l| l.is_3d() && l.has_video()).collect();
adv::scene_of(&r, &ctx, &run, (ctx.comp.width, ctx.comp.height))
}
@@ -277,7 +286,16 @@ pub(crate) fn off_share(a: &[[f32; 4]], b: &[[f32; 4]]) -> (f64, f32) {
}
pub(crate) fn run_of(p: &Project, cid: ItemId) -> (EvalCtx<'_>, Vec<&effectcraft_project::Layer>) {
let ctx = EvalCtx { project: p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None, footage: None };
let ctx = EvalCtx {
project: p,
comp_id: cid,
comp: p.comp(cid).unwrap(),
time: Tick::ZERO,
expr: None,
footage: None,
property_overrides: &[],
media_overrides: &[],
};
let run: Vec<&effectcraft_project::Layer> = ctx.comp.layers.iter().rev().filter(|l| l.is_3d() && l.has_video()).collect();
(ctx, run)
}
+11 -4
View File
@@ -89,7 +89,7 @@ fn draw_comp<'a>(e: &mut Enc, r: &Renderer<'a>, ctx: &EvalCtx<'a>, canvas: &mut
i = j;
} else {
match r.collapsed(ctx, visible[i]) {
Some(item) => draw_collapsed(e, r, ctx, visible[i], item, canvas)?,
Some(collapsed) => draw_collapsed(e, r, ctx, visible[i], &collapsed, canvas)?,
None => draw_layer(e, r, ctx, visible[i], canvas)?,
}
i += 1;
@@ -187,19 +187,26 @@ fn draw_split(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, split: &effectcraft_rend
Some(cv)
}
fn draw_collapsed<'a>(e: &mut Enc, r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: &Layer, item: ItemId, canvas: &mut GpuImage) -> Option<()> {
fn draw_collapsed<'a>(
e: &mut Enc,
r: &Renderer<'a>,
ctx: &EvalCtx<'a>,
layer: &'a Layer,
collapsed: &effectcraft_render::Collapsed,
canvas: &mut GpuImage,
) -> Option<()> {
let opacity = r.layer_opacity(ctx, layer);
if opacity <= 0.0 {
return Some(());
}
let matte = r.track_matte(ctx, layer).is_some();
if layer.blend_mode == BlendMode::Normal && !matte && !layer.preserve_transparency {
if let Some((sub, nctx)) = r.collapse_into(ctx, layer, item, opacity) {
if let Some((sub, nctx)) = r.collapse_into(ctx, layer, collapsed, opacity) {
draw_comp(e, &sub, &nctx, canvas)?;
}
return Some(());
}
let Some((sub, nctx)) = r.collapse_into(ctx, layer, item, 1.0) else { return Some(()) };
let Some((sub, nctx)) = r.collapse_into(ctx, layer, collapsed, 1.0) else { return Some(()) };
let mut iso = e.zeros(canvas.width, canvas.height);
draw_comp(e, &sub, &nctx, &mut iso)?;
composite_iso(e, r, ctx, layer, iso, canvas, opacity)
+20 -3
View File
@@ -269,7 +269,16 @@ fn value(s: &effectcraft_engine::Session, l: u64, path: &str) -> effectcraft_eng
let cid = s.active_comp_id().unwrap();
let comp = s.project.comp(cid).unwrap();
let layer = comp.layer(effectcraft_engine::project::LayerId(l)).unwrap();
let ctx = effectcraft_engine::render::EvalCtx { project: &s.project, comp_id: cid, comp, time: Default::default(), expr: s.expr.as_deref(), footage: None };
let ctx = effectcraft_engine::render::EvalCtx {
project: &s.project,
comp_id: cid,
comp,
time: Default::default(),
expr: s.expr.as_deref(),
footage: None,
property_overrides: &[],
media_overrides: &[],
};
ctx.value(layer, layer.props.prop(path).unwrap())
}
@@ -523,8 +532,16 @@ fn stereo_rig_expressions_follow_the_master_camera() {
let v3 = |s: &effectcraft_engine::Session, path: &str| {
let comp = s.project.comp(left).unwrap();
let layer = &comp.layers[0];
let ctx =
effectcraft_engine::render::EvalCtx { project: &s.project, comp_id: left, comp, time: Default::default(), expr: s.expr.as_deref(), footage: None };
let ctx = effectcraft_engine::render::EvalCtx {
project: &s.project,
comp_id: left,
comp,
time: Default::default(),
expr: s.expr.as_deref(),
footage: None,
property_overrides: &[],
media_overrides: &[],
};
ctx.value(layer, layer.props.prop(path).unwrap()).as_vec3()
};
let close = |a: [f64; 3], b: [f64; 3]| (0..3).all(|i| (a[i] - b[i]).abs() < 1e-6);
+5 -4
View File
@@ -184,8 +184,9 @@ impl<'a> Renderer<'a> {
pub fn layer_aux(&self, ctx: &EvalCtx, layer: &Layer) -> Option<Arc<effectcraft_raster::AuxChannels>> {
match &layer.source {
LayerSource::Footage { item } => {
let ItemKind::Footage(f) = &self.project.item(*item)?.kind else { return None };
let a = self.footage.aux(*item, f, ctx.source_time(layer))?;
let item = ctx.media_item(layer, *item);
let ItemKind::Footage(f) = &self.project.item(item)?.kind else { return None };
let a = self.footage.aux(item, f, ctx.source_time(layer))?;
// Aux pixels per layer pixel (the layer is the footage at its nominal size). The
// planes are only copied when that changes the scale.
let scale = a.width as f64 / f.width as f64;
@@ -332,8 +333,8 @@ impl<'a> Renderer<'a> {
}
/// Draw a collapsed precomp layer into `canvas` on the CPU.
pub fn draw_collapsed_cpu(&self, ctx: &EvalCtx<'a>, layer: &Layer, item: ItemId, canvas: &mut Image) {
self.draw_collapsed(ctx, layer, item, canvas, false);
pub fn draw_collapsed_cpu(&self, ctx: &EvalCtx<'a>, layer: &'a Layer, collapsed: &crate::Collapsed, canvas: &mut Image) {
self.draw_collapsed(ctx, layer, collapsed, canvas, false);
}
/// The layer's finished buffers (styles included) in the blending space.
+20 -2
View File
@@ -479,6 +479,19 @@ fn group_has(g: &PropGroup, match_id: &str) -> bool {
/// Feed every property of `g` that can change over time (keyframes or expression) as its value
/// at the context time. The static structure is hashed separately.
/// Mix the collapsed-instance overrides that target `id` into a cache key. They replace values
/// without editing the layer, so a key without them would reuse another instance's buffer.
fn hash_overrides(h: &mut KeyHasher, ctx: &EvalCtx, id: effectcraft_project::LayerId) {
for o in ctx.property_overrides.iter().filter(|o| o.layer == id) {
o.prop.hash(h);
o.font.hash(h);
hash_debug(h, &o.value);
}
for m in ctx.media_overrides.iter().filter(|m| m.layer == id) {
m.item.hash(h);
}
}
fn hash_values(h: &mut KeyHasher, ctx: &EvalCtx, layer: &Layer, g: &PropGroup) {
for c in &g.children {
match c {
@@ -605,8 +618,9 @@ fn key_any(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, fo
hash_debug(&mut h, s);
}
LayerSource::Text | LayerSource::Shape => {}
LayerSource::Footage { item } if footage || still_footage(ctx, *item) => {
let it = ctx.project.item(*item)?;
LayerSource::Footage { item } if footage || still_footage(ctx, ctx.media_item(layer, *item)) => {
let item = ctx.media_item(layer, *item);
let it = ctx.project.item(item)?;
let ItemKind::Footage(f) = &it.kind else { return None };
hash_debug(&mut h, f);
// A proxy (and its Use Proxy switch) changes the pixels.
@@ -629,6 +643,10 @@ fn key_any(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, fo
scale.to_bits().hash(&mut h);
draft.hash(&mut h);
layer.id.hash(&mut h);
// Collapsed-instance overrides replace values without editing the nested layer, so mix them
// into the key: a Font override, effect parameter or any other source-affecting override
// must not reuse another instance's processed buffer.
hash_overrides(&mut h, ctx, layer.id);
hash_debug(&mut h, &layer.source);
// (Audio, Lock and Shy don't change pixels.)
hash_debug(&mut h, &layer.switches.pixels());
+7
View File
@@ -76,6 +76,9 @@ impl Dependencies<'_> {
// Include transforms/parents as well as pixels: selected-layer anchors and
// continuously rasterized sources can depend on them.
hash_debug(self.h, layer);
// A referenced layer's static values can be overridden by the collapsed instance without
// editing it (`hash_values` only sees animated ones), so the owner's key needs them too.
super::hash_overrides(self.h, ctx, layer.id);
hash_values(self.h, ctx, layer, &layer.props);
hash_debug(self.h, &ctx.world_matrix(layer));
// A cached owner never runs this layer's effects, so the comp camera and lights its
@@ -88,6 +91,10 @@ impl Dependencies<'_> {
}
match layer.source {
LayerSource::Solid { item } | LayerSource::Footage { item } | LayerSource::Comp { item } => {
let item = match layer.source {
LayerSource::Footage { .. } => ctx.media_item(layer, item),
_ => item,
};
let source = ctx.project.item(item);
hash_debug(self.h, &source);
if let Some(source) = source {
+64 -10
View File
@@ -39,17 +39,33 @@ pub struct EvalCtx<'a> {
/// Footage frames for expressions that read rendered pixels (`sampleImage`): the renderer's
/// own source while rendering; `None` renders footage as transparent.
pub footage: Option<&'a dyn crate::FootageSource>,
/// Property values overridden by the Essential Properties of a collapsed precomp instance.
pub property_overrides: &'a [crate::PropertyOverride],
/// Media Replacement values overridden by the Essential Properties of a collapsed precomp
/// instance.
pub media_overrides: &'a [crate::MediaOverride],
}
impl<'a> EvalCtx<'a> {
pub fn new(project: &'a Project, comp_id: ItemId, comp: &'a Comp, time: Tick) -> EvalCtx<'a> {
EvalCtx { project, comp_id, comp, time, expr: None, footage: None }
EvalCtx { project, comp_id, comp, time, expr: None, footage: None, property_overrides: &[], media_overrides: &[] }
}
pub fn at(&self, time: Tick) -> EvalCtx<'a> {
EvalCtx { time, ..*self }
}
/// Value of a property at the context time (keyframes, then expression).
pub fn value(&self, layer: &Layer, prop: &Property) -> Value {
// A collapsed precomp instance's Essential Property override replaces the base value;
// its expression (if any) still runs, and sees the overrides of this context.
if let Some(o) = self.property_overrides.iter().rev().find(|o| o.layer == layer.id && o.prop == prop.uid) {
let v = o.value(layer, prop, self.time);
if prop.has_expression()
&& let Some(h) = self.expr
{
return h.eval(self, layer, prop, &v).unwrap_or(v);
}
return v;
}
// Separated Position reads as the combination of X/Y/Z Position.
if prop.match_id == "position"
&& let Some(tr) = layer.transform()
@@ -145,13 +161,15 @@ impl<'a> EvalCtx<'a> {
/// Source pixel aspect relative to the comp's: non-square footage (or solids, or precomps
/// with another pixel aspect) is stretched horizontally by this factor in the comp.
pub fn par_ratio(&self, layer: &Layer) -> f64 {
let par = match &layer.source {
LayerSource::Footage { item } | LayerSource::Solid { item } | LayerSource::Comp { item } => match self.project.item(*item).map(|i| &i.kind) {
Some(effectcraft_project::ItemKind::Footage(f)) => f.pixel_aspect,
Some(effectcraft_project::ItemKind::Solid(s)) => s.pixel_aspect,
Some(effectcraft_project::ItemKind::Comp(c)) => c.pixel_aspect,
_ => 1.0,
},
let item = match &layer.source {
LayerSource::Footage { item } => Some(self.media_item(layer, *item)),
LayerSource::Solid { item } | LayerSource::Comp { item } => Some(*item),
_ => None,
};
let par = match item.and_then(|item| self.project.item(item)).map(|i| &i.kind) {
Some(effectcraft_project::ItemKind::Footage(f)) => f.pixel_aspect,
Some(effectcraft_project::ItemKind::Solid(s)) => s.pixel_aspect,
Some(effectcraft_project::ItemKind::Comp(c)) => c.pixel_aspect,
_ => 1.0,
};
let r = par / if self.comp.pixel_aspect > 0.0 { self.comp.pixel_aspect } else { 1.0 };
@@ -237,9 +255,27 @@ impl<'a> EvalCtx<'a> {
(Mat3([[m[0][0], m[0][1], m[0][3]], [m[1][0], m[1][1], m[1][3]], [0.0, 0.0, 1.0]]), 0.0)
}
}
/// The footage item a layer uses, honouring this instance's Media Replacement override.
pub fn media_item(&self, layer: &Layer, item: ItemId) -> ItemId {
self.media_overrides.iter().rev().find(|m| m.layer == layer.id).map_or(item, |m| m.item)
}
/// Size of a layer's source in layer pixels, honouring Media Replacement.
pub fn source_size(&self, layer: &Layer) -> (u32, u32) {
let item = match &layer.source {
LayerSource::Footage { item } => Some(self.media_item(layer, *item)),
LayerSource::Comp { item } | LayerSource::Solid { item } => Some(*item),
_ => None,
};
match layer.source {
LayerSource::Null => (100, 100),
_ => item.and_then(|item| self.project.item(item)).and_then(|i| i.dimensions()).unwrap_or((0, 0)),
}
}
/// [`effect_bounds`] of a layer of this comp.
pub fn effect_bounds(&self, layer: &Layer) -> ([f64; 2], [f64; 2]) {
effect_bounds(self.project, self.comp, layer)
effect_bounds_of(self.comp, self.source_size(layer))
}
/// Effect space (see [`effect_bounds`]) → comp matrix.
pub fn effect_to_comp(&self, layer: &Layer) -> Mat3 {
@@ -248,6 +284,20 @@ impl<'a> EvalCtx<'a> {
}
}
impl crate::PropertyOverride {
fn value(&self, layer: &Layer, prop: &Property, time: Tick) -> Value {
if self.font {
// A Font control merges only the font into the target text, as
// `essential::with_overrides` does.
let mut value = prop.value_at(layer.layer_time(time));
effectcraft_project::essential::merge_font(&mut value, &self.value);
value
} else {
self.value.clone()
}
}
}
/// The protected regions of a comp (Responsive Design — Time markers), merged, sorted and
/// clamped to the comp, in seconds.
pub fn protected_regions(comp: &Comp) -> Vec<(f64, f64)> {
@@ -321,7 +371,11 @@ pub fn responsive_source_time(comp: &Comp, stretch: f64, elapsed: f64) -> Option
/// surrounds. Effects work in **effect space**, measured from that corner (After Effects' layer
/// space for effect points: a shape layer's default effect point is its bounds' centre).
pub fn effect_bounds(project: &Project, comp: &Comp, layer: &Layer) -> ([f64; 2], [f64; 2]) {
match source_size(project, layer) {
effect_bounds_of(comp, source_size(project, layer))
}
fn effect_bounds_of(comp: &Comp, size: (u32, u32)) -> ([f64; 2], [f64; 2]) {
match size {
(0, _) => {
let (w, h) = (comp.width as f64, comp.height as f64);
([w, h], [-w / 2.0, -h / 2.0])
+2 -2
View File
@@ -1,6 +1,6 @@
use super::footage::NoFootage;
use super::opts::RenderOpts;
use super::{EvalCtx, Footage, FrameBlend, FrameRate, Image, ItemId, Layer, LayerSource, Project, Renderer, TICKS_PER_SECOND, Tick, shapes, source_size, text};
use super::{EvalCtx, Footage, FrameBlend, FrameRate, Image, ItemId, Layer, LayerSource, Project, Renderer, TICKS_PER_SECOND, Tick, shapes, text};
/// The layer's frame blending mode, if the comp's Enable Frame Blending switch is on.
/// One field of an interlaced frame as a full frame: the field's lines (`parity` 0 = even /
@@ -132,7 +132,7 @@ pub fn content_bounds(ctx: &EvalCtx, layer: &effectcraft_project::Layer) -> Opti
LayerSource::Shape => layer.props.sub("contents").and_then(|c| shapes::content_bounds(ctx, layer, c)).map(|r| [r.x0, r.y0, r.x1, r.y1]),
LayerSource::Camera | LayerSource::Light { .. } => None,
_ => {
let (w, h) = source_size(ctx.project, layer);
let (w, h) = ctx.source_size(layer);
(w > 0 && h > 0).then_some([0.0, 0.0, w as f64, h as f64])
}
}
+3 -2
View File
@@ -90,12 +90,13 @@ impl EffectHost for FxHost<'_, '_, '_> {
fn audio(&self, id: u64, start: f64, frames: usize, rate: u32) -> Option<Vec<f32>> {
let other = self.ctx.layer(effectcraft_project::LayerId(id))?;
let LayerSource::Footage { item } = &other.source else { return None };
let ItemKind::Footage(f) = &self.r.project.item(*item)?.kind else { return None };
let item = self.ctx.media_item(other, *item);
let ItemKind::Footage(f) = &self.r.project.item(item)?.kind else { return None };
if !f.has_audio {
return None;
}
let st = other.layer_time(Tick::from_seconds_f64(start));
self.r.footage.audio(*item, f, st, frames, rate)
self.r.footage.audio(item, f, st, frames, rate)
}
fn self_at(&self, layer_time: f64, effects: usize) -> Option<Buf> {
+2 -2
View File
@@ -2,7 +2,7 @@ use super::frames::adaptive_samples;
use super::opts::{LayerTiming, Through};
use super::{
Arc, BlendMode, Buf, Comp, EvalCtx, Image, IndexedParallelIterator, IntoParallelRefIterator, IntoParallelRefMutIterator, Layer, LayerSource, Mat3,
MatteKind, ParallelIterator, Quality, Region, Renderer, Sampling, Tick, WarpOpts, cache, color, composite_warp_changed, masks, source_size, styles, vec2,
MatteKind, ParallelIterator, Quality, Region, Renderer, Sampling, Tick, WarpOpts, cache, color, composite_warp_changed, masks, styles, vec2,
};
/// Blend policy for placing a layer, including optional output-depth quantization.
@@ -52,7 +52,7 @@ impl<'a> Renderer<'a> {
}
}
let t0 = web_time::Instant::now();
let size = source_size(self.project, layer);
let size = ctx.source_size(layer);
let layer_size = if size.0 == 0 { [ctx.comp.width as f64, ctx.comp.height as f64] } else { [size.0 as f64, size.1 as f64] };
let st = styles::render(ctx, layer, &content, layer_size);
if let Some(t) = timing {
+1
View File
@@ -60,6 +60,7 @@ pub use layers::{Placement, StyledLayer};
pub(crate) use opts::Collapse3d;
pub use opts::{LayerTiming, RenderOpts};
use opts::{MAX_THROUGH, Through};
pub use pipeline::{Collapsed, MediaOverride, PropertyOverride};
pub struct Renderer<'a> {
pub project: &'a Project,
+117 -16
View File
@@ -7,6 +7,8 @@ use super::{
Arc, AutoKey, AutoPick, BlendMode, Buf, Comp, EffectCtx, EffectEnv, EvalCtx, Footage, FootageKind, FrameBlend, FrameRate, GroupKind, Image, ItemId,
ItemKind, Layer, LayerCache, LayerSource, Params, Project, Quality, Region, Renderer, Tick, cache, color, masks, shapes, styles, text, three_d, vec2,
};
use effectcraft_keyframe::Value;
use effectcraft_project::LayerId;
/// Coordinate space and stack range shared by CPU and accelerated effect evaluation.
#[derive(Clone, Copy)]
@@ -16,6 +18,88 @@ pub(crate) struct EffectStack {
pub origin: [f64; 2],
}
/// A property value an Essential Properties override gives a layer of a collapsed precomp.
#[derive(Clone, Debug)]
pub struct PropertyOverride {
pub(crate) layer: LayerId,
pub(crate) prop: u64,
pub(crate) value: Value,
/// A Font control changes only the font of the target text, not its text.
pub(crate) font: bool,
}
/// A Media Replacement an Essential Properties override gives a footage layer of a collapsed
/// precomp.
#[derive(Clone, Copy, Debug)]
pub struct MediaOverride {
pub(crate) layer: LayerId,
pub(crate) item: ItemId,
}
impl MediaOverride {
/// A Media Replacement of `layer`'s footage by `item`.
pub fn new(layer: LayerId, item: ItemId) -> Self {
MediaOverride { layer, item }
}
}
/// A collapsed precomp layer: the nested comp and the instance overrides that apply to it.
pub struct Collapsed {
pub item: ItemId,
pub(crate) property_overrides: Vec<PropertyOverride>,
pub(crate) media_overrides: Vec<MediaOverride>,
}
/// The property and media overrides of a collapsed precomp layer. `None` when its Essential
/// Properties group or the source comp's definitions are missing (the flattened path then
/// preserves the old behaviour).
fn collapsed_instance(ctx: &EvalCtx, layer: &Layer, item: ItemId) -> Option<(Vec<PropertyOverride>, Vec<MediaOverride>)> {
use effectcraft_project::essential::{self, ControlType, EgKind};
let over = essential::overridden(layer);
if over.is_empty() {
return Some((vec![], vec![]));
}
let g = essential::group(layer)?;
let eg = ctx.project.comp(item)?.essential.as_ref()?;
let mut property = vec![];
let mut fonts: Vec<PropertyOverride> = vec![];
let mut media = vec![];
g.walk("", &mut |_, p| {
if !over.contains(&p.uid) {
return;
}
let Some(control) = essential::control_of(&p.match_id) else { return };
let Some(ctl) = eg.resolve(control) else { return };
let value = ctx.value(layer, p);
match &ctl.kind {
EgKind::Property { .. } => {
let font = ctl.as_type == Some(ControlType::Font);
for (layer, prop) in ctl.kind.targets() {
let o = PropertyOverride { layer, prop, value: value.clone(), font };
if font { fonts.push(o) } else { property.push(o) }
}
}
EgKind::Media { layer: target } => {
let Value::Scalar(id) = value else { return };
let item = ItemId(id.max(0.0) as u64);
if matches!(ctx.project.item(item).map(|i| &i.kind), Some(ItemKind::Footage(_))) {
media.push(MediaOverride { layer: *target, item });
}
}
_ => {}
}
});
// A Font control merges into the text its instance shows, after a whole-value (Text) override
// of the same property, as `essential::with_overrides` does: keep one entry per property.
for f in fonts {
match property.iter_mut().rev().find(|o| o.layer == f.layer && o.prop == f.prop) {
Some(o) => effectcraft_project::essential::merge_font(&mut o.value, &f.value),
None => property.push(f),
}
}
Some((property, media))
}
impl<'a> Renderer<'a> {
pub fn new(project: &'a Project, footage: &'a dyn FootageSource, opts: RenderOpts) -> Renderer<'a> {
Renderer {
@@ -122,8 +206,10 @@ impl<'a> Renderer<'a> {
/// The nested comp of a precomp layer whose transformations collapse into this comp: the
/// Collapse Transformations switch is on and the layer has no masks, effects or layer
/// styles (those force a flattened render of the precomp, as in After Effects).
pub fn collapsed(&self, ctx: &EvalCtx, layer: &Layer) -> Option<ItemId> {
/// styles (those force a flattened render of the precomp, as in After Effects). Essential
/// Properties overrides travel with it and are applied as the nested layers are evaluated,
/// so the nested comp is not rasterised at its frame.
pub fn collapsed(&self, ctx: &EvalCtx, layer: &Layer) -> Option<Collapsed> {
if !layer.switches.collapse || layer.switches.adjustment || self.depth > 12 {
return None;
}
@@ -140,24 +226,37 @@ impl<'a> Renderer<'a> {
if styles::active(ctx, layer) {
return None;
}
// Instance overrides (Essential Properties) need the nested comp rendered on its own.
if !effectcraft_project::essential::overridden(layer).is_empty() {
// A composition proxy or frame blending is drawn by the flattened path (`precomp_source`);
// collapsing renders the nested comp directly and would ignore both.
if self.proxy_for(*item).is_some() || frame_blend_mode(ctx, layer) != FrameBlend::Off {
return None;
}
Some(*item)
let (property_overrides, media_overrides) = collapsed_instance(ctx, layer, *item)?;
Some(Collapsed { item: *item, property_overrides, media_overrides })
}
/// A renderer and context that draw a collapsed precomp's layers straight into this comp:
/// their transforms are concatenated with the precomp layer's (one resample, no
/// rasterisation at the precomp's bounds), their opacity multiplied by `opacity`, and their
/// 3D layers use this comp's camera and lights.
pub fn collapse_into(&self, ctx: &EvalCtx<'a>, layer: &'a Layer, item: ItemId, opacity: f32) -> Option<(Renderer<'a>, EvalCtx<'a>)> {
pub fn collapse_into<'p>(&self, ctx: &EvalCtx<'a>, layer: &'a Layer, collapsed: &'p Collapsed, opacity: f32) -> Option<(Renderer<'p>, EvalCtx<'p>)>
where
'a: 'p,
{
let item = collapsed.item;
let nc = self.project.comp(item)?;
let t = ctx.nested_time(layer);
if !nc.covers(t) {
return None;
}
let nctx = EvalCtx { comp_id: item, comp: nc, time: t, ..*ctx };
let nctx = EvalCtx {
comp_id: item,
comp: nc,
time: t,
property_overrides: &collapsed.property_overrides,
media_overrides: &collapsed.media_overrides,
..*ctx
};
let (l2c, _) = ctx.layer_to_comp(layer);
let outer = Some(self.outer.map_or(l2c, |o| o * l2c));
let world = ctx.world_matrix(layer);
@@ -189,19 +288,19 @@ impl<'a> Renderer<'a> {
/// that then takes the precomp layer's track matte, Preserve Transparency and blend mode.
/// Returns the region left to re-quantise (nested layers drawn straight into `canvas`
/// quantise their own footprints).
pub(crate) fn draw_collapsed(&self, ctx: &EvalCtx<'a>, layer: &Layer, item: ItemId, canvas: &mut Image, blank: bool) -> Region {
pub(crate) fn draw_collapsed(&self, ctx: &EvalCtx<'a>, layer: &'a Layer, collapsed: &Collapsed, canvas: &mut Image, blank: bool) -> Region {
let opacity = ctx.opacity(layer) as f32 * self.opacity_mul;
if opacity <= 0.0 {
return Region::Empty;
}
let matte = layer.track_matte.is_some_and(|tm| tm.layer != layer.id && ctx.comp.layer(tm.layer).is_some());
if layer.blend_mode == BlendMode::Normal && !matte && !layer.preserve_transparency {
if let Some((sub, nctx)) = self.collapse_into(ctx, layer, item, opacity) {
if let Some((sub, nctx)) = self.collapse_into(ctx, layer, collapsed, opacity) {
sub.draw_comp(&nctx, canvas, blank);
}
return Region::Empty;
}
let Some((sub, nctx)) = self.collapse_into(ctx, layer, item, 1.0) else { return Region::Empty };
let Some((sub, nctx)) = self.collapse_into(ctx, layer, collapsed, 1.0) else { return Region::Empty };
let mut iso = Image::new(canvas.width, canvas.height);
sub.draw_comp(&nctx, &mut iso, true);
self.composite_iso(ctx, layer, iso, canvas, opacity);
@@ -230,6 +329,7 @@ impl<'a> Renderer<'a> {
let continuous = match layer.source {
LayerSource::Text | LayerSource::Shape => true,
LayerSource::Footage { item } => {
let item = ctx.media_item(layer, item);
layer.switches.collapse && matches!(self.project.item(item).map(|i| &i.kind), Some(ItemKind::Footage(f)) if is_vector_footage(f))
}
_ => false,
@@ -267,7 +367,7 @@ impl<'a> Renderer<'a> {
}
pub(crate) fn ctx(&self, comp_id: ItemId, comp: &'a Comp, t: Tick) -> EvalCtx<'a> {
EvalCtx { project: self.project, comp_id, comp, time: t, expr: self.expr, footage: Some(self.footage) }
EvalCtx { project: self.project, comp_id, comp, time: t, expr: self.expr, footage: Some(self.footage), property_overrides: &[], media_overrides: &[] }
}
/// This renderer with another accelerator (or none): the passes of
@@ -406,7 +506,7 @@ impl<'a> Renderer<'a> {
blank = false;
} else {
let changed = match self.collapsed(ctx, visible[i]) {
Some(item) => self.draw_collapsed(ctx, visible[i], item, canvas, blank),
Some(collapsed) => self.draw_collapsed(ctx, visible[i], &collapsed, canvas, blank),
None => self.draw_layer(ctx, visible[i], canvas, blank),
};
i += 1;
@@ -637,7 +737,8 @@ impl<'a> Renderer<'a> {
Some(Buf { img: sub.comp_frame(*item, lt), offset: [0.0; 2], scale: bs })
}
LayerSource::Footage { item } => {
let it = self.project.item(*item)?;
let item = ctx.media_item(layer, *item);
let it = self.project.item(item)?;
let ItemKind::Footage(f) = &it.kind else { return None };
if !f.has_video {
return None;
@@ -645,7 +746,7 @@ impl<'a> Renderer<'a> {
// Continuously rasterised vector footage: drawn at the on-screen scale.
if layer.switches.collapse && is_vector_footage(f) {
let k = self.raster_scale(ctx, layer);
if let Some(img) = self.footage.vector_frame(*item, f, k) {
if let Some(img) = self.footage.vector_frame(item, f, k) {
let mut buf = Buf { img: (*img).clone(), offset: [0.0; 2], scale: k };
if let Some(c) = self.pipe.media_in(f) {
color::convert(&mut buf.img, &c);
@@ -655,10 +756,10 @@ impl<'a> Renderer<'a> {
}
let lt = ctx.source_time(layer);
// The proxy is decoded instead, at its own size, and fills the footage's frame.
let proxy = self.proxy_for(*item);
let proxy = self.proxy_for(item);
let pf = proxy.unwrap_or(f);
let size = self.reduced_size(f.width, f.height).filter(|_| proxy.is_none());
let img = self.footage_frame(ctx, layer, *item, pf, lt, size)?;
let img = self.footage_frame(ctx, layer, item, pf, lt, size)?;
Some(self.footage_buf(&img, pf, f.width, f.height))
}
LayerSource::Text => Some(self.authored(text::render(ctx, layer, self.raster_scale(ctx, layer)))),
+61 -1
View File
@@ -3,7 +3,7 @@
use effectcraft_color::{BlendMode, Label};
use effectcraft_keyframe::{TextDoc, Value};
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{BitDepth, Comp, ItemId, ItemKind, Layer, LayerSource, Project, Quality, Solid};
use effectcraft_project::{BitDepth, Comp, FrameBlend, ItemId, ItemKind, Layer, LayerSource, Project, Quality, Solid};
use effectcraft_time::{FrameRate, Tick};
use crate::{EvalCtx, Image, NoFootage, RenderOpts, Renderer, render_frame};
@@ -110,6 +110,66 @@ fn masks_on_a_collapsed_layer_force_a_flattened_render() {
assert!(max_diff(&a, &b) < 1e-4, "{}", max_diff(&a, &b));
}
/// #1747: the flattened path honours a composition proxy and frame blending; a collapsed layer
/// renders the nested comp directly and ignores both, so such a layer must not collapse.
#[test]
fn proxy_and_frame_blending_keep_a_precomp_on_the_flattened_path() {
let (mut p, cid) = scene(true, false);
let id = p.comp(cid).unwrap().layers[0].id;
let collapses = |p: &Project| {
let r = Renderer::new(p, &NoFootage, RenderOpts::default());
let ctx = EvalCtx::new(p, cid, p.comp(cid).unwrap(), Tick::ZERO);
r.collapsed(&ctx, ctx.comp.layer(id).unwrap()).is_some()
};
assert!(collapses(&p), "a plain precomp collapses");
// Frame blending needs both the comp's switch and the layer's.
p.comp_mut(cid).unwrap().enable_frame_blending = true;
p.comp_mut(cid).unwrap().layers[0].switches.frame_blend = FrameBlend::FrameMix;
assert!(!collapses(&p), "frame blending keeps the flattened path");
p.comp_mut(cid).unwrap().enable_frame_blending = false;
p.comp_mut(cid).unwrap().layers[0].switches.frame_blend = FrameBlend::Off;
// A composition proxy stands in for the precomp's pixels.
let LayerSource::Comp { item } = p.comp(cid).unwrap().layers[0].source else { panic!("a precomp layer") };
let proxy =
effectcraft_project::Proxy { footage: effectcraft_project::Footage { has_video: true, width: 32, height: 32, ..Default::default() }, enabled: true };
p.item_mut(item).unwrap().proxy = Some(Box::new(proxy));
assert!(!collapses(&p), "a proxy keeps the flattened path");
}
/// #1747: an effect on B that reads A must not reuse its cached buffer when two collapsed
/// instances give A different static overrides (`hash_values` only sees animated values).
#[test]
fn referenced_layer_overrides_change_the_owner_cache_key() {
let mut p = project();
let comp = Comp::new(100, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
let a = solid(&mut p, &comp, [1.0, 1.0, 1.0], (40, 40), [30.0, 50.0, 0.0]);
let aid = a.id;
let mut b = solid(&mut p, &comp, [1.0, 1.0, 1.0], (100, 100), [50.0, 50.0, 0.0]);
let bid = b.id;
let mut next = p.next_id;
let mut ids = Ids(&mut next);
let spec = effectcraft_effects::find("ec.channel.setmatte").unwrap();
let mut fx = effectcraft_effects::instantiate(spec, &mut ids, spec.name, [100.0, 100.0]);
fx.prop_mut("takeMatteFromLayer").unwrap().value = Value::Layer(Some(a.id.0));
p.next_id = next;
b.props.sub_mut("effects").unwrap().children.push(fx.into());
let mut c = comp;
c.layers = vec![b, a];
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(c.into()));
let comp = p.comp(cid).unwrap();
let b = comp.layer(bid).unwrap();
let opacity = comp.layer(aid).unwrap().props.prop("transform/opacity").unwrap().uid;
let key = |value: f64| {
let overrides = [crate::PropertyOverride { layer: aid, prop: opacity, value: Value::Scalar(value), font: false }];
let ctx =
EvalCtx { project: &p, comp_id: cid, comp, time: Tick::ZERO, expr: None, footage: None, property_overrides: &overrides, media_overrides: &[] };
crate::cache::layer_key(&ctx, b, 1.0, false, false)
};
let (opaque, half) = (key(100.0), key(50.0));
assert!(opaque.is_some(), "the owner is cacheable");
assert_ne!(opaque, half, "A's static override changes B's key");
}
#[test]
fn collapsed_3d_layers_join_the_parent_scene() {
// Parent: a red 3D solid at z = 0 above (in the stack) a collapsed 3D precomp holding a
+10 -1
View File
@@ -134,7 +134,16 @@ fn input_keys_ignore_later_effects() {
}
let key = |p: &Project| {
let comp = p.comp(cid).unwrap();
let ctx = crate::EvalCtx { project: p, comp_id: cid, comp, time: Tick::from_seconds_f64(0.5), expr: None, footage: None };
let ctx = crate::EvalCtx {
project: p,
comp_id: cid,
comp,
time: Tick::from_seconds_f64(0.5),
expr: None,
footage: None,
property_overrides: &[],
media_overrides: &[],
};
let l = &comp.layers[0];
(crate::cache::input_key(&ctx, l, 1.0, false, false, 1), crate::cache::input_key(&ctx, l, 1.0, false, false, 2))
};
+4 -4
View File
@@ -857,8 +857,8 @@ fn emit_layer<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: &Layer, b: &mut Bu
if layer.environment || layer.switches.adjustment || ctx.opacity(layer) <= 0.0 || !layer.has_video() {
return;
}
if let Some(item) = r.collapsed(ctx, layer) {
collapsed_layer(r, ctx, layer, item, b);
if let Some(collapsed) = r.collapsed(ctx, layer) {
collapsed_layer(r, ctx, layer, &collapsed, b);
} else if layer.source.is_model() {
model_layer(r, ctx, layer, b);
} else if matches!(layer.source, LayerSource::Text | LayerSource::Shape)
@@ -874,9 +874,9 @@ fn emit_layer<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: &Layer, b: &mut Bu
/// (meshes, extrusions, cards) placed by the precomp layer's world transform, so they
/// intersect, occlude and shadow the parent's layers. Nested 2D layers lie on the precomp
/// layer's plane; nested lights and cameras are ignored (the parent's light the scene).
fn collapsed_layer<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: &Layer, item: effectcraft_project::ItemId, b: &mut Builder) {
fn collapsed_layer<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: &Layer, collapsed: &crate::Collapsed, b: &mut Builder) {
let op = ctx.opacity(layer) as f32 * r.opacity_mul();
let Some((sub, nctx)) = r.collapse_into(ctx, layer, item, op) else { return };
let Some((sub, nctx)) = r.collapse_into(ctx, layer, collapsed, op) else { return };
let saved = b.outer;
b.outer = saved * ctx.world_matrix(layer);
let t = nctx.time;
+40 -4
View File
@@ -370,7 +370,16 @@ fn extruded_text_renders_as_a_mesh() {
set(l, "geometryOptions/bevelStyle", Value::Enum(1));
set(l, "transform/rotationY", Value::Scalar(40.0));
});
let ctx = crate::EvalCtx { project: &p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None, footage: None };
let ctx = crate::EvalCtx {
project: &p,
comp_id: cid,
comp: p.comp(cid).unwrap(),
time: Tick::ZERO,
expr: None,
footage: None,
property_overrides: &[],
media_overrides: &[],
};
let l = &ctx.comp.layers[0];
let params = scene::extrusion_params(&ctx, l).unwrap();
let meshes = scene::extruded_meshes(&ctx, l, &params);
@@ -409,7 +418,16 @@ fn shape_surface_items_colour_the_extruded_surfaces() {
set(l, "geometryOptions/extrusionDepth", Value::Scalar(40.0));
set(l, "transform/position", Value::Vec3([100.0, 100.0, 0.0]));
});
let ctx = crate::EvalCtx { project: &p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None, footage: None };
let ctx = crate::EvalCtx {
project: &p,
comp_id: cid,
comp: p.comp(cid).unwrap(),
time: Tick::ZERO,
expr: None,
footage: None,
property_overrides: &[],
media_overrides: &[],
};
let l = &ctx.comp.layers[0];
let params = scene::extrusion_params(&ctx, l).unwrap();
let meshes = scene::extruded_meshes(&ctx, l, &params);
@@ -445,7 +463,16 @@ fn text_animators_colour_the_extruded_surfaces() {
set(l, "geometryOptions/extrusionDepth", Value::Scalar(30.0));
set(l, "geometryOptions/bevelStyle", Value::Enum(1));
});
let ctx = crate::EvalCtx { project: &p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None, footage: None };
let ctx = crate::EvalCtx {
project: &p,
comp_id: cid,
comp: p.comp(cid).unwrap(),
time: Tick::ZERO,
expr: None,
footage: None,
property_overrides: &[],
media_overrides: &[],
};
let l = &ctx.comp.layers[0];
let params = scene::extrusion_params(&ctx, l).unwrap();
let meshes = scene::extruded_meshes(&ctx, l, &params);
@@ -643,7 +670,16 @@ fn extruded_strokes_render_as_bevelled_meshes() {
set(l, "geometryOptions/extrusionDepth", Value::Scalar(20.0));
set(l, "geometryOptions/bevelStyle", Value::Enum(1));
});
let ctx = crate::EvalCtx { project: &p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None, footage: None };
let ctx = crate::EvalCtx {
project: &p,
comp_id: cid,
comp: p.comp(cid).unwrap(),
time: Tick::ZERO,
expr: None,
footage: None,
property_overrides: &[],
media_overrides: &[],
};
let l = &ctx.comp.layers[0];
let params = scene::extrusion_params(&ctx, l).unwrap();
let meshes = scene::extruded_meshes(&ctx, l, &params);
+11 -18
View File
@@ -195,13 +195,13 @@ fn mb_samples(r: &Renderer, ctx: &EvalCtx, layer: &Layer) -> usize {
}
/// Prepare a layer (buffer, geometry, material). `None` when it shows nothing.
pub(crate) fn prepare<'a>(r: &Renderer, ctx: &EvalCtx<'a>, layer: &'a Layer, order: usize, out: (u32, u32), in_run: bool) -> Option<Item<'a>> {
pub(crate) fn prepare<'a>(r: &Renderer, ctx: &EvalCtx<'_>, layer: &'a Layer, order: usize, out: (u32, u32), in_run: bool) -> Option<Item<'a>> {
let buf = r.blend_layer_buf(ctx, layer)?;
prepare_with(r, ctx, layer, order, out, in_run, PlaneInput { buf, local: Mat4::IDENTITY })
}
/// Prepare a layer as planes: one per character for per-character 3D text, else one.
pub(crate) fn prepare_all<'a>(r: &Renderer, ctx: &EvalCtx<'a>, layer: &'a Layer, order: usize, out: (u32, u32), in_run: bool) -> Vec<Item<'a>> {
pub(crate) fn prepare_all<'a>(r: &Renderer, ctx: &EvalCtx<'_>, layer: &'a Layer, order: usize, out: (u32, u32), in_run: bool) -> Vec<Item<'a>> {
if crate::text::per_char_3d(ctx, layer) {
return crate::text::per_char_planes(ctx, layer, r.opts.scale)
.into_iter()
@@ -218,7 +218,7 @@ struct PlaneInput {
}
/// Prepare the plane's geometry, motion samples and material.
fn prepare_with<'a>(r: &Renderer, ctx: &EvalCtx<'a>, layer: &'a Layer, order: usize, out: (u32, u32), in_run: bool, plane: PlaneInput) -> Option<Item<'a>> {
fn prepare_with<'a>(r: &Renderer, ctx: &EvalCtx<'_>, layer: &'a Layer, order: usize, out: (u32, u32), in_run: bool, plane: PlaneInput) -> Option<Item<'a>> {
let PlaneInput { mut buf, local } = plane;
if buf.img.is_empty() {
return None;
@@ -449,8 +449,8 @@ fn gather<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, run: &[&'a Layer], out: (u32,
.flat_map_iter(|(i, l)| {
// Stack order with room for a collapsed precomp's layers in between.
let i = i * 1024;
if let Some(item) = r.collapsed(ctx, l) {
return collapsed_items(r, ctx, l, item, i, out);
if let Some(collapsed) = r.collapsed(ctx, l) {
return collapsed_items(r, ctx, l, &collapsed, i, out);
}
let mut its = prepare_all(r, ctx, l, i, out, true);
if !its.is_empty() {
@@ -483,20 +483,13 @@ fn gather<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, run: &[&'a Layer], out: (u32,
/// 3D space (world = precomp layer's world × nested world), depth-sorted with the run, so they
/// intersect the parent's 3D layers; nested 2D layers lie on the precomp layer's plane in stack
/// order. (Nested adjustment layers are skipped here.)
fn collapsed_items<'a>(
r: &Renderer<'a>,
ctx: &EvalCtx<'a>,
layer: &'a Layer,
item: effectcraft_project::ItemId,
order: usize,
out: (u32, u32),
) -> Vec<Item<'a>> {
fn collapsed_items<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: &'a Layer, collapsed: &crate::Collapsed, order: usize, out: (u32, u32)) -> Vec<Item<'a>> {
let op = ctx.opacity(layer) as f32 * r.opacity_mul();
let Some((sub, nctx)) = r.collapse_into(ctx, layer, item, op) else { return vec![] };
let Some((sub, nctx)) = r.collapse_into(ctx, layer, collapsed, op) else { return vec![] };
let t = nctx.time;
let any_solo = nctx.comp.any_solo();
let nested: Vec<&'a Layer> = nctx
.comp
let Some(nc) = r.project.comp(collapsed.item) else { return vec![] };
let any_solo = nc.any_solo();
let nested: Vec<&'a Layer> = nc
.layers
.iter()
.rev()
@@ -863,7 +856,7 @@ pub(crate) fn aux_pass(r: &Renderer, ctx: &EvalCtx) -> effectcraft_raster::AuxCh
if nv[2] > 0.0 {
nv = nv.map(|v| -v);
}
let (sw, sh) = crate::source_size(r.project, it.layer);
let (sw, sh) = ctx.source_size(it.layer);
let size = if sw == 0 { [ctx.comp.width as f64, ctx.comp.height as f64] } else { [sw as f64, sh as f64] };
(number(it.layer), source_id(it.layer), crypto_hash(&it.layer.name), crypto_hash(&source_name(it.layer)), nv, size)
})
+10 -1
View File
@@ -222,7 +222,16 @@ fn position_now(s: &Session, layer: u64) -> [f64; 2] {
let cid = s.active_comp_id().unwrap();
let comp = s.project.comp(cid).unwrap();
let l = comp.layer(effectcraft_engine::project::LayerId(layer)).unwrap();
let ctx = effectcraft_engine::render::EvalCtx { project: &s.project, comp_id: cid, comp, time: s.time(), expr: s.expr.as_deref(), footage: None };
let ctx = effectcraft_engine::render::EvalCtx {
project: &s.project,
comp_id: cid,
comp,
time: s.time(),
expr: s.expr.as_deref(),
footage: None,
property_overrides: &[],
media_overrides: &[],
};
let v = ctx.value(l, l.props.prop("transform/position").unwrap()).components();
[v[0], v[1]]
}
+10 -1
View File
@@ -151,7 +151,16 @@ fn vertex_delta(app: &EffectcraftApp, layer: effectcraft_engine::project::LayerI
let s = &app.session;
let (Some(cid), Some(comp)) = (s.active_comp_id(), s.active_comp()) else { return d };
let Some(l) = comp.layer(layer) else { return d };
let ectx = effectcraft_engine::render::EvalCtx { project: &s.project, comp_id: cid, comp, time: s.time(), expr: s.expr.as_deref(), footage: None };
let ectx = effectcraft_engine::render::EvalCtx {
project: &s.project,
comp_id: cid,
comp,
time: s.time(),
expr: s.expr.as_deref(),
footage: None,
property_overrides: &[],
media_overrides: &[],
};
let (l2c, _) = crate::panels::viewer::l2c(&ectx, l);
l2c.inverse().map_or(d, |inv| {
let v = inv.apply_vec(vec2(d[0], d[1]));
@@ -166,7 +166,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let comp_name = app.session.project.item(cid).map(|i| i.name.clone()).unwrap_or_default();
let snap_project = app.session.project.clone();
let snap_expr = app.session.expr.clone();
let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp: &comp, time: app.session.time(), expr: snap_expr.as_deref(), footage: None };
let ectx = EvalCtx {
project: &snap_project,
comp_id: cid,
comp: &comp,
time: app.session.time(),
expr: snap_expr.as_deref(),
footage: None,
property_overrides: &[],
media_overrides: &[],
};
let hdr = Rect::from_min_size(rect.min, vec2(rect.width(), 24.0));
p.text(pos2(hdr.min.x + 10.0, hdr.center().y), Align2::LEFT_CENTER, format!("{} • {}", comp_name, layer.name), Tokens::ui(11.5), t.text_dim);
p.line_segment([hdr.left_bottom(), hdr.right_bottom()], Stroke::new(1.0, t.separator));
+10 -1
View File
@@ -104,7 +104,16 @@ fn render_tex(
match cached {
Some(c) if c.key == key => Some(c),
_ => {
let ectx = EvalCtx { project: &app.session.project, comp_id: cid, comp, time, expr: app.session.expr.as_deref(), footage: None };
let ectx = EvalCtx {
project: &app.session.project,
comp_id: cid,
comp,
time,
expr: app.session.expr.as_deref(),
footage: None,
property_overrides: &[],
media_overrides: &[],
};
let mut r = Renderer::new(&app.session.project, &*app.session.footage, RenderOpts { scale, ..Default::default() });
r.expr = app.session.expr.as_deref();
r.cache = Some(&app.session.layer_cache);
+10 -1
View File
@@ -70,7 +70,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let project = app.session.project.clone();
let expr = app.session.expr.clone();
let now = app.session.time();
let ectx = EvalCtx { project: &project, comp_id: cid, comp: &comp, time: now, expr: expr.as_deref(), footage: None };
let ectx = EvalCtx {
project: &project,
comp_id: cid,
comp: &comp,
time: now,
expr: expr.as_deref(),
footage: None,
property_overrides: &[],
media_overrides: &[],
};
let x0 = rect.min.x + PAD;
let w = rect.width() - 2.0 * PAD;
let scroll = widgets::PanelScroll::begin(ui, egui::Id::unique("props-scroll"), rect);
+10 -1
View File
@@ -69,7 +69,16 @@ pub fn draw_overlay(
let ov = match cached {
Some(c) if c.key == key => Some(c),
_ => {
let ectx = EvalCtx { project: &app.session.project, comp_id: cid, comp, time, expr: app.session.expr.as_deref(), footage: None };
let ectx = EvalCtx {
project: &app.session.project,
comp_id: cid,
comp,
time,
expr: app.session.expr.as_deref(),
footage: None,
property_overrides: &[],
media_overrides: &[],
};
let mut r = Renderer::new(&app.session.project, &*app.session.footage, RenderOpts { scale, ..Default::default() });
r.expr = app.session.expr.as_deref();
r.cache = Some(&app.session.layer_cache);
+10 -1
View File
@@ -57,7 +57,16 @@ pub fn text_target(app: &EffectcraftApp) -> Option<TextTarget> {
.iter()
.filter_map(|id| comp.layer(*id))
.find(|l| matches!(l.source, effectcraft_engine::project::LayerSource::Text))?;
let ectx = EvalCtx { project: &app.session.project, comp_id: cid, comp, time: app.session.time(), expr: app.session.expr.as_deref(), footage: None };
let ectx = EvalCtx {
project: &app.session.project,
comp_id: cid,
comp,
time: app.session.time(),
expr: app.session.expr.as_deref(),
footage: None,
property_overrides: &[],
media_overrides: &[],
};
effectcraft_engine::render::text::source_text(&ectx, layer).map(|d| TextTarget { layer: layer.id.0, doc: d, range: None })
}
+10 -1
View File
@@ -1865,7 +1865,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
pick_whip_edge_scroll(app, g);
let snap_project = app.session.project.clone();
let snap_expr = app.session.expr.clone();
let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp: &comp, time: g.time, expr: snap_expr.as_deref(), footage: None };
let ectx = EvalCtx {
project: &snap_project,
comp_id: cid,
comp: &comp,
time: g.time,
expr: snap_expr.as_deref(),
footage: None,
property_overrides: &[],
media_overrides: &[],
};
let mut rw = begin_rows(app, ui, tl);
let g = &mut rw;
draw_rows(app, ui, g, &ectx);
+10 -1
View File
@@ -178,7 +178,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
phase_frame(app, &mut v);
let snap_project = app.session.project.clone();
let snap_expr = app.session.expr.clone();
let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp: &comp, time: v.time, expr: snap_expr.as_deref(), footage: None };
let ectx = EvalCtx {
project: &snap_project,
comp_id: cid,
comp: &comp,
time: v.time,
expr: snap_expr.as_deref(),
footage: None,
property_overrides: &[],
media_overrides: &[],
};
phase_guides(app, &ectx, &mut v);
phase_layers(app, ui, &ectx, &mut v);
phase_scene(app, &ectx, &mut v);
@@ -202,6 +202,8 @@ pub(super) fn create_shape(app: &mut EffectcraftApp, tool: Tool, a: [f64; 2], b:
time: app.session.time(),
expr: None,
footage: None,
property_overrides: &[],
media_overrides: &[],
};
let inv = l2c(&ectx, &l).0.inverse().unwrap_or(Mat3::IDENTITY);
let p0 = inv.apply(gv2(cx - w / 2.0, cy - h / 2.0));
+10 -1
View File
@@ -44,7 +44,16 @@ pub(super) fn aux_views(
// layout shows them), evaluated on a snapshot of the project.
let snap_project = app.session.project.clone();
let snap_expr = app.session.expr.clone();
let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp, time: t, expr: snap_expr.as_deref(), footage: None };
let ectx = EvalCtx {
project: &snap_project,
comp_id: cid,
comp,
time: t,
expr: snap_expr.as_deref(),
footage: None,
property_overrides: &[],
media_overrides: &[],
};
let selected = app.session.state.selected_layers.clone();
let mut picked: Option<(usize, View3D)> = None;
for (i, (r, v)) in aux.into_iter().enumerate() {
+8 -3
View File
@@ -401,7 +401,10 @@ hierarchical block-matching optical flow and a bidirectional warp (Pixel Motion,
its nested layers straight into the parent with concatenated transforms (one resample), so
nested blend modes and adjustment layers act on the parent's layers; nested 3D layers use the
parent's camera and lights and, when the precomp layer is 3D, are depth-sorted with the parent's
3D run. Masks, effects or styles force a flattened render. Text layers are always rasterised at
3D run. Essential Properties overrides (properties, Font and Media Replacement) travel with the
collapsed context and are applied while the nested layers are evaluated, so they do not force a
flattened render or clip at the precomp's frame. Masks, effects or styles force a flattened
render. Text layers are always rasterised at
their on-screen scale, as text is vector in After Effects; on shape layers and vector footage the
switch is Continuously Rasterize and does the same. Quality: Draft samples
nearest-neighbour, Wireframe draws the layer bounds. Slip edit (`layer.slip`, Alt+PageUp/Down,
@@ -468,8 +471,10 @@ effects crate, hence the `effects → track` edge.
`essential` field lists its exposed controls (properties, Media Replacement, groups, comments).
Every precomp layer of such a comp gets an Essential Properties group (`GroupKind::Essential`,
children `eg<control id>`), kept in step by `Session::edit`; editing a child overrides it for
that instance. The renderer draws an instance with overrides from a copy of the project with the
overridden source properties set (`essential::with_overrides`). Templates are `.ectemplate`
that instance. A collapsed instance carries its property, Font and Media Replacement overrides
into the nested render context (`Collapsed`, `EvalCtx::{property_overrides, media_overrides}`);
other instances are drawn from a copy of the project with the overridden source properties set
(`essential::with_overrides`). Templates are `.ectemplate`
ZIPs: `manifest.json` (controls), `project.ecproj` (the comp and its dependencies), `media/…` and
`poster.png`; importing offsets every id past the project's (`essential::offset_ids`).
**Responsive Design — Time**: a time-stretched precomp maps time piecewise so its protected