Merge upstream/main into warp-skip-discarded-plan

Upstream split lib.rs files into modules and moved engine commands/ to cmd/; this branch's change merges without textual conflicts.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76
2026-10-27 17:51:52 +01:00
co-authored by Claude Sonnet 5.5
617 changed files with 53880 additions and 43740 deletions
+6
View File
@@ -0,0 +1,6 @@
pub mod camera_track;
pub mod mask_track;
pub mod models;
pub mod roto;
pub mod tracking;
pub mod warp;
@@ -7,7 +7,7 @@
//! camera"). When only the solve settings changed (Shot Type, Angle of View, Solve Method,
//! deleted points) the stored tracks are re-solved without re-tracking. Results are written into
//! the effect's hidden parameters as one undo step; edits that change the layer's frames clear
//! them and queue the effect again ([`invalidate`], run after every edit), like After Effects.
//! them and queue the effect again (`invalidate`, run after every edit), like After Effects.
//!
//! Composition space: the solve's canonical frame maps to comp world by
//! [`CameraSolve::world`] with the zoom of the first solved frame (focal length × the layer's
@@ -80,10 +80,10 @@ impl MaskMethod {
}
}
pub fn from_name(s: &str) -> Option<MaskMethod> {
let n: String = s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_ascii_lowercase();
let n: String = s.chars().filter(char::is_ascii_alphanumeric).collect::<String>().to_ascii_lowercase();
MaskMethod::ALL
.into_iter()
.find(|m| m.id().to_ascii_lowercase() == n || m.label().chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_ascii_lowercase() == n)
.find(|m| m.id().to_ascii_lowercase() == n || m.label().chars().filter(char::is_ascii_alphanumeric).collect::<String>().to_ascii_lowercase() == n)
}
pub fn model(self) -> Model {
match self {
@@ -6,7 +6,7 @@
//! `wait`; in the browser the background is a Web Worker whose segmentations stream back,
//! [`crate::offload`]), frame by frame outward from the base frame, rendering the effect's input
//! (`Renderer::layer_input`, source → masks → the effects above it). `roto.freeze` runs the same
//! pass and stores the final mattes in the effect (one undo step). [`sync`], run after every
//! pass and stores the final mattes in the effect (one undo step). `sync`, run after every
//! edit, keeps each instance's hidden Input Key equal to a signature of what its frames are made
//! of (the layer's source and timing, its masks and the effects above it) and drops frozen
//! mattes made from other frames.
@@ -157,7 +157,7 @@ pub struct TrackShared {
}
pub(crate) fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
m.lock().unwrap_or_else(|e| e.into_inner())
m.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// A running (or finished, not yet polled) analysis.
@@ -7,7 +7,7 @@
//! stabilization plan once to validate and cache the result. When the job finishes, the analysis is
//! written into the effect's hidden Analysis parameter as one undo step, together with a key of
//! what it was made from (the layer's source, In/Out, start, stretch and Time Remap). Edits that
//! change those clear the analysis ([`invalidate`], run after every edit) and queue the effect
//! change those clear the analysis (`invalidate`, run after every edit) and queue the effect
//! for re-analysis, which frontends start with [`Session::poll_warp`] (After Effects re-analyses
//! automatically too).
+3
View File
@@ -0,0 +1,3 @@
pub mod jobs;
pub mod offload;
pub mod remote;
@@ -289,10 +289,10 @@ pub struct Inbox(Mutex<Vec<WorkerReply>>);
impl Inbox {
pub fn push(&self, r: WorkerReply) {
self.0.lock().unwrap_or_else(|e| e.into_inner()).push(r);
self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(r);
}
pub fn take(&self) -> Vec<WorkerReply> {
std::mem::take(&mut *self.0.lock().unwrap_or_else(|e| e.into_inner()))
std::mem::take(&mut *self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner))
}
}
@@ -992,317 +992,4 @@ pub(crate) fn merge_group(base: &PropGroup, live: &PropGroup, result: &PropGroup
}
#[cfg(test)]
mod tests {
use super::*;
fn round<T: Serialize + for<'de> Deserialize<'de>>(v: &T) -> String {
let a = serde_json::to_string(v).unwrap();
let back: T = serde_json::from_str(&a).unwrap();
let b = serde_json::to_string(&back).unwrap();
assert_eq!(a, b);
a
}
/// A group with properties `a` (uid 2) and `b` (uid 3) and a sub-group (uid 4) holding `c`
/// (uid 5).
fn group() -> PropGroup {
use effectcraft_keyframe::Value as KV;
use effectcraft_project::{Node, Property};
let mut sub = PropGroup::new(4, "sub", "Sub");
sub.children.push(Node::Prop(Property::new(5, "c", "C", KV::Scalar(0.0))));
let mut g = PropGroup::new(1, "fx", "Effect");
g.children.push(Node::Prop(Property::new(2, "a", "A", KV::Scalar(0.0))));
g.children.push(Node::Prop(Property::new(3, "b", "B", KV::Scalar(0.0))));
g.children.push(Node::Group(sub));
g
}
fn set(g: &mut PropGroup, uid: Uid, v: f64) {
g.find_mut(uid).unwrap().value = effectcraft_keyframe::Value::Scalar(v);
}
fn get(g: &PropGroup, uid: Uid) -> f64 {
match g.find(uid).unwrap().value {
effectcraft_keyframe::Value::Scalar(v) => v,
ref v => panic!("{v:?}"),
}
}
#[test]
fn analysis_results_merge_with_edits_made_meanwhile() {
use effectcraft_project::Node;
let base = group();
// The user changes `a` and switches the effect off; the analysis writes `b` and `c`.
let mut live = base.clone();
set(&mut live, 2, 1.0);
live.enabled = false;
let mut result = base.clone();
set(&mut result, 3, 2.0);
set(&mut result, 5, 3.0);
let m = merge_group(&base, &live, &result).unwrap();
assert_eq!((get(&m, 2), get(&m, 3), get(&m, 5), m.enabled), (1.0, 2.0, 3.0, false));
// Unchanged on either side: the other side wins whole.
assert_eq!(merge_group(&base, &base, &result).unwrap(), result);
assert_eq!(merge_group(&base, &live, &base).unwrap(), live);
// Both changed `b`, differently: a conflict (nothing is applied). The same way: fine.
let mut both = live.clone();
set(&mut both, 3, 9.0);
assert!(merge_group(&base, &both, &result).is_none());
set(&mut both, 3, 2.0);
assert_eq!(get(&merge_group(&base, &both, &result).unwrap(), 3), 2.0);
// The analysis adds a child (a result group) while the user edits `a`: both kept.
let mut added = result.clone();
added.children.push(Node::Group(PropGroup::new(6, "out", "Output")));
let m = merge_group(&base, &live, &added).unwrap();
assert!(m.find_group(6).is_some() && get(&m, 2) == 1.0);
// The analysis drops the sub-group the user didn't touch: dropped.
let mut dropped = result.clone();
dropped.children.retain(|n| !matches!(n, Node::Group(g) if g.uid == 4));
let m = merge_group(&base, &live, &dropped).unwrap();
assert!(m.find_group(4).is_none() && get(&m, 2) == 1.0);
// …but if the user changed something in it, that's a conflict.
let mut touched = live.clone();
set(&mut touched, 5, 7.0);
assert!(merge_group(&base, &touched, &dropped).is_none());
}
#[test]
fn protocol_round_trips() {
let mut s = Session::default();
s.execute("file.openDemoProject", serde_json::json!({})).unwrap();
let comp = s.active_comp_id().unwrap();
s.execute("renderQueue.add", serde_json::json!({})).unwrap();
let item = s.project.render_queue[0].clone();
let reqs = [
WorkerJob::Render { items: vec![vec![(item.clone(), "/out.gif".into())]] },
WorkerJob::Warp { comp, layer: LayerId(3), effect: 9 },
WorkerJob::WarpPlan { comp, layer: LayerId(3), effect: 9, time: Tick::from_seconds_f64(0.5) },
WorkerJob::RotoFreeze { comp, layer: LayerId(3), effect: 9 },
WorkerJob::Camera { comp, layer: LayerId(3), effect: 9 },
WorkerJob::RotoPropagate { comp, layer: LayerId(3), effect: 9, direction: crate::roto::Direction::Forward },
WorkerJob::Track {
comp,
layer: LayerId(1),
tracker: 4,
direction: crate::tracking::Direction::Backward,
settings: TrackerSettings::default(),
points: vec![PointValues {
uid: 5,
spec: effectcraft_track::PointSpec { center: [1.0, 2.0], feature_size: [3.0, 4.0], search_offset: [0.5, 0.0], search_size: [9.0, 9.0] },
attach_offset: [0.25, -1.0],
}],
times: vec![Tick::ZERO, Tick::from_seconds_f64(0.5)],
},
WorkerJob::MaskTrack {
comp,
layer: LayerId(2),
mask: 7,
direction: crate::tracking::Direction::Forward,
path: ShapePath::ellipse([10.0, 20.0], 5.0, 6.0),
method: MaskMethod::Perspective,
times: vec![Tick::from_seconds_f64(1.0)],
},
WorkerJob::ContentFill {
plan: Box::new(crate::commands::content_fill::FillPlan {
comp,
layer: LayerId(2),
name: "Clip".into(),
width: 4,
height: 3,
times: vec![Tick::ZERO],
method: "object".into(),
lighting: 0.5,
expand: 1,
reference: Some((LayerId(1), Tick::ZERO)),
dir: "/Fill/Clip_Fill_1".into(),
}),
},
];
for job in reqs {
let kind = job.kind();
let req = WorkerRequest { id: 42, project: s.project.to_json(), files: footage_files(&s.project), job };
let t = round(&req);
assert!(t.contains("\"kind\":"), "{kind:?}");
let back: WorkerRequest = serde_json::from_str(&t).unwrap();
assert_eq!(back.job.kind(), kind);
assert_eq!(Project::from_json(&back.project).unwrap(), *s.project);
}
let g = s.active_comp().unwrap().layers[0].props.clone();
for r in [
WorkerReply::Render { state: JobState { current: Some(3), done: 4, total: 10, items_total: 1, ..Default::default() } },
WorkerReply::Item { update: ItemUpdate::Started { id: 3, unix: 1_700_000_000 } },
WorkerReply::Item { update: ItemUpdate::Finished { id: 3, status: RenderStatus::Failed("x".into()), seconds: 1.5, output: Some("/a.gif".into()) } },
WorkerReply::Progress { done: 1, total: 2 },
WorkerReply::WarpPlan {
key: [1, u64::MAX],
summary: Box::new(effectcraft_effects::warp_stab::PlanSummary {
warps: vec![[[1.0, 0.0, 2.5], [0.0, 1.0, -1.0], [0.0, 0.0, 1.0]]],
auto_scale: 1.08,
crop: Some([1.0, 2.0, 300.0, 200.0]),
valid_fraction: 0.9,
}),
},
WorkerReply::Group { comp, layer: LayerId(1), group: Box::new(g), message: "ok".into() },
WorkerReply::Segs { segs: vec![SegData::new(5, &effectcraft_track::roto::FrameSeg::empty(4, 3))] },
WorkerReply::Fill {
plan: Box::new(crate::commands::content_fill::FillPlan {
comp,
layer: LayerId(2),
name: "Clip".into(),
width: 4,
height: 3,
times: vec![Tick::ZERO],
method: "object".into(),
lighting: 0.5,
expand: 1,
reference: Some((LayerId(1), Tick::ZERO)),
dir: "/Fill/Clip_Fill_1".into(),
}),
files: vec!["/Fill/Clip_Fill_1/fill_00000.png".into()],
},
WorkerReply::Failed { error: "boom".into() },
WorkerReply::Done,
] {
let t = round(&r);
assert!(t.starts_with("{\"type\":"), "{t}");
}
}
/// An [`Offload`] that runs the job right away in a second session, through JSON (what a
/// Web Worker does, minus the threads).
struct Inline;
impl Offload for Inline {
fn start(&self, req: WorkerRequest, inbox: Arc<Inbox>) -> Result<(), String> {
let req: WorkerRequest = serde_json::from_str(&serde_json::to_string(&req).unwrap()).unwrap();
let mut w = Session { exporter: Some(Arc::new(crate::rq_tests::MockExporter { log: Default::default() })), ..Default::default() };
let post: Post = Rc::new(move |r| inbox.push(serde_json::from_str(&serde_json::to_string(&r).unwrap()).unwrap()));
run_request(&mut w, req, &post);
Ok(())
}
fn cancel(&self, _: u64) {}
}
#[test]
fn render_runs_through_the_offload() {
let mut s = Session {
exporter: Some(Arc::new(crate::rq_tests::MockExporter { log: Default::default() })),
offload: Some(Arc::new(Inline)),
..Default::default()
};
s.execute("file.openDemoProject", serde_json::json!({})).unwrap();
s.execute("renderQueue.add", serde_json::json!({"output": "/x.gif"})).unwrap();
let ids = s.start_render(false).unwrap();
// (the inline offload has replied already; replies are applied when polled)
s.poll_render();
assert!(s.render_job.is_none());
let it = s.project.render_queue.iter().find(|i| i.id == ids[0]).unwrap();
assert_eq!(it.status, RenderStatus::Done, "{:?}", it.status);
assert!(it.last_output.as_deref().is_some_and(|o| o.ends_with("x.gif")));
// `wait` renders inline.
s.execute("renderQueue.add", serde_json::json!({"output": "/y.gif"})).unwrap();
s.start_render(true).unwrap();
assert!(s.render_job.is_none() || s.render_job.as_ref().unwrap().remote.is_none());
}
/// Runs jobs in a session sharing `footage` (the synthetic clip of the tracking tests).
struct InlineWith(Arc<dyn effectcraft_render::FootageSource>);
impl Offload for InlineWith {
fn start(&self, req: WorkerRequest, inbox: Arc<Inbox>) -> Result<(), String> {
let req: WorkerRequest = serde_json::from_str(&serde_json::to_string(&req).unwrap()).unwrap();
let mut w = Session { footage: self.0.clone(), ..Default::default() };
let post: Post = Rc::new(move |r| inbox.push(serde_json::from_str(&serde_json::to_string(&r).unwrap()).unwrap()));
run_request(&mut w, req, &post);
Ok(())
}
fn cancel(&self, _: u64) {}
}
/// Never replies (a job still running); records cancellations.
#[derive(Default)]
struct Hanging(Mutex<Vec<u64>>);
impl Offload for Hanging {
fn start(&self, _: WorkerRequest, _: Arc<Inbox>) -> Result<(), String> {
Ok(())
}
fn cancel(&self, id: u64) {
self.0.lock().unwrap().push(id);
}
}
fn pose(f: u32) -> ([f64; 2], f64, f64) {
([150.0 + 2.0 * f as f64, 110.0 + 1.0 * f as f64], 0.0, 1.0)
}
#[test]
fn track_analysis_runs_through_the_offload_as_one_undo_step() {
let (mut s, clip, _) = crate::tests_track::setup(crate::tests_track::patch_frames(pose));
s.offload = Some(Arc::new(InlineWith(s.footage.clone())));
s.execute("track.motion", serde_json::json!({})).unwrap();
s.execute("track.setPoint", serde_json::json!({"point": 1, "center": pose(0).0, "featureSize": [36, 36], "searchSize": [80, 80]})).unwrap();
let undo = s.history.undo.len();
s.execute("track.analyze", serde_json::json!({"direction": "forward"})).unwrap();
assert!(s.is_tracking() && s.track_job.is_none(), "offloaded, not on a thread");
assert!(s.track_progress().is_some());
assert!(s.poll_offload());
assert!(!s.is_tracking() && s.offloaded.is_empty());
let l = s.active_comp().unwrap().layer(clip).unwrap();
let tp = l.trackers().next().unwrap().0.track_points().next().unwrap().clone();
let fc = tp.get("featureCenter").unwrap();
assert_eq!(fc.keys.len(), 25);
let v = fc.value_at(crate::tests_track::frame_time(12)).as_vec2();
assert!((v[0] - pose(12).0[0]).abs() < 0.3 && (v[1] - pose(12).0[1]).abs() < 0.3, "{v:?}");
assert_eq!(s.history.undo.len(), undo + 1);
assert_eq!(s.history.undo.last().unwrap().0, "Analyze Track");
assert!(s.events.iter().any(|e| matches!(e, Event::Toast { message, error: false } if message.starts_with("Tracked"))));
assert!(s.undo());
let l = s.active_comp().unwrap().layer(clip).unwrap();
assert!(l.trackers().next().unwrap().0.track_points().next().unwrap().get("featureCenter").unwrap().keys.is_empty());
// `wait` runs inline.
s.redo();
s.execute("track.analyze", serde_json::json!({"direction": "forward", "wait": true})).ok();
assert!(s.offloaded.is_empty());
}
#[test]
fn cancelling_offloaded_jobs() {
let (mut s, _, _) = crate::tests_track::setup(crate::tests_track::patch_frames(pose));
let off = Arc::new(Hanging::default());
s.offload = Some(off.clone());
s.exporter = Some(Arc::new(crate::rq_tests::MockExporter { log: Default::default() }));
// Analysis: stopping drops the job, nothing is written.
s.execute("track.motion", serde_json::json!({})).unwrap();
let rev = s.revision;
s.execute("track.analyze", serde_json::json!({})).unwrap();
assert!(s.is_tracking());
assert!(s.execute("track.analyze", serde_json::json!({})).is_err(), "one at a time");
assert!(s.stop_track());
s.poll_offload();
assert!(!s.is_tracking());
assert_eq!(off.0.lock().unwrap().len(), 1);
assert!(s.events.iter().any(|e| matches!(e, Event::Toast { message, .. } if message.contains("stopped"))));
assert!(s.revision > rev);
// Render: the queued items become User Stopped.
s.execute("renderQueue.add", serde_json::json!({"output": "/a.gif"})).unwrap();
let ids = s.start_render(false).unwrap();
assert!(s.is_rendering());
assert!(s.stop_render());
s.poll_render();
assert!(!s.is_rendering());
assert_eq!(off.0.lock().unwrap().len(), 2);
let it = s.project.render_queue.iter().find(|i| i.id == ids[0]).unwrap();
assert_eq!(it.status, RenderStatus::UserStopped);
}
#[test]
fn progress_hook_is_scoped() {
report(1, 2); // no hook: nothing happens
let seen = Rc::new(RefCell::new(vec![]));
let s2 = seen.clone();
effectcraft_render::passes::block_on(with_hook(Box::new(move |d, t| s2.borrow_mut().push((d, t))), async {
report(1, 3);
report(2, 3);
}));
report(3, 3);
assert_eq!(*seen.borrow(), vec![(1, 3), (2, 3)]);
}
}
mod tests;
@@ -0,0 +1,309 @@
use super::*;
fn round<T: Serialize + for<'de> Deserialize<'de>>(v: &T) -> String {
let a = serde_json::to_string(v).unwrap();
let back: T = serde_json::from_str(&a).unwrap();
let b = serde_json::to_string(&back).unwrap();
assert_eq!(a, b);
a
}
/// A group with properties `a` (uid 2) and `b` (uid 3) and a sub-group (uid 4) holding `c`
/// (uid 5).
fn group() -> PropGroup {
use effectcraft_keyframe::Value as KV;
use effectcraft_project::{Node, Property};
let mut sub = PropGroup::new(4, "sub", "Sub");
sub.children.push(Node::Prop(Property::new(5, "c", "C", KV::Scalar(0.0))));
let mut g = PropGroup::new(1, "fx", "Effect");
g.children.push(Node::Prop(Property::new(2, "a", "A", KV::Scalar(0.0))));
g.children.push(Node::Prop(Property::new(3, "b", "B", KV::Scalar(0.0))));
g.children.push(Node::Group(sub));
g
}
fn set(g: &mut PropGroup, uid: Uid, v: f64) {
g.find_mut(uid).unwrap().value = effectcraft_keyframe::Value::Scalar(v);
}
fn get(g: &PropGroup, uid: Uid) -> f64 {
match g.find(uid).unwrap().value {
effectcraft_keyframe::Value::Scalar(v) => v,
ref v => panic!("{v:?}"),
}
}
#[test]
fn analysis_results_merge_with_edits_made_meanwhile() {
use effectcraft_project::Node;
let base = group();
// The user changes `a` and switches the effect off; the analysis writes `b` and `c`.
let mut live = base.clone();
set(&mut live, 2, 1.0);
live.enabled = false;
let mut result = base.clone();
set(&mut result, 3, 2.0);
set(&mut result, 5, 3.0);
let m = merge_group(&base, &live, &result).unwrap();
assert_eq!((get(&m, 2), get(&m, 3), get(&m, 5), m.enabled), (1.0, 2.0, 3.0, false));
// Unchanged on either side: the other side wins whole.
assert_eq!(merge_group(&base, &base, &result).unwrap(), result);
assert_eq!(merge_group(&base, &live, &base).unwrap(), live);
// Both changed `b`, differently: a conflict (nothing is applied). The same way: fine.
let mut both = live.clone();
set(&mut both, 3, 9.0);
assert!(merge_group(&base, &both, &result).is_none());
set(&mut both, 3, 2.0);
assert_eq!(get(&merge_group(&base, &both, &result).unwrap(), 3), 2.0);
// The analysis adds a child (a result group) while the user edits `a`: both kept.
let mut added = result.clone();
added.children.push(Node::Group(PropGroup::new(6, "out", "Output")));
let m = merge_group(&base, &live, &added).unwrap();
assert!(m.find_group(6).is_some() && get(&m, 2) == 1.0);
// The analysis drops the sub-group the user didn't touch: dropped.
let mut dropped = result.clone();
dropped.children.retain(|n| !matches!(n, Node::Group(g) if g.uid == 4));
let m = merge_group(&base, &live, &dropped).unwrap();
assert!(m.find_group(4).is_none() && get(&m, 2) == 1.0);
// …but if the user changed something in it, that's a conflict.
let mut touched = live.clone();
set(&mut touched, 5, 7.0);
assert!(merge_group(&base, &touched, &dropped).is_none());
}
#[test]
fn protocol_round_trips() {
let mut s = Session::default();
s.execute("file.openDemoProject", serde_json::json!({})).unwrap();
let comp = s.active_comp_id().unwrap();
s.execute("renderQueue.add", serde_json::json!({})).unwrap();
let item = s.project.render_queue[0].clone();
let reqs = [
WorkerJob::Render { items: vec![vec![(item.clone(), "/out.gif".into())]] },
WorkerJob::Warp { comp, layer: LayerId(3), effect: 9 },
WorkerJob::WarpPlan { comp, layer: LayerId(3), effect: 9, time: Tick::from_seconds_f64(0.5) },
WorkerJob::RotoFreeze { comp, layer: LayerId(3), effect: 9 },
WorkerJob::Camera { comp, layer: LayerId(3), effect: 9 },
WorkerJob::RotoPropagate { comp, layer: LayerId(3), effect: 9, direction: crate::roto::Direction::Forward },
WorkerJob::Track {
comp,
layer: LayerId(1),
tracker: 4,
direction: crate::tracking::Direction::Backward,
settings: TrackerSettings::default(),
points: vec![PointValues {
uid: 5,
spec: effectcraft_track::PointSpec { center: [1.0, 2.0], feature_size: [3.0, 4.0], search_offset: [0.5, 0.0], search_size: [9.0, 9.0] },
attach_offset: [0.25, -1.0],
}],
times: vec![Tick::ZERO, Tick::from_seconds_f64(0.5)],
},
WorkerJob::MaskTrack {
comp,
layer: LayerId(2),
mask: 7,
direction: crate::tracking::Direction::Forward,
path: ShapePath::ellipse([10.0, 20.0], 5.0, 6.0),
method: MaskMethod::Perspective,
times: vec![Tick::from_seconds_f64(1.0)],
},
WorkerJob::ContentFill {
plan: Box::new(crate::commands::content_fill::FillPlan {
comp,
layer: LayerId(2),
name: "Clip".into(),
width: 4,
height: 3,
times: vec![Tick::ZERO],
method: "object".into(),
lighting: 0.5,
expand: 1,
reference: Some((LayerId(1), Tick::ZERO)),
dir: "/Fill/Clip_Fill_1".into(),
}),
},
];
for job in reqs {
let kind = job.kind();
let req = WorkerRequest { id: 42, project: s.project.to_json(), files: footage_files(&s.project), job };
let t = round(&req);
assert!(t.contains("\"kind\":"), "{kind:?}");
let back: WorkerRequest = serde_json::from_str(&t).unwrap();
assert_eq!(back.job.kind(), kind);
assert_eq!(Project::from_json(&back.project).unwrap(), *s.project);
}
let g = s.active_comp().unwrap().layers[0].props.clone();
for r in [
WorkerReply::Render { state: JobState { current: Some(3), done: 4, total: 10, items_total: 1, ..Default::default() } },
WorkerReply::Item { update: ItemUpdate::Started { id: 3, unix: 1_700_000_000 } },
WorkerReply::Item { update: ItemUpdate::Finished { id: 3, status: RenderStatus::Failed("x".into()), seconds: 1.5, output: Some("/a.gif".into()) } },
WorkerReply::Progress { done: 1, total: 2 },
WorkerReply::WarpPlan {
key: [1, u64::MAX],
summary: Box::new(effectcraft_effects::warp_stab::PlanSummary {
warps: vec![[[1.0, 0.0, 2.5], [0.0, 1.0, -1.0], [0.0, 0.0, 1.0]]],
auto_scale: 1.08,
crop: Some([1.0, 2.0, 300.0, 200.0]),
valid_fraction: 0.9,
}),
},
WorkerReply::Group { comp, layer: LayerId(1), group: Box::new(g), message: "ok".into() },
WorkerReply::Segs { segs: vec![SegData::new(5, &effectcraft_track::roto::FrameSeg::empty(4, 3))] },
WorkerReply::Fill {
plan: Box::new(crate::commands::content_fill::FillPlan {
comp,
layer: LayerId(2),
name: "Clip".into(),
width: 4,
height: 3,
times: vec![Tick::ZERO],
method: "object".into(),
lighting: 0.5,
expand: 1,
reference: Some((LayerId(1), Tick::ZERO)),
dir: "/Fill/Clip_Fill_1".into(),
}),
files: vec!["/Fill/Clip_Fill_1/fill_00000.png".into()],
},
WorkerReply::Failed { error: "boom".into() },
WorkerReply::Done,
] {
let t = round(&r);
assert!(t.starts_with("{\"type\":"), "{t}");
}
}
/// An [`Offload`] that runs the job right away in a second session, through JSON (what a
/// Web Worker does, minus the threads).
struct Inline;
impl Offload for Inline {
fn start(&self, req: WorkerRequest, inbox: Arc<Inbox>) -> Result<(), String> {
let req: WorkerRequest = serde_json::from_str(&serde_json::to_string(&req).unwrap()).unwrap();
let mut w = Session { exporter: Some(Arc::new(crate::rq_tests::MockExporter { log: Default::default() })), ..Default::default() };
let post: Post = Rc::new(move |r| inbox.push(serde_json::from_str(&serde_json::to_string(&r).unwrap()).unwrap()));
run_request(&mut w, req, &post);
Ok(())
}
fn cancel(&self, _: u64) {}
}
#[test]
fn render_runs_through_the_offload() {
let mut s =
Session { exporter: Some(Arc::new(crate::rq_tests::MockExporter { log: Default::default() })), offload: Some(Arc::new(Inline)), ..Default::default() };
s.execute("file.openDemoProject", serde_json::json!({})).unwrap();
s.execute("renderQueue.add", serde_json::json!({"output": "/x.gif"})).unwrap();
let ids = s.start_render(false).unwrap();
// (the inline offload has replied already; replies are applied when polled)
s.poll_render();
assert!(s.render_job.is_none());
let it = s.project.render_queue.iter().find(|i| i.id == ids[0]).unwrap();
assert_eq!(it.status, RenderStatus::Done, "{:?}", it.status);
assert!(it.last_output.as_deref().is_some_and(|o| o.ends_with("x.gif")));
// `wait` renders inline.
s.execute("renderQueue.add", serde_json::json!({"output": "/y.gif"})).unwrap();
s.start_render(true).unwrap();
assert!(s.render_job.is_none() || s.render_job.as_ref().unwrap().remote.is_none());
}
/// Runs jobs in a session sharing `footage` (the synthetic clip of the tracking tests).
struct InlineWith(Arc<dyn effectcraft_render::FootageSource>);
impl Offload for InlineWith {
fn start(&self, req: WorkerRequest, inbox: Arc<Inbox>) -> Result<(), String> {
let req: WorkerRequest = serde_json::from_str(&serde_json::to_string(&req).unwrap()).unwrap();
let mut w = Session { footage: self.0.clone(), ..Default::default() };
let post: Post = Rc::new(move |r| inbox.push(serde_json::from_str(&serde_json::to_string(&r).unwrap()).unwrap()));
run_request(&mut w, req, &post);
Ok(())
}
fn cancel(&self, _: u64) {}
}
/// Never replies (a job still running); records cancellations.
#[derive(Default)]
struct Hanging(Mutex<Vec<u64>>);
impl Offload for Hanging {
fn start(&self, _: WorkerRequest, _: Arc<Inbox>) -> Result<(), String> {
Ok(())
}
fn cancel(&self, id: u64) {
self.0.lock().unwrap().push(id);
}
}
fn pose(f: u32) -> ([f64; 2], f64, f64) {
([150.0 + 2.0 * f as f64, 110.0 + 1.0 * f as f64], 0.0, 1.0)
}
#[test]
fn track_analysis_runs_through_the_offload_as_one_undo_step() {
let (mut s, clip, _) = crate::tests::track::setup(crate::tests::track::patch_frames(pose));
s.offload = Some(Arc::new(InlineWith(s.footage.clone())));
s.execute("track.motion", serde_json::json!({})).unwrap();
s.execute("track.setPoint", serde_json::json!({"point": 1, "center": pose(0).0, "featureSize": [36, 36], "searchSize": [80, 80]})).unwrap();
let undo = s.history.undo.len();
s.execute("track.analyze", serde_json::json!({"direction": "forward"})).unwrap();
assert!(s.is_tracking() && s.track_job.is_none(), "offloaded, not on a thread");
assert!(s.track_progress().is_some());
assert!(s.poll_offload());
assert!(!s.is_tracking() && s.offloaded.is_empty());
let l = s.active_comp().unwrap().layer(clip).unwrap();
let tp = l.trackers().next().unwrap().0.track_points().next().unwrap().clone();
let fc = tp.get("featureCenter").unwrap();
assert_eq!(fc.keys.len(), 25);
let v = fc.value_at(crate::tests::track::frame_time(12)).as_vec2();
assert!((v[0] - pose(12).0[0]).abs() < 0.3 && (v[1] - pose(12).0[1]).abs() < 0.3, "{v:?}");
assert_eq!(s.history.undo.len(), undo + 1);
assert_eq!(s.history.undo.last().unwrap().0, "Analyze Track");
assert!(s.events.iter().any(|e| matches!(e, Event::Toast { message, error: false } if message.starts_with("Tracked"))));
assert!(s.undo());
let l = s.active_comp().unwrap().layer(clip).unwrap();
assert!(l.trackers().next().unwrap().0.track_points().next().unwrap().get("featureCenter").unwrap().keys.is_empty());
// `wait` runs inline.
s.redo();
s.execute("track.analyze", serde_json::json!({"direction": "forward", "wait": true})).ok();
assert!(s.offloaded.is_empty());
}
#[test]
fn cancelling_offloaded_jobs() {
let (mut s, _, _) = crate::tests::track::setup(crate::tests::track::patch_frames(pose));
let off = Arc::new(Hanging::default());
s.offload = Some(off.clone());
s.exporter = Some(Arc::new(crate::rq_tests::MockExporter { log: Default::default() }));
// Analysis: stopping drops the job, nothing is written.
s.execute("track.motion", serde_json::json!({})).unwrap();
let rev = s.revision;
s.execute("track.analyze", serde_json::json!({})).unwrap();
assert!(s.is_tracking());
assert!(s.execute("track.analyze", serde_json::json!({})).is_err(), "one at a time");
assert!(s.stop_track());
s.poll_offload();
assert!(!s.is_tracking());
assert_eq!(off.0.lock().unwrap().len(), 1);
assert!(s.events.iter().any(|e| matches!(e, Event::Toast { message, .. } if message.contains("stopped"))));
assert!(s.revision > rev);
// Render: the queued items become User Stopped.
s.execute("renderQueue.add", serde_json::json!({"output": "/a.gif"})).unwrap();
let ids = s.start_render(false).unwrap();
assert!(s.is_rendering());
assert!(s.stop_render());
s.poll_render();
assert!(!s.is_rendering());
assert_eq!(off.0.lock().unwrap().len(), 2);
let it = s.project.render_queue.iter().find(|i| i.id == ids[0]).unwrap();
assert_eq!(it.status, RenderStatus::UserStopped);
}
#[test]
fn progress_hook_is_scoped() {
report(1, 2); // no hook: nothing happens
let seen = Rc::new(RefCell::new(vec![]));
let s2 = seen.clone();
effectcraft_render::passes::block_on(with_hook(Box::new(move |d, t| s2.borrow_mut().push((d, t))), async {
report(1, 3);
report(2, 3);
}));
report(3, 3);
assert_eq!(*seen.borrow(), vec![(1, 3), (2, 3)]);
}
@@ -9,7 +9,7 @@
//! added, changed or removed; arrays element-wise when their length is unchanged, else
//! replaced). Lossless (unlike JSON Merge Patch, `null` values survive).
//! - [`Mirror`] (page side): what the worker holds; turns the next project revision into a
//! [`FrameMsg::Sync`] (a full project the first time, a diff afterwards).
//! [`FrameMsg::Project`] the first time and [`FrameMsg::Patch`] for subsequent diffs.
//! - [`FrameServer`] (worker side): applies syncs, relayed Roto Brush segmentations and render
//! requests, and renders frames as premultiplied RGBA8 ([`rgba8_premultiplied`]).
//!
@@ -301,7 +301,7 @@ impl FrameServer {
}
/// Identity of virtual footage files, used when native metadata cannot describe the path.
/// Called from [`FrameServer::load`], not from the UI thread's cache lookup.
/// Called from `FrameServer::load`, not from the UI thread's cache lookup.
pub fn set_virtual_id(&mut self, id: Option<Arc<dyn Fn(&str) -> Option<(u64, u64)> + Send + Sync>>) {
self.virtual_id = id;
}
@@ -472,331 +472,4 @@ impl FrameServer {
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Session;
use serde_json::json;
fn round<T: Serialize + for<'de> Deserialize<'de>>(v: &T) -> T {
serde_json::from_str(&serde_json::to_string(v).unwrap()).unwrap()
}
#[test]
fn diff_and_patch_round_trip() {
let a = json!({"a": 1, "b": [1, 2, {"c": null}], "d": {"e": "x", "f": [1]}, "g": null});
let b = json!({"a": 2, "b": [1, 3, {"c": 4}], "d": {"f": [1, 2]}, "g": null, "h": {"i": null}});
let ops = diff(&a, &b);
assert!(!ops.is_empty());
let mut x = a.clone();
patch(&mut x, &round(&ops)).unwrap();
assert_eq!(x, b);
// Removal and the reverse direction.
let mut y = b.clone();
patch(&mut y, &diff(&b, &a)).unwrap();
assert_eq!(y, a);
assert!(diff(&a, &a).is_empty());
// A whole-document change.
let mut z = json!(1);
patch(&mut z, &diff(&json!(1), &json!([1]))).unwrap();
assert_eq!(z, json!([1]));
// A patch for another document fails cleanly.
assert!(patch(&mut json!(3), &diff(&a, &b)).is_err());
}
fn server(s: &Session) -> FrameServer {
FrameServer::new(s.footage.clone(), s.expr.clone())
}
/// Pixels the page itself would render.
fn local(s: &Session, comp: ItemId, t: Tick, opts: RenderOpts) -> Vec<u8> {
let mut r = Renderer::new(&s.project, s.footage.as_ref(), opts);
r.expr = s.expr.as_deref();
rgba8_premultiplied(&r.comp_frame(comp, t))
}
/// Layer buffers through the disk store: a worker's slow buffers queue up as entries; the
/// page plans what another worker fetches before its frame (recent misses on disk); that
/// worker's mid-render lookups find them and the pixels match.
#[test]
fn layer_buffers_go_through_the_disk_store() {
use effectcraft_render::disk_cache::{DiskIndex, Kind, LayerPrefetch};
let mut s = Session::default();
s.execute("file.openDemoProject", json!({})).unwrap();
let comp = s.active_comp_id().unwrap();
let opts = RenderOpts { scale: 0.25, ..Default::default() };
let t = Tick::from_seconds_f64(1.0);
let req = |id: u64, layers: bool, prefetch: Vec<String>| {
round(&FrameMsg::Render { id, revision: s.revision, comp, time: t, opts: Box::new(opts), disk: None, layers, prefetch })
};
let worker = || {
let mut w = server(&s);
let store = Arc::new(PrefetchStore::new(64 << 20).with_min_ms(0));
w.set_layer_store(Some(store.clone()));
let mut m = Mirror::default();
assert!(w.handle(round(&m.sync(s.revision, &s.project).unwrap())).is_empty());
(w, store)
};
let unhex = |v: &[String]| v.iter().map(|k| u128::from_str_radix(k, 16).unwrap()).collect::<Vec<u128>>();
// Worker A: everything misses; its buffers queue up for the disk.
let (mut a, store_a) = worker();
let out = a.handle(req(1, true, vec![]));
let (FrameReply::Frame { layer_misses, layer_hits, .. }, Some(px_a)) = &out[0] else { panic!("{:?}", out[0].0) };
assert!(!layer_misses.is_empty() && layer_hits.is_empty());
let writes = store_a.take_writes();
assert!(!writes.is_empty());
assert!(writes.iter().all(|(k, _)| (*k >> 64) as u64 == a.salt()), "keys are salted per project");
let mut index = DiskIndex::default();
let mut disk = std::collections::HashMap::new();
for (k, e) in writes {
index.insert(Kind::Layer, k, e.len() as u64);
disk.insert(k, e);
}
index.insert(Kind::Frame, 1, 10);
// The page: recent misses that are on disk, most recent first.
let mut plan = LayerPrefetch::default();
plan.note_misses(unhex(layer_misses));
let keys = plan.plan(&index, 64);
assert!(!keys.is_empty() && keys.len() <= index.len());
// Worker B fetches them before its first frame: its lookups hit.
let (mut b, store_b) = worker();
for k in &keys {
assert!(store_b.provide(*k, &disk[k]));
}
assert!(!store_b.provide(1, b"damaged"));
let out = b.handle(req(1, true, keys.iter().map(|k| format!("{k:032x}")).collect()));
let (FrameReply::Frame { layer_misses: mb, layer_hits: hb, .. }, Some(px_b)) = &out[0] else { panic!("{:?}", out[0].0) };
assert_eq!(unhex(hb).len(), keys.len(), "every fetched buffer was used");
assert!(mb.len() < layer_misses.len());
assert_eq!(px_a, px_b);
assert_eq!(*px_b, local(&s, comp, t, opts));
// Nothing new to write: what B rendered itself was rendered by A too.
assert!(store_b.take_writes().iter().all(|(k, _)| disk.contains_key(k)));
// Without `layers` the store is not consulted.
let (mut c, store_c) = worker();
c.handle(req(1, false, vec![]));
assert!(store_c.take_misses().is_empty() && store_c.take_writes().is_empty());
// Purge empties the store.
b.handle(round(&FrameMsg::Purge));
assert!(!store_b.has(keys[0]));
// LRU across frames and layers in the shared index: a touched layer outlives the frame.
index.touch(Kind::Layer, keys[0]);
let order = index.lru_order();
assert_eq!(order.last(), Some(&(Kind::Layer, keys[0])));
assert!(order.iter().position(|e| *e == (Kind::Frame, 1)).unwrap() < order.len() - 1);
}
#[test]
fn replica_follows_edits_through_diffs_and_renders_the_same_frames() {
let mut s = Session::default();
s.execute("file.openDemoProject", json!({})).unwrap();
let comp = s.active_comp_id().unwrap();
let opts = RenderOpts { scale: 0.25, guides: true, ..Default::default() };
let mut m = Mirror::default();
let mut w = server(&s);
let send = |m: FrameMsg, w: &mut FrameServer| w.handle(round(&m));
// First sync: the whole project.
let first = m.sync(s.revision, &s.project).unwrap();
assert!(matches!(first, FrameMsg::Project { .. }));
assert!(send(first, &mut w).is_empty());
assert!(m.sync(s.revision, &s.project).is_none(), "nothing new");
let t = Tick::from_seconds_f64(1.0);
let render = |w: &mut FrameServer, rev: u64, id: u64| {
w.handle(round(&FrameMsg::Render { id, revision: rev, comp, time: t, opts: Box::new(opts), disk: None, layers: false, prefetch: vec![] }))
};
let out = render(&mut w, s.revision, 1);
let (FrameReply::Frame { id, width, height, .. }, Some(px)) = &out[0] else { panic!("{:?}", out[0].0) };
assert_eq!((*id, *width as usize * *height as usize * 4), (1, px.len()));
assert_eq!(*px, local(&s, comp, t, opts));
// An edit travels as a small diff; the replica renders the edited frame.
s.execute("layer.newSolid", json!({"color": "#ff8800", "name": "Orange"})).unwrap();
let msg = m.sync(s.revision, &s.project).unwrap();
let FrameMsg::Patch { ops, .. } = &msg else { panic!("expected a patch") };
assert!(!ops.is_empty() && serde_json::to_string(ops).unwrap().len() < s.project.to_json().len() / 2);
assert!(send(msg, &mut w).is_empty());
assert_eq!(**w.project().unwrap(), *s.project);
let out = render(&mut w, s.revision, 2);
assert_eq!(out[0].1.as_deref(), Some(&local(&s, comp, t, opts)[..]));
// A request for another revision is refused as stale.
let out = render(&mut w, s.revision + 7, 3);
assert!(matches!(out[0].0, FrameReply::Failed { id: 3, stale: true, .. }));
// A patch from the wrong base asks for a resync; the mirror then sends the project.
let bad = FrameMsg::Patch { from: 999, revision: 1000, ops: vec![] };
assert!(matches!(send(bad, &mut w)[0].0, FrameReply::Resync { .. }));
m.reset();
assert!(matches!(m.sync(s.revision, &s.project), Some(FrameMsg::Project { .. })));
}
#[test]
fn frame_messages_round_trip() {
for m in [
FrameMsg::Purge,
FrameMsg::Segs { segs: vec![SegData { key: 7, data: "AAAA".into() }] },
FrameMsg::Render {
id: 3,
revision: 4,
comp: ItemId(2),
time: Tick::from_seconds_f64(0.5),
opts: Box::new(RenderOpts { scale: 0.5, draft: true, roi: Some([1.0, 2.0, 3.0, 4.0]), ..Default::default() }),
disk: Some(format!("{:032x}", 0xabcdu128)),
layers: true,
prefetch: vec![format!("{:032x}", 7u128)],
},
] {
let a = serde_json::to_string(&m).unwrap();
assert_eq!(serde_json::to_string(&round(&m)).unwrap(), a);
assert!(a.starts_with("{\"type\":"), "{a}");
}
for r in [
FrameReply::Frame { id: 1, width: 2, height: 3, ms: 4.5, passes: 2, gpu: true, layer_misses: vec!["0a".into()], layer_hits: vec![] },
FrameReply::Stored { key: "00ff".into(), bytes: 9, layer: false },
FrameReply::Stored { key: "00fe".into(), bytes: 8, layer: true },
FrameReply::Info { gpu: Some("Adapter (BrowserWebGpu)".into()), error: None },
FrameReply::Failed { id: 1, error: "x".into(), stale: true },
FrameReply::Resync { error: "y".into() },
] {
assert_eq!(round(&r), r);
}
}
/// An accelerator with deferred readbacks: a GPU effect chain is "read back" one
/// [`Deferred::settle`] after it was first asked for (computed with the CPU effects, so the
/// finished frame equals the CPU render); until then it misses and returns its input.
#[derive(Default)]
struct Deferred {
ready: std::sync::Mutex<std::collections::HashSet<u64>>,
asked: std::sync::Mutex<std::collections::HashSet<u64>>,
gate: Arc<std::sync::atomic::AtomicBool>,
chains: std::sync::atomic::AtomicUsize,
auto: effectcraft_render::AutoPick,
}
impl Deferred {
fn settle(&self) {
let asked: Vec<u64> = self.asked.lock().unwrap().drain().collect();
self.ready.lock().unwrap().extend(asked);
}
}
impl Accelerator for Deferred {
fn name(&self) -> String {
"deferred".into()
}
fn comp_frame(&self, _: &Renderer, _: ItemId, _: Tick) -> Option<Image> {
None
}
fn supports_effect(&self, id: &str) -> bool {
effectcraft_effects::GPU_EFFECTS.contains(&id)
}
fn effects(&self, chain: &[effectcraft_render::FxStep], buf: &effectcraft_effects::Buf, levels: Option<f32>) -> Option<effectcraft_effects::Buf> {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
for s in chain {
s.spec.id.hash(&mut h);
s.ctx.time.to_bits().hash(&mut h);
}
(buf.img.width, buf.img.height).hash(&mut h);
for p in &buf.img.data {
for c in p {
c.to_bits().hash(&mut h);
}
}
let key = h.finish();
if !self.ready.lock().unwrap().contains(&key) {
self.asked.lock().unwrap().insert(key);
self.gate.store(true, std::sync::atomic::Ordering::Relaxed);
return Some(buf.clone());
}
self.chains.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let mut b = buf.clone();
for s in chain {
b = effectcraft_effects::apply(s.spec, &s.ctx, b);
if let Some(l) = levels {
effectcraft_render::color::quantize(&mut b.img, l);
}
}
Some(b)
}
fn auto_pick(&self) -> Option<&effectcraft_render::AutoPick> {
Some(&self.auto)
}
fn frame_begin(&self) {
self.ready.lock().unwrap().clear();
}
fn pass_begin(&self) {
self.gate.store(false, std::sync::atomic::Ordering::Relaxed);
}
fn pass_missed(&self) -> bool {
self.gate.load(std::sync::atomic::Ordering::Relaxed)
}
fn miss_gate(&self) -> Option<Arc<std::sync::atomic::AtomicBool>> {
Some(self.gate.clone())
}
}
#[test]
fn deferred_readbacks_render_in_passes() {
let mut s = Session::default();
s.execute("file.openDemoProject", json!({})).unwrap();
let comp = s.active_comp_id().unwrap();
s.execute("layer.newSolid", json!({"color": "#3388ff", "name": "Blue", "width": 200, "height": 120})).unwrap();
s.execute("effect.apply", json!({"effect": "ec.blur.gaussian"})).unwrap();
s.execute("effect.apply", json!({"effect": "ec.stylize.glow"})).unwrap();
s.execute("layer.newSolid", json!({"color": "#ffcc00", "name": "Yellow", "width": 80, "height": 80})).unwrap();
s.execute("effect.apply", json!({"effect": "ec.color.levels"})).unwrap();
let opts = RenderOpts { scale: 0.25, backend: Backend::Gpu, ..Default::default() };
let t = Tick::from_seconds_f64(1.0);
let mut w = server(&s);
let acc = Arc::new(Deferred::default());
w.set_accel(Some(acc.clone()));
let mut m = Mirror::default();
w.handle(round(&m.sync(s.revision, &s.project).unwrap()));
let req = |id| round(&FrameMsg::Render { id, revision: s.revision, comp, time: t, opts: Box::new(opts), disk: None, layers: false, prefetch: vec![] });
assert!(w.handle(req(1)).is_empty() && w.waiting(), "the first pass misses");
let mut out = vec![];
for _ in 0..10 {
acc.settle();
out = w.resume();
if !w.waiting() {
break;
}
}
let (FrameReply::Frame { id: 1, passes, gpu: true, .. }, Some(px)) = &out[0] else { panic!("{:?}", out.first().map(|o| &o.0)) };
assert!(*passes >= 2, "{passes}");
assert!(acc.chains.load(std::sync::atomic::Ordering::Relaxed) >= 2);
// The same pixels as the CPU: no placeholder survived, in the frame or the layer cache.
assert_eq!(*px, local(&s, comp, t, RenderOpts { backend: Backend::Cpu, ..opts }));
let out = w.handle(req(2));
let out = if out.is_empty() {
acc.settle();
w.resume()
} else {
out
};
assert_eq!(out[0].1.as_deref(), Some(&px[..]));
// CPU requests never touch the accelerator; Auto times whole frames.
let cpu = RenderOpts { backend: Backend::Cpu, ..opts };
let out =
w.handle(round(&FrameMsg::Render { id: 3, revision: s.revision, comp, time: t, opts: Box::new(cpu), disk: None, layers: false, prefetch: vec![] }));
assert!(matches!(out[0].0, FrameReply::Frame { id: 3, passes: 1, gpu: false, .. }));
for id in 4..12 {
let auto = RenderOpts { backend: Backend::Auto, ..opts };
let mut out = w.handle(round(&FrameMsg::Render {
id,
revision: s.revision,
comp,
time: t,
opts: Box::new(auto),
disk: None,
layers: false,
prefetch: vec![],
}));
while w.waiting() {
acc.settle();
out = w.resume();
}
assert_eq!(out[0].1.as_deref(), Some(&px[..]), "frame {id}");
}
let st = acc.auto.stats(effectcraft_render::AutoKey::new(comp, opts.scale, false)).unwrap();
assert!(st.cpu_frames > 0 && st.gpu_frames > 0, "{st:?}");
}
}
mod tests;
@@ -0,0 +1,318 @@
use super::*;
use crate::Session;
use serde_json::json;
fn round<T: Serialize + for<'de> Deserialize<'de>>(v: &T) -> T {
serde_json::from_str(&serde_json::to_string(v).unwrap()).unwrap()
}
#[test]
fn diff_and_patch_round_trip() {
let a = json!({"a": 1, "b": [1, 2, {"c": null}], "d": {"e": "x", "f": [1]}, "g": null});
let b = json!({"a": 2, "b": [1, 3, {"c": 4}], "d": {"f": [1, 2]}, "g": null, "h": {"i": null}});
let ops = diff(&a, &b);
assert!(!ops.is_empty());
let mut x = a.clone();
patch(&mut x, &round(&ops)).unwrap();
assert_eq!(x, b);
// Removal and the reverse direction.
let mut y = b.clone();
patch(&mut y, &diff(&b, &a)).unwrap();
assert_eq!(y, a);
assert!(diff(&a, &a).is_empty());
// A whole-document change.
let mut z = json!(1);
patch(&mut z, &diff(&json!(1), &json!([1]))).unwrap();
assert_eq!(z, json!([1]));
// A patch for another document fails cleanly.
assert!(patch(&mut json!(3), &diff(&a, &b)).is_err());
}
fn server(s: &Session) -> FrameServer {
FrameServer::new(s.footage.clone(), s.expr.clone())
}
/// Pixels the page itself would render.
fn local(s: &Session, comp: ItemId, t: Tick, opts: RenderOpts) -> Vec<u8> {
let mut r = Renderer::new(&s.project, s.footage.as_ref(), opts);
r.expr = s.expr.as_deref();
rgba8_premultiplied(&r.comp_frame(comp, t))
}
/// Layer buffers through the disk store: a worker's slow buffers queue up as entries; the
/// page plans what another worker fetches before its frame (recent misses on disk); that
/// worker's mid-render lookups find them and the pixels match.
#[test]
fn layer_buffers_go_through_the_disk_store() {
use effectcraft_render::disk_cache::{DiskIndex, Kind, LayerPrefetch};
let mut s = Session::default();
s.execute("file.openDemoProject", json!({})).unwrap();
let comp = s.active_comp_id().unwrap();
let opts = RenderOpts { scale: 0.25, ..Default::default() };
let t = Tick::from_seconds_f64(1.0);
let req = |id: u64, layers: bool, prefetch: Vec<String>| {
round(&FrameMsg::Render { id, revision: s.revision, comp, time: t, opts: Box::new(opts), disk: None, layers, prefetch })
};
let worker = || {
let mut w = server(&s);
let store = Arc::new(PrefetchStore::new(64 << 20).with_min_ms(0));
w.set_layer_store(Some(store.clone()));
let mut m = Mirror::default();
assert!(w.handle(round(&m.sync(s.revision, &s.project).unwrap())).is_empty());
(w, store)
};
let unhex = |v: &[String]| v.iter().map(|k| u128::from_str_radix(k, 16).unwrap()).collect::<Vec<u128>>();
// Worker A: everything misses; its buffers queue up for the disk.
let (mut a, store_a) = worker();
let out = a.handle(req(1, true, vec![]));
let (FrameReply::Frame { layer_misses, layer_hits, .. }, Some(px_a)) = &out[0] else { panic!("{:?}", out[0].0) };
assert!(!layer_misses.is_empty() && layer_hits.is_empty());
let writes = store_a.take_writes();
assert!(!writes.is_empty());
assert!(writes.iter().all(|(k, _)| (*k >> 64) as u64 == a.salt()), "keys are salted per project");
let mut index = DiskIndex::default();
let mut disk = std::collections::HashMap::new();
for (k, e) in writes {
index.insert(Kind::Layer, k, e.len() as u64);
disk.insert(k, e);
}
index.insert(Kind::Frame, 1, 10);
// The page: recent misses that are on disk, most recent first.
let mut plan = LayerPrefetch::default();
plan.note_misses(unhex(layer_misses));
let keys = plan.plan(&index, 64);
assert!(!keys.is_empty() && keys.len() <= index.len());
// Worker B fetches them before its first frame: its lookups hit.
let (mut b, store_b) = worker();
for k in &keys {
assert!(store_b.provide(*k, &disk[k]));
}
assert!(!store_b.provide(1, b"damaged"));
let out = b.handle(req(1, true, keys.iter().map(|k| format!("{k:032x}")).collect()));
let (FrameReply::Frame { layer_misses: mb, layer_hits: hb, .. }, Some(px_b)) = &out[0] else { panic!("{:?}", out[0].0) };
assert_eq!(unhex(hb).len(), keys.len(), "every fetched buffer was used");
assert!(mb.len() < layer_misses.len());
assert_eq!(px_a, px_b);
assert_eq!(*px_b, local(&s, comp, t, opts));
// Nothing new to write: what B rendered itself was rendered by A too.
assert!(store_b.take_writes().iter().all(|(k, _)| disk.contains_key(k)));
// Without `layers` the store is not consulted.
let (mut c, store_c) = worker();
c.handle(req(1, false, vec![]));
assert!(store_c.take_misses().is_empty() && store_c.take_writes().is_empty());
// Purge empties the store.
b.handle(round(&FrameMsg::Purge));
assert!(!store_b.has(keys[0]));
// LRU across frames and layers in the shared index: a touched layer outlives the frame.
index.touch(Kind::Layer, keys[0]);
let order = index.lru_order();
assert_eq!(order.last(), Some(&(Kind::Layer, keys[0])));
assert!(order.iter().position(|e| *e == (Kind::Frame, 1)).unwrap() < order.len() - 1);
}
#[test]
fn replica_follows_edits_through_diffs_and_renders_the_same_frames() {
let mut s = Session::default();
s.execute("file.openDemoProject", json!({})).unwrap();
let comp = s.active_comp_id().unwrap();
let opts = RenderOpts { scale: 0.25, guides: true, ..Default::default() };
let mut m = Mirror::default();
let mut w = server(&s);
let send = |m: FrameMsg, w: &mut FrameServer| w.handle(round(&m));
// First sync: the whole project.
let first = m.sync(s.revision, &s.project).unwrap();
assert!(matches!(first, FrameMsg::Project { .. }));
assert!(send(first, &mut w).is_empty());
assert!(m.sync(s.revision, &s.project).is_none(), "nothing new");
let t = Tick::from_seconds_f64(1.0);
let render = |w: &mut FrameServer, rev: u64, id: u64| {
w.handle(round(&FrameMsg::Render { id, revision: rev, comp, time: t, opts: Box::new(opts), disk: None, layers: false, prefetch: vec![] }))
};
let out = render(&mut w, s.revision, 1);
let (FrameReply::Frame { id, width, height, .. }, Some(px)) = &out[0] else { panic!("{:?}", out[0].0) };
assert_eq!((*id, *width as usize * *height as usize * 4), (1, px.len()));
assert_eq!(*px, local(&s, comp, t, opts));
// An edit travels as a small diff; the replica renders the edited frame.
s.execute("layer.newSolid", json!({"color": "#ff8800", "name": "Orange"})).unwrap();
let msg = m.sync(s.revision, &s.project).unwrap();
let FrameMsg::Patch { ops, .. } = &msg else { panic!("expected a patch") };
assert!(!ops.is_empty() && serde_json::to_string(ops).unwrap().len() < s.project.to_json().len() / 2);
assert!(send(msg, &mut w).is_empty());
assert_eq!(**w.project().unwrap(), *s.project);
let out = render(&mut w, s.revision, 2);
assert_eq!(out[0].1.as_deref(), Some(&local(&s, comp, t, opts)[..]));
// A request for another revision is refused as stale.
let out = render(&mut w, s.revision + 7, 3);
assert!(matches!(out[0].0, FrameReply::Failed { id: 3, stale: true, .. }));
// A patch from the wrong base asks for a resync; the mirror then sends the project.
let bad = FrameMsg::Patch { from: 999, revision: 1000, ops: vec![] };
assert!(matches!(send(bad, &mut w)[0].0, FrameReply::Resync { .. }));
m.reset();
assert!(matches!(m.sync(s.revision, &s.project), Some(FrameMsg::Project { .. })));
}
#[test]
fn frame_messages_round_trip() {
for m in [
FrameMsg::Purge,
FrameMsg::Segs { segs: vec![SegData { key: 7, data: "AAAA".into() }] },
FrameMsg::Render {
id: 3,
revision: 4,
comp: ItemId(2),
time: Tick::from_seconds_f64(0.5),
opts: Box::new(RenderOpts { scale: 0.5, draft: true, roi: Some([1.0, 2.0, 3.0, 4.0]), ..Default::default() }),
disk: Some(format!("{:032x}", 0xabcdu128)),
layers: true,
prefetch: vec![format!("{:032x}", 7u128)],
},
] {
let a = serde_json::to_string(&m).unwrap();
assert_eq!(serde_json::to_string(&round(&m)).unwrap(), a);
assert!(a.starts_with("{\"type\":"), "{a}");
}
for r in [
FrameReply::Frame { id: 1, width: 2, height: 3, ms: 4.5, passes: 2, gpu: true, layer_misses: vec!["0a".into()], layer_hits: vec![] },
FrameReply::Stored { key: "00ff".into(), bytes: 9, layer: false },
FrameReply::Stored { key: "00fe".into(), bytes: 8, layer: true },
FrameReply::Info { gpu: Some("Adapter (BrowserWebGpu)".into()), error: None },
FrameReply::Failed { id: 1, error: "x".into(), stale: true },
FrameReply::Resync { error: "y".into() },
] {
assert_eq!(round(&r), r);
}
}
/// An accelerator with deferred readbacks: a GPU effect chain is "read back" one
/// [`Deferred::settle`] after it was first asked for (computed with the CPU effects, so the
/// finished frame equals the CPU render); until then it misses and returns its input.
#[derive(Default)]
struct Deferred {
ready: std::sync::Mutex<std::collections::HashSet<u64>>,
asked: std::sync::Mutex<std::collections::HashSet<u64>>,
gate: Arc<std::sync::atomic::AtomicBool>,
chains: std::sync::atomic::AtomicUsize,
auto: effectcraft_render::AutoPick,
}
impl Deferred {
fn settle(&self) {
let asked: Vec<u64> = self.asked.lock().unwrap().drain().collect();
self.ready.lock().unwrap().extend(asked);
}
}
impl Accelerator for Deferred {
fn name(&self) -> String {
"deferred".into()
}
fn comp_frame(&self, _: &Renderer, _: ItemId, _: Tick) -> Option<Image> {
None
}
fn supports_effect(&self, id: &str) -> bool {
effectcraft_effects::GPU_EFFECTS.contains(&id)
}
fn effects(&self, chain: &[effectcraft_render::FxStep], buf: &effectcraft_effects::Buf, levels: Option<f32>) -> Option<effectcraft_effects::Buf> {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
for s in chain {
s.spec.id.hash(&mut h);
s.ctx.time.to_bits().hash(&mut h);
}
(buf.img.width, buf.img.height).hash(&mut h);
for p in &buf.img.data {
for c in p {
c.to_bits().hash(&mut h);
}
}
let key = h.finish();
if !self.ready.lock().unwrap().contains(&key) {
self.asked.lock().unwrap().insert(key);
self.gate.store(true, std::sync::atomic::Ordering::Relaxed);
return Some(buf.clone());
}
self.chains.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let mut b = buf.clone();
for s in chain {
b = effectcraft_effects::apply(s.spec, &s.ctx, b);
if let Some(l) = levels {
effectcraft_render::color::quantize(&mut b.img, l);
}
}
Some(b)
}
fn auto_pick(&self) -> Option<&effectcraft_render::AutoPick> {
Some(&self.auto)
}
fn frame_begin(&self) {
self.ready.lock().unwrap().clear();
}
fn pass_begin(&self) {
self.gate.store(false, std::sync::atomic::Ordering::Relaxed);
}
fn pass_missed(&self) -> bool {
self.gate.load(std::sync::atomic::Ordering::Relaxed)
}
fn miss_gate(&self) -> Option<Arc<std::sync::atomic::AtomicBool>> {
Some(self.gate.clone())
}
}
#[test]
fn deferred_readbacks_render_in_passes() {
let mut s = Session::default();
s.execute("file.openDemoProject", json!({})).unwrap();
let comp = s.active_comp_id().unwrap();
s.execute("layer.newSolid", json!({"color": "#3388ff", "name": "Blue", "width": 200, "height": 120})).unwrap();
s.execute("effect.apply", json!({"effect": "ec.blur.gaussian"})).unwrap();
s.execute("effect.apply", json!({"effect": "ec.stylize.glow"})).unwrap();
s.execute("layer.newSolid", json!({"color": "#ffcc00", "name": "Yellow", "width": 80, "height": 80})).unwrap();
s.execute("effect.apply", json!({"effect": "ec.color.levels"})).unwrap();
let opts = RenderOpts { scale: 0.25, backend: Backend::Gpu, ..Default::default() };
let t = Tick::from_seconds_f64(1.0);
let mut w = server(&s);
let acc = Arc::new(Deferred::default());
w.set_accel(Some(acc.clone()));
let mut m = Mirror::default();
w.handle(round(&m.sync(s.revision, &s.project).unwrap()));
let req = |id| round(&FrameMsg::Render { id, revision: s.revision, comp, time: t, opts: Box::new(opts), disk: None, layers: false, prefetch: vec![] });
assert!(w.handle(req(1)).is_empty() && w.waiting(), "the first pass misses");
let mut out = vec![];
for _ in 0..10 {
acc.settle();
out = w.resume();
if !w.waiting() {
break;
}
}
let (FrameReply::Frame { id: 1, passes, gpu: true, .. }, Some(px)) = &out[0] else { panic!("{:?}", out.first().map(|o| &o.0)) };
assert!(*passes >= 2, "{passes}");
assert!(acc.chains.load(std::sync::atomic::Ordering::Relaxed) >= 2);
// The same pixels as the CPU: no placeholder survived, in the frame or the layer cache.
assert_eq!(*px, local(&s, comp, t, RenderOpts { backend: Backend::Cpu, ..opts }));
let out = w.handle(req(2));
let out = if out.is_empty() {
acc.settle();
w.resume()
} else {
out
};
assert_eq!(out[0].1.as_deref(), Some(&px[..]));
// CPU requests never touch the accelerator; Auto times whole frames.
let cpu = RenderOpts { backend: Backend::Cpu, ..opts };
let out =
w.handle(round(&FrameMsg::Render { id: 3, revision: s.revision, comp, time: t, opts: Box::new(cpu), disk: None, layers: false, prefetch: vec![] }));
assert!(matches!(out[0].0, FrameReply::Frame { id: 3, passes: 1, gpu: false, .. }));
for id in 4..12 {
let auto = RenderOpts { backend: Backend::Auto, ..opts };
let mut out =
w.handle(round(&FrameMsg::Render { id, revision: s.revision, comp, time: t, opts: Box::new(auto), disk: None, layers: false, prefetch: vec![] }));
while w.waiting() {
acc.settle();
out = w.resume();
}
assert_eq!(out[0].1.as_deref(), Some(&px[..]), "frame {id}");
}
let st = acc.auto.stats(effectcraft_render::AutoKey::new(comp, opts.scale, false)).unwrap();
assert!(st.cpu_frames > 0 && st.gpu_frames > 0, "{st:?}");
}
+294
View File
@@ -0,0 +1,294 @@
//! The command registry. Ids follow After Effects' menu structure; every menu item, panel button,
//! shortcut and viewer/timeline gesture maps to one of these.
mod align;
mod align_data;
mod anim;
mod anim_presets;
pub mod anim_tools;
mod animation;
pub(crate) mod app_more;
mod autotrace;
mod batch;
mod camera_cmds;
mod comp;
pub(crate) mod comp_more;
pub mod content_fill;
mod create;
mod edit;
mod effect;
pub mod essential;
pub mod expr_tools;
mod file;
pub(crate) mod file_more;
mod focus;
pub mod footage_panel;
pub(crate) mod frame_export;
mod frontend;
mod help;
mod import_project;
mod key_labels;
mod key_transform;
mod keys_more;
mod layer;
mod layer_menu;
mod layer_time;
pub(crate) mod link;
mod liquify;
mod lottie;
pub mod markers;
mod mask;
pub mod mask_interp;
pub(crate) mod model3d;
mod nav_more;
pub mod paint;
pub mod panels_cmds;
pub(crate) mod path_nulls;
mod paths;
mod project_items;
pub(crate) mod prop;
mod prop_groups;
mod proxy;
pub mod puppet;
mod query;
mod quick_offset;
mod relink;
pub(crate) mod relink_moved;
mod render_queue;
pub(crate) mod rig3d;
pub mod roto_cmds;
mod scene_detect;
pub mod scripts;
mod settings;
pub mod shape_stroke;
pub mod shape_tool;
mod stubs;
mod styles;
mod text_anim;
pub mod text_edit;
mod three_d;
pub mod time;
mod timeline;
mod track;
mod view;
pub mod viewer_cmds;
pub(crate) mod vpe;
pub(crate) mod vr_editor;
mod warp_cmds;
pub(crate) mod watch_folder;
#[cfg(test)]
pub(crate) use app_more::report_text as report_text_for_tests;
pub(crate) use app_more::view_command;
#[cfg(test)]
pub(crate) use file::open as open_for_tests;
#[cfg(test)]
pub(crate) use keys_more::amplitudes as amplitudes_for_tests;
#[cfg(test)]
pub(crate) use mask::split_segment as split_segment_for_tests;
use std::sync::OnceLock;
use effectcraft_project::{Comp, ItemId, Layer, LayerId};
use effectcraft_time::Tick;
use serde_json::Value;
use crate::{EngineError, Result, Session};
pub type Run = fn(&mut Session, &Value) -> Result<Value>;
pub type Enabled = fn(&Session) -> std::result::Result<(), String>;
/// Metadata + implementation for one command.
pub struct CommandSpec {
pub id: &'static str,
pub label: &'static str,
/// Menu placement, e.g. `["Layer", "New"]`. Empty = not in menus.
pub menu: &'static [&'static str],
/// Default shortcut (egui-style names, `Cmd` = ⌘ on macOS / Ctrl elsewhere).
pub shortcut: Option<&'static str>,
/// Further default shortcuts (the menus and tooltips show `shortcut`).
pub alt_shortcuts: &'static [&'static str],
/// Parameter description for agents and the MCP schema.
pub params: &'static str,
pub enabled: Enabled,
pub run: Run,
/// Record in the journal (false for pure queries).
pub journal: bool,
}
#[macro_export]
macro_rules! cmd {
// Several default shortcuts: `["PageDown", "Cmd+ArrowRight"]`.
($id:literal, $label:literal, [$($m:literal),*], [$sc:literal, $($alt:literal),+], $params:literal, $en:expr, $run:expr) => {
$crate::commands::CommandSpec { alt_shortcuts: &[$($alt),+], ..$crate::cmd!($id, $label, [$($m),*], Some($sc), $params, $en, $run) }
};
($id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
$crate::commands::CommandSpec {
id: $id,
label: $label,
menu: &[$($m),*],
shortcut: $sc,
alt_shortcuts: &[],
params: $params,
enabled: $en,
run: $run,
journal: true,
}
};
}
#[macro_export]
macro_rules! query {
($id:literal, $label:literal, $params:literal, $run:expr) => {
$crate::commands::CommandSpec {
id: $id,
label: $label,
menu: &[],
shortcut: None,
alt_shortcuts: &[],
params: $params,
enabled: $crate::commands::always,
run: $run,
journal: false,
}
};
}
mod enabled;
mod params;
mod schema;
use super::*;
pub use enabled::always;
pub(crate) use enabled::{has_comp, has_keys, has_layers, has_project_selection, not_yet, not_yet_run};
pub use params::match_path_of;
pub(crate) use params::{
b_p, bad, comp_id, f_p, frontend, layer_mut, layer_p, layers_p, merge_p, resolve_effect_path, resolve_layer, selectable, selected_leaf_props, str_p,
time_p, unlocked, visible_p,
};
pub use schema::{accepted_params, check_params, params_schema};
pub fn command_specs() -> &'static [CommandSpec] {
static SPECS: OnceLock<Vec<CommandSpec>> = OnceLock::new();
SPECS.get_or_init(|| {
let mut v = Vec::new();
v.extend(file::specs());
v.extend(crate::models::specs());
v.extend(edit::specs());
v.extend(comp::specs());
v.extend(layer::specs());
v.extend(styles::specs());
v.extend(three_d::specs());
v.extend(model3d::specs());
v.extend(rig3d::specs());
v.extend(text_anim::specs());
v.extend(text_edit::specs());
v.extend(layer_time::specs());
v.extend(prop::specs());
v.extend(prop_groups::specs());
v.extend(project_items::specs());
v.extend(anim::specs());
v.extend(anim_tools::specs());
v.extend(link::specs());
v.extend(markers::specs());
v.extend(mask::specs());
v.extend(paths::specs());
v.extend(effect::specs());
v.extend(time::specs());
v.extend(nav_more::specs());
v.extend(render_queue::specs());
v.extend(help::specs());
v.extend(query::specs());
v.extend(batch::specs());
v.extend(layer_menu::specs());
v.extend(create::specs());
v.extend(animation::specs());
v.extend(anim_presets::specs());
v.extend(view::specs());
v.extend(viewer_cmds::specs());
v.extend(shape_stroke::specs());
v.extend(shape_tool::specs());
v.extend(focus::specs());
v.extend(key_labels::specs());
v.extend(key_transform::specs());
v.extend(quick_offset::specs());
v.extend(file_more::specs());
v.extend(relink::specs());
v.extend(relink_moved::specs());
v.extend(lottie::specs());
v.extend(import_project::specs());
v.extend(timeline::specs());
v.extend(comp_more::specs());
v.extend(frame_export::specs());
v.extend(watch_folder::specs());
v.extend(vpe::specs());
v.extend(path_nulls::specs());
v.extend(vr_editor::specs());
v.extend(frontend::specs());
v.extend(track::specs());
v.extend(mask_interp::specs());
v.extend(warp_cmds::specs());
v.extend(camera_cmds::specs());
v.extend(roto_cmds::specs());
v.extend(paint::specs());
v.extend(puppet::specs());
v.extend(liquify::specs());
v.extend(settings::specs());
v.extend(app_more::specs());
v.extend(keys_more::specs());
v.extend(essential::specs());
v.extend(expr_tools::specs());
v.extend(proxy::specs());
v.extend(scripts::specs());
v.extend(panels_cmds::specs());
v.extend(footage_panel::specs());
v.extend(autotrace::specs());
v.extend(scene_detect::specs());
v.extend(align_data::specs());
v.extend(align::specs());
v.extend(content_fill::specs());
v.extend(stubs::specs());
v.extend(crate::storage::specs());
v.extend(crate::learn::specs());
v.extend(crate::templates::specs());
v.extend(crate::preview::specs());
v
})
}
/// Set `footage` as the proxy of `item` (File ▸ Create Proxy's post-render action).
pub(crate) fn proxy_set(s: &mut Session, item: ItemId, footage: effectcraft_project::Footage) -> Result<()> {
proxy::set_proxy_footage(s, &[item], footage)
}
/// (id, name) of the text animation presets.
pub fn text_preset_list() -> Vec<(String, String)> {
text_anim::preset_names()
}
/// (id, name, category) of the built-in animation presets (Transitions…).
pub fn builtin_preset_list() -> Vec<(String, String, String)> {
anim_presets::preset_names()
}
pub fn find(id: &str) -> Option<&'static CommandSpec> {
// Every command execution looks its spec up: index the ~2,000 ids once.
static INDEX: OnceLock<std::collections::HashMap<&'static str, usize>> = OnceLock::new();
let index = INDEX.get_or_init(|| {
let mut m = std::collections::HashMap::new();
for (i, c) in command_specs().iter().enumerate() {
m.entry(c.id).or_insert(i);
}
m
});
index.get(id).map(|&i| &command_specs()[i])
}
/// Font families available to text layers (bundled + scanned system fonts).
/// Text animation presets: (id, name), for menus.
pub fn text_presets() -> Vec<(String, String)> {
commands::text_preset_list()
}
/// Built-in animation presets (Effects & Presets ▸ * Animation Presets): (id, name, category).
pub fn animation_presets() -> Vec<(String, String, String)> {
commands::builtin_preset_list()
}
@@ -13,14 +13,23 @@ use super::{CommandSpec, always, b_p, bad, comp_id, has_comp, has_layers, has_pr
use crate::{EngineError, Event, Result, Session, cmd, query};
/// Run `f` (which may run several undoable commands) as one undo step named `label`; on error
/// the project is restored.
/// the project, editor state and undo history are restored.
///
/// Front-end events emitted by a failed `f` are discarded with the state. Pending-analysis
/// queues and the journal are not part of this rollback and keep their existing behaviour.
pub(crate) fn grouped<T>(s: &mut Session, label: &str, f: impl FnOnce(&mut Session) -> Result<T>) -> Result<T> {
// The steps `f` records go to an empty stack (so the undo limit can't drop the snapshot
// taken before them), then become one step on the real one.
// taken before them), then become one step on the real one. Keep the complete pre-`f`
// history and editor state for a failure: rollback must not leave a partially retargeted
// editor or a `f`-recorded redo/branch behind.
let saved_history = s.history.clone();
let saved = std::mem::take(&mut s.history.undo);
let before = s.project.clone();
let before_state = s.state.clone();
let event_mark = s.events.len();
let r = f(s);
let changed = !s.history.undo.is_empty();
let interim_revision = s.ime_revision;
s.history.undo = saved;
s.history.merge_key = None;
match r {
@@ -36,9 +45,22 @@ pub(crate) fn grouped<T>(s: &mut Session, label: &str, f: impl FnOnce(&mut Sessi
Ok(v)
}
Err(e) => {
// Snapshot the failing editor before restoring the pre-`f` state: an intermediate
// text.edit / text change / comp switch must invalidate the pre-`f` editor token
// instead of reviving it.
let edited = super::text_edit::active_editor_snapshot(s);
s.project = before;
s.state = before_state;
s.history = saved_history;
s.history.merge_key = None;
// A failed intermediate comp switch must not reach the front end through an
// `OpenComp` event while its state has been rolled back.
s.events.truncate(event_mark);
s.sanitize_after_replacement(edited.as_ref());
// As in the batch rollback, the IME revision is a high-water mark: leave the highest
// intermediate stamp rather than rewinding to before `f`.
s.ime_revision = s.ime_revision.max(interim_revision);
if changed {
s.project = before;
s.sanitize_state();
s.bump();
}
Err(e)
@@ -332,7 +354,7 @@ fn assign_view_shortcut(s: &mut Session, p: &Value) -> Result<Value> {
reassign(s, view_command(cur), &slot, "view.assign3dShortcut")
}
/// Window ▸ Assign Shortcut to "<workspace>" Workspace: Shift+F10, Shift+F11 or Shift+F12.
/// Window ▸ Assign Shortcut to `"<workspace>"` Workspace: Shift+F10, Shift+F11 or Shift+F12.
/// The frontend fills in the current workspace.
fn assign_workspace_shortcut(s: &mut Session, p: &Value) -> Result<Value> {
let slot = slot_p(p, "window.assignWorkspaceShortcut", true)?;
@@ -646,6 +668,9 @@ fn extract_cubemap(s: &mut Session, p: &Value) -> Result<Value> {
Ok(r)
}
/// The specs for the commands in this module: history steps, recent footage and presets, Save
/// a Copy As XML, footage replacement and Execute File, 3D-view and workspace shortcuts,
/// logging and the system report, VR environment/cubemap creation and System Information.
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("edit.history", "History", [], None, "{steps?: n (undo n steps), redo?: n}", has_history, history),
@@ -704,6 +729,36 @@ pub fn specs() -> Vec<CommandSpec> {
mod tests {
use serde_json::json;
#[test]
fn grouped_rollback_restores_the_editor_state_and_history_after_a_transient_text_change() {
let mut s = crate::Session::default();
s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 1})).unwrap();
let lid = s.execute("layer.newText", json!({"text": "abcdef"})).unwrap()["layer"].as_u64().unwrap();
s.execute("text.edit", json!({"layer": lid, "caret": 3})).unwrap();
let original_comp = s.active_comp_id().unwrap();
let owner = s.state.text_edit.as_ref().unwrap().session;
let other =
s.execute("comp.new", json!({"name": "Other", "width": 160, "height": 90, "duration": 1, "open": false})).unwrap()["comp"].as_u64().unwrap();
let undo = s.history.undo.len();
s.drain_events();
let r = super::grouped(&mut s, "Rollback", |s| -> crate::Result<()> {
// An intermediate document edit and state switch, both undone by the error path.
s.execute("layer.setText", json!({"layer": lid, "text": "ab"}))?;
s.execute("comp.open", json!({"comp": other}))?;
Err(crate::EngineError::Other("stop".into()))
});
assert!(r.is_err());
assert_eq!(s.active_comp_id(), Some(original_comp), "the pre-group comp is restored");
assert_eq!(super::super::text_edit::layer_doc(&s, effectcraft_project::LayerId(lid)).unwrap().text, "abcdef", "the document is restored");
assert_eq!(s.history.undo.len(), undo, "the partial grouped steps are not left in history");
assert_ne!(s.state.text_edit.as_ref().unwrap().session, owner, "a transient replacement does not revive the old IME token");
assert!(
!s.drain_events().iter().any(|e| matches!(e, crate::Event::OpenComp(_))),
"the failed intermediate comp switch is not published to the front end"
);
}
/// `File.execute()` opens the file as a well-formed `file://` URL (spaces, `#`, `%`).
#[test]
fn execute_file_opens_an_encoded_file_url() {
@@ -51,11 +51,25 @@ fn substitute(v: &Value, results: &[Value]) -> Result<Value> {
})
}
/// `engine.batch`: runs `steps` (`[{command|id, params?}]`) as engine commands, substituting
/// `$N` / `$N.key.0` strings in each step's params with earlier results, and returns
/// `{steps, results}`.
///
/// `engine.batch`, `file.runScript` and `script.run` are refused inside a batch. Successful
/// steps are folded into one undo step named `label` (default "Batch"). With the default
/// `atomic: true`, a failing step restores the project, history and editor state, drops the
/// events the batch published, sanitizes the editor against the failing intermediate state (so
/// no pre-batch IME token is revived) and keeps the higher process IME revision; with
/// `atomic: false` the steps before the failure stay applied. Either way the error names the
/// failing step and command.
fn batch(s: &mut Session, p: &Value) -> Result<Value> {
let steps = p.get("steps").and_then(Value::as_array).ok_or_else(|| bad("engine.batch", "missing `steps`: [{command, params?}, …]"))?.clone();
let label = str_p(p, "label").unwrap_or("Batch").to_string();
let atomic = p.get("atomic").and_then(Value::as_bool).unwrap_or(true);
let (project, history, state) = (s.project.clone(), s.history.clone(), s.state.clone());
// Rolled-back steps may have queued events (an `OpenComp`, a Toast) for the state that is
// about to be dropped: the UI must not act on them.
let event_mark = s.events.len();
let mut results: Vec<Value> = Vec::with_capacity(steps.len());
for (i, step) in steps.iter().enumerate() {
let run = |s: &mut Session, results: &[Value]| -> Result<Value> {
@@ -75,10 +89,21 @@ fn batch(s: &mut Session, p: &Value) -> Result<Value> {
Err(e) => {
let id = step.get("command").or_else(|| step.get("id")).and_then(Value::as_str).unwrap_or("?");
let note = if atomic {
// The snapshot is the intermediate state the failing step left: comparing it
// with the restored editor detects a restart or cross-comp switch inside the
// batch, so the pre-batch token is never revived. Do not restore
// `ime_revision`: a transient text edit inside the batch already advanced
// this process-monotonic high-water mark, and rewinding it would reuse a
// stamp (letting a wide-selection composition look current). An unrelated
// failed batch did not advance it, so its value stays exactly as it was.
let edited = crate::commands::text_edit::active_editor_snapshot(s);
let interim_revision = s.ime_revision;
s.project = project;
s.history = history;
s.state = state;
s.sanitize_state();
s.events.truncate(event_mark);
s.sanitize_after_replacement(edited.as_ref());
s.ime_revision = s.ime_revision.max(interim_revision);
s.bump();
"; the batch was rolled back (nothing changed)".to_string()
} else {
@@ -124,6 +149,32 @@ mod tests {
use crate::Session;
/// Failed atomic steps discard their UI events while preserving events queued beforehand.
#[test]
fn atomic_batch_event_rollback_preserves_prior_events() {
for atomic in [true, false] {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "First", "width": 100, "height": 100})).unwrap();
let first = s.active_comp_id().unwrap();
s.execute("comp.new", json!({"name": "Second", "width": 100, "height": 100})).unwrap();
let second = s.active_comp_id().unwrap();
s.drain_events();
s.execute("comp.open", json!({"comp": first.0})).unwrap();
let result = s.execute_checked(
"engine.batch",
json!({"atomic": atomic, "steps": [
{"command": "comp.open", "params": {"comp": second.0}},
{"command": "no.suchCommand", "params": {}}
]}),
);
assert!(result.is_err());
assert_eq!(s.active_comp_id(), Some(if atomic { first } else { second }));
let opened: Vec<_> =
s.drain_events().into_iter().filter_map(|event| if let crate::Event::OpenComp(id) = event { Some(id) } else { None }).collect();
assert_eq!(opened, if atomic { vec![first] } else { vec![first, second] });
}
}
#[test]
fn batch_refs_one_undo_step_and_rollback() {
let mut s = Session::default();
@@ -2,7 +2,7 @@
use effectcraft_color::Label;
use effectcraft_keyframe::Value as KV;
use effectcraft_project::{Comp, Expression, ItemKind, Layer, LayerId, LayerSource, TrackMatte};
use effectcraft_project::{Comp, Expression, ItemId, ItemKind, Layer, LayerId, LayerSource, TrackMatte};
use effectcraft_time::Tick;
use serde_json::{Value, json};
@@ -149,46 +149,73 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!(new.iter().map(|l| l.0).collect::<Vec<_>>()))
}
/// `edit.clear`: delegates to `text.delete` while editing text (deleting the selection or
/// preceding character/word), then handles selected Time Remap (turning remapping off), keyframes, loose mask
/// vertices, puppet pins, effects, masks/shape items/text animators/trackers, and finally the
/// selected (or given) unlocked layers. Layer and selected-property-group paths return the number
/// removed; text, Time Remap, keyframe, loose-vertex, puppet-pin and effect paths return their
/// delegated command's result.
///
/// Layer removal also unparents surviving children in place, clears track mattes that pointed
/// at a removed layer and rewrites layer-reference values and keys to `None` in the same undo
/// step; the text-edit session is then sanitized so a removed layer's editor is not reused.
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
if s.state.text_edit.is_some() && p.get("layers").is_none() {
return s.execute("text.delete", json!({}));
if let Some(done) = delete_selection(s, p)? {
return Ok(done);
}
let (cid, ids) = layers_p(s, p)?;
let ids = super::unlocked(s, cid, ids, "edit.clear")?;
clear_layers(s, cid, &ids)?;
// A removed layer may have been the edited one: end that session before another command
// can reuse it. Undo restores a layer as a new editor, never by reviving the old IME session.
s.sanitize_state();
Ok(json!(ids.len()))
}
/// Deletes the selected things that are not layers. `None` when the layers are deleted
/// instead (nothing else is selected, or the command names its layers).
fn delete_selection(s: &mut Session, p: &Value) -> Result<Option<Value>> {
let no_layers = p.get("layers").is_none();
if s.state.text_edit.is_some() && no_layers {
return Ok(Some(s.execute("text.delete", json!({}))?));
}
// Time Remap selected (its name, which selects all its keys) → time remapping off, not the
// layer (After Effects).
if p.get("layers").is_none() {
if no_layers {
let remapped = super::layer_time::selected_time_remap(s);
if !remapped.is_empty() {
return super::layer_time::disable_time_remap(s, &remapped);
return Ok(Some(super::layer_time::disable_time_remap(s, &remapped)?));
}
}
// Keyframes selected → delete keys; mask vertices → delete them; else layers.
if !s.state.selected_keys.is_empty() && p.get("layers").is_none() {
return s.execute("keys.delete", json!({}));
if !s.state.selected_keys.is_empty() && no_layers {
return Ok(Some(s.execute("keys.delete", json!({}))?));
}
// (Points selected with their mask, which selecting a mask does: the mask goes, below.)
let comp = s.active_comp();
let loose = s.state.selected_vertices.iter().any(|v| {
!s.state.selected_props.contains(&(v.layer, v.mask)) && comp.and_then(|c| c.layer(v.layer)).is_some_and(|l| l.props.find_group(v.mask).is_some())
});
if loose && p.get("layers").is_none() {
return s.execute("mask.deleteVertices", json!({}));
if loose && no_layers {
return Ok(Some(s.execute("mask.deleteVertices", json!({}))?));
}
// Puppet pins selected → delete the pins (not their layer).
if p.get("layers").is_none() && !super::puppet::selected_pins(s).is_empty() {
return s.execute("puppet.removePin", json!({}));
if no_layers && !super::puppet::selected_pins(s).is_empty() {
return Ok(Some(s.execute("puppet.removePin", json!({}))?));
}
// Effects selected → remove them.
if p.get("layers").is_none() && !super::effect::selected_effects(s).is_empty() {
return s.execute("effect.remove", json!({}));
if no_layers && !super::effect::selected_effects(s).is_empty() {
return Ok(Some(s.execute("effect.remove", json!({}))?));
}
// Masks, shape items, text animators or trackers selected → remove them.
if p.get("layers").is_none()
&& let Some(n) = super::prop_groups::remove_selected(s)?
{
return Ok(n);
if no_layers && let Some(n) = super::prop_groups::remove_selected(s)? {
return Ok(Some(n));
}
let (cid, ids) = layers_p(s, p)?;
let ids = super::unlocked(s, cid, ids, "edit.clear")?;
Ok(None)
}
/// Removes layers `ids` from comp `cid` in one undoable step, and clears what referred to them.
fn clear_layers(s: &mut Session, cid: ItemId, ids: &[LayerId]) -> Result<()> {
// Children of deleted layers are unparented where they are (like After Effects).
let comp = s.project.comp(cid).ok_or(crate::EngineError::NoComp)?;
let orphans: Vec<LayerId> = comp.layers.iter().filter(|l| !ids.contains(&l.id) && l.parent.is_some_and(|p| ids.contains(&p))).map(|l| l.id).collect();
@@ -226,8 +253,7 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
st.selected_props.clear();
st.selected_keys.clear();
Ok(())
})?;
Ok(json!(ids.len()))
})
}
fn clear_clipboards(s: &mut Session) {
@@ -847,7 +873,7 @@ fn original_path(s: &Session) -> Option<String> {
/// importer takes. Anything else (a folder or app bundle, a script, a shortcut) is refused.
pub(crate) fn openable_original(path: &str) -> std::result::Result<(), String> {
let media = |p: &std::path::Path| {
let ext = p.extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).unwrap_or_default();
let ext = p.extension().and_then(std::ffi::OsStr::to_str).map(str::to_ascii_lowercase).unwrap_or_default();
crate::media_browser::kind_of(&p.to_string_lossy()).is_some_and(|k| k != "project") || matches!(ext.as_str(), "pdf" | "ai" | "eps")
};
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| path.to_string());
+38
View File
@@ -0,0 +1,38 @@
use super::*;
// ---------- enablement ----------
pub fn always(_: &Session) -> std::result::Result<(), String> {
Ok(())
}
pub(crate) fn has_comp(s: &Session) -> std::result::Result<(), String> {
s.active_comp().map(|_| ()).ok_or_else(|| "no composition is open".into())
}
pub(crate) fn has_layers(s: &Session) -> std::result::Result<(), String> {
has_comp(s)?;
if s.state.selected_layers.is_empty() { Err("select a layer first".into()) } else { Ok(()) }
}
pub(crate) fn has_keys(s: &Session) -> std::result::Result<(), String> {
has_comp(s)?;
if s.state.selected_keys.is_empty() { Err("select keyframes first".into()) } else { Ok(()) }
}
pub(crate) fn has_project_selection(s: &Session) -> std::result::Result<(), String> {
if s.state.project_selection.is_empty() { Err("select an item in the Project panel first".into()) } else { Ok(()) }
}
/// Disabled-command enablement (menu entries whose implementation lands with another milestone;
/// none are left, kept for future stubs).
#[allow(dead_code)]
pub(crate) fn not_yet(_: &Session) -> std::result::Result<(), String> {
Err("not available yet in EffectCraft".into())
}
/// `run` of a not-yet-available command.
#[allow(dead_code)]
pub(crate) fn not_yet_run(_: &mut Session, _: &Value) -> Result<Value> {
Err(EngineError::Other("not available yet in EffectCraft".into()))
}
@@ -560,7 +560,7 @@ pub const DATA_EXTENSIONS: &[&str] = &["json", "csv", "tsv"];
/// A data footage item for `path` (JSON / CSV / TSV), `None` for other files.
pub(crate) fn data_footage(services: &dyn crate::Services, path: &str) -> Option<std::result::Result<effectcraft_project::Footage, String>> {
let ext = std::path::Path::new(path).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();
let ext = std::path::Path::new(path).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("").to_ascii_lowercase();
if !DATA_EXTENSIONS.contains(&ext.as_str()) {
return None;
}
+212
View File
@@ -0,0 +1,212 @@
//! Layer menu.
use effectcraft_color::{BlendMode, Label};
use effectcraft_keyframe::{Justify, TextDoc, Value as KV};
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{
Comp, FootageKind, FrameBlend, GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSource, MaskMode, MatteKind, Node, Project, PropGroup, Quality, Solid,
TrackMatte, limits,
};
use effectcraft_time::Tick;
use serde_json::{Value, json};
use super::{CommandSpec, b_p, bad, comp_id, f_p, has_comp, has_layers, layer_mut, layer_p, layers_p, merge_p, resolve_layer, str_p, unlocked};
use crate::{EngineError, Result, Session, cmd};
mod contents;
mod create;
mod parent;
mod replace;
mod select;
mod switches;
mod timing;
mod transform;
use contents::{add_mask, add_shape_item, mask_props, set_text};
use create::{add_item, new_adjustment, new_null, new_shape, new_solid, new_text};
pub(crate) use create::{color_p, index_p, insert_layer, insert_layer_at, place, position_p};
pub(crate) use parent::parent_fixes;
use parent::set_parent;
use replace::{has_replace_source, replace_source};
use select::{select, select_next, select_prev, select_step};
use switches::{blend, rename, set_comment, set_switch, track_matte};
use timing::{arrange, move_frames, precompose, slip, timing};
use transform::{has_layer_settings, layer_settings, transform_op};
pub(crate) use transform::{offset_position, transform_default, update_position};
/// `p` with `key` set.
fn with(p: &Value, key: &str, v: Value) -> Value {
let mut p = if p.is_object() { p.clone() } else { json!({}) };
p[key] = v;
p
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"layer.newText",
"Text",
["Layer", "New"],
Some("Cmd+Alt+Shift+T"),
"{text?, name? (layer name; default the text), position? [x,y], box? [x,y,w,h] (paragraph text, comp space), vertical?, edit? (start editing), font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?, baselineShift?, hScale?, vScale?, tsume?, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?, superscript?, subscript?, kerning?, ligatures?, justify?, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?, composer?, hangingPunctuation?, strokeOverFill?} (attributes as layer.setText)",
has_comp,
new_text
),
cmd!("layer.newSolid", "Solid...", ["Layer", "New"], Some("Cmd+Y"), "{name?, color? #hex|[r,g,b], width?, height?, pixelAspect?}", has_comp, new_solid),
cmd!("layer.newNull", "Null Object", ["Layer", "New"], Some("Cmd+Alt+Shift+Y"), "{name?}", has_comp, new_null),
cmd!(
"layer.newShape",
"Shape Layer",
["Layer", "New"],
None,
"{kind?: rect|rounded|ellipse|star|polygon|none, name?, size?, fill?, fillType?, fillBlend?, fillOpacity?, stroke?, strokeType?, strokeBlend?, strokeOpacity?, strokeWidth?, position?}",
has_comp,
new_shape
),
cmd!("layer.newAdjustment", "Adjustment Layer", ["Layer", "New"], Some("Cmd+Alt+Y"), "{name?}", has_comp, new_adjustment),
cmd!(
"layer.settings",
"Layer Settings...",
["Layer"],
Some("Cmd+Shift+Y"),
"{layer?, name?, color?, width?, height?, pixelAspect?, affectAll?: bool (default true; false gives this layer its own copy of a solid other layers use)} → {layer, solid?}",
has_layer_settings,
layer_settings
),
cmd!(
"layer.addItem",
"Add Footage to Comp",
["File"],
Some("Cmd+/"),
"{item: id|name, time? (s, the In point), index? (1-based stack position; default above the selected layer), position? ([x, y] comp px; default the centre), duration? (s, for a still)}",
has_comp,
add_item
),
cmd!(
"layer.replaceSource",
"Replace Layer Source",
[],
Some("Cmd+Alt+/"),
"{comp?, layers?, layer?, item?: id|name (default the Project panel selection), fixExpressions?: bool (accepted for After Effects compatibility)} → {layers, item}",
has_replace_source,
replace_source
),
cmd!("layer.select", "Select Layers", [], None, "{layers: [id|name|#n], add?, toggle?}", has_comp, select),
cmd!("layer.selectNext", "Select Next Layer", [], Some("Cmd+ArrowDown"), "{add?}", has_comp, select_next),
cmd!("layer.selectPrevious", "Select Previous Layer", [], Some("Cmd+ArrowUp"), "{add?}", has_comp, select_prev),
cmd!("layer.extendSelectionNext", "Extend Selection to Next Layer", [], Some("Cmd+Shift+ArrowDown"), "{}", has_comp, |s, _| select_step(
s,
&json!({"add": true}),
1
)),
cmd!("layer.extendSelectionPrevious", "Extend Selection to Previous Layer", [], Some("Cmd+Shift+ArrowUp"), "{}", has_comp, |s, _| select_step(
s,
&json!({"add": true}),
-1
)),
cmd!(
"layer.setSwitch",
"Layer Switch",
[],
None,
"{layers?, switch: video|audio|solo|lock|shy|collapse|quality|fx|frameBlend|motionBlur|adjustment|threeD|guide|preserveTransparency, value?, merge?, others? (the named layers alone: the other layers get the opposite state, or come back)}",
has_layers,
set_switch
),
cmd!("layer.rename", "Rename", [], Some("Enter"), "{layer?, name}", has_layers, rename),
cmd!("layer.setComment", "Layer Comment", [], None, "{layers?, comment}", has_layers, set_comment),
cmd!("layer.setBlendMode", "Blending Mode", [], None, "{layers?, mode?: Normal|Multiply|Screen|…, step?: ±1}", has_layers, blend),
cmd!(
"layer.setTrackMatte",
"Track Matte",
[],
None,
"{layer?, matte: layer|null, kind?: alpha|alphaInverted|luma|lumaInverted}",
has_layers,
track_matte
),
cmd!("layer.setParent", "Parent", [], None, "{layers?, parent: layer|null, compensate?: bool}", has_layers, set_parent),
cmd!(
"layer.timing",
"Layer Timing",
[],
None,
"{layers?, op?: moveInToTime|moveOutToTime|moveInToStart|moveOutToEnd|trimInToTime|trimOutToTime, delta?, start?, in?, out?, inDelta?, outDelta? (seconds, per layer), merge?}",
has_layers,
timing
),
cmd!("layer.moveInToStart", "Move Layer In Point to Beginning of Comp", [], Some("Alt+Home"), "{layers?}", has_layers, |s, p| timing(
s,
&with(p, "op", json!("moveInToStart"))
)),
cmd!("layer.moveOutToEnd", "Move Layer Out Point to End of Comp", [], Some("Alt+End"), "{layers?}", has_layers, |s, p| timing(
s,
&with(p, "op", json!("moveOutToEnd"))
)),
cmd!("layer.moveEarlier", "Move Layers 1 Frame Earlier", [], Some("Alt+PageUp"), "{layers?}", has_layers, |s, p| move_frames(s, p, -1)),
cmd!("layer.moveLater", "Move Layers 1 Frame Later", [], Some("Alt+PageDown"), "{layers?}", has_layers, |s, p| move_frames(s, p, 1)),
cmd!("layer.moveEarlier10", "Move Layers 10 Frames Earlier", [], Some("Alt+Shift+PageUp"), "{layers?}", has_layers, |s, p| move_frames(s, p, -10)),
cmd!("layer.moveLater10", "Move Layers 10 Frames Later", [], Some("Alt+Shift+PageDown"), "{layers?}", has_layers, |s, p| move_frames(s, p, 10)),
cmd!("layer.slip", "Slip Edit", [], None, "{layers?, delta? (seconds) | frames?, merge?}", has_layers, slip),
cmd!("layer.slipBack", "Slip Edit 1 Frame Earlier", [], None, "{layers?}", has_layers, |s, p| {
let mut p = p.clone();
p["frames"] = json!(-1);
slip(s, &p)
}),
cmd!("layer.slipForward", "Slip Edit 1 Frame Later", [], None, "{layers?}", has_layers, |s, p| {
let mut p = p.clone();
p["frames"] = json!(1);
slip(s, &p)
}),
cmd!("layer.arrange", "Arrange", ["Layer", "Arrange"], None, "{layers?, to: front|forward|backward|back, index?, above?: layer}", has_layers, arrange),
cmd!(
"layer.precompose",
"Pre-compose...",
["Layer"],
Some("Cmd+Shift+C"),
"{layers?, name?, mode?: move|leave, adjustDuration?, open?}",
has_layers,
precompose
),
cmd!(
"layer.addMask",
"New Mask",
["Layer", "Mask"],
Some("Cmd+Shift+N"),
"{layer?, shape?: rect|ellipse|rounded|polygon|star, rect? [x,y,w,h] (layer space; a polygon or star fits its width), mode?}",
has_layers,
add_mask
),
cmd!("layer.setMask", "Mask Mode", [], None, "{layer?, mask: index|uid|name, mode?, inverted?}", has_layers, mask_props),
cmd!(
"layer.addShapeItem",
"Add (Shape)",
[],
None,
"{layer?, kind: group|rect|ellipse|star|polygon|path|fill|stroke|gfill|gstroke|trim|repeater|round|offset|pucker|twist|zigzag|wiggle|merge|frontColor|bevelColor|sideColor|backColor, group?: uid|path} → {uid, path} (frontColor…backColor: the group's Front / Bevel / Side / Back item, which colours that surface of every extruded paint in the group in Advanced 3D)",
has_layers,
add_shape_item
),
cmd!(
"layer.setText",
"Edit Text",
[],
None,
"{layer?, range?: [start, end] (characters; default all), text?, font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?: px|\"auto\", baselineShift? px, hScale? %, vScale? %, tsume? %, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?: normal|superscript|subscript, superscript?, subscript?, kerning?: metrics|optical|number, ligatures?, discretionaryLigatures? (OpenType), contextualAlternates?, stylisticAlternates?, stylisticSets?: [1–20], ss01?, ss02?, ss03?, ss04?, ss05?, ss06?, ss07?, ss08?, ss09?, ss10?, ss11?, ss12?, ss13?, ss14?, ss15?, ss16?, ss17?, ss18?, ss19?, ss20?, swash?, titling?, ordinals?, fractions?, allSmallCaps?, figureStyle?: default|lining|oldStyle, figureWidth?: default|proportional|tabular, figures?, variations?: {tag: value} (variable font axes; null resets), justify?: left|center|right|justifyLeft|justifyCenter|justifyRight|justifyAll, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?: ltr|rtl, composer?: everyLine|singleLine, hangingPunctuation?, strokeOverFill?, box?: [x,y,w,h]|null (layer space), vertical?}",
has_layers,
set_text
),
cmd!(
"layer.transform",
"Transform",
["Layer", "Transform"],
None,
"{layers?, op: reset|center|fit|fitWidth|fitHeight|flipH|flipV}",
has_layers,
transform_op
),
]
}
#[allow(dead_code)]
fn unused(_: &Comp) {}
+205
View File
@@ -0,0 +1,205 @@
use super::create::{apply_text_params, text_range_p};
use super::*;
pub(super) fn add_mask(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid) = layer_p(s, p, "layer.addMask")?;
let layer = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?.clone();
let (w, h) = effectcraft_render::source_size(&s.project, &layer);
let (w, h) = if w == 0 { (400.0, 300.0) } else { (w as f64, h as f64) };
let shape = str_p(p, "shape").unwrap_or("rect");
let r = p.get("rect").and_then(Value::as_array).map(|a| [0, 1, 2, 3].map(|i| a.get(i).and_then(Value::as_f64).unwrap_or(0.0)));
let (cx, cy, rw, rh) = match r {
Some([x, y, rw, rh]) => (x + rw / 2.0, y + rh / 2.0, rw, rh),
None => (if w > 0.0 { w / 2.0 } else { 0.0 }, h / 2.0, w * 0.6, h * 0.6),
};
// The shape tools' masks: rectangle, ellipse, rounded rectangle, polygon or star.
let path = crate::cmd::shape_tool::mask_path(shape, [cx, cy], [rw, rh])
.ok_or_else(|| bad("layer.addMask", format!("unknown shape `{shape}`; one of rect, ellipse, rounded, polygon, star")))?;
let mode = str_p(p, "mode").and_then(MaskMode::from_name).unwrap_or(MaskMode::Add);
let cycle = s.prefs.appearance.cycle_mask_colors;
let uid = s.edit("New Mask", None, |proj, _| {
let mut next = proj.next_id;
let l = layer_mut(proj, cid, lid)?;
let masks = l.props.sub_mut("masks").ok_or_else(|| bad("layer.addMask", "this layer can't have masks"))?;
let n = masks.children.len();
let g = build::mask(&mut Ids(&mut next), &format!("Mask {}", n + 1), path, mode, crate::prefs::mask_color(cycle, n));
let uid = g.uid;
masks.children.push(g.into());
proj.next_id = next;
Ok(uid)
})?;
Ok(json!({"mask": uid}))
}
pub(super) fn mask_props(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid) = layer_p(s, p, "layer.setMask")?;
let target = p.get("mask").cloned().unwrap_or(json!(1));
let mode = str_p(p, "mode").and_then(MaskMode::from_name);
let inverted = b_p(p, "inverted");
s.edit("Mask Settings", None, |proj, _| {
let l = layer_mut(proj, cid, lid)?;
let masks = l.props.sub_mut("masks").ok_or_else(|| bad("layer.setMask", "the layer has no masks (add one with layer.addMask)"))?;
let g = match &target {
Value::Number(n) => {
let n = n.as_u64().unwrap_or(1);
if let Some(i) = masks.children.iter().position(|c| c.uid() == n) {
masks.children[i].as_group_mut()
} else {
masks.children.get_mut(n.saturating_sub(1) as usize).and_then(|c| c.as_group_mut())
}
}
Value::String(name) => masks.children.iter_mut().find(|c| c.name() == name).and_then(|c| c.as_group_mut()),
_ => None,
}
.ok_or_else(|| bad("layer.setMask", "no such mask"))?;
if let GroupKind::Mask { mode: m, inverted: inv, .. } = &mut g.kind {
if let Some(mm) = mode {
*m = mm;
}
if let Some(i) = inverted {
*inv = i;
}
}
Ok(())
})?;
Ok(Value::Null)
}
pub(super) fn add_shape_item(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid) = layer_p(s, p, "layer.addShapeItem")?;
let kind = str_p(p, "kind").ok_or_else(|| bad("layer.addShapeItem", "missing `kind`"))?.to_string();
// `group`: a uid or a property path (`contents/group`).
let group_uid = match p.get("group") {
Some(Value::String(path)) => Some(
s.project
.comp(cid)
.and_then(|c| c.layer(lid))
.and_then(|l| l.props.group(path))
.map(|g| g.uid)
.ok_or_else(|| bad("layer.addShapeItem", format!("no group `{path}`")))?,
),
v => v.and_then(Value::as_u64),
};
let uid = s.edit("Add Shape Item", None, |proj, _| {
let mut next = proj.next_id;
let mut ids = Ids(&mut next);
// Front / Bevel / Side / Back ▸ property: into the group's surface item (one per group).
if let Some((surface, prop)) = build::shape_surface_kind(&kind) {
let target = shape_item_target(proj, cid, lid, group_uid)?;
let item = match target.children.iter().position(|c| matches!(c, Node::Group(g) if g.match_id == surface)) {
Some(i) => i,
None => {
let g = build::shape_surface(&mut ids, surface).ok_or_else(|| bad("layer.addShapeItem", format!("unknown kind `{kind}`")))?;
target.children.push(g.into());
target.children.len() - 1
}
};
let Some(Node::Group(g)) = target.children.get_mut(item) else { return Err(bad("layer.addShapeItem", "no such group")) };
if g.get(prop).is_none() {
let p = build::shape_surface_prop(&mut ids, prop).ok_or_else(|| bad("layer.addShapeItem", format!("unknown kind `{kind}`")))?;
g.children.push(p.into());
}
let uid = g.uid;
proj.next_id = next;
return Ok(uid);
}
let g = match kind.as_str() {
"group" => build::shape_group(&mut ids, "Group 1", vec![]),
"rect" | "rectangle" => build::shape_rect(&mut ids, [200.0, 200.0], [0.0, 0.0], 0.0),
"ellipse" => build::shape_ellipse(&mut ids, [200.0, 200.0], [0.0, 0.0]),
"star" => build::shape_star(&mut ids, true, 5.0, [0.0, 0.0], 100.0, 50.0),
"polygon" => build::shape_star(&mut ids, false, 5.0, [0.0, 0.0], 100.0, 0.0),
"fill" => build::shape_fill(&mut ids, [1.0, 0.0, 0.0, 1.0]),
"stroke" => build::shape_stroke(&mut ids, [1.0, 1.0, 1.0, 1.0], 2.0),
"path" => build::shape_path(&mut ids, Default::default()),
"gfill" | "gradientFill" => build::shape_gradient_fill(&mut ids, false, [-100.0, 0.0], [100.0, 0.0], Default::default()),
"gstroke" | "gradientStroke" => build::shape_gradient_stroke(&mut ids, false, [-100.0, 0.0], [100.0, 0.0], Default::default(), 2.0),
"trim" | "trimPaths" => build::shape_trim(&mut ids, 0.0, 100.0, 0.0),
"repeater" => build::shape_repeater(&mut ids, 3.0, [100.0, 0.0]),
k => build::shape_simple_op(&mut ids, k).ok_or_else(|| bad("layer.addShapeItem", format!("unknown kind `{k}`")))?,
};
let uid = g.uid;
proj.next_id = next;
shape_item_target(proj, cid, lid, group_uid)?.children.push(g.into());
Ok(uid)
})?;
// The new item's property path, ready for prop.set / add_keyframe (`contents/trim#2/end`).
let path = s.project.comp(cid).and_then(|c| c.layer(lid)).and_then(|l| l.props.match_path_of(uid));
Ok(json!({"uid": uid, "path": path}))
}
/// The contents a new shape item goes into: the shape group `group_uid` (its Contents), else the
/// layer's Contents.
fn shape_item_target(proj: &mut Project, cid: ItemId, lid: LayerId, group_uid: Option<u64>) -> Result<&mut PropGroup> {
let l = layer_mut(proj, cid, lid)?;
let contents = l.props.sub_mut("contents").ok_or_else(|| bad("layer.addShapeItem", "not a shape layer"))?;
Ok(match group_uid {
Some(u) => contents
.find_group_mut(u)
.and_then(|g| if g.match_id == "contents" { Some(g) } else { g.sub_mut("contents") })
.ok_or_else(|| bad("layer.addShapeItem", "no such group"))?,
None => contents,
})
}
/// `layer.setText`: sets a text layer's Source Text and/or character attributes at the CTI.
///
/// A `font` name is also added to the recent fonts and the preferences saved. With an empty
/// `range` and no `text`, while that layer is being edited and its document is non-empty,
/// recognised character attributes are also staged in the pending insertion style without
/// changing existing characters. Paragraph/document attributes still apply through the edit;
/// the session is bumped whenever this branch runs. Document changes use [`Session::edit`],
/// merged by `merge_p`. With `text` given, a layer whose name is empty or starts with `Text`
/// takes the new first line (up to 40 characters) when that line is non-empty. Replies `null`.
pub(super) fn set_text(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid) = layer_p(s, p, "layer.setText")?;
// The Character panel's recent fonts (Settings ▸ Type ▸ Number of Recent Fonts to Display).
if let Some(f) = str_p(p, "font") {
s.prefs.push_recent_font(f);
s.save_prefs();
}
let t = s.time();
let range = text_range_p(p);
// Character attributes with only a caret (no selected text) set the insertion style.
if let Some(r) = range.clone().filter(|r| r.is_empty() && str_p(p, "text").is_none())
&& s.state.text_edit.as_ref().is_some_and(|e| e.layer == lid)
&& let Some(doc) = crate::cmd::text_edit::layer_doc(s, lid)
&& doc.char_len() > 0
&& let Some(obj) = p.as_object()
&& let Some(e) = s.state.text_edit.as_mut()
{
let mut st = e.pending.clone().unwrap_or_else(|| doc.insertion_style(r.start));
let mut any = false;
for (k, v) in obj {
any |= effectcraft_keyframe::text_doc::apply_char_attr(&mut st, k, v).map_err(|m| bad("layer.setText", m))?;
}
if any {
e.pending = Some(st);
}
s.bump();
}
// A changed Source Text is handled by `Session::edit`: its boundary clamps the selection and
// starts a new IME epoch only when the CTI document actually changed, so an identical text
// write stays a no-op and keeps the editor's owner.
s.edit("Edit Text", merge_p(p), |proj, _| {
let l = layer_mut(proj, cid, lid)?;
let lt = l.layer_time(t);
let pr = l.props.prop_mut("text/sourceText").ok_or_else(|| bad("layer.setText", "not a text layer"))?;
let mut doc = match pr.value_at(lt) {
KV::Text(d) => *d,
KV::Str(t) => TextDoc::plain(&t),
_ => TextDoc::default(),
};
apply_text_params("layer.setText", p, &mut doc, range.clone())?;
pr.set_value_at(lt, KV::Text(Box::new(doc.clone())));
if let Some(first) = doc.text.lines().next()
&& str_p(p, "text").is_some()
&& !first.is_empty()
&& (l.name.starts_with("Text") || l.name.is_empty())
{
l.name = first.chars().take(40).collect();
}
Ok(())
})?;
Ok(Value::Null)
}
+312
View File
@@ -0,0 +1,312 @@
use super::*;
pub(crate) fn color_p(p: &Value, k: &str) -> Option<[f32; 3]> {
match p.get(k)? {
Value::String(s) => effectcraft_color::Rgba::from_hex(s).map(|c| [c.r, c.g, c.b]),
Value::Array(a) => {
let g = |i: usize| a.get(i).and_then(Value::as_f64).unwrap_or(0.0) as f32;
Some([g(0), g(1), g(2)])
}
_ => None,
}
}
/// Insert a new layer above the selection (or at the top) and select it.
pub(crate) fn insert_layer(proj: &mut Project, st: &mut crate::EditorState, cid: ItemId, layer: Layer) -> Result<LayerId> {
insert_layer_at(proj, st, cid, layer, None)
}
/// `index`: an `index` parameter, the layer's 1-based place in the stack (1 = top; past the
/// bottom = the bottom), as 0-based.
pub(crate) fn index_p(p: &Value, cmd: &str) -> Result<Option<usize>> {
match p.get("index") {
None | Some(Value::Null) => Ok(None),
Some(v) => {
v.as_u64().filter(|i| *i >= 1).map(|i| Some(usize::try_from(i - 1).unwrap_or(usize::MAX))).ok_or_else(|| bad(cmd, "index: 1-based stack position"))
}
}
}
/// A `position` parameter: `[x, y]` in comp pixels (finite).
pub(crate) fn position_p(p: &Value, cmd: &str) -> Result<Option<[f64; 2]>> {
match p.get("position") {
None | Some(Value::Null) => Ok(None),
Some(v) => v
.as_array()
.and_then(|a| Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?]))
.filter(|c| c.iter().all(|x| x.is_finite()))
.map(Some)
.ok_or_else(|| bad(cmd, "position: [x, y] in comp pixels")),
}
}
/// Put a new layer's Position at `pos` (comp pixels).
pub(crate) fn place(l: &mut Layer, pos: Option<[f64; 2]>) {
if let Some([x, y]) = pos
&& let Some(pr) = l.props.prop_mut("transform/position")
{
let z = match pr.value {
KV::Vec3(v) => v[2],
_ => 0.0,
};
pr.value = KV::Vec3([x, y, z]);
}
}
/// [`insert_layer`] at stack position `at` (0 = top) instead of above the selected layer.
pub(crate) fn insert_layer_at(proj: &mut Project, st: &mut crate::EditorState, cid: ItemId, mut layer: Layer, at: Option<usize>) -> Result<LayerId> {
// Text and shape layers in Advanced 3D comps get Geometry Options (extrusion, bevels).
if proj.comp(cid).is_some_and(|c| c.renderer == effectcraft_project::Renderer::Advanced3D) {
crate::cmd::model3d::add_geometry_options(&mut proj.next_id, &mut layer);
}
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let at = match at {
Some(i) => i.min(comp.layers.len()),
None => st.selected_layers.first().and_then(|id| comp.layers.iter().position(|l| l.id == *id)).unwrap_or(0),
};
let id = layer.id;
comp.layers.insert(at, layer);
st.selected_layers = vec![id];
st.selected_props.clear();
st.selected_keys.clear();
Ok(id)
}
fn solids_folder(proj: &mut Project) -> ItemId {
proj.folder_named("Solids").unwrap_or_else(|| proj.add_item("Solids", Label::Yellow, None, ItemKind::Folder))
}
fn new_solid_like(s: &mut Session, p: &Value, adjustment: bool) -> Result<Value> {
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let color = color_p(p, "color").unwrap_or(if adjustment { [1.0, 1.0, 1.0] } else { [0.85, 0.2, 0.2] });
// 1 to 30,000 px a side (#788): the layer is sized from the same numbers as its solid.
let w = p.get("width").and_then(Value::as_u64).map(limits::size).unwrap_or(comp.width);
let h = p.get("height").and_then(Value::as_u64).map(limits::size).unwrap_or(comp.height);
let pixel_aspect = pixel_aspect_p(p)?.unwrap_or(comp.pixel_aspect);
let default_name = if adjustment { "Adjustment Layer 1".to_string() } else { "Solid 1".to_string() };
let name = str_p(p, "name").map(str::to_string).unwrap_or(default_name);
let id = s.edit(if adjustment { "New Adjustment Layer" } else { "New Solid" }, None, |proj, st| {
let folder = solids_folder(proj);
let sid = proj.add_item(&name, Label::Red, Some(folder), ItemKind::Solid(Solid { color, width: w, height: h, pixel_aspect }));
let mut l = build::layer(proj, &comp, &name, LayerSource::Solid { item: sid }, (w, h), None);
if adjustment {
l.switches.adjustment = true;
l.label = Label::Purple;
}
insert_layer(proj, st, cid, l)
})?;
Ok(json!({"layer": id.0}))
}
/// `pixelAspect` (a positive number), when given.
pub(super) fn pixel_aspect_p(p: &Value) -> Result<Option<f64>> {
match p.get("pixelAspect").and_then(Value::as_f64) {
Some(v) if !(v.is_finite() && v > 0.0) => Err(bad("layer.settings", "`pixelAspect` must be a positive number")),
v => Ok(v.map(limits::pixel_aspect)),
}
}
pub(super) fn new_solid(s: &mut Session, p: &Value) -> Result<Value> {
new_solid_like(s, p, false)
}
pub(super) fn new_adjustment(s: &mut Session, p: &Value) -> Result<Value> {
new_solid_like(s, p, true)
}
/// Parameters of `layer.setText` / `layer.newText` that aren't text attributes.
const NOT_ATTRS: &[&str] = &["layer", "layers", "comp", "merge", "text", "range", "position", "edit"];
/// Apply `layer.setText` keys to `d`: `text` replaces characters `r` (or everything), then each
/// character / paragraph / document attribute applies to `r` (or everything).
pub(super) fn apply_text_params(cmd: &str, p: &Value, d: &mut TextDoc, r: Option<std::ops::Range<usize>>) -> Result<()> {
let mut r = r;
if let Some(t) = str_p(p, "text") {
match r.clone() {
Some(rr) => {
d.replace_range(rr.clone(), t, None);
r = Some(rr.start..rr.start + t.chars().count());
}
None => d.set_text(t),
}
}
let Some(obj) = p.as_object() else { return Ok(()) };
for (k, v) in obj {
if NOT_ATTRS.contains(&k.as_str()) {
continue;
}
if !d.set_attr(k, v, r.clone()).map_err(|e| bad(cmd, e))? {
return Err(bad(cmd, format!("unknown text attribute {k}")));
}
}
Ok(())
}
pub(super) fn text_range_p(p: &Value) -> Option<std::ops::Range<usize>> {
let a = p.get("range")?.as_array()?;
let g = |i: usize| a.get(i).and_then(Value::as_u64).map(|x| x as usize);
let (x, y) = (g(0)?, g(1).or(g(0))?);
Some(x.min(y)..x.max(y))
}
/// `layer.newText`: adds a Text layer to the comp at the CTI and returns `{layer}`.
///
/// The document starts as centre-justified "Text", with the optional text parameters applied.
/// With `box` `[x, y, w, h]`, `(x, y)` is the paragraph box's top-left in comp space. Its width
/// and height are `w.abs().max(1.0)` and `h.abs().max(1.0)`; the layer position is the box centre.
/// It is left justified unless `justify` is given. The layer is named by `name`, else the first 40
/// characters of the first text line, else "Text". `edit: true` also opens a new editing
/// session with anchor `0` and the caret after the text, selecting all initial text so the next
/// insertion replaces it. The session has `created: true`, so leaving it empty removes the layer.
pub(super) fn new_text(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let mut doc = TextDoc { text: "Text".into(), justify: Justify::Center, ..Default::default() };
// A paragraph box given in comp space: the layer sits at its centre.
let bx = p.get("box").and_then(Value::as_array).map(|a| [0, 1, 2, 3].map(|i| a.get(i).and_then(Value::as_f64).unwrap_or(0.0)));
let mut q = p.clone();
if let Some(o) = q.as_object_mut() {
o.remove("box");
o.remove("name");
}
let layer_name = str_p(p, "name").map(str::to_string);
apply_text_params("layer.newText", &q, &mut doc, None)?;
let mut pos = position_p(p, "layer.newText")?;
if let Some([x, y, w, h]) = bx {
let (w, h) = (w.abs().max(1.0), h.abs().max(1.0));
doc.box_size = Some([w, h]);
doc.box_pos = [-w / 2.0, -h / 2.0];
pos = Some([x + w / 2.0, y + h / 2.0]);
if p.get("justify").is_none() {
doc.set_attr("justify", &json!("left"), None).map_err(|e| bad("layer.newText", e))?;
}
}
let edit = b_p(p, "edit").unwrap_or(false);
let id = s.edit("New Text Layer", None, |proj, st| {
let name: String = layer_name.clone().unwrap_or_else(|| doc.text.lines().next().unwrap_or("").chars().take(40).collect());
let mut l = build::layer(proj, &comp, if name.is_empty() { "Text" } else { &name }, LayerSource::Text, (comp.width, comp.height), None);
if let Some(pr) = l.props.prop_mut("text/sourceText") {
pr.value = KV::Text(Box::new(doc.clone()));
}
if let Some(pos) = pos
&& let Some(pr) = l.props.prop_mut("transform/position")
{
pr.value = KV::Vec3([pos[0], pos[1], 0.0]);
}
let n = doc.char_len();
let id = insert_layer(proj, st, cid, l)?;
if edit {
st.text_edit = Some(crate::cmd::text_edit::TextEdit {
layer: id,
anchor: 0,
caret: n,
created: true,
session: crate::cmd::text_edit::next_edit_session(),
..Default::default()
});
}
Ok(id)
})?;
Ok(json!({"layer": id.0}))
}
pub(super) fn new_shape(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let kind = str_p(p, "kind").unwrap_or("none").to_string();
let size = p
.get("size")
.and_then(Value::as_array)
.map(|a| [a.first().and_then(Value::as_f64).unwrap_or(200.0), a.get(1).and_then(Value::as_f64).unwrap_or(200.0)])
.unwrap_or([300.0, 300.0]);
// A fill, and a stroke only with a width (not the Tools bar's paint).
let paint = crate::cmd::shape_tool::paint_p(p, &Default::default(), "layer.newShape")?;
let pos = p
.get("position")
.and_then(|v| v.as_array())
.map(|a| [a.first().and_then(Value::as_f64).unwrap_or(0.0), a.get(1).and_then(Value::as_f64).unwrap_or(0.0)]);
let name = str_p(p, "name").unwrap_or("Shape Layer 1").to_string();
let id = s.edit("New Shape Layer", None, |proj, st| {
let lid = crate::cmd::shape_tool::new_shape_layer(proj, st, &comp, cid, Some(&name), pos)?;
let mut next = proj.next_id;
let g = crate::cmd::shape_tool::shape_group(&mut Ids(&mut next), &kind, size, &paint);
proj.next_id = next;
if let Some(g) = g {
crate::cmd::shape_tool::add_to_contents(proj, cid, lid, g, [0.0, 0.0], "layer.newShape")?;
}
Ok(lid)
})?;
Ok(json!({"layer": id.0}))
}
fn new_simple(s: &mut Session, p: &Value, src: LayerSource, name: &str, label: &str) -> Result<Value> {
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let name = str_p(p, "name").unwrap_or(name).to_string();
let id = s.edit(label, None, |proj, st| {
let l = build::layer(proj, &comp, &name, src, (comp.width, comp.height), None);
insert_layer(proj, st, cid, l)
})?;
Ok(json!({"layer": id.0}))
}
pub(super) fn new_null(s: &mut Session, p: &Value) -> Result<Value> {
new_simple(s, p, LayerSource::Null, "Null 1", "New Null Object")
}
pub(super) fn add_item(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
let item = match p.get("item") {
Some(Value::Number(n)) => ItemId(n.as_u64().unwrap_or(0)),
Some(Value::String(name)) => s.project.find_by_name(name).map(|i| i.id).ok_or_else(|| bad("layer.addItem", format!("no item `{name}`")))?,
_ => *s.state.project_selection.first().ok_or_else(|| bad("layer.addItem", "missing `item`"))?,
};
if s.project.comp_contains(item, cid) {
return Err(bad("layer.addItem", "a composition can't contain itself"));
}
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let it = s.project.item(item).ok_or_else(|| bad("layer.addItem", "no such item"))?.clone();
let (src, size, dur) = match &it.kind {
ItemKind::Comp(c) => (LayerSource::Comp { item }, (c.width, c.height), Some(c.duration)),
ItemKind::Footage(f) if f.kind == effectcraft_project::FootageKind::Still => {
// `duration`, else Settings ▸ Import ▸ Still Footage: length of the composition or a
// duration.
let d = f_p(p, "duration")
.filter(|d| d.is_finite() && *d > 0.0)
.or((s.prefs.import.still_footage == "seconds").then_some(s.prefs.import.still_seconds))
.map(Tick::from_seconds_f64);
(LayerSource::Footage { item }, (f.width, f.height), d)
}
ItemKind::Footage(f) if f.kind == effectcraft_project::FootageKind::Data => {
return Err(bad("layer.addItem", "data files can't be layers; read them in expressions with footage(\"name\").sourceData"));
}
ItemKind::Footage(f) if f.kind == effectcraft_project::FootageKind::Model => {
return crate::cmd::model3d::new_model(
s,
&serde_json::json!({"comp": cid.0, "item": item.0, "time": f_p(p, "time"), "index": p.get("index"), "position": p.get("position")}),
);
}
ItemKind::Footage(f) => (LayerSource::Footage { item }, (f.width, f.height), Some(f.looped_duration())),
ItemKind::Solid(so) => (LayerSource::Solid { item }, (so.width, so.height), None),
ItemKind::Folder => return Err(bad("layer.addItem", "folders can't be layers")),
};
let index = index_p(p, "layer.addItem")?;
let position = position_p(p, "layer.addItem")?;
let fr = comp.frame_rate;
// Settings ▸ General ▸ Create Layers at Composition Start Time (off: at the current time).
let default_start = if s.prefs.general.create_layers_at_comp_start || s.active_comp_id() != Some(cid) { Tick::ZERO } else { s.time() };
let start = fr.snap_nearest(f_p(p, "time").map(Tick::from_seconds_f64).unwrap_or(default_start));
let id = s.edit("Add Footage to Comp", None, |proj, st| {
let mut l = build::layer(proj, &comp, &it.name, src, size, dur);
l.start_time = start;
l.in_point = start;
// Footage and precomps keep their full length past the comp's end (After Effects); items
// without a duration (solids, comp-length stills) end with the comp.
let end = dur.map_or((start + comp.duration).min(comp.duration), |d| start + d);
l.out_point = fr.snap_nearest(end).max(start + fr.frame_duration());
place(&mut l, position);
insert_layer_at(proj, st, cid, l, index)
})?;
Ok(json!({"layer": id.0}))
}
+452
View File
@@ -0,0 +1,452 @@
use super::*;
/// Re-express a layer in its new parent space without moving its anchor.
pub(crate) struct ParentFix {
pub layer: LayerId,
/// Maps Position from the old parent's space into the new one's.
m: effectcraft_geom::Mat3,
/// Added to Rotation.
rotation: f64,
/// Multiply Scale.
scale: [f64; 2],
/// 3D AV layers retain their world-space anchor, including Z.
position_3d: Option<effectcraft_geom::Mat4>,
/// A uniform relative parent transform can preserve static Orientation and Scale.
pose_3d: Option<([f64; 3], f64)>,
}
impl ParentFix {
/// Re-express the layer's Position, Rotation and Scale (every key) in the new parent's space.
pub fn apply(&self, l: &mut Layer) {
let Some(tr) = l.props.sub_mut("transform") else { return };
if let Some(m) = self.position_3d {
// Spatial Position speed measures distance along its path. Its parent-space
// unit changes even when the child's Orientation cannot be re-expressed.
let position_speed = uniform_parent_pose(m, [0.0; 3]).map(|(_, factor)| factor.abs());
let map_position = |v: &KV| {
let q = m.apply(effectcraft_geom::Vec3::from(v.as_vec3()));
KV::Vec3([q.x, q.y, q.z])
};
// Read all separated axes before writing any of them.
let names = ["positionX", "positionY", "positionZ"];
if tr.get("positionX").is_some() {
let axes = names.map(|name| tr.get(name).cloned());
let current = axes.each_ref().map(|pr| pr.as_ref().map(|pr| pr.value.as_f64()).unwrap_or(0.0));
let q = m.apply(effectcraft_geom::Vec3::from(current));
for (i, name) in names.into_iter().enumerate() {
if let Some(pr) = tr.get_mut(name) {
pr.value = KV::Scalar([q.x, q.y, q.z].get(i).copied().unwrap_or(0.0));
for key in &mut pr.keys {
let p = axes.each_ref().map(|axis| axis.as_ref().map(|axis| axis.value_at(key.time).as_f64()).unwrap_or(0.0));
let q = m.apply(effectcraft_geom::Vec3::from(p));
key.value = KV::Scalar([q.x, q.y, q.z].get(i).copied().unwrap_or(0.0));
}
}
}
}
if let Some(pr) = tr.get_mut("position") {
pr.value = map_position(&pr.value);
for key in &mut pr.keys {
key.value = map_position(&key.value);
// Spatial tangents are vectors, so the parent's translation is excluded.
for tangent in [&mut key.spatial_in, &mut key.spatial_out] {
let v = m.apply_vec(effectcraft_geom::Vec3::from(*tangent));
*tangent = [v.x, v.y, v.z];
}
}
if let Some(factor) = position_speed
.filter(|_| pr.spatial)
.filter(|factor| pr.keys.iter().flat_map(|key| key.in_ease.iter().chain(&key.out_ease)).all(|ease| (ease.speed * *factor).is_finite()))
{
for key in &mut pr.keys {
for ease in key.in_ease.iter_mut().chain(&mut key.out_ease) {
ease.speed *= factor;
}
}
}
}
// Cameras and lights aim with Point of Interest: move it by the same parent-space
// change, so a two-node rig keeps its world frame (#1010, #1020).
if let Some(pr) = tr.get_mut("poi") {
pr.value = map_position(&pr.value);
for key in &mut pr.keys {
key.value = map_position(&key.value);
for tangent in [&mut key.spatial_in, &mut key.spatial_out] {
let v = m.apply_vec(effectcraft_geom::Vec3::from(*tangent));
*tangent = [v.x, v.y, v.z];
}
}
if let Some(factor) = position_speed
.filter(|_| pr.spatial)
.filter(|factor| pr.keys.iter().flat_map(|key| key.in_ease.iter().chain(&key.out_ease)).all(|ease| (ease.speed * *factor).is_finite()))
{
for key in &mut pr.keys {
for ease in key.in_ease.iter_mut().chain(&mut key.out_ease) {
ease.speed *= factor;
}
}
}
}
if let Some((orientation, factor)) = self.pose_3d {
if let Some(pr) = tr.get_mut("orientation") {
pr.value = KV::Vec3(orientation);
}
if let Some(pr) = tr.get_mut("scale") {
let scaled = |value: &KV| KV::Vec3(value.as_vec3().map(|v| v * factor));
pr.value = scaled(&pr.value);
for key in &mut pr.keys {
key.value = scaled(&key.value);
// Scale's temporal speeds use the same units as its values.
for ease in key.in_ease.iter_mut().chain(&mut key.out_ease) {
ease.speed *= factor;
}
}
}
}
return;
}
let map = |pr: &mut effectcraft_project::Property, f: &dyn Fn(&KV) -> KV| {
pr.value = f(&pr.value);
for key in &mut pr.keys {
key.value = f(&key.value);
}
};
let m = self.m;
if tr.get("positionX").is_some() {
// Separated dimensions: X and Y move together, key by key at the same times.
// Everything is read before anything is written.
let [x, y] = [tr.get("positionX"), tr.get("positionY")].map(|p| p.map(|p| p.value.as_f64()).unwrap_or(0.0));
let q = m.apply(effectcraft_geom::vec2(x, y));
let keys_x: Vec<(Tick, f64)> = tr.get("positionX").map(|p| p.keys.iter().map(|k| (k.time, k.value.as_f64())).collect()).unwrap_or_default();
let keys_y: Vec<(Tick, f64)> = tr.get("positionY").map(|p| p.keys.iter().map(|k| (k.time, k.value.as_f64())).collect()).unwrap_or_default();
let at = |keys: &[(Tick, f64)], name: &str, t: Tick, tr: &PropGroup| {
keys.iter().find(|k| k.0 == t).map(|k| k.1).unwrap_or_else(|| tr.get(name).map(|p| p.value_at(t).as_f64()).unwrap_or(0.0))
};
let (xs, ys): (Vec<f64>, Vec<f64>) = (
keys_x.iter().map(|&(t, x)| m.apply(effectcraft_geom::vec2(x, at(&keys_y, "positionY", t, tr))).x).collect(),
keys_y.iter().map(|&(t, y)| m.apply(effectcraft_geom::vec2(at(&keys_x, "positionX", t, tr), y)).y).collect(),
);
for (name, value, vals) in [("positionX", q.x, xs), ("positionY", q.y, ys)] {
if let Some(pr) = tr.get_mut(name) {
pr.value = KV::Scalar(value);
for (k, v) in pr.keys.iter_mut().zip(vals) {
k.value = KV::Scalar(v);
}
}
}
} else if let Some(pr) = tr.get_mut("position") {
map(pr, &|v| {
let c = v.as_vec3();
let q = m.apply(effectcraft_geom::vec2(c[0], c[1]));
KV::Vec3([q.x, q.y, c[2]])
});
}
let (dr, k) = (self.rotation, self.scale);
if let Some(pr) = tr.get_mut("rotation") {
map(pr, &|v| KV::Scalar(v.as_f64() + dr));
}
if let Some(pr) = tr.get_mut("scale") {
map(pr, &|v| {
let c = v.as_vec3();
KV::Vec3([c[0] * k[0], c[1] * k[1], c[2]])
});
}
}
}
/// A uniform factor commutes with authored axis Rotation and Scale; prepend its orthogonal
/// rotation to static Orientation using the existing Rz * Ry * Rx order.
fn uniform_parent_pose(m: effectcraft_geom::Mat4, orientation: [f64; 3]) -> Option<([f64; 3], f64)> {
use effectcraft_geom::{Mat4, Vec3, vec3};
if !orientation.iter().all(|v| v.is_finite()) {
return None;
}
let a = m.0;
let columns = [vec3(a[0][0], a[1][0], a[2][0]), vec3(a[0][1], a[1][1], a[2][1]), vec3(a[0][2], a[1][2], a[2][2])];
let magnitude = columns[0].x.hypot(columns[0].y).hypot(columns[0].z);
if !magnitude.is_finite() || magnitude <= 0.0 {
return None;
}
let c = columns.map(|c| c / magnitude);
if c.iter().any(|c| (c.dot(*c) - 1.0).abs() > 1e-9) || c[0].dot(c[1]).abs() > 1e-9 || c[0].dot(c[2]).abs() > 1e-9 || c[1].dot(c[2]).abs() > 1e-9 {
return None;
}
let cross = vec3(c[1].y * c[2].z - c[1].z * c[2].y, c[1].z * c[2].x - c[1].x * c[2].z, c[1].x * c[2].y - c[1].y * c[2].x);
let factor = if c[0].dot(cross) < 0.0 { -magnitude } else { magnitude };
let mut rotation = Mat4::IDENTITY;
for row in 0..3 {
for col in 0..3 {
rotation.0[row][col] = a[row][col] / factor;
}
}
if (0..3).all(|row| (0..3).all(|col| (rotation.0[row][col] - if row == col { 1.0 } else { 0.0 }).abs() < 1e-12)) {
return Some((orientation, factor));
}
let r = (rotation * Mat4::orientation(Vec3::from(orientation))).0;
// atan2 rather than asin: near ±90 degrees asin loses precision, while cos(y) stays exact.
let cy = r[0][0].hypot(r[1][0]);
let y = (-r[2][0]).atan2(cy);
let (x, z) = if cy > 1e-9 { (r[2][1].atan2(r[2][2]), r[1][0].atan2(r[0][0])) } else { ((-r[1][2]).atan2(r[1][1]), 0.0) };
Some(([x.to_degrees(), y.to_degrees(), z.to_degrees()], factor))
}
/// A parent change retains 2D transforms and the world-space anchor of 3D layers (Position,
/// and Point of Interest for two-node cameras and lights). Position is
/// re-expressed in the new parent's space (`None`: the composition), evaluated at the current
/// time.
pub(crate) fn parent_fixes(s: &Session, cid: ItemId, ids: &[LayerId], parent: Option<LayerId>) -> Vec<ParentFix> {
let Some(comp) = s.project.comp(cid) else { return vec![] };
let ectx =
effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: s.time(), expr: s.expr.as_deref(), footage: Some(s.footage.as_ref()) };
let space = |id: Option<LayerId>| -> effectcraft_geom::Mat3 {
id.and_then(|i| comp.layer(i)).filter(|l| !l.is_3d()).map(|l| ectx.layer_to_comp(l).0).unwrap_or(effectcraft_geom::Mat3::IDENTITY)
};
let decompose = |m: &effectcraft_geom::Mat3| {
let a = m.0;
let sx = (a[0][0] * a[0][0] + a[1][0] * a[1][0]).sqrt();
let det = a[0][0] * a[1][1] - a[0][1] * a[1][0];
(a[1][0].atan2(a[0][0]).to_degrees(), sx, if sx > 1e-12 { det / sx } else { 0.0 })
};
let new_m = space(parent);
let Some(inv) = new_m.inverse() else { return vec![] };
let (rn, sxn, syn) = decompose(&new_m);
let mut fixes: Vec<_> = comp
.layers
.iter()
.filter(|l| ids.contains(&l.id) && !l.is_3d() && l.parent != parent)
.map(|l| {
let old_m = space(l.parent);
let (ro, sxo, syo) = decompose(&old_m);
let scale = [if sxn.abs() > 1e-12 { sxo / sxn } else { 1.0 }, if syn.abs() > 1e-12 { syo / syn } else { 1.0 }];
ParentFix { layer: l.id, m: inv * old_m, rotation: ro - rn, scale, position_3d: None, pose_3d: None }
})
.collect();
let space_3d = |id: Option<LayerId>| {
id.and_then(|id| comp.layer(id)).map_or(effectcraft_geom::Mat4::IDENTITY, |layer| {
// Parent transforms do not inherit the parent's source pixel-aspect stretch.
let world = ectx.world_matrix(layer);
let par = ectx.par_ratio(layer);
let a = layer.transform().map(|tr| ectx.v3(layer, tr, "anchor", [0.0; 3])).unwrap_or([0.0; 3]);
let anchor = effectcraft_geom::vec3(a[0], a[1], if layer.is_3d() { a[2] } else { 0.0 });
world
* effectcraft_geom::Mat4::translate(anchor)
* effectcraft_geom::Mat4::scale(effectcraft_geom::vec3(1.0 / par, 1.0, 1.0))
* effectcraft_geom::Mat4::translate(-anchor)
})
};
if let Some(inv) = space_3d(parent).inverse().filter(|m| m.0.iter().flatten().all(|v| v.is_finite())) {
// Cameras and lights compensate like 3D layers (Position, and Point of Interest for
// two-node rigs), so parenting keeps the world frame (#1010, #1020). They have no
// Scale, so `pose_3d` below stays `None` and Orientation is kept as authored.
for layer in comp.layers.iter().filter(|l| ids.contains(&l.id) && l.is_3d() && l.parent != parent) {
let m = inv * space_3d(layer.parent);
if m.0.iter().flatten().all(|v| v.is_finite()) {
let pose_3d = layer.transform().and_then(|tr| {
let orientation = tr.get("orientation")?;
let scale = tr.get("scale")?;
// Animated Euler Orientation and auto-orient cannot be re-expressed by
// prepending a constant Euler value. Expression-driven values stay authored.
if !orientation.keys.is_empty()
|| orientation.expr.is_some()
|| scale.expr.is_some()
|| layer.auto_orient != effectcraft_project::AutoOrient::Off
{
return None;
}
let pose = uniform_parent_pose(m, orientation.value.as_vec3())?;
let factor = pose.1;
let values_finite = std::iter::once(&scale.value)
.chain(scale.keys.iter().map(|key| &key.value))
.all(|value| value.as_vec3().iter().all(|v| (v * factor).is_finite()));
let speeds_finite = scale.keys.iter().flat_map(|key| key.in_ease.iter().chain(&key.out_ease)).all(|ease| (ease.speed * factor).is_finite());
(values_finite && speeds_finite).then_some(pose)
});
fixes.push(ParentFix { layer: layer.id, m: effectcraft_geom::Mat3::IDENTITY, rotation: 0.0, scale: [1.0; 2], position_3d: Some(m), pose_3d });
}
}
}
fixes
}
/// Validate the proposed ancestor chain before any authored edit, including when compensation
/// is disabled. Bound both unique ancestors and linear lookup/selection work on loaded graphs.
fn check_parent_chain(comp: &Comp, ids: &[LayerId], parent: Option<LayerId>, max_visits: usize, max_work: usize) -> Result<()> {
let mut seen = std::collections::HashSet::new();
let mut work = 0usize;
let mut cur = parent;
while let Some(id) = cur {
if seen.len() >= max_visits {
return Err(bad("layer.setParent", "parent chain exceeds the safety visit limit"));
}
if !seen.insert(id) {
return Err(bad("layer.setParent", "parent chain contains an existing cycle"));
}
for selected in ids {
if work >= max_work {
return Err(bad("layer.setParent", "parent chain exceeds the safety work limit"));
}
work += 1;
if *selected == id {
return Err(bad("layer.setParent", "parenting would create a cycle"));
}
}
let mut ancestor = None;
for layer in &comp.layers {
if work >= max_work {
return Err(bad("layer.setParent", "parent chain exceeds the safety work limit"));
}
work += 1;
if layer.id == id {
ancestor = Some(layer);
break;
}
}
let layer = ancestor.ok_or_else(|| bad("layer.setParent", "parent chain refers to a missing layer"))?;
cur = layer.parent;
}
Ok(())
}
pub(super) fn set_parent(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, ids) = layers_p(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let parent = match p.get("parent") {
None | Some(Value::Null) => None,
Some(v) => Some(resolve_layer(comp, v).ok_or_else(|| bad("layer.setParent", "no such parent layer"))?),
};
// A loaded cycle need not include a selected child. Validate before compensation or edit;
// do not impose the renderer's 64-ancestor truncation as a new ordinary hierarchy limit.
check_parent_chain(comp, &ids, parent, 4096, 1_048_576)?;
// Like AE's pick-whip, parenting keeps the layer where it is: its transform is re-expressed
// in the new parent's space (Position, Rotation, Scale), unless `compensate: false`.
let compensate = b_p(p, "compensate").unwrap_or(true);
let fixes = if compensate { parent_fixes(s, cid, &ids, parent) } else { vec![] };
s.edit("Parent", None, |proj, _| {
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) {
l.parent = parent;
if let Some(f) = fixes.iter().find(|f| f.layer == l.id) {
f.apply(l);
}
}
Ok(())
})?;
Ok(Value::Null)
}
#[cfg(test)]
mod parent_chain_tests {
use super::*;
use std::sync::Arc;
fn fixture() -> (Session, u64, u64, u64) {
let mut s = Session::default();
s.execute("comp.new", json!({"name":"Parent safety","width":16,"height":16,"duration":1})).unwrap();
let a = s.execute("layer.newSolid", json!({"name":"A"})).unwrap()["layer"].as_u64().unwrap();
let b = s.execute("layer.newSolid", json!({"name":"B"})).unwrap()["layer"].as_u64().unwrap();
let c = s.execute("layer.newSolid", json!({"name":"C"})).unwrap()["layer"].as_u64().unwrap();
for id in [a, b, c] {
s.execute("prop.set", json!({"layer":id,"path":"transform/anchor","value":[0,0,0]})).unwrap();
}
s.execute("prop.set", json!({"layer":a,"path":"transform/position","value":[100,200,0]})).unwrap();
s.execute("prop.set", json!({"layer":c,"path":"transform/position","value":[10,20,0]})).unwrap();
s.history.undo.clear();
(s, a, b, c)
}
fn malformed_parent(s: &mut Session, child: u64, parent: u64) {
let cid = s.active_comp_id().unwrap();
Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(LayerId(child)).unwrap().parent = Some(LayerId(parent));
}
fn assert_atomic_error(s: &mut Session, child: u64, parent: u64, compensate: bool, message: &str) {
let before = s.project.clone();
let revision = s.revision;
let undo = s.history.undo.len();
let err = s.execute("layer.setParent", json!({"layers":[child],"parent":parent,"compensate":compensate})).unwrap_err();
assert!(err.to_string().contains(message), "{err}");
assert!(Arc::ptr_eq(&before, &s.project));
assert_eq!(s.revision, revision);
assert_eq!(s.history.undo.len(), undo);
}
#[test]
fn loaded_cycle_excluding_selected_child_is_rejected_atomically() {
for compensate in [true, false] {
let (mut s, a, b, c) = fixture();
// Simulate a malformed loaded project. Never execute this on the unguarded baseline.
malformed_parent(&mut s, a, b);
malformed_parent(&mut s, b, a);
assert_atomic_error(&mut s, c, a, compensate, "existing cycle");
}
}
#[test]
fn dangling_loaded_parent_is_rejected_atomically() {
for compensate in [true, false] {
let (mut s, a, _, c) = fixture();
malformed_parent(&mut s, a, u64::MAX);
assert_atomic_error(&mut s, c, a, compensate, "missing layer");
}
}
#[test]
fn selected_descendant_and_direct_self_are_rejected() {
let (mut s, a, _, c) = fixture();
assert_atomic_error(&mut s, c, c, false, "create a cycle");
s.execute("layer.setParent", json!({"layers":[a],"parent":c,"compensate":false})).unwrap();
assert_atomic_error(&mut s, c, a, true, "create a cycle");
}
#[test]
fn ordinary_parent_and_unparent_keep_explicit_false_and_undo_semantics() {
for compensate in [true, false] {
let (mut s, a, _, c) = fixture();
let old = s.project.clone();
let world = |s: &Session| {
let cid = s.active_comp_id().unwrap();
let comp = s.active_comp().unwrap();
let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, comp, s.time());
ctx.world_matrix(comp.layer(LayerId(c)).unwrap()).apply(effectcraft_geom::Vec3::ZERO)
};
let before = world(&s);
s.execute("layer.setParent", json!({"layers":[c],"parent":a,"compensate":compensate})).unwrap();
let expected = if compensate { before } else { before + effectcraft_geom::vec3(100.0, 200.0, 0.0) };
assert!((world(&s) - expected).length() < 1e-9);
assert_eq!(s.active_comp().unwrap().layer(LayerId(c)).unwrap().parent, Some(LayerId(a)));
assert_eq!(s.history.undo.len(), 1);
let parented = s.project.clone();
assert!(s.undo());
assert!(Arc::ptr_eq(&s.project, &old));
assert!(s.redo());
assert!(Arc::ptr_eq(&s.project, &parented));
s.execute("layer.setParent", json!({"layers":[c],"parent":null,"compensate":compensate})).unwrap();
assert_eq!(s.active_comp().unwrap().layer(LayerId(c)).unwrap().parent, None);
assert!(s.undo());
assert!(Arc::ptr_eq(&s.project, &parented));
}
}
#[test]
fn ancestor_and_lookup_work_budgets_fail_closed() {
let (mut s, a, b, c) = fixture();
malformed_parent(&mut s, a, b);
let comp = s.active_comp().unwrap();
// New solids are inserted above the selection: C,B,A. Walking A->B scans
// three then two layers, and compares the one selected child twice: seven work units.
assert_eq!(comp.layers.iter().map(|l| l.id).collect::<Vec<_>>(), vec![LayerId(c), LayerId(b), LayerId(a)]);
assert!(check_parent_chain(comp, &[LayerId(c)], Some(LayerId(a)), 2, 7).is_ok());
assert!(check_parent_chain(comp, &[LayerId(c)], Some(LayerId(a)), 2, 6).unwrap_err().to_string().contains("work limit"));
assert!(check_parent_chain(comp, &[LayerId(c)], Some(LayerId(a)), 2, 256).is_ok());
assert!(check_parent_chain(comp, &[LayerId(c)], Some(LayerId(a)), 1, 256).unwrap_err().to_string().contains("visit limit"));
assert!(check_parent_chain(comp, &[LayerId(c)], Some(LayerId(a)), 2, 1).unwrap_err().to_string().contains("work limit"));
assert!(check_parent_chain(comp, &[LayerId(c)], None, 0, 0).is_ok(), "unparenting does not traverse ancestors");
}
#[test]
fn well_formed_chain_beyond_renderer_depth_is_not_unnecessarily_rejected() {
let (s, _, _, c) = fixture();
let mut comp = s.active_comp().unwrap().clone();
let template = comp.layers.first().unwrap().clone();
for n in 0..80 {
let mut layer = template.clone();
layer.id = LayerId(10_000 + n);
layer.parent = (n < 79).then_some(LayerId(10_001 + n));
comp.layers.push(layer);
}
assert!(check_parent_chain(&comp, &[LayerId(c)], Some(LayerId(10_000)), 4096, 1_048_576).is_ok());
}
}
+98
View File
@@ -0,0 +1,98 @@
use super::*;
/// A layer whose source is an item (footage, precomposition or solid), as in After Effects'
/// Replace Source. Text, shape, null, camera, light and 3D model layers have no AVItem source to
/// replace.
fn replaceable_layer(l: &Layer) -> bool {
matches!(l.source, LayerSource::Footage { .. } | LayerSource::Comp { .. } | LayerSource::Solid { .. })
}
/// The source a Project item gives a layer, or why it can't replace one.
fn replacement_source(item: ItemId, it: &effectcraft_project::Item) -> Result<LayerSource> {
const CMD: &str = "layer.replaceSource";
match &it.kind {
ItemKind::Comp(_) => Ok(LayerSource::Comp { item }),
ItemKind::Footage(f) if f.kind == FootageKind::Data => {
Err(bad(CMD, "data files can't be layer sources; read them in expressions with footage(\"name\").sourceData"))
}
ItemKind::Footage(f) if f.kind == FootageKind::Model => Err(bad(CMD, "3D model items can't replace a 2D layer source; add them as 3D layers")),
ItemKind::Footage(_) => Ok(LayerSource::Footage { item }),
ItemKind::Solid(_) => Ok(LayerSource::Solid { item }),
ItemKind::Folder => Err(bad(CMD, "folders can't be layer sources")),
}
}
/// Whether a source brings audio properties (precompositions always can; footage when it has an
/// audio stream).
fn source_has_audio(project: &Project, source: &LayerSource) -> bool {
match source {
LayerSource::Comp { .. } => true,
LayerSource::Footage { item } => project.item(*item).is_some_and(|i| matches!(&i.kind, ItemKind::Footage(f) if f.has_audio)),
_ => false,
}
}
pub(super) fn has_replace_source(s: &Session) -> std::result::Result<(), String> {
has_comp(s)?;
let c = s.active_comp().ok_or("no composition")?;
if !s.state.selected_layers.iter().filter_map(|l| c.layer(*l)).any(replaceable_layer) {
return Err("select a footage, composition or solid layer".into());
}
if s.state.project_selection.is_empty() {
return Err("select a footage item, a composition or a solid in the Project panel".into());
}
Ok(())
}
/// Replace the source of the selected (or `layers`) AV layers with a Project item, as After
/// Effects' Alt-drag and Ctrl+Alt+/ do. The layer keeps every property: name, timing, transforms,
/// keyframes, expressions, effects, masks, switches, parenting and track matte. Only its source
/// changes; precompositions bring their Essential Properties group through `sync_project`, and a
/// source with audio adds an Audio group when the layer has none.
pub(super) fn replace_source(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "layer.replaceSource";
let (cid, ids) = layers_p(s, p)?;
let ids = unlocked(s, cid, ids, CMD)?;
if ids.is_empty() {
return Err(bad(CMD, "select a layer first"));
}
let item = match p.get("item") {
Some(Value::Number(n)) => ItemId(n.as_u64().ok_or_else(|| bad(CMD, "item: a project item id"))?),
Some(Value::String(name)) => s.project.find_by_name(name).map(|i| i.id).ok_or_else(|| bad(CMD, format!("no item `{name}`")))?,
_ => *s.state.project_selection.first().ok_or_else(|| bad(CMD, "select a footage item, a composition or a solid in the Project panel"))?,
};
let it = s.project.item(item).ok_or_else(|| bad(CMD, format!("no item {}", item.0)))?;
let source = replacement_source(item, it)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
for lid in &ids {
let l = comp.layer(*lid).ok_or_else(|| bad(CMD, "no such layer"))?;
if !replaceable_layer(l) {
return Err(bad(CMD, format!("“{}” has no source item to replace", l.name)));
}
}
if let LayerSource::Comp { item: new } = &source
&& s.project.comp_contains(*new, cid)
{
return Err(bad(CMD, "a composition can't contain itself"));
}
let has_audio = source_has_audio(&s.project, &source);
let source_for_edit = source.clone();
s.edit("Replace Layer Source", None, |proj, _| {
for lid in &ids {
let needs_audio = has_audio && proj.comp(cid).and_then(|c| c.layer(*lid)).is_some_and(|l| l.props.sub("audio").is_none());
let audio = if needs_audio {
let mut next = Ids(&mut proj.next_id);
Some(build::audio(&mut next))
} else {
None
};
let l = layer_mut(proj, cid, *lid)?;
l.source = source_for_edit.clone();
if let Some(audio) = audio {
l.props.children.push(audio.into());
}
}
Ok(())
})?;
Ok(json!({"layers": ids.iter().map(|l| l.0).collect::<Vec<_>>(), "item": item.0}))
}
+73
View File
@@ -0,0 +1,73 @@
use super::*;
pub(super) fn select(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let ids: Vec<LayerId> = match p.get("layers").or(p.get("layer")) {
Some(Value::Array(a)) => a.iter().filter_map(|v| resolve_layer(comp, v)).collect(),
Some(v) => resolve_layer(comp, v).into_iter().collect(),
None => vec![],
};
let add = b_p(p, "add").unwrap_or(false);
let toggle = b_p(p, "toggle").unwrap_or(false);
if toggle {
for id in ids {
if let Some(i) = s.state.selected_layers.iter().position(|l| *l == id) {
s.state.selected_layers.remove(i);
} else {
s.state.selected_layers.push(id);
}
}
} else if add {
for id in ids {
if !s.state.selected_layers.contains(&id) {
s.state.selected_layers.push(id);
}
}
} else {
s.state.selected_layers = ids;
s.state.selected_props.clear();
s.state.selected_keys.clear();
let keep = s.state.selected_layers.clone();
s.state.selected_vertices.retain(|v| keep.contains(&v.layer));
}
Ok(json!(s.state.selected_layers.iter().map(|l| l.0).collect::<Vec<_>>()))
}
pub(super) fn select_step(s: &mut Session, p: &Value, dir: i64) -> Result<Value> {
let comp = s.active_comp().ok_or(EngineError::NoComp)?;
if comp.layers.is_empty() {
return Ok(Value::Null);
}
let cur = s.state.selected_layers.first().and_then(|id| comp.layers.iter().position(|l| l.id == *id));
let n = comp.layers.len() as i64;
// The next layer that can be selected (locked and hidden shy layers are stepped over); at
// the end of the stack the selection stays.
let start = match cur {
Some(i) => i as i64 + dir,
None if dir > 0 => 0,
None => n - 1,
};
let next = std::iter::successors(Some(start), |i| Some(i + dir))
.take_while(|i| (0..n).contains(i))
.find(|i| crate::cmd::selectable(comp, &comp.layers[*i as usize]));
let Some(i) = next.or(cur.map(|i| i as i64)) else { return Ok(Value::Null) };
let id = comp.layers[i as usize].id;
let add = b_p(p, "add").unwrap_or(false);
if add {
if !s.state.selected_layers.contains(&id) {
s.state.selected_layers.insert(0, id);
}
} else {
s.state.selected_layers = vec![id];
}
Ok(json!(id.0))
}
pub(super) fn select_next(s: &mut Session, p: &Value) -> Result<Value> {
select_step(s, p, 1)
}
pub(super) fn select_prev(s: &mut Session, p: &Value) -> Result<Value> {
select_step(s, p, -1)
}
+210
View File
@@ -0,0 +1,210 @@
use super::*;
pub(super) fn set_switch(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, ids) = layers_p(s, p)?;
let name = str_p(p, "switch").ok_or_else(|| bad("layer.setSwitch", "missing `switch`"))?.to_string();
let v = b_p(p, "value");
let label = format!("Layer Switch ({name})");
if b_p(p, "others") == Some(true) {
return set_switch_others(s, cid, &ids, &name, v, &label);
}
// `merge`: a switch dragged over several layers in the Timeline is one undo step.
let r = s.edit(&label, merge_p(p), |proj, _| {
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let mut last = false;
// Toggle relative to the first layer so a multi-selection ends up consistent.
let first = comp.layers.iter().find(|l| ids.contains(&l.id)).map(|l| switch_value(l, &name)).unwrap_or(Some(false));
let Some(first) = first else { return Err(bad("layer.setSwitch", format!("unknown switch `{name}`"))) };
let target = v.unwrap_or(!first);
for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) {
set_switch_value(l, &name, target);
last = target;
}
Ok(last)
})?;
Ok(json!(r))
}
/// `layer.setSwitch {others: true}` (Alt-click on an A/V feature in the Timeline): the named
/// layers stand alone, shown / heard / soloed / unlocked while every other layer the switch
/// applies to is the opposite; when they already do, the other layers come back. `value` sets
/// the other layers directly.
fn set_switch_others(s: &mut Session, cid: ItemId, ids: &[LayerId], name: &str, value: Option<bool>, label: &str) -> Result<Value> {
let r = s.edit(label, None, |proj, _| {
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
// The state that sets a layer apart: unlocked for Lock, on for the rest.
let active = !matches!(name, "lock" | "locked");
// Track mattes keep their video off by design.
let mattes: Vec<LayerId> =
if matches!(name, "video" | "eye") { comp.layers.iter().filter_map(|l| l.track_matte.map(|m| m.layer)).collect() } else { Vec::new() };
let applies = |l: &Layer| match name {
"audio" => l.props.sub("audio").is_some(),
"lock" | "locked" => true,
// Every layer has a Video switch (it hides a null's outline in the viewer and turns a
// camera or light off), as the Timeline's eye cell shows; track mattes keep theirs off
// by design.
"video" | "eye" => !mattes.contains(&l.id),
_ => l.source.is_av() && !mattes.contains(&l.id),
};
let mut alone = true;
for l in comp.layers.iter().filter(|l| applies(l)) {
let Some(on) = switch_value(l, name) else { return Err(bad("layer.setSwitch", format!("unknown switch `{name}`"))) };
alone &= on == (ids.contains(&l.id) == active);
}
let others = value.unwrap_or(alone == active);
for l in comp.layers.iter_mut().filter(|l| applies(l)) {
let v = if ids.contains(&l.id) { active } else { others };
set_switch_value(l, name, v);
}
Ok(others)
})?;
Ok(json!(r))
}
fn switch_value(l: &Layer, name: &str) -> Option<bool> {
let sw = &l.switches;
Some(match name {
"video" | "eye" => sw.video,
"audio" => sw.audio,
"solo" => sw.solo,
"lock" | "locked" => sw.locked,
"shy" => sw.shy,
"collapse" => sw.collapse,
"quality" => sw.quality == Quality::Best,
"fx" | "effects" => sw.effects,
"frameBlend" => sw.frame_blend != FrameBlend::Off,
"motionBlur" => sw.motion_blur,
"adjustment" => sw.adjustment,
"threeD" | "3d" | "3D" => sw.three_d,
"guide" => sw.guide,
"preserveTransparency" => l.preserve_transparency,
_ => return None,
})
}
fn set_switch_value(l: &mut Layer, name: &str, v: bool) {
let sw = &mut l.switches;
match name {
"video" | "eye" => sw.video = v,
"audio" => sw.audio = v,
"solo" => sw.solo = v,
"lock" | "locked" => sw.locked = v,
"shy" => sw.shy = v,
"collapse" => sw.collapse = v,
"quality" => sw.quality = if v { Quality::Best } else { Quality::Draft },
"fx" | "effects" => sw.effects = v,
"frameBlend" => sw.frame_blend = if v { FrameBlend::FrameMix } else { FrameBlend::Off },
"motionBlur" => sw.motion_blur = v,
"adjustment" => sw.adjustment = v,
"threeD" | "3d" | "3D" => {
if sw.three_d && !v {
drop_3d_values(l);
}
l.switches.three_d = v;
}
"guide" => sw.guide = v,
"preserveTransparency" => l.preserve_transparency = v,
_ => {}
}
}
/// Turning the 3D switch off discards Z values, Orientation and X/Y Rotation (as in AE).
fn drop_3d_values(l: &mut Layer) {
let Some(tr) = l.transform_mut() else { return };
for (m, keep) in [("position", 0.0), ("anchor", 0.0), ("scale", 100.0)] {
if let Some(pr) = tr.get_mut(m) {
let flat = |v: &mut KV| {
if let KV::Vec3(a) = v {
a[2] = keep;
}
};
flat(&mut pr.value);
for k in &mut pr.keys {
flat(&mut k.value);
}
}
}
for (m, zero) in [("orientation", KV::Vec3([0.0; 3])), ("rotationX", KV::Scalar(0.0)), ("rotationY", KV::Scalar(0.0))] {
if let Some(pr) = tr.get_mut(m) {
pr.keys.clear();
pr.expr = None;
pr.value = zero;
}
}
}
pub(super) fn rename(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid) = layer_p(s, p, "layer.rename")?;
let name = str_p(p, "name").ok_or_else(|| bad("layer.rename", "missing `name`"))?.to_string();
s.edit("Rename Layer", None, |proj, _| {
layer_mut(proj, cid, lid)?.name = name.clone();
Ok(())
})?;
Ok(Value::Null)
}
/// The Timeline's Comment column.
pub(super) fn set_comment(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, ids) = layers_p(s, p)?;
let text = str_p(p, "comment").ok_or_else(|| bad("layer.setComment", "missing `comment`"))?.to_string();
s.edit("Layer Comment", None, |proj, _| {
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) {
l.comment = text.clone();
}
Ok(())
})?;
Ok(Value::Null)
}
pub(super) fn blend(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, ids) = layers_p(s, p)?;
let mode = str_p(p, "mode").and_then(BlendMode::from_name);
let step = p.get("step").and_then(Value::as_i64).unwrap_or(0);
if mode.is_none() && step == 0 {
return Err(bad("layer.setBlendMode", "need `mode` (e.g. Multiply) or `step` ±1"));
}
s.edit("Blending Mode", None, |proj, _| {
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) {
l.blend_mode = match mode {
Some(m) => m,
None if step > 0 => l.blend_mode.next(),
None => l.blend_mode.previous(),
};
}
Ok(())
})?;
Ok(Value::Null)
}
pub(super) fn track_matte(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid) = layer_p(s, p, "layer.setTrackMatte")?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let matte = match p.get("matte") {
None | Some(Value::Null) => None,
Some(v) => Some(resolve_layer(comp, v).ok_or_else(|| bad("layer.setTrackMatte", "no such matte layer"))?),
};
let kind = str_p(p, "kind")
.map(|k| match k.to_ascii_lowercase().replace([' ', '_', '-'], "").as_str() {
"alphainverted" | "alphainv" => MatteKind::AlphaInverted,
"luma" => MatteKind::Luma,
"lumainverted" | "lumainv" => MatteKind::LumaInverted,
_ => MatteKind::Alpha,
})
.unwrap_or(MatteKind::Alpha);
s.edit("Track Matte", None, |proj, _| {
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
if let Some(m) = matte {
if m == lid {
return Err(bad("layer.setTrackMatte", "a layer can't be its own matte"));
}
if let Some(ml) = comp.layer_mut(m) {
ml.switches.video = false;
}
}
comp.layer_mut(lid).ok_or(EngineError::NoComp)?.track_matte = matte.map(|layer| TrackMatte { layer, kind });
Ok(())
})?;
Ok(Value::Null)
}
+283
View File
@@ -0,0 +1,283 @@
use super::*;
pub(super) fn timing(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, ids) = layers_p(s, p)?;
let t = s.time();
let op = str_p(p, "op").unwrap_or("set").to_string();
// Layer times stay on frame boundaries of the comp (as in AE).
let fr = s.project.comp(cid).ok_or(EngineError::NoComp)?.frame_rate;
let snap = |k: &str| f_p(p, k).map(|v| fr.snap_nearest(Tick::from_seconds_f64(v)));
let (delta, start, inp, outp) = (snap("delta"), snap("start"), snap("in"), snap("out"));
// Relative trims: each layer moves its own in/out point by the same amount, so dragging an
// edge of several selected layers trims them all together (as in AE).
let (in_delta, out_delta) = (snap("inDelta"), snap("outDelta"));
s.edit("Layer Timing", merge_p(p), |proj, _| {
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let fd = comp.frame_duration();
let comp_dur = comp.duration;
for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) {
let shift = |l: &mut Layer, d: Tick| {
l.start_time += d;
l.in_point += d;
l.out_point += d;
};
match op.as_str() {
"moveInToTime" => {
let d = t - l.in_point;
shift(l, d);
}
"moveOutToTime" => {
let d = t - l.out_point;
shift(l, d);
}
"moveInToStart" => {
let d = Tick::ZERO - l.in_point;
shift(l, d);
}
"moveOutToEnd" => {
let d = comp_dur - l.out_point;
shift(l, d);
}
"trimInToTime" => l.in_point = t.min(l.out_point - fd),
"trimOutToTime" => l.out_point = t.max(l.in_point + fd),
_ => {
if let Some(d) = delta {
shift(l, d);
}
if let Some(st) = start {
let d = st - l.start_time;
shift(l, d);
}
if let Some(i) = inp {
l.in_point = i.min(l.out_point - fd);
}
if let Some(o) = outp {
l.out_point = o.max(l.in_point + fd);
}
// Stops at the comp start/end (a point already past it doesn't jump onto
// it), keeping at least one frame of layer.
if let Some(d) = in_delta {
l.in_point = (l.in_point + d).max(Tick::ZERO.min(l.in_point)).min(l.out_point - fd);
}
if let Some(d) = out_delta {
l.out_point = (l.out_point + d).min(comp_dur.max(l.out_point)).max(l.in_point + fd);
}
}
}
}
Ok(())
})?;
Ok(Value::Null)
}
/// Move the layers `frames` frames in time (Alt+Page Up / Page Down, with Shift ten frames).
pub(super) fn move_frames(s: &mut Session, p: &Value, frames: i64) -> Result<Value> {
let (cid, _) = layers_p(s, p)?;
let delta = s.project.comp(cid).ok_or(EngineError::NoComp)?.frame_rate.tick_of(frames).seconds();
timing(s, &with(p, "delta", json!(delta)))
}
/// Slip edit: move a layer's source (footage or precomp) under its fixed in and out points, like
/// dragging the source-duration bar in After Effects. `delta` in seconds or `frames`; positive
/// moves the source later. Clamped so the source still covers the in–out span. Keyframes and
/// layer markers are in layer time, so they travel with the source.
pub(super) fn slip(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, ids) = layers_p(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let fr = comp.frame_rate;
let delta = match (p.get("frames").and_then(Value::as_i64), f_p(p, "delta")) {
(Some(f), _) => fr.tick_of(f),
(None, Some(d)) => fr.snap_nearest(Tick::from_seconds_f64(d)),
_ => return Err(bad("layer.slip", "delta (seconds) or frames required")),
};
// Allowed shift per layer: the source span [lo, hi] in comp time must keep covering in..out.
let mut shifts: Vec<(LayerId, Tick)> = Vec::new();
for l in comp.layers.iter().filter(|l| ids.contains(&l.id)) {
if l.props.get("timeRemap").is_some() {
continue;
}
let dur = match &l.source {
LayerSource::Footage { item } => match s.project.item(*item).map(|i| &i.kind) {
Some(ItemKind::Footage(f)) if f.kind != effectcraft_project::FootageKind::Still => Some(f.looped_duration()),
_ => None,
},
LayerSource::Comp { item } => s.project.comp(*item).map(|c| c.duration),
_ => None,
};
let Some(dur) = dur else { continue };
let (a, b) = (l.comp_time(Tick::ZERO), l.comp_time(dur));
let (lo, hi) = (a.min(b), a.max(b));
let (min_d, max_d) = (l.out_point - hi, l.in_point - lo);
if min_d > max_d {
continue;
}
shifts.push((l.id, delta.max(min_d).min(max_d)));
}
if shifts.is_empty() {
return Err(bad("layer.slip", "select a footage or precomp layer (without time remapping)"));
}
s.edit("Slip Edit", merge_p(p), |proj, _| {
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
for (id, d) in &shifts {
if let Some(l) = comp.layer_mut(*id) {
l.start_time += *d;
}
}
Ok(())
})?;
Ok(json!({"slipped": shifts.iter().map(|(id, d)| json!({"layer": id.0, "delta": d.seconds()})).collect::<Vec<_>>()}))
}
pub(super) fn arrange(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, ids) = layers_p(s, p)?;
let ids = unlocked(s, cid, ids, "layer.arrange")?;
let how = str_p(p, "to").unwrap_or("front").to_string();
let index = p.get("index").and_then(Value::as_u64).map(|i| i as usize);
// `above`: put the layers right above that one (the timeline's drag to reorder).
let above = match p.get("above") {
Some(v) => {
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let a = resolve_layer(comp, v).ok_or_else(|| bad("layer.arrange", "no such layer for `above`"))?;
if ids.contains(&a) {
return Err(bad("layer.arrange", "`above` must be a layer that isn't being moved"));
}
Some(a)
}
None => None,
};
s.edit("Arrange Layers", None, |proj, _| {
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
// Bring Forward / Send Backward move each layer one step past the next layer that isn't
// moving (a run of selected layers moves as one; one already at the end stays).
if above.is_none() && index.is_none() && matches!(how.as_str(), "forward" | "backward") {
let picked = |l: &Layer| ids.contains(&l.id);
let n = comp.layers.len();
if how == "forward" {
for i in 1..n {
if picked(&comp.layers[i]) && !picked(&comp.layers[i - 1]) {
let run_end = (i..n).find(|j| !picked(&comp.layers[*j])).unwrap_or(n);
comp.layers[i - 1..run_end].rotate_left(1);
}
}
} else {
for i in (0..n.saturating_sub(1)).rev() {
if picked(&comp.layers[i]) && !picked(&comp.layers[i + 1]) {
let run_start = (0..=i).rev().find(|j| !picked(&comp.layers[*j])).map_or(0, |j| j + 1);
comp.layers[run_start..=i + 1].rotate_right(1);
}
}
}
return Ok(());
}
let mut picked: Vec<Layer> = Vec::new();
comp.layers.retain(|l| {
if ids.contains(&l.id) {
picked.push(l.clone());
false
} else {
true
}
});
let at = match (how.as_str(), index) {
_ if above.is_some() => comp.layers.iter().position(|l| Some(l.id) == above).unwrap_or(comp.layers.len()),
(_, Some(i)) => i.saturating_sub(1).min(comp.layers.len()),
("back", _) => comp.layers.len(),
_ => 0,
};
for (k, l) in picked.into_iter().enumerate() {
comp.layers.insert((at + k).min(comp.layers.len()), l);
}
Ok(())
})?;
Ok(Value::Null)
}
/// Layer ▸ Pre-compose. `mode: move` (default; "Move all attributes into the new composition")
/// nests the selected layers as they are; `mode: leave` ("Leave all attributes in", one footage,
/// solid or precomp layer) nests only the layer's source, sized to it, and keeps the layer's
/// transform, masks, effects and timing in the current comp. `adjustDuration` (move) trims the
/// new comp to the selected layers' span; `open` opens the new comp.
pub(super) fn precompose(s: &mut Session, p: &Value) -> Result<Value> {
let c = "layer.precompose";
let (cid, ids) = layers_p(s, p)?;
if ids.is_empty() {
return Err(bad(c, "no layers"));
}
let name = str_p(p, "name").unwrap_or("Pre-comp 1").to_string();
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let leave = match str_p(p, "mode").unwrap_or("move") {
"move" => false,
"leave" => true,
x => return Err(bad(c, format!("mode: move|leave, got `{x}`"))),
};
let adjust = b_p(p, "adjustDuration").unwrap_or(false);
if leave {
let [lid] = ids[..] else { return Err(bad(c, "\"Leave all attributes\" needs exactly one layer")) };
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?.clone();
let Some(src_item) = layer.source.item() else {
return Err(bad(c, "\"Leave all attributes\" is available for footage, solid and precomp layers only"));
};
let (w, h) = effectcraft_render::source_size(&s.project, &layer);
let (w, h) = if w == 0 || h == 0 { (comp.width, comp.height) } else { (w, h) };
let dur = match s.project.item(src_item).map(|i| &i.kind) {
Some(ItemKind::Comp(nc)) => nc.duration,
Some(ItemKind::Footage(f)) if f.duration > Tick(0) => f.duration,
_ => comp.duration,
};
let new = s.edit("Pre-compose", None, |proj, st| {
let mut inner = comp.nested_like(w, h, dur);
let mut il = build::layer(proj, &inner, &layer.name, layer.source.clone(), (w, h), Some(dur));
il.name = layer.name.clone();
inner.layers.push(il);
let iid = proj.add_item(&name, Label::Sandstone, None, ItemKind::Comp(inner.into()));
let l = layer_mut(proj, cid, lid)?;
l.source = LayerSource::Comp { item: iid };
l.name = name.clone();
st.selected_layers = vec![lid];
Ok((iid, lid))
})?;
if b_p(p, "open").unwrap_or(false) {
s.open_comp(new.0);
}
return Ok(json!({"comp": new.0.0, "layer": new.1.0}));
}
let span = {
let sel: Vec<&effectcraft_project::Layer> = comp.layers.iter().filter(|l| ids.contains(&l.id)).collect();
let a = sel.iter().map(|l| l.in_point).min().unwrap_or(Tick(0)).max(Tick(0));
let b = sel.iter().map(|l| l.out_point).max().unwrap_or(comp.duration).min(comp.duration);
(a, b)
};
let (offset, dur) = if adjust && span.1 > span.0 { (span.0, span.1 - span.0) } else { (Tick(0), comp.duration) };
let new = s.edit("Pre-compose", None, |proj, st| {
let mut inner = comp.nested_like(comp.width, comp.height, dur);
inner.layers = comp.layers.iter().filter(|l| ids.contains(&l.id)).cloned().collect();
for l in &mut inner.layers {
if l.parent.is_some_and(|p| !ids.contains(&p)) {
l.parent = None;
}
if l.track_matte.is_some_and(|m| !ids.contains(&m.layer)) {
l.track_matte = None;
}
l.start_time -= offset;
l.in_point -= offset;
l.out_point -= offset;
}
let iid = proj.add_item(&name, Label::Sandstone, None, ItemKind::Comp(inner.into()));
let mut l = build::layer(proj, &comp, &name, LayerSource::Comp { item: iid }, (comp.width, comp.height), Some(dur));
l.name = name.clone();
l.start_time = offset;
l.in_point = offset;
l.out_point = offset + dur;
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let at = c.layers.iter().position(|l| ids.contains(&l.id)).unwrap_or(0);
c.layers.retain(|l| !ids.contains(&l.id));
let lid = l.id;
c.layers.insert(at.min(c.layers.len()), l);
st.selected_layers = vec![lid];
Ok((iid, lid))
})?;
if b_p(p, "open").unwrap_or(false) {
s.open_comp(new.0);
}
Ok(json!({"comp": new.0.0, "layer": new.1.0}))
}
+191
View File
@@ -0,0 +1,191 @@
use super::create::{color_p, pixel_aspect_p};
use super::*;
/// Move Position by `(dx, dy)`, including every key. Separated dimensions move X and Y as well
/// as the combined property the channels were split from.
pub(crate) fn offset_position(tr: &mut PropGroup, dx: f64, dy: f64) {
if dx == 0.0 && dy == 0.0 {
return;
}
if tr.get("positionX").is_some() {
for (name, d) in [("positionX", dx), ("positionY", dy)] {
if let Some(pr) = tr.get_mut(name) {
pr.value = KV::Scalar(pr.value.as_f64() + d);
for k in &mut pr.keys {
k.value = KV::Scalar(k.value.as_f64() + d);
}
}
}
}
if let Some(pr) = tr.get_mut("position") {
let shift = |v: &KV| match v {
KV::Vec2(a) => KV::Vec2([a[0] + dx, a[1] + dy]),
other => {
let a = other.as_vec3();
KV::Vec3([a[0] + dx, a[1] + dy, a[2]])
}
};
pr.value = shift(&pr.value);
for k in &mut pr.keys {
k.value = shift(&k.value);
}
}
}
/// Set a layer's Position at layer time `lt` to `f(current)`: X/Y/Z Position when its dimensions
/// are separated.
pub(crate) fn update_position(tr: &mut PropGroup, lt: Tick, f: impl Fn([f64; 3]) -> [f64; 3]) {
const SEPARATE: [&str; 3] = ["positionX", "positionY", "positionZ"];
if tr.get("positionX").is_some() {
let v = f(SEPARATE.map(|m| tr.get(m).map(|p| p.value_at(lt).as_f64()).unwrap_or(0.0)));
for (m, x) in SEPARATE.iter().zip(v) {
if let Some(pr) = tr.get_mut(m) {
pr.set_value_at(lt, KV::Scalar(x));
}
}
} else if let Some(pr) = tr.get_mut("position") {
let v = f(pr.value_at(lt).as_vec3());
pr.set_value_at(lt, KV::Vec3(v));
}
}
/// A Transform property's default (Reset) by match id: the anchor at the centre of the source
/// (`w`×`h`), the position at the comp's (`cw`×`ch`), 100% scale and opacity, no rotation.
pub(crate) fn transform_default(m: &str, (w, h): (f64, f64), (cw, ch): (f64, f64)) -> Option<KV> {
Some(match m {
"anchor" => KV::Vec3([w / 2.0, h / 2.0, 0.0]),
"position" => KV::Vec3([cw / 2.0, ch / 2.0, 0.0]),
"positionX" => KV::Scalar(cw / 2.0),
"positionY" => KV::Scalar(ch / 2.0),
"positionZ" | "rotation" | "rotationX" | "rotationY" => KV::Scalar(0.0),
"scale" => KV::Vec3([100.0; 3]),
"orientation" => KV::Vec3([0.0; 3]),
"opacity" => KV::Scalar(100.0),
_ => return None,
})
}
pub(super) fn transform_op(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, ids) = layers_p(s, p)?;
let ids = unlocked(s, cid, ids, "layer.transform")?;
let op = str_p(p, "op").unwrap_or("reset").to_string();
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let t = s.time();
let sizes: Vec<(LayerId, (u32, u32))> =
comp.layers.iter().filter(|l| ids.contains(&l.id)).map(|l| (l.id, effectcraft_render::source_size(&s.project, l))).collect();
s.edit("Transform", None, |proj, _| {
for (lid, (w, h)) in &sizes {
let l = layer_mut(proj, cid, *lid)?;
let lt = l.layer_time(t);
let (cw, ch) = (comp.width as f64, comp.height as f64);
let (w, h) = (*w as f64, *h as f64);
let Some(tr) = l.transform_mut() else { continue };
let set = |tr: &mut PropGroup, m: &str, v: KV| {
if let Some(pr) = tr.get_mut(m) {
pr.set_value_at(lt, v);
}
};
match op.as_str() {
"reset" => {
for m in ["anchor", "scale", "rotation", "rotationX", "rotationY", "orientation", "opacity"] {
if let Some(v) = transform_default(m, (w, h), (cw, ch)) {
set(tr, m, v);
}
}
update_position(tr, lt, |_| [cw / 2.0, ch / 2.0, 0.0]);
}
// Centred in the frame; a 3D layer keeps its depth.
"center" => update_position(tr, lt, |p| [cw / 2.0, ch / 2.0, p[2]]),
"fit" | "fitWidth" | "fitHeight" if w > 0.0 && h > 0.0 => {
let (sx, sy) = match op.as_str() {
"fitWidth" => (cw / w, cw / w),
"fitHeight" => (ch / h, ch / h),
_ => (cw / w, ch / h),
};
let z = |m: &str, d: f64| tr.get(m).map(|p| p.value_at(lt).as_vec3()[2]).unwrap_or(d);
let (sz, az) = (z("scale", 100.0), z("anchor", 0.0));
set(tr, "scale", KV::Vec3([sx * 100.0, sy * 100.0, sz]));
update_position(tr, lt, |p| [cw / 2.0, ch / 2.0, p[2]]);
set(tr, "anchor", KV::Vec3([w / 2.0, h / 2.0, az]));
}
"flipH" | "flipV" => {
let cur = tr.get("scale").map(|p| p.value_at(lt).as_vec3()).unwrap_or([100.0; 3]);
let v = if op == "flipH" { [-cur[0], cur[1], cur[2]] } else { [cur[0], -cur[1], cur[2]] };
set(tr, "scale", KV::Vec3(v));
}
_ => {}
}
}
Ok(())
})?;
Ok(Value::Null)
}
/// Layer ▸ Layer Settings: cameras and lights open their own settings; solids (and adjustment
/// layers, which are solids) edit their Solid Settings. With `affectAll: false`, a solid other
/// layers also use is copied into a new solid for this layer instead of being changed for all of
/// them (After Effects' "Affect all layers that use this solid"). `name` renames the layer and
/// its solid.
pub(super) fn layer_settings(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid) = layer_p(s, p, "layer.settings")?;
// Cameras and lights open their own settings (Camera Settings / Light Settings).
match s.project.comp(cid).and_then(|c| c.layer(lid)).map(|l| l.source.clone()) {
Some(LayerSource::Camera) => return s.execute("layer.cameraSettings", with_layer(p, lid)),
Some(LayerSource::Light { .. }) => return s.execute("layer.lightSettings", with_layer(p, lid)),
_ => {}
}
let color = color_p(p, "color");
let w = p.get("width").and_then(Value::as_u64).map(limits::size);
let h = p.get("height").and_then(Value::as_u64).map(limits::size);
let pixel_aspect = pixel_aspect_p(p)?;
let name = str_p(p, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string);
let affect_all = p.get("affectAll").and_then(Value::as_bool).unwrap_or(true);
let solid = s.edit("Layer Settings", None, |proj, _| {
let src = layer_mut(proj, cid, lid)?.source.clone();
if let Some(n) = &name {
layer_mut(proj, cid, lid)?.name = n.clone();
}
let LayerSource::Solid { item } = src else { return Ok(None) };
let shared = proj.comps().flat_map(|(_, c)| &c.layers).filter(|l| l.source == src).count() > 1;
let target = if shared && !affect_all {
// This layer gets its own copy of the solid.
let it = proj.item(item).ok_or_else(|| bad("layer.settings", "the layer's solid is missing"))?.clone();
let copy = proj.add_item(name.as_deref().unwrap_or(&it.name), it.label, it.parent, it.kind);
layer_mut(proj, cid, lid)?.source = LayerSource::Solid { item: copy };
copy
} else {
item
};
let it = proj.item_mut(target).ok_or_else(|| bad("layer.settings", "the layer's solid is missing"))?;
if let Some(n) = &name {
it.name = n.clone();
}
if let ItemKind::Solid(so) = &mut it.kind {
so.color = color.unwrap_or(so.color);
so.width = w.unwrap_or(so.width);
so.height = h.unwrap_or(so.height);
so.pixel_aspect = pixel_aspect.unwrap_or(so.pixel_aspect);
}
Ok(Some(target))
})?;
Ok(match solid {
Some(item) => json!({"layer": lid.0, "solid": item.0}),
None => json!({"layer": lid.0}),
})
}
/// Layer Settings applies to solids (and adjustment layers), nulls, cameras and lights.
pub(super) fn has_layer_settings(s: &Session) -> std::result::Result<(), String> {
has_layers(s)?;
let l = s.active_comp().zip(s.state.selected_layers.first()).and_then(|(c, l)| c.layer(*l)).ok_or("select a layer first")?;
match l.source {
LayerSource::Solid { .. } | LayerSource::Null | LayerSource::Camera | LayerSource::Light { .. } => Ok(()),
_ => Err("this kind of layer has no settings".into()),
}
}
fn with_layer(p: &Value, lid: LayerId) -> Value {
let mut p = if p.is_object() { p.clone() } else { json!({}) };
p["layer"] = json!(lid.0);
p
}
@@ -708,7 +708,7 @@ const BUNDLE_EXTENSIONS: &[&str] = &[
/// Whether the system file manager would open folder `dir` as something other than a folder: a
/// bundle (`Tool.app`), or a Windows folder with a `{CLSID}` extension.
fn opens_as_bundle(dir: &std::path::Path) -> bool {
let ext = dir.extension().and_then(|e| e.to_str()).unwrap_or_default().to_ascii_lowercase();
let ext = dir.extension().and_then(std::ffi::OsStr::to_str).unwrap_or_default().to_ascii_lowercase();
ext.starts_with('{') || BUNDLE_EXTENSIONS.contains(&ext.as_str())
}
@@ -107,7 +107,7 @@ impl Default for PaintOptions {
}
fn norm(s: &str) -> String {
s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_ascii_lowercase()
s.chars().filter(char::is_ascii_alphanumeric).collect::<String>().to_ascii_lowercase()
}
/// Index of `v` in `names` (by number or case/space-insensitive name).
+199
View File
@@ -0,0 +1,199 @@
use super::*;
/// Hand a frontend-only command to the UI ([`crate::Event::Frontend`]).
pub(crate) fn frontend(s: &mut Session, id: &str, p: &Value) -> Result<Value> {
s.events.push(crate::Event::Frontend { command: id.to_string(), params: p.clone() });
Ok(serde_json::json!({"frontend": id}))
}
/// Match path of a node (`transform/position`, `effects/#2/blurriness` style with `match#n`
/// occurrences) so it can be found again on another layer.
pub fn match_path_of(g: &effectcraft_project::PropGroup, uid: effectcraft_project::Uid) -> Option<String> {
for c in &g.children {
let nth = g.children.iter().take_while(|x| !std::ptr::eq(*x, c)).filter(|x| x.match_id() == c.match_id()).count() + 1;
let seg = if nth == 1 { c.match_id().to_string() } else { format!("{}#{nth}", c.match_id()) };
if c.uid() == uid {
return Some(seg);
}
if let effectcraft_project::Node::Group(sub) = c
&& let Some(rest) = match_path_of(sub, uid)
{
return Some(format!("{seg}/{rest}"));
}
}
None
}
/// Turn `effects/<match name>[#n]/<property>` (as [`match_path_of`] writes it) into a uid path on
/// `l`. Path parsing splits on `.`, which would cut a match name like `ec.control.slider` apart.
/// Any other path, or an effect that is not on the layer, comes back unchanged.
pub(crate) fn resolve_effect_path(l: &Layer, path: &str) -> String {
let Some((seg, tail)) = path.strip_prefix("effects/").and_then(|r| r.split_once('/')) else { return path.to_string() };
let (match_id, nth) = match seg.rsplit_once('#') {
Some((m, n)) if !m.is_empty() => match n.parse::<usize>() {
Ok(n) if n >= 1 => (m, n),
_ => (seg, 1),
},
_ => (seg, 1),
};
match l.effects().and_then(|fx| fx.groups().filter(|g| g.match_id == match_id).nth(nth - 1)) {
Some(g) => format!("effects/@{}/{tail}", g.uid),
None => path.to_string(),
}
}
/// Selected properties (leaves) of the active comp as (layer, uid); selected groups expand to
/// their properties.
pub(crate) fn selected_leaf_props(s: &Session) -> Vec<(LayerId, effectcraft_project::Uid)> {
let Some(comp) = s.active_comp() else { return vec![] };
let mut out = vec![];
for (lid, uid) in &s.state.selected_props {
let Some(l) = comp.layer(*lid) else { continue };
if l.props.find(*uid).is_some() {
out.push((*lid, *uid));
} else if let Some(g) = l.props.find_group(*uid) {
g.walk("", &mut |_, p| out.push((*lid, p.uid)));
}
}
out
}
// ---------- params ----------
pub(crate) fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
EngineError::BadParams { cmd: cmd.into(), msg: msg.into() }
}
pub(crate) fn str_p<'a>(p: &'a Value, k: &str) -> Option<&'a str> {
p.get(k).and_then(Value::as_str)
}
pub(crate) fn f_p(p: &Value, k: &str) -> Option<f64> {
p.get(k).and_then(Value::as_f64)
}
pub(crate) fn b_p(p: &Value, k: &str) -> Option<bool> {
p.get(k).and_then(Value::as_bool)
}
pub(crate) fn merge_p(p: &Value) -> Option<&str> {
str_p(p, "merge")
}
/// `comp`: item id or name (an all-digit string that names no comp is taken as an id, as MCP
/// clients often send numbers as strings); default the active comp.
pub(crate) fn comp_id(s: &Session, p: &Value) -> Result<ItemId> {
let missing = |v: &Value| EngineError::NoSuchComp(v.to_string());
match p.get("comp") {
Some(v @ Value::Number(n)) => n.as_u64().map(ItemId).filter(|id| s.project.comp(*id).is_some()).ok_or_else(|| missing(v)),
Some(v @ Value::String(name)) => s
.project
.items
.values()
.find(|i| &i.name == name && i.as_comp().is_some())
.map(|i| i.id)
.or_else(|| digits_id(name).map(ItemId).filter(|id| s.project.comp(*id).is_some()))
.ok_or_else(|| missing(v)),
_ => s.active_comp_id().ok_or(EngineError::NoComp),
}
}
/// An all-ASCII-digit string as a number (`"98"` -> 98); anything else (names, `#n`, signs) is `None`.
fn digits_id(s: &str) -> Option<u64> {
if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
s.parse().ok()
}
/// Resolve a layer reference: id (number), `#n` / index (1-based), or name. An all-digit string
/// that names no layer is resolved as the number it spells.
pub(crate) fn resolve_layer(comp: &Comp, v: &Value) -> Option<LayerId> {
match v {
Value::Number(n) => {
let id = n.as_u64()?;
if comp.layer(LayerId(id)).is_some() { Some(LayerId(id)) } else { comp.layers.get((id as usize).checked_sub(1)?).map(|l| l.id) }
}
Value::String(s) => {
if let Some(i) = s.strip_prefix('#').and_then(|i| i.parse::<usize>().ok()) {
return comp.layers.get(i.checked_sub(1)?).map(|l| l.id);
}
comp.layers.iter().find(|l| &l.name == s).map(|l| l.id).or_else(|| digits_id(s).and_then(|id| resolve_layer(comp, &Value::from(id))))
}
_ => None,
}
}
/// `layer` param or the first selected layer.
pub(crate) fn layer_p(s: &Session, p: &Value, cmd: &str) -> Result<(ItemId, LayerId)> {
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
if let Some(v) = p.get("layer") {
return resolve_layer(comp, v).map(|l| (cid, l)).ok_or_else(|| bad(cmd, format!("no layer {v}")));
}
s.state
.selected_layers
.first()
.copied()
.filter(|l| comp.layer(*l).is_some())
.map(|l| (cid, l))
.ok_or_else(|| bad(cmd, "no `layer` given and no layer selected"))
}
/// `layers` param (array) or the selection.
pub(crate) fn layers_p(s: &Session, p: &Value) -> Result<(ItemId, Vec<LayerId>)> {
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
// Explicit references that match no layer are an error (agents otherwise get a silent no-op).
let resolve =
|v: &Value| resolve_layer(comp, v).ok_or_else(|| EngineError::Other(format!("no layer {v} (use a layer id, \"#n\" or a name from comp.info)")));
if let Some(Value::Array(a)) = p.get("layers") {
return Ok((cid, a.iter().map(resolve).collect::<Result<_>>()?));
}
if let Some(v) = p.get("layer").filter(|v| !v.is_null()) {
return Ok((cid, vec![resolve(v)?]));
}
Ok((cid, s.state.selected_layers.iter().copied().filter(|l| comp.layer(*l).is_some()).collect()))
}
/// Whether a layer can be picked by the user (Select All, Ctrl+Up/Down, a Timeline click): not
/// locked, and not hidden by the composition's Hide Shy Layers.
pub(crate) fn selectable(comp: &Comp, l: &Layer) -> bool {
!(l.switches.locked || (comp.hide_shy && l.switches.shy))
}
/// The layers of `ids` a command may edit: locked layers are left alone, as in After Effects.
/// An error when every one of them is locked.
pub(crate) fn unlocked(s: &Session, cid: ItemId, ids: Vec<LayerId>, cmd: &str) -> Result<Vec<LayerId>> {
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let locked = |id: &LayerId| comp.layer(*id).is_some_and(|l| l.switches.locked);
match ids.iter().find(|id| locked(id)) {
Some(first) if ids.iter().all(locked) => {
let name = comp.layer(*first).map(|l| l.name.clone()).unwrap_or_default();
Err(bad(cmd, format!("“{name}” is locked (Layer ▸ Switches ▸ Unlock All Layers)")))
}
_ => Ok(ids.into_iter().filter(|id| !locked(id)).collect()),
}
}
/// `visible: [{layer, prop}]`: the properties the Timeline shows (J / K and Select All
/// Keyframes use only those, as in After Effects). `None` when not given.
pub(crate) fn visible_p(p: &Value) -> Option<Vec<(LayerId, effectcraft_project::Uid)>> {
let a = p.get("visible")?.as_array()?;
Some(a.iter().filter_map(|v| Some((LayerId(v.get("layer")?.as_u64()?), v.get("prop")?.as_u64()?))).collect())
}
/// `time` (seconds) or `frame` param, else the CTI.
pub(crate) fn time_p(s: &Session, p: &Value, comp: Option<&Comp>) -> Tick {
if let Some(t) = f_p(p, "time") {
return Tick::from_seconds_f64(t);
}
if let (Some(f), Some(c)) = (p.get("frame").and_then(Value::as_i64), comp) {
return c.frame_rate.tick_of(f);
}
s.time()
}
pub(crate) fn layer_mut(p: &mut effectcraft_project::Project, cid: ItemId, lid: LayerId) -> Result<&mut Layer> {
p.comp_mut(cid).ok_or(EngineError::NoComp)?.layer_mut(lid).ok_or(EngineError::Project(effectcraft_project::ProjectError::NoLayer(lid)))
}
@@ -103,7 +103,7 @@ fn run(s: &mut Session, p: &Value) -> Result<Value> {
}
let supported = matches!(old.kind, FootageKind::Still | FootageKind::Sequence | FootageKind::Video | FootageKind::Audio)
&& old.layer.is_none() && old.page == 0;
let name = Path::new(&old.path).file_name().and_then(|n| n.to_str()).unwrap_or("");
let name = Path::new(&old.path).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or("");
let paths = index.get(name).cloned().unwrap_or_default();
let mut candidates = vec![];
let mut accepted = vec![];
+119
View File
@@ -0,0 +1,119 @@
use super::params::bad;
use super::*;
// ---------- parameter validation (agents) ----------
/// Top-level keys accepted by a params doc such as `{layers: [id|name|#n], add?, toggle?}` or
/// `{time? (s) | frame? | timecode?}`; `None` when the doc isn't a `{…}` key list.
pub fn accepted_params(doc: &str) -> Option<Vec<String>> {
let body = doc.trim().strip_prefix('{')?;
// Top-level pieces up to the matching close brace, split at `,` and `|`; `:` starts a value.
let (mut keys, mut cur, mut depth, mut in_value) = (Vec::new(), String::new(), 0i32, false);
let push = |cur: &mut String, keys: &mut Vec<String>| {
let k: String = cur.trim().chars().take_while(|c| c.is_ascii_alphanumeric() || *c == '_').collect();
if !k.is_empty() && !keys.contains(&k) {
keys.push(k);
}
cur.clear();
};
for c in body.chars() {
match c {
'{' | '[' | '(' => depth += 1,
']' | ')' => depth -= 1,
'}' if depth == 0 => {
push(&mut cur, &mut keys);
return Some(keys);
}
'}' => depth -= 1,
',' if depth == 0 => {
push(&mut cur, &mut keys);
in_value = false;
}
'|' if depth == 0 && !in_value => push(&mut cur, &mut keys),
':' if depth == 0 => {
push(&mut cur, &mut keys);
in_value = true;
}
_ if depth == 0 && !in_value => cur.push(c),
_ => {}
}
}
None
}
/// Top-level pieces of a params doc (split at depth-0 commas), e.g. `{layer?, path|prop, value}`
/// → `["layer?", "path|prop", "value"]`.
fn doc_pieces(doc: &str) -> Option<Vec<String>> {
let body = doc.trim().strip_prefix('{')?;
let (mut out, mut cur, mut depth) = (Vec::new(), String::new(), 0i32);
for c in body.chars() {
match c {
'{' | '[' | '(' => depth += 1,
']' | ')' => depth -= 1,
'}' if depth == 0 => {
if !cur.trim().is_empty() {
out.push(cur.trim().to_string());
}
return Some(out);
}
'}' => depth -= 1,
',' if depth == 0 => {
if !cur.trim().is_empty() {
out.push(cur.trim().to_string());
}
cur.clear();
continue;
}
_ => {}
}
cur.push(c);
}
None
}
/// The JSON Schema of a command's parameters, derived from its params doc: one property per
/// accepted key (described by its piece of the doc), no others. MCP `describe_command` and
/// `list_commands {schemas: true}` serve it.
pub fn params_schema(spec: &CommandSpec) -> Value {
let mut props = serde_json::Map::new();
let mut required = vec![];
for piece in doc_pieces(spec.params).unwrap_or_default() {
let keys = accepted_params(&format!("{{{piece}}}")).unwrap_or_default();
let alternatives = keys.len() > 1;
for k in keys {
// `key?` is optional; so is any key with alternatives (`path|prop`).
let optional = alternatives || piece.contains(&format!("{k}?"));
if !optional && !required.contains(&k) {
required.push(k.clone());
}
props.insert(k, serde_json::json!({"description": piece}));
}
}
serde_json::json!({
"type": "object",
"description": spec.params,
"properties": props,
"required": required,
"additionalProperties": accepted_params(spec.params).is_none(),
})
}
/// Keys every command understands (comp targeting, gesture merging) and accepted aliases.
const ALWAYS_OK: &[&str] = &["comp", "merge"];
const ALIASES: &[(&str, &str)] = &[("layers", "layer"), ("layer", "layers"), ("time", "frame"), ("frameRate", "fps"), ("properties", "property")];
/// Reject unknown top-level parameters (typos, guessed names) with the list of accepted keys.
pub fn check_params(spec: &CommandSpec, params: &Value) -> Result<()> {
let (Some(obj), Some(accepted)) = (params.as_object(), accepted_params(spec.params)) else { return Ok(()) };
let ok = |k: &str| ALWAYS_OK.contains(&k) || accepted.iter().any(|a| a == k || ALIASES.iter().any(|(doc, alias)| doc == a && *alias == k));
let unknown: Vec<&str> = obj.keys().map(String::as_str).filter(|k| !ok(k)).collect();
if unknown.is_empty() {
return Ok(());
}
let list = if accepted.is_empty() { "none".to_string() } else { accepted.join(", ") };
Err(bad(
spec.id,
format!("unknown parameter(s) {}; accepted: {list} (doc: {})", unknown.iter().map(|k| format!("`{k}`")).collect::<Vec<_>>().join(", "), spec.params),
))
}
@@ -234,7 +234,7 @@ fn uninstall(s: &mut Session, p: &Value) -> Result<Value> {
if removed { Ok(json!({"removed": name})) } else { Err(bad(c, format!("`{name}` is not installed"))) }
}
/// Run an installed or bundled script by name (File ▸ Scripts ▸ <name>). A ScriptUI panel run
/// Run an installed or bundled script by name (File ▸ Scripts ▸ `<name>`). A ScriptUI panel run
/// this way gets `this` = a floating palette-like panel window.
pub(crate) fn run_named(s: &mut Session, name: &str) -> Result<Value> {
let (panel, code) = script_code(s, name, None).ok_or_else(|| bad("file.runScript", format!("no installed or bundled script `{name}`")))?;
@@ -308,7 +308,7 @@ fn restore_panels(s: &mut Session, _: &Value) -> Result<Value> {
Ok(json!({"opened": opened, "failed": failed}))
}
/// Window ▸ <ScriptUI panel>: run the panel script (or bring its panel forward).
/// Window ▸ `<ScriptUI panel>`: run the panel script (or bring its panel forward).
fn open_panel(s: &mut Session, p: &Value) -> Result<Value> {
let c = "window.scriptPanel";
let name = str_p(p, "name").ok_or_else(|| bad(c, "missing `name` (a script in the ScriptUI Panels folder)"))?.to_string();
@@ -6,15 +6,64 @@
//! Positions are character indices. The selection is `anchor..caret` (either order).
use effectcraft_keyframe::{CharStyle, TextDoc, Value as KV};
use effectcraft_project::{LayerId, LayerSource};
use effectcraft_project::{ItemId, LayerId, LayerSource};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
/// Session tokens for the on-canvas editor, unique across the process (so a recreated session
/// never reuses a live IME context). Exhaustion wraps; it cannot panic.
static NEXT_EDIT_SESSION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
/// Takes the next session token: returns the counter's current value and increments it, so
/// successive tokens differ until the process-wide `u64` counter wraps.
pub(super) fn next_edit_session() -> u64 {
NEXT_EDIT_SESSION.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
}
/// Process-global checked high-water allocator for [`crate::Session::ime_revision`] stamps.
///
/// egui's unavailable-IME receipt identity must be process-monotonic. Allocating the stamp from
/// one process-global counter means neither a second session nor a rollback can reuse a value.
/// The counter fails closed at `u64::MAX`: it returns `None` without wrapping, and callers keep
/// their existing stamp. An unavailable before/after pair then compares equal, so it earns no
/// receipt rather than claiming a mutation with a reused revision.
static NEXT_IME_REVISION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
/// Allocates a fresh process-wide IME revision, or `None` once the counter has nowhere left
/// to advance; callers keep their existing stamp rather than reuse one.
pub(crate) fn next_ime_revision() -> Option<u64> {
allocate_ime_revision(&NEXT_IME_REVISION)
}
/// Takes the next value of `counter` with a compare-exchange loop: `Some(current + 1)` after
/// storing it, or `None` without storing when the counter is already `u64::MAX`.
fn allocate_ime_revision(counter: &std::sync::atomic::AtomicU64) -> Option<u64> {
use std::sync::atomic::Ordering::Relaxed;
let mut current = counter.load(Relaxed);
loop {
let next = current.checked_add(1)?;
match counter.compare_exchange_weak(current, next, Relaxed, Relaxed) {
Ok(_) => return Some(next),
Err(observed) => current = observed,
}
}
}
#[test]
fn ime_revision_allocation_stops_at_exhaustion_without_reusing_a_stamp() {
use std::sync::atomic::{AtomicU64, Ordering::Relaxed};
let counter = AtomicU64::new(u64::MAX - 1);
assert_eq!(allocate_ime_revision(&counter), Some(u64::MAX));
assert_eq!(allocate_ime_revision(&counter), None);
assert_eq!(allocate_ime_revision(&counter), None);
assert_eq!(counter.load(Relaxed), u64::MAX);
}
use super::{CommandSpec, b_p, bad, comp_id, layer_mut, resolve_layer, str_p};
use crate::{EditorState, EngineError, Result, Session, cmd};
/// An on-canvas text editing session.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct TextEdit {
pub layer: LayerId,
pub anchor: usize,
@@ -28,6 +77,25 @@ pub struct TextEdit {
/// Line position kept by repeated up / down moves (layout space).
#[serde(default)]
pub goal_x: Option<f64>,
/// This editing session's identity.
///
/// It changes when the editor is started and when its layer or document is replaced, by
/// undo/redo, a history jump, a rollback, or a time change showing another document. A
/// restore that keeps the same composition, layer and byte/style-equal document (an opacity
/// undo while editing) keeps it, so an active IME composition survives an unrelated change.
/// It is deliberately not serialized: a saved token belongs to a previous process and
/// cannot identify a new target. The IME owner uses it as its replacement epoch, so an old
/// composition is never inherited by a recreated layer or a replaced document.
#[serde(skip, default = "next_edit_session")]
pub session: u64,
}
impl Default for TextEdit {
/// An empty, uncreated editing session on layer `0` with no selection, pending style or goal
/// column, and a fresh process-wide session token.
fn default() -> Self {
Self { layer: LayerId(0), anchor: 0, caret: 0, pending: None, created: false, goal_x: None, session: next_edit_session() }
}
}
impl TextEdit {
@@ -49,6 +117,139 @@ pub fn layer_doc(s: &Session, lid: LayerId) -> Option<TextDoc> {
}
}
/// The largest committed-text window an IME observation copies.
///
/// This mirrors `egui::output::MAX_IME_SURROUNDING_TEXT_BYTES`: a selection wider than this is
/// reported as unavailable rather than silently truncated.
pub const MAX_IME_TEXT_BYTES: usize = 4000;
/// The editor's committed-text revision for an IME receipt whose surrounding text is
/// unavailable (a selection wider than [`MAX_IME_TEXT_BYTES`]).
///
/// Unlike [`Session::revision`], unrelated project edits (an opacity undo) do not advance it, so
/// they cannot make an active composition look stale.
pub fn ime_stamp_revision(s: &Session) -> u64 {
s.ime_revision
}
/// Bounded committed text around an IME selection, with UTF-8 byte offsets.
///
/// The Type tool keeps the input method's pre-edit in egui's temporary data, never in the
/// layer's [`TextDoc`], so [`Self::text`] is committed text only.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ImeSurrounding {
/// At most [`MAX_IME_TEXT_BYTES`] bytes of the edited layer's text.
pub text: String,
/// The caret (primary selection end) as a UTF-8 byte offset in [`Self::text`].
pub cursor: usize,
/// The other selection end; larger than `cursor` for a reversed selection.
pub anchor: usize,
}
impl ImeSurrounding {
/// A bounded window of `text` around the selection `cursor`/`anchor`.
///
/// Returns `None` when an offset is past the text or inside a character, or when the
/// selection itself is wider than [`MAX_IME_TEXT_BYTES`]: the caller then reports
/// surrounding text as unavailable instead of sending a truncated selection.
pub fn from_text(text: &str, cursor: usize, anchor: usize) -> Option<Self> {
if cursor > text.len() || anchor > text.len() || !text.is_char_boundary(cursor) || !text.is_char_boundary(anchor) {
return None;
}
let (start, end) = (cursor.min(anchor), cursor.max(anchor));
let selection = end - start;
if selection > MAX_IME_TEXT_BYTES {
return None;
}
// Spend the remaining budget on both sides of the selection, giving unused room on one
// side to the other. Only this bounded window is copied.
let budget = MAX_IME_TEXT_BYTES - selection;
let mut before = (budget / 2).min(start);
let mut after = (budget - before).min(text.len() - end);
before = (budget - after).min(start);
after = (budget - before).min(text.len() - end);
let mut window_start = start - before;
let mut window_end = end + after;
// Byte budgets can land inside a multi-byte character: shrink the window so it never
// splits one and the selection stays inside it.
while window_start < start && !text.is_char_boundary(window_start) {
window_start += 1;
}
while end < window_end && !text.is_char_boundary(window_end) {
window_end -= 1;
}
Some(Self {
text: text.get(window_start..window_end)?.to_owned(),
cursor: cursor.checked_sub(window_start)?,
anchor: anchor.checked_sub(window_start)?,
})
}
}
/// The edited layer's committed text around its selection, or `None` when there is no edit
/// session, the layer is gone, or the selection is wider than [`MAX_IME_TEXT_BYTES`].
///
/// The caret and the anchor are converted to UTF-8 byte offsets separately, so a reversed
/// selection stays reversed. A valid empty document is `Some` with empty text, never `None`.
pub fn ime_surrounding(s: &Session) -> Option<ImeSurrounding> {
let e = s.state.text_edit.as_ref()?;
let comp = s.active_comp()?;
let layer = comp.layer(e.layer)?;
let prop = layer.props.prop("text/sourceText")?;
// A static Source Text (the normal case) is borrowed in place; only an animated one has to
// be evaluated into a temporary. Either way only the bounded window is copied.
if !prop.is_animated() {
return match &prop.value {
KV::Text(doc) => {
let (caret, anchor) = doc_bytes(doc, e.caret, e.anchor);
ImeSurrounding::from_text(&doc.text, caret, anchor)
}
KV::Str(text) => {
let (caret, anchor) = char_bytes(text, e.caret, e.anchor);
ImeSurrounding::from_text(text, caret, anchor)
}
_ => None,
};
}
match prop.value_at(layer.layer_time(s.time())) {
KV::Text(doc) => {
let (caret, anchor) = doc_bytes(&doc, e.caret, e.anchor);
ImeSurrounding::from_text(&doc.text, caret, anchor)
}
KV::Str(text) => {
let (caret, anchor) = char_bytes(&text, e.caret, e.anchor);
ImeSurrounding::from_text(&text, caret, anchor)
}
_ => None,
}
}
/// Byte offsets of two character positions in `text` (clamped to its end), scanning once.
///
/// The second position may be before the first; the returned pair keeps the caller's order.
fn char_bytes(text: &str, first: usize, second: usize) -> (usize, usize) {
let (lo, hi) = if first <= second { (first, second) } else { (second, first) };
let mut lo_byte = if lo == 0 { 0 } else { text.len() };
let mut hi_byte = if hi == 0 { 0 } else { text.len() };
if hi != 0 {
for (i, (b, _)) in text.char_indices().enumerate() {
if i == lo {
lo_byte = b;
}
if i == hi {
hi_byte = b;
break;
}
}
}
if first <= second { (lo_byte, hi_byte) } else { (hi_byte, lo_byte) }
}
/// Byte offsets of the editor's caret and anchor in a document, in that order.
fn doc_bytes(doc: &TextDoc, caret: usize, anchor: usize) -> (usize, usize) {
char_bytes(&doc.text, caret, anchor)
}
/// The text layer a text command targets: `layer`, the edited layer, or the selected text layer.
fn target(s: &Session, p: &Value, cmd: &str) -> Result<LayerId> {
let cid = comp_id(s, p)?;
@@ -78,7 +279,7 @@ fn session_on(s: &mut Session, lid: LayerId) -> TextEdit {
Some(e) if e.layer == lid => e.clone(),
_ => {
let n = layer_doc(s, lid).map_or(0, |d| d.char_len());
let e = TextEdit { layer: lid, anchor: n, caret: n, ..Default::default() };
let e = TextEdit { layer: lid, anchor: n, caret: n, session: next_edit_session(), ..Default::default() };
s.state.selected_layers = vec![lid];
s.state.text_edit = Some(e.clone());
e
@@ -101,7 +302,8 @@ pub(crate) fn edit_doc(
) -> Result<()> {
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
let t = s.time();
s.edit(label, merge, |proj, st| {
let revision = s.revision;
s.edit_text_doc(label, merge, |proj, st| {
let l = layer_mut(proj, cid, lid)?;
let lt = l.layer_time(t);
let pr = l.props.prop_mut("text/sourceText").ok_or_else(|| bad("text", "not a text layer"))?;
@@ -110,16 +312,28 @@ pub(crate) fn edit_doc(
KV::Str(t) => TextDoc::plain(&t),
_ => TextDoc::default(),
};
let before = first_line_name(&doc.text);
let original = doc.clone();
f(&mut doc, st)?;
let after = first_line_name(&doc.text);
pr.set_value_at(lt, KV::Text(Box::new(doc)));
// Text layers are named after their text until renamed.
if before != after && !after.is_empty() && (l.name == before || l.name.starts_with("Text") || l.name.is_empty()) {
l.name = after;
// A no-op (an IME deletion with nothing around the selection, a set to the value it
// already has) must not turn a static Str into Text or add a keyframe at the CTI: that
// would change the project and record an empty undo step.
if doc != original {
let before = first_line_name(&original.text);
let after = first_line_name(&doc.text);
pr.set_value_at(lt, KV::Text(Box::new(doc)));
// Text layers are named after their text until renamed.
if before != after && !after.is_empty() && (l.name == before || l.name.starts_with("Text") || l.name.is_empty()) {
l.name = after;
}
}
Ok(())
})
})?;
// The editor's unavailable-text receipt identity follows only its own document: an edit to
// another text layer while this editor is open must not cancel the composition.
if s.revision != revision && s.state.text_edit.as_ref().is_some_and(|e| e.layer == lid) {
s.note_text_edit_change();
}
Ok(())
}
fn range_p(p: &Value) -> Option<std::ops::Range<usize>> {
@@ -153,7 +367,7 @@ fn edit(s: &mut Session, p: &Value) -> Result<Value> {
};
let created = b_p(p, "created").unwrap_or(false);
s.state.selected_layers = vec![lid];
s.state.text_edit = Some(TextEdit { layer: lid, anchor, caret, created, ..Default::default() });
s.state.text_edit = Some(TextEdit { layer: lid, anchor, caret, created, session: next_edit_session(), ..Default::default() });
s.bump();
Ok(state_json(s))
}
@@ -314,6 +528,78 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
Ok(state_json(s))
}
/// How many whole characters `before_bytes` / `after_bytes` cover around `r`.
///
/// This is the input method's own byte count, as winit reports it: a count that ends inside a
/// UTF-8 character leaves that whole character (stock egui's `surrounding_bytes_to_chars`), so a
/// malformed or platform-specific offset never splits or corrupts the document.
fn surrounding_bytes_as_chars(text: &str, n: usize, r: std::ops::Range<usize>, before_bytes: usize, after_bytes: usize) -> (usize, usize) {
let r = r.start.min(n)..r.end.min(n);
let (start_byte, end_byte) = char_bytes(text, r.start, r.end);
let from = start_byte.saturating_sub(before_bytes);
let before = text.get(..start_byte).map_or(0, |head| head.char_indices().rev().take_while(|(i, _)| *i >= from).count());
let after = text.get(end_byte..).map_or(0, |tail| tail.char_indices().take_while(|(i, c)| i.saturating_add(c.len_utf8()) <= after_bytes).count());
(before.min(r.start), after.min(n.saturating_sub(r.end)))
}
/// Delete the text an input method marks for replacement around the selection
/// (`DeleteSurrounding`, `DeleteSurroundingBytes`).
///
/// Unlike [`delete`] with an explicit range, the **selection stays**: the characters before its
/// start and after its end are removed in two separate ranges, and the following `Commit`
/// replaces the selected text. `bytes` counts UTF-8 bytes (`before`/`after` are characters
/// otherwise); a count that ends inside a character leaves that character.
///
/// The counts are computed from the one document `edit_doc` already evaluated, so an animated
/// Source Text and a large static document are not copied or evaluated again just to decide what
/// the input method asked to replace.
fn delete_surrounding(s: &mut Session, p: &Value) -> Result<Value> {
let lid = target(s, p, "text.deleteSurrounding")?;
// Only an open edit session has a selection to keep: without one there is nothing an input
// method could be replacing.
let e = match &s.state.text_edit {
Some(e) if e.layer == lid => e.clone(),
_ => return Err(bad("text.deleteSurrounding", "not editing this text layer")),
};
let count = |k: &str| p.get(k).and_then(Value::as_u64).map_or(0, |v| usize::try_from(v).unwrap_or(usize::MAX));
let (before_count, after_count) = (count("before"), count("after"));
let bytes = b_p(p, "bytes").unwrap_or(false);
let merge = str_p(p, "merge").map(str::to_string);
edit_doc(s, "Edit Text", merge.as_deref(), lid, |doc, st| {
let n = doc.char_len();
let r = e.range().start.min(n)..e.range().end.min(n);
let (before, after) = if bytes {
surrounding_bytes_as_chars(&doc.text, n, r.clone(), before_count, after_count)
} else {
(before_count.min(r.start), after_count.min(n.saturating_sub(r.end)))
};
let start = r.start.saturating_sub(before);
let end = r.end.saturating_add(after).min(n);
if start == r.start && end == r.end {
// Deletion-only batches with an empty count, or a document with nothing around the
// selection: no empty undo step.
return Ok(());
}
// The selection keeps its text: remove after it first, then before it, so the two ranges
// stay valid and the caret/selection shift by the characters before it.
if end > r.end {
doc.replace_range(r.end..end, "", None);
}
if r.start > start {
doc.replace_range(start..r.start, "", None);
}
if let Some(ed) = st.text_edit.as_mut().filter(|ed| ed.layer == lid) {
let shift = r.start.saturating_sub(start);
ed.anchor = ed.anchor.saturating_sub(shift);
ed.caret = ed.caret.saturating_sub(shift);
ed.pending = None;
ed.goal_x = None;
}
Ok(())
})?;
Ok(state_json(s))
}
/// Selected text (plain) of the session, and its formatted fragment.
fn selection_doc(s: &Session) -> Option<TextDoc> {
let e = s.state.text_edit.as_ref()?;
@@ -448,16 +734,111 @@ fn paste_formatting(s: &mut Session, p: &Value) -> Result<Value> {
Ok(state_json(s))
}
/// Keep the session valid after undo / external edits: clamp the selection, drop it when the
/// Give the current editor a fresh replacement epoch after its document/layer was replaced.
pub(crate) fn invalidate_session(s: &mut Session) {
let replaced = s.state.text_edit.is_some();
if let Some(e) = s.state.text_edit.as_mut() {
e.session = next_edit_session();
}
if replaced {
s.note_text_edit_change();
}
}
/// Clamp the editor selection and give it a fresh IME epoch after its document was replaced.
///
/// This is the one replacement path for document changes outside `edit_doc`:
/// [`crate::Session::edit`] calls [`after_external_change`] after every edit, [`crate::Session::set_time`]
/// and [`crate::Session::sync_related_times`] call it after moving the CTI, and
/// [`crate::Session::sanitize_after_replacement`] calls it with the editor document snapshotted
/// before a history restore or rollback.
/// Clamping first means a shortened document leaves the caret in range, so the next Backspace
/// deletes instead of being swallowed by an out-of-range selection.
pub(crate) fn document_replaced(s: &mut Session) {
sanitize(s);
invalidate_session(s);
}
/// The identity and document of the active on-canvas editor at one moment.
///
/// The composition and the editor session are part of the identity: a restoration must not keep
/// a composition alive when the snapshot came from another comp, or from an intermediate editor
/// that was restarted during the operation being rolled back.
#[derive(Clone, Debug, PartialEq)]
pub struct EditorSnapshot {
/// The composition the editor was attached to.
pub comp: ItemId,
/// The edited layer.
pub layer: LayerId,
/// The editor session token, unique across the process.
pub session: u64,
/// The Source Text the editor was showing.
pub doc: TextDoc,
}
/// The identity and document the active on-canvas editor is showing.
///
/// [`crate::Session::edit`] and the time setters snapshot this before a change and compare it
/// afterwards: only a change that actually replaces this document starts a new epoch, while a
/// write to another property, layer or composition, a move to a time showing the same text, or a
/// write that leaves the text as it was, keeps the editor's owner. With no editor open this is a
/// single `Option` check, with no document copy.
pub fn active_editor_snapshot(s: &Session) -> Option<EditorSnapshot> {
let e = s.state.text_edit.as_ref()?;
Some(EditorSnapshot { comp: s.active_comp_id()?, layer: e.layer, session: e.session, doc: layer_doc(s, e.layer)? })
}
/// Finish an edit outside the editor: if it replaced the document the editor owned, clamp the
/// selection and start a new IME epoch.
///
/// An edit that itself moves or closes the editor owns the resulting session, so an identity
/// mismatch is not a replacement here.
pub(crate) fn after_external_change(s: &mut Session, before: Option<&EditorSnapshot>) {
let Some(before) = before else { return };
let Some(e) = s.state.text_edit.as_ref() else { return };
if s.state.active_comp != Some(before.comp) || e.layer != before.layer || e.session != before.session {
return;
}
if layer_doc(s, before.layer).as_ref() != Some(&before.doc) {
document_replaced(s);
}
}
/// Finish a project-state replacement (undo, redo, a history jump, a rollback): keep the editor
/// only when the restored state still has the same composition, layer and editor session and
/// shows byte/style-equal text.
///
/// The restored session token is the one saved in the replaced state, so an intermediate
/// restart or cross-comp switch must be detected against the snapshot instead of being assumed
/// to be the editor's own change.
pub(crate) fn after_replacement(s: &mut Session, before: Option<&EditorSnapshot>) {
let Some(before) = before else {
// No editor was open when the snapshot was taken: a restored editor must not inherit a
// composition from the state the replacement replaced.
if s.state.text_edit.is_some() {
document_replaced(s);
}
return;
};
let same = s.state.text_edit.as_ref().is_some_and(|e| s.state.active_comp == Some(before.comp) && e.layer == before.layer && e.session == before.session);
if !same || layer_doc(s, before.layer).as_ref() != Some(&before.doc) {
document_replaced(s);
}
}
/// Keep the session valid after routine state cleanup: clamp the selection, drop it when the
/// layer is gone or not in the active comp.
///
/// This is also called after project-item cleanup that leaves the edited document alone, so it
/// must not advance [`TextEdit::session`]: that would make the viewer treat unrelated cleanup as
/// a document replacement and interrupt the active IME composition. Replacement paths call
/// [`document_replaced`] instead.
pub(crate) fn sanitize(s: &mut Session) {
let Some(e) = s.state.text_edit.clone() else { return };
match layer_doc(s, e.layer) {
Some(d) => {
let n = d.char_len();
if (e.anchor > n || e.caret > n)
&& let Some(ed) = s.state.text_edit.as_mut()
{
if let Some(ed) = s.state.text_edit.as_mut() {
ed.anchor = ed.anchor.min(n);
ed.caret = ed.caret.min(n);
}
@@ -536,6 +917,8 @@ fn editing(s: &Session) -> std::result::Result<(), String> {
if s.state.text_edit.is_some() { Ok(()) } else { Err("not editing text".into()) }
}
/// The command specs of this module: the `text.*` editing, selection and caret commands and
/// the two `edit.pasteText*` commands, each with its parameter shape and enablement predicate.
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
@@ -594,6 +977,15 @@ pub fn specs() -> Vec<CommandSpec> {
editing_or_text_layer,
delete
),
cmd!(
"text.deleteSurrounding",
"Delete Text Around Selection",
[],
None,
"{layer?, before?, after?, bytes?, merge?} (input-method replacement: the characters before and after the selection are deleted, the selection stays; bytes counts UTF-8 bytes)",
editing_or_text_layer,
delete_surrounding
),
cmd!(
"edit.pasteTextMatchFormatting",
"Paste Text and Match Formatting",

Some files were not shown because too many files have changed in this diff Show More