Merge branch 'worktree-agent-a549a3d1a8812e372'

# Conflicts:
#	apps/effectcraft-cli/src/main.rs
#	crates/host/src/lib.rs
#	crates/render/src/lib.rs
This commit is contained in:
Brandon Thomas
2026-10-01 22:42:32 -04:00
33 changed files with 3541 additions and 253 deletions
+26 -1
View File
@@ -33,6 +33,16 @@ impl<'a> EvalCtx<'a> {
}
/// Value of a property at the context time (keyframes, then expression).
pub fn value(&self, layer: &Layer, prop: &Property) -> Value {
// Separated Position reads as the combination of X/Y/Z Position.
if prop.match_id == "position"
&& let Some(tr) = layer.transform()
&& let Some(px) = tr.get("positionX")
&& tr.get("position").is_some_and(|p| p.uid == prop.uid)
{
let z = tr.get("positionZ").map(|p| self.value(layer, p).as_f64()).unwrap_or(0.0);
let y = tr.get("positionY").map(|p| self.value(layer, p).as_f64()).unwrap_or(0.0);
return Value::Vec3([self.value(layer, px).as_f64(), y, z]);
}
let lt = layer.layer_time(self.time);
let v = prop.value_at(lt);
if prop.has_expression()
@@ -63,6 +73,21 @@ impl<'a> EvalCtx<'a> {
pub fn b(&self, layer: &Layer, g: &PropGroup, m: &str) -> bool {
self.group_value(layer, g, m).map(|v| v.as_bool()).unwrap_or(false)
}
/// Layer Position, honouring Separate Dimensions (X/Y/Z Position properties).
pub fn position(&self, layer: &Layer, tr: &PropGroup) -> [f64; 3] {
match tr.get("positionX") {
Some(px) => [self.value(layer, px).as_f64(), self.f(layer, tr, "positionY", 0.0), self.f(layer, tr, "positionZ", 0.0)],
None => self.v3(layer, tr, "position", [0.0; 3]),
}
}
/// Source time of a layer at the context time: Time Remap's value when enabled, else the
/// (stretch-aware) layer time.
pub fn source_time(&self, layer: &Layer) -> Tick {
if let Some(tr) = layer.props.get("timeRemap") {
return Tick::from_seconds_f64(self.value(layer, tr).as_f64());
}
layer.layer_time(self.time)
}
pub fn layer(&self, id: LayerId) -> Option<&'a Layer> {
self.comp.layer(id)
}
@@ -71,7 +96,7 @@ impl<'a> EvalCtx<'a> {
pub fn local_matrix(&self, layer: &Layer) -> Mat4 {
let Some(tr) = layer.transform() else { return Mat4::IDENTITY };
let three = layer.is_3d();
let pos = self.v3(layer, tr, "position", [0.0; 3]);
let pos = self.position(layer, tr);
let rz = self.f(layer, tr, "rotation", 0.0);
if layer.is_camera() || layer.is_light() {
return Mat4::translate(Vec3::from(pos)) * Mat4::rotate_z(rz);
+4 -2
View File
@@ -201,7 +201,7 @@ impl<'a> Renderer<'a> {
return None;
}
let sub = Renderer { depth: self.depth + 1, ..*self };
let lt = layer.layer_time(ctx.time);
let lt = ctx.source_time(layer);
Some(Buf { img: sub.comp_frame(*item, lt), offset: [0.0; 2], scale: s })
}
LayerSource::Footage { item } => {
@@ -210,7 +210,7 @@ impl<'a> Renderer<'a> {
if !f.has_video {
return None;
}
let lt = layer.layer_time(ctx.time);
let lt = ctx.source_time(layer);
let img = self.footage.frame(*item, f, lt)?;
let k = if img.width > 0 { f.width.max(1) as f64 / img.width as f64 } else { 1.0 };
// Keep native pixels when downsampling is small; resample otherwise.
@@ -392,6 +392,8 @@ pub fn render_frame(project: &Project, comp: ItemId, t: Tick, scale: f64) -> Ima
#[cfg(test)]
mod tests;
#[cfg(test)]
mod tests_remap;
/// Layer-space bounds `[x0, y0, x1, y1]` of a layer's content at the context time (source size
/// for solids/footage/precomps, glyph bounds for text, painted bounds for shapes). Used for viewer
+65
View File
@@ -0,0 +1,65 @@
//! Time remapping and separated Position dimensions.
use effectcraft_color::Label;
use effectcraft_keyframe::{Keyframe, Value};
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{Comp, ItemKind, LayerSource, Node, Project, Solid};
use effectcraft_time::{FrameRate, Tick};
use crate::render_frame;
fn s(x: f64) -> Tick {
Tick::from_seconds_f64(x)
}
#[test]
fn separated_position_drives_the_transform() {
let mut p = Project::default();
let comp = Comp::new(200, 100, FrameRate::FPS_30, s(2.0));
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
let sid = p.add_item("S", Label::Red, None, ItemKind::Solid(Solid { color: [1.0, 1.0, 1.0], width: 20, height: 20, pixel_aspect: 1.0 }));
let mut l = build::layer(&mut p, &comp, "S", LayerSource::Solid { item: sid }, (20, 20), None);
let mut next = p.next_id;
let mut ids = Ids(&mut next);
let mut px = ids.prop("positionX", "X Position", Value::Scalar(20.0));
px.keys = vec![Keyframe::new(Tick::ZERO, Value::Scalar(20.0)), Keyframe::new(s(1.0), Value::Scalar(180.0))];
let py = ids.prop("positionY", "Y Position", Value::Scalar(50.0));
let pz = ids.prop("positionZ", "Z Position", Value::Scalar(0.0));
p.next_id = next;
let tr = l.props.sub_mut("transform").unwrap();
// The combined property is ignored while separated.
tr.get_mut("position").unwrap().value = Value::Vec3([100.0, 10.0, 0.0]);
tr.children.extend([Node::Prop(px), Node::Prop(py), Node::Prop(pz)]);
p.comp_mut(cid).unwrap().layers.push(l);
let a = render_frame(&p, cid, Tick::ZERO, 1.0);
assert!(a.get(20, 50)[3] > 0.99 && a.get(100, 10)[3] < 0.01);
let b = render_frame(&p, cid, s(0.5), 1.0);
assert!(b.get(100, 50)[3] > 0.99 && b.get(20, 50)[3] < 0.01);
}
#[test]
fn time_remap_picks_the_source_frame() {
let mut p = Project::default();
let outer = Comp::new(100, 100, FrameRate::FPS_30, s(4.0));
let mut inner = Comp::new(100, 100, FrameRate::FPS_30, s(4.0));
// Inner comp: a solid that moves from x=10 (t=0) to x=90 (t=2).
let sid = p.add_item("S", Label::Red, None, ItemKind::Solid(Solid { color: [1.0, 1.0, 1.0], width: 10, height: 10, pixel_aspect: 1.0 }));
let mut l = build::layer(&mut p, &inner, "S", LayerSource::Solid { item: sid }, (10, 10), None);
l.props.prop_mut("transform/position").unwrap().keys =
vec![Keyframe::new(Tick::ZERO, Value::Vec3([10.0, 50.0, 0.0])), Keyframe::new(s(2.0), Value::Vec3([90.0, 50.0, 0.0]))];
inner.layers.push(l);
let iid = p.add_item("Inner", Label::Sandstone, None, ItemKind::Comp(inner.into()));
let cid = p.add_item("Outer", Label::Sandstone, None, ItemKind::Comp(outer.clone().into()));
let mut pre = build::layer(&mut p, &outer, "Inner", LayerSource::Comp { item: iid }, (100, 100), None);
let mut next = p.next_id;
// Freeze on source time 2 s.
let tr = Ids(&mut next).prop("timeRemap", "Time Remap", Value::Scalar(2.0));
p.next_id = next;
let mut tr = tr;
tr.keys = vec![Keyframe::new(Tick::ZERO, Value::Scalar(2.0)).hold()];
pre.props.children.push(Node::Prop(tr));
p.comp_mut(cid).unwrap().layers.push(pre);
let img = render_frame(&p, cid, Tick::ZERO, 1.0);
assert!(img.get(90, 50)[3] > 0.99, "frozen at the end position");
assert!(img.get(10, 50)[3] < 0.01);
}