mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
Merge pull request #289 from storytold/fix/227-shape-effect-points
Effects: effect points on shape layers start at the layer centre (#227) # Conflicts: # crates/ui-egui/src/panels/puppet_tool.rs # crates/ui-egui/src/panels/viewer.rs
This commit is contained in:
commit
e12bf5ca7d
36 files changed
+684
-154
No files matched your search
@@ -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]]
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(¶ms);
|
||||
|
||||
@@ -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 {
|
||||
@@ -450,7 +443,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
|
||||
),
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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)?;
|
||||
{
|
||||
@@ -108,8 +108,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",
|
||||
@@ -278,7 +277,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)"))?;
|
||||
@@ -387,12 +386,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();
|
||||
@@ -602,7 +601,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": []})) };
|
||||
@@ -632,7 +631,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
|
||||
),
|
||||
@@ -671,7 +670,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
|
||||
),
|
||||
|
||||
@@ -949,31 +949,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)]
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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) };
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -27,7 +27,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");
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -476,3 +476,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));
|
||||
}
|
||||
}
|
||||
@@ -1183,13 +1183,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(),
|
||||
@@ -1215,6 +1217,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);
|
||||
@@ -1224,7 +1228,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.
|
||||
@@ -1245,7 +1249,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})));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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).
|
||||
@@ -89,7 +89,7 @@ pub fn overlay(app: &EffectcraftApp, ctx: &egui::Context, cid: ItemId, layer: &L
|
||||
/// rigged, as in After Effects (#284).
|
||||
pub fn target(ectx: &EvalCtx, layers: &[(&Layer, Overlay)], cpt: [f64; 2], on_art: impl Fn(&Layer) -> bool) -> Option<LayerId> {
|
||||
let on_mesh = |l: &Layer, ov: &Overlay| {
|
||||
let Some(inv) = l2c(ectx, l).0.inverse() else { return false };
|
||||
let Some(inv) = fx2c(ectx, l).inverse() else { return false };
|
||||
let p = inv.apply(gv2(cpt[0], cpt[1]));
|
||||
ov.iter().any(|(_, mesh, def, _)| puppet::contains(mesh, def, [p.x, p.y]))
|
||||
};
|
||||
@@ -106,7 +106,7 @@ pub fn reveal_pin(app: &mut EffectcraftApp, layer: LayerId, pin: u64) {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
@@ -148,7 +148,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])
|
||||
|
||||
@@ -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)?;
|
||||
@@ -1150,7 +1162,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);
|
||||
@@ -1571,7 +1583,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) = puppet_layer(app, cpt).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]));
|
||||
match app.session.execute("puppet.addPin", json!({"layer": l.id.0, "kind": t.puppet_kind(), "position": [lp.x, lp.y]})) {
|
||||
|
||||
+11
-2
@@ -242,6 +242,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
|
||||
@@ -372,7 +381,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.
|
||||
@@ -477,7 +486,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
@@ -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).
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user