Effects: effect points on shape layers start at the layer centre (#227)

Shape and text layers have comp-sized effect bounds centred on their
origin, but effects measured positions from the origin: a point at its
default "layer centre" sat at the comp's bottom-right corner, so Twirl
on a centred shape changed nothing and Bulge only its bottom-right
quarter. As in After Effects, effects now work in effect space,
measured from the top-left of the layer's effect bounds.

The renderer moves the layer buffer into effect space for the whole
stack (Buf::rebase, CPU and GPU alike) and hands effects everything
else in it: masks, other layers' pixels for layer parameters (each in
its own effect space, so Set Matte and displacement maps see a whole
shape layer), the layer's own frames (layer_input, also what Puppet,
Roto Brush and the Layer panel read) and the comp camera
(CompScene::rebased). Layer bounds are now right for every effect, so
pin_rect and EffectEnv::bounds_origin go and layer_rect serves edge
pinning too. Adjustment layers still run on the comp below as it is.

effect_bounds / EvalCtx::effect_to_comp replace the repeated "comp size
when there is no source" code; the viewer's effect point handles and
eyedroppers, Puppet pins, the Layer panel (paint, Roto Brush) and the
tracker's Corner Pin output use them.

Project schema 2: opening a schema 1 project moves positions held by
effects on shape and text layers by half the comp size, so set points
keep their place (point parameters with keyframes, Paint paths and
clone positions, Puppet seeds and pins, Liquify and Roto Brush
strokes). Points still at their default become the layer centre;
expression controls, adjustment layers and expressions are left as
they are (docs/architecture.md). The disk cache's render version is
bumped.

Tests: default_effect_points_sit_at_the_centre_of_a_shape_layer and
layer_parameters_see_a_whole_shape_layer (render, both fail before),
effect_points_on_a_shape_layer (GPU vs CPU),
opening_a_schema_1_project_moves_shape_layer_effect_points_into_effect_space
(engine), and the effects migration tests.

Part of #227

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
bflatasticandClaude Opus 5.5 committed 2026-10-08 20:33:03 +07:00
1 parent 52ab38fcff
commit 940bea3ba7
36 files changed
+683 -153

No files matched your search

+26 -3
View File
@@ -298,13 +298,36 @@ pub struct CompLight {
/// The comp's camera and lights relative to the effect's layer.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct CompScene {
/// Layer pixels (x, y, z) → layer pixels, homogeneous rows X, Y, W: the comp camera's view
/// of the layer brought back into the layer's own pixel grid (identity on the layer plane
/// for the default camera).
/// Effect-space pixels (x, y, z) → effect-space pixels, homogeneous rows X, Y, W: the comp
/// camera's view of the layer brought back into the layer's own pixel grid (identity on the
/// layer plane for the default camera).
pub camera: Option<[[f64; 4]; 3]>,
pub light: Option<CompLight>,
}
impl CompScene {
/// The scene measured from `origin` (in its current coordinates, as [`crate::Buf::rebase`]):
/// a point `p` becomes `p − origin`.
pub fn rebased(mut self, origin: [f64; 2]) -> CompScene {
if let Some(c) = &mut self.camera {
for row in c.iter_mut() {
row[3] += row[0] * origin[0] + row[1] * origin[1];
}
let w = c[2];
for (row, o) in c.iter_mut().zip(origin) {
for (v, wv) in row.iter_mut().zip(w) {
*v -= o * wv;
}
}
}
if let Some(l) = &mut self.light {
l.pos[0] -= origin[0];
l.pos[1] -= origin[1];
}
self
}
}
fn sub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}
+3 -3
View File
@@ -6,7 +6,7 @@ use effectcraft_project::ParamUi;
use effectcraft_raster::Image;
use rayon::prelude::*;
use crate::util::pin_rect;
use crate::util::layer_rect;
use crate::{Buf, EffectCtx, EffectSpec, num, p, popup, slider};
fn spec(id: &'static str, name: &'static str, params: Vec<crate::ParamSpec>, render: crate::RenderFn) -> EffectSpec {
@@ -68,7 +68,7 @@ fn bulge(ctx: &EffectCtx, mut b: Buf) -> Buf {
// Taper Radius: higher values make the sides of the bulge shallower (a more peaked profile).
let taper = 1.0 + ctx.params.f("taperRadius").max(0.0) / 100.0 * 3.0;
let pin = ctx.params.b("pinAllEdges");
let (lx, ly, lw, lh) = pin_rect(ctx, b.offset, b.scale);
let (lx, ly, lw, lh) = layer_rect(ctx, &b);
let edge = (lw.min(lh) * 0.1).max(1.0);
b.img = remap(&b.img, false, |x, y| {
let (dx, dy) = ((x - c.0) / rx, (y - c.1) / ry);
@@ -168,7 +168,7 @@ fn wave_warp(ctx: &EffectCtx, mut b: Buf) -> Buf {
if !ctx.adjustment {
b.pad(h.abs().ceil() as u32 + 1);
}
let (x0, y0, lw, lh) = pin_rect(ctx, b.offset, b.scale);
let (x0, y0, lw, lh) = layer_rect(ctx, &b);
let rect = (x0, y0, lw.max(1.0), lh.max(1.0));
b.img = remap(&b.img, false, |x, y| {
// Waves travel along `dir`; displacement is perpendicular to it.
+2 -2
View File
@@ -20,7 +20,7 @@ use effectcraft_raster::{Image, Px};
use rayon::prelude::*;
use crate::generate::value_noise;
use crate::util::{SRC_NAMES, Src, fit_layer, layer_rect, lerp4, map_xy, pick, pin_rect, premul, remap, smoothstep, src_at, unpremul};
use crate::util::{SRC_NAMES, Src, fit_layer, layer_rect, lerp4, map_xy, pick, premul, remap, smoothstep, src_at, unpremul};
use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider};
fn spec(id: &'static str, name: &'static str, params: Vec<crate::ParamSpec>, render: crate::RenderFn) -> EffectSpec {
@@ -103,7 +103,7 @@ fn turbulent_displace(ctx: &EffectCtx, mut b: Buf) -> Buf {
let locked = pin_raw >= 8;
let pin = if locked { pin_raw - 7 } else { pin_raw };
let falloff = if (3..=5).contains(&kind) { 0.3 } else { 0.5 };
let (lx, ly, lw, lh) = pin_rect(ctx, b.offset, b.scale);
let (lx, ly, lw, lh) = layer_rect(ctx, &b);
let edge = (lw.min(lh) * if locked { 0.25 } else { 0.1 }).max(1.0);
let seed = (ctx.seed ^ 0x7d15).wrapping_add((ctx.params.f("evolutionOptions/randomSeed") as i64 as u32).wrapping_mul(0x9e37_79b9));
let n = |u: f64, v: f64, s: u32| {
+8 -6
View File
@@ -238,7 +238,8 @@ pub fn flatten_params(g: &PropGroup, eval: &mut dyn FnMut(&Property) -> Value) -
Params { values }
}
/// A layer image in flight: layer-space point `p` sits at pixel `p * scale + offset`.
/// A layer image in flight: point `p` sits at pixel `p * scale + offset`. Points are in layer
/// space, or in effect space while the layer's effects run (see [`Buf::rebase`]).
#[derive(Clone, Debug, Default)]
pub struct Buf {
pub img: Image,
@@ -250,6 +251,12 @@ impl Buf {
pub fn to_px(&self, p: [f64; 2]) -> (f64, f64) {
(p[0] * self.scale + self.offset[0], p[1] * self.scale + self.offset[1])
}
/// Measure from `origin` (in the current coordinates): a point `p` becomes `p − origin`.
/// Moves between layer space and effect space (the renderer's `effect_bounds`).
pub fn rebase(&mut self, origin: [f64; 2]) {
self.offset[0] += origin[0] * self.scale;
self.offset[1] += origin[1] * self.scale;
}
/// Grow by `pad` transparent pixels on every side.
pub fn pad(&mut self, pad: u32) {
if pad == 0 {
@@ -347,11 +354,6 @@ pub struct EffectEnv<'a> {
/// comp's and the layer's motion blur switches are on (Transform's Use Composition's
/// Shutter Angle).
pub shutter: Option<(f64, f64, u32)>,
/// Top-left corner of the layer bounds ([`EffectCtx::layer_size`]) in layer coordinates:
/// (0, 0) for layers with a source rectangle; shape and text layers have comp-sized bounds
/// centred on their origin (−w/2, −h/2). Edge pinning measures from these bounds
/// ([`util::pin_rect`]).
pub bounds_origin: [f64; 2],
}
/// What an effect gets to render with.
+232
View File
@@ -339,6 +339,120 @@ pub fn upgrade_instance(spec: &EffectSpec, g: &mut PropGroup, ids: &mut Ids, lay
*g != before
}
/// Bring an effect instance saved before project schema 2 into effect space (#227).
///
/// Layers without a source rectangle (shape, text) have comp-sized effect bounds centred on
/// their origin. Effects used to measure positions from the origin, so the default "layer
/// centre" sat at the comp's bottom-right corner; they now measure from the bounds' top-left,
/// as After Effects does. `d` is the bounds' half size: positions the instance holds move by it
/// so they keep their place in the comp (point parameters with their keyframes, Paint stroke
/// paths and clone positions, Puppet mesh seeds and pin rest points, Liquify and Roto Brush
/// strokes). A point parameter still at its default (`layer_size`) and not animated stays: it
/// meant the layer centre and now is. Expression controls keep their values (what they mean is
/// up to the expressions reading them), and expressions are not rewritten.
///
/// `clone_d(source)`: the shift of a Paint clone stroke's source layer (`None` = this layer),
/// whose effect space its Clone Position is in.
pub fn to_effect_space(spec: &EffectSpec, g: &mut PropGroup, layer_size: [f64; 2], d: [f64; 2], clone_d: &dyn Fn(Option<u64>) -> [f64; 2]) {
if spec.id.starts_with("ec.control.") {
return;
}
let defaults: Vec<u64> = spec
.params
.iter()
.filter(|ps| matches!(ps.ui, ParamUi::Point | ParamUi::Point3))
.filter_map(|ps| g.prop(ps.id).filter(|pr| pr.keys.is_empty() && same_point(&pr.value, &default_value(ps, layer_size))).map(|pr| pr.uid))
.collect();
let at = Shift { d, clone_d, defaults: &defaults, effect: spec.id };
at.group(g);
}
/// What [`to_effect_space`] moves, and by how much.
struct Shift<'a> {
d: [f64; 2],
clone_d: &'a dyn Fn(Option<u64>) -> [f64; 2],
/// Point parameters left at their defaults.
defaults: &'a [u64],
effect: &'a str,
}
impl Shift<'_> {
fn group(&self, g: &mut PropGroup) {
// A Paint clone stroke's Clone Position is in its source layer's effect space.
let clone_d = g.get("clone_source").map(|s| (self.clone_d)(s.value.as_layer())).unwrap_or(self.d);
for n in &mut g.children {
match n {
Node::Group(sg) => self.group(sg),
Node::Prop(pr) => {
let d = if pr.match_id == "clone_position" { clone_d } else { self.d };
let kind = match (&pr.ui, pr.match_id.as_str()) {
(ParamUi::Point | ParamUi::Point3, _) if !self.defaults.contains(&pr.uid) => Some(Data::Point),
(ParamUi::Path, _) => Some(Data::Point),
(ParamUi::Hidden, "seed" | "rest") if self.effect == crate::puppet::ID => Some(Data::Point),
(ParamUi::Hidden, "distortionMesh") if self.effect == "ec.distort.liquify" => Some(Data::Liquify),
(ParamUi::Hidden, crate::roto::STROKES) if self.effect == crate::roto::ID => Some(Data::Roto),
_ => None,
};
if let Some(kind) = kind.filter(|_| d != [0.0; 2]) {
shift_value(&mut pr.value, kind, d);
for k in &mut pr.keys {
shift_value(&mut k.value, kind, d);
}
}
}
}
}
}
}
/// How a value holds positions.
#[derive(Clone, Copy)]
enum Data {
/// A point (2D or 3D: x and y move) or a path (its vertices; tangents are relative).
Point,
/// Liquify's Distortion Mesh text (`distort4::LiquifyStroke` lines).
Liquify,
/// Roto Brush's Strokes JSON ([`effectcraft_track::roto::RotoData`]).
Roto,
}
fn shift_value(v: &mut Value, kind: Data, d: [f64; 2]) {
let add = |p: &mut [f64; 2]| *p = [p[0] + d[0], p[1] + d[1]];
match (kind, v) {
(Data::Point, Value::Vec2(p)) => add(p),
(Data::Point, Value::Vec3(p)) => *p = [p[0] + d[0], p[1] + d[1], p[2]],
(Data::Point, Value::Path(sp)) => sp.vertices.iter_mut().for_each(add),
(Data::Liquify, Value::Str(s)) => {
// Lines that don't read as strokes are kept as they are.
let lines: Vec<String> = s
.lines()
.map(|l| match crate::distort4::LiquifyStroke::parse(l) {
Some(mut st) => {
st.points.iter_mut().for_each(add);
st.to_line()
}
None => l.to_string(),
})
.collect();
*s = lines.join("\n");
}
(Data::Roto, Value::Str(s)) => {
// Unreadable data is kept as it is.
if let Ok(mut r) = serde_json::from_str::<effectcraft_track::roto::RotoData>(s) {
r.strokes.iter_mut().flat_map(|st| st.points.iter_mut()).for_each(add);
*s = r.to_json();
}
}
_ => {}
}
}
/// Two point values equal up to float noise.
fn same_point(a: &Value, b: &Value) -> bool {
let (a, b) = (a.components(), b.components());
a.len() == b.len() && a.iter().zip(&b).all(|(x, y)| (x - y).abs() < 1e-6)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -467,4 +581,122 @@ mod tests {
assert!(upgrade_instance(spec, &mut g, &mut Ids(&mut next), [100.0, 50.0]));
assert_eq!(g.get("viewingMode").unwrap().value, Value::Enum(4), "Final Output");
}
/// The shift a 200 × 100 shape layer's effect positions get (#227): half its bounds.
const D: [f64; 2] = [100.0, 50.0];
const SIZE: [f64; 2] = [200.0, 100.0];
fn instance(id: &str, next: &mut u64) -> (&'static EffectSpec, PropGroup) {
let spec = find(id).unwrap();
(spec, instantiate(spec, &mut Ids(next), spec.name, SIZE))
}
fn own(_: Option<u64>) -> [f64; 2] {
D
}
#[test]
fn effect_points_move_into_effect_space_unless_left_at_their_default() {
let mut next = 1;
// Twirl: a set centre (with keyframes) moves; Bulge's default centre stays.
let (spec, mut twirl) = instance("ec.distort.twirl", &mut next);
let c = twirl.get_mut("center").unwrap();
c.value = Value::Vec2([-30.0, -10.0]);
let key = |t: i64, v: Value| {
let mut k = effectcraft_keyframe::Keyframe::new(Default::default(), v);
k.time.0 = t;
k
};
c.keys = vec![key(0, Value::Vec2([0.0, 0.0])), key(1000, Value::Vec2([5.0, -5.0]))];
to_effect_space(spec, &mut twirl, SIZE, D, &own);
let c = twirl.get("center").unwrap();
assert_eq!(c.value, Value::Vec2([70.0, 40.0]));
assert_eq!(c.keys.iter().map(|k| k.value.clone()).collect::<Vec<_>>(), vec![Value::Vec2([100.0, 50.0]), Value::Vec2([105.0, 45.0])]);
let (spec, mut bulge) = instance("ec.distort.bulge", &mut next);
let before = bulge.clone();
to_effect_space(spec, &mut bulge, SIZE, D, &own);
assert_eq!(bulge, before, "the default centre now means the layer centre");
// Expression controls keep their values.
let (spec, mut ctl) = instance("ec.control.point", &mut next);
let pr = ctl.props().find(|p| p.ui == ParamUi::Point).unwrap().uid;
ctl.find_mut(pr).unwrap().value = Value::Vec2([5.0, 5.0]);
to_effect_space(spec, &mut ctl, SIZE, D, &own);
assert_eq!(ctl.find(pr).unwrap().value, Value::Vec2([5.0, 5.0]));
}
#[test]
fn puppet_paint_liquify_and_roto_data_move_into_effect_space() {
let mut next = 1;
// Puppet: mesh seed, pin rest and Position.
let (spec, mut puppet) = instance(crate::puppet::ID, &mut next);
let mut mesh = crate::puppet::mesh_group(&mut Ids(&mut next), "Mesh 1", [1.0, 2.0], &crate::puppet::MeshOpts::default());
let pin = crate::puppet::pin_group(&mut Ids(&mut next), "Puppet Pin 1", crate::puppet::PinKind::Position, [3.0, 4.0]);
mesh.sub_mut("deform").unwrap().children.push(pin.into());
puppet.children.push(mesh.into());
to_effect_space(spec, &mut puppet, SIZE, D, &own);
let mesh = puppet.sub("mesh").unwrap();
assert_eq!(mesh.get("seed").unwrap().value, Value::Vec2([101.0, 52.0]));
let pin = mesh.sub("deform").unwrap().sub("pin").unwrap();
assert_eq!(pin.get("rest").unwrap().value, Value::Vec2([103.0, 54.0]));
assert_eq!(pin.get("position").unwrap().value, Value::Vec2([103.0, 54.0]));
// Paint: the stroke path, its transform, and a clone position in its source layer's space.
let (spec, mut paint) = instance(crate::paint::ID, &mut next);
let s = crate::paint::StrokeSpec {
kind: crate::paint::StrokeKind::Clone,
points: vec![[0.0, 0.0], [10.0, 0.0]],
clone_source: Some(7),
clone_position: [20.0, 20.0],
..Default::default()
};
paint.children.push(crate::paint::stroke_group(&mut Ids(&mut next), "Clone 1", &s).into());
to_effect_space(spec, &mut paint, SIZE, D, &|src| if src == Some(7) { [0.0; 2] } else { D });
let st = paint.sub("clone").unwrap();
let Value::Path(path) = &st.get("path").unwrap().value else { panic!("no path") };
assert_eq!(path.vertices, vec![[100.0, 50.0], [110.0, 50.0]]);
assert_eq!(st.sub("transform").unwrap().get("position").unwrap().value, Value::Vec2([100.0, 50.0]));
assert_eq!(st.sub("stroke_options").unwrap().get("clone_position").unwrap().value, Value::Vec2([20.0, 20.0]), "a footage source");
// Liquify: stroke points move, the clone offset (relative) doesn't; unknown lines stay.
let (spec, mut liquify) = instance("ec.distort.liquify", &mut next);
let line =
crate::distort4::LiquifyStroke { tool: 0, size: 64.0, pressure: 50.0, jitter: 0.0, clone_offset: [3.0, 3.0], points: vec![[1.0, 1.0]] }.to_line();
liquify.get_mut("distortionMesh").unwrap().value = Value::Str(format!(
"{line}
not a stroke"
));
to_effect_space(spec, &mut liquify, SIZE, D, &own);
let Value::Str(text) = &liquify.get("distortionMesh").unwrap().value else { panic!("no mesh") };
let moved = crate::distort4::LiquifyStroke { points: vec![[101.0, 51.0]], ..crate::distort4::LiquifyStroke::parse(&line).unwrap() };
assert_eq!(
text,
&format!(
"{}
not a stroke",
moved.to_line()
)
);
// Roto Brush strokes.
let (spec, mut roto) = instance(crate::roto::ID, &mut next);
let data = effectcraft_track::roto::RotoData {
base: Some(0),
span: [0, 10],
strokes: vec![effectcraft_track::roto::Stroke { kind: effectcraft_track::roto::StrokeKind::Fg, frame: 0, radius: 5.0, points: vec![[2.0, 2.0]] }],
};
roto.get_mut(crate::roto::STROKES).unwrap().value = Value::Str(data.to_json());
to_effect_space(spec, &mut roto, SIZE, D, &own);
let Value::Str(text) = &roto.get(crate::roto::STROKES).unwrap().value else { panic!("no strokes") };
assert_eq!(effectcraft_track::roto::RotoData::from_json(text).strokes[0].points, vec![[102.0, 52.0]]);
}
#[test]
fn layers_with_a_source_rectangle_keep_their_effect_positions() {
let mut next = 1;
let (spec, mut twirl) = instance("ec.distort.twirl", &mut next);
twirl.get_mut("center").unwrap().value = Value::Vec2([3.0, 4.0]);
let before = twirl.clone();
to_effect_space(spec, &mut twirl, SIZE, [0.0; 2], &|_| [0.0; 2]);
assert_eq!(twirl, before);
}
}
+2 -2
View File
@@ -6,7 +6,7 @@
//! ```text
//! Paint (effect `ec.paint.paint`; Paint on Transparent)
//! Brush 1 (match `brush` | `clone` | `eraser`)
//! Path (the recorded stroke, layer space, animatable)
//! Path (the recorded stroke, effect space, animatable)
//! Stroke Options (Start, End, Color, Diameter, Angle, Hardness, Roundness, Spacing,
//! Channels, Opacity, Flow; clone: Clone Source, Clone Position,
//! Clone Time / Clone Time Shift)
@@ -162,7 +162,7 @@ pub fn specs() -> Vec<EffectSpec> {
#[derive(Clone, Debug, PartialEq)]
pub struct StrokeSpec {
pub kind: StrokeKind,
/// Layer-space points.
/// Effect-space points.
pub points: Vec<[f64; 2]>,
/// Pen pressure per point (0–1); empty = full pressure.
pub pressure: Vec<f64>,
+5 -8
View File
@@ -424,16 +424,13 @@ pub fn remap(src: &Image, clamp: bool, f: impl Fn(f64, f64) -> Option<(f64, f64)
/// Layer rectangle in buffer pixels: (x0, y0, width, height).
pub fn layer_rect(ctx: &crate::EffectCtx, b: &crate::Buf) -> (f64, f64, f64, f64) {
(b.offset[0], b.offset[1], ctx.layer_size[0] * b.scale, ctx.layer_size[1] * b.scale)
layer_rect_at(ctx, b.offset, b.scale)
}
/// The layer bounds edge pinning holds in place, in the pixels of a buffer with `offset` and
/// `scale` (see [`crate::Buf`]): (x0, y0, width, height). Unlike [`layer_rect`] it starts at
/// [`crate::EffectEnv::bounds_origin`], so a shape layer's content around its origin lies
/// inside it.
pub fn pin_rect(ctx: &crate::EffectCtx, offset: [f64; 2], scale: f64) -> (f64, f64, f64, f64) {
let o = ctx.env.bounds_origin;
(offset[0] + o[0] * scale, offset[1] + o[1] * scale, ctx.layer_size[0] * scale, ctx.layer_size[1] * scale)
/// [`layer_rect`] in the pixels of a buffer with `offset` and `scale` (see [`crate::Buf`];
/// GPU buffers): the layer bounds start at effect-space (0, 0).
pub fn layer_rect_at(ctx: &crate::EffectCtx, offset: [f64; 2], scale: f64) -> (f64, f64, f64, f64) {
(offset[0], offset[1], ctx.layer_size[0] * scale, ctx.layer_size[1] * scale)
}
/// Resample another layer (a layer parameter) into `b`'s pixel grid. With `stretch` the other
+1 -2
View File
@@ -320,8 +320,7 @@ impl Session {
if times.len() < 2 {
return Err("the layer must be at least two frames long".into());
}
let (w, h) = effectcraft_render::source_size(&self.project, l);
let size = if w == 0 { [c.width as f64, c.height as f64] } else { [w as f64, h as f64] };
let size = effectcraft_render::effect_bounds(&self.project, c, l).0;
let params = static_params(g);
let key = tracks_key(&self.project, comp, l, g);
let settings = ct::settings(&params);
+3 -10
View File
@@ -78,13 +78,7 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
}
let sizes: Vec<_> = {
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
ids.iter()
.filter_map(|id| comp.layer(*id))
.map(|l| {
let (w, h) = effectcraft_render::source_size(&s.project, l);
(l.id, if w == 0 { [comp.width as f64, comp.height as f64] } else { [w as f64, h as f64] })
})
.collect()
ids.iter().filter_map(|id| comp.layer(*id)).map(|l| (l.id, effectcraft_render::effect_bounds(&s.project, comp, l).0)).collect()
};
let uids = s.edit(&format!("Apply {}", spec.name), None, |proj, st| {
let mut out = vec![];
@@ -368,8 +362,7 @@ fn reset(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid, uid) = find_fx(s, p, "effect.reset")?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?;
let (w, h) = effectcraft_render::source_size(&s.project, layer);
let size = if w == 0 { [comp.width as f64, comp.height as f64] } else { [w as f64, h as f64] };
let size = effectcraft_render::effect_bounds(&s.project, comp, layer).0;
let lt = layer.layer_time(s.time());
let g = layer.effects().and_then(|fx| fx.groups().find(|g| g.uid == uid)).ok_or_else(|| bad("effect.reset", "gone"))?;
let spec = match &g.kind {
@@ -423,7 +416,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Pick Effect Colour",
[],
None,
"{layer?, effect?: index|uid|name, param: id (e.g. screenColour) | prop: uid, x, y (layer px), average?: bool (5×5), time?} — the colour of the effect's input there",
"{layer?, effect?: index|uid|name, param: id (e.g. screenColour) | prop: uid, x, y (effect space), average?: bool (5×5), time?} — the colour of the effect's input there",
has_layers,
pick_color
),
+2 -1
View File
@@ -886,7 +886,8 @@ pub fn read_template(bytes: &[u8]) -> std::result::Result<(Value, Project, Vec<(
return Err("the template was made by a newer EffectCraft".into());
}
let text = String::from_utf8(get("project.ecproj").ok_or("template has no project.ecproj")?).map_err(|e| e.to_string())?;
let project = Project::from_json(&text).map_err(|e| e.to_string())?;
let mut project = Project::from_json(&text).map_err(|e| e.to_string())?;
crate::upgrade_effects(&mut project);
Ok((manifest, project, entries))
}
+3 -4
View File
@@ -47,7 +47,7 @@ fn stroke(s: &mut Session, p: &Value) -> Result<Value> {
.iter()
.position(|t| t.eq_ignore_ascii_case(tool_name))
.ok_or_else(|| bad(cmd, format!("unknown tool `{tool_name}` ({})", LIQUIFY_TOOLS.join(" | "))))?;
let raw = p.get("points").and_then(Value::as_array).ok_or_else(|| bad(cmd, "missing `points` [[x, y], …] (layer space)"))?;
let raw = p.get("points").and_then(Value::as_array).ok_or_else(|| bad(cmd, "missing `points` [[x, y], …] (effect space)"))?;
let mut points = vec![];
for q in raw {
let a = q.as_array().ok_or_else(|| bad(cmd, "each point is [x, y]"))?;
@@ -66,8 +66,7 @@ fn stroke(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(cmd, "Liquify works on footage, solid, text, shape and precomp layers"));
}
let lt = layer.layer_time(t);
let (w, h) = effectcraft_render::source_size(&s.project, layer);
let size = if w == 0 { [comp.width as f64, comp.height as f64] } else { [w as f64, h as f64] };
let size = effectcraft_render::effect_bounds(&s.project, comp, layer).0;
let want = p.get("effect").and_then(Value::as_u64);
let (fx_uid, count) = s.edit("Liquify Stroke", None, |proj, _st| {
let mut next = proj.next_id;
@@ -147,7 +146,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Liquify Stroke",
[],
None,
"{layer?, effect?: uid, tool?: warp|turbulence|twirlClockwise|twirlCounterclockwise|pucker|bloat|shiftPixels|reflection|clone|reconstruction|freeze|thaw, points: [[x,y],…] (layer space), size?, pressure? (1-100), jitter? (1-100), cloneOffset?: [dx,dy], time? (s) | frame?}",
"{layer?, effect?: uid, tool?: warp|turbulence|twirlClockwise|twirlCounterclockwise|pucker|bloat|shiftPixels|reflection|clone|reconstruction|freeze|thaw, points: [[x,y],…] (effect space), size?, pressure? (1-100), jitter? (1-100), cloneOffset?: [dx,dy], time? (s) | frame?}",
has_comp,
stroke
),
+5 -6
View File
@@ -53,7 +53,7 @@ pub struct PaintOptions {
pub clone_preset: u32,
/// Source layer (None = the layer being painted).
pub clone_source: Option<u64>,
/// Source point set with Alt/Option-click (layer space of the source layer).
/// Source point set with Alt/Option-click (effect space of the source layer).
pub clone_point: Option<[f64; 2]>,
/// Aligned: keep the source/destination offset between strokes.
pub aligned: bool,
@@ -271,7 +271,7 @@ fn preset(s: &mut Session, p: &Value) -> Result<Value> {
}
fn set_clone_source(s: &mut Session, p: &Value) -> Result<Value> {
let point = pt(p.get("point")).ok_or_else(|| bad("paint.setCloneSource", "missing `point` [x, y] (layer space)"))?;
let point = pt(p.get("point")).ok_or_else(|| bad("paint.setCloneSource", "missing `point` [x, y] (effect space)"))?;
let (_, lid) = layer_p(s, p, "paint.setCloneSource")?;
s.state.paint.clone_source = Some(lid.0);
s.state.paint.clone_point = Some(point);
@@ -300,7 +300,7 @@ fn stroke(s: &mut Session, p: &Value) -> Result<Value> {
Some(k) => StrokeKind::from_name(k).ok_or_else(|| bad(cmd, format!("unknown kind `{k}` (brush | clone | eraser)")))?,
None => StrokeKind::Brush,
};
let raw = p.get("points").and_then(Value::as_array).ok_or_else(|| bad(cmd, "missing `points` [[x, y, pressure?], …] (layer space)"))?;
let raw = p.get("points").and_then(Value::as_array).ok_or_else(|| bad(cmd, "missing `points` [[x, y, pressure?], …] (effect space)"))?;
let mut points = vec![];
let mut pressure = vec![];
for q in raw {
@@ -325,8 +325,7 @@ fn stroke(s: &mut Session, p: &Value) -> Result<Value> {
let t = fr.snap_nearest(super::time_p(s, p, Some(comp)));
let layer = comp.layer(lid).ok_or_else(|| bad(cmd, "no layer"))?;
let lt = layer.layer_time(t);
let (w, h) = effectcraft_render::source_size(&s.project, layer);
let size = if w == 0 { [comp.width as f64, comp.height as f64] } else { [w as f64, h as f64] };
let size = effectcraft_render::effect_bounds(&s.project, comp, layer).0;
if layer.effects().is_none() || !layer.source.is_av() {
return Err(bad(cmd, "this layer can't be painted on"));
}
@@ -463,7 +462,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Paint Stroke",
[],
None,
"{layer?, kind?: brush|clone|eraser, points: [[x,y,pressure?],…] (layer space), time? (s) | frame?, duration? (s, drawing time for Write On), color?: [r,g,b,a], diameter?, angle?, hardness?, roundness?, spacing?, opacity?, flow?, mode?, channels?: RGBA|RGB|Alpha, durationMode?: constant|writeOn|singleFrame|custom, customFrames?, eraseMode?: layerSourceAndPaint|paintOnly|lastStrokeOnly, cloneSource?: layer id, clonePosition?: [x,y], cloneTimeShift? (s), lockSourceTime?, cloneTime? (s), sizePressure?, minSize?, opacityPressure?, flowPressure?}",
"{layer?, kind?: brush|clone|eraser, points: [[x,y,pressure?],…] (effect space), time? (s) | frame?, duration? (s, drawing time for Write On), color?: [r,g,b,a], diameter?, angle?, hardness?, roundness?, spacing?, opacity?, flow?, mode?, channels?: RGBA|RGB|Alpha, durationMode?: constant|writeOn|singleFrame|custom, customFrames?, eraseMode?: layerSourceAndPaint|paintOnly|lastStrokeOnly, cloneSource?: layer id, clonePosition?: [x,y], cloneTimeShift? (s), lockSourceTime?, cloneTime? (s), sizePressure?, minSize?, opacityPressure?, flowPressure?}",
has_comp,
stroke
),
+9 -10
View File
@@ -5,7 +5,7 @@
//! selected pins, not their layer). Follow-Through (`puppet.follow`) makes pins trail a leader pin
//! with a delay, for hair, cloth and overlapping action.
//!
//! Pin positions are layer space. A new pin is attached to the mesh under it: the click is
//! Pin positions are in effect space. A new pin is attached to the mesh under it: the click is
//! mapped back through the current deformation to the rest mesh (its hidden rest position), and
//! Position / Advanced pins get a Position keyframe at the current time, as in After Effects.
@@ -64,7 +64,7 @@ fn add_pin(s: &mut Session, p: &Value) -> Result<Value> {
Some(k) => PinKind::from_name(k).ok_or_else(|| bad(cmd, format!("unknown kind `{k}` (position | advanced | bend | starch | overlap)")))?,
None => PinKind::Position,
};
let at = pt(p.get("position")).ok_or_else(|| bad(cmd, "missing `position` [x, y] (layer space)"))?;
let at = pt(p.get("position")).ok_or_else(|| bad(cmd, "missing `position` [x, y] (effect space)"))?;
let t = snapped(s, cid, p)?;
let lt = layer_time(s, cid, lid, t)?;
{
@@ -112,8 +112,7 @@ fn add_pin(s: &mut Session, p: &Value) -> Result<Value> {
let size = {
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let l = comp.layer(lid).ok_or(EngineError::NoComp)?;
let (w, h) = effectcraft_render::source_size(&s.project, l);
if w == 0 { [comp.width as f64, comp.height as f64] } else { [w as f64, h as f64] }
effectcraft_render::effect_bounds(&s.project, comp, l).0
};
let label = match kind {
PinKind::Starch => "Add Starch Pin",
@@ -282,7 +281,7 @@ fn with_pin<T>(s: &mut Session, p: &Value, cmd: &str, label: &str, f: impl FnOnc
fn move_pin(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "puppet.movePin";
let to = pt(p.get("position")).ok_or_else(|| bad(cmd, "missing `position` [x, y] (layer space)"))?;
let to = pt(p.get("position")).ok_or_else(|| bad(cmd, "missing `position` [x, y] (effect space)"))?;
with_pin(s, p, cmd, "Move Puppet Pin", |g, lt| {
let kind = PinKind::from_index(g.get("kind").map(|k| k.value.as_enum()).unwrap_or(0));
let pos = g.get_mut("position").ok_or_else(|| bad(cmd, "Bend pins have no position (use puppet.setPin rotation/scale)"))?;
@@ -391,12 +390,12 @@ fn mesh_opts(s: &mut Session, p: &Value) -> Result<Value> {
Ok(Value::Null)
}
/// Parse recorded samples `[[t, x, y]…]` (t = seconds since the drag began, layer space).
/// Parse recorded samples `[[t, x, y]…]` (t = seconds since the drag began, effect space).
fn samples(p: &Value, cmd: &str) -> Result<Vec<(f64, [f64; 2])>> {
let arr = p
.get("samples")
.and_then(Value::as_array)
.ok_or_else(|| bad(cmd, "missing `samples`: [[t, x, y]…] (t = seconds since the drag began, layer space)"))?;
.ok_or_else(|| bad(cmd, "missing `samples`: [[t, x, y]…] (t = seconds since the drag began, effect space)"))?;
let mut rec = Vec::with_capacity(arr.len());
for (i, v) in arr.iter().enumerate() {
let a: Vec<f64> = v.as_array().map(|a| a.iter().filter_map(Value::as_f64).collect()).unwrap_or_default();
@@ -606,7 +605,7 @@ fn record_options(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"speed": o.record_speed, "smoothing": o.record_smoothing, "useDraftDeformation": o.record_draft, "showMesh": o.record_show_mesh}))
}
/// Meshes, pins and the deformed mesh at the current time (layer space).
/// Meshes, pins and the deformed mesh at the current time (effect space).
fn info(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid) = layer_p(s, p, "puppet.info")?;
let Some(fx) = find_puppet(s, cid, lid) else { return Ok(json!({"meshes": []})) };
@@ -636,7 +635,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Add Puppet Pin",
[],
None,
"{layer?, kind?: position|advanced|bend|starch|overlap, position: [x,y] (layer space), time? (s) | frame?, mesh?: uid, newMesh?, density?, expansion?, triangles?}",
"{layer?, kind?: position|advanced|bend|starch|overlap, position: [x,y] (effect space), time? (s) | frame?, mesh?: uid, newMesh?, density?, expansion?, triangles?}",
has_comp,
add_pin
),
@@ -675,7 +674,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Record Puppet Pin",
[],
None,
"{layer?, pin: uid | name, samples: [[t, x, y]…] (t = seconds since the drag began, layer space), pins?: [uid | name…] (more pins that move by the same displacement), start? (s, default current time), speed? (%, Record Options), smoothing? (Record Options)}",
"{layer?, pin: uid | name, samples: [[t, x, y]…] (t = seconds since the drag began, effect space), pins?: [uid | name…] (more pins that move by the same displacement), start? (s, default current time), speed? (%, Record Options), smoothing? (Record Options)}",
has_comp,
record_pin
),
+20 -5
View File
@@ -927,31 +927,46 @@ impl Session {
/// Bring effect instances saved by earlier versions up to date with the effect registry
/// (renamed / regrouped / added parameters, reordered popups; see
/// [`effectcraft_effects::migrate`]).
/// [`effectcraft_effects::migrate`]), and a project from before
/// [`effectcraft_project::SCHEMA_EFFECT_SPACE`] into effect space
/// ([`effectcraft_effects::migrate::to_effect_space`]). Stamps the current schema.
pub fn upgrade_effects(p: &mut Project) {
let mut sizes = std::collections::HashMap::new();
let to_effect_space = p.schema < effectcraft_project::SCHEMA_EFFECT_SPACE;
// Effect bounds per layer: (size, top-left in layer coordinates).
let mut bounds = std::collections::HashMap::new();
for (cid, c) in p.comps() {
for l in &c.layers {
let (w, h) = effectcraft_render::source_size(p, l);
sizes.insert((*cid, l.id), if w == 0 { [c.width as f64, c.height as f64] } else { [w as f64, h as f64] });
bounds.insert((*cid, l.id), effectcraft_render::effect_bounds(p, c, l));
}
}
let mut next = p.next_id;
for (cid, it) in p.items.iter_mut() {
let effectcraft_project::ItemKind::Comp(c) = &mut it.kind else { continue };
for l in &mut std::sync::Arc::make_mut(c).layers {
let size = sizes.get(&(*cid, l.id)).copied().unwrap_or([100.0, 100.0]);
let (size, origin) = bounds.get(&(*cid, l.id)).copied().unwrap_or(([100.0, 100.0], [0.0; 2]));
// Effect space starts at the bounds' top-left: positions move by minus that corner.
// Adjustment layers run their effects on the comp below as it is: nothing moves.
let shift = |o: [f64; 2]| [-o[0], -o[1]];
let d = if l.switches.adjustment { [0.0; 2] } else { shift(origin) };
let clone_d = |src: Option<u64>| match src {
Some(id) => bounds.get(&(*cid, effectcraft_project::LayerId(id))).map_or([0.0; 2], |b| shift(b.1)),
None => d,
};
let Some(fx) = l.props.sub_mut("effects") else { continue };
for n in &mut fx.children {
let effectcraft_project::Node::Group(g) = n else { continue };
let effectcraft_project::GroupKind::Effect { effect } = &g.kind else { continue };
if let Some(spec) = effectcraft_effects::find(effect) {
effectcraft_effects::migrate::upgrade_instance(spec, g, &mut effectcraft_project::build::Ids(&mut next), size);
if to_effect_space {
effectcraft_effects::migrate::to_effect_space(spec, g, size, d, &clone_d);
}
}
}
}
}
p.next_id = next;
p.schema = effectcraft_project::SCHEMA_VERSION;
}
#[cfg(test)]
+1 -2
View File
@@ -242,8 +242,7 @@ pub(crate) fn params_at(p: &Project, cid: ItemId, layer: &Layer, g: &PropGroup,
/// The layer's source size (layer pixels).
pub(crate) fn layer_size(p: &Project, comp: &effectcraft_project::Comp, layer: &Layer) -> [f64; 2] {
let (w, h) = effectcraft_render::source_size(p, layer);
if w == 0 { [comp.width as f64, comp.height as f64] } else { [w as f64, h as f64] }
effectcraft_render::effect_bounds(p, comp, layer).0
}
/// First and last layer frame of the layer inside the comp (inclusive).
+60
View File
@@ -257,3 +257,63 @@ fn opening_an_old_project_upgrades_effect_instances() {
// The upgraded project renders.
let _ = s2.render(cid, s2.time(), Default::default());
}
/// A project saved before schema 2 (#227): effect points on a shape layer were measured from
/// its origin. Opening converts them to effect space so points that were set keep their place
/// in the comp; points at their default become the layer centre; layers with a source
/// rectangle, adjustment layers (their effects see the comp below as it is) and expression
/// controls keep their values. Saving writes schema 2, and opening that again changes nothing.
#[test]
fn opening_a_schema_1_project_moves_shape_layer_effect_points_into_effect_space() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Main", "width": 200, "height": 100, "frameRate": 30, "duration": 2})).unwrap();
let shape = s.execute("layer.newShape", json!({"kind": "rect", "size": [180, 80]})).unwrap()["layer"].as_u64().unwrap();
let solid = s.execute("layer.newSolid", json!({"color": "#0000ff", "width": 200, "height": 100})).unwrap()["layer"].as_u64().unwrap();
let point = |s: &Session, lid: u64, fx: u64, id: &str| -> KV {
let l = layer(s, lid);
let g = l.effects().unwrap().groups().find(|g| g.uid == fx).unwrap();
g.props().find(|p| p.match_id == id || (id.is_empty() && p.ui == effectcraft_project::ParamUi::Point)).unwrap().value.clone()
};
let set = |s: &mut Session, lid: u64, fx: u64, id: &str, v: [f64; 2]| {
let l = layer(s, lid);
let g = l.effects().unwrap().groups().find(|g| g.uid == fx).unwrap();
let uid = g.props().find(|p| p.match_id == id || (id.is_empty() && p.ui == effectcraft_project::ParamUi::Point)).unwrap().uid;
s.execute("prop.set", json!({"layer": lid, "prop": uid, "value": v})).unwrap();
};
let twirl = apply(&mut s, shape, "Twirl");
set(&mut s, shape, twirl, "center", [-30.0, -10.0]);
let bulge = apply(&mut s, shape, "Bulge");
let control = apply(&mut s, shape, "Point Control");
set(&mut s, shape, control, "", [5.0, 5.0]);
let on_solid = apply(&mut s, solid, "Twirl");
set(&mut s, solid, on_solid, "center", [30.0, 20.0]);
let adjust = s.execute("layer.newShape", json!({"kind": "rect", "size": [50, 50]})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setSwitch", json!({"layers": [adjust], "switch": "adjustment", "value": true})).unwrap();
let on_adjust = apply(&mut s, adjust, "Twirl");
set(&mut s, adjust, on_adjust, "center", [40.0, 30.0]);
assert_eq!(point(&s, shape, bulge, "center"), KV::Vec2([100.0, 50.0]), "Bulge's default centre");
let old = s.project.to_file_json().unwrap().replacen("\"schema\": 2", "\"schema\": 1", 1);
assert!(old.contains("\"schema\": 1"));
let dir = std::env::temp_dir().join(format!("ec-effect-space-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("old.ecproj").to_string_lossy().to_string();
std::fs::write(&path, old).unwrap();
let mut s2 = Session::default();
s2.execute("file.open", json!({"path": path})).unwrap();
assert_eq!(s2.project.schema, effectcraft_project::SCHEMA_VERSION);
let check = |s2: &Session| {
assert_eq!(point(s2, shape, twirl, "center"), KV::Vec2([70.0, 40.0]), "a set point keeps its place in the comp");
assert_eq!(point(s2, shape, bulge, "center"), KV::Vec2([100.0, 50.0]), "a default point becomes the layer centre");
assert_eq!(point(s2, shape, control, ""), KV::Vec2([5.0, 5.0]), "expression controls keep their values");
assert_eq!(point(s2, solid, on_solid, "center"), KV::Vec2([30.0, 20.0]), "a solid's effect space is its layer space");
assert_eq!(point(s2, adjust, on_adjust, "center"), KV::Vec2([40.0, 30.0]), "an adjustment layer's effects see the comp");
};
check(&s2);
// Saved as schema 2 and opened again: nothing moves twice.
s2.execute("file.save", json!({"path": path})).unwrap();
let mut s3 = Session::default();
s3.execute("file.open", json!({"path": path})).unwrap();
let _ = std::fs::remove_dir_all(dir);
check(&s3);
}
+3 -5
View File
@@ -548,8 +548,8 @@ pub fn apply(p: &mut Project, cid: ItemId, src: LayerId, tracker: Uid, dims: Dim
}
TrackKind::Affine | TrackKind::Perspective => {
let (ms, _) = ctx.layer_to_comp(&source);
let (mt, _) = ctx.layer_to_comp(&target);
let inv = mt.inverse().ok_or("the target layer's transform is not invertible")?;
// Corner Pin's corners are effect points: in the target's effect space.
let inv = ctx.effect_to_comp(&target).inverse().ok_or("the target layer's transform is not invertible")?;
let n = pts.len();
if n < 3 {
return Err("corner pin tracks need 3 or 4 track points".into());
@@ -564,13 +564,11 @@ pub fn apply(p: &mut Project, cid: ItemId, src: LayerId, tracker: Uid, dims: Dim
}
}
let n = track.len();
let tsize = effectcraft_render::source_size(p, &target);
let size = effectcraft_render::effect_bounds(p, &comp, &target).0;
let mut ids_next = p.next_id;
let layer = p.comp_mut(cid).and_then(|c| c.layer_mut(target_id)).ok_or("no target layer")?;
if !corner_keys.is_empty() {
let spec = effectcraft_effects::find("ec.distort.cornerpin").ok_or("Corner Pin is not available")?;
let size = tsize;
let size = if size.0 == 0 { [comp.width as f64, comp.height as f64] } else { [size.0 as f64, size.1 as f64] };
let fx = layer.props.sub_mut("effects").ok_or("the target layer can't have effects")?;
let same = fx.groups().filter(|g| g.match_id == spec.id).count();
let name = if same == 0 { spec.name.to_string() } else { format!("{} {}", spec.name, same + 1) };
+1 -2
View File
@@ -324,8 +324,7 @@ impl Session {
if times.len() < 2 {
return Err("the layer must be at least two frames long".into());
}
let (w, h) = effectcraft_render::source_size(&self.project, l);
let size = if w == 0 { [c.width as f64, c.height as f64] } else { [w as f64, h as f64] };
let size = effectcraft_render::effect_bounds(&self.project, c, l).0;
let detailed = g.prop("advanced/detailedAnalysis").is_some_and(|p| p.value.as_bool());
let n = times.len();
let work = WarpWork {
-2
View File
@@ -253,8 +253,6 @@ pub(crate) fn chain_key(chain: &[FxStep], buf: &Buf, levels: Option<f32>) -> u12
k.debug(&e.working_space);
k.u64(e.working_linear as u64);
k.debug(&e.shutter);
k.f64(e.bounds_origin[0]);
k.f64(e.bounds_origin[1]);
}
k.finish()
}
+3 -3
View File
@@ -96,7 +96,7 @@ fn bulge(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let h = ctx.params.f("height");
let taper = 1.0 + ctx.params.f("taperRadius").max(0.0) / 100.0 * 3.0;
let pin = ctx.params.b("pinAllEdges");
let (x0, y0, lw, lh) = util::pin_rect(ctx, b.offset, b.scale);
let (x0, y0, lw, lh) = util::layer_rect_at(ctx, b.offset, b.scale);
let edge = (lw.min(lh) * 0.1).max(1.0);
let mut p = Params::default();
p.f[0] = [c.0 as f32, c.1 as f32, rx as f32, ry as f32];
@@ -115,7 +115,7 @@ fn wave_warp(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
if !ctx.adjustment {
b.pad(e, h.abs().ceil() as u32 + 1)?;
}
let (x0, y0, lw, lh) = util::pin_rect(ctx, b.offset, b.scale);
let (x0, y0, lw, lh) = util::layer_rect_at(ctx, b.offset, b.scale);
let rect = [x0 as f32, y0 as f32, lw.max(1.0) as f32, lh.max(1.0) as f32];
// The wave phase in cycles, (x·dx + y·dy) / width + phase / 2π, is separable: per column
// and per row (fraction, whole cycles) in f64, so the kernel's sum keeps the fraction exact
@@ -183,7 +183,7 @@ fn turbulent(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
let evo = ctx.params.f("evolution") / 360.0;
let cycle = ctx.params.b("evolutionOptions/cycleEvolution").then(|| ctx.params.f("evolutionOptions/cycle").round().max(1.0));
let falloff = if (3..=5).contains(&kind) { 0.3 } else { 0.5 };
let (x0, y0, lw, lh) = util::pin_rect(ctx, b.offset, b.scale);
let (x0, y0, lw, lh) = util::layer_rect_at(ctx, b.offset, b.scale);
let edge = (lw.min(lh) * 0.1).max(1.0);
let seed = (ctx.seed ^ 0x7d15).wrapping_add((ctx.params.f("evolutionOptions/randomSeed") as i64 as u32).wrapping_mul(0x9e37_79b9));
let (z0, z1, t) = match cycle {
+1 -1
View File
@@ -125,7 +125,7 @@ fn run(e: &mut Enc, entry: &str, p: &Params, mut b: GBuf, data: Option<&wgpu::Bu
/// util::layer_rect.
fn layer_rect(ctx: &EffectCtx, b: &GBuf) -> (f64, f64, f64, f64) {
(b.offset[0], b.offset[1], ctx.layer_size[0] * b.scale, ctx.layer_size[1] * b.scale)
effectcraft_effects::util::layer_rect_at(ctx, b.offset, b.scale)
}
// ---------------------------------------------------------------- per-pixel (fxs_point)
+2 -2
View File
@@ -160,8 +160,8 @@ impl Scene {
pub(crate) fn effect(&mut self, l: &mut Layer, id: &str, vals: &[(&str, Value)]) {
let spec = effectcraft_effects::find(id).unwrap();
let mut next = self.p.next_id;
let size = effectcraft_render::source_size(&self.p, l);
let mut g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [size.0 as f64, size.1 as f64]);
let size = effectcraft_render::effect_bounds(&self.p, &self.comp, l).0;
let mut g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, size);
self.p.next_id = next;
for (k, v) in vals {
g.prop_mut(k).unwrap_or_else(|| panic!("{id}: no {k}")).value = v.clone();
+36 -20
View File
@@ -55,28 +55,44 @@ fn turbulent_displace_and_cc_lens() {
effect_case("ec.distort.cclens", &[("convergence", n(-70.0))]);
}
/// Edge pinning on a shape layer, whose comp-sized bounds are centred on its origin (#227).
/// Effect `id` on a shape layer, whose comp-sized bounds are centred on its origin and whose
/// effects run in effect space from the bounds' top-left (#227), at full and half resolution
/// (the 97 × 61 comp puts that corner between pixels).
fn shape_case(id: &str, vals: &[(&str, Value)]) {
let mut s = Scene::new(BitDepth::Bpc32);
let mut l = build::layer(&mut s.p, &s.comp, "Shape", LayerSource::Shape, (97, 61), None);
let mut next = s.p.next_id;
let mut ids = Ids(&mut next);
let rect = build::shape_rect(&mut ids, [90.0, 54.0], [0.0, 0.0], 0.0);
let fill = build::shape_fill(&mut ids, [0.9, 0.3, 0.1, 1.0]);
let g = build::shape_group(&mut ids, "Rectangle 1", vec![rect, fill]);
s.p.next_id = next;
l.props.sub_mut("contents").unwrap().children.push(g.into());
s.effect(&mut l, id, vals);
s.push(l);
check(&format!("{id} on a shape layer"), compare_at(&s, opts(), Tick::ZERO), 0.0);
check(&format!("{id} on a shape layer, half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, Tick::ZERO), 0.0);
}
/// Edge pinning on a shape layer (#227).
#[test]
fn edge_pinning_on_a_shape_layer() {
let cases: [(&str, &[(&str, Value)]); 3] = [
("ec.distort.turbulentdisplace", &[("size", n(30.0)), ("amount", n(20.0))]),
("ec.distort.wavewarp", &[("pinning", e(1)), ("height", n(6.0)), ("width", n(25.0))]),
("ec.distort.bulge", &[("pinAllEdges", on()), ("height", n(2.0)), ("hradius", n(40.0)), ("vradius", n(30.0)), ("center", Value::Vec2([-20.0, -10.0]))]),
];
for (id, vals) in cases {
let mut s = Scene::new(BitDepth::Bpc32);
let mut l = build::layer(&mut s.p, &s.comp, "Shape", LayerSource::Shape, (97, 61), None);
let mut next = s.p.next_id;
let mut ids = Ids(&mut next);
let rect = build::shape_rect(&mut ids, [90.0, 54.0], [0.0, 0.0], 0.0);
let fill = build::shape_fill(&mut ids, [0.9, 0.3, 0.1, 1.0]);
let g = build::shape_group(&mut ids, "Rectangle 1", vec![rect, fill]);
s.p.next_id = next;
l.props.sub_mut("contents").unwrap().children.push(g.into());
s.effect(&mut l, id, vals);
s.push(l);
check(&format!("{id} on a shape layer"), compare_at(&s, opts(), Tick::ZERO), 0.0);
}
shape_case("ec.distort.turbulentdisplace", &[("size", n(30.0)), ("amount", n(20.0))]);
shape_case("ec.distort.wavewarp", &[("pinning", e(1)), ("height", n(6.0)), ("width", n(25.0))]);
shape_case(
"ec.distort.bulge",
&[("pinAllEdges", on()), ("height", n(2.0)), ("hradius", n(40.0)), ("vradius", n(30.0)), ("center", Value::Vec2([28.5, 20.5]))],
);
}
/// Effect points on a shape layer at their defaults (the bounds' centre) and set (#227).
#[test]
fn effect_points_on_a_shape_layer() {
shape_case("ec.distort.twirl", &[("angle", n(120.0))]);
shape_case("ec.distort.bulge", &[("hradius", n(30.0)), ("vradius", n(20.0)), ("height", n(1.8))]);
shape_case("ec.distort.ripple", &[("radius", n(80.0)), ("waveWidth", n(9.0)), ("waveHeight", n(4.0))]);
shape_case("ec.distort.cclens", &[("size", n(45.0)), ("convergence", n(60.0)), ("center", Value::Vec2([30.0, 20.0]))]);
shape_case("ec.distort.turbulentdisplace", &[("size", n(25.0)), ("amount", n(30.0)), ("offset", Value::Vec2([10.0, 40.0]))]);
}
#[test]
+7 -1
View File
@@ -26,7 +26,13 @@ use serde::{Deserialize, Serialize};
pub use effectcraft_keyframe as keyframe;
pub use effectcraft_keyframe::{Keyframe, Value};
pub const SCHEMA_VERSION: u32 = 1;
/// The project file schema. 2: effects on layers without a source rectangle (shape, text)
/// measure positions from the top-left of their comp-sized bounds (effect space), not from
/// the layer's origin; projects from schema 1 are converted when opened (#227).
pub const SCHEMA_VERSION: u32 = 2;
/// The first schema in which effect positions are in effect space (see [`SCHEMA_VERSION`]).
pub const SCHEMA_EFFECT_SPACE: u32 = 2;
/// The EffectCraft version, written into project files as `savedBy`.
pub const APP_VERSION: &str = env!("CARGO_PKG_VERSION");
+1 -1
View File
@@ -732,7 +732,7 @@ fn decode_layer(p: &[u8]) -> Option<Buf> {
// ---------------------------------------------------------------- content keys
/// Bump when rendering changes in a way that makes old disk entries wrong.
const RENDER_VERSION: &str = "effectcraft-render-1";
const RENDER_VERSION: &str = "effectcraft-render-2";
fn hash_debug(h: &mut Hash128, v: &impl std::fmt::Debug) {
h.write(format!("{v:?}").as_bytes());
+25 -1
View File
@@ -3,7 +3,7 @@
//! Keyframe times are stored in **layer time** (they move with the layer), so evaluating a
//! property at comp time `t` first maps `t` through the layer's start time and stretch.
use effectcraft_geom::{Mat3, Mat4, Vec3, vec3};
use effectcraft_geom::{Mat3, Mat4, Vec3, vec2, vec3};
use effectcraft_keyframe::Value;
use effectcraft_project::{Comp, ItemId, Layer, LayerId, LayerSource, Project, PropGroup, Property};
use effectcraft_time::Tick;
@@ -231,6 +231,15 @@ 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)
}
}
/// [`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 space (see [`effect_bounds`]) → comp matrix.
pub fn effect_to_comp(&self, layer: &Layer) -> Mat3 {
let o = self.effect_bounds(layer).1;
self.layer_to_comp(layer).0 * Mat3::translate(vec2(o[0], o[1]))
}
}
/// The protected regions of a comp (Responsive Design — Time markers), merged, sorted and
@@ -300,6 +309,21 @@ pub fn responsive_source_time(comp: &Comp, stretch: f64, elapsed: f64) -> Option
Some(Tick::from_seconds_f64(d + (elapsed - pos) / k))
}
/// A layer's effect bounds ([`effectcraft_effects::EffectCtx::layer_size`]) and their top-left
/// corner in layer coordinates: the source rectangle at (0, 0), or for layers without one
/// (shape, text) a comp-sized rectangle centred on the layer's origin, which their content
/// 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) {
(0, _) => {
let (w, h) = (comp.width as f64, comp.height as f64);
([w, h], [-w / 2.0, -h / 2.0])
}
(w, h) => ([w as f64, h as f64], [0.0; 2]),
}
}
/// Size of a layer's source in layer pixels.
pub fn source_size(project: &Project, layer: &Layer) -> (u32, u32) {
match &layer.source {
+74 -35
View File
@@ -33,7 +33,7 @@ use effectcraft_project::{Comp, Footage, FootageKind, FrameBlend, GroupKind, Ite
pub use effectcraft_raster::Image;
use effectcraft_raster::{WarpOpts, composite_warp};
use effectcraft_time::{FrameRate, TICKS_PER_SECOND, Tick};
pub use eval::{EvalCtx, ExprHost, source_size};
pub use eval::{EvalCtx, ExprHost, effect_bounds, source_size};
use rayon::prelude::*;
/// Supplies decoded footage frames (implemented by the media layer).
@@ -80,15 +80,32 @@ pub fn is_vector_footage(f: &Footage) -> bool {
matches!(f.codec.as_str(), "SVG" | "PDF" | "AI" | "EPS")
}
/// Layer parameters and audio for effects (see [`effectcraft_effects::EffectHost`]).
/// Layer parameters and audio for effects (see [`effectcraft_effects::EffectHost`]). What it
/// hands out is in effect space (see [`effect_bounds`]): the other layer's for its pixels, the
/// running layer's for its own frames and the comp scene.
struct FxHost<'r, 'a, 'c> {
r: &'r Renderer<'a>,
ctx: &'c EvalCtx<'a>,
layer: &'c Layer,
/// Where the stack's effect space starts in layer coordinates (zero on adjustment layers,
/// whose effects run on the comp below).
origin: [f64; 2],
/// Index of the effect being rendered (bounds `self_at` to effects before it).
index: std::sync::atomic::AtomicUsize,
}
impl FxHost<'_, '_, '_> {
/// Another layer's pixels for a layer parameter, in its effect space; `layer_space`: `buf`
/// is still in layer space (its source or its content).
fn pixels(&self, other: &Layer, mut buf: Buf, layer_space: bool) -> LayerPixels {
let (size, origin) = self.ctx.effect_bounds(other);
if layer_space {
buf.rebase(origin);
}
LayerPixels { buf, size }
}
}
/// Nesting limit for effects that render layers (other layers, other times).
const MAX_FX_DEPTH: usize = 8;
@@ -104,9 +121,7 @@ impl EffectHost for FxHost<'_, '_, '_> {
let sub = self.r.nested();
// The cached layer buffer is shared; effects get their own copy.
let buf = if masks_and_effects { (*sub.content_buf(self.ctx, other)?).clone() } else { sub.source(self.ctx, other)? };
let size = source_size(self.r.project, other);
let size = if size.0 == 0 { [self.ctx.comp.width as f64, self.ctx.comp.height as f64] } else { [size.0 as f64, size.1 as f64] };
Some(LayerPixels { buf, size })
Some(self.pixels(other, buf, true))
}
fn layer_masks(&self, id: u64) -> Option<LayerPixels> {
@@ -116,9 +131,7 @@ impl EffectHost for FxHost<'_, '_, '_> {
}
let sub = self.r.nested();
let buf = (*sub.layer_input(self.ctx, other, 0)?).clone();
let size = source_size(self.r.project, other);
let size = if size.0 == 0 { [self.ctx.comp.width as f64, self.ctx.comp.height as f64] } else { [size.0 as f64, size.1 as f64] };
Some(LayerPixels { buf, size })
Some(self.pixels(other, buf, false))
}
fn audio(&self, id: u64, start: f64, frames: usize, rate: u32) -> Option<Vec<f32>> {
@@ -194,7 +207,7 @@ impl EffectHost for FxHost<'_, '_, '_> {
};
Some(effectcraft_effects::CompLight { pos: [pos.x, pos.y, pos.z], dir: [dir.x / len, dir.y / len, dir.z / len], color: l.color, kind })
});
Some(effectcraft_effects::CompScene { camera: Some(camera), light })
Some(effectcraft_effects::CompScene { camera: Some(camera), light }.rebased(self.origin))
}
fn layer_at(&self, id: u64, comp_time: f64, masks_and_effects: bool) -> Option<LayerPixels> {
@@ -204,10 +217,11 @@ impl EffectHost for FxHost<'_, '_, '_> {
}
let ctx = self.ctx.at(Tick::from_seconds_f64(comp_time));
let sub = self.r.nested();
let buf = if masks_and_effects { (*sub.content_buf(&ctx, other)?).clone() } else { (*sub.layer_input(&ctx, other, 0)?).clone() };
let size = source_size(self.r.project, other);
let size = if size.0 == 0 { [self.ctx.comp.width as f64, self.ctx.comp.height as f64] } else { [size.0 as f64, size.1 as f64] };
Some(LayerPixels { buf, size })
Some(if masks_and_effects {
self.pixels(other, (*sub.content_buf(&ctx, other)?).clone(), true)
} else {
self.pixels(other, (*sub.layer_input(&ctx, other, 0)?).clone(), false)
})
}
}
@@ -870,16 +884,40 @@ impl<'a> Renderer<'a> {
self.apply_effects_timed(ctx, layer, buf, adjustment, usize::MAX, None)
}
/// Run the first `limit` effects of the layer's stack (by position; disabled ones count).
fn apply_effects_timed(&self, ctx: &EvalCtx, layer: &Layer, buf: Buf, adjustment: bool, limit: usize, timing: Option<&mut Vec<(String, f64)>>) -> Buf {
/// Run the first `limit` effects of the layer's stack (by position; disabled ones count) on
/// `buf` (layer space, or the comp below for an adjustment layer).
fn apply_effects_timed(&self, ctx: &EvalCtx, layer: &Layer, mut buf: Buf, adjustment: bool, limit: usize, timing: Option<&mut Vec<(String, f64)>>) -> Buf {
// The comp below an adjustment layer is not in its layer space: it stays as it is.
let origin = if adjustment { [0.0; 2] } else { ctx.effect_bounds(layer).1 };
buf.rebase(origin);
let mut out = self.effect_space_stack(ctx, layer, buf, adjustment, limit, timing, origin);
out.rebase([-origin[0], -origin[1]]);
out
}
/// [`Renderer::apply_effects_timed`] on a buffer already in effect space (measured from
/// `origin`, in layer coordinates); the result stays in effect space.
#[allow(clippy::too_many_arguments)]
fn effect_space_stack(
&self,
ctx: &EvalCtx,
layer: &Layer,
buf: Buf,
adjustment: bool,
limit: usize,
timing: Option<&mut Vec<(String, f64)>>,
origin: [f64; 2],
) -> Buf {
let mut t = CpuFx { accel: self.active_accel(), levels: self.pipe.levels, buf: Some(buf) };
self.run_effect_stack(ctx, layer, adjustment, limit, timing, &mut t);
self.run_effect_stack(ctx, layer, adjustment, limit, timing, origin, &mut t);
t.buf.unwrap_or_else(|| Buf { img: Image::new(1, 1), offset: [0.0; 2], scale: self.opts.scale })
}
/// Run the layer's video effect stack on `target`: runs of GPU-capable effects go to
/// [`FxTarget::gpu`] (when an accelerator is active and supports them), the rest to
/// [`FxTarget::cpu`] one by one (quantised to the bit depth after each).
/// Run the layer's video effect stack on `target` (in effect space, measured from `origin`
/// in layer coordinates): runs of GPU-capable effects go to [`FxTarget::gpu`] (when an
/// accelerator is active and supports them), the rest to [`FxTarget::cpu`] one by one
/// (quantised to the bit depth after each).
#[allow(clippy::too_many_arguments)]
fn run_effect_stack(
&self,
ctx: &EvalCtx,
@@ -887,6 +925,7 @@ impl<'a> Renderer<'a> {
adjustment: bool,
limit: usize,
mut timing: Option<&mut Vec<(String, f64)>>,
origin: [f64; 2],
target: &mut dyn FxTarget,
) {
if !layer.switches.effects {
@@ -894,17 +933,14 @@ impl<'a> Renderer<'a> {
}
let Some(fx) = layer.effects() else { return };
let lt = layer.layer_time(ctx.time);
let size = source_size(self.project, layer);
// Layers without a source rectangle (shape, text) have comp-sized bounds centred on
// their origin, which their content surrounds.
let (layer_size, bounds_origin) = if size.0 == 0 {
let (w, h) = (ctx.comp.width as f64, ctx.comp.height as f64);
([w, h], [-w / 2.0, -h / 2.0])
} else {
([size.0 as f64, size.1 as f64], [0.0; 2])
};
let mask_shapes = masks::shapes(ctx, layer);
let host = FxHost { r: self, ctx, layer, index: Default::default() };
let layer_size = ctx.effect_bounds(layer).0;
let mut mask_shapes = masks::shapes(ctx, layer);
if origin != [0.0; 2] {
for q in mask_shapes.iter_mut().flat_map(|m| m.points.iter_mut()) {
*q = [q[0] - origin[0], q[1] - origin[1]];
}
}
let host = FxHost { r: self, ctx, layer, origin, index: Default::default() };
let env = EffectEnv {
masks: &mask_shapes,
host: Some(&host),
@@ -914,7 +950,6 @@ impl<'a> Renderer<'a> {
working_space: self.pipe.space,
working_linear: self.pipe.linear,
shutter: self.mb_on(ctx, layer).then_some((ctx.comp.shutter_angle, ctx.comp.shutter_phase, ctx.comp.motion_blur_samples)),
bounds_origin,
};
// Video effects in stack order (index, group, spec); disabled and audio effects skipped.
let stack: Vec<(usize, &effectcraft_project::PropGroup, &'static effectcraft_effects::EffectSpec)> = fx
@@ -1243,8 +1278,9 @@ impl<'a> Renderer<'a> {
}
/// A layer's input at the context time: source → masks → its first `effects` effects
/// (what Time effects read at neighbouring times). Served from / stored in the layer cache
/// under its own key, so scrubbing reuses frames rendered for earlier output frames.
/// (what Time effects read at neighbouring times), in effect space (see [`effect_bounds`]):
/// what effect `effects` sees. Served from / stored in the layer cache under its own key,
/// so scrubbing reuses frames rendered for earlier output frames.
pub fn layer_input(&self, ctx: &EvalCtx, layer: &Layer, effects: usize) -> Option<Arc<Buf>> {
let key = self.cache.and_then(|_| cache::input_key(ctx, layer, self.raster_scale(ctx, layer), self.opts.draft, self.mb_on(ctx, layer), effects));
let key = self.content_key(ctx, layer, key);
@@ -1254,8 +1290,10 @@ impl<'a> Renderer<'a> {
return Some(b);
}
let mut buf = self.masked_source(ctx, layer)?;
let origin = ctx.effect_bounds(layer).1;
buf.rebase(origin);
if effects > 0 {
buf = self.apply_effects_timed(ctx, layer, buf, false, effects, None);
buf = self.effect_space_stack(ctx, layer, buf, false, effects, None, origin);
}
let buf = Arc::new(buf);
if let (Some(c), Some(k)) = (self.cache, key) {
@@ -1888,7 +1926,8 @@ impl<'a> Renderer<'a> {
/// parameters and environment. `adjustment`: the stack runs on the comp below (adjustment
/// layers).
pub fn run_effects_on(&self, ctx: &EvalCtx, layer: &Layer, adjustment: bool, target: &mut dyn FxTarget) {
self.run_effect_stack(ctx, layer, adjustment, usize::MAX, None, target);
// `target` is used as it is: the comp below for adjustment layers (the callers).
self.run_effect_stack(ctx, layer, adjustment, usize::MAX, None, [0.0; 2], target);
}
/// An adjustment layer's footprint (its source with masks applied, layer space): where
+65
View File
@@ -456,3 +456,68 @@ fn edge_pinning_displaces_all_of_a_centred_shape_layer() {
assert!(q.iter().all(|&d| d > 20.0), "{id}: per-quadrant change {q:?}");
}
}
/// Effect points on a shape layer are measured from the top-left of its comp-sized bounds
/// (#227), so the default "layer centre" is where the content's origin is. Measured from the
/// origin they sat at the comp's bottom-right corner: Twirl changed nothing and Bulge only
/// the bottom-right quadrant.
#[test]
fn default_effect_points_sit_at_the_centre_of_a_shape_layer() {
let cases: [(&str, Vec<(&str, Value)>); 2] =
[("ec.distort.twirl", vec![("angle", Value::Scalar(90.0))]), ("ec.distort.bulge", vec![("height", Value::Scalar(2.0))])];
for (id, vals) in cases {
let (mut p, cid, comp) = setup();
let mut l = build::layer(&mut p, &comp, "Shape Layer 1", LayerSource::Shape, (200, 100), None);
let mut next = p.next_id;
let mut ids = Ids(&mut next);
let rect = build::shape_rect(&mut ids, [180.0, 80.0], [0.0, 0.0], 0.0);
let fill = build::shape_fill(&mut ids, [1.0, 0.0, 0.0, 1.0]);
let g = build::shape_group(&mut ids, "Rectangle 1", vec![rect, fill]);
p.next_id = next;
l.props.sub_mut("contents").unwrap().children.push(g.into());
p.comp_mut(cid).unwrap().layers.push(l.clone());
let plain = render_frame(&p, cid, Tick::ZERO, 1.0);
add_effect_200(&mut p, &mut l, id, &vals);
p.comp_mut(cid).unwrap().layers = vec![l];
let warped = render_frame(&p, cid, Tick::ZERO, 1.0);
// Alpha change per comp quadrant (TL, TR, BL, BR): the top and bottom edges move in each.
let mut q = [0.0f32; 4];
for y in 0..100 {
for x in 0..200 {
q[(x >= 100) as usize + 2 * (y >= 50) as usize] += (plain.get(x, y)[3] - warped.get(x, y)[3]).abs();
}
}
assert!(q.iter().all(|&d| d > 5.0), "{id}: per-quadrant change {q:?}");
// Symmetric about the centre: opposite quadrants change alike.
assert!((q[0] - q[3]).abs() < 0.05 * q[0].max(q[3]) && (q[1] - q[2]).abs() < 0.05 * q[1].max(q[2]), "{id}: {q:?}");
}
}
/// A layer parameter pointing at a shape layer sees all of it (in its effect space), and the
/// effect's own masks are in effect space too: Set Matte with a centred shape layer as the
/// matte keeps the centre of a full-comp solid (#227).
#[test]
fn layer_parameters_see_a_whole_shape_layer() {
let (mut p, cid, comp) = setup();
let mut shape = build::layer(&mut p, &comp, "Matte", LayerSource::Shape, (200, 100), None);
let mut next = p.next_id;
let mut ids = Ids(&mut next);
let rect = build::shape_rect(&mut ids, [100.0, 50.0], [0.0, 0.0], 0.0);
let fill = build::shape_fill(&mut ids, [1.0, 1.0, 1.0, 1.0]);
let g = build::shape_group(&mut ids, "Rectangle 1", vec![rect, fill]);
p.next_id = next;
shape.props.sub_mut("contents").unwrap().children.push(g.into());
shape.switches.video = false;
let matte_id = shape.id;
let mut s = solid(&mut p, &comp, [0.0, 0.0, 1.0], 200, 100);
add_effect_200(&mut p, &mut s, "ec.channel.setmatte", &[("takeMatteFromLayer", Value::Layer(Some(matte_id.0)))]);
p.comp_mut(cid).unwrap().layers = vec![s, shape];
let f = render_frame(&p, cid, Tick::ZERO, 1.0);
// The matte covers x 50..150, y 25..75: kept inside (all four quadrants), gone outside.
for (x, y) in [(60, 30), (140, 30), (60, 70), (140, 70)] {
assert!(f.get(x, y)[3] > 0.99, "inside at ({x}, {y}): {:?}", f.get(x, y));
}
for (x, y) in [(20, 10), (180, 90), (190, 50)] {
assert!(f.get(x, y)[3] < 0.01, "outside at ({x}, {y}): {:?}", f.get(x, y));
}
}
+8 -4
View File
@@ -1160,13 +1160,15 @@ pub fn viewer_hook(
&& let Some(pos) = resp.interact_pointer_pos()
{
let c = map.to_comp(pos);
// Effect points and the keyer's input are in effect space.
let m = super::viewer::from_effect_space(ectx, &layer, l2c(ectx, &layer));
if pick.kind == "point" {
if let Some(lp) = fw::comp_to_layer(&l2c(ectx, &layer), c) {
if let Some(lp) = fw::comp_to_layer(&m, c) {
actions.push(("prop.set".into(), json!({"layer": pick.layer, "prop": pick.prop, "value": [lp[0], lp[1]]})));
}
} else if let Some(fx) = keyer {
// Ctrl/Cmd+click averages the 5 × 5 pixels around the point.
if let Some(lp) = fw::comp_to_layer(&l2c(ectx, &layer), c) {
if let Some(lp) = fw::comp_to_layer(&m, c) {
let average = ui.input(|i| i.modifiers.command);
actions.push((
"effect.pickColor".into(),
@@ -1192,6 +1194,8 @@ pub fn viewer_hook(
for layer in ectx.comp.layers.iter().filter(|l| selected.contains(&l.id) && l.is_active_at(ectx.time)) {
let Some(fx) = layer.effects() else { continue };
let m = l2c(ectx, layer);
// Masks are in layer space, effect points in effect space.
let fm = super::viewer::from_effect_space(ectx, layer, m);
for g in fx.groups().filter(|g| g.enabled && sel.iter().any(|(l, u)| *l == layer.id && *u == g.uid)) {
if matches!(&g.kind, GroupKind::Effect { effect } if effect == super::fx_editors::RESHAPE) {
super::fx_editors::reshape_overlay(app, ui, painter, map, ectx, layer, g, &m, &mut actions);
@@ -1201,7 +1205,7 @@ pub fn viewer_hook(
.iter()
.map(|pr| {
let v = ectx.value(layer, pr).as_vec2();
map.to_screen(fw::layer_to_comp(&m, v))
map.to_screen(fw::layer_to_comp(&fm, v))
})
.collect();
// Gradient Ramp and friends: a guide between start and end points.
@@ -1222,7 +1226,7 @@ pub fn viewer_hook(
}
if resp.dragged()
&& let Some(pos) = resp.interact_pointer_pos()
&& let Some(lp) = fw::comp_to_layer(&m, map.to_comp(pos))
&& let Some(lp) = fw::comp_to_layer(&fm, map.to_comp(pos))
{
let key = gesture_key(ui, id, resp.drag_started());
actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": pr.uid, "value": [lp[0], lp[1]], "merge": key})));
+2 -2
View File
@@ -113,8 +113,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
// Canvas.
let area = Rect::from_min_max(pos2(rect.min.x, top.max.y), pos2(rect.max.x, canvas_bottom));
p.rect_filled(area, 0.0, t.pasteboard);
let (w, h) = effectcraft_engine::render::source_size(&app.session.project, &layer);
let (lw, lh) = if w == 0 { (comp.width as f64, comp.height as f64) } else { (w as f64, h as f64) };
// The layer's effect bounds, in effect space (where paint and Roto Brush strokes are).
let [lw, lh] = effectcraft_engine::render::effect_bounds(&app.session.project, &comp, &layer).0;
let zoom = ((area.width() - 40.0) as f64 / lw).min((area.height() - 40.0) as f64 / lh).max(0.01);
let origin = area.center() - vec2((lw * zoom / 2.0) as f32, (lh * zoom / 2.0) as f32);
let to_screen = |q: [f64; 2]| pos2(origin.x + (q[0] * zoom) as f32, origin.y + (q[1] * zoom) as f32);
+3 -3
View File
@@ -12,7 +12,7 @@ use effectcraft_engine::render::EvalCtx;
use effectcraft_engine::time::Tick;
use egui::{Color32, Pos2, Stroke};
use super::viewer::{ViewerMap, l2c};
use super::viewer::{ViewerMap, fx2c};
use crate::EffectcraftApp;
/// Deformed meshes of a layer's Puppet effect: (mesh uid, mesh, deformed vertices, pins).
@@ -82,7 +82,7 @@ pub fn overlay(app: &EffectcraftApp, ctx: &egui::Context, cid: ItemId, layer: &L
Some(overlay)
}
/// Where a pin sits now (layer space): its Position, or (Bend / Starch / Overlap pins) its rest
/// Where a pin sits now (effect space): its Position, or (Bend / Starch / Overlap pins) its rest
/// point carried along by the deformation.
pub fn pin_position(mesh: &Mesh, def: &[[f64; 2]], p: &Pin) -> [f64; 2] {
if p.kind.moves() {
@@ -124,7 +124,7 @@ pub fn draw(
selected: &[u64],
hover: Option<Pos2>,
) -> Vec<PinHit> {
let (m, _) = l2c(ectx, layer);
let m = fx2c(ectx, layer);
let scr = |q: [f64; 2]| {
let c = m.apply(gv2(q[0], q[1]));
map.to_screen([c.x, c.y])
+15 -3
View File
@@ -159,7 +159,7 @@ enum Gesture {
start: Pos2,
},
/// Puppet tool Record mode (⌘/Ctrl-drag a pin): the drag is sampled in real time (seconds
/// since it began, layer space) and becomes keyframes on release (`puppet.recordPin`); the
/// since it began, effect space) and becomes keyframes on release (`puppet.recordPin`); the
/// current time runs along while recording.
PuppetRecord {
layer: LayerId,
@@ -245,6 +245,18 @@ pub(crate) fn l2c(ctx: &EvalCtx, layer: &Layer) -> (Mat3, f64) {
}
}
/// `l2c` (layer → comp) applied from effect space instead: effect points and Puppet pins,
/// measured from the top-left of the layer's effect bounds (see `effect_bounds`).
pub(crate) fn from_effect_space(ctx: &EvalCtx, layer: &Layer, l2c: Mat3) -> Mat3 {
let o = ctx.effect_bounds(layer).1;
l2c * Mat3::translate(gv2(o[0], o[1]))
}
/// Effect space → comp (viewer) matrix through the current 3D view.
pub(crate) fn fx2c(ctx: &EvalCtx, layer: &Layer) -> Mat3 {
from_effect_space(ctx, layer, l2c(ctx, layer).0)
}
/// Layer → comp matrix and its bounds quad (in comp pixels).
pub(crate) fn layer_quad(ctx: &EvalCtx, layer: &Layer) -> Option<(Mat3, [[f64; 2]; 4], [f64; 4])> {
let b = effectcraft_engine::render::content_bounds(ctx, layer)?;
@@ -1135,7 +1147,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
PinPart::Center => {}
}
let l = comp.layer(h.layer)?;
let inv = l2c(&ectx, l).0.inverse()?;
let inv = fx2c(&ectx, l).inverse()?;
if mods.command {
let lp = inv.apply(gv2(cpt[0], cpt[1]));
let began = ui.input(|i| i.time);
@@ -1550,7 +1562,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
// Click selects the pin; Shift-click adds or removes it.
let _ = app.session.execute("puppet.selectPins", json!({"layer": h.layer.0, "pins": [h.pin], "toggle": mods.shift}));
} else if let Some(l) = pick(app, &ectx, cpt, false).and_then(|l| comp.layer(l))
&& let Some(inv) = l2c(&ectx, l).0.inverse()
&& let Some(inv) = fx2c(&ectx, l).inverse()
{
let lp = inv.apply(gv2(cpt[0], cpt[1]));
let r = app.session.execute("puppet.addPin", json!({"layer": l.id.0, "kind": t.puppet_kind(), "position": [lp.x, lp.y]}));
+11 -2
View File
@@ -174,6 +174,15 @@ build real projects through these interfaces; they are worked examples of everyt
Save / Don't Save / Cancel prompt (`ui_click` on `dialog.unsaved.save` / `dontSave` / `cancel`;
`dialog.unsaved.revert` for Revert); the control channel's `app.quit {"force": true}` skips it.
### Effect positions
Effect points, Puppet pins (`puppet.addPin`, `puppet.recordPin`), Paint, Liquify and Roto Brush
strokes and `effect.pickColor` are in **effect space**: layer pixels from the top-left of the
layer's effect bounds. For footage, solids and precomps that is the layer's own pixel grid. Shape
and text layers have comp-sized bounds centred on their content's origin, so in a `w` × `h` comp
the point `[w / 2, h / 2]` is that origin (a new effect's default point). `prop.get` / `prop.set`
read and write these values as they are.
### Motion tracking
Trackers live on the tracked layer (`Motion Trackers ▸ Tracker n ▸ Track Point n`) and are driven
@@ -304,7 +313,7 @@ effectcraft-cli run shot.ecproj camera.createFromSolve '{"kind":"solid","points"
The Roto Brush tool (Alt+W cycles Roto Brush / Refine Edge) paints in the Layer panel; agents use
`roto.stroke {layer, kind: fg|bg|refine|refineErase, points: [[x, y], …], frame?, radius?}`
(layer pixels, layer frames). The first foreground stroke applies the Roto Brush & Refine Edge
(effect space, layer frames). The first foreground stroke applies the Roto Brush & Refine Edge
effect and sets the base frame with a span of 20 frames each side. `roto.propagate {layer,
direction?: forward|backward|both, to?, wait?}` segments the span (in the background unless
`wait`); strokes on any other frame correct it and propagation restarts from there.
@@ -409,7 +418,7 @@ that closes the dialog). File ▸ Scripts: `file.scripts.list`, `file.runScript
states as a branch); `edit.history.goto {"index": n}` (or `id`, or `steps`) jumps to any of
them. MCP: the `history` tool.
* `puppet.recordPin {"layer": "#1", "pin": "Puppet Pin 1", "samples": [[t, x, y]…]}` records a
drag (t = seconds since it began, layer space) into Position keys at the comp frame rate from the
drag (t = seconds since it began, effect space) into Position keys at the comp frame rate from the
current time; `puppet.recordOptions {speed, smoothing, useDraftDeformation, showMesh}`.
* `puppet.selectPins {"layer": "#1", "pins": ["Puppet Pin 1"], "add"?, "toggle"?}` selects pins
like the viewer's click / Shift-click (`pins: []` deselects); Edit ▸ Clear (Delete) then removes
+34 -1
View File
@@ -82,13 +82,46 @@ a file that a newer EffectCraft saved shows a warning: fields this version doesn
dropped if it saves the project. Save, Save As, Save a Copy and Increment and Save keep the
file's format (`.ecprojx` stays XML).
`schema` changes when a saved value changes meaning; a project from an older schema is converted
when it is opened (`effectcraft_engine::upgrade_effects`, which also refreshes effect instances
from the registry) and saved with the current one, which older versions refuse to open rather than
render wrongly. Schema 2 put effect positions on shape and text layers in effect space (§4,
#227). Opening a schema 1 project moves what an effect on a shape or text layer (not an adjustment
layer, whose effects see the comp below as before) holds as a position by half the comp size,
so points that were set keep their place in the comp: point parameters with their keyframes, Paint
stroke paths and clone positions, Puppet mesh seeds, pin rest points and positions, and Liquify
and Roto Brush strokes. A point parameter still at its default and not animated is left alone:
the old default sat at the comp's bottom-right corner, where most effects did nothing, and the
same value is now the layer centre, as in After Effects. Expression controls (Point Control…)
keep their values, because their meaning is up to the expressions that read them, and expressions
are not rewritten, so an expression that computes an effect point on a shape or text layer from
other values may need `+ [thisComp.width / 2, thisComp.height / 2]`. Effects that use the layer
bounds (edge pinning, wipes, Offset, generators) now cover the whole comp-sized bounds instead of
their bottom-right quarter. Tracking data of effects that analyse footage (Warp Stabilizer, 3D
Camera Tracker, Mocha shape) is left as it is: footage has a source rectangle, whose effect space
did not change.
## 4. Rendering
`Renderer::comp_frame(comp, t)` walks the layers bottom to top. For each visible layer it renders
the **source** (solid, footage frame, text, shape contents, or a nested comp), applies **masks**,
then **effects** in order, then **layer styles**, then the **transform** into comp space (with motion blur sub-samples
when enabled), the **track matte**, and finally **blends** into the accumulator. Adjustment layers
apply their effects to the accumulator. Runs of 3D layers are composited per pixel through the
apply their effects to the accumulator.
Effects run in **effect space** (`effectcraft_render::effect_bounds`): layer pixels measured from
the top-left of the layer's effect bounds, which are the source rectangle, or for layers without
one (shape, text) a comp-sized rectangle centred on the layer's origin that their content
surrounds. A shape layer's default effect point (the bounds' centre) is therefore its content's
origin, as in After Effects. The renderer moves the layer buffer into effect space for the whole
stack (`Buf::rebase`, on the CPU and the GPU alike) and hands effects everything else in it too:
masks, other layers' pixels for layer parameters (each in its own effect space), the layer's own
frames at other times (`Renderer::layer_input`, also what Puppet, Roto Brush and the Layer panel
read) and the comp camera. Effect point handles, Puppet pins and the Layer panel map effect space
to the comp with `EvalCtx::effect_to_comp`. Adjustment layers run their effects on the comp below
as it is.
Runs of 3D layers are composited per pixel through the
active camera, with lights, shadows and depth of field (Classic 3D), or rasterised as meshes by
the Advanced 3D renderer (below).
+9
View File
@@ -108,6 +108,15 @@ The most important missing piece: it turns every other estimate here into a meas
- Regression evidence for the Turbulent Displace, Wave Warp and Bulge pinning of #227:
`edge_pinning_displaces_all_of_a_centred_shape_layer` (render) and
`edge_pinning_on_a_shape_layer` (GPU against CPU).
- Regression evidence for the effect points of #227 (shape and text layers measure effect points
from the top-left of their comp-sized bounds, so Twirl, Bulge, CC Lens, Ripple and Turbulent
Displace's Offset default to the layer centre): `default_effect_points_sit_at_the_centre_of_a_shape_layer`
and `layer_parameters_see_a_whole_shape_layer` (render), `effect_points_on_a_shape_layer` (GPU
against CPU), and the schema 1 → 2 conversion in
`opening_a_schema_1_project_moves_shape_layer_effect_points_into_effect_space` (engine) and
`effect_points_move_into_effect_space_unless_left_at_their_default` /
`puppet_paint_liquify_and_roto_data_move_into_effect_space` (effects); see
[architecture.md](architecture.md) §3 for what the conversion keeps.
- Regression evidence for the interface items of [#227](https://github.com/storytold/effectcraft/issues/227),
checked headlessly on Windows: `spacebar_taps_preview_and_held_spacebar_pans_the_viewer` and
`spacebar_drag_scrolls_the_time_graph` (Spacebar previews on its release, held it is the Hand
+1 -1
View File
@@ -549,7 +549,7 @@ Since (M5.16): Key Light (After Effects' Keylight 1.2) is checked end to end on
plate with an uneven screen, spill and soft edges (`effect.apply "Keylight (1.2)"`, pick, Clip
Black / White, render). The colour eyedroppers of Keying effects sample the effect's input
rather than the keyed frame (Ctrl/Cmd+click averages 5 × 5 pixels); agents do the same with
`effect.pickColor {effect, param, x, y, average?}` (layer pixels). Searching "keylight" in
`effect.pickColor {effect, param, x, y, average?}` (effect space). Searching "keylight" in
Effects & Presets, or `list_effects`, finds it.
## Update: M4.9–M4.11 nested comps and motion blur