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:
Brandon Thomas
2026-10-09 09:32:13 -04:00
36 changed files with 684 additions and 154 deletions
+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 {
@@ -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
),
+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)?;
{
@@ -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
),
+20 -5
View File
@@ -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)]
+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 {